mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(sleep): collapse the measurements list to one row per night
SLEEP_DURATION is stored one row per stage per night, so a plain list query leaked every stage segment as its own value. Route a sleep list through the canonical per-night aggregation: one row per wake day carrying the night's TIME ASLEEP (the main session's asleep total per the nap convention), with a chevron drilling into that night's stage segments. The drill-down renders stage labels + per-stage minutes, and the night row captions same-day naps and mid-sleep awakenings. API: a `?type=SLEEP_DURATION` list now returns synthetic per-night rows (value = time asleep in minutes); `?dayKey=` returns the night's stage segments. Additive — no existing client requested per-stage sleep rows as a feature. Storage is untouched. New i18n keys: measurements.sleepNapCaption + sleepAwakeningsCaption in all six locales.
This commit is contained in:
1 parent
d5e67307e5
commit
5c863a0bcc
8 files changed
+365
-32
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<typeof s> => 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<string, typeof sessions>();
|
||||
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<string, number>;
|
||||
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:<night>` 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<string, number>,
|
||||
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) {
|
||||
|
||||
@@ -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<typeof useTranslations>["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 && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(
|
||||
m.sampleCount as number,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
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)}
|
||||
<SleepNightCaption m={m} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(m.value)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(
|
||||
m.sampleCount as number,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
{/*
|
||||
@@ -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}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-[11px]">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(m.sampleCount as number),
|
||||
})}
|
||||
</span>
|
||||
{isSleep ? (
|
||||
<SleepNightCaption m={m} />
|
||||
) : (
|
||||
isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-[11px]">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
count: fmt.integer(m.sampleCount as number),
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{/*
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 (
|
||||
<div className="bg-muted/40 space-y-1 p-2">
|
||||
@@ -1134,10 +1224,10 @@ function DayDrillDown({
|
||||
className="flex items-center justify-between gap-3 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDateTime(s.measuredAt)}
|
||||
{isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{s.value} {unit}
|
||||
{isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -1152,10 +1242,10 @@ function DayDrillDown({
|
||||
className="flex items-center justify-between gap-2 px-1 py-1 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDateTime(s.measuredAt)}
|
||||
{isSleep ? sleepStageLabel(s.sleepStage, t) : formatDateTime(s.measuredAt)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{s.value} {unit}
|
||||
{isSleep ? formatSleepMinutes(s.value, locale) : `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -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 (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
column,
|
||||
label,
|
||||
|
||||
Reference in new issue
Block a user