feat: persist package amount metadata in backend (#356)

* feat: add package amount persistence and backend route support

* test: align backend test schemas with medication metadata fields

* fix(backend): restore intake usage normalizer for planner endpoint

* fix(backend): keep export typing compatible before liquid-unit stack step
This commit is contained in:
Daniel Volz
2026-02-28 23:21:13 +01:00
committed by GitHub
parent 2f2edfa479
commit 7accb2aad6
14 changed files with 2887 additions and 18 deletions
@@ -0,0 +1,5 @@
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1771694832866,
"tag": "0010_mean_spot",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1772219947541,
"tag": "0011_stiff_randall_flagg",
"breakpoints": true
}
]
}
+8
View File
@@ -125,6 +125,14 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
// Added for explicit medication lifecycle start date
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
// Added for form/lifecycle modeling (V1 medication forms)
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
`ALTER TABLE medications ADD COLUMN pill_form text`,
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
+9
View File
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
@@ -48,6 +53,10 @@ export const medications = sqliteTable("medications", {
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
medicationStartDate: text("medication_start_date").notNull().default(""),
medicationEndDate: text("medication_end_date"),
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
.notNull()
.default(true),
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
+34 -5
View File
@@ -17,7 +17,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.1";
const EXPORT_VERSION = "1.3";
// =============================================================================
// Zod Schemas for Import Validation
@@ -27,6 +27,7 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
});
@@ -38,7 +39,9 @@ const inventorySchema = z.object({
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"),
packageType: z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
const medicationExportSchema = z.object({
@@ -46,11 +49,16 @@ const medicationExportSchema = z.object({
name: z.string().min(1),
genericName: z.string().nullable().optional(),
takenBy: z.array(z.string()).default([]),
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
@@ -155,9 +163,14 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
}
// Parse intakes from DB format to export format (with per-intake takenBy)
function parseIntakesForExport(
row: typeof medications.$inferSelect
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
}> {
// Use the new parseIntakesJson which falls back to legacy format
const intakes = parseIntakesJson(
row.intakesJson,
@@ -169,6 +182,7 @@ function parseIntakesForExport(
usage: intake.usage,
every: intake.every,
start: intake.start,
intakeUnit: null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -295,6 +309,9 @@ export async function exportRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
@@ -303,11 +320,15 @@ export async function exportRoutes(app: FastifyInstance) {
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -555,6 +576,7 @@ export async function exportRoutes(app: FastifyInstance) {
usage: s.usage,
every: s.every,
start: s.start,
intakeUnit: s.intakeUnit ?? null,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
@@ -570,7 +592,12 @@ export async function exportRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: med.inventory.packageType ?? "blister",
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
@@ -581,6 +608,8 @@ export async function exportRoutes(app: FastifyInstance) {
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
+184 -8
View File
@@ -14,7 +14,13 @@ import {
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
import {
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
@@ -23,6 +29,7 @@ const intakeSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime({ local: true }),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
});
@@ -34,26 +41,37 @@ const blisterSchema = z.object({
start: z.string().datetime({ local: true }),
});
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
const packageTypeSchema = z.enum(["blister", "bottle", "tube", "liquid_container"]).default("blister");
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
const pillFormSchema = z.enum(["capsule", "tablet"]);
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
const medicationStartDateSchema = z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
.optional();
const medicationEndDateSchema = z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]).optional();
const medicationSchema = z
.object({
name: z.string().trim().max(100).default(""),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
medicationForm: medicationFormSchema,
pillForm: pillFormSchema.nullable().optional(),
lifecycleCategory: lifecycleCategorySchema,
packageType: packageTypeSchema,
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().nonnegative().nullable().optional(),
doseUnit: doseUnitSchema,
medicationStartDate: medicationStartDateSchema,
medicationEndDate: medicationEndDateSchema,
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
prescriptionEnabled: z.boolean().default(false),
@@ -84,6 +102,77 @@ const medicationSchema = z
path: ["medicationStartDate"],
}
)
.refine(
(data) => {
const startDate = data.medicationStartDate ?? "";
const endDate = data.medicationEndDate ?? "";
if (!startDate || !endDate) return true;
return startDate <= endDate;
},
{
message: "Medication end date must be on or after medication start date",
path: ["medicationEndDate"],
}
)
.refine(
(data) => {
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
return data.pillForm == null || data.pillForm === "capsule" || data.pillForm === "tablet";
}
return true;
},
{
message: "pillForm must be capsule or tablet for capsule/tablet medications",
path: ["pillForm"],
}
)
.refine(
(data) => {
if (data.medicationForm === "topical") {
return data.packageType === "tube";
}
return true;
},
{
message: "Topical medications must use tube package type",
path: ["packageType"],
}
)
.refine(
(data) => {
if (data.medicationForm === "liquid") {
return data.packageType === "liquid_container";
}
return true;
},
{
message: "Liquid medications must use liquid_container package type",
path: ["packageType"],
}
)
.refine(
(data) => {
if (data.medicationForm === "capsule" || data.medicationForm === "tablet") {
return data.packageType !== "tube" && data.packageType !== "liquid_container";
}
return true;
},
{
message: "Capsule and tablet medications cannot use tube or liquid_container package type",
path: ["packageType"],
}
)
.refine(
(data) => {
const schedules = data.intakes ?? data.blisters ?? [];
if (data.pillForm !== "capsule") return true;
return schedules.every((entry) => Number.isInteger(entry.usage));
},
{
message: "Fractional intake is not allowed for capsule",
path: ["intakes"],
}
)
.refine(
(data) => {
if (!data.prescriptionEnabled) return true;
@@ -131,6 +220,26 @@ export async function medicationRoutes(app: FastifyInstance) {
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
const userId = await getUserId(request, reply);
const includeObsolete = request.query.includeObsolete === "true";
const initialRows = await db
.select()
.from(medications)
.where(eq(medications.userId, userId))
.orderBy(medications.id);
const todayDate = new Date().toISOString().slice(0, 10);
for (const row of initialRows) {
if (row.isObsolete) continue;
if (!(row.autoMarkObsoleteAfterEndDate ?? true)) continue;
const endDate = row.medicationEndDate?.slice(0, 10);
if (!endDate) continue;
if (endDate > todayDate) continue;
await db
.update(medications)
.set({ isObsolete: true, obsoleteAt: new Date(), updatedAt: new Date() })
.where(and(eq(medications.id, row.id), eq(medications.userId, userId)));
}
const whereClause = includeObsolete
? eq(medications.userId, userId)
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
@@ -148,10 +257,15 @@ export async function medicationRoutes(app: FastifyInstance) {
name: row.name,
genericName: row.genericName,
takenBy: parseTakenByJson(row.takenByJson),
medicationForm: row.medicationForm ?? "tablet",
pillForm: row.pillForm ?? null,
lifecycleCategory: row.lifecycleCategory ?? "refill_when_empty",
packageType: row.packageType ?? "blister",
packCount: row.packCount ?? 1,
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
packageAmountValue: row.packageAmountValue ?? 0,
packageAmountUnit: (row.packageAmountUnit ?? "ml") as "ml" | "g",
totalPills: row.totalPills ?? null,
looseTablets: row.looseTablets ?? 0,
stockAdjustment: row.stockAdjustment ?? 0,
@@ -159,6 +273,8 @@ export async function medicationRoutes(app: FastifyInstance) {
pillWeightMg: row.pillWeightMg,
doseUnit: row.doseUnit ?? "mg",
medicationStartDate: row.medicationStartDate || null,
medicationEndDate: row.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: row.autoMarkObsoleteAfterEndDate ?? true,
intakes, // New unified format with per-intake takenBy
// Legacy blisters format (for backward compat with frontend during transition)
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
@@ -188,15 +304,22 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName,
takenBy,
medicationForm,
pillForm,
lifecycleCategory,
packageType,
packCount,
blistersPerPack,
pillsPerBlister,
packageAmountValue,
packageAmountUnit,
totalPills,
looseTablets,
pillWeightMg,
doseUnit,
medicationStartDate,
medicationEndDate,
autoMarkObsoleteAfterEndDate,
expiryDate,
notes,
prescriptionEnabled,
@@ -209,6 +332,9 @@ export async function medicationRoutes(app: FastifyInstance) {
blisters: inputBlisters,
} = parsed.data;
const normalizedPillForm =
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
// Convert to unified intakes format
let intakes: Intake[];
if (inputIntakes) {
@@ -217,6 +343,7 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: i.usage,
every: i.every,
start: i.start,
intakeUnit: i.intakeUnit ?? null,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
@@ -226,6 +353,7 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
@@ -247,15 +375,22 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName: genericName || null,
takenByJson,
medicationForm: medicationForm ?? "tablet",
pillForm: normalizedPillForm,
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
packageType: packageType ?? "blister",
packCount,
blistersPerPack,
pillsPerBlister,
packageAmountValue,
packageAmountUnit,
totalPills: totalPills || null,
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
medicationStartDate: medicationStartDate ?? "",
medicationEndDate: medicationEndDate || null,
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
@@ -276,10 +411,15 @@ export async function medicationRoutes(app: FastifyInstance) {
name: inserted.name,
genericName: inserted.genericName,
takenBy: parseTakenByJson(inserted.takenByJson),
medicationForm: inserted.medicationForm ?? "tablet",
pillForm: inserted.pillForm ?? null,
lifecycleCategory: inserted.lifecycleCategory ?? "refill_when_empty",
packageType: inserted.packageType ?? "blister",
packCount: inserted.packCount,
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
packageAmountValue: inserted.packageAmountValue ?? 0,
packageAmountUnit: (inserted.packageAmountUnit ?? "ml") as "ml" | "g",
totalPills: inserted.totalPills ?? null,
looseTablets: inserted.looseTablets,
stockAdjustment: inserted.stockAdjustment ?? 0,
@@ -287,6 +427,8 @@ export async function medicationRoutes(app: FastifyInstance) {
pillWeightMg: inserted.pillWeightMg,
doseUnit: inserted.doseUnit ?? "mg",
medicationStartDate: inserted.medicationStartDate || null,
medicationEndDate: inserted.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: inserted.autoMarkObsoleteAfterEndDate ?? true,
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: inserted.imageUrl,
@@ -323,15 +465,22 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName,
takenBy,
medicationForm,
pillForm,
lifecycleCategory,
packageType,
packCount,
blistersPerPack,
pillsPerBlister,
packageAmountValue,
packageAmountUnit,
totalPills,
looseTablets,
pillWeightMg,
doseUnit,
medicationStartDate,
medicationEndDate,
autoMarkObsoleteAfterEndDate,
expiryDate,
notes,
prescriptionEnabled,
@@ -344,6 +493,9 @@ export async function medicationRoutes(app: FastifyInstance) {
blisters: inputBlisters,
} = parsed.data;
const normalizedPillForm =
medicationForm === "capsule" || medicationForm === "tablet" ? (pillForm ?? medicationForm) : null;
// Convert to unified intakes format
let intakes: Intake[];
if (inputIntakes) {
@@ -352,6 +504,7 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: i.usage,
every: i.every,
start: i.start,
intakeUnit: i.intakeUnit ?? null,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
@@ -361,6 +514,7 @@ export async function medicationRoutes(app: FastifyInstance) {
usage: b.usage,
every: b.every,
start: b.start,
intakeUnit: null,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
@@ -392,15 +546,22 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName: genericName || null,
takenByJson,
medicationForm: medicationForm ?? "tablet",
pillForm: normalizedPillForm,
lifecycleCategory: lifecycleCategory ?? "refill_when_empty",
packageType: packageType ?? "blister",
packCount,
blistersPerPack,
pillsPerBlister,
totalPills: totalPills || null,
packageAmountValue,
packageAmountUnit,
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
medicationStartDate: medicationStartDate ?? "",
medicationEndDate: medicationEndDate || null,
autoMarkObsoleteAfterEndDate: autoMarkObsoleteAfterEndDate ?? true,
expiryDate: expiryDate || null,
notes: notes || null,
prescriptionEnabled: prescriptionEnabled ?? false,
@@ -545,10 +706,15 @@ export async function medicationRoutes(app: FastifyInstance) {
name: result[0].name,
genericName: result[0].genericName,
takenBy: parseTakenByJson(result[0].takenByJson),
medicationForm: result[0].medicationForm ?? "tablet",
pillForm: result[0].pillForm ?? null,
lifecycleCategory: result[0].lifecycleCategory ?? "refill_when_empty",
packageType: result[0].packageType ?? "blister",
packCount: result[0].packCount,
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
packageAmountValue: result[0].packageAmountValue ?? 0,
packageAmountUnit: (result[0].packageAmountUnit ?? "ml") as "ml" | "g",
totalPills: result[0].totalPills ?? null,
looseTablets: result[0].looseTablets,
stockAdjustment: result[0].stockAdjustment ?? 0,
@@ -556,6 +722,8 @@ export async function medicationRoutes(app: FastifyInstance) {
pillWeightMg: result[0].pillWeightMg,
doseUnit: result[0].doseUnit ?? "mg",
medicationStartDate: result[0].medicationStartDate || null,
medicationEndDate: result[0].medicationEndDate || null,
autoMarkObsoleteAfterEndDate: result[0].autoMarkObsoleteAfterEndDate ?? true,
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: result[0].imageUrl,
@@ -845,7 +1013,12 @@ export async function medicationRoutes(app: FastifyInstance) {
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const medForm = row.medicationForm ?? "tablet";
const blisters = intakes.map((i) => ({
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
every: i.every,
start: i.start,
}));
const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 1;
const blistersPerPack = row.blistersPerPack ?? 1;
@@ -854,8 +1027,9 @@ export async function medicationRoutes(app: FastifyInstance) {
const packageType = row.packageType ?? "blister";
// For bottle type, looseTablets IS the current stock (no blister math)
const isTopical = medForm === "topical" || packageType === "tube";
const originalTotalPills =
packageType === "bottle"
packageType === "bottle" || packageType === "liquid_container"
? looseTablets + stockAdjustment
: packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
@@ -867,7 +1041,9 @@ export async function medicationRoutes(app: FastifyInstance) {
let consumedUntilNow = 0;
const msPerDay = 86400000;
if (stockCalculationMode === "automatic") {
if (isTopical) {
consumedUntilNow = 0;
} else if (stockCalculationMode === "automatic") {
blisters.forEach((blister, blisterIdx) => {
const blisterStart = parseLocalDateTime(blister.start).getTime();
if (Number.isNaN(blisterStart)) return;
@@ -963,7 +1139,7 @@ export async function medicationRoutes(app: FastifyInstance) {
});
}
const currentStock = Math.max(0, originalTotalPills - consumedUntilNow);
const currentStock = isTopical ? originalTotalPills : Math.max(0, originalTotalPills - consumedUntilNow);
// Calculate usage for the planning period
// Always use the user-selected start date for the usage calculation.
@@ -973,7 +1149,7 @@ export async function medicationRoutes(app: FastifyInstance) {
// The stock already reflects consumed doses, so no double-counting occurs.
// When includeUntilStart is true, calculate from now to end (useful for trip planning)
const effectivePlannerStart = includeUntilStart ? now : start;
const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end);
const usageTotal = isTopical ? 0 : calculateUsageInRange(blisters, effectivePlannerStart, end);
const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0;
@@ -983,7 +1159,7 @@ export async function medicationRoutes(app: FastifyInstance) {
let fullBlisters: number;
let loosePills: number;
if (packageType === "bottle") {
if (packageType === "bottle" || packageType === "tube" || packageType === "liquid_container") {
// Bottle type: no blisters, everything is loose pills
fullBlisters = 0;
loosePills = availableAfterPeriod;
+3 -3
View File
@@ -32,8 +32,8 @@ async function loadDbClientModule(options: ClientTestOptions = {}) {
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
const getDbPaths = vi.fn().mockReturnValue({
dataDir: "/tmp/medassist-data",
dbPath: "/tmp/medassist-data/medassist.db",
url: "file:/tmp/medassist-data/medassist.db",
dbPath: "/tmp/medassist-data/medassist-ng.db",
url: "file:/tmp/medassist-data/medassist-ng.db",
});
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
@@ -102,7 +102,7 @@ describe("db/client bootstrap", () => {
await mod.migrationsReady;
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist.db" });
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
+77 -2
View File
@@ -82,7 +82,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -101,6 +106,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
@@ -2499,10 +2506,10 @@ describe("E2E Tests with Real Routes", () => {
});
// ---------------------------------------------------------------------------
// Package Type (bottle vs blister) Tests
// Package Type (blister, bottle, liquid_container) Tests
// ---------------------------------------------------------------------------
describe("Package type handling (bottle vs blister)", () => {
describe("Package type handling (blister, bottle, liquid_container)", () => {
const bottleMedication = {
name: "Vitamin D Drops",
packageType: "bottle",
@@ -2523,6 +2530,18 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const liquidContainerMedication = {
name: "Cough Syrup",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 180,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
it("should create and return bottle type medication", async () => {
const response = await app.inject({
method: "POST",
@@ -2567,6 +2586,49 @@ describe("E2E Tests with Real Routes", () => {
expect(data.medications[0].totalPills).toBe(120);
});
it("should create and return liquid_container type medication", async () => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload: liquidContainerMedication,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.packageType).toBe("liquid_container");
expect(data.medicationForm).toBe("liquid");
expect(data.doseUnit).toBe("ml");
expect(data.looseTablets).toBe(180);
});
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: { ...liquidContainerMedication, takenBy: ["Daniel"] },
});
const shareResponse = await app.inject({
method: "POST",
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareResponse.statusCode).toBe(200);
const { token } = shareResponse.json();
const scheduleResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleResponse.statusCode).toBe(200);
const data = scheduleResponse.json();
expect(data.medications).toHaveLength(1);
expect(data.medications[0].packageType).toBe("liquid_container");
// Liquid container follows container semantics (stock from looseTablets only).
expect(data.medications[0].totalPills).toBe(180);
});
it("should calculate correct totalPills for shared blister medication", async () => {
await app.inject({
method: "POST",
@@ -2742,5 +2804,18 @@ describe("E2E Tests with Real Routes", () => {
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].packageType).toBe("blister");
});
it("should reject liquid medication form with non-liquid package type", async () => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packageType: "bottle",
},
});
expect(response.statusCode).toBe(400);
});
});
});
+7
View File
@@ -76,7 +76,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -95,6 +100,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
+7
View File
@@ -93,7 +93,12 @@ async function createSchema(client: Client) {
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
medication_form text NOT NULL DEFAULT 'tablet',
pill_form text,
lifecycle_category text NOT NULL DEFAULT 'refill_when_empty',
package_type text NOT NULL DEFAULT 'blister',
package_amount_value integer NOT NULL DEFAULT 0,
package_amount_unit text NOT NULL DEFAULT 'ml',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
@@ -112,6 +117,8 @@ async function createSchema(client: Client) {
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
medication_start_date text NOT NULL DEFAULT '',
medication_end_date text,
auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1,
is_obsolete integer NOT NULL DEFAULT 0,
obsolete_at integer,
prescription_enabled integer NOT NULL DEFAULT 0,
+16
View File
@@ -17,6 +17,22 @@ export type Intake = {
intakeRemindersEnabled: boolean;
};
/**
* Normalize intake usage for stock math.
*
* Stock semantics currently treat numeric usage as-is for all supported
* medication forms/package types. The helper centralizes this behavior so route
* logic can depend on a single validated numeric value.
*/
export function normalizeIntakeUsageForStock(
intake: Pick<Intake, "usage">,
_medicationForm?: string | null,
_packageType?: string | null
): number {
const usage = Number(intake.usage);
return Number.isFinite(usage) && usage > 0 ? usage : 0;
}
// =============================================================================
// Timezone utilities
// =============================================================================