fix health offset calculation

This commit is contained in:
ゆめ 2022-11-18 23:37:47 -06:00
parent 3284792aa7
commit fb7b1dd969
8 changed files with 621 additions and 266 deletions

View file

@ -1,15 +1,19 @@
package health
import (
"log"
"sync"
"time"
"github.com/eternal-flame-AD/yoake/internal/auth"
"github.com/eternal-flame-AD/yoake/internal/comm"
"github.com/eternal-flame-AD/yoake/internal/comm/model"
"github.com/eternal-flame-AD/yoake/internal/db"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/labstack/echo/v4"
)
func Register(g *echo.Group, db db.DB, comm *comm.Communicator) {
func Register(g *echo.Group, database db.DB, comm *comm.Communicator) {
megsG := g.Group("/meds")
{
shortHands := megsG.Group("/shorthand")
@ -23,22 +27,109 @@ func Register(g *echo.Group, db db.DB, comm *comm.Communicator) {
writeMutex := new(sync.Mutex)
directions := megsG.Group("/directions", auth.RequireMiddleware(auth.RoleAdmin))
{
directions.GET("", RESTMedGetDirections(db))
directions.POST("", RESTMedPostDirections(db, writeMutex))
directions.DELETE("/:name", RESTMedDeleteDirections(db, writeMutex))
directions.GET("", RESTMedGetDirections(database))
directions.POST("", RESTMedPostDirections(database, writeMutex))
directions.DELETE("/:name", RESTMedDeleteDirections(database, writeMutex))
}
compliance := megsG.Group("/compliance", auth.RequireMiddleware(auth.RoleAdmin))
{
complianceByMed := compliance.Group("/med/:med")
{
complianceByMed.GET("/log", RESTComplianceLogGet(db))
complianceByMed.GET("/project", RESTComplianceLogProjectMed(db))
complianceByMed.GET("/log", RESTComplianceLogGet(database))
complianceByMed.GET("/project", RESTComplianceLogProjectMed(database))
}
compliance.GET("/log", RESTComplianceLogGet(db))
compliance.GET("/log", RESTComplianceLogGet(database))
compliance.POST("/log", RESTComplianceLogPost(db, writeMutex))
compliance.POST("/log", RESTComplianceLogPost(database, writeMutex))
compliance.POST("/recalc", RESTRecalcMedComplianceLog(database, writeMutex))
}
}
go func() {
ticker := time.NewTicker(5 * time.Minute)
notified := make(map[string]time.Time)
for {
func() {
txn := database.NewTransaction(false)
defer txn.Discard()
existingNotified := make(map[string]time.Time)
err := db.GetJSON(txn, []byte("health_meds_compliance_notified_meds"), &existingNotified)
if err != nil && !db.IsNotFound(err) {
log.Println("Error getting notified meds: ", err)
return
}
for k, v := range existingNotified {
o := notified[k]
if o.Before(v) {
notified[k] = v
}
}
txn.Discard()
txn = database.NewTransaction(true)
err = db.SetJSON(txn, []byte("health_meds_compliance_notified_meds"), notified)
if err != nil {
log.Println("Error setting notified meds: ", err)
return
} else if err := txn.Commit(); err != nil {
log.Println("Error committing notified meds: ", err)
return
}
}()
meds, err := DBMedListGet(database)
if err != nil {
log.Println("Error getting med list:", err)
continue
}
logs, err := DBMedComplianceLogGet(database, util.DateRangeAround(time.Now(), 1))
if err != nil {
log.Println("Error getting med compliance log:", err)
continue
}
var notifications []CommCtx
hasNew := false
for _, med := range meds {
nextDose := logs.ProjectNextDose(med)
if nextDose.Expected.Time.Before(time.Now()) {
if lastNotified, ok := notified[med.KeyName()]; !ok ||
lastNotified.Before(nextDose.Expected.Time) ||
lastNotified.Add(4*time.Hour).Before(time.Now()) {
{
if !util.Contain(med.Flags, DirectionFlagPRN) {
hasNew = true
}
}
notifications = append(notifications, CommCtx{
Med: med,
Dose: nextDose,
})
}
}
}
if hasNew {
if err := comm.SendGenericMessage("gotify", model.GenericMessage{
Subject: "Medications Due",
Body: commTemplate,
MIME: "text/markdown+html/template",
Context: notifications,
}, true); err != nil {
log.Println("Error sending med compliance notification:", err)
}
for _, n := range notifications {
notified[n.Med.KeyName()] = time.Now()
}
}
<-ticker.C
}
}()
}

View file

@ -11,6 +11,8 @@ import (
"github.com/labstack/echo/v4"
)
const yearAbsZero = 2000
func RESTComplianceLogGet(database db.DB) func(c echo.Context) error {
return func(c echo.Context) error {
filterKeyname := c.Param("med")
@ -124,3 +126,57 @@ func RESTComplianceLogProjectMed(db db.DB) func(c echo.Context) error {
return c.JSON(200, complianceLog.ProjectNextDose(*dir))
}
}
func RESTRecalcMedComplianceLog(db db.DB, writeMutex *sync.Mutex) func(c echo.Context) error {
return func(c echo.Context) error {
meds, err := DBMedListGet(db)
if err != nil {
return echoerror.NewHttp(500, err)
}
from := time.Date(yearAbsZero, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Now()
if fromStr := c.QueryParam("from"); fromStr != "" {
from, err = time.Parse("2006-01", fromStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
}
if toStr := c.QueryParam("to"); toStr != "" {
to, err = time.Parse("2006-01", toStr)
if err != nil {
return echoerror.NewHttp(400, err)
}
}
writeMutex.Lock()
defer writeMutex.Unlock()
for year := from.Year(); year <= to.Year(); year++ {
for month := 1; month <= 12; month++ {
if year == from.Year() && month < int(from.Month()) {
continue
}
if year == to.Year() && month > int(to.Month()) {
continue
}
log, err := DBMedComplianceLogGet(db, util.DateRangeAround(time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC), 1))
if err != nil {
return echoerror.NewHttp(500, err)
}
if len(log) == 0 {
continue
}
for _, dir := range meds {
log.UpdateDoseOffset(dir)
}
if err := DBMedComplianceLogAppend(db, log); err != nil {
return echoerror.NewHttp(500, err)
}
}
}
return c.NoContent(204)
}
}

View file

@ -1,11 +1,14 @@
package health
import (
"encoding/json"
"log"
"math"
"sort"
"time"
"github.com/eternal-flame-AD/yoake/internal/util"
"github.com/google/uuid"
)
type ComplianceLog struct {
@ -17,7 +20,9 @@ type ComplianceLog struct {
// 0 = closest to expected time +1 = closest to next expected dose
// get a cumsum of this to get a compliance stat
DoseOffset float64 `json:"dose_offset"`
DoseOffset f64OrNan `json:"dose_offset"`
EffectiveLastDose *ComplianceLog `json:"effective_last_dose,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@ -28,122 +33,124 @@ type ComplianceDoseInfo struct {
Dose int `json:"dose"`
}
type ComplianceLogList []ComplianceLog
type f64OrNan float64
func (c ComplianceLogList) ProjectNextDose(dir Direction) (nextDose ComplianceLog) {
sort.Sort(c)
var lastDose ComplianceLog
var cumDosage int
for ptr := 0; ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() {
if dir.OptSchedule == OptScheduleWholeDose {
lastDose = c[ptr]
break
} else {
cumDosage += c[ptr].Actual.Dose
if cumDosage < dir.Dosage {
continue
} else {
lastDose = c[ptr]
break
}
}
}
func (n *f64OrNan) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
*n = f64OrNan(math.NaN())
return nil
}
if lastDose.UUID == "" /* not found */ {
nextDose = ComplianceLog{
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
DoseOffset: 0,
CreatedAt: time.Now(),
}
} else {
nextDose = ComplianceLog{
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: lastDose.Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
CreatedAt: time.Now(),
}
nextDose.DoseOffset, _, _ = c.ComputeDoseOffset(dir, &nextDose)
var f float64
if err := json.Unmarshal(b, &f); err != nil {
return err
}
return
*n = f64OrNan(f)
return nil
}
func (c ComplianceLogList) ComputeDoseOffset(dir Direction, newLog *ComplianceLog) (float64, bool, error) {
sort.Sort(c)
func (n f64OrNan) MarshalJSON() ([]byte, error) {
if math.IsNaN(float64(n)) {
return []byte("null"), nil
}
return json.Marshal(float64(n))
}
var lastTwoDoses []ComplianceLog
if newLog != nil {
lastTwoDoses = []ComplianceLog{*newLog}
}
for ptr := 0; len(lastTwoDoses) < 2 && ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() {
if len(lastTwoDoses) == 0 || lastTwoDoses[0].Actual.Time.After(c[ptr].Actual.Time) {
lastTwoDoses = append(lastTwoDoses, c[ptr])
}
}
}
if newLog != nil {
if newLog.Expected.Dose == 0 && dir.KeyName() == newLog.MedKeyname {
newLog.Expected.Dose = dir.Dosage
}
if newLog.Expected.Time.IsZero() {
if len(lastTwoDoses) == 2 {
newLog.Expected.Time = lastTwoDoses[1].Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour)
} else {
newLog.Expected.Time = newLog.Actual.Time
}
}
lastTwoDoses[0] = *newLog
}
if len(lastTwoDoses) < 2 {
return 0, false, nil
func doseOffset(dir Direction, this ComplianceLog, last ComplianceLog) float64 {
if last.UUID == "" {
return math.NaN()
}
// now we have:
// *exp ~actual
// * ~ ~ *
// offset = (new_actual - last_expected) / diff(new_expected, last_actual) - 1
if lastTwoDoses[0].Actual.Time.IsZero() {
lastTwoDoses[0].Actual.Time = time.Now()
}
offset := float64(lastTwoDoses[0].Actual.Time.Sub(lastTwoDoses[1].Expected.Time))/
float64(lastTwoDoses[0].Expected.Time.Sub(lastTwoDoses[1].Actual.Time)) - 1
offset := float64(this.Actual.Time.Sub(last.Actual.Time))/
float64(time.Duration(dir.PeriodHours)*time.Hour) - 1
// for prn ignore positive offsets
if util.Contain(dir.Flags, DirectionFlagPRN) {
if offset > 0 {
offset = 0
return 0
}
}
// ad lib ignore negative offsets
if util.Contain(dir.Flags, DirectionFlagAdLib) {
if offset < 0 {
offset = 0
return 0
}
}
if math.Abs(offset) > 2 {
// stop counting if three or more doses are missed
return 0, false, nil
return offset
}
type ComplianceLogList []ComplianceLog
func (c ComplianceLogList) findEffectiveLastDose(dir Direction, this ComplianceLog) ComplianceLog {
// for ad lib directions, this finds the last dose
// for default scheduling, this find the earliest dose that does not cumulatively exceed a whole dose
var lastDose ComplianceLog
var cumDosage int
for ptr := 0; ptr < len(c); ptr++ {
if c[ptr].MedKeyname == dir.KeyName() && c[ptr].Actual.Time.Before(this.Actual.Time) {
if dir.OptSchedule == OptScheduleWholeDose {
return c[ptr]
}
cumDosage += c[ptr].Actual.Dose
if cumDosage > dir.Dosage {
return lastDose
} else if cumDosage == dir.Dosage {
return c[ptr]
}
lastDose = c[ptr]
}
}
return lastDose
}
func (c ComplianceLogList) ProjectNextDose(dir Direction) (nextDose ComplianceLog) {
tmpUUID := uuid.New().String()
nextDose = ComplianceLog{
UUID: tmpUUID,
MedKeyname: dir.KeyName(),
Expected: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
Actual: ComplianceDoseInfo{
Time: time.Now(),
Dose: dir.Dosage,
},
DoseOffset: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
lastDose := c.findEffectiveLastDose(dir, nextDose)
if lastDose.UUID == "" /* not found */ {
return
}
nextDose.EffectiveLastDose = &lastDose
nextDose.Expected.Time = lastDose.Actual.Time.Add(time.Duration(dir.PeriodHours) * time.Hour)
nextDose.DoseOffset = f64OrNan(doseOffset(dir, nextDose, lastDose))
return
}
func (c ComplianceLogList) UpdateDoseOffset(dir Direction) {
sort.Sort(c)
for i := range c {
if c[i].MedKeyname == dir.KeyName() {
lastDose, thisDose := c.findEffectiveLastDose(dir, c[i]), c[i]
if lastDose.UUID == "" /* not found */ {
return
}
c[i].DoseOffset = f64OrNan(doseOffset(dir, thisDose, lastDose))
log.Printf("thisDose: %+v, \nlastDose: %+v\n-->offset: %f\n", thisDose, lastDose, c[i].DoseOffset)
}
}
return offset, true, nil
}
func (c ComplianceLogList) Len() int {

View file

@ -68,6 +68,45 @@ func DBMedComplianceLogGet(database db.DB, dates util.DateRange) (ComplianceLogL
return res, nil
}
func DBMedComplianceLogAppend(database db.DB, pending ComplianceLogList) error {
txn := database.NewTransaction(true)
defer txn.Discard()
for len(pending) > 0 {
index := pending[0].Actual.Time.UTC().Format("2006-01")
var origLogs ComplianceLogList
if err := db.GetJSON(txn, []byte(dbMedComplianceLogPrefix+index), &origLogs); db.IsNotFound(err) {
origLogs = []ComplianceLog{}
} else if err != nil {
return err
}
for i := len(pending) - 1; i >= 0; i-- {
if pending[i].Actual.Time.UTC().Format("2006-01") != index {
continue
}
origLogs = append(origLogs, pending[i])
pending = append(pending[:i], pending[i+1:]...)
uuidMap := make(map[string]int)
for j := len(origLogs) - 1; j >= 0; j-- {
if _, ok := uuidMap[origLogs[j].UUID]; ok {
origLogs = append(origLogs[:j], origLogs[j+1:]...)
} else {
uuidMap[origLogs[j].UUID] = j
}
}
}
sort.Sort(origLogs)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), origLogs); err != nil {
return err
}
}
return txn.Commit()
}
func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog) error {
index := log.Actual.Time.UTC().Format("2006-01")
@ -111,6 +150,7 @@ func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog)
log.UpdatedAt = time.Now()
existingLogs[foundIdx] = *log
}
sort.Sort(existingLogs)
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), existingLogs); err != nil {
return err
}
@ -131,6 +171,11 @@ func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog)
if log.UUID == "" {
log.UUID = uuid.New().String()
log.CreatedAt = time.Now()
if log.Expected.Dose == 0 {
nextDose := existingLogs.ProjectNextDose(dir)
log.Expected.Time = nextDose.Expected.Time
log.Expected.Dose = nextDose.Expected.Dose
}
}
log.UpdatedAt = time.Now()
@ -142,17 +187,10 @@ func DBMedComplianceLogSetOne(database db.DB, dir Direction, log *ComplianceLog)
} else {
logs = append(logs, *log)
}
for i := len(logs) - 1; i >= 0; i-- {
offset, upd, err := logs.ComputeDoseOffset(dir, &logs[i])
if err != nil {
return err
}
if upd {
logs[i].DoseOffset = offset
}
}
logs.UpdateDoseOffset(dir)
sort.Sort(ComplianceLogList(logs))
if err := db.SetJSON(txn, []byte(dbMedComplianceLogPrefix+index), logs); err != nil {
return err
}

View file

@ -0,0 +1,33 @@
package health
const commTemplate = `
The following doses are due:
{{ range . }}
---
### {{ .Med.Name }} _{{ .Med.DirectionShorthand }}_
{{ $isPRN := false -}}
{{- range .Med.Flags -}}
{{- if eq . "prn" -}}
{{- $isPRN = true -}}
{{end}}{{- end -}}
{{ if not $isPRN -}}
Expected at: {{ .Dose.Expected.Time }}
{{if .Dose.EffectiveLastDose -}}
Last Taken at: {{ .Dose.EffectiveLastDose.Actual.Time }}
{{ end -}}
Offset: {{ .Dose.DoseOffset }}
{{ else -}}
avail as PRN
{{- end }}
{{ end }}
`
type CommCtx struct {
Med Direction
Dose ComplianceLog
}

View file

@ -133,7 +133,7 @@ func ServeTemplateDir(dir string) echo.MiddlewareFunc {
tplPath := filepath.Join(dir, tplName)
if _, err := os.Stat(tplPath); err == nil {
log.Printf("dispatch template: %s(%s) ext=%s", tplName, tplPath, ext)
//log.Printf("dispatch template: %s(%s) ext=%s", tplName, tplPath, ext)
// template file is still there, execute
if ext == ".html" {
return func(wr io.Writer, data any) error { return templates.ExecuteTemplate(wr, tplName, data) }

View file

@ -65,7 +65,8 @@
<input type="checkbox" class="form-check-input"
id="time-override">
</span>
<input type="datetime-local" class="form-control" id="time">
<input type="datetime-local" class="form-control" id="time"
disabled>
</div>
</div>
@ -78,10 +79,11 @@
<div class="p-2">
<h5>History</h5>
<div class="p-2 table-responsive" style="height:20em;overflow-y:scroll;">
<div class="p-2 table-responsive" style="height:20em;overflow:scroll;">
<table class="table table-striped compliance-log">
<thead>
<tr>
<th scope="col">Action</th>
<th scope="col">Time</th>
<th scope="col">Dose</th>
<th scope="col">Offset</th>
@ -93,6 +95,8 @@
<th class="placeholder">Loading...</th>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
<td class="placeholder"></td>
</tr>
</tbody>
</table>
@ -112,123 +116,236 @@
<h3 class="card-title">Manage</h3>
</div>
<div class="card-body">
<form id="addMed" autocomplete="off">
<div class="mb-3">
<label id="med-shorthand-input" for="shorthand" class="form-label">Shorthand</label>
<input type="text" class="form-control" id="shorthand"
placeholder="Atorvastatin 10mg TAB 20mg PO qAM">
<div class="accordion" id="medManageAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="addMedHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#addMedCollapse" aria-expanded="true"
aria-controls="addMedCollapse">
Edit Direction
</button>
</h2>
<div id="addMedCollapse" class="accordion-collapse collapse"
data-bs-parent="#medManageAccordion">
<form id="addMed" autocomplete="off">
<div class="mb-3">
<label id="med-shorthand-input" for="shorthand"
class="form-label">Shorthand</label>
<input type="text" class="form-control" id="shorthand"
placeholder="Atorvastatin 10mg TAB 20mg PO qAM">
<label for="name" class="form-label">Name: </label>
<input type="text" class="form-control" id="name" placeholder="Atorvastatin 10mg TAB">
<label for="dosage" class="form-label">Dosage: </label>
<input type="number" class="form-control" id="dosage" placeholder="20">
<label for="dosage_unit" class="form-label">Dosage Unit: </label>
<input type="text" class="form-control" id="dosage_unit" placeholder="mg">
<label for="dosage_route" class="form-label">Dosage Route: </label>
<input type="text" class="form-control" id="dosage_route" placeholder="PO">
<label for="period_hours" class="form-label">Period (Hours): </label>
<input type="number" class="form-control" id="period_hours" placeholder="24">
</div>
<div class="mb-3">
<label for="flags" class="form-label">Flags:</label>
<label for="name" class="form-label">Name: </label>
<input type="text" class="form-control" id="name"
placeholder="Atorvastatin 10mg TAB">
<label for="dosage" class="form-label">Dosage: </label>
<input type="number" class="form-control" id="dosage" placeholder="20">
<label for="dosage_unit" class="form-label">Dosage Unit: </label>
<input type="text" class="form-control" id="dosage_unit" placeholder="mg">
<label for="dosage_route" class="form-label">Dosage Route: </label>
<input type="text" class="form-control" id="dosage_route" placeholder="PO">
<label for="period_hours" class="form-label">Period (Hours): </label>
<input type="number" class="form-control" id="period_hours" placeholder="24">
</div>
<div class="mb-3">
<label for="flags" class="form-label">Flags:</label>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qam" name="qAM" value="qam">
<label for="flags-qam" class="form-check-label">qAM</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qhs" name="qHS" value="qhs">
<label for="flags-qhs" class="form-check-label">qHS</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-prn" name="PRN" value="prn">
<label for="flags-prn" class="form-check-label">PRN</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-adlib" name="adlib"
value="ad lib">
<label for="flags-adlib" class="form-check-label">ad lib</label>
</div>
</div>
<div class="mb-3">
<label for="flags" class="form-label">Schedule:</label>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-default" name="schedule"
value="default">
<label for="schedule-default" class="form-check-label">Default</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-whole" name="schedule"
value="whole">
<label for="schedule-whole" class="form-check-label">Whole Dose</label>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qam" name="qAM"
value="qam">
<label for="flags-qam" class="form-check-label">qAM</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-qhs" name="qHS"
value="qhs">
<label for="flags-qhs" class="form-check-label">qHS</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-prn" name="PRN"
value="prn">
<label for="flags-prn" class="form-check-label">PRN</label>
</div>
<div class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="flags-adlib"
name="adlib" value="ad lib">
<label for="flags-adlib" class="form-check-label">ad lib</label>
</div>
</div>
<div class="mb-3">
<label for="flags" class="form-label">Schedule:</label>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-default"
name="schedule" value="default">
<label for="schedule-default" class="form-check-label">Default</label>
</div>
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" id="schedule-whole"
name="schedule" value="whole">
<label for="schedule-whole" class="form-check-label">Whole Dose</label>
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<script>
$("#addMed #shorthand").on('change', function () {
let shorthand = $(this).val();
$.ajax({
url: "/api/health/meds/shorthand/parse?shorthand=" + encodeURIComponent(shorthand),
success: function (data) {
$("#addMed #name").val(data.name);
$("#addMed #dosage").val(data.dosage);
$("#addMed #dosage_unit").val(data.dosage_unit);
$("#addMed #dosage_route").val(data.dosage_route);
$("#addMed #period_hours").val(data.period_hours);
$("#addMed #flags-qam").prop("checked", data.flags.includes("qam"));
$("#addMed #flags-qhs").prop("checked", data.flags.includes("qhs"));
$("#addMed #flags-prn").prop("checked", data.flags.includes("prn"));
$("#addMed #flags-adlib").prop("checked", data.flags.includes("ad lib"));
$("#addMed #schedule-default").prop("checked", data.schedule == "default");
$("#addMed #schedule-whole").prop("checked", data.schedule == "whole");
}
})
});
$("#addMed").on("submit", function (e) {
e.preventDefault();
let name = $("#addMed #name").val();
let dosage = $("#addMed #dosage").val();
let dosage_unit = $("#addMed #dosage_unit").val();
let dosage_route = $("#addMed #dosage_route").val();
let period_hours = $("#addMed #period_hours").val();
let flags = [];
if ($("#addMed #flags-qam").prop("checked")) {
flags.push("qam");
}
if ($("#addMed #flags-qhs").prop("checked")) {
flags.push("qhs");
}
if ($("#addMed #flags-prn").prop("checked")) {
flags.push("prn");
}
if ($("#addMed #flags-adlib").prop("checked")) {
flags.push("ad lib");
}
let schedule = $("#addMed input[name=schedule]:checked").val();
$.ajax({
url: "/api/health/meds/directions",
method: "POST",
contentType: "application/json",
data: JSON.stringify({
name: name,
dosage: parseInt(dosage),
dosage_unit: dosage_unit,
dosage_route: dosage_route,
period_hours: parseInt(period_hours),
flags: flags,
schedule: schedule
}),
success: function (data) {
window.location.reload();
},
})
})
</script>
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
<div class="accordion-item">
<h2 class="accordion-header" id="medManageLog">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#medManageLogCollapse" aria-expanded="false"
aria-controls="medManageLogCollapse">
Manage Medication Log
</button>
</h2>
<div id="medManageLogCollapse" class="accordion-collapse collapse"
data-bs-parent="#medManageAccordion">
<form id="editMedLog" autocomplete="off">
<div class="mb-3">
<label id="log-keyname-input" for="shorthand" class="form-label">Med Key
name:</label>
<input type="text" class="form-control" id="key-name"
placeholder="atorvastatin">
</form>
<script>
$("#addMed #shorthand").on('change', function () {
let shorthand = $(this).val();
$.ajax({
url: "/api/health/meds/shorthand/parse?shorthand=" + encodeURIComponent(shorthand),
success: function (data) {
$("#addMed #name").val(data.name);
$("#addMed #dosage").val(data.dosage);
$("#addMed #dosage_unit").val(data.dosage_unit);
$("#addMed #dosage_route").val(data.dosage_route);
$("#addMed #period_hours").val(data.period_hours);
$("#addMed #flags-qam").prop("checked", data.flags.includes("qam"));
$("#addMed #flags-qhs").prop("checked", data.flags.includes("qhs"));
$("#addMed #flags-prn").prop("checked", data.flags.includes("prn"));
$("#addMed #flags-adlib").prop("checked", data.flags.includes("ad lib"));
$("#addMed #schedule-default").prop("checked", data.schedule == "default");
$("#addMed #schedule-whole").prop("checked", data.schedule == "whole");
}
<label for="uuid" class="form-label">Dose UUID:</label>
<input type="text" class="form-control" id="uuid" placeholder="UUID">
</div>
<div class="mb-3">
<label for="expected-time" class="form-label">Expected Time:</label>
<input type="datetime-local" class="form-control" id="expected-time"
placeholder="2021-01-01T00:00:00">
<label for="expected-dose" class="form-label">Expected Dose:</label>
<input type="text" class="form-control" id="expected-dose" placeholder="10">
</div>
<label for="dose-offset" class="form-label">Dose Offset:</label>
<input type="text" class="form-control" id="dose-offset" placeholder="+0.1">
<div class="mb-3">
<label for="actual-time" class="form-label">Actual Time:</label>
<input type="datetime-local" class="form-control" id="actual-time"
placeholder="2021-01-01T00:00:00">
<label for="actual-dose" class="form-label">Actual Dose:</label>
<input type="text" class="form-control" id="actual-dose" placeholder="10">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<script>
(() => {
window.loadMedLogForm = log => {
const form = $("#editMedLog");
form.find("#key-name").val(log.med_keyname);
form.find("#uuid").val(log.uuid);
form.find("#expected-time").val(dayjs(log.expected.time).format("YYYY-MM-DDTHH:mm:ss"));
form.find("#expected-dose").val(log.expected.dose);
form.find("#dose-offset").val(log.dose_offset);
form.find("#actual-time").val(dayjs(log.actual.time).format("YYYY-MM-DDTHH:mm:ss"));
form.find("#actual-dose").val(log.actual.dose);
}
document.getElementById("editMedLog").onsubmit = function (e) {
e.preventDefault();
const form = $("#editMedLog");
data = {
med_keyname: form.find("#key-name").val(),
uuid: form.find("#uuid").val(),
expected: {
time: form.find("#expected-time").valueAsDate,
dose: parseInt(form.find("#expected-dose").val())
},
dose_offset: parseFloat(form.find("#dose-offset").val()),
actual: {
time: form.find("#actual-time")[0].valueAsDate,
dose: parseInt(form.find("#actual-dose").val())
}
}
$.ajax({
url: "/api/health/meds/compliance/log",
method: "POST",
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
window.location.reload();
},
})
}
})()
</script>
</div>
</div>
</div>
})
});
$("#addMed").on("submit", function (e) {
e.preventDefault();
let name = $("#addMed #name").val();
let dosage = $("#addMed #dosage").val();
let dosage_unit = $("#addMed #dosage_unit").val();
let dosage_route = $("#addMed #dosage_route").val();
let period_hours = $("#addMed #period_hours").val();
let flags = [];
if ($("#addMed #flags-qam").prop("checked")) {
flags.push("qam");
}
if ($("#addMed #flags-qhs").prop("checked")) {
flags.push("qhs");
}
if ($("#addMed #flags-prn").prop("checked")) {
flags.push("prn");
}
if ($("#addMed #flags-adlib").prop("checked")) {
flags.push("ad lib");
}
let schedule = $("#addMed input[name=schedule]:checked").val();
$.ajax({
url: "/api/health/meds/directions",
method: "POST",
contentType: "application/json",
data: JSON.stringify({
name: name,
dosage: parseInt(dosage),
dosage_unit: dosage_unit,
dosage_route: dosage_route,
period_hours: parseInt(period_hours),
flags: flags,
schedule: schedule
}),
success: function (data) {
window.location.reload();
},
})
})
</script>
</div>
</div>
@ -261,7 +378,7 @@
}
const medTakeForm = accEl.querySelector(".med-take-form")
medTakeForm.querySelector("input#dosage").value = med.dosage
const medTimeline = (() => {
let medTimeline = (() => {
let timeline = accEl.querySelector("#timeline")
const medTimelineTpl = accEl.querySelector("#timeline-tpl")
if (timeline) {
@ -312,22 +429,25 @@
},
success: function (data) {
let icon = accEl.querySelector(".accordion-icon")
let important = false
let available = false
let important = dayjs().isAfter(dayjs(data.expected.time)) && !prn;
let available = data.dose_offset > -0.2 || adlib;
icon.setAttribute("class", "accordion-icon")
if (data.dose_offset < -0.2 || dayjs().isAfter(dayjs(data.expected.time).add(1, "day"))) {
if (important) {
available = true
accEl.setAttribute("data-weight", 50)
important = !adlib
icon.classList.add("trima-procedure-optimal")
} else if (data.dose_offset < -0.2 || dayjs().isAfter(dayjs(data.expected.time).add(1, "day"))) {
accEl.setAttribute("data-weight", (prn || adlib) ? 5 : 10)
icon.classList.add("trima-procedure-ineligible")
} else if (data.dose_offset < 0 || adlib || (prn && data.dose_offset == 0)) {
available = true
accEl.setAttribute("data-weight", (prn || adlib) ? 15 : 20)
icon.classList.add("trima-procedure-valid")
} else {
available = true
accEl.setAttribute("data-weight", 50)
important = !adlib
icon.classList.add("trima-procedure-optimal")
}
if (initial) {
accEl.querySelector(".accordion-collapse").classList[important ? "add" : "remove"]("show")
accEl.querySelector(".accordion-button").classList[important ? "remove" : "add"]("collapsed")
@ -375,47 +495,57 @@
tbody.innerHTML = "";
let projectedTr = document.createElement("tr");
projectedTr.classList.add("table-primary");
projectedTr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td>`;
labelTimeElement(projectedTr.children[0], data.expected.time);
projectedTr.children[1].innerText = `${data.expected.dose} ${med.dosage_unit} (${(prn || adlib) ? "available" : "scheduled"})`;
projectedTr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td><td></td>`;
projectedTr.children[0].innerHTML = `<button class="btn btn-sm btn-warning" type="button" aria-expanded="false">Edit Directions</button>`;
projectedTr.querySelector("button").onclick = e => {
document.querySelector("#addMed #name").value = med.name + " " + med.shorthand;
document.querySelector("#addMed #name").dispatchEvent(new CustomEvent("change"));
}
labelTimeElement(projectedTr.children[1], data.expected.time, "YY-MM-DD HH:mm");
projectedTr.children[2].innerText = `${data.expected.dose} ${med.dosage_unit} (${(prn || adlib) ? "available" : "scheduled"})`;
projectedTr.children[3].innerText = data.dose_offset;
tbody.appendChild(projectedTr);
logs.forEach(log => {
const tr = document.createElement("tr");
tr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td>`;
labelTimeElement(tr.children[0], log.actual.time)
tr.children[1].innerText = `${log.actual.dose}/${log.expected.dose} ${med.dosage_unit}`;
if (log.actual.dose !== log.expected.dose) {
tr.children[1].classList.add("table-warning");
} else {
tr.children[1].classList.add("table-success");
tr.innerHTML = `<th scope="row"></th><td></td><td></td><td></td><td></td>`;
tr.children[0].innerHTML = `<button type="button" class="btn btn-sm btn-warning">Edit</button>`;
tr.querySelector("button").onclick = e => {
window.loadMedLogForm(log)
}
tr.children[2].innerText = log.dose_offset.toFixed(2);
if (Math.abs(log.dose_offset) > 1) {
tr.children[2].classList.add("table-danger");
} else if (Math.abs(log.dose_offset) > 0.5) {
labelTimeElement(tr.children[1], log.actual.time, "YY-MM-DD HH:mm")
tr.children[2].innerText = `${log.actual.dose}/${log.expected.dose} ${med.dosage_unit}`;
if (log.actual.dose !== log.expected.dose) {
tr.children[2].classList.add("table-warning");
} else {
tr.children[2].classList.add("table-success");
}
// compute 7 day offset
const weekFrom = dayjs(log.actual.time).subtract(7, 'day')
let offset = 0
logs.forEach(log => {
if (dayjs(log.actual.time).isAfter(weekFrom)) {
offset += log.dose_offset
}
})
tr.children[3].innerText = offset.toFixed(2);
if (Math.abs(offset) > 1) {
tr.children[3].innerText = log.dose_offset?.toFixed(2);
if (Math.abs(log.dose_offset) > 0.5) {
tr.children[3].classList.add("table-danger");
} else if (Math.abs(offset) > 0.5) {
} else if (Math.abs(log.dose_offset) > 0.2) {
tr.children[3].classList.add("table-warning");
} else {
tr.children[3].classList.add("table-success");
}
// compute 7 day offset
const weekFrom = dayjs(log.actual.time).subtract(7, 'day')
let offset = log.dose_offset
logs.forEach(logI => {
if (dayjs(logI.actual.time).isAfter(weekFrom) && dayjs(logI.actual.time).isBefore(log.actual.time)) {
offset += logI.dose_offset
}
})
tr.children[4].innerText = offset.toFixed(2);
if (Math.abs(offset) > 1) {
tr.children[4].classList.add("table-danger");
} else if (Math.abs(offset) > 0.5) {
tr.children[4].classList.add("table-warning");
} else {
tr.children[4].classList.add("table-success");
}
tbody.appendChild(tr);
});
@ -468,7 +598,7 @@
fill = ""
break
}
let arrow = $("." + baseClass).clone()
let arrow = $(".d-none." + baseClass).first().clone()
if (fill)
arrow.attr("fill", fill)
arrow.removeClass("d-none")
@ -513,14 +643,14 @@
dataType: "json",
success: function (data) {
writeAccordion(data, true).then(() => {
updateTimer = setInterval(() => writeAccordion(data).catch(err => {
updateTimer = setTimeout(() => writeAccordion(data).catch(err => {
throw err
}), 300 * 1000)
})
}
})
} else {
clearInterval(updateTimer);
clearTimeout(updateTimer);
}
})
})()

View file

@ -3,11 +3,11 @@ function doNow(fn) {
return fn;
}
function labelTimeElement(tag, time) {
function labelTimeElement(tag, time, format) {
time = dayjs(time);
if (tag.innerText == "")
tag.innerText = time.fromNow();
if (tag.innerText == "" || format)
tag.innerText = format ? time.format(format) : time.fromNow();
tag.setAttribute("data-bs-toggle", "tooltip");
tag.setAttribute("data-bs-title", time.format("L LT"));
new bootstrap.Tooltip(tag);