mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone.
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* `<ModulesSection>` — Settings → Module ("Was du trackst") hub. v1.18.0.
|
|
*
|
|
* The one place a user decides which secondary domains HealthLog surfaces.
|
|
* Each toggleable module (mood, sleep, glucose, workouts, recovery, labs,
|
|
* achievements, coach, insights, doctor report, cycle) gets a row with an
|
|
* icon, label, one-line description, and a live `<Switch>`. For most modules
|
|
* the switch PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the
|
|
* endpoint refuses core keys). The two delegated modules drive their
|
|
* canonical column instead — coach → `PATCH /api/auth/me/disable-coach`
|
|
* (`User.disableCoach`, inverted), cycle → `PATCH /api/auth/me/cycle-prefs`
|
|
* (`cycleTrackingEnabled`) — so there is exactly one source of truth, and
|
|
* keep a small "manage" deep-link to the fuller settings surface beside the
|
|
* switch. Every path then invalidates `authMe()` (delegated cycle also evicts
|
|
* its own reads) so the nav, Insights pills, and dashboard tiles re-gate live
|
|
* off `useAuth().user.modules`.
|
|
*
|
|
* Operator precedence stays honest: a module the operator turned off
|
|
* server-wide (the module-availability blob, or for the coach row the
|
|
* operator's Coach switch as the `coach` capability reports it) renders a disabled switch + a
|
|
* "disabled server-wide" hint — a per-user toggle could not re-enable it.
|
|
*
|
|
* The three CORE domains (weight, blood pressure, pulse) render as a
|
|
* separate read-only "always on" group — locked switches with a short note —
|
|
* so the always-on measurement engine reads as deliberately fixed, never
|
|
* disableable. Palette stays neutral throughout; this is a calm
|
|
* configuration surface, not an alarm panel. (v1.18.1 D3 — medications
|
|
* graduated to the toggleable group above.)
|
|
*
|
|
* State source is `useAuth().user.modules` (the resolved `/auth/me` map).
|
|
* A module is enabled unless its key is explicitly `false` — default-on.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Activity,
|
|
Award,
|
|
Blocks,
|
|
Brain,
|
|
CalendarHeart,
|
|
Droplet,
|
|
CloudSun,
|
|
Dumbbell,
|
|
FileScan,
|
|
FileText,
|
|
History,
|
|
MessageCircleHeart,
|
|
Leaf,
|
|
Moon,
|
|
Plug,
|
|
Pill,
|
|
Smile,
|
|
Sparkles,
|
|
Syringe,
|
|
TestTube,
|
|
Thermometer,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
|
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useAiCapability } from "@/hooks/use-ai-capability";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { apiPatch } from "@/lib/api/api-fetch";
|
|
import {
|
|
readUpdatedAtToken,
|
|
withBaseToken,
|
|
isConflict,
|
|
} from "@/lib/api/optimistic-token";
|
|
import {
|
|
aiInputDependentKeys,
|
|
invalidateKeys,
|
|
queryKeys,
|
|
} from "@/lib/query-keys";
|
|
import {
|
|
MODULE_REGISTRY,
|
|
isCodeDisabledModule,
|
|
moduleDelegatesTo,
|
|
type ModuleKey,
|
|
} from "@/lib/modules/registry";
|
|
|
|
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
|
|
type ModulesPatchResult = {
|
|
modules: Partial<Record<ModuleKey, boolean>>;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
/** Neutral Lucide glyph per toggleable module. */
|
|
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
|
cycle: CalendarHeart,
|
|
mood: Smile,
|
|
sleep: Moon,
|
|
glucose: Droplet,
|
|
workouts: Dumbbell,
|
|
recovery: Activity,
|
|
labs: TestTube,
|
|
illness: Thermometer,
|
|
achievements: Award,
|
|
coach: MessageCircleHeart,
|
|
insights: Sparkles,
|
|
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
|
medications: Pill,
|
|
doctorReport: FileText,
|
|
// v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
|
|
environment: CloudSun,
|
|
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
|
|
mcp: Plug,
|
|
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in).
|
|
inboundDocuments: FileScan,
|
|
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
|
|
mentalHealth: Brain,
|
|
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
|
|
nutrients: Leaf,
|
|
// v1.38.0 — the immunization log.
|
|
vaccinations: Syringe,
|
|
// v1.42 — the life timeline (opt-in).
|
|
timeline: History,
|
|
};
|
|
|
|
export function ModulesSection() {
|
|
const { t } = useTranslations();
|
|
const { user } = useAuth();
|
|
const coach = useAiCapability("coach");
|
|
const queryClient = useQueryClient();
|
|
// v1.42 (#613) — switching the timeline on opens its readiness inventory:
|
|
// an honest look at what it can already show, with one link per gap,
|
|
// before the person goes there. It blocks nothing.
|
|
const [readinessOpen, setReadinessOpen] = useState(false);
|
|
|
|
const modules = user?.modules ?? {};
|
|
const moduleAvailability = user?.moduleAvailability ?? {};
|
|
|
|
const toggle = useMutation({
|
|
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
|
|
mutationKey: queryKeys.modulesPrefs(),
|
|
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
|
|
// The delegated modules keep a single source of truth: their switch
|
|
// drives the canonical column, not the `modulePreferencesJson`
|
|
// allowlist (the gate ignores the blob for these two keys). Reuse the
|
|
// existing per-column endpoints rather than inventing a new one.
|
|
const delegate = moduleDelegatesTo(vars.key);
|
|
if (delegate === "coach") {
|
|
// The stored column is `disableCoach` — the inverse of "on".
|
|
return apiPatch("/api/auth/me/disable-coach", {
|
|
disableCoach: !vars.enabled,
|
|
});
|
|
}
|
|
if (delegate === "cycle") {
|
|
// `enabled` maps straight onto `cycleTrackingEnabled`.
|
|
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
|
|
}
|
|
// DISABLED allowlist: send only the single key the user flipped.
|
|
//
|
|
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
|
|
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
|
|
// clobbering. The token rides on `User.updatedAt`; the module map itself
|
|
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
|
|
// seeded from each write response below and read back here at mutate
|
|
// time. The first write of a session is tokenless — the server's
|
|
// backward-compatible unconditional arm.
|
|
const result = await apiPatch<ModulesPatchResult>(
|
|
"/api/auth/me/modules",
|
|
withBaseToken(
|
|
{ [vars.key]: vars.enabled },
|
|
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
|
|
),
|
|
);
|
|
queryClient.setQueryData<{ updatedAt?: string }>(
|
|
queryKeys.modulesPrefs(),
|
|
{ updatedAt: result.updatedAt },
|
|
);
|
|
return result;
|
|
},
|
|
onSuccess: (_data, vars) => {
|
|
// Re-gate every surface that reads the module map off /auth/me: nav,
|
|
// Insights pills, dashboard tiles, quick-add, search. The two delegated
|
|
// keys also feed dedicated settings surfaces, so evict their reads too
|
|
// (mirrors the canonical Coach / cycle cards' own invalidation).
|
|
// The module map is also an input of the AI capability answer.
|
|
void invalidateKeys(queryClient, aiInputDependentKeys);
|
|
if (moduleDelegatesTo(vars.key) === "cycle") {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: queryKeys.cyclePrefs(),
|
|
});
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
|
}
|
|
toast.success(t("settings.sections.modules.saved"));
|
|
if (vars.key === "timeline" && vars.enabled) setReadinessOpen(true);
|
|
},
|
|
onError: (err) => {
|
|
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
|
|
// advanced since this switch was based (another tab / the iOS client
|
|
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
|
|
// so the switch snaps back to server truth, then nudge — nothing was
|
|
// clobbered. A retry re-seeds a fresh token from its own response.
|
|
if (isConflict(err)) {
|
|
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
|
toast.message(t("common.conflictReloaded"));
|
|
return;
|
|
}
|
|
toast.error(t("settings.sections.modules.error"));
|
|
},
|
|
});
|
|
|
|
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
|
// `<SettingsSectionFrame>` (the former muted-`<p>` intro folded into the
|
|
// frame's standard subtitle). The "Module immer aktiv" core-domains card
|
|
// was removed: a domain that can't be turned off doesn't need to be listed.
|
|
return (
|
|
<SettingsCard>
|
|
{/* The "what this section does" hint rides the header's standard
|
|
description slot; the body follows on the card's gap-based
|
|
header→content rhythm instead of the former mb-2/mb-3 one-offs. */}
|
|
<SettingsCardHeader
|
|
icon={Blocks}
|
|
title={t("settings.sections.modules.toggleable.title")}
|
|
description={t("settings.sections.modules.toggleable.description")}
|
|
/>
|
|
<div className="divide-border divide-y">
|
|
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
|
|
// Modules switched off in code (pending a rebuild) carry no live
|
|
// toggle — drop the row entirely so a user can't turn one on.
|
|
.filter((key) => !isCodeDisabledModule(key))
|
|
.map((key) => {
|
|
const def = MODULE_REGISTRY[key];
|
|
const delegate = moduleDelegatesTo(key);
|
|
|
|
// Enabled-state per key. Delegated modules read their canonical
|
|
// per-user state, NOT the `modulePreferencesJson` allowlist the
|
|
// gate ignores for them: coach ← `!disableCoach`, cycle ← the
|
|
// resolved `cycleTrackingEnabled`. Every other module is
|
|
// default-on unless explicitly `false`.
|
|
let enabled: boolean;
|
|
if (delegate === "coach") {
|
|
enabled = !(user?.disableCoach ?? false);
|
|
} else if (delegate === "cycle") {
|
|
enabled = user?.cycleTrackingEnabled ?? false;
|
|
} else {
|
|
enabled = modules[key] !== false;
|
|
}
|
|
|
|
// Operator precedence. A per-user toggle can never re-enable a
|
|
// module the operator turned off server-wide, so the switch goes
|
|
// disabled + hint. For coach the operator layer is the
|
|
// operator's Coach switch, read from the resolved `coach`
|
|
// capability (`operator_disabled`, master applied); for cycle
|
|
// and the owned modules it is the module-availability blob.
|
|
const operatorAvailable =
|
|
delegate === "coach"
|
|
? coach.reason !== "operator_disabled"
|
|
: moduleAvailability[key] !== false;
|
|
const disabledReason = operatorAvailable
|
|
? undefined
|
|
: t("settings.sections.modules.operatorDisabled");
|
|
|
|
// Delegated modules carry more than on/off at their canonical
|
|
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
|
|
// so keep a small deep-link to it beside the live switch.
|
|
const manageLink =
|
|
delegate !== undefined && def.managedAt
|
|
? {
|
|
href: def.managedAt.href,
|
|
label: t("settings.sections.modules.manageIn", {
|
|
section: t(def.managedAt.labelKey),
|
|
}),
|
|
}
|
|
: undefined;
|
|
|
|
return (
|
|
<ModuleToggleRow
|
|
key={key}
|
|
moduleKey={key}
|
|
icon={MODULE_ICONS[key]}
|
|
label={t(def.labelKey)}
|
|
description={t(def.descriptionKey)}
|
|
enabled={enabled}
|
|
pending={toggle.isPending}
|
|
manageLink={manageLink}
|
|
disabledReason={disabledReason}
|
|
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<TimelineReadinessSheet
|
|
open={readinessOpen}
|
|
onOpenChange={setReadinessOpen}
|
|
/>
|
|
</SettingsCard>
|
|
);
|
|
}
|