chore(release): v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork

The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.

Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.

Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
This commit is contained in:
Marc-André Bombeck committed 2026-07-11 00:18:23 +02:00
1 parent 755da41019
commit c400e2aa88
29 files changed
+1585 -276

No files matched your search

@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { SleepSourceDiscrepancyMarker } from "@/components/insights/sleep-source-discrepancy-marker";
import { I18nProvider } from "@/lib/i18n/context";
/**
@@ -210,6 +211,52 @@ describe("<TrendCard> value is never truncated", () => {
});
});
describe("<TrendCard> valueAdornment slot", () => {
// v1.28.x — the dashboard sleep tile passes the discreet source-
// discrepancy marker into the value row when the server flagged two
// writers with clearly different totals for the latest night.
const baseProps = {
label: "Sleep",
latest: 10.25,
unit: "h",
avg7: 7.5,
avg30: 7.4,
slope30: null,
icon: Activity,
};
it("renders the sleep source-discrepancy marker next to the value when present", () => {
const html = render(
<TrendCard
{...baseProps}
valueAdornment={
<SleepSourceDiscrepancyMarker
discrepancy={{
deltaMinutes: 160,
sources: [
{ source: "WITHINGS", deviceType: null, asleepMinutes: 615 },
{
source: "APPLE_HEALTH",
deviceType: null,
asleepMinutes: 455,
},
],
}}
/>
}
/>,
);
expect(html).toContain('data-slot="trend-card-value-adornment"');
expect(html).toContain('data-slot="sleep-source-discrepancy"');
});
it("renders no adornment slot when the annotation is absent", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).not.toContain('data-slot="trend-card-value-adornment"');
expect(html).not.toContain('data-slot="sleep-source-discrepancy"');
});
});
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
// v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime`
+21
View File
@@ -99,6 +99,14 @@ interface TrendCardProps {
* height.
*/
emptyHint?: string | null;
/**
* v1.28.x — optional adornment rendered directly after the unit in the
* value row (before the trend-arrow slot). The dashboard sleep tile
* passes the discreet source-discrepancy marker here so the hint sits
* next to the number it annotates. Absent → the row renders exactly as
* before (no reserved space, no layout shift).
*/
valueAdornment?: React.ReactNode;
}
export function TrendCard({
@@ -120,6 +128,7 @@ export function TrendCard({
compareDelta = null,
staleDays = null,
emptyHint = null,
valueAdornment = null,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -274,6 +283,18 @@ export function TrendCard({
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{unit}
</span>
{/* v1.28.x — optional inline adornment (e.g. the sleep tile's
source-discrepancy marker). `self-center` keeps the icon
vertically centred in the row without disturbing the
baseline alignment of the value + unit pair. */}
{valueAdornment != null ? (
<span
className="inline-flex shrink-0 self-center"
data-slot="trend-card-value-adornment"
>
{valueAdornment}
</span>
) : null}
</>
)}
<span
+14 -117
View File
@@ -10,16 +10,13 @@ import {
YAxis,
} from "recharts";
import { TriangleAlert } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TileHeader } from "@/components/insights/tile-header";
import {
SleepSourceDiscrepancyMarker,
formatMinutes,
type SleepSourceDiscrepancyDto,
} from "@/components/insights/sleep-source-discrepancy-marker";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { hourCycleOptions } from "@/lib/format-locale";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
@@ -85,14 +82,7 @@ export interface SleepHypnogramSession {
* headline total stays the winning writer's). Renders as a discreet
* marker with a per-source breakdown in a tooltip.
*/
sourceDiscrepancy?: {
deltaMinutes: number;
sources: {
source: string;
deviceType: string | null;
asleepMinutes: number;
}[];
} | null;
sourceDiscrepancy?: SleepSourceDiscrepancyDto | null;
}
export interface SleepHypnogramProps {
@@ -125,69 +115,9 @@ const BREAKDOWN_ORDER = [
"IN_BED",
] as const;
function formatMinutes(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`;
}
/**
* Localised label keys per `MeasurementSource` — the same map the
* source-priority settings surface uses (`sources-section.tsx`), so both
* surfaces humanise a source identically.
*/
const SOURCE_LABEL_KEYS: Record<string, string> = {
WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS",
APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH",
MANUAL: "settings.sections.sources.sourceLabels.MANUAL",
IMPORT: "settings.sections.sources.sourceLabels.IMPORT",
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
/**
* Brand names for sources without a localised settings label — brand
* spellings are locale-invariant, so no i18n key is needed for them.
*/
const SOURCE_BRAND_NAMES: Record<string, string> = {
OURA: "Oura",
POLAR: "Polar",
GOOGLE_HEALTH: "Google Health",
NIGHTSCOUT: "Nightscout",
STRAVA: "Strava",
TELEGRAM: "Telegram",
MCP: "MCP",
};
/**
* Localised device-type label keys (`Measurement.deviceType`), reused from
* the source-priority settings surface. Appended in parentheses so two
* writer apps behind the same source (watch vs phone under Apple Health)
* stay distinguishable in the discrepancy tooltip.
*/
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
band: "settings.sections.sources.deviceLabels.band",
ring: "settings.sections.sources.deviceLabels.ring",
phone: "settings.sections.sources.deviceLabels.phone",
scale: "settings.sections.sources.deviceLabels.scale",
other: "settings.sections.sources.deviceLabels.other",
};
function sourceDisplayName(
source: string,
deviceType: string | null,
t: (key: string) => string,
): string {
const labelKey = SOURCE_LABEL_KEYS[source];
const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source);
const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined;
return deviceKey ? `${base} (${t(deviceKey)})` : base;
}
// v1.28.x — `formatMinutes`, the source/device label maps, and the
// discrepancy marker itself moved to `sleep-source-discrepancy-marker.tsx`
// so the dashboard sleep tile renders the identical hint.
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
@@ -335,44 +265,11 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
{/* Discreet source-discrepancy marker: two writers reported
clearly different totals for this night. Observational — the
headline stays the winning writer's number; the tooltip lists
each source's own total. Muted, no colour wash, no banner;
negative margins keep the 44 px hit target from growing the
caption line (same trick as measurement-diversity-nudge). */}
{session.sourceDiscrepancy ? (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="sleep-source-discrepancy"
aria-label={t(
"insights.sleep.sourceDiscrepancy.tooltipTitle",
)}
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="sleep-source-discrepancy-body"
align="start"
className="max-w-xs leading-relaxed"
>
<p className="font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</p>
<p>
{session.sourceDiscrepancy.sources
.map(
(b) =>
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
)
.join(" · ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
each source's own total. Shared with the dashboard sleep
tile via `sleep-source-discrepancy-marker.tsx`. */}
<SleepSourceDiscrepancyMarker
discrepancy={session.sourceDiscrepancy}
/>
</span>
{/* The night's measuring source rides the header as a muted
caption — it is already on the wire, and "which device
@@ -0,0 +1,148 @@
"use client";
import { TriangleAlert } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.28.x — the discreet "sources disagree" marker, extracted from
* `sleep-hypnogram.tsx` (which introduced it in v1.28.21) so the dashboard
* sleep tile and the hypnogram render the identical hint off the identical
* wire shape. Observational only: the headline number always stays the
* winning writer's total; the tooltip lists each writer's own total so a
* multi-tracker user can see WHY the night reads surprising.
*/
/** Wire shape of the server-computed annotation (rounded minutes). */
export interface SleepSourceDiscrepancyDto {
deltaMinutes: number;
sources: {
source: string;
deviceType: string | null;
asleepMinutes: number;
}[];
}
export function formatMinutes(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`;
}
/**
* Localised label keys per `MeasurementSource` — the same map the
* source-priority settings surface uses (`sources-section.tsx`), so both
* surfaces humanise a source identically.
*/
const SOURCE_LABEL_KEYS: Record<string, string> = {
WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS",
APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH",
MANUAL: "settings.sections.sources.sourceLabels.MANUAL",
IMPORT: "settings.sections.sources.sourceLabels.IMPORT",
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
/**
* Brand names for sources without a localised settings label — brand
* spellings are locale-invariant, so no i18n key is needed for them.
*/
const SOURCE_BRAND_NAMES: Record<string, string> = {
OURA: "Oura",
POLAR: "Polar",
GOOGLE_HEALTH: "Google Health",
NIGHTSCOUT: "Nightscout",
STRAVA: "Strava",
TELEGRAM: "Telegram",
MCP: "MCP",
};
/**
* Localised device-type label keys (`Measurement.deviceType`), reused from
* the source-priority settings surface. Appended in parentheses so two
* writer apps behind the same source (watch vs phone under Apple Health)
* stay distinguishable in the discrepancy tooltip.
*/
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
band: "settings.sections.sources.deviceLabels.band",
ring: "settings.sections.sources.deviceLabels.ring",
phone: "settings.sections.sources.deviceLabels.phone",
scale: "settings.sections.sources.deviceLabels.scale",
other: "settings.sections.sources.deviceLabels.other",
};
export function sourceDisplayName(
source: string,
deviceType: string | null,
t: (key: string) => string,
): string {
const labelKey = SOURCE_LABEL_KEYS[source];
const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source);
const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined;
return deviceKey ? `${base} (${t(deviceKey)})` : base;
}
export interface SleepSourceDiscrepancyMarkerProps {
discrepancy: SleepSourceDiscrepancyDto | null | undefined;
/** Tooltip alignment relative to the trigger (default `"start"`). */
align?: "start" | "center" | "end";
}
/**
* Discreet source-discrepancy marker: two writers reported clearly
* different totals for the night. Muted, no colour wash, no banner;
* negative margins keep the 44 px hit target from growing the host line
* (same trick as measurement-diversity-nudge). Renders nothing when the
* annotation is absent — no layout shift on the ordinary path.
*/
export function SleepSourceDiscrepancyMarker({
discrepancy,
align = "start",
}: SleepSourceDiscrepancyMarkerProps) {
const { t, locale } = useTranslations();
if (!discrepancy) return null;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="sleep-source-discrepancy"
aria-label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="sleep-source-discrepancy-body"
align={align}
className="max-w-xs leading-relaxed"
>
<p className="font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</p>
<p>
{discrepancy.sources
.map(
(b) =>
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
)
.join(" · ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}