mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Close the gap between the welcome carousel's AI promise and the payoff: a fresh keyless user used to meet "No AI provider connected" on every AI surface, because the only setup prompt was a skippable card on the final screen. Add an "insights" row to the getting-started checklist, derived from real provider state — any personal key, local model, OAuth sign-in, or the operator's shared key flips it done via /api/user/ai-provider. It persists past onboarding, self-satisfies on connect, and dismisses like the others. Elevate the done-screen AI card into a value-first panel: it leads with a static, clearly-labelled example briefing (no model call, no egress, no consent), then the local-first ladder — local model, bring-your-own-key, subscription sign-in with the training caveat — and the honest line that HealthLog is fully useful without AI. All three exits stay, so the panel gates nothing. On a deployment with a shared provider it says so plainly. Extract the worked example into a shared SampleBriefingCard. Add onboarding.ai.* copy across all six locales and retire the old single-card done-screen keys.
199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
/**
|
|
* Pure progression logic for the v1.4 dashboard "Getting started"
|
|
* checklist. Decoupled from React so it can be unit-tested without a
|
|
* DOM. The component layer (`<GettingStartedChecklist>`) reads inputs
|
|
* (auth user + analytics summaries + medications count + Withings
|
|
* status + dismissed-ids set) and renders against the result.
|
|
*/
|
|
|
|
export const CHECKLIST_ITEM_IDS = [
|
|
"profile",
|
|
"measurement",
|
|
"medication",
|
|
"dataSource",
|
|
"notifications",
|
|
"insights",
|
|
] as const;
|
|
|
|
export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number];
|
|
|
|
export interface ChecklistItem {
|
|
id: ChecklistItemId;
|
|
done: boolean;
|
|
/**
|
|
* Pre-built href the CTA should jump to. The settings entries point at
|
|
* the v1.4 split routes under `/settings/[section]` (introduced by PR
|
|
* A2-shell), so the CTA lands directly on the right section instead of
|
|
* relying on a hash anchor on the legacy monolith.
|
|
*/
|
|
href: string;
|
|
/** True if the user explicitly hid this row. */
|
|
dismissed: boolean;
|
|
}
|
|
|
|
export interface ChecklistInputs {
|
|
/** Profile completeness — height + dateOfBirth + gender all set. */
|
|
profile: {
|
|
heightCm: number | null;
|
|
dateOfBirth: string | null;
|
|
gender: string | null;
|
|
};
|
|
/** Total measurements logged across all types. */
|
|
measurementCount: number;
|
|
/** Number of medications the user has created. */
|
|
medicationCount: number;
|
|
/**
|
|
* True iff at least one data source is connected — Withings, WHOOP,
|
|
* Oura, Polar, Nightscout, Fitbit, or Apple Health. Any one satisfies
|
|
* the "connect a data source" step.
|
|
*/
|
|
dataSourceConnected: boolean;
|
|
/**
|
|
* True iff the user has set up at least one notification channel
|
|
* (Telegram, ntfy, or Web Push).
|
|
*/
|
|
notificationsConfigured: boolean;
|
|
/**
|
|
* True iff any AI provider can serve this user — a personal key,
|
|
* a local model, an OAuth sign-in, OR the operator's shared key.
|
|
* Derived from `/api/user/ai-provider`'s `aiAvailable`, so the row
|
|
* self-satisfies the moment insights become reachable (including on a
|
|
* deployment that ships a shared operator key). Presence-only — it
|
|
* never decrypts a credential or probes liveness.
|
|
*/
|
|
insightsConfigured: boolean;
|
|
/** Dismissed item ids (per-item localStorage state). */
|
|
dismissedIds: ReadonlySet<ChecklistItemId>;
|
|
}
|
|
|
|
/**
|
|
* Compute the ordered checklist for the dashboard hero. Stable order:
|
|
* profile → measurement → medication → dataSource → notifications →
|
|
* insights. Each item carries the deep-link the row's CTA should
|
|
* navigate to.
|
|
*/
|
|
export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
|
|
const profileDone = isProfileComplete(inputs.profile);
|
|
const items: ChecklistItem[] = [
|
|
{
|
|
id: "profile",
|
|
done: profileDone,
|
|
href: "/settings/account",
|
|
dismissed: inputs.dismissedIds.has("profile"),
|
|
},
|
|
{
|
|
id: "measurement",
|
|
done: inputs.measurementCount >= 1,
|
|
href: "/measurements",
|
|
dismissed: inputs.dismissedIds.has("measurement"),
|
|
},
|
|
{
|
|
id: "medication",
|
|
done: inputs.medicationCount >= 1,
|
|
href: "/medications",
|
|
dismissed: inputs.dismissedIds.has("medication"),
|
|
},
|
|
{
|
|
id: "dataSource",
|
|
done: inputs.dataSourceConnected,
|
|
href: "/settings/integrations",
|
|
dismissed: inputs.dismissedIds.has("dataSource"),
|
|
},
|
|
{
|
|
id: "notifications",
|
|
done: inputs.notificationsConfigured,
|
|
href: "/settings/notifications",
|
|
dismissed: inputs.dismissedIds.has("notifications"),
|
|
},
|
|
{
|
|
id: "insights",
|
|
done: inputs.insightsConfigured,
|
|
href: "/settings/ai",
|
|
dismissed: inputs.dismissedIds.has("insights"),
|
|
},
|
|
];
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* The checklist visible to the user — drops dismissed items and items
|
|
* already done **and** hidden by completion. We keep done items
|
|
* visible until the user completes the whole list, so they get the
|
|
* satisfaction of ticking the last box.
|
|
*/
|
|
export function visibleChecklist(items: ChecklistItem[]): ChecklistItem[] {
|
|
return items.filter((item) => !item.dismissed);
|
|
}
|
|
|
|
export interface ChecklistProgress {
|
|
total: number;
|
|
done: number;
|
|
/** Integer percentage 0-100. */
|
|
percent: number;
|
|
/** True iff every non-dismissed item is done. */
|
|
allDone: boolean;
|
|
}
|
|
|
|
export function checklistProgress(items: ChecklistItem[]): ChecklistProgress {
|
|
const visible = visibleChecklist(items);
|
|
const total = visible.length;
|
|
const done = visible.filter((item) => item.done).length;
|
|
const percent = total === 0 ? 100 : Math.round((done / total) * 100);
|
|
return { total, done, percent, allDone: total > 0 && done === total };
|
|
}
|
|
|
|
/**
|
|
* Should the dashboard render the checklist at all?
|
|
*
|
|
* Visible while the user is still in the setup phase:
|
|
* - `onboardingCompletedAt` is null OR
|
|
* - they have fewer than 5 measurements
|
|
*
|
|
* AND the user has not dismissed the entire checklist
|
|
* AND there is at least one undone, non-dismissed item.
|
|
*/
|
|
export function shouldShowChecklist(args: {
|
|
onboardingCompletedAt: string | null;
|
|
measurementCount: number;
|
|
dismissedAll: boolean;
|
|
items: ChecklistItem[];
|
|
}): boolean {
|
|
if (args.dismissedAll) return false;
|
|
const stillInSetup =
|
|
args.onboardingCompletedAt == null || args.measurementCount < 5;
|
|
if (!stillInSetup) return false;
|
|
const visible = visibleChecklist(args.items);
|
|
if (visible.length === 0) return false;
|
|
return visible.some((item) => !item.done);
|
|
}
|
|
|
|
/**
|
|
* Profile is "complete" once height, date of birth and gender are all
|
|
* set. Display name is captured automatically at signup, so it doesn't
|
|
* gate this item.
|
|
*/
|
|
export function isProfileComplete(
|
|
profile: ChecklistInputs["profile"],
|
|
): boolean {
|
|
return (
|
|
profile.heightCm != null &&
|
|
profile.heightCm > 0 &&
|
|
profile.dateOfBirth != null &&
|
|
profile.gender != null &&
|
|
profile.gender !== ""
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Trend hint: when the user has between 1 and 4 readings of a metric we
|
|
* surface "First trend after 5 readings — N more to go". 0 readings is
|
|
* handled by the existing chart empty-state. ≥5 readings hides the
|
|
* hint entirely.
|
|
*/
|
|
export function trendHintFor(
|
|
count: number,
|
|
): { kind: "hidden" } | { kind: "show"; remaining: number } {
|
|
if (count < 1 || count >= 5) return { kind: "hidden" };
|
|
return { kind: "show", remaining: 5 - count };
|
|
}
|