Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dab318b66 | |||
| 932524125e | |||
| c291c88f2b | |||
| e42e4f5639 | |||
| b70fc88921 | |||
| 95aec8350a | |||
| 401228699f | |||
| 0d2b21199e | |||
| d5b3c5c21f | |||
| 002f16c505 | |||
| aa050f7dc5 | |||
| 0795bfe589 | |||
| 25483c12f0 | |||
| 2a340855fb | |||
| 52fec1a4e5 | |||
| 1cb4a44cef | |||
| 51b09dc563 | |||
| dbbd9d5ed8 | |||
| 15f1e33aa4 |
+2
-1
@@ -37,7 +37,8 @@ LOG_LEVEL=warn
|
||||
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
|
||||
+12
-2
@@ -83,18 +83,28 @@ Thumbs.db
|
||||
AGENTS.md
|
||||
docs/TECH_STACK.md
|
||||
doku/
|
||||
|
||||
# Local agent work logs stay on disk but must never go upstream.
|
||||
doku/memory_notes.md
|
||||
doku/report.md
|
||||
plan/
|
||||
.copilot-tracking/
|
||||
.playwright-cli/
|
||||
.agents/
|
||||
skills-lock.json
|
||||
|
||||
# ===================
|
||||
# Local Spec Kit artifacts
|
||||
# Local Spec Kit workspace state
|
||||
# ===================
|
||||
.specify/
|
||||
specs/
|
||||
docs/SPEC_KIT.md
|
||||
.github/agents/medassist-feature-orchestrator.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
|
||||
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
|
||||
<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" />
|
||||
</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 |
|
||||
| `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. |
|
||||
| `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:
|
||||
|
||||
@@ -305,6 +305,8 @@ API reference:
|
||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||
| `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)
|
||||
|
||||
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;
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1773348659979,
|
||||
"tag": "0013_add_share_medication_overview",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1775849300000,
|
||||
"tag": "0014_add_user_settings_timezone",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+161
-1075
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,20 +28,20 @@
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"@libsql/client": "^0.17.2",
|
||||
"argon2": "^0.44.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.2.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^8.0.5",
|
||||
"openid-client": "^6.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
@@ -50,5 +50,10 @@
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"overrides": {
|
||||
"@esbuild-kit/esm-loader": "2.6.5",
|
||||
"@esbuild-kit/core-utils": "3.3.2",
|
||||
"esbuild": "0.25.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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 timezone text NOT NULL DEFAULT ''`,
|
||||
`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 user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function getTableCreationSQL(): string[] {
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
share_stock_status integer NOT NULL DEFAULT 1,
|
||||
upcoming_today_only integer NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -105,6 +105,7 @@ export const userSettings = sqliteTable("user_settings", {
|
||||
expiryWarningDays: integer("expiry_warning_days").notNull().default(90),
|
||||
// UI preferences
|
||||
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)
|
||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import {
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
type StockReminderItem as SharedStockReminderItem,
|
||||
} from "../services/notifications/builders.js";
|
||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js";
|
||||
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||
import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
@@ -428,19 +427,9 @@ ${getFooterPlain(language)}`;
|
||||
`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
request.log.info({ userId, recipientEmail: email }, "[Planner] Sending demand email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
const mailResult = await sendEmailNotification({
|
||||
from: smtpFrom,
|
||||
to: email,
|
||||
subject: t(dc.subject, { from: fromDate, until: untilDate }),
|
||||
@@ -448,9 +437,8 @@ ${getFooterPlain(language)}`;
|
||||
html,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Failed to send demand email");
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
||||
import {
|
||||
classifyTestEmailFailure,
|
||||
getAllUserSettingsFromDb,
|
||||
getAvailableTimezones,
|
||||
getDefaultSettings,
|
||||
getNotificationProvider,
|
||||
loadUserSettingsFromDb,
|
||||
normalizeSettingsTimezone,
|
||||
sanitizeNotificationUrl,
|
||||
type UserSettings,
|
||||
validateNotificationHostname,
|
||||
@@ -20,6 +22,7 @@ import type { AuthUser } from "../types/fastify.js";
|
||||
export type { UserSettings } from "../services/settings-service.js";
|
||||
|
||||
type SettingsBody = {
|
||||
timezone: string;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
@@ -174,6 +177,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const reminderMinutesBefore = envInt("REMINDER_MINUTES_BEFORE", 15);
|
||||
|
||||
return reply.send({
|
||||
timezone: settings.timezone ?? "",
|
||||
availableTimezones: getAvailableTimezones(),
|
||||
serverTimezone: process.env.TZ || "UTC",
|
||||
// User notification settings (from DB)
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail ?? "",
|
||||
@@ -241,6 +247,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
type: "object",
|
||||
required: ["emailEnabled", "notificationEmail", "reminderDaysBefore", "language"],
|
||||
properties: {
|
||||
timezone: { type: "string" },
|
||||
emailEnabled: { type: "boolean" },
|
||||
notificationEmail: { type: "string" },
|
||||
reminderDaysBefore: { type: "number" },
|
||||
@@ -293,6 +300,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upcomingTodayOnly: false,
|
||||
shareScheduleTodayOnly: false,
|
||||
swapDashboardMainSections: false,
|
||||
timezone: "",
|
||||
},
|
||||
},
|
||||
response: {
|
||||
@@ -318,6 +326,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const settingsData = {
|
||||
timezone: normalizeSettingsTimezone(body.timezone),
|
||||
emailEnabled: body.emailEnabled,
|
||||
notificationEmail: body.notificationEmail || null,
|
||||
emailStockReminders: body.emailStockReminders ?? true,
|
||||
@@ -445,49 +454,34 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
|
||||
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
|
||||
const smtpSecure = process.env.SMTP_SECURE === "true";
|
||||
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
|
||||
const smtp = getSmtpConfig();
|
||||
|
||||
request.log.info(
|
||||
{
|
||||
to: email,
|
||||
hasSmtpHost: Boolean(smtpHost),
|
||||
hasSmtpUser: Boolean(smtpUser),
|
||||
hasSmtpPass: Boolean(smtpPass),
|
||||
hasSmtpFrom: Boolean(smtpFrom),
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
hasSmtpHost: Boolean(smtp.host),
|
||||
hasSmtpUser: Boolean(smtp.user),
|
||||
hasSmtpPass: Boolean(smtp.pass),
|
||||
hasSmtpFrom: Boolean(smtp.from),
|
||||
smtpPort: smtp.port,
|
||||
smtpSecure: smtp.secure,
|
||||
},
|
||||
"[Settings] Test email request received"
|
||||
);
|
||||
|
||||
if (!smtpHost || !smtpUser) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
request.log.warn(
|
||||
{ to: email, hasSmtpHost: Boolean(smtpHost), hasSmtpUser: Boolean(smtpUser) },
|
||||
{ to: email, hasSmtpHost: Boolean(smtp.host), hasSmtpUser: Boolean(smtp.user) },
|
||||
"[Settings] Test email skipped: SMTP not configured"
|
||||
);
|
||||
return reply.status(400).send({ error: "SMTP not configured" });
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
request.log.info({ to: email }, "[Settings] Sending test email");
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: smtpFrom,
|
||||
const mailResult = await sendEmailNotification({
|
||||
from: smtp.from,
|
||||
to: email,
|
||||
subject: "MedAssist-ng - Test Email",
|
||||
text: "This is a test email from MedAssist-ng. If you received this, your email configuration is working correctly!",
|
||||
@@ -502,9 +496,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
`,
|
||||
});
|
||||
|
||||
const deliveryError = getDeliveryError(mailResult);
|
||||
if (deliveryError) {
|
||||
throw new Error(deliveryError);
|
||||
if (!mailResult.success) {
|
||||
throw new Error(mailResult.error ?? "Failed to send test email");
|
||||
}
|
||||
|
||||
request.log.info({ to: email, messageId: mailResult.messageId }, "[Settings] Test email sent");
|
||||
|
||||
@@ -99,9 +99,16 @@ export function computeMedicationCurrentStock(options: {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 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;
|
||||
}
|
||||
|
||||
@@ -125,9 +132,16 @@ export function computeMedicationCurrentStock(options: {
|
||||
const match = doseIdPattern.exec(dose.doseId);
|
||||
if (!match) continue;
|
||||
|
||||
const parsedMedicationId = Number.parseInt(match[1], 10);
|
||||
const parsedIntakeIndex = Number.parseInt(match[2], 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { ServiceLogger } from "../utils/logger.js";
|
||||
import {
|
||||
cleanOldIntakeReminders,
|
||||
createDefaultIntakeReminderState,
|
||||
getTimezone,
|
||||
getEffectiveTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
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})`;
|
||||
}
|
||||
|
||||
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(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
@@ -137,7 +147,7 @@ async function autoMarkDueIntakesAsTaken(
|
||||
}
|
||||
|
||||
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({
|
||||
medication: med,
|
||||
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 locale = getDateLocale(language);
|
||||
const tz = getTimezone();
|
||||
const tz = getEffectiveTimezone(settings.timezone ?? null);
|
||||
|
||||
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
|
||||
if (autoMarkedCount > 0) {
|
||||
@@ -488,7 +498,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||
// Medication-level takenBy (for fallback/display purposes)
|
||||
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
|
||||
intakesWithReminders.forEach((intake, _blisterIndex) => {
|
||||
|
||||
@@ -64,6 +64,25 @@ export function getSmtpConfig(): {
|
||||
return { host, user, pass, port, secure, from };
|
||||
}
|
||||
|
||||
export function createSmtpTransport(smtp = getSmtpConfig()) {
|
||||
if (!smtp.host || !smtp.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The SMTP endpoint is configured by the server operator via environment variables,
|
||||
// not derived from request-controlled input.
|
||||
// lgtm [js/request-forgery]
|
||||
return nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: {
|
||||
user: smtp.user,
|
||||
pass: smtp.pass ?? "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmailNotification(input: EmailDeliveryRequest): Promise<EmailDeliveryResult> {
|
||||
const smtp = getSmtpConfig();
|
||||
if (!smtp.host || !smtp.user) {
|
||||
@@ -71,15 +90,10 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: {
|
||||
user: smtp.user,
|
||||
pass: smtp.pass ?? "",
|
||||
},
|
||||
});
|
||||
const transporter = createSmtpTransport(smtp);
|
||||
if (!transporter) {
|
||||
return { success: false, error: "SMTP not configured" };
|
||||
}
|
||||
|
||||
const mailResult = await transporter.sendMail({
|
||||
from: input.from ?? smtp.from,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getDateOnlyTimestamp,
|
||||
getEffectiveTimezone,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
@@ -125,6 +126,16 @@ type PrescriptionReminderItem = {
|
||||
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(
|
||||
userId: number,
|
||||
reminderDaysBefore: number,
|
||||
@@ -296,7 +307,7 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
if (isCritical || isLow) {
|
||||
lowStock.push({
|
||||
name: row.name,
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
medsLeft: currentPills,
|
||||
daysLeft,
|
||||
depletionDate,
|
||||
@@ -322,7 +333,7 @@ async function getMedicationsNeedingPrescriptionReminder(userId: number): Promis
|
||||
(row.prescriptionRemainingRefills ?? 0) <= (row.prescriptionLowRefillThreshold ?? 1)
|
||||
)
|
||||
.map((row) => ({
|
||||
name: row.name,
|
||||
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
|
||||
remainingRefills: row.prescriptionRemainingRefills ?? 0,
|
||||
lowThreshold: row.prescriptionLowRefillThreshold ?? 1,
|
||||
expiryDate: row.prescriptionExpiryDate ?? null,
|
||||
@@ -534,7 +545,8 @@ async function checkAndSendReminderForUser(
|
||||
}
|
||||
|
||||
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 userStockNotifiedKey = `${userStateKey}_${today}_stock`;
|
||||
const userPrescriptionNotifiedKey = `${userStateKey}_${today}_prescription`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Language } from "../i18n/translations.js";
|
||||
|
||||
export type UserSettings = {
|
||||
userId: number;
|
||||
timezone?: string | null;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string | null;
|
||||
emailStockReminders: boolean;
|
||||
@@ -105,6 +106,7 @@ function envInt(key: string, defaultVal: number): number {
|
||||
|
||||
export function getDefaultSettings() {
|
||||
return {
|
||||
timezone: "",
|
||||
emailEnabled: envBool("DEFAULT_EMAIL_ENABLED", false),
|
||||
notificationEmail: process.env.DEFAULT_NOTIFICATION_EMAIL || null,
|
||||
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 {
|
||||
const hostname = hostnameRaw.toLowerCase();
|
||||
|
||||
@@ -245,6 +274,7 @@ export async function loadUserSettingsFromDb(userId: number): Promise<UserSettin
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
return {
|
||||
userId: settings.userId,
|
||||
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
@@ -288,6 +318,7 @@ export async function getAllUserSettingsFromDb(): Promise<UserSettings[]> {
|
||||
const allSettings = await db.select().from(userSettings);
|
||||
return allSettings.map((settings) => ({
|
||||
userId: settings.userId,
|
||||
timezone: settings.timezone?.trim() ? settings.timezone : null,
|
||||
emailEnabled: settings.emailEnabled,
|
||||
notificationEmail: settings.notificationEmail,
|
||||
emailStockReminders: settings.emailStockReminders,
|
||||
|
||||
@@ -123,6 +123,7 @@ async function createSchema(client: Client) {
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -117,6 +117,7 @@ async function createSchema(client: Client) {
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -134,6 +134,7 @@ async function createSchema(client: Client) {
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL UNIQUE,
|
||||
timezone text NOT NULL DEFAULT '',
|
||||
email_enabled integer NOT NULL DEFAULT 0,
|
||||
notification_email text,
|
||||
email_stock_reminders integer NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -68,6 +68,7 @@ async function setStockMode(mode: "automatic" | "manual") {
|
||||
|
||||
async function createMedication(options: {
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
pillsPerBlister?: number;
|
||||
@@ -80,6 +81,7 @@ async function createMedication(options: {
|
||||
}) {
|
||||
const {
|
||||
name,
|
||||
genericName = null,
|
||||
packCount = 1,
|
||||
blistersPerPack = 1,
|
||||
pillsPerBlister = 10,
|
||||
@@ -106,16 +108,17 @@ async function createMedication(options: {
|
||||
|
||||
const result = await testClient.execute({
|
||||
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,
|
||||
stock_adjustment, last_stock_correction_at,
|
||||
usage_json, every_json, start_json, intakes_json,
|
||||
is_obsolete, intake_reminders_enabled
|
||||
) VALUES (?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
) VALUES (?, ?, ?, ?, 'blister', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
RETURNING id`,
|
||||
args: [
|
||||
1,
|
||||
name,
|
||||
genericName,
|
||||
JSON.stringify(takenBy),
|
||||
packCount,
|
||||
blistersPerPack,
|
||||
@@ -348,6 +351,21 @@ describe("Stock semantics parity (planner usage vs scheduler)", () => {
|
||||
const lowStock = await getMedicationsNeedingReminderForTests(1, 7, 365, "en", "automatic");
|
||||
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", () => {
|
||||
|
||||
@@ -64,6 +64,16 @@ function toDateOnly(date: Date): Date {
|
||||
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 {
|
||||
return toDateOnly(date).getTime();
|
||||
}
|
||||
@@ -175,13 +185,23 @@ export function getNextScheduledOccurrenceTime(
|
||||
|
||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
const intervalDays = Math.max(1, schedule.every);
|
||||
if (startTime >= lowerBound) {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
||||
return startTime + intervals * period;
|
||||
const lowerBoundDate = new Date(lowerBound);
|
||||
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);
|
||||
@@ -224,17 +244,28 @@ export function forEachScheduledOccurrenceInRange(
|
||||
}
|
||||
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
let occurrenceMs = startTime;
|
||||
if (occurrenceMs < rangeStartMs) {
|
||||
const intervals = Math.ceil((rangeStartMs - occurrenceMs) / period);
|
||||
occurrenceMs += intervals * period;
|
||||
const intervalDays = Math.max(1, schedule.every);
|
||||
let occurrence = new Date(startDate);
|
||||
if (occurrence.getTime() < rangeStartMs) {
|
||||
const rangeStartDate = new Date(rangeStartMs);
|
||||
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) {
|
||||
callback(occurrenceMs);
|
||||
}
|
||||
|
||||
occurrence = addLocalCalendarDays(occurrence, intervalDays);
|
||||
occurrenceMs = occurrence.getTime();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -348,6 +379,23 @@ export function getTimezone(): string {
|
||||
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 */
|
||||
export function formatInTimezone(date: Date, tz?: string): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
|
||||
@@ -4,6 +4,14 @@ Purpose: persistent agent work memory to survive context loss.
|
||||
|
||||
## Entries
|
||||
|
||||
### 2026-04-10
|
||||
|
||||
- Task: Investigate and fix the production blank-homepage bug (user report: both containers running, blank page, many `400 - -` log lines in frontend container).
|
||||
- Root cause: `upgrade-insecure-requests` directive was present in the `Content-Security-Policy` header in `frontend/nginx.conf`. This directive instructs browsers to upgrade all same-host HTTP requests to HTTPS (preserving the port). When users access the app over plain HTTP (e.g., `http://host:4174/`), the browser receives this CSP and upgrades subsequent asset requests (`/assets/index-*.js`, `/assets/index-*.css`, favicons, API calls) to `https://host:4174/...`. The nginx container only speaks plain HTTP on port 4174, so it receives TLS Client Hello bytes which it cannot parse as an HTTP request. nginx returns `400 Bad Request` with no parseable method or URI — producing the `400 - -` log pattern. All JS/CSS bundles fail to load, React never mounts, and the page stays blank.
|
||||
- Fix: Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf` (line 20). No other changes needed.
|
||||
- Validation notes: The directive is safe to remove — `upgrade-insecure-requests` is designed for HTTPS-only sites and is harmful when the server runs on plain HTTP. Removing it does not weaken security for self-hosted HTTP deployments (mixed content is not a concern when the origin itself is HTTP). If a reverse proxy with TLS termination is added in front, the directive can be re-introduced at the proxy level.
|
||||
- Files touched: `frontend/nginx.conf`.
|
||||
|
||||
### 2026-03-25
|
||||
|
||||
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## Entries
|
||||
|
||||
### 2026-04-10
|
||||
- Scope: Investigate and fix the production blank-homepage bug.
|
||||
- Root cause: The `Content-Security-Policy` header in `frontend/nginx.conf` included the `upgrade-insecure-requests` directive. This directive instructs browsers to upgrade all HTTP resource requests to HTTPS (same port). In a plain HTTP deployment (the default Docker setup on port 4174), this causes the browser to attempt TLS connections to the nginx HTTP port. nginx cannot parse the TLS bytes as HTTP and returns `400 Bad Request` with no method/URI — the `400 - -` log pattern the user observed. All JS/CSS bundles fail to load; React never mounts; the page stays blank.
|
||||
- What changed:
|
||||
- Removed `; upgrade-insecure-requests` from the CSP string in `frontend/nginx.conf`.
|
||||
- Validation:
|
||||
- `upgrade-insecure-requests` is designed for HTTPS-only sites. Removing it from a plain HTTP server is correct and does not reduce security.
|
||||
- After this fix, browsers accessing the app over HTTP will load assets normally without being redirected to a non-existent HTTPS endpoint.
|
||||
- If TLS termination is added via a reverse proxy in future, the directive can be applied at the proxy layer.
|
||||
- Result: The blank-homepage bug is fixed. All asset and API requests now succeed over plain HTTP as expected.
|
||||
|
||||
### 2026-03-25
|
||||
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
|
||||
- What changed:
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'; upgrade-insecure-requests" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' https://api.github.com; frame-src 'self'; form-action 'self'" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||
|
||||
# Allow larger file uploads (for medication images and data import/export)
|
||||
|
||||
Generated
+75
-75
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.1",
|
||||
"dependencies": {
|
||||
"i18next": "^26.0.1",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
@@ -169,9 +169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
|
||||
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.10.tgz",
|
||||
"integrity": "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -185,20 +185,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.9",
|
||||
"@biomejs/cli-darwin-x64": "2.4.9",
|
||||
"@biomejs/cli-linux-arm64": "2.4.9",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.9",
|
||||
"@biomejs/cli-linux-x64": "2.4.9",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.9",
|
||||
"@biomejs/cli-win32-arm64": "2.4.9",
|
||||
"@biomejs/cli-win32-x64": "2.4.9"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.10",
|
||||
"@biomejs/cli-darwin-x64": "2.4.10",
|
||||
"@biomejs/cli-linux-arm64": "2.4.10",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.10",
|
||||
"@biomejs/cli-linux-x64": "2.4.10",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.10",
|
||||
"@biomejs/cli-win32-arm64": "2.4.10",
|
||||
"@biomejs/cli-win32-x64": "2.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -213,9 +213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -230,9 +230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -247,9 +247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
|
||||
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz",
|
||||
"integrity": "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -264,9 +264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -281,9 +281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
|
||||
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz",
|
||||
"integrity": "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -298,9 +298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -315,9 +315,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -597,13 +597,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1023,9 +1023,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"version": "25.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz",
|
||||
"integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1539,9 +1539,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.0.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.1.tgz",
|
||||
"integrity": "sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==",
|
||||
"version": "26.0.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz",
|
||||
"integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2100,13 +2100,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -2119,9 +2119,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2208,9 +2208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.1.tgz",
|
||||
"integrity": "sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==",
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz",
|
||||
"integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -2243,9 +2243,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -2265,12 +2265,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
||||
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
||||
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.2"
|
||||
"react-router": "7.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -2586,9 +2586,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
|
||||
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz",
|
||||
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2613,7 +2613,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.1",
|
||||
"version": "1.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,22 +27,22 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.0.1",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
export interface Settings {
|
||||
timezone: string;
|
||||
availableTimezones: string[];
|
||||
serverTimezone: string;
|
||||
emailEnabled: boolean;
|
||||
notificationEmail: string;
|
||||
reminderDaysBefore: number;
|
||||
@@ -58,6 +61,9 @@ export interface Settings {
|
||||
export type SettingsLoadError = "auth" | "forbidden" | "request" | null;
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
timezone: "",
|
||||
availableTimezones: [],
|
||||
serverTimezone: "UTC",
|
||||
emailEnabled: false,
|
||||
notificationEmail: "",
|
||||
reminderDaysBefore: 7,
|
||||
@@ -243,6 +249,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const repeatDailyReminders = hasAnyStockReminder ? settingsToSave.repeatDailyReminders : false;
|
||||
|
||||
return {
|
||||
timezone: settingsToSave.timezone,
|
||||
emailEnabled: effectiveEmailEnabled,
|
||||
notificationEmail: settingsToSave.notificationEmail,
|
||||
reminderDaysBefore: settingsToSave.reminderDaysBefore,
|
||||
|
||||
@@ -389,6 +389,14 @@
|
||||
"title": "Sprache",
|
||||
"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": {
|
||||
"title": "API-Zugriff",
|
||||
"generateTitle": "API-Key erzeugen",
|
||||
|
||||
@@ -389,6 +389,14 @@
|
||||
"title": "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": {
|
||||
"title": "API Access",
|
||||
"generateTitle": "Generate API key",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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 { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
@@ -13,8 +13,11 @@ export function SettingsPage() {
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
const {
|
||||
settings,
|
||||
savedSettings,
|
||||
setSettings,
|
||||
settingsLoading,
|
||||
settingsSaving,
|
||||
settingsSaved,
|
||||
settingsLoadError,
|
||||
// Email testing
|
||||
testEmail,
|
||||
@@ -39,6 +42,8 @@ export function SettingsPage() {
|
||||
setImportResult,
|
||||
meds,
|
||||
} = useAppContext();
|
||||
const [timezoneTouched, setTimezoneTouched] = useState(false);
|
||||
const [timezoneDraft, setTimezoneDraft] = useState("");
|
||||
|
||||
const hasExistingData = meds.length > 0;
|
||||
let emailUnavailableReason: string | null = null;
|
||||
@@ -117,6 +122,49 @@ export function SettingsPage() {
|
||||
const automaticStockCalculationId = "settings-stock-calculation-automatic";
|
||||
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 (
|
||||
<section className="grid">
|
||||
{settingsLoading ? (
|
||||
@@ -160,6 +208,53 @@ export function SettingsPage() {
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
</select>
|
||||
</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 className="card" data-testid="settings-notification-card">
|
||||
|
||||
Vendored
+1
-1
@@ -613,7 +613,7 @@ body.modal-open {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
margin-bottom: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
@@ -311,7 +311,7 @@
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 1100;
|
||||
z-index: 12000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
visibility 0.15s;
|
||||
z-index: 1101;
|
||||
z-index: 12001;
|
||||
}
|
||||
|
||||
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
|
||||
@@ -507,6 +507,20 @@
|
||||
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 */
|
||||
@media (max-width: 480px) {
|
||||
.notification-matrix {
|
||||
|
||||
Generated
+36
-36
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "medassist-ng",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^16.4.0"
|
||||
}
|
||||
@@ -76,9 +76,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
|
||||
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.10.tgz",
|
||||
"integrity": "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -92,20 +92,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.9",
|
||||
"@biomejs/cli-darwin-x64": "2.4.9",
|
||||
"@biomejs/cli-linux-arm64": "2.4.9",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.9",
|
||||
"@biomejs/cli-linux-x64": "2.4.9",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.9",
|
||||
"@biomejs/cli-win32-arm64": "2.4.9",
|
||||
"@biomejs/cli-win32-x64": "2.4.9"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.10",
|
||||
"@biomejs/cli-darwin-x64": "2.4.10",
|
||||
"@biomejs/cli-linux-arm64": "2.4.10",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.10",
|
||||
"@biomejs/cli-linux-x64": "2.4.10",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.10",
|
||||
"@biomejs/cli-win32-arm64": "2.4.10",
|
||||
"@biomejs/cli-win32-x64": "2.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -120,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -137,9 +137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -154,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
|
||||
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.10.tgz",
|
||||
"integrity": "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -171,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -188,9 +188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
|
||||
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.10.tgz",
|
||||
"integrity": "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -205,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
|
||||
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.10.tgz",
|
||||
"integrity": "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -222,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
|
||||
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
|
||||
"version": "2.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.10.tgz",
|
||||
"integrity": "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.9",
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^16.4.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user