mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into the flat `MoodEntry.tags` column, and three readers matched that label against a hand-written list of English and German words: the therapy timeline, the Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales. Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the day never reached the timeline, the Coach grounded its answers on an empty side-effect list, and a clinician read an empty table over a record that had three months of symptoms. Nothing on any of those surfaces said the table was a filter rather than the truth. The three lists had also drifted apart from each other. The chips are a closed set with stable keys, so matching the label was the defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue — seven keys, each with the i18n key its chip renders — and the mood form now draws its strip from it, so the write side and the read side cannot diverge again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading `messages/<locale>.json` for every entry in `locales`, rather than transcribing the labels into a second list. Transcribing would have fixed today's four locales and reopened the same hole on the day a seventh is added; deriving means a new bundle is matchable as soon as it lands, with no edit in the matcher. Comparison folds case, accents and separators, so the English and German tags already in the database keep resolving and a straight apostrophe matches the typographic one the French bundle uses. All three readers now report the catalogue key instead of the stored string. The same recorded symptom yields one key whatever language it was tagged in, which also fixes the mixed-language output an account gets after switching UI locale; the doctor-report PDF resolves the key through the label map so the clinician reads it in the report's language. A tag outside the catalogue is still never counted — a free-text mood tag must not reach a doctor as an invented symptom, and the rest of somebody's day does not belong on a therapy timeline. It is no longer dropped without trace: the timeline route annotates the unresolved count on its wide event, so the classification rate stays answerable while the tag text stays in the database. The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`. Those are removed. No chip writes them, so they only ever matched a hand-typed tag in two of six languages; keeping them would have rebuilt the defect inside its own fix. Restoring either symptom means giving it a chip and a label in all six bundles. The response schema for `GET /api/insights/glp1-timeline` now enumerates the keys, and its description no longer documents the bilingual match as if it were the contract.
This commit is contained in:
1 parent
b76468f455
commit
97d0c26ceb
14 files changed
+833
-167
No files matched your search
+15
-3
@@ -50680,12 +50680,24 @@ components:
|
||||
reason:
|
||||
type: string
|
||||
tags:
|
||||
description: The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and
|
||||
German tag strings, so a tag written in any other language — or spelled differently — does not appear
|
||||
here at all.
|
||||
description: "Catalogue keys of the side effects tagged that day, deduplicated. These are STABLE KEYS, not the strings
|
||||
the entries were written with: the capture chip writes its label in the writer's language, and the
|
||||
matcher indexes every shipped locale's label back to the key, so the same recorded symptom yields the
|
||||
same key whether it was tagged in German, English, Spanish, French, Italian or Polish. Render the key
|
||||
in the reader's own language rather than echoing it. A mood tag that is not a catalogue side effect —
|
||||
anything the user typed freehand — is not reported here; it is not a side effect, and a therapy
|
||||
timeline is not the place for the rest of somebody's day."
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- nausea
|
||||
- constipation
|
||||
- diarrhea
|
||||
- fatigue
|
||||
- appetite-loss
|
||||
- heartburn
|
||||
- headache
|
||||
required:
|
||||
- date
|
||||
- kind
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `GET /api/insights/glp1-timeline` — the reported surface.
|
||||
*
|
||||
* A side-effect day was assembled by matching the stored mood tag against a
|
||||
* hand-written English/German word list. Tap the nausea chip with the UI in
|
||||
* French, Spanish, Italian or Polish and the day never appeared on the
|
||||
* timeline: a thinner therapy history, with nothing anywhere saying why.
|
||||
*
|
||||
* The property asserted here is equality, not presence — a tag recorded in any
|
||||
* shipped locale contributes exactly what the English one does.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
medicationFindMany: vi.fn(),
|
||||
moodFindMany: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: mocks.annotate }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: mocks.medicationFindMany },
|
||||
moodEntry: { findMany: mocks.moodFindMany },
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/glp1-timeline");
|
||||
}
|
||||
|
||||
/** One GLP-1 medication with no events of its own, so only mood days show. */
|
||||
function medication() {
|
||||
return {
|
||||
name: "Mounjaro",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
inventoryEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
function moodDay(day: number, tags: string[]) {
|
||||
return {
|
||||
moodLoggedAt: new Date(Date.UTC(2026, 3, day, 9, 0, 0)),
|
||||
tags: JSON.stringify(tags),
|
||||
};
|
||||
}
|
||||
|
||||
interface TimelineBody {
|
||||
data: {
|
||||
hasGlp1: boolean;
|
||||
entries: Array<{ date: string; kind: string; tags?: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function timeline(moods: ReturnType<typeof moodDay>[]) {
|
||||
mocks.medicationFindMany.mockResolvedValue([medication()]);
|
||||
mocks.moodFindMany.mockResolvedValue(moods);
|
||||
const res = await handler(request());
|
||||
const body = (await res.json()) as TimelineBody;
|
||||
return body.data;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/insights/glp1-timeline side-effect days", () => {
|
||||
it("builds the same timeline from a French record as from an English one", async () => {
|
||||
const en = await timeline([
|
||||
moodDay(10, ["nausea"]),
|
||||
moodDay(11, ["headache", "fatigue"]),
|
||||
]);
|
||||
const fr = await timeline([
|
||||
moodDay(10, ["Nausées"]),
|
||||
moodDay(11, ["Maux de tête", "Fatigue"]),
|
||||
]);
|
||||
expect(fr).toEqual(en);
|
||||
expect(fr.entries).toEqual([
|
||||
{
|
||||
date: "2026-04-11T12:00:00Z",
|
||||
kind: "side-effect",
|
||||
tags: ["headache", "fatigue"],
|
||||
},
|
||||
{ date: "2026-04-10T12:00:00Z", kind: "side-effect", tags: ["nausea"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises the chip label of every shipped locale", async () => {
|
||||
const out = await timeline([
|
||||
moodDay(10, ["Übelkeit"]),
|
||||
moodDay(11, ["Estreñimiento"]),
|
||||
moodDay(12, ["Stitichezza"]),
|
||||
moodDay(13, ["Zgaga"]),
|
||||
moodDay(14, ["Perte d’appétit"]),
|
||||
]);
|
||||
expect(out.entries.map((e) => e.tags)).toEqual([
|
||||
["appetite-loss"],
|
||||
["heartburn"],
|
||||
["constipation"],
|
||||
["constipation"],
|
||||
["nausea"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the catalogue key, not the string the entry was written with", async () => {
|
||||
// The stored column mixes languages as soon as an account switches UI
|
||||
// locale. A key renders in the reader's language; a stored label renders
|
||||
// in whichever one the writer happened to be using.
|
||||
const out = await timeline([moodDay(10, ["Nudności", "Übelkeit"])]);
|
||||
expect(out.entries[0].tags).toEqual(["nausea"]);
|
||||
});
|
||||
|
||||
it("leaves free-text mood tags off the therapy timeline", async () => {
|
||||
const out = await timeline([moodDay(10, ["gym", "date night"])]);
|
||||
expect(out.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts what it could not classify on the wide event", async () => {
|
||||
// Not dropped without trace: the miss rate stays answerable from a
|
||||
// dashboard, while the tag text — the user's own prose — stays in the
|
||||
// database.
|
||||
await timeline([moodDay(10, ["Nudności", "siłownia", "rodzina"])]);
|
||||
expect(mocks.annotate).toHaveBeenCalledWith({
|
||||
action: { name: "insights.glp1-timeline.read" },
|
||||
meta: { side_effect_days: 1, unresolved_mood_tags: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,25 +13,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const DEFAULT_LIMIT = 60;
|
||||
const SIDE_EFFECT_TAGS = new Set([
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
"übelkeit",
|
||||
"verstopfung",
|
||||
"durchfall",
|
||||
"müdigkeit",
|
||||
"appetitlosigkeit",
|
||||
"sodbrennen",
|
||||
"kopfschmerzen",
|
||||
]);
|
||||
|
||||
interface TimelineEntry {
|
||||
date: string;
|
||||
@@ -44,25 +31,7 @@ interface TimelineEntry {
|
||||
injectionSite?: string | null;
|
||||
inventoryDelta?: number;
|
||||
reason?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
function parseTagList(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
tags?: Glp1SideEffectTag[];
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -157,17 +126,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Side-effect days — collapse to one entry per day (most recent
|
||||
// first) listing every recognised tag picked up that day.
|
||||
const sideEffectByDay = new Map<string, string[]>();
|
||||
// Side-effect days — collapse to one entry per day (most recent first)
|
||||
// listing every catalogue side effect picked up that day.
|
||||
//
|
||||
// `tags` carries the catalogue KEY, not the string the entry was written
|
||||
// with. The chip writes the label of whatever language the writer's UI was
|
||||
// in, so the stored column mixes languages within one account the moment
|
||||
// somebody switches locale; a key renders in the reader's language and a
|
||||
// stored label renders in the writer's.
|
||||
const sideEffectByDay = new Map<string, Glp1SideEffectTag[]>();
|
||||
let unresolvedTagCount = 0;
|
||||
for (const mood of moods) {
|
||||
const tags = parseTagList(mood.tags).filter((t) =>
|
||||
SIDE_EFFECT_TAGS.has(t.toLowerCase()),
|
||||
);
|
||||
if (tags.length === 0) continue;
|
||||
const { matched, unresolvedCount } = matchGlp1SideEffectTags(mood.tags);
|
||||
unresolvedTagCount += unresolvedCount;
|
||||
if (matched.length === 0) continue;
|
||||
const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10);
|
||||
const existing = sideEffectByDay.get(dayKey) ?? [];
|
||||
for (const t of tags) {
|
||||
for (const t of matched) {
|
||||
if (!existing.includes(t)) existing.push(t);
|
||||
}
|
||||
sideEffectByDay.set(dayKey, existing);
|
||||
@@ -182,5 +157,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
entries.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
// Mood tags that are not catalogue side effects are correctly absent from a
|
||||
// GLP-1 timeline — they are the user's own prose about their day, and this
|
||||
// surface is not where it belongs. Absent is not the same as untracked
|
||||
// though: the count rides the wide event so the classification rate stays
|
||||
// answerable from a dashboard, without the text leaving the database.
|
||||
annotate({
|
||||
action: { name: "insights.glp1-timeline.read" },
|
||||
meta: {
|
||||
side_effect_days: sideEffectByDay.size,
|
||||
unresolved_mood_tags: unresolvedTagCount,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
|
||||
});
|
||||
@@ -64,6 +64,10 @@ import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
// One catalogue for the chip strip and for every server-side reader of what it
|
||||
// writes. Split apart, the write side gained a locale and the read side did
|
||||
// not notice for four of them.
|
||||
import { GLP1_SIDE_EFFECT_TAGS } from "@/lib/medications/glp1-side-effect-tags";
|
||||
|
||||
// v1.12.0 — best-on-the-left face order for the "How are you?" hero,
|
||||
// mirroring the iOS `Mood1…Mood5` imageset order. The numeric `score`
|
||||
@@ -82,24 +86,6 @@ const MOOD_LEVELS = [
|
||||
// so the truncation at the cap is no longer silent.
|
||||
const NOTE_MAX_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — curated GLP-1 side-effect chip strip. Tapping a chip
|
||||
* appends the localised tag string to the existing free-text tag input
|
||||
* (comma-separated). The list is intentionally short — these are the
|
||||
* symptoms clinicians most often ask about at GLP-1 follow-up visits
|
||||
* (cf. PMC GLP-1 adverse-effects review). Generic mood entries that
|
||||
* never touch the chips keep their tag input byte-identical to v1.4.24.
|
||||
*/
|
||||
const GLP1_SIDE_EFFECT_KEYS = [
|
||||
"medications.sideEffectTagNausea",
|
||||
"medications.sideEffectTagConstipation",
|
||||
"medications.sideEffectTagDiarrhea",
|
||||
"medications.sideEffectTagFatigue",
|
||||
"medications.sideEffectTagAppetiteLoss",
|
||||
"medications.sideEffectTagHeartburn",
|
||||
"medications.sideEffectTagHeadache",
|
||||
] as const;
|
||||
|
||||
interface MoodFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
@@ -491,18 +477,19 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
{/* GLP-1 side-effect quick-tags. Tapping a chip appends the
|
||||
localised label to the free-text tag list. The catalogue is
|
||||
shared with the server-side matcher, which indexes every shipped
|
||||
locale's label back to the chip's key — so what a chip writes is
|
||||
what the timeline, the Coach snapshot and the doctor report
|
||||
count, in all six languages. */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
{GLP1_SIDE_EFFECT_TAGS.map(({ key, labelKey }) => {
|
||||
const label = t(labelKey);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
|
||||
@@ -201,3 +201,63 @@ describe("buildGlp1SnapshotBlock", () => {
|
||||
expect(out?.medications[0].penInventory).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Coach's side-effect tally used to match the stored mood tag against a
|
||||
* hand-written English/German word list. Four of the six shipped locales
|
||||
* matched nothing, so the Coach grounded its answers about side-effect timing
|
||||
* on an empty list and never said the list was empty for a reason.
|
||||
*/
|
||||
describe("buildGlp1SnapshotBlock side-effect tags across locales", () => {
|
||||
function moodDays(...tagSets: string[][]) {
|
||||
return tagSets.map((tags, i) => ({
|
||||
moodLoggedAt: new Date(Date.UTC(2026, 3, 10 + i)),
|
||||
tags: JSON.stringify(tags),
|
||||
}));
|
||||
}
|
||||
|
||||
it("counts a French-labelled tag exactly as an English one", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]);
|
||||
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue(
|
||||
moodDays(["nausea"], ["headache"]),
|
||||
);
|
||||
const en = await buildGlp1SnapshotBlock("user-1");
|
||||
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue(
|
||||
moodDays(["Nausées"], ["Maux de tête"]),
|
||||
);
|
||||
const fr = await buildGlp1SnapshotBlock("user-1");
|
||||
|
||||
expect(fr?.medications[0].sideEffects).toEqual(
|
||||
en?.medications[0].sideEffects,
|
||||
);
|
||||
expect(fr?.medications[0].sideEffects).toEqual([
|
||||
{ tag: "nausea", count: 1 },
|
||||
{ tag: "headache", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the catalogue key rather than the language it was typed in", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]);
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue(
|
||||
moodDays(["Nudności"], ["Übelkeit"], ["Náuseas"]),
|
||||
);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
// One symptom, three languages, one grounded fact for the model.
|
||||
expect(out?.medications[0].sideEffects).toEqual([
|
||||
{ tag: "nausea", count: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves free-text mood tags out of the block", async () => {
|
||||
prismaMock.medication.findMany.mockResolvedValue([fakeMedication()]);
|
||||
prismaMock.moodEntry.findMany.mockResolvedValue(
|
||||
moodDays(["gym", "Feierabend"], ["Zgaga"]),
|
||||
);
|
||||
const out = await buildGlp1SnapshotBlock("user-1");
|
||||
expect(out?.medications[0].sideEffects).toEqual([
|
||||
{ tag: "heartburn", count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
|
||||
/**
|
||||
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
|
||||
@@ -118,7 +120,13 @@ interface PenInventory {
|
||||
}
|
||||
|
||||
interface SideEffectSummary {
|
||||
tag: string;
|
||||
/**
|
||||
* The catalogue KEY, not the string the entry was written with. The model
|
||||
* gets one stable vocabulary for a symptom instead of whichever language the
|
||||
* chip was tapped in, which also keeps the block's wording out of the
|
||||
* prompt-injection surface the free-text columns need sanitising for.
|
||||
*/
|
||||
tag: Glp1SideEffectTag;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -144,50 +152,6 @@ interface RawMoodEntry {
|
||||
tags: string | null;
|
||||
}
|
||||
|
||||
const SIDE_EFFECT_TAGS = new Set([
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
"vomiting",
|
||||
"reflux",
|
||||
// German variants the mood-tag picker exposes — the maintainer's userbase types
|
||||
// in both languages and we don't want the snapshot to miss "Übelkeit"
|
||||
// because the user picked the German chip.
|
||||
"übelkeit",
|
||||
"verstopfung",
|
||||
"durchfall",
|
||||
"müdigkeit",
|
||||
"appetitlosigkeit",
|
||||
"sodbrennen",
|
||||
"kopfschmerzen",
|
||||
"erbrechen",
|
||||
]);
|
||||
|
||||
function parseTagList(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
// The MoodEntry.tags column carries either a JSON array (modern
|
||||
// mood-form writes) or a comma-separated list (legacy imported
|
||||
// imports). Be permissive.
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
/* fall through to CSV */
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function weeksBetween(from: Date, to: Date): number {
|
||||
const ms = to.getTime() - from.getTime();
|
||||
return Math.max(0, Math.round(ms / (7 * 24 * 60 * 60 * 1000)));
|
||||
@@ -297,11 +261,9 @@ export async function buildGlp1SnapshotBlock(
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: moodCutoff } },
|
||||
select: { moodLoggedAt: true, tags: true },
|
||||
});
|
||||
const tagCounts = new Map<string, number>();
|
||||
const tagCounts = new Map<Glp1SideEffectTag, number>();
|
||||
for (const row of moods) {
|
||||
for (const rawTag of parseTagList(row.tags)) {
|
||||
const tag = rawTag.toLowerCase();
|
||||
if (!SIDE_EFFECT_TAGS.has(tag)) continue;
|
||||
for (const tag of matchGlp1SideEffectTags(row.tags).matched) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DoctorReportPdfCursorState,
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "../medications/glp1-side-effect-tags";
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
@@ -242,7 +243,12 @@ export function buildMedicationMoodWellnessSection(
|
||||
}
|
||||
|
||||
if (data.glp1.sideEffects.length > 0) {
|
||||
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
|
||||
// The tally arrives keyed, so the clinician reads the symptom in the
|
||||
// report's language regardless of the one it was captured in.
|
||||
const seRows = data.glp1.sideEffects.map((s) => [
|
||||
t(GLP1_SIDE_EFFECT_TAG_LABEL_KEYS[s.tag]),
|
||||
String(s.count),
|
||||
]);
|
||||
// Keep the heading with the table; the side-effect tally reads as one
|
||||
// block.
|
||||
y = ensureSpace(y, 5 + estimateTableHeight(seRows.length));
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GlucoseUnit } from "@/lib/glucose";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
import type {
|
||||
ComplianceSchedule,
|
||||
IntakeEvent,
|
||||
@@ -186,7 +187,13 @@ export interface DoctorReportData {
|
||||
weightDeltaKg: number | null;
|
||||
weightStartKg: number | null;
|
||||
weightEndKg: number | null;
|
||||
sideEffects: Array<{ tag: string; count: number }>;
|
||||
/**
|
||||
* Side-effect tallies keyed by `Glp1SideEffectTag`, never by the string the
|
||||
* mood entry was written with: the report renders in the reader's language
|
||||
* and the tag may have been captured in another one. The PDF resolves each
|
||||
* key through `GLP1_SIDE_EFFECT_TAG_LABEL_KEYS`.
|
||||
*/
|
||||
sideEffects: Array<{ tag: Glp1SideEffectTag; count: number }>;
|
||||
} | null;
|
||||
/**
|
||||
* v1.10.0 — the server-derived nightly wellness scores
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The GLP-1 block of the doctor report must tally a side effect the same way
|
||||
* whatever language the patient recorded it in.
|
||||
*
|
||||
* It did not. The tally matched the stored tag string against a hand-written
|
||||
* English/German word list, so a clinician reading a French, Spanish, Italian
|
||||
* or Polish patient's report saw an empty side-effect table over a record that
|
||||
* had three months of them — and nothing on the page said the table was a
|
||||
* filter rather than the truth.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGlp1Block } from "../medications";
|
||||
import type { MedicationRowForReport } from "../medications";
|
||||
|
||||
const MED: MedicationRowForReport = {
|
||||
id: "med-1",
|
||||
name: "Mounjaro",
|
||||
dose: "7.5 mg",
|
||||
treatmentClass: "GLP1",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date("2026-04-01T00:00:00.000Z"),
|
||||
doseValue: 7.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
noteEncrypted: null,
|
||||
},
|
||||
],
|
||||
intakeEvents: [],
|
||||
} as unknown as MedicationRowForReport;
|
||||
|
||||
function block(tagRows: string[]) {
|
||||
return buildGlp1Block({
|
||||
medications: [MED],
|
||||
compliance: {},
|
||||
weightSeries: [],
|
||||
moodTagRows: tagRows.map((tags) => ({ tags })),
|
||||
});
|
||||
}
|
||||
|
||||
describe("doctor report GLP-1 side-effect tally across locales", () => {
|
||||
it("tallies the French chip label exactly as the English one", () => {
|
||||
const en = block([JSON.stringify(["nausea", "headache"])]);
|
||||
const fr = block([JSON.stringify(["Nausées", "Maux de tête"])]);
|
||||
expect(fr?.sideEffects).toEqual(en?.sideEffects);
|
||||
expect(fr?.sideEffects).toEqual([
|
||||
{ tag: "nausea", count: 1 },
|
||||
{ tag: "headache", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tallies Spanish, Italian and Polish chip labels", () => {
|
||||
expect(block([JSON.stringify(["Estreñimiento"])])?.sideEffects).toEqual([
|
||||
{ tag: "constipation", count: 1 },
|
||||
]);
|
||||
expect(block([JSON.stringify(["Affaticamento"])])?.sideEffects).toEqual([
|
||||
{ tag: "fatigue", count: 1 },
|
||||
]);
|
||||
expect(block([JSON.stringify(["Zgaga"])])?.sideEffects).toEqual([
|
||||
{ tag: "heartburn", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds up one symptom recorded in several languages", () => {
|
||||
// An account that switched UI language mid-therapy holds both spellings.
|
||||
// They are one symptom and must count as three days of it, not as two
|
||||
// unrelated rows or one.
|
||||
const out = block([
|
||||
JSON.stringify(["nausea"]),
|
||||
JSON.stringify(["Übelkeit"]),
|
||||
JSON.stringify(["Nudności"]),
|
||||
]);
|
||||
expect(out?.sideEffects).toEqual([{ tag: "nausea", count: 3 }]);
|
||||
});
|
||||
|
||||
it("still refuses free text, so no invented symptom reaches a clinician", () => {
|
||||
expect(
|
||||
block([JSON.stringify(["gym", "date night", "Feierabend"])])?.sideEffects,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
DoctorReportData,
|
||||
} from "@/lib/doctor-report-types";
|
||||
import { resolveMaxMedicationAdministrations } from "@/lib/doctor-report-helpers";
|
||||
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
|
||||
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
|
||||
|
||||
/** The medication columns the ledger and the GLP-1 block read. */
|
||||
export interface MedicationRowForReport {
|
||||
@@ -140,44 +142,6 @@ export function buildAdministrationLedger(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mood tags that mark a GLP-1 side effect, in both languages the tag
|
||||
* vocabulary ships. A tag not on this list is never counted, so a free-text
|
||||
* tag cannot leak into the block as an invented side effect.
|
||||
*/
|
||||
const SIDE_EFFECT_TAGS = new Set([
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
"übelkeit",
|
||||
"verstopfung",
|
||||
"durchfall",
|
||||
"müdigkeit",
|
||||
"appetitlosigkeit",
|
||||
"sodbrennen",
|
||||
"kopfschmerzen",
|
||||
]);
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed
|
||||
.map((v: unknown) => (typeof v === "string" ? v.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
} catch {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GLP-1 therapy block: current dose, dose history, last injection, the
|
||||
* weight delta over the window, and side-effect tallies.
|
||||
@@ -205,12 +169,16 @@ export function buildGlp1Block(params: {
|
||||
? Math.round((weightEndKg - weightStartKg) * 10) / 10
|
||||
: null;
|
||||
|
||||
const sideEffectCounts = new Map<string, number>();
|
||||
// Tallied by catalogue KEY, in every language the app ships. The tally used
|
||||
// to match the stored label against an English/German word list, so a
|
||||
// clinician reading a French, Spanish, Italian or Polish patient's report
|
||||
// saw "no side effects logged" over a record that had them. A tag outside
|
||||
// the catalogue is still never counted — a free-text mood tag must not
|
||||
// reach a doctor as an invented symptom.
|
||||
const sideEffectCounts = new Map<Glp1SideEffectTag, number>();
|
||||
for (const row of params.moodTagRows) {
|
||||
for (const tag of parseTags(row.tags ?? "")) {
|
||||
const lower = tag.toLowerCase();
|
||||
if (!SIDE_EFFECT_TAGS.has(lower)) continue;
|
||||
sideEffectCounts.set(lower, (sideEffectCounts.get(lower) ?? 0) + 1);
|
||||
for (const tag of matchGlp1SideEffectTags(row.tags).matched) {
|
||||
sideEffectCounts.set(tag, (sideEffectCounts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The GLP-1 side-effect tag catalogue, and the property the three readers
|
||||
* used to get wrong: a tag recorded in ANY shipped locale resolves to the same
|
||||
* key, so it contributes exactly as much as the English one.
|
||||
*
|
||||
* Before this module the readers matched the stored LABEL against a
|
||||
* hand-written English/German word list. A French, Spanish, Italian or Polish
|
||||
* account tapping the same chip contributed nothing to the timeline, the Coach
|
||||
* snapshot or the doctor report, and nothing said so.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import {
|
||||
GLP1_SIDE_EFFECT_TAGS,
|
||||
GLP1_SIDE_EFFECT_TAG_KEYS,
|
||||
GLP1_SIDE_EFFECT_TAG_LABEL_KEYS,
|
||||
normaliseSideEffectTag,
|
||||
parseMoodTagList,
|
||||
} from "../glp1-side-effect-tags";
|
||||
import {
|
||||
glp1SideEffectTagIndex,
|
||||
matchGlp1SideEffectTags,
|
||||
resolveGlp1SideEffectTag,
|
||||
} from "../glp1-side-effect-tag-match";
|
||||
|
||||
const ROOT = join(__dirname, "../../../..");
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
readFileSync(join(ROOT, `messages/${locale}.json`), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("GLP-1 side-effect tag catalogue", () => {
|
||||
it("resolves the chip label of every shipped locale to the same key", () => {
|
||||
for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) {
|
||||
for (const locale of locales) {
|
||||
const label = resolveKey(bundle(locale), labelKey);
|
||||
expect(
|
||||
label,
|
||||
`${labelKey} missing from messages/${locale}.json`,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
resolveGlp1SideEffectTag(label as string),
|
||||
`${locale} label "${label}" must resolve to ${key}`,
|
||||
).toBe(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a French-labelled tag exactly as an English one", () => {
|
||||
// The reported defect, stated as an equality rather than a spot check.
|
||||
for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) {
|
||||
const en = resolveKey(bundle("en"), labelKey) as string;
|
||||
const fr = resolveKey(bundle("fr"), labelKey) as string;
|
||||
expect(matchGlp1SideEffectTags(JSON.stringify([fr]))).toEqual(
|
||||
matchGlp1SideEffectTags(JSON.stringify([en])),
|
||||
);
|
||||
expect(matchGlp1SideEffectTags(JSON.stringify([fr])).matched).toEqual([
|
||||
key,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the Spanish, Italian and Polish chip labels", () => {
|
||||
// Named explicitly so a regression reads as "Polish stopped working"
|
||||
// rather than as an anonymous loop failure.
|
||||
expect(resolveGlp1SideEffectTag("Náuseas")).toBe("nausea");
|
||||
expect(resolveGlp1SideEffectTag("Pérdida de apetito")).toBe(
|
||||
"appetite-loss",
|
||||
);
|
||||
expect(resolveGlp1SideEffectTag("Bruciore di stomaco")).toBe("heartburn");
|
||||
expect(resolveGlp1SideEffectTag("Stitichezza")).toBe("constipation");
|
||||
expect(resolveGlp1SideEffectTag("Nudności")).toBe("nausea");
|
||||
expect(resolveGlp1SideEffectTag("Ból głowy")).toBe("headache");
|
||||
expect(resolveGlp1SideEffectTag("Zmęczenie")).toBe("fatigue");
|
||||
});
|
||||
|
||||
it("keeps matching the English and German tags already in the database", () => {
|
||||
// The pre-fix word list is the historical write vocabulary; every string
|
||||
// on it must survive the swap or existing records lose their history.
|
||||
for (const legacy of [
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
"übelkeit",
|
||||
"verstopfung",
|
||||
"durchfall",
|
||||
"müdigkeit",
|
||||
"appetitlosigkeit",
|
||||
"sodbrennen",
|
||||
"kopfschmerzen",
|
||||
]) {
|
||||
expect(
|
||||
resolveGlp1SideEffectTag(legacy),
|
||||
`legacy tag "${legacy}" must still resolve`,
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates case, accents and separator drift", () => {
|
||||
expect(resolveGlp1SideEffectTag(" NAUSEA ")).toBe("nausea");
|
||||
expect(resolveGlp1SideEffectTag("ubelkeit")).toBe("nausea");
|
||||
expect(resolveGlp1SideEffectTag("appetite loss")).toBe("appetite-loss");
|
||||
// The French bundle uses a typographic apostrophe; a keyboard produces a
|
||||
// straight one.
|
||||
expect(resolveGlp1SideEffectTag("Perte d'appétit")).toBe("appetite-loss");
|
||||
expect(resolveGlp1SideEffectTag("Perte d’appétit")).toBe("appetite-loss");
|
||||
});
|
||||
|
||||
it("refuses free text rather than inventing a side effect", () => {
|
||||
for (const free of ["gym", "date night", "Arbeit", "", " "]) {
|
||||
expect(resolveGlp1SideEffectTag(free)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts what it could not classify instead of dropping it silently", () => {
|
||||
const match = matchGlp1SideEffectTags(
|
||||
JSON.stringify(["Nudności", "siłownia", "rodzina"]),
|
||||
);
|
||||
expect(match.matched).toEqual(["nausea"]);
|
||||
expect(match.unresolvedCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deduplicates a key reached through two different spellings", () => {
|
||||
const match = matchGlp1SideEffectTags(
|
||||
JSON.stringify(["nausea", "Übelkeit", "Náuseas"]),
|
||||
);
|
||||
expect(match.matched).toEqual(["nausea"]);
|
||||
expect(match.unresolvedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("reads both stored column shapes", () => {
|
||||
expect(parseMoodTagList('["nausea","gym"]')).toEqual(["nausea", "gym"]);
|
||||
expect(parseMoodTagList("nausea, gym")).toEqual(["nausea", "gym"]);
|
||||
expect(parseMoodTagList(null)).toEqual([]);
|
||||
expect(matchGlp1SideEffectTags("Übelkeit, Sport").matched).toEqual([
|
||||
"nausea",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps every key to a label key that resolves in all six locales", () => {
|
||||
for (const key of GLP1_SIDE_EFFECT_TAG_KEYS) {
|
||||
const labelKey = GLP1_SIDE_EFFECT_TAG_LABEL_KEYS[key];
|
||||
expect(labelKey, `${key} has no label key`).toBeTruthy();
|
||||
for (const locale of locales) {
|
||||
expect(
|
||||
resolveKey(bundle(locale), labelKey),
|
||||
`${labelKey} missing from messages/${locale}.json`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("folds no two catalogue entries onto the same index row", () => {
|
||||
// A collision would make one symptom shadow another. Cheap to assert, and
|
||||
// the only way a new locale can break the index.
|
||||
const index = glp1SideEffectTagIndex();
|
||||
const byKey = new Map<string, string[]>();
|
||||
for (const [folded, key] of index) {
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), folded]);
|
||||
}
|
||||
expect([...byKey.keys()].sort()).toEqual(
|
||||
[...GLP1_SIDE_EFFECT_TAG_KEYS].sort(),
|
||||
);
|
||||
// Every catalogue entry carries at least its own key plus the six labels
|
||||
// it ships under, minus whatever two locales happen to spell identically.
|
||||
for (const key of GLP1_SIDE_EFFECT_TAG_KEYS) {
|
||||
expect(
|
||||
(byKey.get(key) ?? []).length,
|
||||
`${key} resolves from too few spellings — a locale is missing`,
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers a locale the day it is added, with no edit here", () => {
|
||||
// The property that closes the class: every locale in `locales` has its
|
||||
// labels in the index already. Adding messages/xx.json and listing it in
|
||||
// `locales` is the whole change.
|
||||
const index = glp1SideEffectTagIndex();
|
||||
for (const locale of locales) {
|
||||
for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) {
|
||||
const label = resolveKey(bundle(locale), labelKey) as string;
|
||||
expect(index.get(normaliseSideEffectTag(label))).toBe(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The GLP-1 side-effect tag catalogue — server-only matching half.
|
||||
*
|
||||
* Turns a stored flat mood tag back into the catalogue key it was written
|
||||
* from, in any locale the app ships.
|
||||
*
|
||||
* The index is DERIVED from `messages/<locale>.json`, not transcribed from it.
|
||||
* That is the load-bearing part. The three readers this replaces each carried
|
||||
* their own hand-written word list, covering English and German because those
|
||||
* were the two languages the maintainer typed in; the four other shipped
|
||||
* locales silently matched nothing, and the three lists had already drifted
|
||||
* apart from each other. A transcription would fix today's four locales and
|
||||
* reopen the same hole the day a seventh is added. Reading the bundles means a
|
||||
* new locale needs no edit in this file — `locales` grows, `messages/xx.json`
|
||||
* lands, and the labels that ship in it are matchable the same day.
|
||||
*
|
||||
* `allMessages` statically imports all six bundles and is SERVER-ONLY: do not
|
||||
* import this module from a client component. The three callers (the timeline
|
||||
* route, the Coach snapshot builder, the doctor-report aggregator) all run on
|
||||
* the server. A browser that needs to render a key resolves it through
|
||||
* `GLP1_SIDE_EFFECT_TAG_LABEL_KEYS` in the isomorphic half instead.
|
||||
*
|
||||
* Known limit, stated rather than engineered around: the index is built from
|
||||
* the labels a bundle carries TODAY, and it is applied to tags written in the
|
||||
* past. Editing a shipped translation therefore orphans the rows written under
|
||||
* the old wording. If a label has to change, keep the old string reachable —
|
||||
* the fix is a legacy alias here, not a data migration.
|
||||
*/
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { allMessages, resolveKey } from "@/lib/i18n/shared-resolve";
|
||||
import {
|
||||
GLP1_SIDE_EFFECT_TAGS,
|
||||
normaliseSideEffectTag,
|
||||
parseMoodTagList,
|
||||
type Glp1SideEffectTag,
|
||||
} from "./glp1-side-effect-tags";
|
||||
|
||||
/**
|
||||
* Normalised label (and canonical key) → catalogue key, across every shipped
|
||||
* locale. Built once at module load: 7 entries × 6 bundles is a handful of
|
||||
* string folds, and the bundles are already resident for the server
|
||||
* translator.
|
||||
*
|
||||
* Two locales spelling one catalogue entry the same way ("Constipation" in
|
||||
* English and French) is expected and folds onto one row. Two DIFFERENT
|
||||
* entries folding together would be a bundle bug; it resolves first-wins here
|
||||
* — a typo must not take down every GLP-1 surface at boot — and the guard
|
||||
* suite fails on it through `glp1SideEffectTagIndex()`.
|
||||
*/
|
||||
function buildIndex(): Map<string, Glp1SideEffectTag> {
|
||||
const index = new Map<string, Glp1SideEffectTag>();
|
||||
for (const { key, labelKey } of GLP1_SIDE_EFFECT_TAGS) {
|
||||
// The canonical key itself, so a client or importer that already speaks
|
||||
// keys round-trips without going through a label.
|
||||
index.set(normaliseSideEffectTag(key), key);
|
||||
for (const locale of locales) {
|
||||
const label = resolveKey(allMessages[locale], labelKey);
|
||||
if (!label) continue;
|
||||
const folded = normaliseSideEffectTag(label);
|
||||
if (!folded || index.has(folded)) continue;
|
||||
index.set(folded, key);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const TAG_INDEX = buildIndex();
|
||||
|
||||
/**
|
||||
* Test seam: the guard suite asserts the index covers every locale and that no
|
||||
* two catalogue entries fold together. Production reads `TAG_INDEX` directly.
|
||||
*/
|
||||
export function glp1SideEffectTagIndex(): ReadonlyMap<
|
||||
string,
|
||||
Glp1SideEffectTag
|
||||
> {
|
||||
return TAG_INDEX;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single stored tag → its catalogue key, or `null` when the tag is not a
|
||||
* catalogue side effect.
|
||||
*
|
||||
* `null` is the honest answer for genuine free text ("gym", "date night"):
|
||||
* those are mood tags, not side effects, and admitting them would invent
|
||||
* symptoms in a doctor's report. What can no longer produce `null` is a tag
|
||||
* the app's own chip strip wrote, in any shipped language.
|
||||
*/
|
||||
export function resolveGlp1SideEffectTag(
|
||||
raw: string,
|
||||
): Glp1SideEffectTag | null {
|
||||
return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null;
|
||||
}
|
||||
|
||||
/** What one `MoodEntry.tags` column contributed. */
|
||||
export interface Glp1SideEffectTagMatch {
|
||||
/** Catalogue keys found, deduplicated, in the order they were written. */
|
||||
matched: Glp1SideEffectTag[];
|
||||
/**
|
||||
* How many tags on the row were not catalogue entries. Carried so callers
|
||||
* can report the miss rate instead of dropping the tags without trace; it is
|
||||
* a COUNT and never the tag text, because the text is the user's own prose
|
||||
* and these surfaces (a therapy timeline, a clinician PDF, a model prompt)
|
||||
* are exactly where it must not turn up uninvited.
|
||||
*/
|
||||
unresolvedCount: number;
|
||||
}
|
||||
|
||||
/** Parse a stored tags column and resolve every tag on it. */
|
||||
export function matchGlp1SideEffectTags(
|
||||
rawTagsColumn: string | null | undefined,
|
||||
): Glp1SideEffectTagMatch {
|
||||
const matched: Glp1SideEffectTag[] = [];
|
||||
let unresolvedCount = 0;
|
||||
for (const raw of parseMoodTagList(rawTagsColumn)) {
|
||||
const key = resolveGlp1SideEffectTag(raw);
|
||||
if (!key) {
|
||||
unresolvedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (!matched.includes(key)) matched.push(key);
|
||||
}
|
||||
return { matched, unresolvedCount };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The GLP-1 side-effect tag catalogue — isomorphic half.
|
||||
*
|
||||
* A mood entry carries two tag axes: the structured `MoodEntryTagLink`
|
||||
* taxonomy, and the flat free-text `MoodEntry.tags` column. The GLP-1
|
||||
* side-effect chips in the mood form write onto the FLAT axis, and they write
|
||||
* `t(labelKey)` — the label in whatever language the writer's UI was in. So
|
||||
* the column holds "nausea" for an English account and "Nudności" for a Polish
|
||||
* one, for the same recorded symptom.
|
||||
*
|
||||
* The chips are nonetheless a CLOSED set with stable keys. That is the whole
|
||||
* point of this module: the timeline, the Coach snapshot and the doctor report
|
||||
* used to match the stored label against a hand-written English/German word
|
||||
* list, which meant a French, Spanish, Italian or Polish account recorded a
|
||||
* side effect and every downstream surface counted zero. Matching the label
|
||||
* was the defect; the key is the fix.
|
||||
*
|
||||
* This file holds only what a browser may also need — the key set and each
|
||||
* key's label key, so the PDF renderer can print a canonical key in the
|
||||
* reader's language. The label → key direction needs every locale's bundle and
|
||||
* therefore lives server-side in `./glp1-side-effect-tag-match`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The catalogue, in display order. `key` is the stable machine value that
|
||||
* crosses the API and the Coach prompt; `labelKey` is the i18n key the chip
|
||||
* renders and — through the six bundles — the set of strings that can appear
|
||||
* in the stored column.
|
||||
*
|
||||
* Adding an entry here is a product decision about what the capture UI asks:
|
||||
* the strip is deliberately short, covering the symptoms clinicians ask about
|
||||
* at a GLP-1 follow-up. Adding a LOCALE, by contrast, needs no edit here at
|
||||
* all — the matcher reads whatever `messages/<locale>.json` says.
|
||||
*
|
||||
* The Coach's own copy of the old word list also carried `vomiting`, `reflux`
|
||||
* and `erbrechen`. Those are gone on purpose. No chip writes them, so they
|
||||
* only ever matched a hand-typed tag, and only in English and German — an
|
||||
* English speaker who typed "vomiting" got a line and an Italian who typed
|
||||
* "vomito" did not. Keeping them would have rebuilt the defect inside its own
|
||||
* fix. Bringing either symptom back means giving it a chip and a label in all
|
||||
* six bundles, which is the product decision above.
|
||||
*/
|
||||
export const GLP1_SIDE_EFFECT_TAGS = [
|
||||
{ key: "nausea", labelKey: "medications.sideEffectTagNausea" },
|
||||
{ key: "constipation", labelKey: "medications.sideEffectTagConstipation" },
|
||||
{ key: "diarrhea", labelKey: "medications.sideEffectTagDiarrhea" },
|
||||
{ key: "fatigue", labelKey: "medications.sideEffectTagFatigue" },
|
||||
{ key: "appetite-loss", labelKey: "medications.sideEffectTagAppetiteLoss" },
|
||||
{ key: "heartburn", labelKey: "medications.sideEffectTagHeartburn" },
|
||||
{ key: "headache", labelKey: "medications.sideEffectTagHeadache" },
|
||||
] as const;
|
||||
|
||||
/** Stable machine key of a catalogue side effect. */
|
||||
export type Glp1SideEffectTag = (typeof GLP1_SIDE_EFFECT_TAGS)[number]["key"];
|
||||
|
||||
/** Catalogue order, for callers that only need the keys. */
|
||||
export const GLP1_SIDE_EFFECT_TAG_KEYS: readonly Glp1SideEffectTag[] =
|
||||
GLP1_SIDE_EFFECT_TAGS.map((t) => t.key);
|
||||
|
||||
/**
|
||||
* Key → i18n label key. Every surface that shows a canonical key to a person
|
||||
* (the doctor-report PDF, any client rendering the timeline) resolves it
|
||||
* through here so the reader sees their own language rather than the language
|
||||
* the tag happened to be typed in.
|
||||
*/
|
||||
export const GLP1_SIDE_EFFECT_TAG_LABEL_KEYS: Record<
|
||||
Glp1SideEffectTag,
|
||||
string
|
||||
> = Object.fromEntries(
|
||||
GLP1_SIDE_EFFECT_TAGS.map((t) => [t.key, t.labelKey]),
|
||||
) as Record<Glp1SideEffectTag, string>;
|
||||
|
||||
/**
|
||||
* Fold a tag string to its comparison form.
|
||||
*
|
||||
* Applied identically to the stored tag and to every catalogue label, so the
|
||||
* two only have to agree up to case, accents and separators. That tolerance is
|
||||
* what lets "Perte d'appétit" typed with a straight quote match the bundle's
|
||||
* "Perte d’appétit", and "ubelkeit" match "Übelkeit" — without it the matcher
|
||||
* would be exact-string again, one keyboard away from the defect it replaces.
|
||||
*
|
||||
* Note the tolerance is one-directional folding, not transliteration: German
|
||||
* "ue" for "ü" does not fold to the same form, and the Polish ł is a single
|
||||
* codepoint that NFD leaves alone. Both are fine — the label the chip wrote is
|
||||
* the label the matcher indexes.
|
||||
*/
|
||||
export function normaliseSideEffectTag(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[\u2018\u2019\u02bc'`]/g, " ")
|
||||
.replace(/[-_/]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the flat `MoodEntry.tags` column.
|
||||
*
|
||||
* The column carries either a JSON array (mood-form writes) or a
|
||||
* comma-separated list (legacy imports). Be permissive — this used to be
|
||||
* copy-pasted into all three readers, which is how their tag vocabularies
|
||||
* drifted apart in the first place.
|
||||
*/
|
||||
export function parseMoodTagList(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the CSV form */
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -2606,10 +2606,20 @@ export const glp1TimelineResponse = z
|
||||
inventoryDelta: z.number().int().optional(),
|
||||
reason: z.string().optional(),
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.array(
|
||||
z.enum([
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
]),
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and German tag strings, so a tag written in any other language — or spelled differently — does not appear here at all.",
|
||||
"Catalogue keys of the side effects tagged that day, deduplicated. These are STABLE KEYS, not the strings the entries were written with: the capture chip writes its label in the writer's language, and the matcher indexes every shipped locale's label back to the key, so the same recorded symptom yields the same key whether it was tagged in German, English, Spanish, French, Italian or Polish. Render the key in the reader's own language rather than echoing it. A mood tag that is not a catalogue side effect — anything the user typed freehand — is not reported here; it is not a side effect, and a therapy timeline is not the place for the rest of somebody's day.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user