From 5818dcc00d8c7eeb0a216a870fecf5f5f4530c16 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 6 Feb 2026 22:01:01 +0100 Subject: [PATCH] feat: add checkbox to include consumption from today until planner start date (#98) - Add 'Include consumption from today until start date' checkbox to planner - When checked, usage calculation starts from today instead of max(today, startDate) - Persist checkbox state in localStorage per user - Add i18n translations (EN + DE) - Update planner tests to use dynamic future dates --- backend/src/routes/medications.ts | 116 +++++++++++++++++++++++---- backend/src/test/integration.test.ts | 99 +++++++++++++++++------ frontend/src/i18n/de.json | 1 + frontend/src/i18n/en.json | 1 + frontend/src/pages/PlannerPage.tsx | 22 ++++- frontend/src/styles.css | 19 +++++ 6 files changed, 215 insertions(+), 43 deletions(-) diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index f255801..ed07dfa 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -495,10 +495,14 @@ export async function medicationRoutes(app: FastifyInstance) { }); app.post("/medications/usage", async (req, reply) => { - const schema = z.object({ startDate: z.string().datetime(), endDate: z.string().datetime() }); + const schema = z.object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), + includeUntilStart: z.boolean().optional().default(false), + }); const parsed = schema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const { startDate, endDate } = parsed.data; + const { startDate, endDate, includeUntilStart } = parsed.data; const start = new Date(startDate); const end = new Date(endDate); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end <= start) { @@ -507,6 +511,30 @@ export async function medicationRoutes(app: FastifyInstance) { const userId = await getUserId(req, reply); const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); + + // Get all taken doses for this user to calculate actual consumption + const takenDoses = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, false))); + + // Create a map of medication ID to taken dose count + const takenDosesMap = new Map(); + takenDoses.forEach((dose) => { + const parts = dose.doseId.split("-"); + if (parts.length >= 3) { + const medId = parseInt(parts[0], 10); + const blisterIdx = parseInt(parts[1], 10); + if (!Number.isNaN(medId) && !Number.isNaN(blisterIdx)) { + if (!takenDosesMap.has(medId)) { + takenDosesMap.set(medId, []); + } + takenDosesMap.get(medId)!.push({ blisterIdx, usage: 0 }); // usage filled later + } + } + }); + + // Use current time as the reference point for "available" stock const now = new Date(); const payload = rows.map((row) => { @@ -517,7 +545,6 @@ export async function medicationRoutes(app: FastifyInstance) { row.intakeRemindersEnabled ?? false ); const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })); - const usageTotal = calculateUsageInRange(blisters, start, end); const pillsPerBlister = row.pillsPerBlister ?? 1; const packCount = row.packCount ?? 1; const blistersPerPack = row.blistersPerPack ?? 1; @@ -525,25 +552,80 @@ export async function medicationRoutes(app: FastifyInstance) { const stockAdjustment = row.stockAdjustment ?? 0; const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment; - // Calculate consumption up to now (same logic as frontend) + // Calculate consumption based on ACTUAL taken doses from dose_tracking + // This ensures Planner shows the same "current stock" as the Dashboard/Modal + // Use the same logic as frontend: generate expected doses and check which are marked + const stockCorrectionCutoff = row.lastStockCorrectionAt ? new Date(row.lastStockCorrectionAt).getTime() : 0; + + // Build a Set of taken dose IDs for quick lookup + const takenDoseIds = new Set( + takenDoses + .filter((dose) => { + const parts = dose.doseId.split("-"); + return parts.length >= 3 && parseInt(parts[0], 10) === row.id; + }) + .map((dose) => dose.doseId) + ); + + // Count consumed pills by generating expected doses and checking if they're taken let consumedUntilNow = 0; - blisters.forEach((blister) => { + const msPerDay = 86400000; + + blisters.forEach((blister, blisterIdx) => { const blisterStart = parseLocalDateTime(blister.start); - if (Number.isNaN(blisterStart.getTime()) || blisterStart > now) return; - const msPerDay = 86400000; + if (Number.isNaN(blisterStart.getTime())) return; + + const effectiveStart = Math.max(blisterStart.getTime(), stockCorrectionCutoff); + if (effectiveStart > now.getTime()) return; + const period = Math.max(1, blister.every) * msPerDay; - const occurrences = Math.floor((now.getTime() - blisterStart.getTime()) / period) + 1; - consumedUntilNow += occurrences * blister.usage; + const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1; + + // Get the people for this intake (from intakes array or medication takenBy) + const takenByJson = row.takenByJson ? JSON.parse(row.takenByJson) : []; + const intake = intakes[blisterIdx]; + const intakePerson = intake?.takenBy; + const peopleForThisIntake: (string | null)[] = intakePerson + ? [intakePerson] + : takenByJson.length > 0 + ? takenByJson + : [null]; + + // Generate expected dose IDs and check if they're taken + for (let i = 0; i < occurrences; i++) { + const doseDate = new Date(effectiveStart + i * period); + const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime(); + const baseDoseId = `${row.id}-${blisterIdx}-${dateOnlyMs}`; + + // Check if each person has taken this dose + for (const person of peopleForThisIntake) { + const doseId = person ? `${baseDoseId}-${person}` : baseDoseId; + if (takenDoseIds.has(doseId)) { + consumedUntilNow += blister.usage; + } + } + } }); - const currentPills = Math.max(0, originalTotalPills - consumedUntilNow); + const currentStock = Math.max(0, originalTotalPills - consumedUntilNow); + + // Calculate usage for the planning period + // When includeUntilStart is true, calculate from now to end (useful for trip planning) + // When false, calculate from max(now, start) to end (default behavior) + const effectivePlannerStart = includeUntilStart ? now : new Date(Math.max(now.getTime(), start.getTime())); + const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end); + const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; - // Calculate current stock using realistic consumption order (loose first, then blisters) - const consumed = originalTotalPills - currentPills; - const looseConsumed = Math.min(consumed, looseTablets); - const loosePillsRemaining = looseTablets - looseConsumed; - const blisterPillsConsumed = consumed - looseConsumed; + // Calculate AVAILABLE = stock AFTER the planned period (currentStock - usageTotal) + const availableAfterPeriod = Math.max(0, currentStock - usageTotal); + + // Calculate stock breakdown for availableAfterPeriod + // Consumption order: loose pills first, then from blisters + const totalConsumedByEnd = originalTotalPills - availableAfterPeriod; + const looseConsumedByEnd = Math.min(totalConsumedByEnd, looseTablets); + const loosePillsRemaining = Math.max(0, looseTablets - looseConsumedByEnd); + const blisterPillsConsumed = totalConsumedByEnd - looseConsumedByEnd; const originalBlisterPills = originalTotalPills - looseTablets; const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed); @@ -551,11 +633,11 @@ export async function medicationRoutes(app: FastifyInstance) { const openBlisterPills = pillsPerBlister > 0 ? blisterPillsRemaining % pillsPerBlister : 0; const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose - const enough = currentPills >= usageTotal; + const enough = currentStock >= usageTotal; return { medicationId: row.id, medicationName: row.name, - totalPills: currentPills, + totalPills: currentStock, plannerUsage: usageTotal, blisterSize: pillsPerBlister, blistersNeeded, diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 2494d15..d126c19 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -706,7 +706,16 @@ describe("Integration Tests", () => { describe("Planner usage calculation", () => { it("should calculate correct usage for daily medication", async () => { // Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total - // Schedule: 1 pill daily starting Jan 1 + // Schedule: 1 pill daily starting tomorrow (future date) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + const intakeStart = tomorrow.toISOString(); + + const planEnd = new Date(tomorrow); + planEnd.setDate(planEnd.getDate() + 10); + const planEndStr = planEnd.toISOString(); + await app.inject({ method: "POST", url: "/medications", @@ -716,17 +725,17 @@ describe("Integration Tests", () => { blistersPerPack: 3, pillsPerBlister: 10, looseTablets: 0, - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 1, start: intakeStart }], }, }); - // Calculate usage for Jan 1-10 (10 days = 10 pills needed) + // Calculate usage for 10 days starting tomorrow const response = await app.inject({ method: "POST", url: "/medications/usage", payload: { - startDate: "2025-01-01T00:00:00.000Z", - endDate: "2025-01-11T00:00:00.000Z", // 10 days + startDate: intakeStart, + endDate: planEndStr, // 10 days }, }); @@ -735,13 +744,22 @@ describe("Integration Tests", () => { expect(data).toHaveLength(1); expect(data[0].medicationName).toBe("Daily Med"); expect(data[0].plannerUsage).toBe(10); // 10 days × 1 pill - // Note: 'enough' depends on current stock after consumption since start date - // Since test runs ~364 days after Jan 1, most pills are consumed + expect(data[0].totalPills).toBe(60); // Current stock is full (no consumption yet) + expect(data[0].enough).toBe(true); }); it("should detect insufficient stock", async () => { // Create medication: 1 pack × 1 blister × 5 pills = 5 pills total - // Schedule: 1 pill daily + // Schedule: 1 pill daily starting tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + const intakeStart = tomorrow.toISOString(); + + const planEnd = new Date(tomorrow); + planEnd.setDate(planEnd.getDate() + 10); + const planEndStr = planEnd.toISOString(); + await app.inject({ method: "POST", url: "/medications", @@ -751,17 +769,17 @@ describe("Integration Tests", () => { blistersPerPack: 1, pillsPerBlister: 5, looseTablets: 0, - blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 1, start: intakeStart }], }, }); - // Calculate usage for 10 days (needs 10 pills, only have 5 originally) + // Calculate usage for 10 days (needs 10 pills, only have 5) const response = await app.inject({ method: "POST", url: "/medications/usage", payload: { - startDate: "2025-01-01T00:00:00.000Z", - endDate: "2025-01-11T00:00:00.000Z", + startDate: intakeStart, + endDate: planEndStr, }, }); @@ -773,7 +791,16 @@ describe("Integration Tests", () => { it("should calculate weekly medication usage correctly", async () => { // Create medication: 10 pills total - // Schedule: 1 pill every 7 days starting Jan 1 + // Schedule: 1 pill every 7 days starting tomorrow + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + const intakeStart = tomorrow.toISOString(); + + const planEnd = new Date(tomorrow); + planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses + const planEndStr = planEnd.toISOString(); + await app.inject({ method: "POST", url: "/medications", @@ -782,29 +809,42 @@ describe("Integration Tests", () => { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, - blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 1, every: 7, start: intakeStart }], }, }); - // Calculate usage for 30 days (should need ~4-5 pills) + // Calculate usage for 35 days (should need 5 pills) const response = await app.inject({ method: "POST", url: "/medications/usage", payload: { - startDate: "2025-01-01T00:00:00.000Z", - endDate: "2025-01-31T00:00:00.000Z", // 30 days + startDate: intakeStart, + endDate: planEndStr, }, }); expect(response.statusCode).toBe(200); const data = response.json(); - // Jan 1, 8, 15, 22, 29 = 5 doses + // Day 0, 7, 14, 21, 28 = 5 doses expect(data[0].plannerUsage).toBe(5); }); it("should handle multiple intake schedules per medication", async () => { // Create medication with morning and evening doses // 30 pills total, 1.5 pills per day (1 morning + 0.5 evening) + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + const morningStart = tomorrow.toISOString(); + + const eveningStart = new Date(tomorrow); + eveningStart.setHours(20, 0, 0, 0); + const eveningStartStr = eveningStart.toISOString(); + + const planEnd = new Date(tomorrow); + planEnd.setDate(planEnd.getDate() + 10); + const planEndStr = planEnd.toISOString(); + await app.inject({ method: "POST", url: "/medications", @@ -814,8 +854,8 @@ describe("Integration Tests", () => { blistersPerPack: 1, pillsPerBlister: 30, blisters: [ - { usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }, // Morning: 1 pill - { usage: 0.5, every: 1, start: "2025-01-01T20:00:00.000Z" }, // Evening: 0.5 pill + { usage: 1, every: 1, start: morningStart }, // Morning: 1 pill + { usage: 0.5, every: 1, start: eveningStartStr }, // Evening: 0.5 pill ], }, }); @@ -825,8 +865,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: "2025-01-01T00:00:00.000Z", - endDate: "2025-01-11T00:00:00.000Z", + startDate: morningStart, + endDate: planEndStr, }, }); @@ -838,6 +878,15 @@ describe("Integration Tests", () => { it("should calculate correct blisters needed", async () => { // 10 pills per blister, need 25 pills → need 3 blisters + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(8, 0, 0, 0); + const intakeStart = tomorrow.toISOString(); + + const planEnd = new Date(tomorrow); + planEnd.setDate(planEnd.getDate() + 10); + const planEndStr = planEnd.toISOString(); + await app.inject({ method: "POST", url: "/medications", @@ -846,7 +895,7 @@ describe("Integration Tests", () => { packCount: 5, blistersPerPack: 1, pillsPerBlister: 10, - blisters: [{ usage: 2.5, every: 1, start: "2025-01-01T08:00:00.000Z" }], + blisters: [{ usage: 2.5, every: 1, start: intakeStart }], }, }); @@ -855,8 +904,8 @@ describe("Integration Tests", () => { method: "POST", url: "/medications/usage", payload: { - startDate: "2025-01-01T00:00:00.000Z", - endDate: "2025-01-11T00:00:00.000Z", + startDate: intakeStart, + endDate: planEndStr, }, }); diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 37c6fbe..5ea3eb1 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -176,6 +176,7 @@ "badge": "Vorrat planen", "from": "Von", "until": "Bis", + "includeUntilStart": "Verbrauch von heute bis Startdatum einrechnen", "calculate": "Berechnen", "calculating": "Wird berechnet...", "sendEmail": "📧 Per E-Mail senden", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index ca2bcb4..d699ded 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -176,6 +176,7 @@ "badge": "Plan your supply", "from": "From", "until": "Until", + "includeUntilStart": "Include consumption from today until start date", "calculate": "Calculate", "calculating": "Calculating...", "sendEmail": "📧 Send via Email", diff --git a/frontend/src/pages/PlannerPage.tsx b/frontend/src/pages/PlannerPage.tsx index 63d35ae..80d597a 100644 --- a/frontend/src/pages/PlannerPage.tsx +++ b/frontend/src/pages/PlannerPage.tsx @@ -41,6 +41,7 @@ export function PlannerPage() { start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)), }); + const [includeUntilStart, setIncludeUntilStart] = useState(false); const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false); const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null); @@ -49,6 +50,7 @@ export function PlannerPage() { if (typeof window !== "undefined" && user?.id) { const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows")); const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange")); + const savedIncludeUntilStart = localStorage.getItem(userStorageKey(user.id, "plannerIncludeUntilStart")); if (savedRows) { try { @@ -69,16 +71,23 @@ export function PlannerPage() { } else { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); } + + if (savedIncludeUntilStart) { + setIncludeUntilStart(savedIncludeUntilStart === "true"); + } else { + setIncludeUntilStart(false); + } } else { setPlannerRows([]); setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); + setIncludeUntilStart(false); } }, [user?.id]); async function runPlanner(e: React.FormEvent) { e.preventDefault(); setPlannerLoading(true); - const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end) }; + const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart }; const rows = (await fetch("/api/medications/usage", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -93,15 +102,18 @@ export function PlannerPage() { if (user?.id) { localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range)); localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows)); + localStorage.setItem(userStorageKey(user.id, "plannerIncludeUntilStart"), String(includeUntilStart)); } } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); + setIncludeUntilStart(false); setPlannerRows([]); if (user?.id) { localStorage.removeItem(userStorageKey(user.id, "plannerRange")); localStorage.removeItem(userStorageKey(user.id, "plannerRows")); + localStorage.removeItem(userStorageKey(user.id, "plannerIncludeUntilStart")); } } @@ -159,6 +171,14 @@ export function PlannerPage() { onChange={(e) => setRange({ ...range, end: e.target.value })} /> +