From d3b9d34bd2c972ea995120ccee8cb9d4d05fa57a Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 21 Nov 2025 05:46:31 +0000 Subject: [PATCH] Add basic average cadence calculation to treatments --- html.go | 140 ++++++++++++++++++++++++++++++++++++------ model_conversion.go | 8 +++ templates/source.html | 3 +- time.go | 42 +++++++++++++ 4 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 time.go diff --git a/html.go b/html.go index ff420996..aef5138b 100644 --- a/html.go +++ b/html.go @@ -9,6 +9,7 @@ import ( "html/template" "io" "log/slog" + "math" "net/http" "os" "strconv" @@ -18,8 +19,7 @@ import ( "github.com/Gleipnir-Technology/nidus-sync/models" "github.com/Gleipnir-Technology/nidus-sync/sql" "github.com/aarondl/opt/null" - //"github.com/riverqueue/river/rivershared/util/slogutil" - //"github.com/rs/zerolog/log" + "github.com/rs/zerolog/log" "github.com/stephenafamo/bob" "github.com/stephenafamo/bob/dialect/psql" "github.com/stephenafamo/bob/dialect/psql/sm" @@ -123,12 +123,13 @@ type ContentSignin struct { } type ContentSignup struct{} type ContentSource struct { - Inspections []Inspection - MapData ComponentMap - Source *BreedingSourceDetail - Traps []TrapNearby - Treatments []Treatment - User User + Inspections []Inspection + MapData ComponentMap + Source *BreedingSourceDetail + Traps []TrapNearby + Treatments []Treatment + TreatmentCadence time.Duration + User User } type Inspection struct { Action string @@ -146,12 +147,6 @@ type ServiceRequestSummary struct { Location string Status string } -type Treatment struct { - Date time.Time - LocationID string - Notes string - Product string -} type User struct { DisplayName string Initials string @@ -511,6 +506,15 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st respondError(w, "Failed to get treatments", err, http.StatusInternalServerError) return } + treatment_times := make([]time.Time, 0) + for _, treatment := range treatments { + treatment_times = append(treatment_times, treatment.Date) + } + cadence, deltas := calculateCadenceVariance(treatment_times) + for i, treatment := range treatments { + treatment.CadenceDelta = deltas[i] + treatments[i] = treatment + } data := ContentSource{ Inspections: inspections, MapData: ComponentMap{ @@ -524,10 +528,11 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st }, Zoom: 13, }, - Source: s, - Traps: traps, - Treatments: treatments, - User: userContent, + Source: s, + Traps: traps, + Treatments: treatments, + TreatmentCadence: cadence, + User: userContent, } renderOrError(w, source, data) @@ -569,7 +574,9 @@ func makeFuncMap() template.FuncMap { "bigNumber": bigNumber, "GISStatement": gisStatement, "latLngDisplay": latLngDisplay, + "timeDelta": timeDelta, "timeElapsed": timeElapsed, + "timeInterval": timeInterval, "timeSince": timeSince, "uuidShort": uuidShort, } @@ -625,7 +632,6 @@ func parseFromDisk(files []string) (*template.Template, error) { for _, f := range components { paths = append(paths, "templates/components/"+f+".html") } - //slog.Info("Rendering templates from disk", slog.Any("paths", slogutil.SliceString(paths))) templ, err := template.New(name).Funcs(funcMap).ParseFiles(paths...) if err != nil { return nil, fmt.Errorf("Failed to parse %s: %w", paths, err) @@ -633,6 +639,59 @@ func parseFromDisk(files []string) (*template.Template, error) { return templ, nil } +// FormatTimeDuration returns a human-readable string representing a time.Duration +// as "X units early" or "X units late" +func timeDelta(d time.Duration) string { + suffix := "late" + if d < 0 { + suffix = "early" + d = -d // Make duration positive for calculations + } + + const ( + day = 24 * time.Hour + week = 7 * day + ) + + log.Info().Int64("delta", int64(d)).Str("suffix", suffix).Msg("Time delta") + switch { + case d >= week: + weeks := d / week + if weeks == 1 { + return "1 week " + suffix + } + return fmt.Sprintf("%d weeks %s", weeks, suffix) + + case d >= day: + days := d / day + if days == 1 { + return "1 day " + suffix + } + return fmt.Sprintf("%d days %s", days, suffix) + + case d >= time.Hour: + hours := d / time.Hour + if hours == 1 { + return "1 hour " + suffix + } + return fmt.Sprintf("%d hours %s", hours, suffix) + + case d >= time.Minute: + minutes := d / time.Minute + if minutes == 1 { + return "1 minute " + suffix + } + return fmt.Sprintf("%d minutes %s", minutes, suffix) + + default: + seconds := d / time.Second + if seconds == 1 { + return "1 second " + suffix + } + return fmt.Sprintf("%d seconds %s", seconds, suffix) + } +} + func timeElapsed(seconds null.Val[float32]) string { if !seconds.IsValue() { return "none" @@ -651,6 +710,47 @@ func timeElapsed(seconds null.Val[float32]) string { } } +func timeInterval(d time.Duration) string { + seconds := d.Seconds() + + // Less than 120 seconds -> show in seconds + if seconds < 120 { + return fmt.Sprintf("every %d seconds", int(math.Round(seconds))) + } + + minutes := d.Minutes() + // Less than 120 minutes -> show in minutes + if minutes < 120 { + return fmt.Sprintf("every %d minutes", int(math.Round(minutes))) + } + + hours := d.Hours() + // Less than 48 hours -> show in hours + if hours < 48 { + return fmt.Sprintf("every %d hours", int(math.Round(hours))) + } + + days := hours / 24 + // Less than 14 days -> show in days + if days < 14 { + return fmt.Sprintf("every %d days", int(math.Round(days))) + } + + weeks := days / 7 + // Less than 8 weeks -> show in weeks + if weeks < 8 { + return fmt.Sprintf("every %d weeks", int(math.Round(weeks))) + } + + months := days / 30 + // Less than 24 months -> show in months + if months < 24 { + return fmt.Sprintf("every %d months", int(math.Round(months))) + } + + years := days / 365 + return fmt.Sprintf("every %d years", int(math.Round(years))) +} func timeSince(t *time.Time) string { if t == nil { return "never" @@ -721,7 +821,7 @@ func treatmentsBySource(ctx context.Context, org *models.Organization, sourceID sm.Where( models.FSTreatments.Columns.Pointlocid.EQ(psql.Arg(sourceID)), ), - sm.OrderBy("enddatetime"), + sm.OrderBy("enddatetime").Desc(), ).All(ctx, PGInstance.BobDB) if err != nil { return results, fmt.Errorf("Failed to query rows: %w", err) diff --git a/model_conversion.go b/model_conversion.go index 74b8501b..387a73e3 100644 --- a/model_conversion.go +++ b/model_conversion.go @@ -154,6 +154,14 @@ type TrapData struct { Comments string `json:"comments"` } +type Treatment struct { + CadenceDelta time.Duration + Date time.Time + LocationID string + Notes string + Product string +} + func toTemplateTraps(locations []sql.TrapLocationBySourceIDRow, trap_data models.FSTrapdatumSlice, counts []sql.TrapCountByLocationIDRow) ([]TrapNearby, error) { results := make([]TrapNearby, 0) count_by_trap_data_id := make(map[string]*sql.TrapCountByLocationIDRow) diff --git a/templates/source.html b/templates/source.html index cf814060..b2cdb81c 100644 --- a/templates/source.html +++ b/templates/source.html @@ -251,6 +251,7 @@
+

Estimated Treatment Cadence: {{.TreatmentCadence|timeInterval}}

@@ -265,7 +266,7 @@ - + {{ end }} diff --git a/time.go b/time.go new file mode 100644 index 00000000..2e5fbdea --- /dev/null +++ b/time.go @@ -0,0 +1,42 @@ +package main + +import ( + "time" + + "github.com/rs/zerolog/log" +) + +// calculateCadenceVariance takes a slice of time.Time instances and returns +// the average time span between consecutive instances and how much each +// instance deviates from its expected position in an evenly-spaced sequence +// timestamps should be in descending order (most recent time in index 0) +func calculateCadenceVariance(timestamps []time.Time) (averageSpan time.Duration, deviations []time.Duration) { + if len(timestamps) <= 1 { + return 0, []time.Duration{} + } + + // Calculate total span between first and last timestamp + totalSpan := timestamps[0].Sub(timestamps[len(timestamps)-1]) + + // Calculate average span + averageSpan = totalSpan / time.Duration(len(timestamps)-1) + if averageSpan < 0 { + log.Error().Int64("average", int64(averageSpan)).Msg("Negative average") + return 0, []time.Duration{} + } + + // Calculate deviations + deviations = make([]time.Duration, len(timestamps)) + + // The first timestamp is our reference point (deviation = 0) + deviations[0] = 0 + + // Calculate expected timestamps based on perfect intervals + for i := 1; i < len(timestamps); i++ { + expectedTime := timestamps[0].Add(-averageSpan * time.Duration(i)) + deviations[i] = timestamps[i].Sub(expectedTime) + } + + log.Info().Int64("average", int64(averageSpan)).Int("number deviations", len(deviations)).Msg("Calculated cadence") + return averageSpan, deviations +}
{{.Date|timeSince}} {{.Product}}On time{{.CadenceDelta|timeDelta}} {{.Notes}}