mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
f38d8a5803
commit
58592e4822
3 files changed
+96
-5
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<DateKey | null>;
|
||||
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<ChartDayLinks, "active" | "coarse">;
|
||||
links: Pick<ChartDayLinks, "active" | "coarse"> &
|
||||
Partial<Pick<ChartDayLinks, "days">>;
|
||||
/** 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({
|
||||
<>
|
||||
<DayRug
|
||||
points={points}
|
||||
pointDays={links.days}
|
||||
axis={axis}
|
||||
insetLeft={insetLeft}
|
||||
insetRight={insetRight}
|
||||
|
||||
@@ -144,6 +144,30 @@ export function rugFraction(
|
||||
return at / (n - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a day's dot sits under a time axis drawn from real instants (a lab
|
||||
* result at 08:10, a questionnaire in the evening), given the calendar day
|
||||
* each point stands for. The span is the first to the last of those days,
|
||||
* both included: a noon anchor tested against the instants dropped the first
|
||||
* day when its reading came after noon UTC, the last when its reading came
|
||||
* before, and a single reading never had a dot. The dot then sits along the
|
||||
* instants' own span, held to its ends.
|
||||
*/
|
||||
export function timeRugFraction(
|
||||
day: DateKey,
|
||||
points: readonly RugPoint[],
|
||||
days: ReadonlyArray<DateKey | null>,
|
||||
): 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<DateKey | null>;
|
||||
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 (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user