diff --git a/src/components/day/__tests__/chart-day.test.ts b/src/components/day/__tests__/chart-day.test.ts index f29ae03cf..7677fa894 100644 --- a/src/components/day/__tests__/chart-day.test.ts +++ b/src/components/day/__tests__/chart-day.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { chartPointDayKey, rugFraction, + rugEntries, rugPosition, timeRugFraction, } from "../chart-day"; @@ -191,3 +192,36 @@ describe("rug on a time axis drawn from real instants", () => { expect(timeRugFraction("2026-03-06", points, days)).toBeNull(); }); }); + +describe("rugEntries", () => { + const points = ["2026-09-01", "2026-09-04", "2026-09-08", "2026-09-12"].map( + (day) => ({ timestamp: noon(day) }), + ); + const everyDay = Array.from( + { length: 12 }, + (_, i) => `2026-09-${String(i + 1).padStart(2, "0")}`, + ); + + it("marks only the chart's own points on an index axis", () => { + // Days between two points have no place on an axis that spaces points; + // interpolated, a week of them ran together into a bar in one gap. + const entries = rugEntries(everyDay, points, "index"); + expect(entries.map((e) => e.day)).toEqual([ + "2026-09-01", + "2026-09-04", + "2026-09-08", + "2026-09-12", + ]); + expect(entries.map((e) => e.at)).toEqual([0, 1 / 3, 2 / 3, 1]); + }); + + it("keeps every day of the span on a time axis", () => { + expect(rugEntries(everyDay, points, "time")).toHaveLength(12); + }); + + it("never puts two dots closer than one point step on an index axis", () => { + const entries = rugEntries(everyDay, points, "index"); + const gaps = entries.slice(1).map((e, i) => e.at - entries[i]!.at); + expect(Math.min(...gaps)).toBeCloseTo(1 / (points.length - 1)); + }); +}); diff --git a/src/components/day/chart-day.tsx b/src/components/day/chart-day.tsx index 538c1354e..0a1a22e70 100644 --- a/src/components/day/chart-day.tsx +++ b/src/components/day/chart-day.tsx @@ -168,6 +168,38 @@ export function timeRugFraction( return Math.min(1, Math.max(0, (t - first) / (last - first))); } +/** + * The rug's dots: each day that holds anything, where it sits along the plot. + * + * On an index or band axis a day has a place only if it is one of the + * chart's points, because the axis spaces points, not days. A day between + * two points used to be interpolated into the gap between them, so a sparse + * series (blood pressure taken twice a week beside a mood logged every day) + * squeezed a week of other days into one gap and their dots ran together + * into bars. A time axis spaces days and keeps every day of its span. + */ +export function rugEntries( + days: readonly string[], + points: readonly RugPoint[], + axis: ChartDayAxis, + pointDays?: ReadonlyArray, +): Array<{ day: string; at: number }> { + const onPoints = + axis === "time" + ? null + : new Set(points.map((point) => chartPointDayKey(point.timestamp))); + return days + .filter((day) => onPoints === null || onPoints.has(day)) + .map((day) => ({ + day, + at: + axis === "time" && pointDays !== undefined + ? timeRugFraction(day as DateKey, points, pointDays) + : rugFraction(day as DateKey, points, axis), + })) + .filter((entry): entry is { day: string; at: number } => entry.at !== null); +} + /** * 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, @@ -216,15 +248,12 @@ export function DayRug({ 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: byDays - ? timeRugFraction(day, points, pointDays) - : rugFraction(day, points, axis), - })) - .filter((entry): entry is { day: string; at: number } => entry.at !== null); + const days = rugEntries( + Object.keys(data?.days ?? {}).filter((day) => isOpenableDay(day, today)), + points, + axis, + byDays ? pointDays : undefined, + ); return (