feat: add stock calculation mode to user settings with automatic and manual options
This commit is contained in:
@@ -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';
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false },
|
{ "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": 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": 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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
highStockDays: integer("high_stock_days").notNull().default(180),
|
highStockDays: integer("high_stock_days").notNull().default(180),
|
||||||
// UI preferences
|
// UI preferences
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
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
|
// Last notification tracking
|
||||||
lastAutoEmailSent: text("last_auto_email_sent"),
|
lastAutoEmailSent: text("last_auto_email_sent"),
|
||||||
lastNotificationType: text("last_notification_type"),
|
lastNotificationType: text("last_notification_type"),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type UserSettings = {
|
|||||||
normalStockDays: number;
|
normalStockDays: number;
|
||||||
highStockDays: number;
|
highStockDays: number;
|
||||||
language: Language;
|
language: Language;
|
||||||
|
stockCalculationMode: "automatic" | "manual";
|
||||||
lastAutoEmailSent: string | null;
|
lastAutoEmailSent: string | null;
|
||||||
lastNotificationType: string | null;
|
lastNotificationType: string | null;
|
||||||
lastNotificationChannel: string | null;
|
lastNotificationChannel: string | null;
|
||||||
@@ -45,6 +46,7 @@ type SettingsBody = {
|
|||||||
shoutrrrStockReminders: boolean;
|
shoutrrrStockReminders: boolean;
|
||||||
shoutrrrIntakeReminders: boolean;
|
shoutrrrIntakeReminders: boolean;
|
||||||
language: string;
|
language: string;
|
||||||
|
stockCalculationMode: "automatic" | "manual";
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestEmailBody = {
|
type TestEmailBody = {
|
||||||
@@ -71,6 +73,7 @@ const defaultSettings = {
|
|||||||
normalStockDays: 90,
|
normalStockDays: 90,
|
||||||
highStockDays: 180,
|
highStockDays: 180,
|
||||||
language: "en",
|
language: "en",
|
||||||
|
stockCalculationMode: "automatic" as const,
|
||||||
lastAutoEmailSent: null,
|
lastAutoEmailSent: null,
|
||||||
lastNotificationType: null,
|
lastNotificationType: null,
|
||||||
lastNotificationChannel: null,
|
lastNotificationChannel: null,
|
||||||
@@ -110,6 +113,7 @@ export async function loadUserSettings(userId: number): Promise<UserSettings> {
|
|||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -135,6 +139,7 @@ export async function getAllUserSettings(): Promise<UserSettings[]> {
|
|||||||
normalStockDays: settings.normalStockDays,
|
normalStockDays: settings.normalStockDays,
|
||||||
highStockDays: settings.highStockDays,
|
highStockDays: settings.highStockDays,
|
||||||
language: settings.language as Language,
|
language: settings.language as Language,
|
||||||
|
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||||
lastAutoEmailSent: settings.lastAutoEmailSent,
|
lastAutoEmailSent: settings.lastAutoEmailSent,
|
||||||
lastNotificationType: settings.lastNotificationType,
|
lastNotificationType: settings.lastNotificationType,
|
||||||
lastNotificationChannel: settings.lastNotificationChannel,
|
lastNotificationChannel: settings.lastNotificationChannel,
|
||||||
@@ -183,6 +188,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
language: settings.language,
|
language: settings.language,
|
||||||
|
stockCalculationMode: settings.stockCalculationMode ?? "automatic",
|
||||||
// SMTP settings (from .env - shared/server-configured)
|
// SMTP settings (from .env - shared/server-configured)
|
||||||
smtpHost: process.env.SMTP_HOST ?? "",
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
|
||||||
@@ -231,6 +237,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
normalStockDays: body.normalStockDays ?? 90,
|
normalStockDays: body.normalStockDays ?? 90,
|
||||||
highStockDays: body.highStockDays ?? 180,
|
highStockDays: body.highStockDays ?? 180,
|
||||||
language: body.language ?? "en",
|
language: body.language ?? "en",
|
||||||
|
stockCalculationMode: body.stockCalculationMode ?? "automatic",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+77
-9
@@ -294,6 +294,8 @@ function AppContent() {
|
|||||||
emailIntakeReminders: true,
|
emailIntakeReminders: true,
|
||||||
shoutrrrStockReminders: true,
|
shoutrrrStockReminders: true,
|
||||||
shoutrrrIntakeReminders: true,
|
shoutrrrIntakeReminders: true,
|
||||||
|
// Stock calculation mode: "automatic" or "manual"
|
||||||
|
stockCalculationMode: "automatic" as "automatic" | "manual",
|
||||||
// Admin settings (from .env, read-only)
|
// Admin settings (from .env, read-only)
|
||||||
expiryWarningDays: 30,
|
expiryWarningDays: 30,
|
||||||
});
|
});
|
||||||
@@ -477,7 +479,7 @@ function AppContent() {
|
|||||||
|
|
||||||
const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language, true), [meds, i18n.language]);
|
const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language, true), [meds, i18n.language]);
|
||||||
const totalTablets = useMemo(() => deriveTotal(form), [form]);
|
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 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]);
|
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
|
||||||
|
|
||||||
@@ -588,6 +590,8 @@ function AppContent() {
|
|||||||
emailIntakeReminders: settings.emailIntakeReminders,
|
emailIntakeReminders: settings.emailIntakeReminders,
|
||||||
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
shoutrrrStockReminders: settings.shoutrrrStockReminders,
|
||||||
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
|
||||||
|
// Stock calculation mode
|
||||||
|
stockCalculationMode: settings.stockCalculationMode,
|
||||||
// Language setting (for backend notifications)
|
// Language setting (for backend notifications)
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
// SMTP (legacy - not saved, read from .env)
|
// SMTP (legacy - not saved, read from .env)
|
||||||
@@ -1978,6 +1982,46 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h3>{t('settings.stock.calculationMode')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="setting-group calculation-mode-group">
|
||||||
|
<label className={`radio-card ${settings.stockCalculationMode === 'automatic' ? 'selected' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="stockCalculationMode"
|
||||||
|
value="automatic"
|
||||||
|
checked={settings.stockCalculationMode === 'automatic'}
|
||||||
|
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||||
|
/>
|
||||||
|
<div className="radio-card-content">
|
||||||
|
<span className="radio-card-icon">🔄</span>
|
||||||
|
<div className="radio-card-text">
|
||||||
|
<span className="radio-card-title">{t('settings.stock.automatic')}</span>
|
||||||
|
<span className="radio-card-desc">{t('settings.stock.automaticDesc')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className={`radio-card ${settings.stockCalculationMode === 'manual' ? 'selected' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="stockCalculationMode"
|
||||||
|
value="manual"
|
||||||
|
checked={settings.stockCalculationMode === 'manual'}
|
||||||
|
onChange={(e) => setSettings({ ...settings, stockCalculationMode: e.target.value as 'automatic' | 'manual' })}
|
||||||
|
/>
|
||||||
|
<div className="radio-card-content">
|
||||||
|
<span className="radio-card-icon">✋</span>
|
||||||
|
<div className="radio-card-text">
|
||||||
|
<span className="radio-card-title">{t('settings.stock.manual')}</span>
|
||||||
|
<span className="radio-card-desc">{t('settings.stock.manualDesc')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h3>{t('settings.stock.display')}</h3>
|
<h3>{t('settings.stock.display')}</h3>
|
||||||
@@ -2864,7 +2908,14 @@ function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays
|
|||||||
return "success-text"; // outside warning period
|
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<string>
|
||||||
|
) {
|
||||||
const MS_PER_DAY = 86_400_000;
|
const MS_PER_DAY = 86_400_000;
|
||||||
const now = Date.now();
|
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);
|
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
|
||||||
|
|
||||||
let consumed = 0;
|
let consumed = 0;
|
||||||
m.blisters.forEach((s) => {
|
|
||||||
const start = new Date(s.start).getTime();
|
if (stockCalculationMode === "automatic") {
|
||||||
if (Number.isNaN(start) || start > now) return;
|
// Automatic mode: calculate consumed based on schedule since start date
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
m.blisters.forEach((s) => {
|
||||||
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
|
const start = new Date(s.start).getTime();
|
||||||
consumed += occurrences * s.usage;
|
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 medsLeft = Math.max(0, m.count - consumed);
|
||||||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||||||
|
|||||||
@@ -182,6 +182,11 @@
|
|||||||
"remindWhen": "Erinnern wenn Vorrat unter",
|
"remindWhen": "Erinnern wenn Vorrat unter",
|
||||||
"repeatDaily": "Täglich wiederholen",
|
"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.",
|
"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",
|
"display": "Anzeige",
|
||||||
"lowStockDays": "Niedriger Bestand (Tage)",
|
"lowStockDays": "Niedriger Bestand (Tage)",
|
||||||
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
|
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
|
||||||
|
|||||||
@@ -184,6 +184,11 @@
|
|||||||
"remindWhen": "Remind when supply drops below",
|
"remindWhen": "Remind when supply drops below",
|
||||||
"repeatDaily": "Repeat daily",
|
"repeatDaily": "Repeat daily",
|
||||||
"repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.",
|
"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",
|
"display": "Display",
|
||||||
"lowStockDays": "Low Stock (days)",
|
"lowStockDays": "Low Stock (days)",
|
||||||
"lowStockTooltip": "Yellow warning color threshold",
|
"lowStockTooltip": "Yellow warning color threshold",
|
||||||
|
|||||||
@@ -1289,6 +1289,69 @@ textarea.auto-resize {
|
|||||||
letter-spacing: 0.03em;
|
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 {
|
.setting-section {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
|||||||
Reference in New Issue
Block a user