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;