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
This commit is contained in:
Daniel Volz
2026-02-06 22:01:01 +01:00
committed by GitHub
parent 01deea1fa0
commit 5818dcc00d
6 changed files with 215 additions and 43 deletions
+99 -17
View File
@@ -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<number, { blisterIdx: number; usage: number }[]>();
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,
+74 -25
View File
@@ -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,
},
});
+1
View File
@@ -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",
+1
View File
@@ -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",
+21 -1
View File
@@ -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 })}
/>
</label>
<label className="planner-checkbox">
<input
type="checkbox"
checked={includeUntilStart}
onChange={(e) => setIncludeUntilStart(e.target.checked)}
/>
{t("planner.includeUntilStart")}
</label>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>
{t("common.reset")}
+19
View File
@@ -2019,6 +2019,25 @@ textarea.auto-resize {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.planner-checkbox {
grid-column: 1 / -1;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.planner-checkbox input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
cursor: pointer;
accent-color: var(--accent-primary);
}
.planner-actions {
grid-column: 1 / -1;
display: flex;