feat: migrate taken_by to taken_by_json for multi-person support
- Added `taken_by_json` column to `medications` table to store an array of names. - Updated migration scripts to convert existing `taken_by` data into JSON format. - Modified backend routes to handle the new `taken_by_json` structure, including parsing and filtering logic. - Updated frontend to support multi-value input for "Taken By" using tags. - Adjusted validation and state management for the new array format in forms. - Enhanced UI for displaying multiple names and added autocomplete suggestions for input. - Updated translations for input placeholders to reflect new functionality. - Added CSS styles for tag input components.
This commit is contained in:
@@ -32,6 +32,7 @@ async function main() {
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
strips integer NOT NULL DEFAULT 0,
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Convert taken_by from single string to JSON array
|
||||
-- This allows multiple people to share the same medication
|
||||
|
||||
-- Add new column for JSON array
|
||||
ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Migrate existing data: convert single string to JSON array
|
||||
-- If taken_by is not null/empty, convert to ["value"], otherwise keep as []
|
||||
UPDATE medications
|
||||
SET taken_by_json = CASE
|
||||
WHEN taken_by IS NOT NULL AND taken_by != ''
|
||||
THEN json_array(taken_by)
|
||||
ELSE '[]'
|
||||
END;
|
||||
|
||||
-- Note: We keep the old taken_by column for backwards compatibility during migration
|
||||
-- It can be dropped in a future migration once all code uses taken_by_json
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
|
||||
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false },
|
||||
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false },
|
||||
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false }
|
||||
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false },
|
||||
{ "idx": 16, "version": 1, "when": 1735400002, "tag": "0016_taken_by_json_array", "breakpoint": false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export const medications = sqliteTable("medications", {
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name", { length: 100 }).notNull(),
|
||||
genericName: text("generic_name", { length: 100 }),
|
||||
takenBy: text("taken_by", { length: 100 }),
|
||||
takenBy: text("taken_by", { length: 100 }), // Deprecated: use takenByJson
|
||||
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
|
||||
count: integer("count").notNull().default(0),
|
||||
strips: integer("strips").notNull().default(0),
|
||||
packCount: integer("pack_count").notNull().default(1),
|
||||
|
||||
@@ -21,7 +21,7 @@ const blisterSchema = z.object({
|
||||
const medicationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
genericName: z.string().trim().max(100).nullable().optional(),
|
||||
takenBy: 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),
|
||||
stripsPerPack: z.number().int().min(1).default(1),
|
||||
tabsPerStrip: z.number().int().min(1).default(1),
|
||||
@@ -53,6 +53,16 @@ function parseBlisters(row: typeof medications.$inferSelect) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function medicationRoutes(app: FastifyInstance) {
|
||||
// All medication routes require auth
|
||||
app.addHook("preHandler", requireAuth);
|
||||
@@ -81,7 +91,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genericName: row.genericName,
|
||||
takenBy: row.takenBy,
|
||||
takenBy: parseTakenByJson(row.takenByJson),
|
||||
count: row.count,
|
||||
strips: row.strips,
|
||||
stripSize: row.stripSize,
|
||||
@@ -108,6 +118,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
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));
|
||||
const takenByJson = JSON.stringify(takenBy || []);
|
||||
|
||||
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
|
||||
|
||||
@@ -117,7 +128,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
userId,
|
||||
name,
|
||||
genericName: genericName || null,
|
||||
takenBy: takenBy || null,
|
||||
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
|
||||
takenByJson,
|
||||
count: derivedCount,
|
||||
strips: stripsPerPack,
|
||||
stripSize: tabsPerStrip,
|
||||
@@ -139,7 +151,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
id: inserted.id,
|
||||
name: inserted.name,
|
||||
genericName: inserted.genericName,
|
||||
takenBy: inserted.takenBy,
|
||||
takenBy: parseTakenByJson(inserted.takenByJson),
|
||||
count: inserted.count,
|
||||
strips: inserted.strips,
|
||||
stripSize: inserted.stripSize,
|
||||
@@ -173,6 +185,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
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));
|
||||
const takenByJson = JSON.stringify(takenBy || []);
|
||||
|
||||
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
|
||||
|
||||
@@ -181,7 +194,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
.set({
|
||||
name,
|
||||
genericName: genericName || null,
|
||||
takenBy: takenBy || null,
|
||||
takenBy: (takenBy && takenBy.length > 0) ? takenBy[0] : null, // Backwards compat
|
||||
takenByJson,
|
||||
count: derivedCount,
|
||||
strips: stripsPerPack,
|
||||
stripSize: tabsPerStrip,
|
||||
@@ -207,7 +221,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
id: result[0].id,
|
||||
name: result[0].name,
|
||||
genericName: result[0].genericName,
|
||||
takenBy: result[0].takenBy,
|
||||
takenBy: parseTakenByJson(result[0].takenByJson),
|
||||
count: result[0].count,
|
||||
strips: result[0].strips,
|
||||
stripSize: result[0].stripSize,
|
||||
|
||||
+39
-20
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { randomBytes } from "crypto";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
@@ -35,6 +35,17 @@ async function getUserId(request: any, reply: any): Promise<number> {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
// Helper to parse takenByJson
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Share Routes
|
||||
// =============================================================================
|
||||
@@ -70,13 +81,15 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Get user settings for stock thresholds
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
|
||||
|
||||
// Get medications for this user filtered by takenBy
|
||||
const meds = await db.select().from(medications).where(
|
||||
and(
|
||||
eq(medications.userId, share.userId),
|
||||
eq(medications.takenBy, share.takenBy)
|
||||
)
|
||||
);
|
||||
// Get medications for this user filtered by takenBy (search in JSON array)
|
||||
// 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
|
||||
const meds = allMeds.filter((med) => {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
return takenByArray.includes(share.takenBy);
|
||||
});
|
||||
|
||||
// Parse blisters and build schedule data
|
||||
const medicationsWithBlisters = meds.map((med) => {
|
||||
@@ -135,15 +148,14 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
|
||||
const { takenBy, scheduleDays } = parsed.data;
|
||||
|
||||
// Check if user has medications for this takenBy
|
||||
const [existingMed] = await db.select().from(medications).where(
|
||||
and(
|
||||
eq(medications.userId, userId),
|
||||
eq(medications.takenBy, takenBy)
|
||||
)
|
||||
);
|
||||
// Check if user has medications for this takenBy (search in JSON array)
|
||||
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);
|
||||
});
|
||||
|
||||
if (!existingMed) {
|
||||
if (medsForPerson.length === 0) {
|
||||
return reply.status(400).send({
|
||||
error: "No medications found for this person",
|
||||
code: "NO_MEDICATIONS",
|
||||
@@ -182,14 +194,21 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
|
||||
// Get all unique takenBy values for this user
|
||||
const meds = await db.select({ takenBy: medications.takenBy })
|
||||
// Get all unique takenBy values for this user (from JSON arrays)
|
||||
const meds = await db.select({ takenByJson: medications.takenByJson })
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId));
|
||||
|
||||
const uniquePeople = [...new Set(meds.map((m) => m.takenBy).filter(Boolean))] as string[];
|
||||
// Collect all unique person names from all takenByJson arrays
|
||||
const allPeople = new Set<string>();
|
||||
for (const med of meds) {
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
for (const person of takenByArray) {
|
||||
if (person) allPeople.add(person);
|
||||
}
|
||||
}
|
||||
|
||||
return { people: uniquePeople };
|
||||
return { people: [...allPeople].sort() };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ function getTimezone(): string {
|
||||
return process.env.TZ || "UTC";
|
||||
}
|
||||
|
||||
// Parse takenByJson to array of strings
|
||||
function parseTakenByJson(takenByJson: string | null | undefined): string[] {
|
||||
if (!takenByJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(takenByJson);
|
||||
return Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string" && s.trim()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const intakeReminderStateFile = resolve(process.cwd(), "data", "intake-reminder-state.json");
|
||||
|
||||
function loadIntakeReminderState(): IntakeReminderState {
|
||||
@@ -63,11 +74,11 @@ type UpcomingIntake = {
|
||||
usage: number;
|
||||
intakeTime: Date;
|
||||
intakeTimeStr: string;
|
||||
takenBy: string | null;
|
||||
takenBy: string[]; // Changed to array
|
||||
pillWeightMg: number | null;
|
||||
};
|
||||
|
||||
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string | null, pillWeightMg: number | null, locale: string): UpcomingIntake[] {
|
||||
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string[], pillWeightMg: number | null, locale: string): UpcomingIntake[] {
|
||||
const now = Date.now();
|
||||
// Window to detect if "now" is the right time to send reminder
|
||||
// We check if the notify time (intake - 15min) falls within current minute ±1
|
||||
@@ -153,10 +164,11 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
||||
return pillText;
|
||||
};
|
||||
|
||||
// Helper to format medication name with takenBy
|
||||
// Helper to format medication name with takenBy (array of names)
|
||||
const formatMedName = (intake: UpcomingIntake): string => {
|
||||
if (intake.takenBy) {
|
||||
return `${intake.medName} <span style="color: #6b7280; font-size: 12px;">${t(tr.intakeReminder.takenBy, { name: intake.takenBy })}</span>`;
|
||||
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>`;
|
||||
}
|
||||
return intake.medName;
|
||||
};
|
||||
@@ -226,7 +238,7 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[],
|
||||
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
|
||||
|
||||
${intakes.map((i) => {
|
||||
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
|
||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||
return `${i.medName}${takenByStr}: ${formatDosagePlain(i)} - ${i.intakeTimeStr}`;
|
||||
}).join("\n")}
|
||||
|
||||
@@ -304,7 +316,8 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
// Find all upcoming intakes across all medications for this user
|
||||
for (const med of medsWithReminders) {
|
||||
const blisters = parseBlisters(med);
|
||||
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
|
||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, takenByArray, med.pillWeightMg, locale);
|
||||
allUpcoming.push(...upcoming);
|
||||
}
|
||||
|
||||
@@ -343,7 +356,7 @@ async function checkAndSendIntakeRemindersForUser(
|
||||
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
|
||||
const message = newReminders
|
||||
.map((i) => {
|
||||
const takenByStr = i.takenBy ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy })}` : "";
|
||||
const takenByStr = i.takenBy.length > 0 ? ` ${t(tr.intakeReminder.takenBy, { name: i.takenBy.join(", ") })}` : "";
|
||||
let dosage = `${i.usage} ${i.usage === 1 ? tr.common.pill : tr.common.pills}`;
|
||||
if (i.pillWeightMg) {
|
||||
const totalMg = i.usage * i.pillWeightMg;
|
||||
|
||||
+117
-39
@@ -13,7 +13,7 @@ type Medication = {
|
||||
id: number;
|
||||
name: string;
|
||||
genericName?: string | null;
|
||||
takenBy?: string | null;
|
||||
takenBy: string[]; // Changed from string | null to array
|
||||
count: number;
|
||||
strips: number;
|
||||
stripSize: number;
|
||||
@@ -47,7 +47,7 @@ type FormBlister = { usage: string; every: string; startDate: string; startTime:
|
||||
type FormState = {
|
||||
name: string;
|
||||
genericName: string;
|
||||
takenBy: string;
|
||||
takenBy: string[]; // Changed from string to array
|
||||
packCount: string;
|
||||
stripsPerPack: string;
|
||||
tabsPerStrip: string;
|
||||
@@ -69,7 +69,7 @@ const defaultBlister = (): FormBlister => {
|
||||
};
|
||||
};
|
||||
|
||||
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
|
||||
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: [], packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
|
||||
|
||||
// Field validation limits (must match backend)
|
||||
const FIELD_LIMITS = {
|
||||
@@ -216,13 +216,16 @@ function AppContent() {
|
||||
});
|
||||
|
||||
// Validate form fields
|
||||
const validateField = (field: keyof FieldErrors, value: string): string | undefined => {
|
||||
const validateField = (field: keyof FieldErrors, value: string | string[]): string | undefined => {
|
||||
const limits = FIELD_LIMITS[field];
|
||||
if (field === 'name' && (!value || value.trim().length === 0)) {
|
||||
// Skip validation for takenBy array (individual items validated on add)
|
||||
if (field === 'takenBy') return undefined;
|
||||
const strValue = typeof value === 'string' ? value : '';
|
||||
if (field === 'name' && (!strValue || strValue.trim().length === 0)) {
|
||||
return t('common.validation.required');
|
||||
}
|
||||
if ('max' in limits && value.length > limits.max) {
|
||||
return t('common.validation.maxLength', { max: limits.max, current: value.length });
|
||||
if ('max' in limits && strValue.length > limits.max) {
|
||||
return t('common.validation.maxLength', { max: limits.max, current: strValue.length });
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -235,12 +238,12 @@ function AppContent() {
|
||||
// Validate all fields when form changes
|
||||
useEffect(() => {
|
||||
const errors: FieldErrors = {};
|
||||
(['name', 'genericName', 'takenBy', 'notes'] as const).forEach(field => {
|
||||
(['name', 'genericName', 'notes'] as const).forEach(field => {
|
||||
const error = validateField(field, form[field]);
|
||||
if (error) errors[field] = error;
|
||||
});
|
||||
setFieldErrors(errors);
|
||||
}, [form.name, form.genericName, form.takenBy, form.notes, t]);
|
||||
}, [form.name, form.genericName, form.notes, t]);
|
||||
|
||||
// Load user-specific planner data when user changes
|
||||
useEffect(() => {
|
||||
@@ -320,6 +323,8 @@ function AppContent() {
|
||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
// Tag input state for "Taken By" field
|
||||
const [takenByInput, setTakenByInput] = useState("");
|
||||
// Share dialog state
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
@@ -482,6 +487,12 @@ function AppContent() {
|
||||
const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses), [meds, schedule.events, i18n.language, settings.reminderDaysBefore, settings.stockCalculationMode, takenDoses]);
|
||||
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
|
||||
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
|
||||
|
||||
// Get all unique people from medications for autocomplete suggestions
|
||||
const existingPeople = useMemo(() => {
|
||||
const allPeople = meds.flatMap(m => m.takenBy || []);
|
||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
||||
}, [meds]);
|
||||
|
||||
// Get worst stock status for a day's medications (for coloring day blocks)
|
||||
const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => {
|
||||
@@ -536,7 +547,7 @@ function AppContent() {
|
||||
setLoading(true);
|
||||
fetch("/api/medications")
|
||||
.then((res) => res.json())
|
||||
.then((data: Medication[]) => setMeds(data))
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
@@ -771,10 +782,11 @@ function AppContent() {
|
||||
|
||||
function startEdit(med: Medication) {
|
||||
setEditingId(med.id);
|
||||
setTakenByInput(""); // Clear tag input when starting edit
|
||||
setForm({
|
||||
name: med.name,
|
||||
genericName: med.genericName ?? "",
|
||||
takenBy: med.takenBy ?? "",
|
||||
takenBy: med.takenBy || [], // Already an array from API
|
||||
packCount: String(med.packCount ?? 1),
|
||||
stripsPerPack: String(med.stripsPerPack ?? med.strips ?? 1),
|
||||
tabsPerStrip: String(med.tabsPerStrip ?? med.stripSize ?? 1),
|
||||
@@ -801,6 +813,7 @@ function AppContent() {
|
||||
setShowEditModal(false);
|
||||
setPendingImage(null);
|
||||
setPendingImagePreview(null);
|
||||
setTakenByInput("");
|
||||
setForm(defaultForm());
|
||||
}
|
||||
|
||||
@@ -808,6 +821,29 @@ function AppContent() {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
// Tag input helpers for "Taken By" field
|
||||
function addTakenByPerson(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
||||
setForm(prev => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
}
|
||||
|
||||
function removeTakenByPerson(name: string) {
|
||||
setForm(prev => ({ ...prev, takenBy: prev.takenBy.filter(p => p !== name) }));
|
||||
}
|
||||
|
||||
function handleTakenByKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTakenByPerson(takenByInput);
|
||||
} else if (e.key === 'Backspace' && !takenByInput && form.takenBy.length > 0) {
|
||||
// Remove last tag on backspace when input is empty
|
||||
removeTakenByPerson(form.takenBy[form.takenBy.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMedication(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) return;
|
||||
@@ -816,7 +852,7 @@ function AppContent() {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
genericName: form.genericName.trim() || null,
|
||||
takenBy: form.takenBy.trim() || null,
|
||||
takenBy: form.takenBy.filter(name => name.trim()), // Send array, filter empty strings
|
||||
packCount: Number(form.packCount) || 0,
|
||||
stripsPerPack: Math.max(1, Number(form.stripsPerPack) || 1),
|
||||
tabsPerStrip: Math.max(1, Number(form.tabsPerStrip) || 1),
|
||||
@@ -887,8 +923,9 @@ function AppContent() {
|
||||
setShareSelectedPerson("");
|
||||
setShareSelectedDays(30);
|
||||
|
||||
// Get unique takenBy people from medications
|
||||
const uniquePeople = [...new Set(meds.map(m => m.takenBy).filter(Boolean))] as string[];
|
||||
// Get unique takenBy people from all medications (flatten arrays)
|
||||
const allPeople = meds.flatMap(m => m.takenBy || []);
|
||||
const uniquePeople = [...new Set(allPeople)].filter(Boolean).sort();
|
||||
setSharePeople(uniquePeople);
|
||||
if (uniquePeople.length > 0) {
|
||||
setShareSelectedPerson(uniquePeople[0]);
|
||||
@@ -1133,7 +1170,9 @@ function AppContent() {
|
||||
<span data-label={t('table.name')} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && <span className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}</span>}
|
||||
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
|
||||
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(person); }}>{person}</span>
|
||||
))}
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}
|
||||
@@ -1201,7 +1240,9 @@ function AppContent() {
|
||||
<span className="med-name-line">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && <span className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}</span>}
|
||||
{med?.takenBy && med.takenBy.length > 0 && med.takenBy.map((person) => (
|
||||
<span key={person} className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(person); }}>{person}</span>
|
||||
))}
|
||||
</span>
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
@@ -1315,7 +1356,7 @@ function AppContent() {
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
@@ -1414,7 +1455,7 @@ function AppContent() {
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
@@ -1504,12 +1545,28 @@ function AppContent() {
|
||||
</label>
|
||||
<label className={fieldErrors.takenBy ? 'has-error' : ''}>
|
||||
{t('form.takenBy')}
|
||||
<input
|
||||
value={form.takenBy}
|
||||
onChange={(e) => setForm({ ...form, takenBy: e.target.value })}
|
||||
placeholder={t('form.placeholders.takenBy')}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
/>
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
onKeyDown={handleTakenByKeyDown}
|
||||
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
|
||||
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions"
|
||||
/>
|
||||
<datalist id="takenby-suggestions">
|
||||
{existingPeople.filter(p => !form.takenBy.includes(p)).map(person => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
@@ -2148,7 +2205,7 @@ function AppContent() {
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
@@ -2202,7 +2259,7 @@ function AppContent() {
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
@@ -2243,7 +2300,7 @@ function AppContent() {
|
||||
<div className="med-detail-titles">
|
||||
<h2>{selectedMed.name}</h2>
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && <span className="med-taken-by">{t('modal.for')} {selectedMed.takenBy}</span>}
|
||||
{selectedMed.takenBy && selectedMed.takenBy.length > 0 && <span className="med-taken-by">{t('modal.for')} {selectedMed.takenBy.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="med-detail-section">
|
||||
@@ -2404,7 +2461,7 @@ function AppContent() {
|
||||
</div>
|
||||
|
||||
<div className="user-meds-list">
|
||||
{meds.filter(m => m.takenBy === selectedUser).map((med) => {
|
||||
{meds.filter(m => m.takenBy.includes(selectedUser)).map((med) => {
|
||||
const medCoverage = coverage.all.find(c => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(med.count);
|
||||
@@ -2426,7 +2483,7 @@ function AppContent() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{meds.filter(m => m.takenBy === selectedUser).length === 0 && (
|
||||
{meds.filter(m => m.takenBy.includes(selectedUser)).length === 0 && (
|
||||
<div className="user-meds-empty">{t('modal.noMedsForUser', { name: selectedUser })}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2546,12 +2603,28 @@ function AppContent() {
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.takenBy ? 'has-error' : ''}`}>
|
||||
{t('form.takenBy')}
|
||||
<input
|
||||
value={form.takenBy}
|
||||
onChange={(e) => setForm({ ...form, takenBy: e.target.value })}
|
||||
placeholder={t('form.placeholders.takenBy')}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
/>
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => removeTakenByPerson(person)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => setTakenByInput(e.target.value)}
|
||||
onKeyDown={handleTakenByKeyDown}
|
||||
onBlur={() => { if (takenByInput.trim()) addTakenByPerson(takenByInput); }}
|
||||
placeholder={form.takenBy.length === 0 ? t('form.placeholders.takenBy') : t('form.placeholders.addPerson')}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
/>
|
||||
<datalist id="takenby-suggestions-modal">
|
||||
{existingPeople.filter(p => !form.takenBy.includes(p)).map(person => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label>
|
||||
@@ -2749,7 +2822,7 @@ function generateICS(med: Medication) {
|
||||
const description = [
|
||||
`Medication: ${med.name}`,
|
||||
med.genericName ? `Generic: ${med.genericName}` : '',
|
||||
med.takenBy ? `For: ${med.takenBy}` : '',
|
||||
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(', ')}` : '',
|
||||
`Dosage: ${pillInfo}`,
|
||||
`Frequency: every ${interval} day${interval !== 1 ? 's' : ''}`,
|
||||
med.notes ? `Notes: ${med.notes}` : '',
|
||||
@@ -2793,6 +2866,8 @@ END:VCALENDAR`;
|
||||
|
||||
function buildSchedulePreview(meds: Medication[], locale: string, includePast: boolean = false) {
|
||||
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number; isPast: boolean }> = [];
|
||||
if (!Array.isArray(meds)) return { events, groups: [] };
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
|
||||
const end = new Date();
|
||||
@@ -2920,7 +2995,9 @@ function calculateCoverage(
|
||||
const now = Date.now();
|
||||
|
||||
const coverage: Coverage[] = meds.map((m) => {
|
||||
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
|
||||
// Multiply daily rate by number of people taking this medication
|
||||
const personCount = Math.max(1, m.takenBy?.length || 1);
|
||||
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
|
||||
|
||||
let consumed = 0;
|
||||
|
||||
@@ -3473,8 +3550,9 @@ function SharedSchedule() {
|
||||
const totalCount = med.count ?? 0;
|
||||
const taken = takenByMed[med.name] || 0;
|
||||
const currentCount = Math.max(0, totalCount - taken);
|
||||
// Calculate daily usage from blisters
|
||||
const dailyUsage = med.blisters.reduce((sum, b) => sum + (b.usage / b.every), 0);
|
||||
// Calculate daily usage from blisters, multiplied by number of people
|
||||
const personCount = Math.max(1, med.takenBy?.length || 1);
|
||||
const dailyUsage = med.blisters.reduce((sum, b) => sum + (b.usage / b.every), 0) * personCount;
|
||||
const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null;
|
||||
coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage };
|
||||
|
||||
|
||||
@@ -110,7 +110,8 @@
|
||||
"placeholders": {
|
||||
"commercial": "z.B. Ozempic",
|
||||
"generic": "z.B. Semaglutid (optional)",
|
||||
"takenBy": "z.B. Max, Anna (optional)",
|
||||
"takenBy": "Name eingeben und Enter drücken",
|
||||
"addPerson": "Weitere Person hinzufügen...",
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"placeholders": {
|
||||
"commercial": "e.g. Ozempic",
|
||||
"generic": "e.g. Semaglutide (optional)",
|
||||
"takenBy": "e.g. John, Sarah (optional)",
|
||||
"takenBy": "Type name and press Enter",
|
||||
"addPerson": "Add another person...",
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
|
||||
@@ -548,6 +548,75 @@ textarea.auto-resize {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Tag input for multi-value fields (e.g., Taken By) */
|
||||
.tag-input-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem;
|
||||
min-height: 44px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--input-radius);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.tag-input-container:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(var(--accent-rgb, 59, 130, 246), 0.15);
|
||||
}
|
||||
|
||||
.tag-input-container input {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0.25rem !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-input-container input:focus {
|
||||
outline: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.char-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.7rem;
|
||||
|
||||
Reference in New Issue
Block a user