mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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