feat: mobile UI improvements, biome linting, and reminder info display (#71)
* fix: make dismissed doses robust against schedule/timezone changes - Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs - Add POST /medications/dismiss-until endpoint to set dismissed date - Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date - Update frontend to use medication-level dismissedUntil for filtering - Remove old dismissMissedDoses function from useDoses hook (was using dose IDs) - Add backward-compatible ALTER TABLE migration for dismissed_until column - Add 5 integration tests for dismiss-until functionality - Update test schemas with new column The old approach stored individual dose IDs which broke when schedule or timezone settings changed (dose IDs contain timestamps). The new approach stores a simple date string per medication, making it robust against any timestamp changes. * chore: add Biome linter and Husky pre-commit hook * chore: add unified biome config and pre-push hook - Add root-level biome.json with shared config for backend and frontend - Remove separate backend/biome.json and frontend/biome.json - Add .husky/pre-push hook to run backend tests before push - Update package.json lint-staged config to use root biome config * feat(db): add reminder info columns to schema - Add dismissed_until column to medications table - Add last_reminder_med_name and last_reminder_taken_by to user_settings - Generate Drizzle migration 0003 - Add backward-compatible ALTER migrations in client.ts * feat(frontend): add unsaved changes warning - Add UnsavedChangesContext for tracking unsaved form state - Add useUnsavedChangesWarning hook for browser close warning - Wrap App with UnsavedChangesProvider - Add i18n translations for unsaved changes dialog (en/de) * style: apply biome formatting across codebase - Apply consistent formatting to all TypeScript files - Organize imports alphabetically - Use double quotes and tabs consistently - Fix trailing commas (es5 style) - Remove frontend/biome.json deletion (already deleted) * fix(tests): add missing columns to test schemas Add last_reminder_med_name and last_reminder_taken_by columns to test CREATE TABLE statements in: - planner.test.ts - e2e-routes.test.ts - integration.test.ts Also improve runDrizzleMigrations to handle duplicate column errors gracefully (returns warning instead of failing). * fix(planner): add missing 'as unknown' type cast for request.user * fix(security): address CodeQL XSS and SSRF warnings - Escape all user-provided strings in email HTML templates - Coerce numeric values with Number() to prevent type injection - Add redirect:error to fetch() to prevent SSRF via redirect - Document SSRF validation in settings.ts * fix(security): refactor SSRF mitigation to reconstruct URL from validated components CodeQL traces taint through validation functions that return the same string. Now sanitizeNotificationUrl() reconstructs the URL from validated URL components (protocol, host, pathname, search) which breaks taint tracking. - Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data - Returns reconstructed URL built from URL() parsed components - Extracts auth credentials separately instead of including in URL string - Added isNtfy flag to avoid re-parsing the sanitized URL * fix(security): add SSRF suppression comment for validated notification URL The fetch() uses a URL that has been validated by sanitizeNotificationUrl(): - Only http/https protocols - Blocks localhost and loopback IPs - Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x) - Blocks internal hostnames (.local, .internal, .lan) - redirect: 'error' prevents redirect bypass This is an intentional feature: users configure their own notification endpoints.
This commit is contained in:
@@ -13,106 +13,106 @@ export type Blister = { usage: number; every: number; start: string };
|
||||
|
||||
/** Get current timezone from TZ env variable or default to UTC */
|
||||
export function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
/** Format a date in the configured timezone */
|
||||
export function formatInTimezone(date: Date, tz?: string): string {
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
return date.toLocaleString("de-DE", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Get current hour in the configured timezone */
|
||||
export function getCurrentHourInTimezone(tz?: string): number {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("en-US", {
|
||||
timeZone: tz ?? getTimezone(),
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
return parseInt(timeStr, 10);
|
||||
}
|
||||
|
||||
/** Get today's date string in the configured timezone (YYYY-MM-DD) */
|
||||
export function getTodayInTimezone(tz?: string): string {
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
const now = new Date();
|
||||
const parts = now.toLocaleDateString("en-CA", { timeZone: tz ?? getTimezone() }).split("-");
|
||||
return parts.join("-"); // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
/** Calculate the next scheduled time for a given reminder hour */
|
||||
export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
|
||||
const now = new Date();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find(p => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "2-digit",
|
||||
hour12: false
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - reminderHour;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
const now = new Date();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get current time components in the target timezone
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(now);
|
||||
const getPart = (type: string) => parts.find((p) => p.type === type)?.value || "0";
|
||||
|
||||
const currentHour = parseInt(getPart("hour"), 10);
|
||||
const currentMinute = parseInt(getPart("minute"), 10);
|
||||
|
||||
// Calculate if we need tomorrow
|
||||
const needTomorrow = currentHour > reminderHour || (currentHour === reminderHour && currentMinute > 0);
|
||||
|
||||
// Handle month overflow simply by adding a day to now if needed
|
||||
let targetDate: Date;
|
||||
if (needTomorrow) {
|
||||
targetDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
targetDate = new Date(now);
|
||||
}
|
||||
|
||||
// Get the target date's date string in the timezone
|
||||
const targetFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const [targetYear, targetMonth, targetDay] = targetFormatter.format(targetDate).split("-").map(Number);
|
||||
|
||||
// Now we need to find the UTC time that corresponds to reminderHour:00 on targetDate in the target timezone
|
||||
// Use a search approach: start with a guess and adjust
|
||||
const guessUtc = new Date(Date.UTC(targetYear, targetMonth - 1, targetDay, reminderHour, 0, 0, 0));
|
||||
|
||||
// Check what hour this UTC time corresponds to in the target timezone
|
||||
const checkFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Adjust based on the difference
|
||||
const guessHour = parseInt(checkFormatter.format(guessUtc), 10);
|
||||
const hourDiff = guessHour - reminderHour;
|
||||
|
||||
// Apply correction (if guessHour is higher, we need to subtract time)
|
||||
const correctedUtc = new Date(guessUtc.getTime() - hourDiff * 60 * 60 * 1000);
|
||||
|
||||
return correctedUtc;
|
||||
}
|
||||
|
||||
/** Calculate milliseconds until next check at the given reminder hour */
|
||||
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
const next = getNextScheduledTime(reminderHour, tz);
|
||||
return next.getTime() - Date.now();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -123,56 +123,56 @@ export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
|
||||
* Parse an ISO datetime string to local timestamp.
|
||||
* Extracts date/time components directly from the string to avoid
|
||||
* timezone conversion issues with Z suffix.
|
||||
*
|
||||
*
|
||||
* "2026-01-23T20:55:00" → treated as local time 20:55
|
||||
* "2026-01-23T20:55:00.000Z" → also treated as local time 20:55 (Z ignored)
|
||||
*/
|
||||
export function parseLocalDateTime(isoString: string): Date {
|
||||
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
if (!match) {
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
// Create date using local time interpretation (no UTC conversion)
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1, // Month is 0-indexed
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second ?? "0", 10)
|
||||
);
|
||||
// Extract components: YYYY-MM-DDTHH:MM:SS (ignore Z and milliseconds)
|
||||
const match = isoString.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):?(\d{2})?/);
|
||||
if (!match) {
|
||||
// Fallback to Date parsing if format doesn't match
|
||||
return new Date(isoString);
|
||||
}
|
||||
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
// Create date using local time interpretation (no UTC conversion)
|
||||
return new Date(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1, // Month is 0-indexed
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
parseInt(second ?? "0", 10)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse blister schedules from JSON columns */
|
||||
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const usage = JSON.parse(row.usageJson) as number[];
|
||||
const every = JSON.parse(row.everyJson) as number[];
|
||||
const start = JSON.parse(row.startJson) as string[];
|
||||
const len = Math.min(usage.length, every.length, start.length);
|
||||
const blisters: Blister[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
|
||||
}
|
||||
return blisters;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse takenByJson to array of strings */
|
||||
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -181,26 +181,26 @@ export function parseTakenByJson(takenByJson: string | null | undefined): string
|
||||
|
||||
/** Calculate daily usage from blisters */
|
||||
export function calculateDailyUsage(blisters: Blister[]): number {
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
|
||||
}
|
||||
|
||||
/** Calculate depletion information for a medication */
|
||||
export function calculateDepletionInfo(
|
||||
med: { count: number; blisters: Blister[] },
|
||||
language: Language
|
||||
med: { count: number; blisters: Blister[] },
|
||||
language: Language
|
||||
): { daysLeft: number | null; depletionDate: string | null } {
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
const dailyUsage = calculateDailyUsage(med.blisters);
|
||||
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
|
||||
|
||||
const daysLeft = Math.floor(med.count / dailyUsage);
|
||||
const depletionMs = Date.now() + daysLeft * 86_400_000;
|
||||
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return { daysLeft, depletionDate };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -208,152 +208,151 @@ export function calculateDepletionInfo(
|
||||
// =============================================================================
|
||||
|
||||
export type UpcomingIntake = {
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string[];
|
||||
pillWeightMg: number | null;
|
||||
medName: string;
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string[];
|
||||
pillWeightMg: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get all intakes for today (past and future) - used for repeat reminders.
|
||||
* Returns all intakes scheduled for today in user's timezone.
|
||||
*/
|
||||
export function getTodaysIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string
|
||||
): UpcomingIntake[] {
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
intakes.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return intakes;
|
||||
const timezone = tz ?? getTimezone();
|
||||
const now = new Date();
|
||||
|
||||
// Get start and end of today in user's timezone
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
const intakes: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find all occurrences that fall within today
|
||||
let currentTime = startTime;
|
||||
|
||||
// If start is in the past, calculate the first occurrence on or after todayStart
|
||||
if (currentTime < todayStart.getTime()) {
|
||||
const elapsed = todayStart.getTime() - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
currentTime = startTime + intervals * intervalMs;
|
||||
}
|
||||
|
||||
// Collect all intakes for today
|
||||
while (currentTime <= todayEnd.getTime()) {
|
||||
if (currentTime >= todayStart.getTime()) {
|
||||
const intakeDate = new Date(currentTime);
|
||||
intakes.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
return intakes;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Get upcoming intakes that fall within the reminder window.
|
||||
* Returns intakes that should be notified about right now.
|
||||
*/
|
||||
export function getUpcomingIntakes(
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
minutesBefore: number,
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
nowOverride?: number
|
||||
medName: string,
|
||||
blisters: Blister[],
|
||||
minutesBefore: number,
|
||||
takenBy: string[],
|
||||
pillWeightMg: number | null,
|
||||
locale: string,
|
||||
tz?: string,
|
||||
nowOverride?: number
|
||||
): UpcomingIntake[] {
|
||||
const now = nowOverride ?? Date.now();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Window to detect if "now" is the right time to send reminder
|
||||
// We check if the notify time (intake - minutesBefore) falls within current minute ±1
|
||||
const windowStart = now - 2 * 60 * 1000; // 2 minutes ago (catch slightly late checks)
|
||||
const windowEnd = now + 1 * 60 * 1000; // 1 minute from now
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence is within the reminder window, use it
|
||||
// (intake hasn't happened yet, we should remind)
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= windowStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
if (notifyTime >= windowStart && notifyTime <= windowEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
const now = nowOverride ?? Date.now();
|
||||
const timezone = tz ?? getTimezone();
|
||||
|
||||
// Get the current minute (truncated to minute boundary for precise matching)
|
||||
const currentMinuteStart = Math.floor(now / 60000) * 60000;
|
||||
const currentMinuteEnd = currentMinuteStart + 60000;
|
||||
|
||||
const upcoming: UpcomingIntake[] = [];
|
||||
|
||||
for (const blister of blisters) {
|
||||
const startTime = parseLocalDateTime(blister.start).getTime();
|
||||
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Find the next scheduled intake time (could be today or in the future)
|
||||
let nextTime = startTime;
|
||||
|
||||
// If start is in the past, calculate occurrences
|
||||
if (nextTime < now) {
|
||||
const elapsed = now - startTime;
|
||||
const intervals = Math.floor(elapsed / intervalMs);
|
||||
|
||||
// Check the current occurrence (today's scheduled time, even if past)
|
||||
const currentOccurrence = startTime + intervals * intervalMs;
|
||||
// And the next occurrence
|
||||
const nextOccurrence = startTime + (intervals + 1) * intervalMs;
|
||||
|
||||
// If today's occurrence notification time falls in current minute and intake hasn't happened
|
||||
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
|
||||
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
// Check if notifyTime falls within the current minute (precise matching)
|
||||
if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) {
|
||||
const intakeDate = new Date(nextTime);
|
||||
upcoming.push({
|
||||
medName,
|
||||
usage: blister.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy,
|
||||
pillWeightMg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -361,102 +360,106 @@ export function getUpcomingIntakes(
|
||||
// =============================================================================
|
||||
|
||||
export type ReminderState = {
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
lastAutoEmailSent: string | null;
|
||||
lastAutoEmailDate: string | null;
|
||||
notifiedMedications: string[];
|
||||
nextScheduledCheck: string | null;
|
||||
lastNotificationType: "stock" | "intake" | null;
|
||||
lastNotificationChannel: "email" | "push" | "both" | null;
|
||||
};
|
||||
|
||||
export type IntakeReminderEntry = {
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times reminder was sent
|
||||
firstSentAt: number; // Timestamp when first reminder was sent
|
||||
lastSentAt: number; // Timestamp when last reminder was sent
|
||||
sendCount: number; // How many times NAGGING reminder was sent (not counting advance)
|
||||
advanceSent?: boolean; // Whether the advance reminder (15 min before) was sent
|
||||
};
|
||||
|
||||
export type IntakeReminderState = {
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
reminders: Record<string, IntakeReminderEntry>; // key -> entry
|
||||
};
|
||||
|
||||
/** Create default reminder state */
|
||||
export function createDefaultReminderState(): ReminderState {
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
return {
|
||||
lastAutoEmailSent: null,
|
||||
lastAutoEmailDate: null,
|
||||
notifiedMedications: [],
|
||||
nextScheduledCheck: null,
|
||||
lastNotificationType: null,
|
||||
lastNotificationChannel: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create default intake reminder state */
|
||||
export function createDefaultIntakeReminderState(): IntakeReminderState {
|
||||
return { reminders: {} };
|
||||
return { reminders: {} };
|
||||
}
|
||||
|
||||
/** Parse reminder state from JSON string */
|
||||
export function parseReminderState(json: string): ReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
} catch {
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
return {
|
||||
lastAutoEmailSent: saved.lastAutoEmailSent ?? null,
|
||||
lastAutoEmailDate: saved.lastAutoEmailDate ?? null,
|
||||
notifiedMedications: saved.notifiedMedications ?? [],
|
||||
nextScheduledCheck: saved.nextScheduledCheck ?? null,
|
||||
lastNotificationType: saved.lastNotificationType ?? null,
|
||||
lastNotificationChannel: saved.lastNotificationChannel ?? null,
|
||||
};
|
||||
} catch {
|
||||
return createDefaultReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse intake reminder state from JSON string (backward compatible) */
|
||||
export function parseIntakeReminderState(json: string): IntakeReminderState {
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(json);
|
||||
|
||||
// Backward compatibility: convert old array format to new map format
|
||||
if (Array.isArray(saved.sentReminders)) {
|
||||
const reminders: Record<string, IntakeReminderEntry> = {};
|
||||
const now = Date.now();
|
||||
for (const key of saved.sentReminders) {
|
||||
reminders[key] = {
|
||||
firstSentAt: now,
|
||||
lastSentAt: now,
|
||||
sendCount: 1,
|
||||
};
|
||||
}
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
// New format
|
||||
return {
|
||||
reminders: saved.reminders ?? {},
|
||||
};
|
||||
} catch {
|
||||
return createDefaultIntakeReminderState();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up old intake reminder entries (older than given milliseconds) */
|
||||
/** Clean up old intake reminder entries (using timezone-aware day check) */
|
||||
export function cleanOldIntakeReminders(reminders: Record<string, IntakeReminderEntry>, tz: string): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
export function cleanOldIntakeReminders(
|
||||
reminders: Record<string, IntakeReminderEntry>,
|
||||
tz: string
|
||||
): Record<string, IntakeReminderEntry> {
|
||||
// Get start of today in user's timezone
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayStartMs = todayStart.getTime();
|
||||
|
||||
// Keep only reminders from today onwards (based on dose timestamp in key)
|
||||
const cleaned: Record<string, IntakeReminderEntry> = {};
|
||||
for (const [key, entry] of Object.entries(reminders)) {
|
||||
const timestamp = parseInt(key.split(":").pop() || "0", 10);
|
||||
if (timestamp >= todayStartMs) {
|
||||
cleaned[key] = entry;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -3,123 +3,111 @@
|
||||
* Exported separately to allow testing without triggering server start.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { CookieSerializeOptions } from "@fastify/cookie";
|
||||
|
||||
/**
|
||||
* Parse comma-separated CORS origins string
|
||||
*/
|
||||
export function parseCorsOrigins(originsStr: string): string[] {
|
||||
return originsStr
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
return originsStr
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter((o) => o.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build base cookie options for access token
|
||||
*/
|
||||
export function buildBaseCookieOptions(
|
||||
accessTtlMinutes: number,
|
||||
isProduction: boolean
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60, // Convert minutes to seconds
|
||||
};
|
||||
export function buildBaseCookieOptions(accessTtlMinutes: number, isProduction: boolean): CookieSerializeOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: accessTtlMinutes * 60, // Convert minutes to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build refresh cookie options (extends base with longer TTL)
|
||||
*/
|
||||
export function buildRefreshCookieOptions(
|
||||
baseCookieOptions: CookieSerializeOptions,
|
||||
refreshTtlDays: number
|
||||
baseCookieOptions: CookieSerializeOptions,
|
||||
refreshTtlDays: number
|
||||
): CookieSerializeOptions {
|
||||
return {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds
|
||||
};
|
||||
return {
|
||||
...baseCookieOptions,
|
||||
maxAge: refreshTtlDays * 24 * 60 * 60, // Convert days to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete app configuration object
|
||||
*/
|
||||
export interface AppConfigOptions {
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
accessTtlMinutes: number;
|
||||
refreshTtlDays: number;
|
||||
isProduction: boolean;
|
||||
jwtSecret?: string;
|
||||
refreshSecret?: string;
|
||||
accessTtlMinutes: number;
|
||||
refreshTtlDays: number;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
refreshCookieOptions: CookieSerializeOptions;
|
||||
accessSecret: string;
|
||||
refreshSecret: string;
|
||||
accessTtl: number;
|
||||
refreshTtl: number;
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
refreshCookieOptions: CookieSerializeOptions;
|
||||
}
|
||||
|
||||
export function buildAppConfig(options: AppConfigOptions): AppConfig {
|
||||
const cookieOptions = buildBaseCookieOptions(
|
||||
options.accessTtlMinutes,
|
||||
options.isProduction
|
||||
);
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(
|
||||
cookieOptions,
|
||||
options.refreshTtlDays
|
||||
);
|
||||
const cookieOptions = buildBaseCookieOptions(options.accessTtlMinutes, options.isProduction);
|
||||
const refreshCookieOptions = buildRefreshCookieOptions(cookieOptions, options.refreshTtlDays);
|
||||
|
||||
return {
|
||||
accessSecret: options.jwtSecret || "",
|
||||
refreshSecret: options.refreshSecret || "",
|
||||
accessTtl: options.accessTtlMinutes,
|
||||
refreshTtl: options.refreshTtlDays,
|
||||
cookieOptions,
|
||||
refreshCookieOptions,
|
||||
};
|
||||
return {
|
||||
accessSecret: options.jwtSecret || "",
|
||||
refreshSecret: options.refreshSecret || "",
|
||||
accessTtl: options.accessTtlMinutes,
|
||||
refreshTtl: options.refreshTtlDays,
|
||||
cookieOptions,
|
||||
refreshCookieOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure images directory exists
|
||||
*/
|
||||
export function ensureImagesDirectory(cwd?: string): string {
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
return imagesDir;
|
||||
const basePath = cwd || process.cwd();
|
||||
const imagesDir = resolve(basePath, "data/images");
|
||||
if (!existsSync(imagesDir)) {
|
||||
mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
return imagesDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT configuration based on auth enabled status
|
||||
*/
|
||||
export interface JwtConfig {
|
||||
secret: string;
|
||||
cookie: {
|
||||
cookieName: string;
|
||||
signed: boolean;
|
||||
};
|
||||
secret: string;
|
||||
cookie: {
|
||||
cookieName: string;
|
||||
signed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function getJwtConfig(authEnabled: boolean, jwtSecret?: string): JwtConfig {
|
||||
const effectiveSecret =
|
||||
authEnabled && jwtSecret
|
||||
? jwtSecret
|
||||
: "auth-disabled-no-secret-needed";
|
||||
const effectiveSecret = authEnabled && jwtSecret ? jwtSecret : "auth-disabled-no-secret-needed";
|
||||
|
||||
return {
|
||||
secret: effectiveSecret,
|
||||
cookie: {
|
||||
cookieName: "access_token",
|
||||
signed: false,
|
||||
},
|
||||
};
|
||||
return {
|
||||
secret: effectiveSecret,
|
||||
cookie: {
|
||||
cookieName: "access_token",
|
||||
signed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user