From 58592e4822906c016d7f617501cabe07b8487bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:38:16 +0200 Subject: [PATCH] fix(day): keep edge days and single readings in the dots under a time axis A time-axis chart drawn from real instants tested each day's noon anchor against the readings themselves, so the first day dropped out when its reading came after noon UTC, the last when it came before, a single reading never got a dot, and the span was cut in UTC while the days were local. The row now spans the points' own calendar days, as the chart hands them to its day links. --- .../day/__tests__/chart-day.test.ts | 40 +++++++++++++- src/components/day/chart-day-links.tsx | 7 ++- src/components/day/chart-day.tsx | 54 +++++++++++++++++-- 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/components/day/__tests__/chart-day.test.ts b/src/components/day/__tests__/chart-day.test.ts index 127921512..35d715fce 100644 --- a/src/components/day/__tests__/chart-day.test.ts +++ b/src/components/day/__tests__/chart-day.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { chartPointDayKey, rugFraction, rugPosition } from "../chart-day"; +import { + chartPointDayKey, + rugFraction, + rugPosition, + timeRugFraction, +} from "../chart-day"; import { daysFromCoachSteps } from "../coach-day-chips"; import { preparationRows } from "../since-last-visit"; import type { CoachStep } from "@/lib/ai/coach/types"; @@ -152,3 +157,36 @@ describe("rug placement per axis", () => { ); }); }); + +describe("rug on a time axis drawn from real instants", () => { + const at = (iso: string) => ({ timestamp: Date.parse(iso) }); + + it("keeps the first day when its reading came after noon UTC", () => { + const points = [at("2026-03-01T18:30:00Z"), at("2026-03-05T08:00:00Z")]; + const days = ["2026-03-01", "2026-03-05"]; + expect(rugFraction("2026-03-01", points, "time")).toBeNull(); + expect(timeRugFraction("2026-03-01", points, days)).toBe(0); + }); + + it("keeps the last day when its reading came before noon UTC", () => { + const points = [at("2026-03-01T08:00:00Z"), at("2026-03-05T07:00:00Z")]; + const days = ["2026-03-01", "2026-03-05"]; + expect(rugFraction("2026-03-05", points, "time")).toBeNull(); + expect(timeRugFraction("2026-03-05", points, days)).toBe(1); + }); + + it("gives a single reading its dot", () => { + const points = [at("2026-03-03T21:00:00Z")]; + expect(rugFraction("2026-03-03", points, "time")).toBeNull(); + expect(timeRugFraction("2026-03-03", points, ["2026-03-03"])).toBe(0.5); + }); + + it("spans the readings' own days, not their UTC dates", () => { + // An evening reading in a zone ahead of UTC is the next local day. + const points = [at("2026-03-01T23:30:00Z"), at("2026-03-04T23:30:00Z")]; + const days = ["2026-03-02", "2026-03-05"]; + expect(timeRugFraction("2026-03-01", points, days)).toBeNull(); + expect(timeRugFraction("2026-03-05", points, days)).toBe(1); + expect(timeRugFraction("2026-03-06", points, days)).toBeNull(); + }); +}); diff --git a/src/components/day/chart-day-links.tsx b/src/components/day/chart-day-links.tsx index 7e645b155..bd4f21dcc 100644 --- a/src/components/day/chart-day-links.tsx +++ b/src/components/day/chart-day-links.tsx @@ -74,6 +74,8 @@ export interface ChartDayLinksOptions { export interface ChartDayLinks { active: boolean; + /** The calendar day of each drawn point, as the chart passed them in. */ + days: ReadonlyArray; coarse: boolean; /** Index of the open day's point, for the dashed line. */ openIndex: number | undefined; @@ -158,6 +160,7 @@ export function useChartDayLinks({ return { active: enabled, + days, coarse, openIndex, openDay: enabled ? openKey : null, @@ -198,7 +201,8 @@ export function ChartDayFooter({ insetRight, mark = "point", }: { - links: Pick; + links: Pick & + Partial>; /** The drawn points' noon-UTC day anchors (or instants), in data order. */ points: ReadonlyArray<{ timestamp: number }>; axis?: ChartDayAxis; @@ -211,6 +215,7 @@ export function ChartDayFooter({ <> , +): number | null { + const keys = days.filter((d): d is DateKey => d !== null).sort(); + if (keys.length === 0 || points.length === 0) return null; + if (day < keys[0]! || day > keys[keys.length - 1]!) return null; + const first = points[0]!.timestamp; + const last = points[points.length - 1]!.timestamp; + if (last === first) return 0.5; + const t = Date.parse(`${day}T12:00:00.000Z`); + return Math.min(1, Math.max(0, (t - first) / (last - first))); +} + /** * The row of day dots under a daily chart. `insetLeft` / `insetRight` are the * pixels between the chart box and the first and last point (margin, axis, @@ -155,27 +179,51 @@ export function rugFraction( */ export function DayRug({ points, + pointDays, insetLeft, insetRight, axis = "index", }: { points: readonly RugPoint[]; + /** + * The calendar day of each point, in data order (the `days` the chart hands + * `useChartDayLinks`). A time axis drawn from real instants places its dots + * by these; without them every point is read as a noon-UTC day anchor. + */ + pointDays?: ReadonlyArray; insetLeft: number; insetRight: number; axis?: ChartDayAxis; }) { const today = useTodayKey(); const open = useOpenDay(); + const byDays = axis === "time" && pointDays !== undefined; + const dayKeys = byDays + ? pointDays.filter((d): d is DateKey => d !== null).sort() + : []; const first = points[0]; const last = points[points.length - 1]; - const from = first ? chartPointDayKey(first.timestamp) : null; - const to = last ? chartPointDayKey(last.timestamp) : null; + const from = byDays + ? (dayKeys[0] ?? null) + : first + ? chartPointDayKey(first.timestamp) + : null; + const to = byDays + ? (dayKeys[dayKeys.length - 1] ?? null) + : last + ? chartPointDayKey(last.timestamp) + : null; const index = useDayIndex(from, to); const data = index.data; const notable = new Set(data?.notable ?? []); const days = Object.keys(data?.days ?? {}) .filter((day) => isOpenableDay(day, today)) - .map((day) => ({ day, at: rugFraction(day, points, axis) })) + .map((day) => ({ + day, + at: byDays + ? timeRugFraction(day, points, pointDays) + : rugFraction(day, points, axis), + })) .filter((entry): entry is { day: string; at: number } => entry.at !== null); return (