From 0e52a03f7a2a060bef2ca49194917ab39e803eee Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 28 Dec 2025 15:03:24 +0100 Subject: [PATCH] feat: add stock calculation mode to user settings with automatic and manual options --- .../0014_add_stock_calculation_mode.sql | 4 + backend/src/db/migrations/meta/_journal.json | 3 +- backend/src/db/schema.ts | 2 + backend/src/routes/settings.ts | 7 ++ frontend/src/App.tsx | 86 +++++++++++++++++-- frontend/src/i18n/de.json | 5 ++ frontend/src/i18n/en.json | 5 ++ frontend/src/styles.css | 63 ++++++++++++++ 8 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 backend/src/db/migrations/0014_add_stock_calculation_mode.sql diff --git a/backend/src/db/migrations/0014_add_stock_calculation_mode.sql b/backend/src/db/migrations/0014_add_stock_calculation_mode.sql new file mode 100644 index 0000000..3f3e97d --- /dev/null +++ b/backend/src/db/migrations/0014_add_stock_calculation_mode.sql @@ -0,0 +1,4 @@ +-- Add stock calculation mode setting +-- "automatic" = stock decreases based on schedule from start date +-- "manual" = stock only decreases when doses are marked as taken +ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 963e4fa..3842cad 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -13,6 +13,7 @@ { "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }, { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }, { "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false }, - { "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false } + { "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false }, + { "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index dc26893..412bcb9 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -69,6 +69,8 @@ export const userSettings = sqliteTable("user_settings", { highStockDays: integer("high_stock_days").notNull().default(180), // UI preferences language: text("language", { length: 10 }).notNull().default("en"), + // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) + stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), // Last notification tracking lastAutoEmailSent: text("last_auto_email_sent"), lastNotificationType: text("last_notification_type"), diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 468f44e..0413536 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -25,6 +25,7 @@ export type UserSettings = { normalStockDays: number; highStockDays: number; language: Language; + stockCalculationMode: "automatic" | "manual"; lastAutoEmailSent: string | null; lastNotificationType: string | null; lastNotificationChannel: string | null; @@ -45,6 +46,7 @@ type SettingsBody = { shoutrrrStockReminders: boolean; shoutrrrIntakeReminders: boolean; language: string; + stockCalculationMode: "automatic" | "manual"; }; type TestEmailBody = { @@ -71,6 +73,7 @@ const defaultSettings = { normalStockDays: 90, highStockDays: 180, language: "en", + stockCalculationMode: "automatic" as const, lastAutoEmailSent: null, lastNotificationType: null, lastNotificationChannel: null, @@ -110,6 +113,7 @@ export async function loadUserSettings(userId: number): Promise { normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, language: settings.language as Language, + stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, @@ -135,6 +139,7 @@ export async function getAllUserSettings(): Promise { normalStockDays: settings.normalStockDays, highStockDays: settings.highStockDays, language: settings.language as Language, + stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic", lastAutoEmailSent: settings.lastAutoEmailSent, lastNotificationType: settings.lastNotificationType, lastNotificationChannel: settings.lastNotificationChannel, @@ -183,6 +188,7 @@ export async function settingsRoutes(app: FastifyInstance) { shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, language: settings.language, + stockCalculationMode: settings.stockCalculationMode ?? "automatic", // SMTP settings (from .env - shared/server-configured) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587"), @@ -231,6 +237,7 @@ export async function settingsRoutes(app: FastifyInstance) { normalStockDays: body.normalStockDays ?? 90, highStockDays: body.highStockDays ?? 180, language: body.language ?? "en", + stockCalculationMode: body.stockCalculationMode ?? "automatic", updatedAt: new Date(), }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b95773..f86b2ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -294,6 +294,8 @@ function AppContent() { emailIntakeReminders: true, shoutrrrStockReminders: true, shoutrrrIntakeReminders: true, + // Stock calculation mode: "automatic" or "manual" + stockCalculationMode: "automatic" as "automatic" | "manual", // Admin settings (from .env, read-only) expiryWarningDays: 30, }); @@ -477,7 +479,7 @@ function AppContent() { const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language, true), [meds, i18n.language]); const totalTablets = useMemo(() => deriveTotal(form), [form]); - const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]); + const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses), [meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses]); const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]); const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); @@ -588,6 +590,8 @@ function AppContent() { emailIntakeReminders: settings.emailIntakeReminders, shoutrrrStockReminders: settings.shoutrrrStockReminders, shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + // Stock calculation mode + stockCalculationMode: settings.stockCalculationMode, // Language setting (for backend notifications) language: i18n.language, // SMTP (legacy - not saved, read from .env) @@ -1978,6 +1982,46 @@ function AppContent() { +
+
+

{t('settings.stock.calculationMode')}

+
+
+ + +
+
+

{t('settings.stock.display')}

@@ -2864,7 +2908,14 @@ function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays return "success-text"; // outside warning period } -function calculateCoverage(meds: Medication[], events: Array<{ medName: string; when: number }>, locale: string, reminderDaysBefore: number) { +function calculateCoverage( + meds: Medication[], + events: Array<{ medName: string; when: number }>, + locale: string, + reminderDaysBefore: number, + stockCalculationMode: "automatic" | "manual", + takenDoses: Set +) { const MS_PER_DAY = 86_400_000; const now = Date.now(); @@ -2872,13 +2923,30 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string; const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0); let consumed = 0; - m.blisters.forEach((s) => { - const start = new Date(s.start).getTime(); - if (Number.isNaN(start) || start > now) return; - const period = Math.max(1, s.every) * MS_PER_DAY; - const occurrences = Math.floor((now - start) / period) + 1; // include today if started - consumed += occurrences * s.usage; - }); + + if (stockCalculationMode === "automatic") { + // Automatic mode: calculate consumed based on schedule since start date + m.blisters.forEach((s) => { + const start = new Date(s.start).getTime(); + if (Number.isNaN(start) || start > now) return; + const period = Math.max(1, s.every) * MS_PER_DAY; + const occurrences = Math.floor((now - start) / period) + 1; // include today if started + consumed += occurrences * s.usage; + }); + } else { + // Manual mode: count only doses marked as taken for this medication + // Dose IDs follow pattern: "{medicationId}-{blisterIndex}-{timestampMs}" + takenDoses.forEach((doseId) => { + const parts = doseId.split("-"); + if (parts.length >= 3) { + const medId = parseInt(parts[0], 10); + const blisterIdx = parseInt(parts[1], 10); + if (medId === m.id && m.blisters[blisterIdx]) { + consumed += m.blisters[blisterIdx].usage; + } + } + }); + } const medsLeft = Math.max(0, m.count - consumed); const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null; diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 1f5f6e6..8985670 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -182,6 +182,11 @@ "remindWhen": "Erinnern wenn Vorrat unter", "repeatDaily": "Täglich wiederholen", "repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand niedrig ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen.", + "calculationMode": "Bestandsberechnung", + "automatic": "Automatisch", + "automaticDesc": "Bestand wird automatisch anhand des Einnahmeplans reduziert", + "manual": "Manuell", + "manualDesc": "Bestand wird nur reduziert wenn Dosen als genommen markiert werden", "display": "Anzeige", "lowStockDays": "Niedriger Bestand (Tage)", "lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a23df81..78e95c9 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -184,6 +184,11 @@ "remindWhen": "Remind when supply drops below", "repeatDaily": "Repeat daily", "repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.", + "calculationMode": "Stock Calculation", + "automatic": "Automatic", + "automaticDesc": "Stock automatically decreases based on schedule", + "manual": "Manual", + "manualDesc": "Stock only decreases when doses are marked as taken", "display": "Display", "lowStockDays": "Low Stock (days)", "lowStockTooltip": "Yellow warning color threshold", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0c2ccaa..a105f6c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1289,6 +1289,69 @@ textarea.auto-resize { letter-spacing: 0.03em; } +/* Stock calculation mode radio cards */ +.calculation-mode-group { + grid-template-columns: 1fr 1fr; +} + +.calculation-mode-group label { + text-transform: none; + letter-spacing: normal; +} + +.radio-card { + cursor: pointer; + padding: 1rem; + background: var(--bg-secondary); + border: 2px solid var(--border-primary); + border-radius: 10px; + transition: all 0.2s ease; +} + +.radio-card:hover { + border-color: var(--accent-light); + background: var(--bg-tertiary); +} + +.radio-card.selected { + border-color: var(--accent); + background: var(--bg-tertiary); + box-shadow: 0 0 0 1px var(--accent); +} + +.radio-card input[type="radio"] { + display: none; +} + +.radio-card-content { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.radio-card-icon { + font-size: 1.5rem; + line-height: 1; +} + +.radio-card-text { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.radio-card-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); +} + +.radio-card-desc { + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.4; +} + .setting-section { padding: 1.25rem; background: var(--bg-tertiary);