mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user