mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(medications): once-weekly cadence preset for GLP1 category
Phase 3 of W4d GLP-1 full integration. The form gains a small "Treatment class" selector (GENERIC | GLP-1 injection) sitting under the existing clinical-category dropdown. Picking GLP-1 reveals two helpers that stay hidden for every other medication: - "Once weekly on …" preset — a single-tap weekday row that writes daysOfWeek=[N] + intervalWeeks=1 into the first schedule and reveals the advanced block so the user can still tune the window. The last weekday picked persists in localStorage so a follow-up med starts on the same day. - "Doses per pen / vial" optional input that powers the inventory math (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1). Leaving it blank keeps inventory tracking off. The wire schema gains optional `treatmentClass` and `dosesPerUnit` fields. Both default sensibly (GENERIC + null) so the v1.4.24 client keeps working untouched. Marc directive 2026-05-14: GLP-1 must integrate "harmonisch", not crowd the form. The new affordances live behind the treatment-class toggle — for every other med the form is byte-identical to v1.4.24.
This commit is contained in:
1 parent
c7eb226969
commit
94cc7f571d
7 files changed
+279
-3
No files matched your search
@@ -516,6 +516,39 @@
|
||||
"formDose": "Dosis",
|
||||
"formUnit": "Einheit",
|
||||
"formSchedule": "Zeitfenster",
|
||||
"formTreatmentClass": "Behandlungsklasse",
|
||||
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte, die Dosis-Historie und den Pen-Bestand unten frei.",
|
||||
"treatmentClassGeneric": "Standard-Medikament",
|
||||
"treatmentClassGlp1": "GLP-1-Injektion",
|
||||
"glp1WeeklyPresetTitle": "Einmal wöchentlich am …",
|
||||
"glp1WeeklyPresetHelp": "Die meisten GLP-1-Rezeptoragonisten werden einmal pro Woche injiziert. Tippe einen Tag, um den Rhythmus zu setzen.",
|
||||
"glp1WeeklyPresetSuggest": "Letzten gewählten Wochentag übernehmen",
|
||||
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
|
||||
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — setzt die Reichweite-Anzeige im Dashboard frei. Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, um die Bestandsverfolgung auszuschalten.",
|
||||
"glp1Headline": "{name} · {dose}",
|
||||
"glp1LastInjection": "Letzter Termin: {label}",
|
||||
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
|
||||
"glp1NextInjection": "Nächster Termin: {label}",
|
||||
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
|
||||
"glp1NextInjectionToday": "Nächster Termin: heute",
|
||||
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
|
||||
"glp1DoseHistory": "Dosis-Historie",
|
||||
"glp1DoseHistoryEmpty": "Noch keine Dosis-Änderung erfasst.",
|
||||
"glp1DoseSince": "seit {date}",
|
||||
"glp1RotationLast": "Letzte Stelle: {site}",
|
||||
"glp1RotationSuggested": "Empfohlen: {site}",
|
||||
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
|
||||
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
|
||||
"glp1LogSideEffect": "Nebenwirkung loggen",
|
||||
"siteAbdomenLeft": "Bauch, unten links",
|
||||
"siteAbdomenRight": "Bauch, unten rechts",
|
||||
"siteAbdomenUpperLeft": "Bauch, oben links",
|
||||
"siteAbdomenUpperRight": "Bauch, oben rechts",
|
||||
"siteThighLeft": "Linker Oberschenkel",
|
||||
"siteThighRight": "Rechter Oberschenkel",
|
||||
"siteUpperArmLeft": "Linker Oberarm",
|
||||
"siteUpperArmRight": "Rechter Oberarm",
|
||||
"newSchedule": "Neues Zeitfenster",
|
||||
"removeSchedule": "Zeitfenster entfernen",
|
||||
"advancedEditing": "Erweiterte Bearbeitung",
|
||||
|
||||
@@ -516,6 +516,39 @@
|
||||
"formDose": "Dose",
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
"glp1WeeklyPresetHelp": "Most GLP-1 receptor agonists are once-weekly. Tap a day to set the cadence.",
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1Headline": "{name} · {dose}",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjection": "Next: {label}",
|
||||
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"siteAbdomenLeft": "Abdomen, lower left",
|
||||
"siteAbdomenRight": "Abdomen, lower right",
|
||||
"siteAbdomenUpperLeft": "Abdomen, upper left",
|
||||
"siteAbdomenUpperRight": "Abdomen, upper right",
|
||||
"siteThighLeft": "Left thigh",
|
||||
"siteThighRight": "Right thigh",
|
||||
"siteUpperArmLeft": "Left upper arm",
|
||||
"siteUpperArmRight": "Right upper arm",
|
||||
"newSchedule": "New schedule",
|
||||
"removeSchedule": "Remove schedule",
|
||||
"advancedEditing": "Advanced editing",
|
||||
|
||||
@@ -71,8 +71,16 @@ export const PUT = apiHandler(
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { name, dose, category, active, notificationsEnabled, schedules } =
|
||||
parsed.data;
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
} = parsed.data;
|
||||
|
||||
const pausedAtPatch =
|
||||
active === undefined
|
||||
@@ -93,6 +101,8 @@ export const PUT = apiHandler(
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
...(schedules && {
|
||||
|
||||
@@ -83,6 +83,11 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess(
|
||||
medications.map((m) => ({
|
||||
...m,
|
||||
// v1.4.25 W4d — `category` is the existing clinical taxonomy
|
||||
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
|
||||
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
|
||||
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
|
||||
// orthogonal concepts, both exposed.
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
@@ -101,13 +106,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { name, dose, category, schedules } = parsed.data;
|
||||
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
|
||||
parsed.data;
|
||||
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
dose,
|
||||
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
schedules: {
|
||||
create: schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
|
||||
@@ -56,6 +56,10 @@ interface Medication {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -253,6 +257,12 @@ export default function MedicationsPage() {
|
||||
name: editingMed.name,
|
||||
dose: editingMed.dose,
|
||||
category: editingMed.category,
|
||||
// v1.4.25 W4d — pass through the Prisma treatment-class
|
||||
// discriminator and the optional dosesPerUnit so the
|
||||
// form preselects the GLP-1 surfaces correctly when
|
||||
// editing an existing GLP-1 row.
|
||||
treatmentClass: editingMed.treatmentClass,
|
||||
dosesPerUnit: editingMed.dosesPerUnit ?? null,
|
||||
active: editingMed.active,
|
||||
notificationsEnabled: editingMed.notificationsEnabled,
|
||||
schedules: editingMed.schedules.map((s) => ({
|
||||
|
||||
@@ -168,6 +168,10 @@ interface MedicationFormProps {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.4.25 W4d — Prisma treatment class. Defaults to GENERIC. */
|
||||
treatmentClass?: string;
|
||||
/** v1.4.25 W4d — only relevant for inventory-tracked meds. */
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled?: boolean;
|
||||
schedules: Array<{
|
||||
@@ -257,6 +261,16 @@ export function MedicationForm({
|
||||
const [doseUnit, setDoseUnit] = useState(initialDose.unit);
|
||||
|
||||
const [category, setCategory] = useState(initial?.category ?? "OTHER");
|
||||
// v1.4.25 W4d — treatment class. GLP1 unlocks the weekly-cadence
|
||||
// preset below and the GLP-1 medication-card variant on the medications
|
||||
// page. The default GENERIC keeps every existing medication rendering
|
||||
// exactly as before.
|
||||
const [treatmentClass, setTreatmentClass] = useState(
|
||||
initial?.treatmentClass ?? "GENERIC",
|
||||
);
|
||||
const [dosesPerUnit, setDosesPerUnit] = useState<string>(
|
||||
initial?.dosesPerUnit ? String(initial.dosesPerUnit) : "",
|
||||
);
|
||||
const [active, setActive] = useState(initial?.active ?? true);
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(
|
||||
initial?.notificationsEnabled ?? true,
|
||||
@@ -341,12 +355,58 @@ export function MedicationForm({
|
||||
setDoseAmount("");
|
||||
setDoseUnit("");
|
||||
setCategory("OTHER");
|
||||
setTreatmentClass("GENERIC");
|
||||
setDosesPerUnit("");
|
||||
setActive(true);
|
||||
setNotificationsEnabled(true);
|
||||
setSchedules([{ ...DEFAULT_SCHEDULE }]);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — "Once weekly on …" preset. Marc directive
|
||||
* 2026-05-14: GLP-1 receptor agonists are weekly meds, and the
|
||||
* existing form buries that behind two advanced toggles. The preset
|
||||
* collapses the picker to a single weekday button row that writes
|
||||
* MedicationSchedule.daysOfWeek correctly and remembers the choice
|
||||
* locally so the next weekly med starts on the same weekday.
|
||||
*
|
||||
* Persisted to localStorage as a one-row preference so the convenience
|
||||
* survives across sessions — non-invasive (key is namespaced) and
|
||||
* web-only (the iOS surface uses its own picker).
|
||||
*/
|
||||
function applyWeeklyPreset(weekday: number) {
|
||||
try {
|
||||
localStorage.setItem("medication-form:last-weekly-weekday", String(weekday));
|
||||
} catch {
|
||||
/* private mode — silent */
|
||||
}
|
||||
setSchedules((prev) =>
|
||||
sortSchedules(
|
||||
prev.map((s, i) =>
|
||||
i === 0
|
||||
? {
|
||||
...s,
|
||||
daysOfWeek: [weekday],
|
||||
intervalWeeks: 1,
|
||||
showAdvanced: true,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function readLastWeeklyWeekday(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem("medication-form:last-weekly-weekday");
|
||||
const n = raw === null ? NaN : Number(raw);
|
||||
return Number.isInteger(n) && n >= 0 && n <= 6 ? n : 3; // Wednesday default
|
||||
} catch {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -363,6 +423,12 @@ export function MedicationForm({
|
||||
name,
|
||||
dose,
|
||||
category,
|
||||
treatmentClass,
|
||||
...(dosesPerUnit
|
||||
? { dosesPerUnit: Number(dosesPerUnit) }
|
||||
: isEdit
|
||||
? { dosesPerUnit: null }
|
||||
: {}),
|
||||
...(isEdit
|
||||
? {
|
||||
active,
|
||||
@@ -504,6 +570,33 @@ export function MedicationForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W4d — treatment-class selector. Surfaces the GLP-1
|
||||
option without crowding the existing clinical-category
|
||||
dropdown (those are orthogonal taxonomies). GENERIC keeps the
|
||||
form rendering exactly as v1.4.24; GLP1 opens the
|
||||
weekly-cadence preset + pen-inventory inputs below. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="med-treatment-class">
|
||||
{t("medications.formTreatmentClass")}
|
||||
</Label>
|
||||
<Select value={treatmentClass} onValueChange={setTreatmentClass}>
|
||||
<SelectTrigger id="med-treatment-class" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GENERIC">
|
||||
{t("medications.treatmentClassGeneric")}
|
||||
</SelectItem>
|
||||
<SelectItem value="GLP1">
|
||||
{t("medications.treatmentClassGlp1")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs leading-4">
|
||||
{t("medications.formTreatmentClassHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="med-dose-amount">{t("medications.formDose")}</Label>
|
||||
@@ -537,6 +630,75 @@ export function MedicationForm({
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{treatmentClass === "GLP1" && (
|
||||
<>
|
||||
{/* v1.4.25 W4d — once-weekly preset. One tap on a weekday
|
||||
writes daysOfWeek=[N] + intervalWeeks=1 into the first
|
||||
schedule and reveals the advanced section so the user can
|
||||
still tune the window. Persists the last pick across
|
||||
sessions for convenience. */}
|
||||
<div className="border-border/70 bg-card/60 space-y-2.5 rounded-xl border p-3.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm leading-none">
|
||||
{t("medications.glp1WeeklyPresetTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs leading-4">
|
||||
{t("medications.glp1WeeklyPresetHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-1">
|
||||
{getDayLabelsShort(t).map((label, dayIndex) => {
|
||||
const isSelected =
|
||||
schedules[0]?.daysOfWeek.length === 1 &&
|
||||
schedules[0]?.daysOfWeek[0] === dayIndex;
|
||||
return (
|
||||
<button
|
||||
key={dayIndex}
|
||||
type="button"
|
||||
onClick={() => applyWeeklyPreset(dayIndex)}
|
||||
className={`h-9 flex-1 rounded-md border text-xs font-medium transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!schedules.some((s) => s.daysOfWeek.length === 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyWeeklyPreset(readLastWeeklyWeekday())}
|
||||
className="text-primary text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("medications.glp1WeeklyPresetSuggest")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="med-doses-per-unit">
|
||||
{t("medications.glp1DosesPerUnit")}
|
||||
</Label>
|
||||
<Input
|
||||
id="med-doses-per-unit"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
value={dosesPerUnit}
|
||||
onChange={(e) => setDosesPerUnit(e.target.value)}
|
||||
placeholder={t("medications.glp1DosesPerUnitPlaceholder")}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-4">
|
||||
{t("medications.glp1DosesPerUnitHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-8 items-center justify-between">
|
||||
<Label className="text-sm leading-none">
|
||||
|
||||
@@ -15,6 +15,18 @@ export const MEDICATION_CATEGORY_VALUES = [
|
||||
"OTHER",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Prisma-level treatment class. Orthogonal to
|
||||
* `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the
|
||||
* `medication_categories` side-table). GLP1 turns on the GLP-1
|
||||
* specialist surfaces — injection-site picker, titration history, pen
|
||||
* inventory, GLP-1-aware Coach replies. Future treatment classes drop
|
||||
* into this list (INSULIN, BIOLOGIC, FERTILITY, …).
|
||||
*/
|
||||
export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
|
||||
export type MedicationTreatmentClass =
|
||||
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
|
||||
|
||||
export const scheduleSchema = z.object({
|
||||
windowStart: z.string().regex(timeRegex, "Format: HH:mm"),
|
||||
windowEnd: z.string().regex(timeRegex, "Format: HH:mm"),
|
||||
@@ -28,6 +40,10 @@ export const createMedicationSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
dose: z.string().min(1).max(50),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
/** v1.4.25 W4d — doses per pen/vial for inventory tracking. */
|
||||
dosesPerUnit: z.number().int().min(1).max(100).optional(),
|
||||
schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"),
|
||||
});
|
||||
|
||||
@@ -35,6 +51,8 @@ export const updateMedicationSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
dose: z.string().min(1).max(50).optional(),
|
||||
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
|
||||
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
|
||||
dosesPerUnit: z.number().int().min(1).max(100).nullable().optional(),
|
||||
active: z.boolean().optional(),
|
||||
notificationsEnabled: z.boolean().optional(),
|
||||
schedules: z.array(scheduleSchema).optional(),
|
||||
|
||||
Reference in new issue
Block a user