mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Move the preventive-care surface from the German-derived slug /vorsorge to the English /checkups and make the feature read consistently across locales. The route directory, the nav entry, the dashboard card link, and the onboarding tour step now point at /checkups. A permanent redirect keeps old bookmarks, the PWA's cached navigation, and push deep-links resolving. Display label is now "Checkups" in English and the equivalent term in Spanish (Revisiones), French (Examens), Italian (Controlli), and Polish (Badania); German keeps "Vorsorge". The internal id stays "vorsorge" so saved layouts, module-order preferences, and tour state keep resolving without a data migration.
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
/**
|
|
* v1.4.15 Phase B5 — onboarding tour state model.
|
|
* v1.18.6 — rebuilt as a 15-stop module-by-module guided tour.
|
|
*
|
|
* Pure data + state-machine helpers for the spotlight tour. Kept
|
|
* DOM-free so vitest (Node environment, no jsdom) can exercise the
|
|
* navigation logic directly.
|
|
*
|
|
* The tour walks every module in the order data → intelligence →
|
|
* export, teaching each module's value, one hidden capability, and how
|
|
* it feeds the others. The UI layer (`src/components/onboarding/tour.tsx`)
|
|
* builds the step list once on mount and then drives navigation through
|
|
* these helpers.
|
|
*
|
|
* Stops anchor to UI targets via `data-tour-id` attributes (NOT
|
|
* class-name selectors — class names churn during Tailwind tweaks;
|
|
* `data-tour-id` is a stable contract). A stop on another route also
|
|
* carries a `route`; the overlay `router.push`es there, waits for the
|
|
* anchor to mount, then measures. A missing target at runtime falls
|
|
* back to a centred-screen tooltip so the tour never blocks.
|
|
*/
|
|
|
|
import type { ModuleKey } from "@/lib/modules/registry";
|
|
|
|
/** Stable IDs for every stop in the tour, in presentation order. */
|
|
export type TourStopId =
|
|
| "dashboardOverview"
|
|
| "quickAdd"
|
|
| "measurements"
|
|
| "medications"
|
|
| "labs"
|
|
| "illness"
|
|
| "vorsorge"
|
|
| "cycle"
|
|
| "mood"
|
|
| "insights"
|
|
| "coach"
|
|
| "integrations"
|
|
| "export"
|
|
| "achievements"
|
|
| "wrapUp";
|
|
|
|
/**
|
|
* One step in the tour. The `targetId` matches a `data-tour-id` on a
|
|
* live element; the title/body i18n keys live under
|
|
* `onboarding.tour.steps.<id>.*`.
|
|
*/
|
|
export interface TourStop {
|
|
id: TourStopId;
|
|
/**
|
|
* The `data-tour-id` value of the element to anchor the spotlight
|
|
* to. `null` for a purely informational centred stop (the wrap-up).
|
|
*/
|
|
targetId: string | null;
|
|
/**
|
|
* Preferred tooltip placement relative to the target. The UI layer
|
|
* may flip it if the target is too close to a viewport edge.
|
|
*/
|
|
placement: "top" | "bottom" | "left" | "right" | "center";
|
|
/** i18n key for the step title. */
|
|
titleKey: string;
|
|
/** i18n key for the step body. */
|
|
bodyKey: string;
|
|
/**
|
|
* v1.18.6 — the route this stop's anchor lives on. Absent ⇒ the
|
|
* anchor is on the current page (the wrap-up centred stop). When set,
|
|
* the overlay navigates there before measuring. `"/"` is explicit for
|
|
* the two dashboard stops so the resume path can route back from any
|
|
* page.
|
|
*/
|
|
route?: string;
|
|
/**
|
|
* v1.18.6 — module gate. When set, the stop is dropped unless the
|
|
* account's resolved module map has the key enabled (default-on:
|
|
* only an explicit `false` drops it, mirroring the nav gate).
|
|
*/
|
|
requiresModule?: ModuleKey;
|
|
}
|
|
|
|
/**
|
|
* v1.18.6 — the canonical 15-stop module tour. Each entry is a literal
|
|
* so the order, anchors, routes and gates are one auditable list.
|
|
*/
|
|
const ALL_STOPS: readonly TourStop[] = [
|
|
{
|
|
id: "dashboardOverview",
|
|
targetId: "dashboard-tile-strip",
|
|
placement: "bottom",
|
|
route: "/",
|
|
titleKey: "onboarding.tour.steps.dashboardOverview.title",
|
|
bodyKey: "onboarding.tour.steps.dashboardOverview.body",
|
|
},
|
|
{
|
|
id: "quickAdd",
|
|
targetId: "dashboard-quick-add",
|
|
placement: "bottom",
|
|
route: "/",
|
|
titleKey: "onboarding.tour.steps.quickAdd.title",
|
|
bodyKey: "onboarding.tour.steps.quickAdd.body",
|
|
},
|
|
{
|
|
id: "measurements",
|
|
targetId: "measurements-hero",
|
|
placement: "bottom",
|
|
route: "/measurements",
|
|
titleKey: "onboarding.tour.steps.measurements.title",
|
|
bodyKey: "onboarding.tour.steps.measurements.body",
|
|
},
|
|
{
|
|
id: "medications",
|
|
targetId: "medications-hero",
|
|
placement: "bottom",
|
|
route: "/medications",
|
|
requiresModule: "medications",
|
|
titleKey: "onboarding.tour.steps.medications.title",
|
|
bodyKey: "onboarding.tour.steps.medications.body",
|
|
},
|
|
{
|
|
id: "labs",
|
|
targetId: "labs-hero",
|
|
placement: "bottom",
|
|
route: "/labs",
|
|
requiresModule: "labs",
|
|
titleKey: "onboarding.tour.steps.labs.title",
|
|
bodyKey: "onboarding.tour.steps.labs.body",
|
|
},
|
|
{
|
|
id: "illness",
|
|
targetId: "illness-hero",
|
|
placement: "bottom",
|
|
route: "/illness",
|
|
requiresModule: "illness",
|
|
titleKey: "onboarding.tour.steps.illness.title",
|
|
bodyKey: "onboarding.tour.steps.illness.body",
|
|
},
|
|
{
|
|
id: "vorsorge",
|
|
targetId: "vorsorge-hero",
|
|
placement: "bottom",
|
|
route: "/checkups",
|
|
titleKey: "onboarding.tour.steps.vorsorge.title",
|
|
bodyKey: "onboarding.tour.steps.vorsorge.body",
|
|
},
|
|
{
|
|
id: "cycle",
|
|
targetId: "cycle-hero",
|
|
placement: "bottom",
|
|
route: "/cycle",
|
|
requiresModule: "cycle",
|
|
titleKey: "onboarding.tour.steps.cycle.title",
|
|
bodyKey: "onboarding.tour.steps.cycle.body",
|
|
},
|
|
{
|
|
id: "mood",
|
|
targetId: "mood-hero",
|
|
placement: "bottom",
|
|
route: "/mood",
|
|
requiresModule: "mood",
|
|
titleKey: "onboarding.tour.steps.mood.title",
|
|
bodyKey: "onboarding.tour.steps.mood.body",
|
|
},
|
|
{
|
|
id: "insights",
|
|
targetId: "insights-hero",
|
|
placement: "bottom",
|
|
route: "/insights",
|
|
requiresModule: "insights",
|
|
titleKey: "onboarding.tour.steps.insights.title",
|
|
bodyKey: "onboarding.tour.steps.insights.body",
|
|
},
|
|
{
|
|
id: "coach",
|
|
targetId: "coach-hero",
|
|
placement: "bottom",
|
|
route: "/coach",
|
|
requiresModule: "coach",
|
|
titleKey: "onboarding.tour.steps.coach.title",
|
|
bodyKey: "onboarding.tour.steps.coach.body",
|
|
},
|
|
{
|
|
id: "integrations",
|
|
targetId: "integrations-hero",
|
|
placement: "bottom",
|
|
route: "/settings/integrations",
|
|
titleKey: "onboarding.tour.steps.integrations.title",
|
|
bodyKey: "onboarding.tour.steps.integrations.body",
|
|
},
|
|
{
|
|
id: "export",
|
|
targetId: "export-hero",
|
|
placement: "bottom",
|
|
route: "/settings/gesundheitsakte",
|
|
titleKey: "onboarding.tour.steps.export.title",
|
|
bodyKey: "onboarding.tour.steps.export.body",
|
|
},
|
|
{
|
|
id: "achievements",
|
|
targetId: "achievements-hero",
|
|
placement: "bottom",
|
|
route: "/achievements",
|
|
requiresModule: "achievements",
|
|
titleKey: "onboarding.tour.steps.achievements.title",
|
|
bodyKey: "onboarding.tour.steps.achievements.body",
|
|
},
|
|
{
|
|
id: "wrapUp",
|
|
targetId: null,
|
|
placement: "center",
|
|
titleKey: "onboarding.tour.steps.wrapUp.title",
|
|
bodyKey: "onboarding.tour.steps.wrapUp.body",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* A partial map of `ModuleKey → enabled` — the `modules` field from
|
|
* `GET /api/auth/me`. Default-on: a missing key or `true` keeps the
|
|
* stop; only an explicit `false` drops it.
|
|
*/
|
|
export type TourModuleMap = Partial<Record<ModuleKey, boolean>>;
|
|
|
|
/**
|
|
* Build the resolved tour-step list for the given module map. Stops
|
|
* whose `requiresModule` resolves to `false` are dropped without
|
|
* renumbering the rest.
|
|
*
|
|
* `filterToStop` narrows the list to a single stop — the per-module
|
|
* "Diese Tour zeigen" re-entry shows just that module's card. An
|
|
* unknown / module-disabled stop id yields an empty list (caller
|
|
* renders nothing).
|
|
*/
|
|
export function buildTourStops(opts?: {
|
|
modules?: TourModuleMap;
|
|
filterToStop?: TourStopId;
|
|
}): TourStop[] {
|
|
const modules = opts?.modules;
|
|
const gated = ALL_STOPS.filter(
|
|
(s) => !s.requiresModule || modules?.[s.requiresModule] !== false,
|
|
);
|
|
if (opts?.filterToStop) {
|
|
return gated.filter((s) => s.id === opts.filterToStop);
|
|
}
|
|
return gated;
|
|
}
|
|
|
|
/**
|
|
* Tour navigation state. Pure value, immutable per transition — every
|
|
* helper returns a fresh state instead of mutating in place.
|
|
*/
|
|
export interface TourState {
|
|
/** Index into the steps array. May equal `steps.length` when complete. */
|
|
index: number;
|
|
/** Cached step list — handy for the renderer. */
|
|
steps: TourStop[];
|
|
/**
|
|
* `null` while running, `"completed"` once the user reached the end
|
|
* and clicked Done, `"skipped"` if they hit Skip on any step.
|
|
*/
|
|
outcome: "completed" | "skipped" | null;
|
|
}
|
|
|
|
/**
|
|
* Initial state. `resumeFromStopId` (v1.18.6) seeds the index from a
|
|
* persisted resume point — if the id is present in the resolved list,
|
|
* the tour opens there; otherwise it starts at the first stop (e.g. the
|
|
* resume stop's module was disabled since the checkpoint was written).
|
|
*/
|
|
export function initTourState(
|
|
steps: TourStop[],
|
|
resumeFromStopId?: string | null,
|
|
): TourState {
|
|
let index = 0;
|
|
if (resumeFromStopId) {
|
|
const found = steps.findIndex((s) => s.id === resumeFromStopId);
|
|
if (found >= 0) index = found;
|
|
}
|
|
return { index, steps, outcome: null };
|
|
}
|
|
|
|
/**
|
|
* Advance to the next step. On the last step, mark `completed`. A
|
|
* no-op on a terminal state so the renderer can fire-and-forget on
|
|
* rapid double-clicks.
|
|
*/
|
|
export function nextStep(state: TourState): TourState {
|
|
if (state.outcome !== null) return state;
|
|
const lastIndex = state.steps.length - 1;
|
|
if (state.index >= lastIndex) {
|
|
return { ...state, index: state.steps.length, outcome: "completed" };
|
|
}
|
|
return { ...state, index: state.index + 1 };
|
|
}
|
|
|
|
/**
|
|
* Step backwards. Pinned at index 0 — the first step has no Back
|
|
* button visually; this keeps the LeftArrow shortcut safe.
|
|
*/
|
|
export function prevStep(state: TourState): TourState {
|
|
if (state.outcome !== null) return state;
|
|
if (state.index <= 0) return state;
|
|
return { ...state, index: state.index - 1 };
|
|
}
|
|
|
|
/** User explicitly dismissed the tour from any step. */
|
|
export function skipTour(state: TourState): TourState {
|
|
if (state.outcome !== null) return state;
|
|
return { ...state, index: state.steps.length, outcome: "skipped" };
|
|
}
|
|
|
|
/** Whether the tour has reached a terminal state. */
|
|
export function isTourFinished(state: TourState): boolean {
|
|
return state.outcome !== null;
|
|
}
|
|
|
|
/** The current stop, or null if the tour is finished. */
|
|
export function currentStop(state: TourState): TourStop | null {
|
|
if (state.outcome !== null) return null;
|
|
return state.steps[state.index] ?? null;
|
|
}
|
|
|
|
/** 1-based step counter for "{n}/{total}" displays. */
|
|
export function stepCounter(state: TourState): {
|
|
current: number;
|
|
total: number;
|
|
} {
|
|
return {
|
|
current: Math.min(state.index + 1, state.steps.length),
|
|
total: state.steps.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.18.6 — derive the persisted progress checkpoint from the live
|
|
* navigation state. Pure so the overlay can fire-and-forget it without
|
|
* touching the DOM. `completedStopIds` accumulates every stop the user
|
|
* has reached up to (and including) the current index; `lastStopId` is
|
|
* the resume point.
|
|
*/
|
|
export function deriveProgress(state: TourState): {
|
|
lastStopId: string | null;
|
|
completedStopIds: string[];
|
|
status: "in_progress" | "completed" | "skipped";
|
|
} {
|
|
const status =
|
|
state.outcome === "completed"
|
|
? "completed"
|
|
: state.outcome === "skipped"
|
|
? "skipped"
|
|
: "in_progress";
|
|
const reachedCount =
|
|
state.outcome !== null
|
|
? state.steps.length
|
|
: Math.min(state.index + 1, state.steps.length);
|
|
const completedStopIds = state.steps.slice(0, reachedCount).map((s) => s.id);
|
|
const current = currentStop(state);
|
|
return {
|
|
lastStopId: current?.id ?? null,
|
|
completedStopIds,
|
|
status,
|
|
};
|
|
}
|