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 (