feat(stock-status): implement stock status indicators for medication days and update styles

This commit is contained in:
Daniel Volz
2025-12-27 21:46:04 +01:00
parent 57377aeead
commit 65f007732a
3 changed files with 157 additions and 7 deletions
+9 -1
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { db } from "../db/client.js"; 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 { eq, and } from "drizzle-orm";
import { requireAuth, optionalAuth } from "../plugins/auth.js"; import { requireAuth, optionalAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
@@ -48,6 +48,9 @@ export async function shareRoutes(app: FastifyInstance) {
return reply.notFound("Share link not found"); 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 // Get medications for this user filtered by takenBy
const meds = await db.select().from(medications).where( const meds = await db.select().from(medications).where(
and( and(
@@ -78,6 +81,8 @@ export async function shareRoutes(app: FastifyInstance) {
genericName: med.genericName, genericName: med.genericName,
pillWeightMg: med.pillWeightMg, pillWeightMg: med.pillWeightMg,
imageUrl: med.imageUrl, imageUrl: med.imageUrl,
count: med.count,
tabsPerStrip: med.tabsPerStrip,
blisters, blisters,
}; };
}); });
@@ -86,6 +91,9 @@ export async function shareRoutes(app: FastifyInstance) {
takenBy: share.takenBy, takenBy: share.takenBy,
scheduleDays: share.scheduleDays, scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters, medications: medicationsWithBlisters,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
},
}; };
}); });
+135 -6
View File
@@ -968,7 +968,7 @@ function AppContent() {
{coverage.low.map((row) => { {coverage.low.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name); 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( const stock = getBlisterStock(
Math.round(row.medsLeft), Math.round(row.medsLeft),
med?.tabsPerStrip ?? 1, med?.tabsPerStrip ?? 1,
@@ -1026,7 +1026,7 @@ function AppContent() {
const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
const med = meds.find(m => m.name === row.name); const med = meds.find(m => m.name === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); 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( const stock = getBlisterStock(
Math.round(row.medsLeft), Math.round(row.medsLeft),
med?.tabsPerStrip ?? 1, med?.tabsPerStrip ?? 1,
@@ -1154,6 +1154,18 @@ function AppContent() {
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; 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 // Check if this is today, past, or future
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@@ -1168,7 +1180,7 @@ function AppContent() {
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return ( return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""}`}> <div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} ${worstStatus ? `stock-${worstStatus}` : ""}`}>
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
@@ -1989,7 +2001,7 @@ function AppContent() {
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : selectedMed.count; const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : selectedMed.count;
const totalStock = (selectedMed.packCount ?? 1) * (selectedMed.stripsPerPack ?? 1) * (selectedMed.tabsPerStrip ?? 1) + (selectedMed.looseTablets ?? 0); 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 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( const stock = getBlisterStock(
currentStock, currentStock,
selectedMed.tabsPerStrip ?? 1, selectedMed.tabsPerStrip ?? 1,
@@ -2696,6 +2708,8 @@ type SharedMedication = {
genericName?: string | null; genericName?: string | null;
pillWeightMg?: number | null; pillWeightMg?: number | null;
imageUrl?: string | null; imageUrl?: string | null;
count?: number;
tabsPerStrip?: number;
blisters: Blister[]; blisters: Blister[];
}; };
@@ -2703,6 +2717,9 @@ type SharedScheduleData = {
takenBy: string; takenBy: string;
scheduleDays: number; scheduleDays: number;
medications: SharedMedication[]; medications: SharedMedication[];
stockThresholds?: {
lowStockDays: number;
};
}; };
function SharedSchedule() { function SharedSchedule() {
@@ -2941,6 +2958,72 @@ function SharedSchedule() {
const pastDays = useMemo(() => schedule.filter(d => d.isPast), [schedule]); const pastDays = useMemo(() => schedule.filter(d => d.isPast), [schedule]);
const futureDays = 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<string, { daysLeft: number | null; medsLeft: number; dailyUsage: number }> = {};
const depletion: Record<string, number | null> = {};
// Calculate total pills taken per medication from takenDoses
const takenByMed: Record<string, number> = {};
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) { if (loading) {
return ( return (
<div className="shared-schedule-page"> <div className="shared-schedule-page">
@@ -2999,8 +3082,11 @@ function SharedSchedule() {
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
// Calculate stock status for this day
const worstStatus = getDayStockStatus(day.meds);
return ( return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}> <div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)} onClick={() => toggleDayCollapse(day.dateStr, true)}
@@ -3018,6 +3104,25 @@ function SharedSchedule() {
</div> </div>
{!isCollapsed && day.meds.map((item) => { {!isCollapsed && day.meds.map((item) => {
const med = data.medications.find(m => m.name === item.medName); 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)); const allTaken = item.doses.every((d) => takenDoses.has(d.id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -3034,6 +3139,7 @@ function SharedSchedule() {
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span> <span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
@@ -3068,6 +3174,9 @@ function SharedSchedule() {
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; 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 // Check if this is today
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@@ -3082,7 +3191,7 @@ function SharedSchedule() {
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed; const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return ( return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""}`}> <div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""} stock-${worstStatus}`}>
<div <div
className="day-divider clickable" className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)} onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
@@ -3100,6 +3209,25 @@ function SharedSchedule() {
</div> </div>
{!isCollapsed && day.meds.map((item) => { {!isCollapsed && day.meds.map((item) => {
const med = data.medications.find(m => m.name === item.medName); 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)); const allTaken = item.doses.every((d) => takenDoses.has(d.id));
return ( return (
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}> <div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
@@ -3116,6 +3244,7 @@ function SharedSchedule() {
</div> </div>
<div className="tag-row"> <div className="tag-row">
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span> <span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
{status && <span className={`tag ${status.className}`}>{t(status.label)}</span>}
</div> </div>
</div> </div>
<div className="doses-col"> <div className="doses-col">
+13
View File
@@ -451,6 +451,15 @@ textarea {
.day-block.today .day-divider { color: var(--accent); } .day-block.today .day-divider { color: var(--accent); }
.day-block.all-taken { border-color: rgba(57, 217, 138, 0.3); } .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%); } .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 { .day-divider {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -2182,6 +2191,10 @@ textarea {
color: var(--text-primary); 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 { .med-detail-schedules {
display: flex; display: flex;
flex-direction: column; flex-direction: column;