feat: Add package type support and per-intake takenBy (#89)

## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
This commit is contained in:
Daniel Volz
2026-01-31 23:49:11 +01:00
committed by GitHub
parent ac4b8151e4
commit 571d94bf7e
37 changed files with 2896 additions and 990 deletions
+7
View File
@@ -92,6 +92,13 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
// Added for package type support (blister vs bottle)
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
`ALTER TABLE medications ADD COLUMN total_pills integer`,
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
// Added for intake-level takenBy: unified intakes structure
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
];
for (const sql of alterMigrations) {
+9 -4
View File
@@ -28,16 +28,21 @@ export const medications = sqliteTable("medications", {
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
totalPills: integer("total_pills"), // For bottle type: total capacity of the container
looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
// New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}]
intakesJson: text("intakes_json").notNull().default("[]"),
imageUrl: text("image_url"),
expiryDate: text("expiry_date"),
notes: text("notes"),
+36 -24
View File
@@ -9,7 +9,7 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
@@ -27,6 +27,7 @@ const scheduleSchema = z.object({
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
});
const inventorySchema = z.object({
@@ -44,6 +45,7 @@ const medicationExportSchema = z.object({
takenBy: z.array(z.string()).default([]),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
@@ -126,28 +128,24 @@ async function getUserId(request: any, reply: any): Promise<number> {
return authUser.id;
}
// Parse blisters from DB format to export format
function parseBlistersForExport(
// Parse intakes from DB format to export format (with per-intake takenBy)
function parseIntakesForExport(
row: typeof medications.$inferSelect
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
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 schedules: Array<{ usage: number; every: number; start: string; remind: boolean }> = [];
for (let i = 0; i < len; i++) {
schedules.push({
usage: usage[i],
every: every[i],
start: start[i],
remind: row.intakeRemindersEnabled ?? false,
});
}
return schedules;
} catch {
return [];
}
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
// Use the new parseIntakesJson which falls back to legacy format
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
return intakes.map((intake) => ({
usage: intake.usage,
every: intake.every,
start: intake.start,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
}
// Read image file and convert to base64 data URL
@@ -279,7 +277,8 @@ export async function exportRoutes(app: FastifyInstance) {
stockAdjustment: med.stockAdjustment ?? 0,
},
pillWeightMg: med.pillWeightMg,
schedules: parseBlistersForExport(med),
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
@@ -463,12 +462,23 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules back to JSON arrays
// 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 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,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
@@ -486,6 +496,8 @@ export async function exportRoutes(app: FastifyInstance) {
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
intakesJson,
usageJson,
everyJson,
startJson,
+165 -47
View File
@@ -9,30 +9,50 @@ import { doseTracking, medications } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseBlisters, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
import { type Intake, parseIntakesJson, parseLocalDateTime, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
// New intake schema with per-intake takenBy
const intakeSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime({ local: true }),
takenBy: z.string().trim().max(100).nullable().optional(), // Person for this specific intake
intakeRemindersEnabled: z.boolean().default(false), // Per-intake reminder setting
});
// Legacy blister schema (for backward compatibility during transition)
const blisterSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime({ local: true }),
});
const medicationSchema = z.object({
name: z.string().trim().min(1).max(100),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Array of person names
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().int().min(1).nullable().optional(),
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
blisters: z.array(blisterSchema).min(1).max(12),
});
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
const medicationSchema = z
.object({
name: z.string().trim().min(1).max(100),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
packageType: packageTypeSchema,
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
totalPills: z.number().int().min(1).nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
pillWeightMg: z.number().nonnegative().nullable().optional(),
doseUnit: doseUnitSchema,
expiryDate: z.string().nullable().optional(),
notes: z.string().max(2000).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false), // Medication-level (deprecated, kept for backward compat)
// Accept either new intakes format or legacy blisters format
intakes: z.array(intakeSchema).min(1).max(12).optional(),
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
})
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" });
export async function medicationRoutes(app: FastifyInstance) {
// All medication routes require auth
@@ -58,26 +78,40 @@ export async function medicationRoutes(app: FastifyInstance) {
app.get("/medications", async (request, reply) => {
const userId = await getUserId(request, reply);
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
return rows.map((row) => ({
id: row.id,
name: row.name,
genericName: row.genericName,
takenBy: parseTakenByJson(row.takenByJson),
packCount: row.packCount ?? 1,
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
looseTablets: row.looseTablets ?? 0,
stockAdjustment: row.stockAdjustment ?? 0,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
blisters: parseBlisters(row),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
dismissedUntil: row.dismissedUntil ?? null,
updatedAt: row.updatedAt,
}));
return rows.map((row) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
return {
id: row.id,
name: row.name,
genericName: row.genericName,
takenBy: parseTakenByJson(row.takenByJson),
packageType: row.packageType ?? "blister",
packCount: row.packCount ?? 1,
blistersPerPack: row.blistersPerPack ?? 1,
pillsPerBlister: row.pillsPerBlister ?? 1,
totalPills: row.totalPills ?? null,
looseTablets: row.looseTablets ?? 0,
stockAdjustment: row.stockAdjustment ?? 0,
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: row.pillWeightMg,
doseUnit: row.doseUnit ?? "mg",
intakes, // New unified format with per-intake takenBy
// Legacy blisters format (for backward compat with frontend during transition)
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
dismissedUntil: row.dismissedUntil ?? null,
updatedAt: row.updatedAt,
};
});
});
app.post("/medications", async (req, reply) => {
@@ -89,19 +123,50 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName,
takenBy,
packageType,
packCount,
blistersPerPack,
pillsPerBlister,
totalPills,
looseTablets,
pillWeightMg,
doseUnit,
expiryDate,
notes,
intakeRemindersEnabled,
blisters,
intakes: inputIntakes,
blisters: inputBlisters,
} = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
// 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,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
// Store both formats for backward compatibility
const intakesJson = JSON.stringify(intakes);
const usageJson = JSON.stringify(intakes.map((s) => s.usage));
const everyJson = JSON.stringify(intakes.map((s) => s.every));
const startJson = JSON.stringify(intakes.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const [inserted] = await db
@@ -111,14 +176,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName: genericName || null,
takenByJson,
packageType: packageType ?? "blister",
packCount,
blistersPerPack,
pillsPerBlister,
totalPills: totalPills || null,
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson,
everyJson,
startJson,
@@ -130,14 +199,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name: inserted.name,
genericName: inserted.genericName,
takenBy: parseTakenByJson(inserted.takenByJson),
packageType: inserted.packageType ?? "blister",
packCount: inserted.packCount,
blistersPerPack: inserted.blistersPerPack,
pillsPerBlister: inserted.pillsPerBlister,
totalPills: inserted.totalPills ?? null,
looseTablets: inserted.looseTablets,
stockAdjustment: inserted.stockAdjustment ?? 0,
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: inserted.pillWeightMg,
blisters,
doseUnit: inserted.doseUnit ?? "mg",
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
notes: inserted.notes,
@@ -165,19 +238,50 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName,
takenBy,
packageType,
packCount,
blistersPerPack,
pillsPerBlister,
totalPills,
looseTablets,
pillWeightMg,
doseUnit,
expiryDate,
notes,
intakeRemindersEnabled,
blisters,
intakes: inputIntakes,
blisters: inputBlisters,
} = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
// 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,
takenBy: i.takenBy || null,
intakeRemindersEnabled: i.intakeRemindersEnabled ?? false,
}));
} else if (inputBlisters) {
// Legacy format - convert to new format
intakes = inputBlisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // No per-intake takenBy from legacy
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
}));
} else {
return reply.status(400).send({ error: "Either 'intakes' or 'blisters' must be provided" });
}
// Store both formats for backward compatibility
const intakesJson = JSON.stringify(intakes);
const usageJson = JSON.stringify(intakes.map((s) => s.usage));
const everyJson = JSON.stringify(intakes.map((s) => s.every));
const startJson = JSON.stringify(intakes.map((s) => s.start));
const takenByJson = JSON.stringify(takenBy || []);
const result = await db
@@ -186,14 +290,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name,
genericName: genericName || null,
takenByJson,
packageType: packageType ?? "blister",
packCount,
blistersPerPack,
pillsPerBlister,
totalPills: totalPills || null,
looseTablets,
pillWeightMg: pillWeightMg || null,
doseUnit: doseUnit ?? "mg",
expiryDate: expiryDate || null,
notes: notes || null,
intakeRemindersEnabled: intakeRemindersEnabled ?? false,
intakesJson,
usageJson,
everyJson,
startJson,
@@ -206,7 +314,7 @@ export async function medicationRoutes(app: FastifyInstance) {
// Clean up dose tracking entries that are before the earliest start date
// This ensures consistency when the user changes the start date
const earliestStart = Math.min(...blisters.map((b) => parseLocalDateTime(b.start).getTime()));
const earliestStart = Math.min(...intakes.map((b) => parseLocalDateTime(b.start).getTime()));
if (!Number.isNaN(earliestStart)) {
// Get all dose tracking entries for this medication and filter out invalid ones
const allDoses = await db
@@ -235,14 +343,18 @@ export async function medicationRoutes(app: FastifyInstance) {
name: result[0].name,
genericName: result[0].genericName,
takenBy: parseTakenByJson(result[0].takenByJson),
packageType: result[0].packageType ?? "blister",
packCount: result[0].packCount,
blistersPerPack: result[0].blistersPerPack,
pillsPerBlister: result[0].pillsPerBlister,
totalPills: result[0].totalPills ?? null,
looseTablets: result[0].looseTablets,
stockAdjustment: result[0].stockAdjustment ?? 0,
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
pillWeightMg: result[0].pillWeightMg,
blisters,
doseUnit: result[0].doseUnit ?? "mg",
intakes,
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
notes: result[0].notes,
@@ -398,7 +510,13 @@ export async function medicationRoutes(app: FastifyInstance) {
const now = new Date();
const payload = rows.map((row) => {
const blisters = parseBlisters(row);
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
row.intakesJson,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
const blisters = intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
const usageTotal = calculateUsageInRange(blisters, start, end);
const pillsPerBlister = row.pillsPerBlister ?? 1;
const packCount = row.packCount ?? 1;
+53 -23
View File
@@ -7,7 +7,12 @@ import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseTakenByJson } from "../utils/scheduler-utils.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
@@ -78,27 +83,32 @@ export async function shareRoutes(app: FastifyInstance) {
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Filter medications where takenByJson array contains the share.takenBy value
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(share.takenBy);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
let blisters: { usage: number; every: number; start: string }[] = [];
try {
const usageArr = JSON.parse(med.usageJson || "[]");
const everyArr = JSON.parse(med.everyJson || "[]");
const startArr = JSON.parse(med.startJson || "[]");
blisters = usageArr.map((usage: number, i: number) => ({
usage,
every: everyArr[i] ?? 1,
start: startArr[i] ?? new Date().toISOString(),
}));
} catch {
blisters = [];
}
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
@@ -110,6 +120,7 @@ export async function shareRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packCount: med.packCount,
@@ -117,7 +128,8 @@ export async function shareRoutes(app: FastifyInstance) {
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
blisters,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
};
@@ -153,11 +165,16 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in JSON array)
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
return takenByArray.includes(takenBy);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(takenBy, takenByArray, intakes);
});
if (medsForPerson.length === 0) {
@@ -196,17 +213,30 @@ export async function shareRoutes(app: FastifyInstance) {
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from JSON arrays)
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({ takenByJson: medications.takenByJson })
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(eq(medications.userId, userId));
// Collect all unique person names from all takenByJson arrays
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
for (const person of takenByArray) {
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
}
}
@@ -8,15 +8,15 @@ import { getDateLocale, getTranslations, type Language, t } from "../i18n/transl
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
// Import shared utilities
import {
type Blister,
cleanOldIntakeReminders,
createDefaultIntakeReminderState,
getTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
type IntakeReminderState,
parseBlisters,
parseIntakeReminderState,
parseIntakesJson,
parseTakenByJson,
type UpcomingIntake,
} from "../utils/scheduler-utils.js";
@@ -75,11 +75,10 @@ async function sendIntakeReminderEmail(
return pillText;
};
// Helper to format medication name with takenBy (array of names)
// Helper to format medication name with takenBy (single person or null)
const formatMedName = (intake: UpcomingIntake): string => {
if (intake.takenBy.length > 0) {
const namesStr = intake.takenBy.join(", ");
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: namesStr })}</span>`;
if (intake.takenBy) {
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}</span>`;
}
return intake.medName;
};
@@ -172,7 +171,7 @@ ${description}
${intakes
.map((i) => {
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
})
.join("\n")}
@@ -291,62 +290,92 @@ async function checkAndSendIntakeRemindersForUser(
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) {
const blisters = parseBlisters(med);
const takenByArray = parseTakenByJson(med.takenByJson);
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
logger.info(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${blisters.length} blisters`
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
);
// Process each blister separately to track blisterIndex
blisters.forEach((blister, blisterIndex) => {
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.info(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - start: ${blister.start}, every: ${blister.every} days, usage: ${blister.usage}`
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
);
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
med.name,
[blister],
[intake],
REMINDER_MINUTES_BEFORE,
takenByArray,
medicationTakenBy,
med.pillWeightMg,
locale,
tz
tz,
undefined, // nowOverride
med.id,
med.doseUnit ?? "mg"
);
logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
);
// Add upcoming intakes for first reminders
allUpcoming.push(
...upcomingIntakes.map((intake) => ({
...intake,
...upcomingIntakes.map((upcomingIntake) => ({
...upcomingIntake,
medicationId: med.id,
blisterIndex,
blisterIndex: actualIndex,
}))
);
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(med.name, [blister], takenByArray, med.pillWeightMg, locale, tz);
logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
const allTodaysIntakes = getTodaysIntakes(
med.name,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
const missedIntakes = allTodaysIntakes.filter((intake) => intake.intakeTime.getTime() < now.getTime());
logger.info(
`[IntakeReminder] User ${settings.userId}: Blister ${blisterIndex} found ${missedIntakes.length} missed intakes (past intake time)`
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
);
const missedIntakes = allTodaysIntakes.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.info(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
allUpcoming.push(
...missedIntakes
.filter((intake) => !upcomingTimes.has(intake.intakeTime.getTime()))
.map((intake) => ({
...intake,
.filter((missed) => !upcomingTimes.has(missed.intakeTime.getTime()))
.map((missed) => ({
...missed,
medicationId: med.id,
blisterIndex,
blisterIndex: actualIndex,
}))
);
}
@@ -438,20 +467,31 @@ async function checkAndSendIntakeRemindersForUser(
// Filter out reminders for doses that were already taken
remindersToSend = remindersToSend.filter((intake) => {
const timestamp = intake.intakeTime.getTime();
// Convert to date-only timestamp (midnight) to match frontend dose ID format
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
// Check both with and without person suffix
if (intake.takenBy.length > 0) {
// For multi-person medications, check if any person has taken it
const anyTaken = intake.takenBy.some((person) => {
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}-${person}`;
return takenDoseIds.has(doseId);
});
return !anyTaken; // Skip if any person has taken it
if (intake.takenBy) {
// For person-specific intake, check if that person has taken it
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.info(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
} else {
// For non-person-specific medications
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${timestamp}`;
return !takenDoseIds.has(doseId);
// For non-person-specific intakes
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.info(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
}
});
@@ -541,8 +581,7 @@ async function checkAndSendIntakeRemindersForUser(
const message =
remindersToSend
.map((i) => {
const takenByStr =
i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
if (i.pillWeightMg) {
const totalMg = i.usage * i.pillWeightMg;
@@ -621,7 +660,7 @@ async function checkAndSendIntakeRemindersForUser(
// Get the first reminder's medication name and taken by for display
const firstReminder = remindersToSend[0];
const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy?.length > 0 ? firstReminder.takenBy.join(", ") : undefined;
const takenBy = firstReminder?.takenBy || undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
}
}
+27 -23
View File
@@ -76,29 +76,33 @@ async function createSchema(client: Client) {
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
package_type text NOT NULL DEFAULT 'blister',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
total_pills integer,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
dose_unit text DEFAULT 'mg',
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
intakes_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
+27 -23
View File
@@ -71,29 +71,33 @@ async function createSchema(client: Client) {
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by_json text NOT NULL DEFAULT '[]',
package_type text NOT NULL DEFAULT 'blister',
pack_count integer NOT NULL DEFAULT 1,
blisters_per_pack integer NOT NULL DEFAULT 1,
pills_per_blister integer NOT NULL DEFAULT 1,
total_pills integer,
loose_tablets integer NOT NULL DEFAULT 0,
stock_adjustment integer NOT NULL DEFAULT 0,
last_stock_correction_at integer,
pill_weight_mg integer,
dose_unit text DEFAULT 'mg',
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
intakes_json text NOT NULL DEFAULT '[]',
image_url text,
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
dismissed_until text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
+48 -33
View File
@@ -16,12 +16,24 @@ import {
getTodayInTimezone,
getTodaysIntakes,
getUpcomingIntakes,
type Intake,
parseBlisters,
parseIntakeReminderState,
parseReminderState,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
// Helper to convert Blister to Intake for tests
function blisterToIntake(blister: Blister, takenBy: string | null = null, intakeRemindersEnabled = false): Intake {
return {
usage: blister.usage,
every: blister.every,
start: blister.start,
takenBy,
intakeRemindersEnabled,
};
}
describe("Scheduler Utils - Timezone Functions", () => {
let originalTz: string | undefined;
@@ -333,45 +345,45 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
describe("getUpcomingIntakes", () => {
it("should return empty array when no intakes in window", () => {
// With parseLocalDateTime, times are treated as local - use same format for consistency
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00" }];
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
// Set "now" to a time far from any scheduled intake (12:00 local)
const now = new Date(2025, 0, 1, 12, 0, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
});
it("should find intake within reminder window", () => {
// Schedule intake at 08:00 local, check at 07:45 local (15 minutes before)
const blisters: Blister[] = [{ usage: 2, every: 1, start: "2025-01-01T08:00:00" }];
const intakes: Intake[] = [blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:00:00" }, "Alice")];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, ["Alice"], 500, "en-US", "UTC", now);
const result = getUpcomingIntakes("TestMed", intakes, 15, [], 500, "en-US", "UTC", now);
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("TestMed");
expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toEqual(["Alice"]);
expect(result[0].takenBy).toBe("Alice");
expect(result[0].pillWeightMg).toBe(500);
});
it("should skip blisters with zero interval", () => {
const blisters: Blister[] = [{ usage: 1, every: 0, start: "2025-01-01T08:00:00" }];
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", blisters, 15, [], null, "en-US", "UTC", now);
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toEqual([]);
});
it("should handle multiple blisters", () => {
// Two intakes at 08:00 and 08:01 local
const blisters: Blister[] = [
{ usage: 1, every: 1, start: "2025-01-01T08:00:00" },
{ usage: 2, every: 1, start: "2025-01-01T08:01:00" },
const intakes: Intake[] = [
blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" }),
blisterToIntake({ usage: 2, every: 1, start: "2025-01-01T08:01:00" }),
];
const now = new Date(2025, 0, 1, 7, 45, 0).getTime();
const result = getUpcomingIntakes("TestMed", blisters, 15, [], null, "en-US", "UTC", now);
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Both should be found as they're within the window
expect(result.length).toBeGreaterThanOrEqual(1);
@@ -382,10 +394,10 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
it("should return all intakes for today", () => {
// Daily medication at 08:00 starting yesterday
// With parseLocalDateTime, "08:00:00.000Z" is treated as 08:00 local time
const blisters: Blister[] = [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }];
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" })];
// Get intakes for today (today's intake should be at 08:00 local)
const result = getTodaysIntakes("TestMed", blisters, [], null, "en-US", "UTC");
const result = getTodaysIntakes("TestMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(1);
const intake = result.find((i) => i.intakeTime.getHours() === 8);
@@ -399,20 +411,23 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const todayMidnight = new Date();
todayMidnight.setUTCHours(0, 1, 0, 0);
const blisters: Blister[] = [
{
usage: 2,
every: 1,
start: todayMidnight.toISOString(),
},
const intakes: Intake[] = [
blisterToIntake(
{
usage: 2,
every: 1,
start: todayMidnight.toISOString(),
},
"Bob"
),
];
const result = getTodaysIntakes("PastMed", blisters, ["Bob"], 250, "en-US", "UTC");
const result = getTodaysIntakes("PastMed", intakes, [], 250, "en-US", "UTC");
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("PastMed");
expect(result[0].usage).toBe(2);
expect(result[0].takenBy).toEqual(["Bob"]);
expect(result[0].takenBy).toBe("Bob");
expect(result[0].pillWeightMg).toBe(250);
});
@@ -424,12 +439,12 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const evening = new Date(today);
evening.setUTCHours(20, 0, 0, 0);
const blisters: Blister[] = [
{ usage: 1, every: 1, start: morning.toISOString() },
{ usage: 1, every: 1, start: evening.toISOString() },
const intakes: Intake[] = [
blisterToIntake({ usage: 1, every: 1, start: morning.toISOString() }),
blisterToIntake({ usage: 1, every: 1, start: evening.toISOString() }),
];
const result = getTodaysIntakes("MultiMed", blisters, [], null, "en-US", "UTC");
const result = getTodaysIntakes("MultiMed", intakes, [], null, "en-US", "UTC");
expect(result.length).toBeGreaterThanOrEqual(2);
});
@@ -439,16 +454,16 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
const blisters: Blister[] = [
{
const intakes: Intake[] = [
blisterToIntake({
usage: 1,
every: 7,
start: lastWeek.toISOString(),
},
}),
];
// If today is not the same day of week, should return empty
const result = getTodaysIntakes("WeeklyMed", blisters, [], null, "en-US", "UTC");
const result = getTodaysIntakes("WeeklyMed", intakes, [], null, "en-US", "UTC");
// This test might return 0 or 1 depending on the day
expect(Array.isArray(result)).toBe(true);
@@ -458,15 +473,15 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
// With parseLocalDateTime, the Z suffix is ignored and time is treated as local server time
// The intakeTimeStr is then formatted for the target timezone (Europe/Berlin)
// So if server is in UTC, 14:00 server time becomes 15:00 Europe/Berlin time
const blisters: Blister[] = [
{
const intakes: Intake[] = [
blisterToIntake({
usage: 1,
every: 1,
start: "2025-01-01T14:00:00.000Z", // Treated as 14:00 server local time
},
}),
];
const result = getTodaysIntakes("TzMed", blisters, [], null, "de-DE", "Europe/Berlin");
const result = getTodaysIntakes("TzMed", intakes, [], null, "de-DE", "Europe/Berlin");
expect(Array.isArray(result)).toBe(true);
if (result.length > 0) {
+130 -21
View File
@@ -5,8 +5,18 @@
import { getDateLocale, type Language } from "../i18n/translations.js";
// Legacy type - individual blister schedule (DEPRECATED: use Intake instead)
export type Blister = { usage: number; every: number; start: string };
// New unified intake type with per-intake takenBy
export type Intake = {
usage: number;
every: number;
start: string;
takenBy: string | null; // Person taking this specific intake (null = use medication-level takenBy)
intakeRemindersEnabled: boolean;
};
// =============================================================================
// Timezone utilities
// =============================================================================
@@ -147,7 +157,7 @@ export function parseLocalDateTime(isoString: string): Date {
);
}
/** Parse blister schedules from JSON columns */
/** Parse blister schedules from JSON columns (DEPRECATED: use parseIntakesJson instead) */
export function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
@@ -164,6 +174,59 @@ export function parseBlisters(row: { usageJson: string; everyJson: string; start
}
}
/**
* Parse intakes from the new unified intakesJson format.
* Falls back to legacy parallel arrays if intakesJson is empty.
* @param intakesJson - The new unified JSON string
* @param legacyRow - Optional legacy row with usageJson, everyJson, startJson for fallback
* @param medicationIntakeRemindersEnabled - Medication-level intakeRemindersEnabled (fallback for legacy)
*/
export function parseIntakesJson(
intakesJson: string | null | undefined,
legacyRow?: { usageJson: string; everyJson: string; startJson: string },
medicationIntakeRemindersEnabled?: boolean
): Intake[] {
// Try new format first
if (intakesJson) {
try {
const parsed = JSON.parse(intakesJson);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map((intake: any) => ({
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(),
takenBy: typeof intake.takenBy === "string" && intake.takenBy.trim() ? intake.takenBy.trim() : null,
intakeRemindersEnabled:
typeof intake.intakeRemindersEnabled === "boolean" ? intake.intakeRemindersEnabled : false,
}));
}
} catch {
// Fall through to legacy parsing
}
}
// Fallback to legacy parallel arrays
if (legacyRow) {
const blisters = parseBlisters(legacyRow);
return blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: medicationIntakeRemindersEnabled ?? false,
}));
}
return [];
}
/**
* Convert intakes to legacy blister format (for backward compatibility)
*/
export function intakesToBlisters(intakes: Intake[]): Blister[] {
return intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
/** Parse takenByJson to array of strings */
export function parseTakenByJson(takenByJson: string | null | undefined): string[] {
if (!takenByJson) return [];
@@ -175,6 +238,28 @@ export function parseTakenByJson(takenByJson: string | null | undefined): string
}
}
/**
* Get all unique takenBy values from both medication-level and intake-level.
* Used for filtering and sharing functionality.
*/
export function getAllTakenByForMedication(medicationTakenBy: string[], intakes: Intake[]): string[] {
const allPeople = new Set<string>(medicationTakenBy);
for (const intake of intakes) {
if (intake.takenBy) {
allPeople.add(intake.takenBy);
}
}
return Array.from(allPeople);
}
/**
* Check if a person takes this medication (either via medication-level or intake-level takenBy).
*/
export function personTakesMedication(person: string, medicationTakenBy: string[], intakes: Intake[]): boolean {
if (medicationTakenBy.includes(person)) return true;
return intakes.some((intake) => intake.takenBy === person);
}
// =============================================================================
// Stock calculation utilities
// =============================================================================
@@ -209,24 +294,30 @@ export function calculateDepletionInfo(
export type UpcomingIntake = {
medName: string;
medicationId?: number;
blisterIndex?: number;
usage: number;
intakeTime: Date;
intakeTimeStr: string;
takenBy: string[];
takenBy: string | null; // Single person for this intake (null = no specific person)
pillWeightMg: number | null;
doseUnit?: string;
};
/**
* Get all intakes for today (past and future) - used for repeat reminders.
* Returns all intakes scheduled for today in user's timezone.
* Now uses per-intake takenBy instead of medication-level.
*/
export function getTodaysIntakes(
medName: string,
blisters: Blister[],
takenBy: string[],
intakes: Intake[],
medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null,
locale: string,
tz?: string
tz?: string,
medicationId?: number,
doseUnit?: string
): UpcomingIntake[] {
const timezone = tz ?? getTimezone();
const now = new Date();
@@ -238,14 +329,19 @@ export function getTodaysIntakes(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: timezone }));
todayEnd.setHours(23, 59, 59, 999);
const intakes: UpcomingIntake[] = [];
const result: UpcomingIntake[] = [];
for (const blister of blisters) {
const startTime = parseLocalDateTime(blister.start).getTime();
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
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;
@@ -260,39 +356,45 @@ export function getTodaysIntakes(
while (currentTime <= todayEnd.getTime()) {
if (currentTime >= todayStart.getTime()) {
const intakeDate = new Date(currentTime);
intakes.push({
result.push({
medName,
usage: blister.usage,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy,
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
currentTime += intervalMs;
}
}
return intakes;
return result;
}
/**
* Get upcoming intakes that fall within the reminder window.
* Returns intakes that should be notified about right now.
* Now uses per-intake takenBy instead of medication-level.
*/
export function getUpcomingIntakes(
medName: string,
blisters: Blister[],
intakes: Intake[],
minutesBefore: number,
takenBy: string[],
medicationTakenBy: string[], // Medication-level takenBy as fallback
pillWeightMg: number | null,
locale: string,
tz?: string,
nowOverride?: number
nowOverride?: number,
medicationId?: number,
doseUnit?: string
): UpcomingIntake[] {
const now = nowOverride ?? Date.now();
const timezone = tz ?? getTimezone();
@@ -303,12 +405,16 @@ export function getUpcomingIntakes(
const upcoming: UpcomingIntake[] = [];
for (const blister of blisters) {
const startTime = parseLocalDateTime(blister.start).getTime();
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
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;
@@ -339,15 +445,18 @@ export function getUpcomingIntakes(
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
usage: blister.usage,
medicationId,
blisterIndex: blisterIdx,
usage: intake.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}),
takenBy,
takenBy: effectiveTakenBy,
pillWeightMg,
doseUnit,
});
}
}