Compare commits

..

8 Commits

Author SHA1 Message Date
Daniel Volz 0dab318b66 chore: release v1.23.0 (#536) 2026-04-10 22:40:01 +02:00
github-actions[bot] 932524125e chore: update test count badges [skip ci] 2026-04-10 20:38:49 +00:00
Daniel Volz c291c88f2b fix(notifications): fallback to generic medication names (#532)
* fix(notifications): fallback to generic medication names

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:34:06 +02:00
Daniel Volz e42e4f5639 fix(stock): ignore doses from other medications (#533)
* fix(stock): ignore doses from other medications

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:33:58 +02:00
Daniel Volz b70fc88921 chore(gitignore): ignore local agent workspace artifacts (#534) 2026-04-10 22:31:54 +02:00
Daniel Volz 95aec8350a fix(settings): stabilize timezone edit UX and tooltip visibility (#535) 2026-04-10 22:31:22 +02:00
Daniel Volz 401228699f Add searchable timezone settings override for reminder scheduling 2026-04-10 21:08:16 +02:00
Daniel Volz 0d2b21199e chore(release): bump app version to 1.22.3
chore(release): bump app version to 1.22.3
2026-04-10 19:01:40 +02:00
26 changed files with 333 additions and 32 deletions
+2 -1
View File
@@ -37,7 +37,8 @@ LOG_LEVEL=warn
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false # production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true # OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) # Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
# Users can override this per account in Settings -> Timezone.
TZ=Europe/Berlin TZ=Europe/Berlin
# ============================================================================= # =============================================================================
+11 -1
View File
@@ -83,14 +83,18 @@ Thumbs.db
AGENTS.md AGENTS.md
docs/TECH_STACK.md docs/TECH_STACK.md
doku/ doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md doku/memory_notes.md
doku/report.md doku/report.md
plan/ plan/
.copilot-tracking/ .copilot-tracking/
.playwright-cli/ .playwright-cli/
.agents/
skills-lock.json
# =================== # ===================
# Local Spec Kit artifacts # Local Spec Kit workspace state
# =================== # ===================
.specify/ .specify/
specs/ specs/
@@ -98,3 +102,9 @@ docs/SPEC_KIT.md
.github/agents/medassist-feature-orchestrator.agent.md .github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md .github/agents/speckit.*.agent.md
.github/prompts/speckit.*.prompt.md .github/prompts/speckit.*.prompt.md
.github/skills/accessibility/
.github/skills/frontend-design/
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
+4 -2
View File
@@ -18,7 +18,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-639%2F639-brightgreen?logo=vitest" alt="Backend Tests 454/454" /> <img src="https://img.shields.io/badge/Backend_Tests-640%2F640-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" /> <img src="https://img.shields.io/badge/Frontend_Tests-884%2F884-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p> </p>
@@ -203,7 +203,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS | | `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. | | `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. | | `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders | | `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
Recommended values for API docs by environment: Recommended values for API docs by environment:
@@ -305,6 +305,8 @@ API reference:
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder | | `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning | | `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
### Push Notifications (Shoutrrr) ### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format. MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
+7
View File
@@ -99,6 +99,13 @@
"when": 1773348659979, "when": 1773348659979,
"tag": "0013_add_share_medication_overview", "tag": "0013_add_share_medication_overview",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.22.2", "version": "1.23.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1
View File
@@ -33,6 +33,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`, `ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`, `ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`, `ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
`ALTER TABLE user_settings ADD COLUMN timezone text NOT NULL DEFAULT ''`,
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`, `ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
`ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`, `ALTER TABLE dose_tracking ADD COLUMN taken_source text NOT NULL DEFAULT 'manual'`,
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`, `ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
+1
View File
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
high_stock_days integer NOT NULL DEFAULT 180, high_stock_days integer NOT NULL DEFAULT 180,
expiry_warning_days integer NOT NULL DEFAULT 90, expiry_warning_days integer NOT NULL DEFAULT 90,
language text NOT NULL DEFAULT 'en', language text NOT NULL DEFAULT 'en',
timezone text NOT NULL DEFAULT '',
stock_calculation_mode text NOT NULL DEFAULT 'automatic', stock_calculation_mode text NOT NULL DEFAULT 'automatic',
share_stock_status integer NOT NULL DEFAULT 1, share_stock_status integer NOT NULL DEFAULT 1,
upcoming_today_only integer NOT NULL DEFAULT 0, upcoming_today_only integer NOT NULL DEFAULT 0,
+1
View File
@@ -105,6 +105,7 @@ export const userSettings = sqliteTable("user_settings", {
expiryWarningDays: integer("expiry_warning_days").notNull().default(90), expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
// UI preferences // UI preferences
language: text("language", { length: 10 }).notNull().default("en"), language: text("language", { length: 10 }).notNull().default("en"),
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses) // Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"), stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users // Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
+9
View File
@@ -8,9 +8,11 @@ import { getSmtpConfig, sendEmailNotification } from "../services/notifications/
import { import {
classifyTestEmailFailure, classifyTestEmailFailure,
getAllUserSettingsFromDb, getAllUserSettingsFromDb,
getAvailableTimezones,
getDefaultSettings, getDefaultSettings,
getNotificationProvider, getNotificationProvider,
loadUserSettingsFromDb, loadUserSettingsFromDb,
normalizeSettingsTimezone,
sanitizeNotificationUrl, sanitizeNotificationUrl,
type UserSettings, type UserSettings,
validateNotificationHostname, validateNotificationHostname,
@@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js";
export type { UserSettings } from "../services/settings-service.js"; export type { UserSettings } from "../services/settings-service.js";
type SettingsBody = { type SettingsBody = {
timezone: string;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string; notificationEmail: string;
reminderDaysBefore: number; reminderDaysBefore: number;
@@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15); const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
return reply.send({ return reply.send({
timezone: settings.timezone ?? "",
availableTimezones: getAvailableTimezones(),
serverTimezone: process.env.TZ || "UTC",
// User notification settings (from DB) // User notification settings (from DB)
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail ?? "", notificationEmail: settings.notificationEmail ?? "",
@@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) {
type: "object", type: "object",
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"], required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
properties: { properties: {
timezone: { type: "string" },
emailEnabled: { type: "boolean" }, emailEnabled: { type: "boolean" },
notificationEmail: { type: "string" }, notificationEmail: { type: "string" },
reminderDaysBefore: { type: "number" }, reminderDaysBefore: { type: "number" },
@@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
upcomingTodayOnly: false, upcomingTodayOnly: false,
shareScheduleTodayOnly: false, shareScheduleTodayOnly: false,
swapDashboardMainSections: false, swapDashboardMainSections: false,
timezone: "",
}, },
}, },
response: { response: {
@@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) {
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const settingsData = { const settingsData = {
timezone: normalizeSettingsTimezone(body.timezone),
emailEnabled: body.emailEnabled, emailEnabled: body.emailEnabled,
notificationEmail: body.notificationEmail || null, notificationEmail: body.notificationEmail || null,
emailStockReminders: body.emailStockReminders ?? true, emailStockReminders: body.emailStockReminders ?? true,
+16 -2
View File
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId); const match = doseIdPattern.exec(dose.doseId);
if (!match) continue; if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10); const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10); const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) { if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue; continue;
} }
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
const match = doseIdPattern.exec(dose.doseId); const match = doseIdPattern.exec(dose.doseId);
if (!match) continue; if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10); const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10); const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(parsedIntakeIndex) || Number.isNaN(doseDateOnlyMs) || parsedIntakeIndex !== intakeIndex) { if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue; continue;
} }
@@ -18,7 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js";
import { import {
cleanOldIntakeReminders, cleanOldIntakeReminders,
createDefaultIntakeReminderState, createDefaultIntakeReminderState,
getTimezone, getEffectiveTimezone,
getTodaysIntakes, getTodaysIntakes,
getUpcomingIntakes, getUpcomingIntakes,
type IntakeReminderState, type IntakeReminderState,
@@ -83,6 +83,16 @@ function formatIntakeLog(intake: {
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`; return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
} }
function getMedicationDisplayName(med: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = med.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = med.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${med.id}`;
}
async function autoMarkDueIntakesAsTaken( async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number }, settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[], rows: (typeof medications.$inferSelect)[],
@@ -137,7 +147,7 @@ async function autoMarkDueIntakesAsTaken(
} }
const medicationTakenBy = parseTakenByJson(med.takenByJson); const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || ""; const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
let remainingStock = computeMedicationCurrentStock({ let remainingStock = computeMedicationCurrentStock({
medication: med, medication: med,
doses: trackedDoses, doses: trackedDoses,
@@ -425,7 +435,7 @@ export async function checkAndSendIntakeRemindersForUser(
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id); const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language); const locale = getDateLocale(language);
const tz = getTimezone(); const tz = getEffectiveTimezone(settings.timezone ?? null);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger); const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) { if (autoMarkedCount > 0) {
@@ -488,7 +498,7 @@ export async function checkAndSendIntakeRemindersForUser(
for (const { med, intakes, intakesWithReminders } of reminderEntries) { for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Medication-level takenBy (for fallback/display purposes) // Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson); const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || ""; const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
// Process each intake separately to track blisterIndex // Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => { intakesWithReminders.forEach((intake, _blisterIndex) => {
+15 -3
View File
@@ -21,6 +21,7 @@ import {
formatInTimezone, formatInTimezone,
getCurrentHourInTimezone, getCurrentHourInTimezone,
getDateOnlyTimestamp, getDateOnlyTimestamp,
getEffectiveTimezone,
getMsUntilNextCheck, getMsUntilNextCheck,
getNextScheduledOccurrenceTime, getNextScheduledOccurrenceTime,
getNextScheduledTime, getNextScheduledTime,
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
expiryDate: string | null; expiryDate: string | null;
}; };
function getMedicationDisplayName(row: { id: number; name: string | null; genericName: string | null }): string {
const commercialName = row.name?.trim() ?? "";
if (commercialName) return commercialName;
const genericName = row.genericName?.trim() ?? "";
if (genericName) return genericName;
return `Medication #${row.id}`;
}
async function getMedicationsNeedingReminder( async function getMedicationsNeedingReminder(
userId: number, userId: number,
reminderDaysBefore: number, reminderDaysBefore: number,
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) { if (isCritical || isLow) {
lowStock.push({ lowStock.push({
name: row.name, name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills, medsLeft: currentPills,
daysLeft, daysLeft,
depletionDate, depletionDate,
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1) (row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
) )
.map((row) => ({ .map((row) => ({
name: row.name, name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
remainingRefills: row.prescriptionRemainingRefills ?? 0, remainingRefills: row.prescriptionRemainingRefills ?? 0,
lowThreshold: row.prescriptionLowRefillThreshold ?? 1, lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
expiryDate: row.prescriptionExpiryDate ?? null, expiryDate: row.prescriptionExpiryDate ?? null,
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
} }
const state = loadReminderState(); const state = loadReminderState();
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone const userTimezone = getEffectiveTimezone(settings.timezone ?? null);
const today = getTodayInTimezone(userTimezone); // YYYY-MM-DD in effective user timezone
const userStateKey = `user_${settings.userId}`; const userStateKey = `user_${settings.userId}`;
const userStockNotifiedKey = `${userStateKey}_${today}_stock`; const userStockNotifiedKey = `${userStateKey}_${today}_stock`;
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`; const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
+31
View File
@@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js";
export type UserSettings = { export type UserSettings = {
userId: number; userId: number;
timezone?: string | null;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string | null; notificationEmail: string | null;
emailStockReminders: boolean; emailStockReminders: boolean;
@@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number {
export function getDefaultSettings() { export function getDefaultSettings() {
return { return {
timezone: "",
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false), emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null, notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true), emailStockReminders: envBool("DEFAULT_EMAIL_STOCK_REMINDERS", true),
@@ -144,6 +146,33 @@ export function getDefaultSettings() {
}; };
} }
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
let cachedTimezones: Set<string> | null = null;
function getTimezoneSet(): Set<string> {
if (cachedTimezones) return cachedTimezones;
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
cachedTimezones = new Set(intlWithSupportedValues.supportedValuesOf("timeZone"));
return cachedTimezones;
}
cachedTimezones = new Set([process.env.TZ || "UTC", "UTC"]);
return cachedTimezones;
}
export function getAvailableTimezones(): string[] {
return [...getTimezoneSet()].sort((left, right) => left.localeCompare(right));
}
export function normalizeSettingsTimezone(value: string | null | undefined): string {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
return getTimezoneSet().has(trimmed) ? trimmed : "";
}
export function validateNotificationHostname(hostnameRaw: string): string | null { export function validateNotificationHostname(hostnameRaw: string): string | null {
const hostname = hostnameRaw.toLowerCase(); const hostname = hostnameRaw.toLowerCase();
@@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
const settings = await getOrCreateUserSettings(userId); const settings = await getOrCreateUserSettings(userId);
return { return {
userId: settings.userId, userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail, notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders, emailStockReminders: settings.emailStockReminders,
@@ -288,6 +318,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
const allSettings = await db.select().from(userSettings); const allSettings = await db.select().from(userSettings);
return allSettings.map((settings) => ({ return allSettings.map((settings) => ({
userId: settings.userId, userId: settings.userId,
timezone: settings.timezone?.trim() ? settings.timezone : null,
emailEnabled: settings.emailEnabled, emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail, notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders, emailStockReminders: settings.emailStockReminders,
+1
View File
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
+1
View File
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
+1
View File
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
`CREATE TABLE IF NOT EXISTS user_settings ( `CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE, user_id integer NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT '',
email_enabled integer NOT NULL DEFAULT 0, email_enabled integer NOT NULL DEFAULT 0,
notification_email text, notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1, email_stock_reminders integer NOT NULL DEFAULT 1,
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
async function createMedication(options: { async function createMedication(options: {
name: string; name: string;
genericName?: string | null;
packCount?: number; packCount?: number;
blistersPerPack?: number; blistersPerPack?: number;
pillsPerBlister?: number; pillsPerBlister?: number;
@@ -80,6 +81,7 @@ async function createMedication(options: {
}) { }) {
const { const {
name, name,
genericName = null,
packCount = 1, packCount = 1,
blistersPerPack = 1, blistersPerPack = 1,
pillsPerBlister = 10, pillsPerBlister = 10,
@@ -106,16 +108,17 @@ async function createMedication(options: {
const result = await testClient.execute({ const result = await testClient.execute({
sql: `INSERT INTO medications ( sql: `INSERT INTO medications (
user_id, name, taken_by_json, package_type, user_id, name, generic_name, taken_by_json, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
stock_adjustment, last_stock_correction_at, stock_adjustment, last_stock_correction_at,
usage_json, every_json, start_json, intakes_json, usage_json, every_json, start_json, intakes_json,
is_obsolete, intake_reminders_enabled is_obsolete, intake_reminders_enabled
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
RETURNING id`, RETURNING id`,
args: [ args: [
1, 1,
name, name,
genericName,
JSON.stringify(takenBy), JSON.stringify(takenBy),
packCount, packCount,
blistersPerPack, blistersPerPack,
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic"); const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false); expect(lowStock.some((r) => r.name === "Obsolete Med")).toBe(false);
}); });
it("uses generic name fallback in scheduler reminders when commercial name is empty", async () => {
await setStockMode("automatic");
await createMedication({
name: "",
genericName: "Acetylsalicylic acid",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00" }],
});
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
expect(lowStock.some((r) => r.name === "Acetylsalicylic acid")).toBe(true);
});
}); });
describe("getLiquidReminderThresholds", () => { describe("getLiquidReminderThresholds", () => {
+57 -9
View File
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0); return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
} }
function getLocalDateOrdinal(date: Date): number {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
}
function addLocalCalendarDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
export function getDateOnlyTimestamp(date: Date): number { export function getDateOnlyTimestamp(date: Date): number {
return toDateOnly(date).getTime(); return toDateOnly(date).getTime();
} }
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
const lowerBound = inclusive ? fromMs : fromMs + 1; const lowerBound = inclusive ? fromMs : fromMs + 1;
if (schedule.scheduleMode !== "weekdays") { if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000; const intervalDays = Math.max(1, schedule.every);
if (startTime >= lowerBound) { if (startTime >= lowerBound) {
return startTime; return startTime;
} }
const intervals = Math.ceil((lowerBound - startTime) / period); const lowerBoundDate = new Date(lowerBound);
return startTime + intervals * period; const startOrdinal = getLocalDateOrdinal(startDate);
const lowerBoundOrdinal = getLocalDateOrdinal(lowerBoundDate);
const daysBetween = Math.max(0, lowerBoundOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
let candidate = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (candidate.getTime() < lowerBound) {
candidate = addLocalCalendarDays(candidate, intervalDays);
}
return candidate.getTime();
} }
const candidateStart = Math.max(lowerBound, startTime); const candidateStart = Math.max(lowerBound, startTime);
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
} }
if (schedule.scheduleMode !== "weekdays") { if (schedule.scheduleMode !== "weekdays") {
const period = Math.max(1, schedule.every) * 86_400_000; const intervalDays = Math.max(1, schedule.every);
let occurrenceMs = startTime; let occurrence = new Date(startDate);
if (occurrenceMs < rangeStartMs) { if (occurrence.getTime() < rangeStartMs) {
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period); const rangeStartDate = new Date(rangeStartMs);
occurrenceMs += intervals * period; const startOrdinal = getLocalDateOrdinal(startDate);
const rangeStartOrdinal = getLocalDateOrdinal(rangeStartDate);
const daysBetween = Math.max(0, rangeStartOrdinal - startOrdinal);
const wholeIntervals = Math.floor(daysBetween / intervalDays);
occurrence = addLocalCalendarDays(startDate, wholeIntervals * intervalDays);
while (occurrence.getTime() < rangeStartMs) {
occurrence = addLocalCalendarDays(occurrence, intervalDays);
}
} }
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) { for (let occurrenceMs = occurrence.getTime(); occurrenceMs <= rangeEndMs; ) {
if (occurrenceMs >= rangeStartMs) { if (occurrenceMs >= rangeStartMs) {
callback(occurrenceMs); callback(occurrenceMs);
} }
occurrence = addLocalCalendarDays(occurrence, intervalDays);
occurrenceMs = occurrence.getTime();
} }
return; return;
} }
@@ -348,6 +379,23 @@ export function getTimezone(): string {
return process.env.TZ || "UTC"; return process.env.TZ || "UTC";
} }
export function isValidTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value });
return true;
} catch {
return false;
}
}
export function getEffectiveTimezone(override?: string | null): string {
const normalized = override?.trim() ?? "";
if (normalized && isValidTimezone(normalized)) {
return normalized;
}
return getTimezone();
}
/** Format a date in the configured timezone */ /** Format a date in the configured timezone */
export function formatInTimezone(date: Date, tz?: string): string { export function formatInTimezone(date: Date, tz?: string): string {
return date.toLocaleString("de-DE", { return date.toLocaleString("de-DE", {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.22.2", "version": "1.23.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+7
View File
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
export interface Settings { export interface Settings {
timezone: string;
availableTimezones: string[];
serverTimezone: string;
emailEnabled: boolean; emailEnabled: boolean;
notificationEmail: string; notificationEmail: string;
reminderDaysBefore: number; reminderDaysBefore: number;
@@ -58,6 +61,9 @@ export interface Settings {
export type SettingsLoadError = "auth" | "forbidden" | "request" | null; export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
const defaultSettings: Settings = { const defaultSettings: Settings = {
timezone: "",
availableTimezones: [],
serverTimezone: "UTC",
emailEnabled: false, emailEnabled: false,
notificationEmail: "", notificationEmail: "",
reminderDaysBefore: 7, reminderDaysBefore: 7,
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false; const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
return { return {
timezone: settingsToSave.timezone,
emailEnabled: effectiveEmailEnabled, emailEnabled: effectiveEmailEnabled,
notificationEmail: settingsToSave.notificationEmail, notificationEmail: settingsToSave.notificationEmail,
reminderDaysBefore: settingsToSave.reminderDaysBefore, reminderDaysBefore: settingsToSave.reminderDaysBefore,
+8
View File
@@ -389,6 +389,14 @@
"title": "Sprache", "title": "Sprache",
"select": "Sprache auswählen" "select": "Sprache auswählen"
}, },
"timezone": {
"select": "Zeitzone",
"hint": "IANA-Zeitzone wählen. Wenn gesetzt, überschreibt sie die Server-TZ für deine Reminder-Zeitpunkte.",
"useServerDefault": "Server-Standard nutzen",
"currentServerTz": "Server-Standardzeitzone: {{timezone}}",
"saving": "Zeitzone wird gespeichert...",
"saved": "Zeitzone gespeichert"
},
"apiKey": { "apiKey": {
"title": "API-Zugriff", "title": "API-Zugriff",
"generateTitle": "API-Key erzeugen", "generateTitle": "API-Key erzeugen",
+8
View File
@@ -389,6 +389,14 @@
"title": "Language", "title": "Language",
"select": "Select language" "select": "Select language"
}, },
"timezone": {
"select": "Timezone",
"hint": "Select an IANA timezone. When set, this overrides server TZ for your reminder timing.",
"useServerDefault": "Use server default",
"currentServerTz": "Server default timezone: {{timezone}}",
"saving": "Saving timezone...",
"saved": "Timezone saved"
},
"apiKey": { "apiKey": {
"title": "API Access", "title": "API Access",
"generateTitle": "Generate API key", "generateTitle": "Generate API key",
+96 -1
View File
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */ /* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfirmModal, ExportModal } from "../components"; import { ConfirmModal, ExportModal } from "../components";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
@@ -13,8 +13,11 @@ export function SettingsPage() {
const [apiKeyError, setApiKeyError] = useState<string | null>(null); const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const { const {
settings, settings,
savedSettings,
setSettings, setSettings,
settingsLoading, settingsLoading,
settingsSaving,
settingsSaved,
settingsLoadError, settingsLoadError,
// Email testing // Email testing
testEmail, testEmail,
@@ -39,6 +42,8 @@ export function SettingsPage() {
setImportResult, setImportResult,
meds, meds,
} = useAppContext(); } = useAppContext();
const [timezoneTouched, setTimezoneTouched] = useState(false);
const [timezoneDraft, setTimezoneDraft] = useState("");
const hasExistingData = meds.length > 0; const hasExistingData = meds.length > 0;
let emailUnavailableReason: string | null = null; let emailUnavailableReason: string | null = null;
@@ -117,6 +122,49 @@ export function SettingsPage() {
const automaticStockCalculationId = "settings-stock-calculation-automatic"; const automaticStockCalculationId = "settings-stock-calculation-automatic";
const manualStockCalculationId = "settings-stock-calculation-manual"; const manualStockCalculationId = "settings-stock-calculation-manual";
useEffect(() => {
setTimezoneDraft(settings.timezone);
}, [settings.timezone]);
const commitTimezoneDraft = () => {
if (timezoneDraft === settings.timezone) {
return;
}
setTimezoneTouched(true);
setSettings((prev) => ({ ...prev, timezone: timezoneDraft }));
};
const savedTimezone = savedSettings?.timezone ?? settings.timezone;
const timezoneChanged = settings.timezone !== savedTimezone;
const showTimezoneSaving = timezoneTouched && timezoneChanged && settingsSaving;
const showTimezoneSaved = timezoneTouched && !timezoneChanged && settingsSaved;
let timezoneStatusText = "";
if (showTimezoneSaving) {
timezoneStatusText = t("settings.timezone.saving");
} else if (showTimezoneSaved) {
timezoneStatusText = t("settings.timezone.saved");
}
const timezoneStatusClassName = showTimezoneSaved ? "timezone-status timezone-status-saved" : "timezone-status";
const availableTimezones = Array.isArray(settings.availableTimezones) ? settings.availableTimezones : [];
const timezoneSuggestions =
availableTimezones.length > 0
? availableTimezones
: (() => {
try {
type IntlWithSupportedValuesOf = typeof Intl & {
supportedValuesOf?: (key: string) => string[];
};
const intlWithSupportedValues = Intl as IntlWithSupportedValuesOf;
if (typeof intlWithSupportedValues.supportedValuesOf === "function") {
return intlWithSupportedValues.supportedValuesOf("timeZone");
}
} catch {
// fall through
}
return [settings.serverTimezone || "UTC", "UTC"];
})();
return ( return (
<section className="grid"> <section className="grid">
{settingsLoading ? ( {settingsLoading ? (
@@ -160,6 +208,53 @@ export function SettingsPage() {
<option value="de">🇩🇪 Deutsch</option> <option value="de">🇩🇪 Deutsch</option>
</select> </select>
</label> </label>
<div className="setting-row language-row" style={{ marginTop: "12px" }}>
<div className="setting-label">
<span>{t("settings.timezone.select")}</span>
<span className="info-tooltip small tooltip-align-left" data-tooltip={t("settings.timezone.hint")}>
</span>
</div>
<div className="setting-actions" style={{ margin: 0, flexWrap: "nowrap", gap: "8px", width: "auto" }}>
<input
type="text"
className="select-field language-select"
value={timezoneDraft}
onChange={(e) => {
setTimezoneDraft(e.target.value);
}}
onBlur={commitTimezoneDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
list="settings-timezone-suggestions"
placeholder={settings.serverTimezone || "UTC"}
/>
<datalist id="settings-timezone-suggestions">
{timezoneSuggestions.map((zone) => (
<option key={zone} value={zone} />
))}
</datalist>
<button
type="button"
className="ghost"
onClick={() => {
setTimezoneTouched(true);
setTimezoneDraft("");
setSettings((prev) => ({ ...prev, timezone: "" }));
}}
>
{t("settings.timezone.useServerDefault")}
</button>
</div>
</div>
<p className={timezoneStatusClassName}>{timezoneStatusText || " "}</p>
<p className="hint-text" style={{ marginTop: "8px" }}>
{t("settings.timezone.currentServerTz", { timezone: settings.serverTimezone || "UTC" })}
</p>
</article> </article>
<article className="card" data-testid="settings-notification-card"> <article className="card" data-testid="settings-notification-card">
+1 -1
View File
@@ -613,7 +613,7 @@ body.modal-open {
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
margin-bottom: 1rem; margin-bottom: 1rem;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: visible;
} }
.card { .card {
+17 -3
View File
@@ -10,7 +10,7 @@
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: visible;
} }
.setting-row { .setting-row {
@@ -311,7 +311,7 @@
transition: transition:
opacity 0.15s, opacity 0.15s,
visibility 0.15s; visibility 0.15s;
z-index: 1100; z-index: 12000;
pointer-events: none; pointer-events: none;
} }
@@ -329,7 +329,7 @@
transition: transition:
opacity 0.15s, opacity 0.15s,
visibility 0.15s; visibility 0.15s;
z-index: 1101; z-index: 12001;
} }
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */ /* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
@@ -507,6 +507,20 @@
border-radius: 6px; border-radius: 6px;
} }
.timezone-status {
min-height: 1.25rem;
margin-top: 8px;
margin-bottom: 0;
padding: 0;
font-size: 0.85rem;
color: transparent;
background: transparent;
}
.timezone-status-saved {
color: var(--success);
}
/* Notification Matrix Mobile */ /* Notification Matrix Mobile */
@media (max-width: 480px) { @media (max-width: 480px) {
.notification-matrix { .notification-matrix {