mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(illness): add the condition-journal MVP behind a born-gated module
Introduce a retrospective illness/condition journal: episodes with a lifecycle (acute / chronic-ongoing / recurring / flare) and a daily symptom timeline forked structurally from the cycle log-day surface. The journal is a record, not a medical device — no prediction or diagnosis UI. - Register `illness` as a born-gated (opt-in / default-off) module. The gate resolver flips the disabled-allowlist default to off for born-gated keys, so the surface stays dark until the account opts in from the Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the module foundation (one source of truth) and re-stamp an illness errorCode. - Episode CRUD under /api/illness: list / create / get / edit / resolve / delete, plus a per-episode-day day-log upsert on (episodeId, date). Every route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows userId from auth, builds data field-by-field, and gates the module. Free-text notes encrypt at rest through the shared Bytes codec. - Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a functional-impact group + fever + an encrypted note, one row per day. - Episode history page + a born-gated nav entry, neutral palette, status via a neutral Badge, retrospective-only copy. i18n across all six locales. - Cover the new routes in the module-route-gate inventory; unit tests for the day-log upsert, the gate enforcement, and the born-gated resolver. The /api/auth/me module map now projects the `illness` flag (openapi.yaml regenerated).
This commit is contained in:
1 parent
bee7295b19
commit
b079431a8d
34 files changed
+2799
-6
No files matched your search
@@ -78,6 +78,54 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
|
||||
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
|
||||
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is ON only on an explicit true", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: true } }),
|
||||
true,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF on an explicit false", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: false } }),
|
||||
true,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("operator-off short-circuits even when the user opted in", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"illness",
|
||||
inputs({ modulePreferences: { illness: true } }),
|
||||
true,
|
||||
operator({ illness: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not affect sibling default-on modules", () => {
|
||||
const i = inputs({ modulePreferences: {} });
|
||||
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
|
||||
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — cycle delegation", () => {
|
||||
it("ignores the module blob and reads the cycle gate (gender-derived)", () => {
|
||||
// Even a crafted `{ cycle: false }` blob cannot override the real
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from "./operator-availability";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
isBornGatedModule,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "./registry";
|
||||
@@ -146,6 +147,13 @@ export function resolveModuleEnabled(
|
||||
return assistantCoach && !inputs.disableCoach;
|
||||
}
|
||||
|
||||
if (isBornGatedModule(key)) {
|
||||
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
|
||||
// The module stays OFF until the account explicitly enables it — only a
|
||||
// literal `true` turns it on, so the surface never appears unbidden.
|
||||
return inputs.modulePreferences[key] === true;
|
||||
}
|
||||
|
||||
// Disabled allowlist: only an explicit `false` turns the module off.
|
||||
return inputs.modulePreferences[key] !== false;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ export interface ModuleDefinition {
|
||||
* link; `labelKey` names the destination ("Account", "Coach settings").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
/**
|
||||
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
|
||||
* the inverse of the standard disabled-allowlist: it stays OFF until the
|
||||
* account explicitly enables it (`modulePreferencesJson[key] === true`).
|
||||
* Used for new optional verticals (the illness/condition journal) that
|
||||
* ship dark and only appear once the user opts in from the Modules hub.
|
||||
* The gate's `false`-disables contract still holds — an explicit `false`
|
||||
* (or simply the absent default) keeps it off — but here ONLY an explicit
|
||||
* `true` turns it on, so the surface never appears unbidden.
|
||||
*/
|
||||
bornGated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +99,7 @@ export const MODULE_KEYS = [
|
||||
"workouts",
|
||||
"recovery",
|
||||
"labs",
|
||||
"illness",
|
||||
"achievements",
|
||||
"coach",
|
||||
"insights",
|
||||
@@ -169,6 +181,15 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.labs.description",
|
||||
category: "tracking",
|
||||
},
|
||||
illness: {
|
||||
key: "illness",
|
||||
labelKey: "modules.illness.label",
|
||||
descriptionKey: "modules.illness.description",
|
||||
category: "tracking",
|
||||
// Ships dark — only appears once the account opts in from the
|
||||
// Modules hub. Default-off, no nag, retrospective-only.
|
||||
bornGated: true,
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
labelKey: "modules.sleep.label",
|
||||
@@ -230,3 +251,12 @@ export function isModuleKey(key: string): key is ModuleKey {
|
||||
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
|
||||
return MODULE_REGISTRY[key].delegatesTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
|
||||
* flips its default from on to off for these keys: they require an explicit
|
||||
* `modulePreferencesJson[key] === true` to enable.
|
||||
*/
|
||||
export function isBornGatedModule(key: ModuleKey): boolean {
|
||||
return MODULE_REGISTRY[key].bornGated === true;
|
||||
}
|
||||
Reference in new issue
Block a user