Merge branch 'v143/ios' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 00:28:40 +02:00
commit 3fc37c9183
78 files changed
+5928 -489

No files matched your search

+59 -9
View File
@@ -23,6 +23,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import {
cycleDependentKeys,
@@ -318,10 +319,33 @@ export interface CycleDayLogPatch {
note?: string | null;
}
/**
* A period start the server folded in or gave back (v1.43). The server names
* it; the toast only says it, so the person learns which date moved without
* comparing the calendar before and after.
*/
export interface CycleStartRef {
cycleId: string;
startDate: string;
}
/** The toast after a write that may have given starts back. */
function useRestoredStartsMessage() {
const { t } = useTranslations();
const formatDay = useCalendarDate();
return (fallback: string, restored: readonly CycleStartRef[] | undefined) =>
restored && restored.length > 0
? t("cycle.startRestored", {
dates: restored.map((r) => formatDay(r.startDate)).join(", "),
})
: fallback;
}
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async ({
id,
@@ -330,11 +354,17 @@ export function usePatchDayLog() {
id: string;
patch: CycleDayLogPatch;
}) => {
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
`/api/cycle/day-logs/${id}`,
patch,
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.saveSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.saveSuccess"), data?.restored),
);
},
});
}
@@ -342,17 +372,26 @@ export function usePatchDayLog() {
export function useStartPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
const formatDay = useCalendarDate();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<unknown>("/api/cycle/period", {
return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", {
action: "start",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: () => {
onSuccess: (data, date) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.periodStartSaved"));
toastWrittenOutcome(
"success",
data?.absorbed
? t("cycle.startAbsorbed", {
absorbed: formatDay(data.absorbed.startDate),
date: formatDay(date),
})
: t("cycle.periodStartSaved"),
);
},
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
@@ -386,13 +425,24 @@ export function useEndPeriod() {
export function useDeleteDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/cycle/day-logs/${id}`);
// Ask for what the delete changed: removing a start day can give back a
// start it had folded in, and the toast names it. A server that does
// not know the preference answers 204, read as nothing given back.
return apiDelete<{ restored?: CycleStartRef[] } | null>(
`/api/cycle/day-logs/${id}`,
undefined,
{ headers: { Prefer: "return=representation" } },
);
},
onSuccess: () => {
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.deleteSuccess"), data?.restored),
);
},
onError: (failure) =>
showErrorToast(t("cycle.deleteError"), { cause: failure }),
@@ -28,7 +28,6 @@ import {
} from "./use-dashboard-derived";
import { SAME_TIME_BASELINE_MIN_HISTORY_DAYS } from "@/lib/insights/derived/registry";
import { resolveTileLayout, type InsightsLayout } from "@/lib/insights-layout";
import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment";
// Type-only — the compute payloads never drag the server graph into the bundle.
import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline";
import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age";
@@ -64,26 +63,6 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types";
// moved to `use-dashboard-derived.ts` so the page can own the one shared batch
// and feed it to both the wellness strip and this grid. Imported above.
/**
* Up-is-bad for the vitals where a rise is unfavourable. Weight is not one of
* them: which way it should move depends on the person's own target (#1006,
* `src/lib/targets/weight-trend.ts`), and this grid has no target in hand, so
* it stays neutral rather than assuming a fall is good.
*/
const UP_BAD_VITALS = new Set([
"RESTING_HEART_RATE",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
]);
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
return "neutral";
}
interface DashboardProps {
/**
* The shared overview derived batch (owned by the page so the wellness
@@ -306,7 +285,10 @@ function BaselineTile({
icon={Icon}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
// v1.43 — resolved on the server against the person's own target
// or profile default; an older server sends none and the tile
// stays neutral rather than assuming a polarity.
directionSentiment={v.direction?.direction ?? "neutral"}
provenance={
// v1.29.1 muted this on VITALS_BASELINE tiles because the always-on
// "typical range = median ± MAD" caption repeated across every vital
@@ -30,6 +30,7 @@ import {
Gauge,
Grid3x3,
Link2,
Repeat,
Tag,
Tags,
TrendingUp,
@@ -110,6 +111,7 @@ import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
import { MoodPatterns } from "./mood-patterns";
export function MoodInsightsBreakdowns({
data,
@@ -151,6 +153,7 @@ export function MoodInsightsBreakdowns({
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const patterns = data.patterns ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
@@ -184,6 +187,14 @@ export function MoodInsightsBreakdowns({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.43 — the patterns the server found and gated; the card stays
away when none passed. */}
{patterns.length > 0 && (
<SectionCard title={t("insights.mood.patterns.title")} icon={Repeat}>
<MoodPatterns patterns={patterns} />
</SectionCard>
)}
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
@@ -30,6 +30,7 @@ import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
import type { MoodPatternRow } from "./mood-patterns";
export interface MoodInsightsResponse {
summary: {
@@ -65,6 +66,11 @@ export interface MoodInsightsResponse {
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
/**
* v1.43 — the gated patterns the server found over the requested window.
* Optional to tolerate a payload from before it existed.
*/
patterns?: MoodPatternRow[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
@@ -0,0 +1,111 @@
"use client";
/**
* v1.43 (ios #116 item 6) — the mood patterns the server found.
*
* Nothing here decides whether a pattern exists: every row is one the server
* published after its gate (eight days a side, Welch, false discovery
* control), computed on the mood day. This component only phrases each one.
* Rows render in the order delivered; a kind this build does not know is
* skipped rather than guessed at.
*/
import { useFormatters, useTranslations } from "@/lib/i18n/context";
export interface MoodPatternRow {
id: string;
kind: string;
strength: "weak" | "moderate" | "strong";
direction: "higher" | "lower" | "none";
values: {
subject: number;
comparison: number;
delta: number;
weekday?: number;
tagKey?: string;
tagLabel?: string;
};
sampleSize: { subject: number; comparison: number };
}
const WEEKDAY_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
"charts.weekdaysFull.wed",
"charts.weekdaysFull.thu",
"charts.weekdaysFull.fri",
"charts.weekdaysFull.sat",
"charts.weekdaysFull.sun",
] as const;
/** The sentence key per kind and direction; `none` cannot pass the gate. */
const SENTENCE_KEYS: Record<string, { higher: string; lower: string | null }> =
{
previous_day: {
higher: "insights.mood.patterns.previousDayHigher",
lower: "insights.mood.patterns.previousDayLower",
},
weekend: {
higher: "insights.mood.patterns.weekendHigher",
lower: "insights.mood.patterns.weekendLower",
},
best_weekday: {
higher: "insights.mood.patterns.bestWeekday",
lower: null,
},
notes: {
higher: "insights.mood.patterns.notesHigher",
lower: "insights.mood.patterns.notesLower",
},
delayed_tag: {
higher: "insights.mood.patterns.delayedTagHigher",
lower: "insights.mood.patterns.delayedTagLower",
},
multiple_entries: {
higher: "insights.mood.patterns.multipleEntriesHigher",
lower: "insights.mood.patterns.multipleEntriesLower",
},
};
export function MoodPatterns({ patterns }: { patterns: MoodPatternRow[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
const rows = patterns.flatMap((pattern) => {
const keys = SENTENCE_KEYS[pattern.kind];
if (!keys || pattern.direction === "none") return [];
const key = pattern.direction === "higher" ? keys.higher : keys.lower;
if (!key) return [];
const weekdayKey =
pattern.values.weekday !== undefined
? WEEKDAY_KEYS[pattern.values.weekday]
: undefined;
return [
{
id: pattern.id,
sentence: t(key, {
subject: fmt.number(pattern.values.subject, 1),
comparison: fmt.number(pattern.values.comparison, 1),
weekday: weekdayKey ? t(weekdayKey) : "",
tag: pattern.values.tagLabel ?? pattern.values.tagKey ?? "",
}),
basedOn: t("insights.mood.patterns.basedOn", {
subject: pattern.sampleSize.subject,
comparison: pattern.sampleSize.comparison,
}),
},
];
});
if (rows.length === 0) return null;
return (
<ul className="space-y-3" data-slot="mood-patterns">
{rows.map((row) => (
<li key={row.id} data-pattern-id={row.id} className="space-y-1">
<p className="text-sm">{row.sentence}</p>
<p className="text-muted-foreground text-xs">{row.basedOn}</p>
</li>
))}
</ul>
);
}
@@ -41,6 +41,17 @@ interface PersonalRecordRow {
value: number;
unit: string;
achievedAt: string;
/**
* v1.43 — the cited band for the age the record was set at, resolved on
* the server; null (or absent on an older server) when none applies.
*/
benchmark?: {
low: number;
high: number;
unit: string;
source: string;
sourceLabel: string;
} | null;
}
interface PersonalRecordBadgeProps {
@@ -146,6 +157,19 @@ export function PersonalRecordBadge({
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
{fresh.benchmark ? (
<span
className="text-muted-foreground block text-xs"
data-slot="insights-pr-badge-benchmark"
>
{t("insights.personalRecord.benchmark", {
low: fmt.number(fresh.benchmark.low, 1),
high: fmt.number(fresh.benchmark.high, 1),
unit: fresh.benchmark.unit,
source: fresh.benchmark.sourceLabel,
})}
</span>
) : null}
</TooltipContent>
</Tooltip>
);
@@ -474,6 +474,31 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
expect(out).not.toContain("Last in background");
});
// v1.43 (ios #77) — a history catch-up is named apart, and a type that
// only ever arrived through one still reads as not received live.
it("names the last backfill apart from the live arrival", () => {
const live = html({
...base,
lastReceivedAt: hoursAgo(1),
lastTrigger: "foreground",
lastNewSampleAt: hoursAgo(1),
lastBackfillAt: hoursAgo(3),
});
expect(live).toContain('data-state="received"');
expect(live).toContain("History caught up 3 h ago");
const backfillOnly = html({
...base,
lastReceivedAt: null,
lastTrigger: null,
lastNewSampleAt: null,
lastBackfillAt: hoursAgo(2),
});
expect(backfillOnly).toContain('data-state="never"');
expect(backfillOnly).toContain(
"Not received yet, History caught up 2 h ago",
);
});
it("names the medication dose row in the freshness list", () => {
statusPayload = {
lastSyncedAt: hoursAgo(1),
@@ -148,16 +148,25 @@ export function ArrivalDetail({
now?: Date;
}) {
if (entry.lastReceivedAt === undefined) return null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{t("settings.appleHealth.freshness.never")}
</span>
);
}
const reference = (now ?? new Date()).getTime();
const relative = (iso: string) =>
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
// v1.43 (ios #77) — a history catch-up is named apart from the live
// arrivals, and never counts as one.
const backfill = entry.lastBackfillAt
? t("settings.appleHealth.freshness.lastBackfill", {
relative: relative(entry.lastBackfillAt),
})
: null;
if (entry.lastReceivedAt === null) {
return (
<span data-testid="apple-health-arrival" data-state="never">
{[t("settings.appleHealth.freshness.never"), backfill]
.filter((part): part is string => part !== null)
.join(", ")}
</span>
);
}
const trigger = entry.lastTrigger ?? null;
const parts = [
t("settings.appleHealth.freshness.lastReceived", {
@@ -189,6 +198,7 @@ export function ArrivalDetail({
}),
);
}
if (backfill) parts.push(backfill);
return (
<span
data-testid="apple-health-arrival"