@@ -10,7 +10,13 @@ import { fileURLToPath } from "node:url";
|
||||
import type { Client } from "@libsql/client";
|
||||
import type { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import {
|
||||
forEachScheduledOccurrenceInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getScheduleMatchWindowMs,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Get migrations folder path (relative to this file's location)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -363,9 +369,9 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
if (every <= 0 || Number.isNaN(start.getTime())) continue;
|
||||
|
||||
const validDates = new Set<number>();
|
||||
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
|
||||
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
|
||||
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||
});
|
||||
validDatesByIntake.set(idx, validDates);
|
||||
}
|
||||
|
||||
@@ -388,7 +394,7 @@ export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired:
|
||||
const intake = intakes[intakeIdx];
|
||||
if (!intake) continue;
|
||||
|
||||
const halfInterval = (intake.every * MS_PER_DAY) / 2;
|
||||
const halfInterval = getScheduleMatchWindowMs(intake);
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
|
||||
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
|
||||
|
||||
const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.3";
|
||||
const EXPORT_VERSION = "1.4";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -33,6 +33,8 @@ const scheduleSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string(), // ISO datetime string
|
||||
scheduleMode: z.unknown().optional(),
|
||||
weekdays: z.unknown().optional(),
|
||||
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||
remind: z.boolean().optional().default(false),
|
||||
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
|
||||
@@ -237,6 +239,8 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode: "interval" | "weekdays";
|
||||
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
intakeUnit: "ml" | "tsp" | "tbsp" | null;
|
||||
remind: boolean;
|
||||
takenBy: string | null;
|
||||
@@ -252,7 +256,9 @@ function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
|
||||
usage: intake.usage,
|
||||
every: intake.every,
|
||||
start: intake.start,
|
||||
intakeUnit: null,
|
||||
scheduleMode: intake.scheduleMode ?? "interval",
|
||||
weekdays: intake.weekdays ?? [],
|
||||
intakeUnit: intake.intakeUnit ?? null,
|
||||
remind: intake.intakeRemindersEnabled,
|
||||
takenBy: intake.takenBy, // Per-intake takenBy
|
||||
}));
|
||||
@@ -671,26 +677,28 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
// Convert schedules to both legacy and new formats
|
||||
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
|
||||
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
|
||||
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
|
||||
// Build intakesJson array (new unified format with per-intake takenBy)
|
||||
const intakesJson = JSON.stringify(
|
||||
med.schedules.map((s) => ({
|
||||
usage: s.usage,
|
||||
every: s.every,
|
||||
start: s.start,
|
||||
intakeUnit: s.intakeUnit ?? null,
|
||||
takenBy: s.takenBy || null,
|
||||
intakeRemindersEnabled: s.remind ?? false,
|
||||
}))
|
||||
);
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(medications)
|
||||
|
||||
@@ -29,7 +29,13 @@ import {
|
||||
PACKAGE_TYPES,
|
||||
} from "../utils/package-profiles.js";
|
||||
import {
|
||||
countScheduledOccurrencesInRange,
|
||||
forEachScheduledOccurrenceInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getScheduleMatchWindowMs,
|
||||
type Intake,
|
||||
normalizeIntake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -100,6 +106,8 @@ const intakeSchema = z.object({
|
||||
usage: z.number().nonnegative(),
|
||||
every: z.number().int().min(1),
|
||||
start: z.string().datetime({ local: true }),
|
||||
scheduleMode: z.unknown().optional(),
|
||||
weekdays: z.unknown().optional(),
|
||||
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
|
||||
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
|
||||
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
|
||||
@@ -274,6 +282,11 @@ const intakeOpenApiSchema = {
|
||||
usage: { type: "number", minimum: 0 },
|
||||
every: { type: "integer", minimum: 1 },
|
||||
start: { type: "string", description: "ISO datetime string; timezone suffix optional." },
|
||||
scheduleMode: { type: "string", enum: ["interval", "weekdays"] },
|
||||
weekdays: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] },
|
||||
},
|
||||
intakeUnit: { type: ["string", "null"], enum: ["ml", "tsp", "tbsp", null] },
|
||||
takenBy: { type: ["string", "null"], maxLength: 100 },
|
||||
intakeRemindersEnabled: { type: "boolean" },
|
||||
@@ -359,6 +372,8 @@ const medicationBodyOpenApiSchema = {
|
||||
usage: 1,
|
||||
every: 8,
|
||||
start: "2026-03-11T08:00:00.000Z",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
takenBy: "Daniel",
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
@@ -664,25 +679,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
// Convert to unified intakes format
|
||||
let intakes: Intake[];
|
||||
if (inputIntakes) {
|
||||
// New format with per-intake takenBy
|
||||
intakes = inputIntakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
intakeUnit: i.intakeUnit ?? null,
|
||||
takenBy: i.takenBy || null,
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
|
||||
} else if (inputBlisters) {
|
||||
// Legacy format - convert to new format
|
||||
intakes = inputBlisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // No per-intake takenBy from legacy
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputBlisters.map((blister) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
intakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
|
||||
}
|
||||
@@ -840,25 +850,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
// Convert to unified intakes format
|
||||
let intakes: Intake[];
|
||||
if (inputIntakes) {
|
||||
// New format with per-intake takenBy
|
||||
intakes = inputIntakes.map((i) => ({
|
||||
usage: i.usage,
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
intakeUnit: i.intakeUnit ?? null,
|
||||
takenBy: i.takenBy || null,
|
||||
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputIntakes.map((intake) => normalizeIntake(intake));
|
||||
} else if (inputBlisters) {
|
||||
// Legacy format - convert to new format
|
||||
intakes = inputBlisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // No per-intake takenBy from legacy
|
||||
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
|
||||
}));
|
||||
intakes = inputBlisters.map((blister) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: blister.usage,
|
||||
every: blister.every,
|
||||
start: blister.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
intakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
|
||||
}
|
||||
@@ -942,8 +947,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
if (allDoses.length > 0) {
|
||||
// Build migration map: for each intake index, map old dateOnlyMs → new dateOnlyMs
|
||||
const now = new Date();
|
||||
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const migrationEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
|
||||
for (let idx = 0; idx < Math.max(oldIntakes.length, intakes.length); idx++) {
|
||||
const oldIntake = oldIntakes[idx];
|
||||
@@ -954,44 +958,45 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
const oldStart = parseLocalDateTime(oldIntake.start);
|
||||
const newStart = parseLocalDateTime(newIntake.start);
|
||||
const oldEvery = oldIntake.every;
|
||||
const newEvery = newIntake.every;
|
||||
|
||||
// Check if start date or interval changed (time-of-day changes don't matter for dateOnlyMs)
|
||||
// Check if start date or schedule changed (time-of-day changes don't matter for dateOnlyMs)
|
||||
const oldStartDateOnly = new Date(oldStart.getFullYear(), oldStart.getMonth(), oldStart.getDate()).getTime();
|
||||
const newStartDateOnly = new Date(newStart.getFullYear(), newStart.getMonth(), newStart.getDate()).getTime();
|
||||
|
||||
if (oldStartDateOnly === newStartDateOnly && oldEvery === newEvery) {
|
||||
const scheduleUnchanged =
|
||||
oldStartDateOnly === newStartDateOnly &&
|
||||
oldIntake.every === newIntake.every &&
|
||||
oldIntake.scheduleMode === newIntake.scheduleMode &&
|
||||
(oldIntake.weekdays ?? []).join(",") === (newIntake.weekdays ?? []).join(",");
|
||||
|
||||
if (scheduleUnchanged) {
|
||||
continue; // No schedule change that affects dose IDs
|
||||
}
|
||||
|
||||
// Build set of new valid dateOnlyMs values for this intake
|
||||
const newDates = new Set<number>();
|
||||
for (let d = new Date(newStart); d <= migrationEnd; d.setDate(d.getDate() + newEvery)) {
|
||||
newDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(newIntake, newStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
|
||||
newDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
|
||||
});
|
||||
|
||||
// Build set of old dateOnlyMs values with mapping to nearest new date
|
||||
const oldToNewMap = new Map<number, number>();
|
||||
for (let d = new Date(oldStart); d <= migrationEnd; d.setDate(d.getDate() + oldEvery)) {
|
||||
const oldDateMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
// Find the closest new date within ±(newEvery/2) days
|
||||
const halfInterval = (newEvery * MS_PER_DAY) / 2;
|
||||
const scheduleMatchWindowMs = getScheduleMatchWindowMs(newIntake);
|
||||
forEachScheduledOccurrenceInRange(oldIntake, oldStart.getTime(), migrationEnd.getTime(), (occurrenceMs) => {
|
||||
const oldDateMs = getDateOnlyTimestamp(new Date(occurrenceMs));
|
||||
let bestMatch: number | null = null;
|
||||
let bestDist = Infinity;
|
||||
let bestDistance = Infinity;
|
||||
for (const newDateMs of newDates) {
|
||||
const dist = Math.abs(newDateMs - oldDateMs);
|
||||
if (dist < bestDist && dist <= halfInterval) {
|
||||
bestDist = dist;
|
||||
const distance = Math.abs(newDateMs - oldDateMs);
|
||||
if (distance < bestDistance && distance <= scheduleMatchWindowMs) {
|
||||
bestDistance = distance;
|
||||
bestMatch = newDateMs;
|
||||
}
|
||||
}
|
||||
if (bestMatch !== null && bestMatch !== oldDateMs) {
|
||||
oldToNewMap.set(oldDateMs, bestMatch);
|
||||
// Remove matched new date to prevent double-mapping
|
||||
newDates.delete(bestMatch);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply migrations to dose tracking entries
|
||||
if (oldToNewMap.size > 0) {
|
||||
@@ -1503,6 +1508,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
usage: normalizeIntakeUsageForStock(i, medForm, row.packageType),
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
scheduleMode: i.scheduleMode,
|
||||
weekdays: i.weekdays,
|
||||
}));
|
||||
const pillsPerBlister = row.pillsPerBlister ?? 1;
|
||||
const packCount = row.packCount ?? 1;
|
||||
@@ -1523,8 +1530,6 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
|
||||
// Count consumed pills by generating expected doses and checking if they're taken
|
||||
let consumedUntilNow = 0;
|
||||
const msPerDay = 86400000;
|
||||
|
||||
if (isTopical) {
|
||||
consumedUntilNow = 0;
|
||||
} else if (stockCalculationMode === "automatic") {
|
||||
@@ -1532,16 +1537,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
|
||||
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
|
||||
: blisterStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
@@ -1559,25 +1559,20 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now.getTime()) {
|
||||
const occurrences = Math.floor((now.getTime() - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
blister,
|
||||
effectiveStart,
|
||||
now.getTime()
|
||||
);
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
@@ -1768,34 +1763,19 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
function calculateUsageInRange(
|
||||
blisters: Array<{ usage: number; every: number; start: string }>,
|
||||
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
|
||||
start: Date,
|
||||
end: Date
|
||||
) {
|
||||
if (end.getTime() <= start.getTime()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const msPerDay = 86400000;
|
||||
blisters.forEach((blister) => {
|
||||
const blisterStart = parseLocalDateTime(blister.start);
|
||||
if (Number.isNaN(blisterStart.getTime())) return;
|
||||
|
||||
const every = Math.max(1, blister.every);
|
||||
|
||||
// Skip ahead to the first occurrence at or after start to avoid
|
||||
// iterating through months/years of past doses
|
||||
const dt = new Date(blisterStart);
|
||||
if (dt < start) {
|
||||
const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay));
|
||||
dt.setDate(dt.getDate() + daysToSkip * every);
|
||||
// Fine-tune: advance until we reach or pass start
|
||||
while (dt < start) {
|
||||
dt.setDate(dt.getDate() + every);
|
||||
}
|
||||
}
|
||||
|
||||
// Count occurrences in [start, end)
|
||||
for (; dt < end; dt.setDate(dt.getDate() + every)) {
|
||||
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
|
||||
total += blister.usage;
|
||||
}
|
||||
});
|
||||
});
|
||||
return Number(total.toFixed(2));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
getAverageOccurrencesPerDay,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getTodayInTimezone,
|
||||
type Intake,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
@@ -60,35 +60,27 @@ function computeCapacity(medication: MedicationRow): number {
|
||||
|
||||
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
|
||||
return intakes.reduce((sum, intake) => {
|
||||
if (intake.every <= 0) return sum;
|
||||
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
|
||||
return sum + normalizedUsage / intake.every;
|
||||
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
|
||||
const today = parseDateOnly(todayDateOnly);
|
||||
let nextDate: Date | null = null;
|
||||
let nextOccurrenceMs: number | null = null;
|
||||
|
||||
for (const intake of intakes) {
|
||||
if (intake.every <= 0) continue;
|
||||
|
||||
const startDate = parseLocalDateTime(intake.start);
|
||||
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate(), 0, 0, 0, 0);
|
||||
|
||||
let candidate = startDateOnly;
|
||||
if (candidate.getTime() < today.getTime()) {
|
||||
const elapsedDays = Math.floor((today.getTime() - candidate.getTime()) / MS_PER_DAY);
|
||||
const intervals = Math.ceil(elapsedDays / intake.every);
|
||||
candidate = new Date(candidate.getTime() + intervals * intake.every * MS_PER_DAY);
|
||||
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
|
||||
if (occurrenceMs === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextDate || candidate.getTime() < nextDate.getTime()) {
|
||||
nextDate = candidate;
|
||||
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
|
||||
nextOccurrenceMs = occurrenceMs;
|
||||
}
|
||||
}
|
||||
|
||||
return nextDate ? toDateOnlyString(nextDate) : null;
|
||||
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
|
||||
}
|
||||
|
||||
function computeTakenAmount(
|
||||
@@ -188,7 +180,7 @@ export function buildSharedMedicationOverview(options: {
|
||||
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
|
||||
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
|
||||
const depletionDate =
|
||||
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * MS_PER_DAY));
|
||||
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
|
||||
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
|
||||
return {
|
||||
name: medication.name,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { doseTracking, medications } from "../db/schema.js";
|
||||
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
|
||||
import {
|
||||
countScheduledOccurrencesInRange,
|
||||
getDateOnlyTimestamp,
|
||||
getNextScheduledOccurrenceTime,
|
||||
normalizeIntakeUsageForStock,
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -10,7 +13,6 @@ import {
|
||||
type MedicationRow = typeof medications.$inferSelect;
|
||||
type DoseRow = typeof doseTracking.$inferSelect;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getDoseTakenAtMs(dose: DoseRow): number {
|
||||
@@ -60,15 +62,11 @@ export function computeMedicationCurrentStock(options: {
|
||||
const intakeStart = parseLocalDateTime(intake.start).getTime();
|
||||
if (Number.isNaN(intakeStart)) return;
|
||||
|
||||
const period = Math.max(1, intake.every) * MS_PER_DAY;
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - intakeStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = intakeStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = intakeStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
|
||||
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
|
||||
: intakeStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
let peopleForThisIntake: Array<string | null>;
|
||||
if (intake.takenBy) {
|
||||
@@ -81,25 +79,20 @@ export function computeMedicationCurrentStock(options: {
|
||||
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
if (effectiveStart <= nowMs) {
|
||||
const occurrences = Math.floor((nowMs - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
intake,
|
||||
effectiveStart,
|
||||
nowMs
|
||||
);
|
||||
consumed += occurrences * usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
for (const dose of relevantDoses) {
|
||||
|
||||
@@ -18,10 +18,13 @@ import {
|
||||
import {
|
||||
type Blister,
|
||||
calculateDepletionInfo,
|
||||
countScheduledOccurrencesInRange,
|
||||
createDefaultReminderState,
|
||||
formatInTimezone,
|
||||
getCurrentHourInTimezone,
|
||||
getDateOnlyTimestamp,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
@@ -271,7 +274,6 @@ async function getMedicationsNeedingReminder(
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
const now = Date.now();
|
||||
const msPerDay = 86_400_000;
|
||||
|
||||
for (const row of rows) {
|
||||
const packageType = normalizePackageType(row.packageType);
|
||||
@@ -288,6 +290,8 @@ async function getMedicationsNeedingReminder(
|
||||
usage: normalizeIntakeUsageForStock(i, row.medicationForm, row.packageType),
|
||||
every: i.every,
|
||||
start: i.start,
|
||||
scheduleMode: i.scheduleMode,
|
||||
weekdays: i.weekdays,
|
||||
}));
|
||||
|
||||
const originalTotalPills = isAmountBasedPackageType(packageType)
|
||||
@@ -304,16 +308,11 @@ async function getMedicationsNeedingReminder(
|
||||
const blisterStart = parseLocalDateTime(blister.start).getTime();
|
||||
if (Number.isNaN(blisterStart)) return;
|
||||
|
||||
const period = Math.max(1, blister.every) * msPerDay;
|
||||
|
||||
let effectiveStart: number;
|
||||
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
|
||||
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
|
||||
const periodsElapsed = Math.floor(elapsedSinceStart / period);
|
||||
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
|
||||
} else {
|
||||
effectiveStart = blisterStart;
|
||||
}
|
||||
const effectiveStart =
|
||||
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart
|
||||
? getNextScheduledOccurrenceTime(blister, stockCorrectionCutoff, false)
|
||||
: blisterStart;
|
||||
if (effectiveStart === null) return;
|
||||
|
||||
const intake = intakes[blisterIdx];
|
||||
const intakePerson = intake?.takenBy;
|
||||
@@ -331,25 +330,20 @@ async function getMedicationsNeedingReminder(
|
||||
let lastAutoConsumedDateMs = 0;
|
||||
|
||||
if (effectiveStart <= now) {
|
||||
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
|
||||
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
|
||||
blister,
|
||||
effectiveStart,
|
||||
now
|
||||
);
|
||||
timeBasedConsumed = occurrences * blister.usage * peopleForThisIntake.length;
|
||||
|
||||
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
|
||||
lastAutoConsumedDateMs = new Date(
|
||||
lastDoseTime.getFullYear(),
|
||||
lastDoseTime.getMonth(),
|
||||
lastDoseTime.getDate()
|
||||
).getTime();
|
||||
if (lastOccurrenceMs !== null) {
|
||||
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
|
||||
}
|
||||
}
|
||||
|
||||
const stockCorrectionDateOnly =
|
||||
stockCorrectionCutoff > 0
|
||||
? new Date(
|
||||
new Date(stockCorrectionCutoff).getFullYear(),
|
||||
new Date(stockCorrectionCutoff).getMonth(),
|
||||
new Date(stockCorrectionCutoff).getDate()
|
||||
).getTime()
|
||||
: 0;
|
||||
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
|
||||
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
|
||||
|
||||
let earlyTakenConsumed = 0;
|
||||
|
||||
@@ -942,17 +942,17 @@ describe("Integration Tests", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Planner usage calculation", () => {
|
||||
const plannerWindowStart = "2030-01-15T00:00:00.000Z";
|
||||
const futureDailyStart = "2030-01-15T08:00:00.000Z";
|
||||
const futureEveningStart = "2030-01-15T20:00:00.000Z";
|
||||
const tenDayPlanEnd = "2030-01-24T23:59:59.999Z";
|
||||
const thirtyFiveDayPlanEnd = "2030-02-18T23:59:59.999Z";
|
||||
|
||||
it("should calculate correct usage for daily medication", async () => {
|
||||
// Create medication: 2 packs × 3 blisters × 10 pills = 60 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow (future date)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
// This avoids daylight-saving-time edge cases in local test environments.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -972,8 +972,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr, // 10 days
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -988,15 +988,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should detect insufficient stock", async () => {
|
||||
// Create medication: 1 pack × 1 blister × 5 pills = 5 pills total
|
||||
// Schedule: 1 pill daily starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill daily starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1016,8 +1009,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1029,15 +1022,8 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate weekly medication usage correctly", async () => {
|
||||
// Create medication: 10 pills total
|
||||
// Schedule: 1 pill every 7 days starting tomorrow
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 35); // 35 days to get 5 weekly doses
|
||||
const planEndStr = planEnd.toISOString();
|
||||
// Schedule: 1 pill every 7 days starting on a fixed future winter date.
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1056,8 +1042,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: thirtyFiveDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1070,18 +1056,8 @@ describe("Integration Tests", () => {
|
||||
it("should handle multiple intake schedules per medication", async () => {
|
||||
// Create medication with morning and evening doses
|
||||
// 30 pills total, 1.5 pills per day (1 morning + 0.5 evening)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const morningStart = tomorrow.toISOString();
|
||||
|
||||
const eveningStart = new Date(tomorrow);
|
||||
eveningStart.setHours(20, 0, 0, 0);
|
||||
const eveningStartStr = eveningStart.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const morningStart = futureDailyStart;
|
||||
const eveningStartStr = futureEveningStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1103,8 +1079,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: morningStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1116,14 +1092,7 @@ describe("Integration Tests", () => {
|
||||
|
||||
it("should calculate correct blisters needed", async () => {
|
||||
// 10 pills per blister, need 25 pills → need 3 blisters
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(8, 0, 0, 0);
|
||||
const intakeStart = tomorrow.toISOString();
|
||||
|
||||
const planEnd = new Date(tomorrow);
|
||||
planEnd.setDate(planEnd.getDate() + 10);
|
||||
const planEndStr = planEnd.toISOString();
|
||||
const intakeStart = futureDailyStart;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
@@ -1142,8 +1111,8 @@ describe("Integration Tests", () => {
|
||||
method: "POST",
|
||||
url: "/medications/usage",
|
||||
payload: {
|
||||
startDate: intakeStart,
|
||||
endDate: planEndStr,
|
||||
startDate: plannerWindowStart,
|
||||
endDate: tenDayPlanEnd,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,22 +6,30 @@ import {
|
||||
calculateDailyUsage,
|
||||
calculateDepletionInfo,
|
||||
cleanOldIntakeReminders,
|
||||
countScheduledOccurrencesInRange,
|
||||
createDefaultIntakeReminderState,
|
||||
createDefaultReminderState,
|
||||
forEachScheduledOccurrenceInRange,
|
||||
formatInTimezone,
|
||||
getAverageOccurrencesPerDay,
|
||||
getCurrentHourInTimezone,
|
||||
getMaxScheduledGapDays,
|
||||
getMsUntilNextCheck,
|
||||
getNextScheduledOccurrenceTime,
|
||||
getNextScheduledTime,
|
||||
getTimezone,
|
||||
getTodayInTimezone,
|
||||
getTodaysIntakes,
|
||||
getUpcomingIntakes,
|
||||
type Intake,
|
||||
normalizeIntake,
|
||||
parseBlisters,
|
||||
parseIntakeReminderState,
|
||||
parseIntakesJson,
|
||||
parseReminderState,
|
||||
parseTakenByJson,
|
||||
personTakesMedication,
|
||||
type Weekday,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
|
||||
// Helper to convert Blister to Intake for tests
|
||||
@@ -267,6 +275,77 @@ describe("Scheduler Utils - Blister Parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Intake Schedule Normalization", () => {
|
||||
describe("normalizeIntake", () => {
|
||||
it("keeps interval schedules backward-compatible by default", () => {
|
||||
const intake = normalizeIntake({
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-01T08:00:00",
|
||||
});
|
||||
|
||||
expect(intake).toMatchObject({
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-01T08:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes malformed weekday schedules to the start date weekday", () => {
|
||||
const intake = normalizeIntake({
|
||||
usage: 1,
|
||||
every: 99,
|
||||
start: "2025-01-06T08:00:00",
|
||||
scheduleMode: "weekdays",
|
||||
weekdays: ["bogus", null],
|
||||
});
|
||||
|
||||
expect(intake.scheduleMode).toBe("weekdays");
|
||||
expect(intake.every).toBe(1);
|
||||
expect(intake.weekdays).toEqual(["mon"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIntakesJson", () => {
|
||||
it("falls back to legacy interval data when unified intakes are absent", () => {
|
||||
const intakes = parseIntakesJson(
|
||||
null,
|
||||
{
|
||||
usageJson: "[1,2]",
|
||||
everyJson: "[1,3]",
|
||||
startJson: '["2025-01-01T08:00:00","2025-01-02T20:00:00"]',
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
expect(intakes).toEqual([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2025-01-01T08:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
{
|
||||
usage: 2,
|
||||
every: 3,
|
||||
start: "2025-01-02T20:00:00",
|
||||
scheduleMode: "interval",
|
||||
weekdays: [],
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Daily Usage Calculation", () => {
|
||||
describe("calculateDailyUsage", () => {
|
||||
it("should calculate daily usage for single daily dose", () => {
|
||||
@@ -306,6 +385,71 @@ describe("Scheduler Utils - Daily Usage Calculation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Schedule Occurrence Calculation", () => {
|
||||
it("calculates average usage and gap length for weekday schedules", () => {
|
||||
const weekdaysSchedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
|
||||
};
|
||||
|
||||
expect(getAverageOccurrencesPerDay(weekdaysSchedule)).toBeCloseTo(3 / 7, 5);
|
||||
expect(getMaxScheduledGapDays(weekdaysSchedule)).toBe(3);
|
||||
expect(getAverageOccurrencesPerDay({ every: 2, start: "2025-01-01T09:00:00" })).toBe(0.5);
|
||||
expect(getMaxScheduledGapDays({ every: 2, start: "2025-01-01T09:00:00" })).toBe(2);
|
||||
});
|
||||
|
||||
it("finds the next weekday occurrence after a given timestamp", () => {
|
||||
const schedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["mon", "wed", "fri"] satisfies Weekday[],
|
||||
};
|
||||
|
||||
const fromMs = new Date(2025, 0, 7, 12, 0, 0).getTime();
|
||||
const nextOccurrence = getNextScheduledOccurrenceTime(schedule, fromMs);
|
||||
|
||||
expect(nextOccurrence).toBe(new Date(2025, 0, 8, 9, 0, 0).getTime());
|
||||
});
|
||||
|
||||
it("iterates weekday occurrences in canonical order within a range", () => {
|
||||
const schedule = {
|
||||
every: 1,
|
||||
start: "2025-01-06T09:00:00",
|
||||
scheduleMode: "weekdays" as const,
|
||||
weekdays: ["wed", "mon", "fri"] satisfies Weekday[],
|
||||
};
|
||||
const occurrences: number[] = [];
|
||||
|
||||
forEachScheduledOccurrenceInRange(
|
||||
schedule,
|
||||
new Date(2025, 0, 6, 0, 0, 0).getTime(),
|
||||
new Date(2025, 0, 12, 23, 59, 59).getTime(),
|
||||
(occurrenceMs) => {
|
||||
occurrences.push(occurrenceMs);
|
||||
}
|
||||
);
|
||||
|
||||
expect(occurrences.sort((a, b) => a - b)).toEqual([
|
||||
new Date(2025, 0, 6, 9, 0, 0).getTime(),
|
||||
new Date(2025, 0, 8, 9, 0, 0).getTime(),
|
||||
new Date(2025, 0, 10, 9, 0, 0).getTime(),
|
||||
]);
|
||||
expect(
|
||||
countScheduledOccurrencesInRange(
|
||||
schedule,
|
||||
new Date(2025, 0, 6, 0, 0, 0).getTime(),
|
||||
new Date(2025, 0, 12, 23, 59, 59).getTime()
|
||||
)
|
||||
).toEqual({
|
||||
count: 3,
|
||||
lastOccurrenceMs: new Date(2025, 0, 10, 9, 0, 0).getTime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scheduler Utils - Depletion Calculation", () => {
|
||||
describe("calculateDepletionInfo", () => {
|
||||
it("should calculate days left correctly", () => {
|
||||
@@ -378,12 +522,17 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
|
||||
expect(result[0].pillWeightMg).toBe(500);
|
||||
});
|
||||
|
||||
it("should skip blisters with zero interval", () => {
|
||||
it("should treat zero interval as a daily fallback", () => {
|
||||
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 0, start: "2025-01-01T08:00:00" })];
|
||||
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
|
||||
|
||||
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
medName: "TestMed",
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple blisters", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
createTestMedication,
|
||||
createTestShareToken,
|
||||
createTestUser,
|
||||
setUserSettings,
|
||||
type TestContext,
|
||||
} from "./setup.js";
|
||||
|
||||
|
||||
@@ -6,14 +6,34 @@
|
||||
import { getDateLocale, type Language } from "../i18n/translations.js";
|
||||
import { isLiquidContainerPackageType, isTubePackageType } from "./package-profiles.js";
|
||||
|
||||
export const CANONICAL_WEEKDAY_ORDER = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
export type Weekday = (typeof CANONICAL_WEEKDAY_ORDER)[number];
|
||||
export type IntakeScheduleMode = "interval" | "weekdays";
|
||||
|
||||
type ScheduleLike = {
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
};
|
||||
|
||||
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
|
||||
export type Blister = { usage: number; every: number; start: string };
|
||||
export type Blister = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
};
|
||||
|
||||
// New unified intake type with per-intake takenBy
|
||||
export type Intake = {
|
||||
usage: number;
|
||||
every: number;
|
||||
start: string;
|
||||
scheduleMode?: IntakeScheduleMode;
|
||||
weekdays?: Weekday[];
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null;
|
||||
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
|
||||
intakeRemindersEnabled: boolean;
|
||||
@@ -22,6 +42,278 @@ export type Intake = {
|
||||
const isValidIntakeUnit = (value: unknown): value is "ml" | "tsp" | "tbsp" =>
|
||||
value === "ml" || value === "tsp" || value === "tbsp";
|
||||
|
||||
const weekdayToJavascriptDay: Record<Weekday, number> = {
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
sun: 0,
|
||||
};
|
||||
|
||||
function isWeekday(value: unknown): value is Weekday {
|
||||
return typeof value === "string" && CANONICAL_WEEKDAY_ORDER.includes(value as Weekday);
|
||||
}
|
||||
|
||||
function normalizeScheduleMode(value: unknown): IntakeScheduleMode {
|
||||
return value === "weekdays" ? "weekdays" : "interval";
|
||||
}
|
||||
|
||||
function toDateOnly(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function getDateOnlyTimestamp(date: Date): number {
|
||||
return toDateOnly(date).getTime();
|
||||
}
|
||||
|
||||
export function getWeekdayFromDate(date: Date): Weekday {
|
||||
const weekday = CANONICAL_WEEKDAY_ORDER.find((entry) => weekdayToJavascriptDay[entry] === date.getDay());
|
||||
return weekday ?? "mon";
|
||||
}
|
||||
|
||||
export function getWeekdayFromStart(start: string): Weekday {
|
||||
const startDate = parseLocalDateTime(start);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
return "mon";
|
||||
}
|
||||
return getWeekdayFromDate(startDate);
|
||||
}
|
||||
|
||||
export function normalizeWeekdays(value: unknown, start: string): Weekday[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [getWeekdayFromStart(start)];
|
||||
}
|
||||
|
||||
const uniqueWeekdays = new Set<Weekday>();
|
||||
for (const weekday of value) {
|
||||
if (isWeekday(weekday)) {
|
||||
uniqueWeekdays.add(weekday);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = CANONICAL_WEEKDAY_ORDER.filter((weekday) => uniqueWeekdays.has(weekday));
|
||||
return normalized.length > 0 ? normalized : [getWeekdayFromStart(start)];
|
||||
}
|
||||
|
||||
function createOccurrenceAtDate(date: Date, startDate: Date): number {
|
||||
return new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes(),
|
||||
startDate.getSeconds(),
|
||||
startDate.getMilliseconds()
|
||||
).getTime();
|
||||
}
|
||||
|
||||
function getNormalizedWeekdays(schedule: ScheduleLike): Weekday[] {
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (schedule.weekdays && schedule.weekdays.length > 0) {
|
||||
return schedule.weekdays;
|
||||
}
|
||||
|
||||
return [getWeekdayFromStart(schedule.start)];
|
||||
}
|
||||
|
||||
export function getAverageOccurrencesPerDay(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
if (schedule.scheduleMode === "weekdays") {
|
||||
return getNormalizedWeekdays(schedule).length / 7;
|
||||
}
|
||||
|
||||
return 1 / Math.max(1, schedule.every);
|
||||
}
|
||||
|
||||
export function getMaxScheduledGapDays(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
return Math.max(1, schedule.every);
|
||||
}
|
||||
|
||||
const weekdays = getNormalizedWeekdays(schedule).map((weekday) => CANONICAL_WEEKDAY_ORDER.indexOf(weekday));
|
||||
if (weekdays.length === 0) {
|
||||
return 7;
|
||||
}
|
||||
|
||||
let maxGap = 0;
|
||||
for (let index = 0; index < weekdays.length; index++) {
|
||||
const current = weekdays[index];
|
||||
const next = weekdays[(index + 1) % weekdays.length];
|
||||
const gap = index === weekdays.length - 1 ? next + 7 - current : next - current;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
}
|
||||
}
|
||||
|
||||
return maxGap || 7;
|
||||
}
|
||||
|
||||
export function getScheduleMatchWindowMs(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">
|
||||
): number {
|
||||
return (getMaxScheduledGapDays(schedule) * 86_400_000) / 2;
|
||||
}
|
||||
|
||||
export function getNextScheduledOccurrenceTime(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
fromMs: number,
|
||||
inclusive: boolean = true
|
||||
): number | null {
|
||||
const startDate = parseLocalDateTime(schedule.start);
|
||||
const startTime = startDate.getTime();
|
||||
if (Number.isNaN(startTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lowerBound = inclusive ? fromMs : fromMs + 1;
|
||||
if (schedule.scheduleMode !== "weekdays") {
|
||||
const period = Math.max(1, schedule.every) * 86_400_000;
|
||||
if (startTime >= lowerBound) {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
const intervals = Math.ceil((lowerBound - startTime) / period);
|
||||
return startTime + intervals * period;
|
||||
}
|
||||
|
||||
const candidateStart = Math.max(lowerBound, startTime);
|
||||
const candidateDateOnly = toDateOnly(new Date(candidateStart));
|
||||
let nextOccurrence: number | null = null;
|
||||
|
||||
for (const weekday of getNormalizedWeekdays(schedule)) {
|
||||
const candidateDate = new Date(candidateDateOnly);
|
||||
const offsetDays = (weekdayToJavascriptDay[weekday] - candidateDate.getDay() + 7) % 7;
|
||||
candidateDate.setDate(candidateDate.getDate() + offsetDays);
|
||||
|
||||
let occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
|
||||
if (occurrenceMs < candidateStart) {
|
||||
candidateDate.setDate(candidateDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(candidateDate, startDate);
|
||||
}
|
||||
|
||||
if (nextOccurrence === null || occurrenceMs < nextOccurrence) {
|
||||
nextOccurrence = occurrenceMs;
|
||||
}
|
||||
}
|
||||
|
||||
return nextOccurrence;
|
||||
}
|
||||
|
||||
export function forEachScheduledOccurrenceInRange(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
callback: (occurrenceMs: number) => void
|
||||
): void {
|
||||
if (!Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs) || rangeEndMs < rangeStartMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDate = parseLocalDateTime(schedule.start);
|
||||
const startTime = startDate.getTime();
|
||||
if (Number.isNaN(startTime) || rangeEndMs < startTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
for (; occurrenceMs <= rangeEndMs; occurrenceMs += period) {
|
||||
if (occurrenceMs >= rangeStartMs) {
|
||||
callback(occurrenceMs);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerBound = Math.max(rangeStartMs, startTime);
|
||||
const firstDateOnly = toDateOnly(new Date(lowerBound));
|
||||
|
||||
for (const weekday of getNormalizedWeekdays(schedule)) {
|
||||
const occurrenceDate = new Date(firstDateOnly);
|
||||
const offsetDays = (weekdayToJavascriptDay[weekday] - occurrenceDate.getDay() + 7) % 7;
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + offsetDays);
|
||||
|
||||
let occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
if (occurrenceMs < lowerBound) {
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
}
|
||||
|
||||
while (occurrenceMs <= rangeEndMs) {
|
||||
callback(occurrenceMs);
|
||||
occurrenceDate.setDate(occurrenceDate.getDate() + 7);
|
||||
occurrenceMs = createOccurrenceAtDate(occurrenceDate, startDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function countScheduledOccurrencesInRange(
|
||||
schedule: Pick<ScheduleLike, "every" | "start" | "scheduleMode" | "weekdays">,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number
|
||||
): { count: number; lastOccurrenceMs: number | null } {
|
||||
let count = 0;
|
||||
let lastOccurrenceMs: number | null = null;
|
||||
|
||||
forEachScheduledOccurrenceInRange(schedule, rangeStartMs, rangeEndMs, (occurrenceMs) => {
|
||||
count += 1;
|
||||
if (lastOccurrenceMs === null || occurrenceMs > lastOccurrenceMs) {
|
||||
lastOccurrenceMs = occurrenceMs;
|
||||
}
|
||||
});
|
||||
|
||||
return { count, lastOccurrenceMs };
|
||||
}
|
||||
|
||||
export function normalizeIntake(
|
||||
value: {
|
||||
usage?: unknown;
|
||||
every?: unknown;
|
||||
start?: unknown;
|
||||
scheduleMode?: unknown;
|
||||
weekdays?: unknown;
|
||||
intakeUnit?: unknown;
|
||||
takenBy?: unknown;
|
||||
intakeRemindersEnabled?: unknown;
|
||||
},
|
||||
defaultIntakeRemindersEnabled: boolean = false
|
||||
): Intake {
|
||||
const start = typeof value.start === "string" ? value.start : new Date().toISOString();
|
||||
const scheduleMode = normalizeScheduleMode(value.scheduleMode);
|
||||
let every = 1;
|
||||
if (scheduleMode !== "weekdays") {
|
||||
if (typeof value.every === "number" && Number.isFinite(value.every) && value.every >= 1) {
|
||||
every = value.every;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usage: typeof value.usage === "number" && Number.isFinite(value.usage) ? value.usage : 0,
|
||||
every,
|
||||
start,
|
||||
scheduleMode,
|
||||
weekdays: scheduleMode === "weekdays" ? normalizeWeekdays(value.weekdays, start) : [],
|
||||
intakeUnit: isValidIntakeUnit(value.intakeUnit) ? value.intakeUnit : null,
|
||||
takenBy: typeof value.takenBy === "string" && value.takenBy.trim() ? value.takenBy.trim() : null,
|
||||
intakeRemindersEnabled:
|
||||
typeof value.intakeRemindersEnabled === "boolean" ? value.intakeRemindersEnabled : defaultIntakeRemindersEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize intake usage for stock math.
|
||||
*
|
||||
@@ -225,15 +517,7 @@ export function parseIntakesJson(
|
||||
try {
|
||||
const parsed = JSON.parse(intakesJson);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((intake: Record<string, unknown>) => ({
|
||||
usage: typeof intake.usage === "number" ? intake.usage : 0,
|
||||
every: typeof intake.every === "number" ? intake.every : 1,
|
||||
start: typeof intake.start === "string" ? intake.start : new Date().toISOString(),
|
||||
intakeUnit: isValidIntakeUnit(intake.intakeUnit) ? intake.intakeUnit : null,
|
||||
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
|
||||
intakeRemindersEnabled:
|
||||
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
|
||||
}));
|
||||
return parsed.map((intake: Record<string, unknown>) => normalizeIntake(intake));
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy parsing
|
||||
@@ -243,14 +527,18 @@ export function parseIntakesJson(
|
||||
// Fallback to legacy parallel arrays
|
||||
if (legacyRow) {
|
||||
const blisters = parseBlisters(legacyRow);
|
||||
return blisters.map((b) => ({
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null, // Legacy format has no per-intake takenBy
|
||||
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
|
||||
}));
|
||||
return blisters.map((b) =>
|
||||
normalizeIntake(
|
||||
{
|
||||
usage: b.usage,
|
||||
every: b.every,
|
||||
start: b.start,
|
||||
intakeUnit: null,
|
||||
takenBy: null,
|
||||
},
|
||||
medicationIntakeRemindersEnabled ?? false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -303,7 +591,7 @@ export function personTakesMedication(person: string, medicationTakenBy: 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, blister) => sum + blister.usage * getAverageOccurrencesPerDay(blister), 0);
|
||||
}
|
||||
|
||||
/** Calculate depletion information for a medication */
|
||||
@@ -370,50 +658,31 @@ export function getTodaysIntakes(
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
// If intake has its own takenBy, use it; otherwise null (no specific person)
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// 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);
|
||||
result.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
}
|
||||
currentTime += intervalMs;
|
||||
}
|
||||
forEachScheduledOccurrenceInRange(intake, todayStart.getTime(), todayEnd.getTime(), (occurrenceMs) => {
|
||||
const intakeDate = new Date(occurrenceMs);
|
||||
result.push({
|
||||
medName,
|
||||
medicationId,
|
||||
blisterIndex: blisterIdx,
|
||||
usage: intake.usage,
|
||||
intakeTime: intakeDate,
|
||||
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: timezone,
|
||||
}),
|
||||
takenBy: effectiveTakenBy,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return result.sort((left, right) => left.intakeTime.getTime() - right.intakeTime.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,40 +713,11 @@ export function getUpcomingIntakes(
|
||||
|
||||
for (let blisterIdx = 0; blisterIdx < intakes.length; blisterIdx++) {
|
||||
const intake = intakes[blisterIdx];
|
||||
const startTime = parseLocalDateTime(intake.start).getTime();
|
||||
const intervalMs = intake.every * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (intervalMs <= 0) continue;
|
||||
|
||||
// Determine takenBy for this intake
|
||||
const effectiveTakenBy = intake.takenBy || null;
|
||||
|
||||
// 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 if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
|
||||
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
|
||||
// but the intake time is still in the future — include it so the advance
|
||||
// reminder can still be sent rather than falling into a dead zone.
|
||||
nextTime = currentOccurrence;
|
||||
} else {
|
||||
nextTime = nextOccurrence;
|
||||
}
|
||||
}
|
||||
const nextTime = getNextScheduledOccurrenceTime(intake, now, true);
|
||||
if (nextTime === null) continue;
|
||||
|
||||
// Calculate when we should notify for this intake
|
||||
const notifyTime = nextTime - minutesBefore * 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user