From 65f007732a4c392fe1844de4b1c6f51a741bc6cb Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 27 Dec 2025 21:46:04 +0100 Subject: [PATCH] feat(stock-status): implement stock status indicators for medication days and update styles --- backend/src/routes/share.ts | 10 ++- frontend/src/App.tsx | 141 ++++++++++++++++++++++++++++++++++-- frontend/src/styles.css | 13 ++++ 3 files changed, 157 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index a4db41b..893a477 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import { randomBytes } from "crypto"; import { db } from "../db/client.js"; -import { medications, shareTokens } from "../db/schema.js"; +import { medications, shareTokens, userSettings } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; import { requireAuth, optionalAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; @@ -48,6 +48,9 @@ export async function shareRoutes(app: FastifyInstance) { return reply.notFound("Share link not found"); } + // 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( @@ -78,6 +81,8 @@ export async function shareRoutes(app: FastifyInstance) { genericName: med.genericName, pillWeightMg: med.pillWeightMg, imageUrl: med.imageUrl, + count: med.count, + tabsPerStrip: med.tabsPerStrip, blisters, }; }); @@ -86,6 +91,9 @@ export async function shareRoutes(app: FastifyInstance) { takenBy: share.takenBy, scheduleDays: share.scheduleDays, medications: medicationsWithBlisters, + stockThresholds: { + lowStockDays: settings?.lowStockDays ?? 30, + }, }; }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed65206..fe2cdf4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -968,7 +968,7 @@ function AppContent() { {coverage.low.map((row) => { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); - const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; + const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock( Math.round(row.medsLeft), med?.tabsPerStrip ?? 1, @@ -1026,7 +1026,7 @@ function AppContent() { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); - const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; + const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock( Math.round(row.medsLeft), med?.tabsPerStrip ?? 1, @@ -1154,6 +1154,18 @@ function AppContent() { const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; + // Calculate worst stock status for this day + const dayStockStatuses = day.meds.map((item) => { + const medCoverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + if (willBeOutOfStock) return "danger"; + if (!medCoverage) return "success"; + const status = getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings); + return status.className; + }); + const worstStatus = dayStockStatuses.includes("danger") ? "danger" : dayStockStatuses.includes("warning") ? "warning" : "success"; + // Check if this is today, past, or future const today = new Date(); today.setHours(0, 0, 0, 0); @@ -1168,7 +1180,7 @@ function AppContent() { const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; return ( -
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} @@ -1989,7 +2001,7 @@ function AppContent() { const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : selectedMed.count; const totalStock = (selectedMed.packCount ?? 1) * (selectedMed.stripsPerPack ?? 1) * (selectedMed.tabsPerStrip ?? 1) + (selectedMed.looseTablets ?? 0); const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; - const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : ""; + const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text"; const stock = getBlisterStock( currentStock, selectedMed.tabsPerStrip ?? 1, @@ -2696,6 +2708,8 @@ type SharedMedication = { genericName?: string | null; pillWeightMg?: number | null; imageUrl?: string | null; + count?: number; + tabsPerStrip?: number; blisters: Blister[]; }; @@ -2703,6 +2717,9 @@ type SharedScheduleData = { takenBy: string; scheduleDays: number; medications: SharedMedication[]; + stockThresholds?: { + lowStockDays: number; + }; }; function SharedSchedule() { @@ -2941,6 +2958,72 @@ function SharedSchedule() { const pastDays = useMemo(() => schedule.filter(d => d.isPast), [schedule]); const futureDays = useMemo(() => schedule.filter(d => !d.isPast), [schedule]); + // Calculate coverage for stock status colors (matches main app logic) + // This needs to account for taken doses and calculate depletion time + const { coverageByMed, depletionByMed } = useMemo(() => { + if (!data) return { coverageByMed: {}, depletionByMed: {} }; + const coverage: Record = {}; + const depletion: Record = {}; + + // Calculate total pills taken per medication from takenDoses + const takenByMed: Record = {}; + for (const dose of schedule.flatMap(d => d.meds.flatMap(m => m.doses))) { + if (takenDoses.has(dose.id)) { + takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage; + } + } + + for (const med of data.medications) { + 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); + const daysLeft = dailyUsage > 0 ? currentCount / dailyUsage : null; + coverage[med.name] = { daysLeft, medsLeft: currentCount, dailyUsage }; + + // Calculate depletion time (when medication will run out) + if (dailyUsage > 0 && currentCount > 0) { + const daysUntilEmpty = currentCount / dailyUsage; + depletion[med.name] = Date.now() + daysUntilEmpty * 24 * 60 * 60 * 1000; + } else if (currentCount <= 0) { + depletion[med.name] = Date.now(); // Already empty + } else { + depletion[med.name] = null; // No usage schedule + } + } + return { coverageByMed: coverage, depletionByMed: depletion }; + }, [data, schedule, takenDoses]); + + // Stock thresholds from user settings (provided by API) or defaults + const lowStockDays = data?.stockThresholds?.lowStockDays ?? 30; + + // Get worst stock status for a day's medications (matches main app logic with depletion) + const getDayStockStatus = (meds: { medName: string; lastWhen: number }[]) => { + const statuses = meds.map((item) => { + const coverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + + // Will be out of stock by this day? + if (typeof depletionTime === "number" && item.lastWhen > depletionTime) { + return "danger"; + } + + if (!coverage) return "success"; + const { daysLeft, medsLeft } = coverage; + + // Currently out of stock + if (medsLeft <= 0 || daysLeft === 0) return "danger"; + // No schedule (can't calculate) + if (daysLeft === null) return "success"; + // Low stock: < lowStockDays (warning) + if (daysLeft < lowStockDays) return "warning"; + // Normal/High stock + return "success"; + }); + return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; + }; + if (loading) { return (
@@ -2999,8 +3082,11 @@ function SharedSchedule() { const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; + // Calculate stock status for this day + const worstStatus = getDayStockStatus(day.meds); + return ( -
+
toggleDayCollapse(day.dateStr, true)} @@ -3018,6 +3104,25 @@ function SharedSchedule() {
{!isCollapsed && day.meds.map((item) => { const med = data.medications.find(m => m.name === item.medName); + const medCoverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + + // Calculate status for this medication on this day + let status: { className: string; label: string } | null = null; + if (willBeOutOfStock) { + status = { className: "danger", label: "status.outOfStock" }; + } else if (medCoverage) { + const { daysLeft, medsLeft } = medCoverage; + if (medsLeft <= 0 || daysLeft === 0) { + status = { className: "danger", label: "status.outOfStock" }; + } else if (daysLeft !== null && daysLeft < lowStockDays) { + status = { className: "warning", label: "status.lowStock" }; + } else { + status = { className: "success", label: "status.normal" }; + } + } + const allTaken = item.doses.every((d) => takenDoses.has(d.id)); return (
@@ -3034,6 +3139,7 @@ function SharedSchedule() {
{item.total} {t('common.pills')} {t('common.total')} + {status && {t(status.label)}}
@@ -3068,6 +3174,9 @@ function SharedSchedule() { const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; + // Calculate stock status for this day + const worstStatus = getDayStockStatus(day.meds); + // Check if this is today const today = new Date(); today.setHours(0, 0, 0, 0); @@ -3082,7 +3191,7 @@ function SharedSchedule() { const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; return ( -
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} @@ -3100,6 +3209,25 @@ function SharedSchedule() {
{!isCollapsed && day.meds.map((item) => { const med = data.medications.find(m => m.name === item.medName); + const medCoverage = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime; + + // Calculate status for this medication on this day + let status: { className: string; label: string } | null = null; + if (willBeOutOfStock) { + status = { className: "danger", label: "status.outOfStock" }; + } else if (medCoverage) { + const { daysLeft, medsLeft } = medCoverage; + if (medsLeft <= 0 || daysLeft === 0) { + status = { className: "danger", label: "status.outOfStock" }; + } else if (daysLeft !== null && daysLeft < lowStockDays) { + status = { className: "warning", label: "status.lowStock" }; + } else { + status = { className: "success", label: "status.normal" }; + } + } + const allTaken = item.doses.every((d) => takenDoses.has(d.id)); return (
@@ -3116,6 +3244,7 @@ function SharedSchedule() {
{item.total} {t('common.pills')} {t('common.total')} + {status && {t(status.label)}}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 72a4ae7..e1e810e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -451,6 +451,15 @@ textarea { .day-block.today .day-divider { color: var(--accent); } .day-block.all-taken { border-color: rgba(57, 217, 138, 0.3); } .day-block.today.all-taken { border-color: var(--success); background: linear-gradient(135deg, rgba(57, 217, 138, 0.08) 0%, rgba(57, 217, 138, 0.02) 100%); } + +/* Stock status colors for day blocks */ +.day-block.stock-success { border-color: rgba(57, 217, 138, 0.3); } +.day-block.stock-success .day-divider { color: var(--success); opacity: 0.8; } +.day-block.stock-warning { border-color: rgba(252, 211, 77, 0.35); } +.day-block.stock-warning .day-divider { color: var(--warning); opacity: 0.8; } +.day-block.stock-danger { border-color: rgba(255, 94, 94, 0.35); } +.day-block.stock-danger .day-divider { color: var(--danger); opacity: 0.8; } + .day-divider { margin: 0 0 0.75rem; padding-bottom: 0.5rem; @@ -2182,6 +2191,10 @@ textarea { color: var(--text-primary); } +.med-detail-value.success-text { color: var(--success); } +.med-detail-value.warning-text { color: var(--warning); } +.med-detail-value.danger-text { color: var(--danger); } + .med-detail-schedules { display: flex; flex-direction: column;