mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'v143/ios' into release/v1.43.0
This commit is contained in:
commit
3fc37c9183
78 files changed
+5928
-489
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user