diff --git a/internal/health/api.go b/internal/health/api.go index 727e496..df8a3ee 100644 --- a/internal/health/api.go +++ b/internal/health/api.go @@ -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 + } + }() } diff --git a/internal/health/api_compliance.go b/internal/health/api_compliance.go index 1e4388c..c6719fe 100644 --- a/internal/health/api_compliance.go +++ b/internal/health/api_compliance.go @@ -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) + } +} diff --git a/internal/health/compliance.go b/internal/health/compliance.go index 1dc0be2..deb1d9b 100644 --- a/internal/health/compliance.go +++ b/internal/health/compliance.go @@ -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 { diff --git a/internal/health/db.go b/internal/health/db.go index 298a0d5..0b6e9b1 100644 --- a/internal/health/db.go +++ b/internal/health/db.go @@ -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 } diff --git a/internal/health/template.go b/internal/health/template.go new file mode 100644 index 0000000..72892ae --- /dev/null +++ b/internal/health/template.go @@ -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 +} diff --git a/internal/servetpl/middleware.go b/internal/servetpl/middleware.go index b9ee9c4..4aa4650 100644 --- a/internal/servetpl/middleware.go +++ b/internal/servetpl/middleware.go @@ -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) } diff --git a/webroot/includes/page-health-meds.tpl.html b/webroot/includes/page-health-meds.tpl.html index 63dc6df..947b5aa 100644 --- a/webroot/includes/page-health-meds.tpl.html +++ b/webroot/includes/page-health-meds.tpl.html @@ -65,7 +65,8 @@ - + @@ -78,10 +79,11 @@
Action | Time | Dose | Offset | @@ -93,6 +95,8 @@Loading... | + | + |
---|