Files
HealthLog/src/lib/onboarding/goals.ts
T
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00

113 lines
3.9 KiB
TypeScript

/**
* v1.17.1 — onboarding goal slugs + dashboard seeding.
*
* The closed set of goal slugs the GoalsChipPicker offers, plus the
* server-authoritative seeding that turns a goal selection into a
* personalization signal: which dashboard tiles get promoted to the
* top and made visible. Goals are NOT a clinical target store — the
* targets surface (`/api/insights/targets`) is fully derived from
* height/age/gender + clinical references. Their only leverage is the
* one-time dashboard tile pinning below, applied by
* `/api/onboarding/step` while the layout is still unset. Nothing else
* reads `User.onboardingGoals` — the column is collected, seeded from
* once, and then inert.
*
* One home for the slug enum so the API validation
* (`/api/onboarding/step`), the client picker, and the seeding all
* read the same source — same-key drift was the v1.4.16 dashboard-enum
* bug class.
*/
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
type DashboardLayout,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
export const ONBOARDING_GOAL_SLUGS = [
"weight-management",
"bp-tracking",
"glucose-tracking",
"sleep-improvement",
"medication-compliance",
"general-wellness",
] as const;
export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
export function isOnboardingGoalSlug(
value: unknown,
): value is OnboardingGoalSlug {
return (
typeof value === "string" &&
(ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
);
}
/**
* Slug → dashboard widget ids to promote. `general-wellness` maps to
* nothing — it carries no tile preference, so a general-wellness-only
* selection leaves the default layout untouched. Ids must be members
* of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
*/
export const GOAL_WIDGET_SEED_MAP: Record<
OnboardingGoalSlug,
readonly DashboardWidgetId[]
> = {
"weight-management": ["weight", "bodyFat"],
"bp-tracking": ["bp", "bpInTarget"],
"glucose-tracking": ["glucose"],
"sleep-improvement": ["sleep"],
"medication-compliance": ["medications"],
"general-wellness": [],
};
/**
* Build the seeded dashboard layout for a goal selection.
*
* Returns a layout where the goal-mapped tiles are promoted to the top
* of the order (preserving their relative order within the default
* layout) and forced visible on both surfaces; every other tile keeps
* its default config, shifted down. Returns `null` when there is
* nothing to promote (empty selection or general-wellness only) so the
* caller can skip the write entirely and leave the column null →
* default layout.
*
* This is a ONE-TIME seed: the caller MUST gate it on
* `User.dashboardWidgetsJson == null` so it never clobbers a user who
* already arranged tiles.
*/
export function buildGoalSeededDashboardLayout(
goals: readonly string[] | null | undefined,
): DashboardLayout | null {
if (!goals || goals.length === 0) return null;
const promoted = new Set<DashboardWidgetId>();
for (const slug of goals) {
if (!isOnboardingGoalSlug(slug)) continue;
for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
}
if (promoted.size === 0) return null;
const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
const promotedWidgets = base.filter((w) =>
promoted.has(w.id as DashboardWidgetId),
);
const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
...w,
// Force the goal-mapped tiles visible on both surfaces; leave the
// rest exactly as the default layout had them.
...(promoted.has(w.id as DashboardWidgetId)
? { visible: true, tileVisible: true }
: {}),
order: index,
}));
return serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: ordered,
});
}