diff --git a/messages/de.json b/messages/de.json index 6279c75b8..f6f3f4a80 100644 --- a/messages/de.json +++ b/messages/de.json @@ -434,6 +434,8 @@ "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", + "sleepNapCaption": "+{count} Nickerchen", + "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", "collapseDay": "Einzelwerte dieses Tages ausblenden", "loadError": "Messwerte konnten nicht geladen werden.", diff --git a/messages/en.json b/messages/en.json index 7bf8cd516..33ec9750d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -434,6 +434,8 @@ "previousPage": "Previous page", "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", + "sleepNapCaption": "+{count} nap(s)", + "sleepAwakeningsCaption": "{count} awakening(s)", "expandDay": "Show samples for this day", "collapseDay": "Hide samples for this day", "loadError": "Could not load measurements.", diff --git a/messages/es.json b/messages/es.json index 8e67fd515..b4069c3e1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -434,6 +434,8 @@ "previousPage": "Página anterior", "nextPage": "Página siguiente", "dailyTotalCaption": "({count} muestras)", + "sleepNapCaption": "+{count} siesta(s)", + "sleepAwakeningsCaption": "{count} despertar(es)", "expandDay": "Mostrar las muestras de este día", "collapseDay": "Ocultar las muestras de este día", "loadError": "No se pudieron cargar las mediciones.", diff --git a/messages/fr.json b/messages/fr.json index 387eb3146..f628a06bc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -434,6 +434,8 @@ "previousPage": "Page précédente", "nextPage": "Page suivante", "dailyTotalCaption": "({count} échantillons)", + "sleepNapCaption": "+{count} sieste(s)", + "sleepAwakeningsCaption": "{count} réveil(s)", "expandDay": "Afficher les échantillons de ce jour", "collapseDay": "Masquer les échantillons de ce jour", "loadError": "Impossible de charger les mesures.", diff --git a/messages/it.json b/messages/it.json index bb59bbeba..3959ae74e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -434,6 +434,8 @@ "previousPage": "Pagina precedente", "nextPage": "Pagina successiva", "dailyTotalCaption": "({count} campioni)", + "sleepNapCaption": "+{count} pisolino/i", + "sleepAwakeningsCaption": "{count} risveglio/i", "expandDay": "Mostra i campioni di questa giornata", "collapseDay": "Nascondi i campioni di questa giornata", "loadError": "Impossibile caricare le misurazioni.", diff --git a/messages/pl.json b/messages/pl.json index 3cd2f31d2..9a7ac9ee7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -434,6 +434,8 @@ "previousPage": "Poprzednia strona", "nextPage": "Następna strona", "dailyTotalCaption": "({count} pomiarów)", + "sleepNapCaption": "+{count} drzemka/i", + "sleepAwakeningsCaption": "{count} przebudzenia", "expandDay": "Pokaż pomiary z tego dnia", "collapseDay": "Ukryj pomiary z tego dnia", "loadError": "Nie udało się załadować pomiarów.", diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 11c5939b3..525ee52e2 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -38,6 +38,11 @@ import { collapseRollupRowsBySource, loadUserSourcePriority, } from "@/lib/rollups/measurement-read"; +import { + reconstructSleepSessions, + pickMainNightAndNaps, +} from "@/lib/analytics/sleep-night"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; import { NextRequest } from "next/server"; import type { @@ -115,6 +120,26 @@ export const GET = apiHandler(async (request: NextRequest) => { : {}), }; + // v1.11.5 — sleep is read row-per-STAGE (≈5 rows/night × source-count), + // not a spot sample and not a cumulative day-sum. A plain list query for + // SLEEP_DURATION would leak every stage segment as its own "value" — the + // "many small numbers that stand for themselves" complaint. Route it + // through the canonical per-night aggregation (the same helper the chart + // series + dashboard tile use) so a sleep list returns one row per night + // carrying the night's TIME ASLEEP, with a `dayKey` drill-down to that + // night's stage segments. Storage is untouched; only the display + // collapses. Runs ahead of the cumulative branches because sleep is in + // neither CUMULATIVE_HK_TYPES nor the spot-sample set. + if (type === "SLEEP_DURATION") { + return sleepListResponse(user, { + from, + to, + limit, + sortDir, + dayKey, + }); + } + // v1.4.37 W7c — list-view drill-down to per-sample rows for a single // calendar day in the user's IANA timezone. Resolves the day's UTC // bounds against `User.timezone`, then runs a plain `findMany` so the @@ -626,6 +651,186 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); +/** + * v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is + * stored one row per STAGE per night (minutes); a plain list leaks every + * stage segment. This collapses the raw rows into the canonical per-night + * shape used by the chart series + dashboard tile. + * + * Two modes: + * - `dayKey` set → drill-down: return the night's stage SEGMENTS (the + * canonical source's per-stage spans) so the list row can reveal the + * hypnogram's underlying rows, mirroring the cumulative-type drill-down. + * - no `dayKey` → one synthetic row per night, value = TIME ASLEEP in + * minutes (the canonical unit), with the per-night stage breakdown, + * nap count, and awakenings in the row so the UI can caption the night + * without a second fetch. + * + * The read is bounded to the last year (matching the series route's sleep + * read) so an unbounded list never walks a multi-year per-stage row set. + */ +const SLEEP_LIST_MAX_DAYS = 365; + +async function sleepListResponse( + user: { id: string }, + opts: { + from?: Date; + to?: Date; + limit: number; + sortDir: "asc" | "desc"; + dayKey?: string; + }, +) { + const { from, to, limit, sortDir, dayKey } = opts; + const [tz, priorityJson] = await Promise.all([ + resolveUserTimezone(user.id), + loadUserSourcePriority(user.id), + ]); + + // Bound the read window: honour an explicit from/to, otherwise cap to + // the last year so the per-stage scan stays small. + const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { + gte: since, + ...(to ? { lte: to } : {}), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + id: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + }, + }); + + // Drill-down — return the requested night's canonical stage segments. + if (dayKey) { + const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter( + (s) => s.night === dayKey, + ); + const { main, naps } = pickMainNightAndNaps(sessions); + const dayUnit = getUnitForType("SLEEP_DURATION"); + // One row per stage segment of the main night + each nap, so the + // drill-down reveals exactly the rows that summed into the headline. + const segments = [main, ...naps] + .filter((s): s is NonNullable => s != null) + .flatMap((s) => s.segments); + const measurements = segments + .sort((a, b) => + sortDir === "asc" + ? a.start.getTime() - b.start.getTime() + : b.start.getTime() - a.start.getTime(), + ) + .map((seg, i) => ({ + id: `sleep-seg:${dayKey}:${i}`, + type: "SLEEP_DURATION", + value: Math.round(seg.minutes), + unit: dayUnit, + source: main?.source ?? naps[0]?.source ?? "MANUAL", + measuredAt: seg.end.toISOString(), + sleepStage: seg.stage, + notes: null, + })); + annotate({ + action: { name: "measurement.list" }, + meta: { + total: measurements.length, + type: "SLEEP_DURATION", + dayKey, + mode: "sleep-drill-down", + }, + }); + return apiSuccess({ + measurements, + meta: { total: measurements.length, limit, offset: 0, dayKey }, + }); + } + + // Per-night collapse — one synthetic row per wake day. The headline + // value is the MAIN night's TIME ASLEEP (the longest session that day), + // per the nap convention: naps are surfaced as a count, never folded + // into the main night's headline. Reconstruct sessions and pick the + // main + naps per wake day. + const sessions = reconstructSleepSessions(rows, tz, priorityJson); + const byDay = new Map(); + for (const s of sessions) { + const list = byDay.get(s.night) ?? []; + list.push(s); + byDay.set(s.night, list); + } + + const dayUnit = getUnitForType("SLEEP_DURATION"); + const nightRows: Array<{ + id: string; + type: string; + value: number; + unit: string; + source: string; + measuredAt: string; + notes: null; + dayKey: string; + sampleCount: number; + sleepStages: Record; + inBedMinutes: number | null; + awakeMinutes: number | null; + napCount: number; + napAsleepMinutes: number; + awakenings: number; + }> = []; + for (const [day, daySessions] of byDay) { + const { main, naps } = pickMainNightAndNaps(daySessions); + if (!main) continue; + nightRows.push({ + // Synthetic id mirrors the series route's `sleep:` shape. + id: `sleep:${day}`, + type: "SLEEP_DURATION", + value: Math.round(main.asleepMinutes), + unit: dayUnit, + source: main.source ?? "MANUAL", + measuredAt: main.end.toISOString(), + notes: null, + dayKey: day, + // Drives the drill-down chevron — number of stage segments behind + // the night, matching the cumulative-type `sampleCount` contract. + sampleCount: main.segments.length, + sleepStages: main.stages as Record, + inBedMinutes: main.inBedMinutes, + awakeMinutes: main.awakeMinutes, + napCount: naps.length, + napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0), + awakenings: main.awakenings, + }); + } + + const measurements = nightRows + .sort((a, b) => { + const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0; + return sortDir === "asc" ? cmp : -cmp; + }) + .slice(0, limit); + + annotate({ + action: { name: "measurement.list" }, + meta: { + total: measurements.length, + type: "SLEEP_DURATION", + mode: "sleep-per-night", + rowsScanned: rows.length, + }, + }); + return apiSuccess({ + measurements, + meta: { total: nightRows.length, limit, offset: 0, groupBy: "night" }, + }); +} + export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement)); async function postMeasurement(request: NextRequest) { diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 60019e5da..f1f5bc938 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -92,6 +92,51 @@ interface Measurement { */ dayKey?: string; sampleCount?: number; + /** + * v1.11.5 — present only on per-night SLEEP_DURATION rows. `value` is + * the night's TIME ASLEEP in minutes; these fields carry the night's + * context so the row reads as one night, not many stage values. + */ + sleepStage?: string | null; + napCount?: number; + napAsleepMinutes?: number; + awakenings?: number; +} + +/** + * v1.11.5 — format a minutes total as an "8h 12m" / "8 Std. 12 Min." + * sleep headline. + */ +function formatSleepMinutes(total: number, locale: string): string { + const hours = Math.floor(total / 60); + const mins = Math.round(total - hours * 60); + if (locale === "de") { + return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`; + } + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; +} + +/** v1.11.5 — i18n labels for the sleep stages shown in the drill-down. */ +function sleepStageLabel( + stage: string | null | undefined, + t: ReturnType["t"], +): string { + switch (stage) { + case "DEEP": + return t("insights.sleep.stages.deep"); + case "REM": + return t("insights.sleep.stages.rem"); + case "CORE": + return t("insights.sleep.stages.core"); + case "ASLEEP": + return t("insights.sleep.stages.asleep"); + case "AWAKE": + return t("insights.sleep.stages.awake"); + case "IN_BED": + return t("insights.sleep.stages.inBed"); + default: + return stage ?? ""; + } } interface MeasurementListProps { @@ -166,7 +211,7 @@ export function MeasurementList({ onAddFirst, lockedType, }: MeasurementListProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); @@ -235,6 +280,12 @@ export function MeasurementList({ // `.planning/research/v1437-step-aggregation.md` §"How the leaders // do it" — never list per-sample chunks as the default view. const isCumulativeFilter = CUMULATIVE_TYPES.has(typeFilter); + // v1.11.5 — SLEEP_DURATION is collapsed server-side to one row per + // night (TIME ASLEEP), so the list paints per-night rows with a + // chevron drilling into the night's stage segments. Like the + // cumulative branch it returns a single page of synthesised rows. + const isSleepFilter = typeFilter === "SLEEP_DURATION"; + const isDayGroupedFilter = isCumulativeFilter || isSleepFilter; const { data, isLoading } = useQuery({ queryKey: [ @@ -243,7 +294,7 @@ export function MeasurementList({ page, sortBy, sortDir, - isCumulativeFilter ? "groupBy=day" : "raw", + isSleepFilter ? "sleep-night" : isCumulativeFilter ? "groupBy=day" : "raw", ], queryFn: async () => { const params = new URLSearchParams(); @@ -267,6 +318,14 @@ export function MeasurementList({ // the offset so a sort-direction flip doesn't paginate into // an empty slice. params.set("offset", "0"); + } else if (isSleepFilter) { + // v1.11.5 — the route auto-collapses SLEEP_DURATION to one row + // per night (no `groupBy` param needed). Request a wide limit + // so the per-night list spans the trailing year the route + // bounds the read to; offset doesn't apply to the synthesised + // rows. + params.set("limit", "366"); + params.set("offset", "0"); } const res = await fetch(`/api/measurements?${params}`); if (!res.ok) throw new Error("Failed to fetch"); @@ -357,7 +416,7 @@ export function MeasurementList({ // path collapses to a single page. Per-sample lists continue to // paginate at PAGE_SIZE = 25. const totalPages = - data && !isCumulativeFilter + data && !isDayGroupedFilter ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; @@ -554,6 +613,11 @@ export function MeasurementList({ {data.measurements.map((m) => { const isGrouped = m.dayKey !== undefined && m.sampleCount !== undefined; + // v1.11.5 — sleep night rows are grouped (chevron + // drills into the stage segments) but render a + // sleep-aware headline + nap caption, not the + // cumulative "daily total" caption. + const isSleep = m.type === "SLEEP_DURATION"; const isExpanded = isGrouped ? expandedDayKeys.has(m.dayKey as string) : false; @@ -585,19 +649,30 @@ export function MeasurementList({ cumulative-type day aggregates (steps, distance, etc.) and stay on `fmt.integer` because the underlying readings are - integer-only by definition. */} - {isGrouped - ? fmt.integer(m.value) - : fmt.number(m.value)}{" "} - {m.unit} - {isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer( - m.sampleCount as number, - ), - })} - + integer-only by definition. + v1.11.5 — sleep rows render the night's + TIME ASLEEP as "8h 12m" + a nap caption. */} + {isSleep ? ( + <> + {formatSleepMinutes(m.value, locale)} + + + ) : ( + <> + {isGrouped + ? fmt.integer(m.value) + : fmt.number(m.value)}{" "} + {m.unit} + {isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer( + m.sampleCount as number, + ), + })} + + )} + )} {/* @@ -700,6 +775,7 @@ export function MeasurementList({ const Icon = TYPE_ICONS[m.type]; const isGrouped = m.dayKey !== undefined && m.sampleCount !== undefined; + const isSleep = m.type === "SLEEP_DURATION"; const isExpanded = isGrouped ? expandedDayKeys.has(m.dayKey as string) : false; @@ -740,18 +816,29 @@ export function MeasurementList({ grouped daily aggregates stay integer, non-grouped single readings render with their native decimal precision so - "78.4 kg" no longer truncates to "78". */} - {isGrouped - ? fmt.integer(m.value) - : fmt.number(m.value)}{" "} - {m.unit} + "78.4 kg" no longer truncates to "78". + v1.11.5 — sleep rows render TIME ASLEEP. */} + {isSleep ? ( + formatSleepMinutes(m.value, locale) + ) : ( + <> + {isGrouped + ? fmt.integer(m.value) + : fmt.number(m.value)}{" "} + {m.unit} + + )} - {isGrouped && ( - - {t("measurements.dailyTotalCaption", { - count: fmt.integer(m.sampleCount as number), - })} - + {isSleep ? ( + + ) : ( + isGrouped && ( + + {t("measurements.dailyTotalCaption", { + count: fmt.integer(m.sampleCount as number), + })} + + ) )}

{/* @@ -1080,7 +1167,7 @@ function DayDrillDown({ unit: string; layout: "desktop" | "mobile"; }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const { isAuthenticated } = useAuth(); const { data, isLoading, error } = useQuery({ queryKey: ["measurement-drilldown", type, dayKey], @@ -1125,6 +1212,9 @@ function DayDrillDown({ ); } + // v1.11.5 — for SLEEP_DURATION the drill-down rows are stage segments; + // render the stage label + minutes instead of a bare value + unit. + const isSleep = type === "SLEEP_DURATION"; if (layout === "desktop") { return (

@@ -1134,10 +1224,10 @@ function DayDrillDown({ className="flex items-center justify-between gap-3 px-2 py-1 text-xs" > - {formatDateTime(s.measuredAt)} + {isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)} - {s.value} {unit} + {isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`}
))} @@ -1152,10 +1242,10 @@ function DayDrillDown({ className="flex items-center justify-between gap-2 px-1 py-1 text-xs" > - {formatDateTime(s.measuredAt)} + {isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)} - {s.value} {unit} + {isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`} ))} @@ -1163,6 +1253,32 @@ function DayDrillDown({ ); } +/** + * v1.11.5 — sub-caption for a per-night sleep row: surfaces the nap count + * (naps are separate from the main night per the nap convention) and the + * night's mid-sleep awakenings when present. + */ +function SleepNightCaption({ m }: { m: Measurement }) { + const { t } = useTranslations(); + const napCount = m.napCount ?? 0; + const awakenings = m.awakenings ?? 0; + const parts: string[] = []; + if (napCount > 0) { + parts.push(t("measurements.sleepNapCaption", { count: String(napCount) })); + } + if (awakenings > 0) { + parts.push( + t("measurements.sleepAwakeningsCaption", { count: String(awakenings) }), + ); + } + if (parts.length === 0) return null; + return ( + + {parts.join(" · ")} + + ); +} + function SortableHead({ column, label,