+ {rangeControls}
@@ -444,6 +548,7 @@ const LEGEND_MEAN_KEY: Readonly> = {
quarter: "timeline.legendMeanQuarter",
month: "timeline.legendMeanMonth",
week: "timeline.legendMeanWeek",
+ day: "timeline.legendMeanDay",
};
function Legend({
diff --git a/src/components/timeline/use-series-value-format.ts b/src/components/timeline/use-series-value-format.ts
new file mode 100644
index 000000000..c96e35f50
--- /dev/null
+++ b/src/components/timeline/use-series-value-format.ts
@@ -0,0 +1,35 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useDayValueFormat } from "@/components/day/use-day-value-format";
+import { useFormatters } from "@/lib/i18n/context";
+
+import { plainSeriesFormat, type SeriesValueFormat } from "./series-format";
+
+/** The mood score's series key (`MOOD_SERIES_KEY` on the server). */
+const MOOD_SERIES_KEY = "MOOD";
+
+/**
+ * The timeline's value format: the day view's formatter for every
+ * measurement series (unit preference, whole numbers for pressure and
+ * pulse, sleep as a duration), one plain decimal for mood, which has no
+ * measurement type behind it.
+ */
+export function useSeriesValueFormat(): SeriesValueFormat {
+ const fmt = useFormatters();
+ const { number, unitFor } = useDayValueFormat();
+ return useMemo(() => {
+ const plain = plainSeriesFormat(fmt);
+ return {
+ number: (key, value, unit) =>
+ key === MOOD_SERIES_KEY || unit === null
+ ? plain.number(key, value, unit)
+ : number({ type: key, unit }, value),
+ unit: (key, unit) =>
+ key === MOOD_SERIES_KEY || unit === null
+ ? plain.unit(key, unit)
+ : unitFor({ type: key, unit }),
+ };
+ }, [fmt, number, unitFor]);
+}
diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts
index ffb3ee430..505bcb7aa 100644
--- a/src/lib/day/contract.ts
+++ b/src/lib/day/contract.ts
@@ -211,17 +211,29 @@ export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number];
/* ─── Timeline ────────────────────────────────────────────────────────────── */
-/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */
-export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const;
+/**
+ * Zoom levels. `all` bundles hardest: no symptoms, no single intakes.
+ * `range` is a stretch the person chose, named by `from` and `to` (both
+ * required, at most {@link TIMELINE_RANGE_MAX_DAYS} days).
+ */
+export const TIMELINE_ZOOMS = ["all", "year", "quarter", "range"] as const;
export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number];
/**
* The span one value-series point averages. The server picks it per zoom
* (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a
- * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`.
+ * multi-year `all`, months for `year` and a short `all`, weeks for `quarter`,
+ * and for a `range` by its length, down to single days under six weeks.
*/
-export const TIMELINE_BUCKETS = ["quarter", "month", "week"] as const;
+export const TIMELINE_BUCKETS = ["quarter", "month", "week", "day"] as const;
+
+/**
+ * The longest `range` the timeline answers: fifteen years. Longer than any
+ * fixed zoom reads, and the series of such a window come from the rollup
+ * tier in quarters, so the cost stays that of an `all` view.
+ */
+export const TIMELINE_RANGE_MAX_DAYS = 15 * 366;
export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number];
diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts
index 0bbc1892f..a3dbd24eb 100644
--- a/src/lib/day/wire-schemas.ts
+++ b/src/lib/day/wire-schemas.ts
@@ -28,6 +28,7 @@ import {
TIMELINE_READINESS_KEYS,
TIMELINE_READINESS_STATUSES,
TIMELINE_READINESS_VERDICTS,
+ TIMELINE_RANGE_MAX_DAYS,
TIMELINE_ZOOMS,
} from "./contract";
@@ -266,17 +267,43 @@ export const dayNotableResponseSchema = z
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
-export const timelineQuerySchema = z.object({
- zoom: z.enum(TIMELINE_ZOOMS).default("all"),
- from: dateKeySchema.optional(),
- to: dateKeySchema.optional(),
- values: z
- .string()
- .optional()
- .describe(
- "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
- ),
-});
+export const timelineQuerySchema = z
+ .object({
+ zoom: z
+ .enum(TIMELINE_ZOOMS)
+ .default("all")
+ .describe(
+ "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length.",
+ ),
+ from: dateKeySchema.optional(),
+ to: dateKeySchema.optional(),
+ values: z
+ .string()
+ .optional()
+ .describe(
+ "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
+ ),
+ })
+ .refine((q) => q.zoom !== "range" || (q.from && q.to), {
+ message: "`range` needs both `from` and `to`",
+ path: ["from"],
+ })
+ .refine(
+ (q) =>
+ q.zoom !== "range" ||
+ !q.from ||
+ !q.to ||
+ // Both ends as the same instant of their calendar day, so the
+ // difference counts calendar days whatever the process zone.
+ (dateOnlyAtNoonUtc(q.to).getTime() -
+ dateOnlyAtNoonUtc(q.from).getTime()) /
+ 86_400_000 <
+ TIMELINE_RANGE_MAX_DAYS,
+ {
+ message: `A range spans at most ${TIMELINE_RANGE_MAX_DAYS} days`,
+ path: ["to"],
+ },
+ );
const timelineItem = z
.object({
@@ -357,7 +384,7 @@ export const timelineResponseSchema = z
bucket: z
.enum(TIMELINE_BUCKETS)
.describe(
- "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`.",
+ "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.",
),
series: z.array(timelineSeries),
notable: z.array(
diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts
index b9aa6d4c4..f3b985d54 100644
--- a/src/lib/timeline/__tests__/series.test.ts
+++ b/src/lib/timeline/__tests__/series.test.ts
@@ -3,7 +3,12 @@
*/
import { describe, expect, it } from "vitest";
-import { overlaps, parseSeriesKeys, timelineBucket } from "../load-timeline";
+import {
+ overlaps,
+ parseSeriesKeys,
+ rangeBucket,
+ timelineBucket,
+} from "../load-timeline";
import { coveredWeeks, READINESS_WEEKS } from "../readiness";
import { bucketKey, foldDays, foldMonths } from "../series";
@@ -72,6 +77,24 @@ describe("buckets", () => {
expect(timelineBucket("all", "2025-01-01", "2026-03-31")).toBe("month");
expect(timelineBucket("all", "2019-01-01", "2026-03-31")).toBe("quarter");
});
+
+ it("picks a chosen range's bucket by its length, down to days", () => {
+ // Two years and more: quarters.
+ expect(rangeBucket("2024-03-31", "2026-03-30")).toBe("quarter");
+ expect(rangeBucket("2024-04-01", "2026-03-30")).toBe("month");
+ // Four months (120 days) and more: months.
+ expect(rangeBucket("2026-01-01", "2026-04-30")).toBe("month");
+ expect(rangeBucket("2026-01-02", "2026-04-30")).toBe("week");
+ // Six weeks and more: weeks.
+ expect(rangeBucket("2026-03-01", "2026-04-11")).toBe("week");
+ expect(rangeBucket("2026-03-02", "2026-04-11")).toBe("day");
+ expect(rangeBucket("2026-03-01", "2026-03-01")).toBe("day");
+ expect(timelineBucket("range", "2026-03-01", "2026-03-14")).toBe("day");
+ });
+
+ it("names a day bucket by the day itself", () => {
+ expect(bucketKey("2026-03-05", "day")).toBe("2026-03-05");
+ });
});
describe("window", () => {
diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts
index bc97b723d..a24ef1a97 100644
--- a/src/lib/timeline/load-timeline.ts
+++ b/src/lib/timeline/load-timeline.ts
@@ -7,7 +7,8 @@
* read only the window.
*
* Windows by zoom, when the request names none: `quarter` the last 91 days,
- * `year` the last 365, `all` from the earliest date the record carries. The
+ * `year` the last 365, `all` from the earliest date the record carries. A
+ * `range` always names its own (the query schema requires both ends). The
* notable days of an `all` window are computed over its last three years at
* most: an extreme is "the highest for at least three months", and older
* history is what the comparison reads, not what it marks.
@@ -48,6 +49,29 @@ export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
/** An `all` window longer than this many days averages quarters. */
const QUARTER_BUCKET_MIN_DAYS = 2 * 365;
+/**
+ * The bucket for a chosen range, by its length: about the point count the
+ * fixed zooms draw (a dozen to a few dozen), never one point per reading.
+ * Two years and more average quarters (eight and up), four months and more
+ * months (four to twenty-four), six weeks and more weeks (six to
+ * seventeen), and anything shorter single days (at most six weeks of them).
+ */
+export const RANGE_BUCKET_MIN_DAYS: Readonly<
+ Record, number>
+> = {
+ quarter: 2 * 365,
+ month: 120,
+ week: 42,
+};
+
+export function rangeBucket(from: string, to: string): TimelineBucket {
+ const days = daysBetweenDateKeys(from, to) + 1;
+ if (days >= RANGE_BUCKET_MIN_DAYS.quarter) return "quarter";
+ if (days >= RANGE_BUCKET_MIN_DAYS.month) return "month";
+ if (days >= RANGE_BUCKET_MIN_DAYS.week) return "week";
+ return "day";
+}
+
/**
* The span one series point averages. A point per day or per week over
* years drew a line of fragments wherever readings came in bursts, so the
@@ -61,13 +85,16 @@ export function timelineBucket(
from: string,
to: string,
): TimelineBucket {
+ if (zoom === "range") return rangeBucket(from, to);
if (zoom === "quarter") return "week";
if (zoom === "year") return "month";
const days = daysBetweenDateKeys(from, to);
return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month";
}
-const ZOOM_DAYS: Readonly, number>> = {
+const ZOOM_DAYS: Readonly<
+ Record, number>
+> = {
year: 365,
quarter: 91,
};
@@ -144,7 +171,7 @@ export async function loadTimeline(args: {
const to = query.to ?? today;
const from =
query.from ??
- (query.zoom === "all"
+ (query.zoom === "all" || query.zoom === "range"
? dataFrom !== null && dataFrom < to
? dataFrom
: shiftDateKey(to, -364)
diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts
index 9dd6a56b7..4a6be0a76 100644
--- a/src/lib/timeline/series.ts
+++ b/src/lib/timeline/series.ts
@@ -61,6 +61,7 @@ export const MOOD_SERIES_KEY = "MOOD";
/** The bucket a local day belongs to, named by its first day. */
export function bucketKey(day: string, bucket: TimelineBucket): string {
+ if (bucket === "day") return day;
if (bucket === "month") return `${day.slice(0, 7)}-01`;
if (bucket === "quarter") {
const month = Number(day.slice(5, 7));
@@ -110,7 +111,7 @@ export interface RolledMonth {
*/
export function foldMonths(
months: readonly RolledMonth[],
- bucket: Exclude,
+ bucket: Exclude,
): SeriesPoint[] {
const buckets = new Map<
string,
@@ -230,7 +231,13 @@ async function measurementSeries(args: {
const start = startOfLocalDayKey(args.from, args.tz);
const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz);
let points: SeriesPoint[];
- if (args.bucket !== "week" && readsRollups(args.from, args.to)) {
+ // Weeks and days never read the rollups: their windows are short (a
+ // `day` range is under six weeks), and the rollups cut at UTC midnight.
+ if (
+ args.bucket !== "week" &&
+ args.bucket !== "day" &&
+ readsRollups(args.from, args.to)
+ ) {
const bucket = args.bucket;
// The fold boundary rounded up to a bucket start, so no bucket is half
// live and half rolled up.
diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts
index 4bbb9a28f..bac08bd06 100644
--- a/tests/integration/day-view.test.ts
+++ b/tests/integration/day-view.test.ts
@@ -932,6 +932,43 @@ describe("timeline", () => {
["2026-03-02", 1],
]);
+ // A chosen range under six weeks averages single days; the two
+ // readings of 10 January stay one day.
+ const daily = await json(
+ await call(
+ GET as Handler,
+ "GET",
+ "/api/timeline?zoom=range&from=2026-01-05&to=2026-01-25&values=BLOOD_PRESSURE_SYS",
+ ),
+ );
+ expect(daily.zoom).toBe("range");
+ expect(daily.bucket).toBe("day");
+ expect(daily.range).toMatchObject({ from: "2026-01-05", to: "2026-01-25" });
+ expect(daily.series[0].points).toEqual([
+ { t: "2026-01-10", mean: 125, count: 2 },
+ { t: "2026-01-20", mean: 135, count: 1 },
+ ]);
+ // Four months and more: months, as the fixed zooms draw them.
+ const monthly = await json(
+ await call(
+ GET as Handler,
+ "GET",
+ "/api/timeline?zoom=range&from=2025-12-01&to=2026-03-31&values=BLOOD_PRESSURE_SYS",
+ ),
+ );
+ expect(monthly.bucket).toBe("month");
+ // A range needs both ends, and spans fifteen years at most.
+ for (const query of [
+ "zoom=range",
+ "zoom=range&from=2026-01-01",
+ "zoom=range&from=2000-01-01&to=2026-01-01",
+ ]) {
+ expect(
+ (await call(GET as Handler, "GET", `/api/timeline?${query}`)).status,
+ query,
+ ).toBe(422);
+ }
+
const six =
"BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD";
expect(