feat: Stock Correction Modal (#47)

* feat: add stock correction modal with blister-based input

- Add 'Correct Stock' button to medication detail modal
- New modal with Full Blisters + Partial Blister Pills inputs
- Auto-conversion for edge cases (full/negative partial)
- New stockAdjustment field for DB corrections without touching looseTablets
- New lastStockCorrectionAt timestamp to ignore old consumed doses after correction
- Tracking data preserved for future statistics
- Add Drizzle migrations for new columns
- Add translations for en/de

* fix: add stock_adjustment columns to e2e/integration test schemas
This commit is contained in:
Daniel Volz
2026-01-18 12:53:25 +01:00
committed by GitHub
parent bb46b26ec6
commit 75bb7abebc
16 changed files with 2072 additions and 58 deletions
+4
View File
@@ -75,6 +75,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
// Added for stock correction - timestamp to ignore consumed doses before correction
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
];
for (const sql of alterMigrations) {
+3 -1
View File
@@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", {
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
+43 -1
View File
@@ -96,6 +96,8 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
looseTablets: row.looseTablets ?? 0,
stockAdjustment: row.stockAdjustment ?? 0,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
blisters: parseBlisters(row),
imageUrl: row.imageUrl,
@@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
looseTablets: inserted.looseTablets,
stockAdjustment: inserted.stockAdjustment ?? 0,
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: inserted.pillWeightMg,
blisters,
imageUrl: inserted.imageUrl,
@@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) {
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
looseTablets: result[0].looseTablets,
stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: result[0].pillWeightMg,
blisters,
imageUrl: result[0].imageUrl,
@@ -245,6 +251,41 @@ export async function medicationRoutes(app: FastifyInstance) {
};
});
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>("/medications/:id/stock-adjustment", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
const userId = await getUserId(req, reply);
// Verify ownership
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const { stockAdjustment } = req.body as { stockAdjustment: number };
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
const result = await db
.update(medications)
.set({
stockAdjustment,
lastStockCorrectionAt: new Date(), // Mark when correction was made
updatedAt: new Date(),
})
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
.returning();
if (!result.length) return reply.notFound();
return {
id: result[0].id,
stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
updatedAt: result[0].updatedAt,
};
});
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
const idNum = Number(req.params.id);
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
@@ -339,7 +380,8 @@ export async function medicationRoutes(app: FastifyInstance) {
const packCount = row.packCount ?? 1;
const blistersPerPack = row.blistersPerPack ?? 1;
const looseTablets = row.looseTablets ?? 0;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
const stockAdjustment = row.stockAdjustment ?? 0;
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
// Calculate consumption up to now (same logic as frontend)
let consumedUntilNow = 0;
+1 -1
View File
@@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
+1 -1
View File
@@ -93,7 +93,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
for (const row of rows) {
const blisters = parseBlistersFromRow(row);
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
// Check if medication runs out within reminderDaysBefore days
+2
View File
@@ -85,6 +85,8 @@ async function createSchema(client: Client) {
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
+2
View File
@@ -80,6 +80,8 @@ async function createSchema(client: Client) {
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',