Skip to content

Commit fd0ff14

Browse files
atulmguptaCopilot
andauthored
Fix/misc fixes (#71)
* fix(api): raise FSM-narrator window cap to 7d to match SPA default The StateMachineDebuggerPage's RangePicker defaults to the '7d' preset (see useRangeState defaultPresetId in StateMachineDebuggerPage.tsx, explicitly chosen because 24h was misleading whenever the last transition was older than a day). The Helix FSM narrator handler POST /api/v1/ai/system/fsm/narrate capped the requested window at 24 hours and rejected anything wider with HTTP 400 'window (604800 s) exceeds cap 86400 s'. The SPA surfaced that as 'Helix error: stream_http_400' in the Helix narrator panel, so the default operator workflow — open page, click 'Ask Helix' — never produced a narration on a fresh install. Raise the cap to 7 days so the default range works. The narrator's production tools.FSMTraceSource (AIFSMTraceSource) returns a deterministic empty envelope regardless of window size and a future real reader is still bounded by transition density, so the wider cap does not change the source's load profile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): tire pressure page — table sorted DESC, chart chronological, Last Updated from history User reported tire pressure showing data only through May 9 even though TpmsPressureFl/Fr/Rl/Rr signals were arriving for May 22 (visible on the Live Signals page which reads the same signal_log table). The data was present in the API response — three independent display bugs combined to hide it: 1. **Chart x-axis was reversed.** `chartData` used `[...history].reverse()` to flip the ASC API response, so the chart plotted newest on the LEFT and oldest on the RIGHT. The cliff drop at the rightmost edge — actually the OLDEST data with leading zeros — looked like 'today's reading collapsed'. Removed the reverse so the time axis reads oldest→newest left-to-right (canonical). 2. **History table sorted ASC.** DataTable rendered rows in array order, so page 1 of 11 showed the first 25 rows = oldest = all May 9. Users reading page 1 concluded the data stopped there. Wired `useSortToggle('created_at', 'desc')` so the table defaults to newest-first AND the previously-misleading `sortable: true` column headers now actually function (DataTable does not sort internally — it only emits onSort callbacks). The numeric tire columns use a sortAccessor that returns the normalised Pa value so Badge-wrapped cells sort by magnitude, not Badge label text. 3. **'Last Updated' always showed —.** The MetricCard read `latest.created_at` but `/tire-pressure/latest` returns only field values (no timestamp). Derive the freshness label from the newest row in the history response instead. Documented as range-bound (freshness of the visible window, not necessarily global). Defensive sort: history is sorted by created_at ASC on the client into `historyAsc` before being consumed by chart / table / lastUpdatedAt, so a future backend reorder cannot silently break the chart axis. Frontend-only change. The backend tire pressure handler and its underlying StateReader.Timeline contract are unchanged — other handlers (climate, motor, security, …) also return ASC and leave display-order decisions to callers per the documented contract on timelineRowsToFlat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): re-wire firmware version writes to software_updates table The Software Updates page was showing a stale CURRENT VERSION (the most recent insert was the May-4 firmware) because nothing was writing to the software_updates table anymore — even though Fleet Telemetry was still emitting newer SoftwareUpdateVersion values (visible on /signals/{id}/live). Root cause is a two-step delete chain: 1. f31a173 'chore(phase-42a): delete dead legacy ingest code' removed the legacy MQTT ingest path that called (*TelemetryHandler).trackVehicleConfig per payload. 2. fa7440a 'lint: delete pre-existing dead helpers' then deleted trackVehicleConfig itself because it had zero callers — but the Phase-42 rewrite never re-wired the firmware-version write through the new normalize.Pipeline / Router / writers stack. Result: SoftwareUpdateRepo.InsertIfChanged is defined but has zero production callers, the software_updates table stops receiving rows, and the page's 'CURRENT VERSION' / 'Update Timeline' both go stale. Fix: - New AtomicsObserver in internal/tesla_pipeline: SoftwareUpdateObserver scans each post-route atomics batch for a SoftwareUpdateVersion (preferred) or Version atomic and forwards it to SoftwareUpdateRepo.InsertIfChanged(..., 'installed') with a 2s timeout. Idempotency is owned by the existing ON CONFLICT (vehicle_id, version) DO NOTHING from migration 000197, so every payload retries safely and only true version transitions produce a new row. - Registered as a second observer alongside SideEffectsObserver in initPipelineSubscriber. No ordering dependency between the two — they target independent stores. - Startup backfill in initPipelineSubscriber iterates SignalStore.VehicleIDs() (already hydrated from signal_log via stateReader a few lines earlier) and InsertIfChanged's the current firmware version per vehicle. This covers the 'version-already-changed-before-deploy' edge case where the observer alone would have to wait for the next emission. The backfill and the observer share PickFirmwareVersionFromSignals so their precedence rule cannot drift. Precedence: SoftwareUpdateVersion wins over Version. Live Signals proves SoftwareUpdateVersion (Field 220, vehicle_state) is the field being emitted today; Version (Field 68, config) is retained as a fallback for installations that emit only the legacy field. Failure semantics: per the AtomicsObserver contract, observer errors MUST NOT fail the payload. Recorder errors are logged at WARN and swallowed. The backfill loop logs WARN on per-vehicle errors and continues — it never aborts boot. Verified: go build ./... ok go test -race ./internal/tesla_pipeline/... ./internal/tesla/normalize/... ./internal/app/... ./internal/api/... ./internal/database/... ok docker compose up -d --build teslasync-api healthy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): compact dashboard layouts to eliminate blank-space accumulation Widgets in saved dashboards accumulated vertical gaps over time because removing a widget left behind its y/h slot and react-grid-layout v2's 'findOrGenerateResponsiveLayout' early-returns saved breakpoint layouts without applying the compactor. Each removed widget contributed one permanent gap to the layout JSON, surviving every reload. Add 'compactLayouts' helper using react-grid-layout's verticalCompactor and apply it from 'reconcileLayouts' so every flow that produces a new layout (load, addWidget, removeWidget, dashboard switch, import, duplicate) ends up vertically compacted. Preserves user-resized w/h and relative ordering — compactor only changes y. Also wire duplicateDashboard, undo, and redo through reconcileLayouts so those paths cannot reintroduce gaps (per rubber-duck critique). Existing dashboards with accumulated gaps will self-heal on next load because useDashboardLayouts -> hook state goes through reconcileLayouts. Verified clean: npx tsc --noEmit, npx eslint on changed file, web container rebuild healthy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): persist geocoded place names to SI canonical drive columns Visited Locations page showed 0 places because every reverse-geocode call silently no-op'd. resolveAndUpdateAddress wrote to columns 'start_address' / 'end_address' but Phase-48 slice-1 (commit f916031) renamed those columns to 'start_place' / 'end_place' per the SI canonical drives schema (migration 000185_drives_si). buildPartialUpdate iterates the drivePartialAllowed allowlist and filters unknown field keys out. The keys 'start_address' / 'end_address' were never added to the allowlist after the rename, so every call returned an empty UPDATE statement and PartialUpdate returned nil without writing anything. end_place stayed NULL on every drive forever and the visited_location_repo (which derives places via 'WHERE end_place IS NOT NULL AND end_place != ""') returned an empty result set. Fix: write to the SI canonical column names that drivePartialAllowed already permits. Read path was unaffected — scanDrive selects 'start_place' / 'end_place' positionally into d.StartAddress / d.EndAddress (Go field names are stale-but-stable JSON shape). Existing drives self-heal on next api restart via BackfillAddresses, which already iterates DriveRepo.FindMissingAddresses and re-runs the geocoder; the loop was running but writing into a column that didn't exist on the allowlist, so the rows kept resurfacing in FindMissingAddresses forever — once the writer is fixed, the backfill loop completes them. Verified clean: go build ./..., go test -race -timeout 180s ./internal/api/... + ./internal/database/... pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api,web): restore /mileage/daily endpoint and rewire MileagePage to live shapes The Mileage page (web/src/features/analytics/pages/MileagePage.tsx) was 404ing because Phase-42/0077 deleted the /mileage/* handler family and Phase-43a/0004 only restored /mileage/{stats,monthly}. The page also expected the pre-deletion camelCase MileageStats shape ({totalDistance, avgDaily, maxDaily, daysTracked}) which does not match the restored snake_case MileageStatsResponse ({lifetime_km, last_30d_km, drive_count_lifetime, …}). Backend: * Add MileageRepo.Daily() with SI-canonical dailySelectSQL aggregating drives per UTC calendar day (drive_count, total_km from distance_m, end_odometer_km from end_odometer_m). Same NULL-distance skip + ASC ordering as Monthly. SQL-shape regression test pins column names. * Add MileageHandler.Daily handler + MileageDailyBucket / MileageDailyResponse envelope. days param defaults 90, max 730. end_odometer_km is *float64 so days with all-NULL odometer rows surface JSON null instead of a fabricated zero. * Register GET /mileage/daily on the same /mileage route block (60/min admin rate limit) and add daysAgo() snap-to-midnight helper. * New tests: TestMileage_Daily_{DaysClamp,BadVehicleID,UnknownVehicle_404, EmptyVehicle_200,RepoError_500,GroupingPassThrough} + TestDaysAgo_SnapsToMidnightUTC + TestDailySelectSQL_Shape. Frontend: * Replace stale MileageStats / MonthlyStat types with the actual MileageStats / MonthlyMileageBucket{Response} / DailyMileageBucket{Response} shapes returned by the live backend (snake_case, _km / _wh). * Drop @deprecated banners on useMileageStats / useMonthlyMileage — the endpoints have been live since Phase-43a/0004. useMonthlyMileage now unwraps {vehicle_id, months} → MonthlyMileageBucket[]. * Add useDailyMileage(vehicleId, days=90) hook. * MileagePage: stop calling request() directly. Derive 4 summary cards from lifetime_km / last_30d_km / drive_count_lifetime; Odometer Over Time chart from end_odometer_km; Daily Distance bar chart from total_km; Monthly Summary table from /mileage/monthly buckets. * MileageStatsWidget, MonthlyMileageWidget, StatisticsPage, FleetComparePage: update field accesses to the live shapes (lifetime_km, last_30d_km, year_month, total_km, drive_count). Verified: * go build + vet + go test -race ./internal/database/ ./internal/api/ all PASS (3.8s + 5.8s). * npx tsc --noEmit clean. * npx eslint clean on all changed frontend files. * GET /mileage/daily?vehicle_id=abc → 400 (vehicle_id must be positive int) * GET /mileage/daily?vehicle_id=1&days=999 → 400 (days exceeds maximum, max=730) * Route is registered (OPTIONS returns 405 not 404). * docker compose up -d --build teslasync-api web → both healthy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api,web): populate Speed Profile hero gauges with SI-canonical aggregates The /analytics/speed-profile handler returned only {distribution, categories, points} but SpeedProfilePage rendered three RadialGauges wired to data.avgSpeedKmh / peakSpeedKmh / optimalSpeedKmh — fields the backend never emitted. The page also passed those (would-be) km/h values through convertSpeedFromSI (which expects m/s), so even if backend had emitted km/h the gauges would have over-converted by 3.6x. Fix: - Backend: compute and emit avg_speed_mps, peak_speed_mps, optimal_speed_mps (SI canonical per Phase-48). Optimal = midpoint of the speed bucket with the lowest mean Wh/km, mapped to m/s. Window- aware (mirrors the existing hasRange branching). - Frontend types (driving.ts + api/types.ts): rename SpeedProfileData fields to avgSpeedMps / peakSpeedMps / optimalSpeedMps. - SpeedProfilePage: read new fields; gauge max literals also moved to m/s (55.56 m/s ~ 200 km/h, 69.44 m/s ~ 250 km/h) so the gauges no longer underfill at 1/3.6 of their nominal range. - SpeedProfileWidget: drop the broken km/h->mph workaround; pass m/s straight through to convertSpeedFromSI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(web): correct Speed Profile bucket cards and efficiency formula Two compounding display bugs visible after pushing the gauge fix (bf480e9): 1. Bucket card 'Avg Speed' was double-converted. bucketEfficiency stored the speed already converted to display units (mph or km/h), then the card re-applied toSpeedDisplay = convertSpeedFromSI to it, which treats the value as m/s and multiplies by 3.6 (km/h pref) or 2.237 (mph pref). A 7.5 mph drive was rendered as 16.82 mph and placed in the '0-15' bucket label that no longer fit the displayed value. Fix: store SI m/s in the accumulator (avgSpeedMps) and run toSpeedDisplay only at render. Bucket-assignment loop keeps using the display-unit value to match the backend's mph bucket labels. 2. getEfficiency used a battery-pct heuristic (battUsed * 0.75 kWh per percent) that produces absurd Wh/mi when a drive has tiny distance + non-trivial battery delta. The scatter chart showed dots at 65,000-130,000 Wh/mi which crowded out the real cluster around 200-300 Wh/mi. Prefer the SI energyUsedWh field that the Drive type already carries (Phase-42 SI canonical); fall back to the battery-pct heuristic only when energyUsedWh is null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): show routes whose place names haven't been geocoded yet Route Efficiency page rendered 0 routes / 0 trips / 'No data available' because the SQL filter required start_place AND end_place to be non-null. BackfillAddresses is rate-limited to 1 req/sec per Nominatim policy so historical drives lag the geocoded set — every drive without a place name was silently dropped from the aggregation even when full GPS coordinates were present. Fix the List + Detail queries to accept drives with either: - geocoded place names (the original happy path), OR - non-null start/end coordinates (new fallback) When start_place / end_place is null/empty, synthesise a label from the rounded lat/lng (3 decimals ~= 110 m precision) inside a CTE and GROUP BY the same expression. Routes with real place names continue to group exactly as before; un-geocoded routes now appear under a '37.123, -122.456' style label instead of being filtered out. Detail handler uses the same labelled CTE so click-through from a coordinate-labelled route in the summary matches the underlying drives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api): wire MQTT PipelineSubscriber to streaming health recorder Bug: MQTT Inspector page showed 0 vehicles / 0 signals / 0 batches even when Connected ✓ and signal_log + drives were updating from live fleet telemetry. Root cause: TelemetryHandler.streamingState (read by /api/v1/telemetry for the Inspector page) was only populated by the HTTP TelemetryIngest spine. After the Phase-42 per-field MQTT cutover, telemetry flows through mqtt.PipelineSubscriber → normalize.Pipeline.ProcessAtomics, which never touched recordStreamingHealth, so the inspector silently zeroed out for all production traffic. Fix: - Add mqtt.StreamingHealthRecorder interface (RecordStream callback) plus a non-nil StreamingRecorder field on PipelineSubscriberConfig. - PipelineSubscriber.handlePayload invokes RecordStream exactly once per successfully dispatched batch — codec drops and pipeline errors do NOT count, so the inspector only reflects signals that actually persisted. - TelemetryHandler.RecordStream adapts []codec.Atomic to the existing recordStreamingHealth(vin, count, signals) contract, building a compact one-key-per-field LastSignals snapshot. - internal/app/new.go threads a.TelemetryHandler into the subscriber config; compile-time guard in telemetry_handler.go pins the interface implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b7235b7 commit fd0ff14

28 files changed

Lines changed: 1586 additions & 183 deletions

internal/api/ai_state_machine_debugger_narrator_handler.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,16 @@ const aiStateMachineDebuggerNarratorMaxIterations = 8
105105
const aiStateMachineDebuggerNarratorMaxBodyBytes = 16 * 1024
106106

107107
// aiStateMachineDebuggerNarratorMaxWindowSeconds caps the window
108-
// the caller may request. 24 hours is generous for an operator
109-
// FSM-trace investigation workflow and bounds the size of the
110-
// envelope the source has to compute.
111-
const aiStateMachineDebuggerNarratorMaxWindowSeconds = 24 * 60 * 60
108+
// the caller may request. 7 days matches the SPA's
109+
// StateMachineDebuggerPage default range preset ('7d' — see the
110+
// useRangeState defaultPresetId in
111+
// web/src/features/system/pages/StateMachineDebuggerPage.tsx),
112+
// so the default operator workflow (open page → click "Ask Helix")
113+
// no longer trips the cap with a stream_http_400. The previous
114+
// 24-hour cap silently rejected every default-range request and
115+
// bounds the size of the envelope the source has to compute even
116+
// at the wider 7-day window.
117+
const aiStateMachineDebuggerNarratorMaxWindowSeconds = 7 * 24 * 60 * 60
112118

113119
// aiStateMachineDebuggerNarratorMaxFromUnix is a sanity upper
114120
// bound on from_unix to reject obvious garbage (e.g. epoch year

internal/api/mileage_handler.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type mileageRepository interface {
3434
VehicleExists(ctx context.Context, vehicleID int64) (bool, error)
3535
Monthly(ctx context.Context, vehicleID int64, windowStart time.Time) ([]database.MileageMonthlyRow, error)
3636
Stats(ctx context.Context, vehicleID int64, since7d, since30d, since365d time.Time) (database.MileageStats, error)
37+
Daily(ctx context.Context, vehicleID int64, windowStart time.Time) ([]database.MileageDailyRow, error)
3738
}
3839

3940
// mileageClock is injected so handler tests can pin the window
@@ -62,6 +63,15 @@ const (
6263
// with one row per trip, so 10 years of monthly aggregation is
6364
// bounded by trip frequency rather than telemetry tick rate.
6465
mileageMaxMonths = 120
66+
// mileageDefaultDays is the default per-day window for /mileage/daily
67+
// (Phase-43a / Prompt 0009 — fix/misc-fixes). MileagePage.tsx today
68+
// requests limit=90; 90 daily buckets renders cleanly on the
69+
// Odometer Over Time area chart and Daily Distance bar chart.
70+
mileageDefaultDays = 90
71+
// mileageMaxDays caps the per-day window. 730 days = 2 years —
72+
// plenty for the page's pagination patterns without unbounded
73+
// growth in the response payload.
74+
mileageMaxDays = 730
6575
)
6676

6777
// parseMonthlyParams extracts and validates vehicle_id + months for
@@ -266,6 +276,120 @@ func (h *MileageHandler) Stats(w http.ResponseWriter, r *http.Request) {
266276
})
267277
}
268278

279+
// parseDailyParams extracts and validates vehicle_id + days for
280+
// /mileage/daily. Returns ok=false after writing the appropriate 4xx
281+
// response so the caller can early-return.
282+
//
283+
// Phase-43a / Prompt 0009 (fix/misc-fixes). Mirrors parseMonthlyParams
284+
// but with the days cap (Decision #3 of Prompt 0004 generalised to
285+
// daily granularity).
286+
func (h *MileageHandler) parseDailyParams(w http.ResponseWriter, r *http.Request) (vehicleID int64, days int, ok bool) {
287+
q := r.URL.Query()
288+
289+
vidStr := q.Get("vehicle_id")
290+
if vidStr == "" {
291+
writeError(w, http.StatusBadRequest, "vehicle_id is required")
292+
return 0, 0, false
293+
}
294+
vid, err := strconv.ParseInt(vidStr, 10, 64)
295+
if err != nil || vid <= 0 {
296+
writeError(w, http.StatusBadRequest, "vehicle_id must be a positive integer")
297+
return 0, 0, false
298+
}
299+
300+
days = mileageDefaultDays
301+
if d := q.Get("days"); d != "" {
302+
v, err := strconv.Atoi(d)
303+
if err != nil {
304+
writeError(w, http.StatusBadRequest, "days must be an integer")
305+
return 0, 0, false
306+
}
307+
if v < 1 {
308+
writeError(w, http.StatusBadRequest, "days must be >= 1")
309+
return 0, 0, false
310+
}
311+
if v > mileageMaxDays {
312+
writeJSON(w, http.StatusBadRequest, map[string]any{
313+
"error": "days exceeds maximum",
314+
"max": mileageMaxDays,
315+
"code": httpStatusCode(http.StatusBadRequest),
316+
})
317+
return 0, 0, false
318+
}
319+
days = v
320+
}
321+
return vid, days, true
322+
}
323+
324+
// MileageDailyBucket is one bucket in the /mileage/daily response.
325+
// Date is rendered as YYYY-MM-DD so consumers can sort lexically or
326+
// pass it directly into Date/dayjs constructors. end_odometer_km is
327+
// a pointer so a day with non-null distance but all-null end_odometer_m
328+
// (rare but possible when a drive ends abnormally) reports JSON null
329+
// for the odometer field instead of a fabricated zero.
330+
type MileageDailyBucket struct {
331+
Date string `json:"date"`
332+
DriveCount int `json:"drive_count"`
333+
TotalKm float64 `json:"total_km"`
334+
EndOdometerKm *float64 `json:"end_odometer_km"`
335+
}
336+
337+
// MileageDailyResponse is the envelope returned by Daily. Mirrors the
338+
// MileageMonthlyResponse shape so the frontend hook layer can reuse
339+
// the same envelope-unwrap pattern.
340+
type MileageDailyResponse struct {
341+
VehicleID int64 `json:"vehicle_id"`
342+
Days []MileageDailyBucket `json:"days"`
343+
}
344+
345+
// Daily serves GET /mileage/daily?vehicle_id=...&days=N.
346+
//
347+
// Returns 200 with {vehicle_id, days: []} for an existing vehicle even
348+
// when no drives are recorded — consistent with Monthly's Decision #6.
349+
// 404 only when the vehicle id is unknown.
350+
func (h *MileageHandler) Daily(w http.ResponseWriter, r *http.Request) {
351+
vehicleID, days, ok := h.parseDailyParams(w, r)
352+
if !ok {
353+
return
354+
}
355+
356+
ctx := r.Context()
357+
exists, err := h.repo.VehicleExists(ctx, vehicleID)
358+
if err != nil {
359+
log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("mileage.daily: existence probe failed")
360+
writeError(w, http.StatusInternalServerError, "failed to verify vehicle")
361+
return
362+
}
363+
if !exists {
364+
writeError(w, http.StatusNotFound, "vehicle not found")
365+
return
366+
}
367+
368+
now := h.now()
369+
windowStart := daysAgo(now, days)
370+
rows, err := h.repo.Daily(ctx, vehicleID, windowStart)
371+
if err != nil {
372+
log.Error().Err(err).Int64("vehicle_id", vehicleID).Int("days", days).Msg("mileage.daily: query failed")
373+
writeError(w, http.StatusInternalServerError, "failed to load daily mileage")
374+
return
375+
}
376+
377+
out := MileageDailyResponse{
378+
VehicleID: vehicleID,
379+
Days: make([]MileageDailyBucket, 0, len(rows)),
380+
}
381+
for _, row := range rows {
382+
out.Days = append(out.Days, MileageDailyBucket{
383+
Date: row.Day.UTC().Format("2006-01-02"),
384+
DriveCount: row.DriveCount,
385+
TotalKm: row.TotalKm,
386+
EndOdometerKm: row.EndOdometerKm,
387+
})
388+
}
389+
390+
writeJSON(w, http.StatusOK, out)
391+
}
392+
269393
// now returns the injected clock value or wall time if no clock is
270394
// configured. Splitting it out keeps every time-derived computation in
271395
// the handler reading from the same source.
@@ -291,3 +415,14 @@ func monthsAgo(now time.Time, months int) time.Time {
291415
// `now.Day()` of that month (which would clip the earliest bucket).
292416
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
293417
}
418+
419+
// daysAgo subtracts `days` calendar days from `now` and snaps to UTC
420+
// midnight so the earliest bucket includes drives from the start of
421+
// that day rather than from `now.Hour()` of that day (which would clip
422+
// the earliest bucket exactly like monthsAgo's month-snap does).
423+
//
424+
// Phase-43a / Prompt 0009 (fix/misc-fixes).
425+
func daysAgo(now time.Time, days int) time.Time {
426+
t := now.AddDate(0, 0, -days)
427+
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
428+
}

0 commit comments

Comments
 (0)