Compare commits

..

8 Commits

Author SHA1 Message Date
Daniel Volz 1c50e9395f fix: past days UX improvements and clear missed logic (#152)
- Render past days above 'Show past days' toggle when expanded
- Auto-scroll to today when expanding past days
- Remove blue hover color from past day dividers (use opacity instead)
- Fix 'All taken' logic: green only for manually taken doses
- Yellow styling stays for days with non-taken doses (even after dismissal)
- Warning icon disappears after 'Clear missed' (dismissed doses not counted)
2026-02-10 16:42:23 +01:00
Daniel Volz e335729399 fix: prevent badge workflow push rejection on concurrent runs (#151)
Add git pull --rebase before push to handle cases where main moved
between checkout and push (e.g., two Docker builds triggering badge
updates simultaneously). Also add concurrency group to cancel
duplicate runs.
2026-02-09 21:09:45 +01:00
github-actions[bot] 399d63caec chore: update test count badges [skip ci] 2026-02-09 20:02:55 +00:00
Daniel Volz ffbe957f41 chore: release v1.10.1 (#150) 2026-02-09 21:01:42 +01:00
Daniel Volz 749e92b135 fix: bottle total capacity backward compatibility (#149)
* fix: bottle total capacity shows dash for old medications

Old medications created before the totalPills column was added had
totalPills=null. This caused two issues:

1. MedDetailModal showed '—' instead of the actual capacity in the
   Package Details section (while the Stock section showed correct values)
2. Edit form showed an empty Total Capacity field on mobile

Fix: Fall back to packageSize (looseTablets for bottles) when totalPills
is null, matching the behavior already used in MedicationsPage and the
stock display section.

Added test for backward compatibility scenario.

* chore: retrigger CI
2026-02-09 20:59:30 +01:00
Daniel Volz 5093f96e8a fix: intake reminder catch-up for missed advance notification window (#148)
When the scheduler missed the exact notification minute (due to system sleep,
high load, or GC pauses), the advance reminder was permanently lost. A dead zone
existed between the notify time and the intake time where neither advance nor
missed-intake logic would trigger.

Changes:
- getUpcomingIntakes now catches up intakes where the notify window passed but
  the intake time is still in the future
- Seeding logic sends a catch-up notification for recently missed intakes
  (within grace period) instead of silently seeding state
- Added 4 tests covering catch-up scenarios
2026-02-09 20:58:08 +01:00
github-actions[bot] bd6eccdb22 chore: update test count badges [skip ci] 2026-02-09 18:37:26 +00:00
Daniel Volz 9d289d45c9 chore: release v1.10.0 (#147) 2026-02-09 19:36:04 +01:00
15 changed files with 374 additions and 168 deletions
+8
View File
@@ -10,6 +10,11 @@ on:
permissions:
contents: write
# Prevent parallel badge workflows from racing each other
concurrency:
group: update-test-badges
cancel-in-progress: true
jobs:
update-badges:
name: Update Test Count Badges
@@ -99,5 +104,8 @@ jobs:
echo "No badge changes to commit"
else
git commit -m "chore: update test count badges [skip ci]"
# Rebase on latest main to avoid push rejection when concurrent
# badge workflows or other [skip ci] commits land between checkout and push
git pull --rebase origin main
git push
fi
+2 -2
View File
@@ -18,8 +18,8 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-504%2F504-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-662%2F662-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-518%2F518-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-709%2F709-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.9.0",
"version": "1.10.1",
"private": true,
"type": "module",
"scripts": {
@@ -416,19 +416,29 @@ async function checkAndSendIntakeRemindersForUser(
if (!existingEntry) {
// New dose - send first reminder
if (isIntakePast) {
// Intake time already passed and we have no state entry — this means the scheduler
// was not aware of this intake before it happened (e.g., user just enabled reminders).
// Seed the state as already handled so repeat reminders can track from here,
// but do NOT send a notification for intakes that were missed before tracking started.
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 0,
advanceSent: false,
};
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — first detection)`
);
// Intake time already passed and we have no state entry. Check how recently it was missed.
const minutesSinceIntake = (nowMs - intakeTimeMs) / 60000;
const gracePeriodMinutes = (settings.reminderRepeatIntervalMinutes ?? 30) + REMINDER_MINUTES_BEFORE;
if (minutesSinceIntake <= gracePeriodMinutes) {
// Recently missed — scheduler likely recovered from sleep/restart.
// Send a catch-up reminder (counts as first nagging reminder).
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
);
} else {
// Long ago — seed state without notification (user likely already noticed)
state.reminders[key] = {
firstSentAt: nowMs,
lastSentAt: nowMs,
sendCount: 0,
advanceSent: false,
};
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
);
}
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
+50
View File
@@ -388,6 +388,56 @@ describe("Scheduler Utils - Upcoming Intakes", () => {
// Both should be found as they're within the window
expect(result.length).toBeGreaterThanOrEqual(1);
});
it("should catch up missed advance reminder when notify window passed but intake still future", () => {
// Intake at 15:57, reminder 15 min before = 15:42
// Scheduler was down at 15:42, now running at 15:50 (intake still in future)
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T15:57:00" })];
// "now" = 15:50 local time on the same day — past the 15:42 notify window, but before 15:57 intake
const now = new Date(2025, 0, 1, 15, 50, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should still return the intake as a catch-up advance reminder
expect(result).toHaveLength(1);
expect(result[0].medName).toBe("TestMed");
expect(result[0].usage).toBe(1);
});
it("should catch up missed advance reminder even 1 minute before intake", () => {
// Intake at 08:00, reminder at 07:45. Scheduler catches up at 07:59.
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 7, 59, 30).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
expect(result).toHaveLength(1);
});
it("should not catch up for intakes already in the past", () => {
// Intake at 08:00, reminder at 07:45. Now = 08:05 (intake already past).
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T08:00:00" })];
const now = new Date(2025, 0, 1, 8, 5, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should NOT return — intake is past, handled by getTodaysIntakes instead
expect(result).toHaveLength(0);
});
it("should catch up for recurring intake on later day", () => {
// Intake started Jan 1 at 10:00, every 1 day. Now = Jan 3 at 09:50 (past notify, before intake)
const intakes: Intake[] = [blisterToIntake({ usage: 1, every: 1, start: "2025-01-01T10:00:00" })];
const now = new Date(2025, 0, 3, 9, 50, 0).getTime();
const result = getUpcomingIntakes("TestMed", intakes, 15, [], null, "en-US", "UTC", now);
// Should return today's occurrence via catch-up
expect(result).toHaveLength(1);
// The intake time should be Jan 3 at 10:00
expect(result[0].intakeTime.getHours()).toBe(10);
expect(result[0].intakeTime.getDate()).toBe(3);
});
});
describe("getTodaysIntakes", () => {
+14 -2
View File
@@ -432,6 +432,11 @@ export function getUpcomingIntakes(
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence;
} else if (currentNotifyTime < currentMinuteStart && currentOccurrence > now) {
// CATCH-UP: The notify window was missed (e.g. due to system sleep/restart)
// but the intake time is still in the future — include it so the advance
// reminder can still be sent rather than falling into a dead zone.
nextTime = currentOccurrence;
} else {
nextTime = nextOccurrence;
}
@@ -440,8 +445,15 @@ export function getUpcomingIntakes(
// Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000;
// Check if notifyTime falls within the current minute (precise matching)
if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) {
// Match if:
// 1. notifyTime falls within the current minute (normal case), OR
// 2. notifyTime is in the past but intakeTime is still in the future (catch-up
// for missed advance reminder window — e.g. scheduler was down during the
// exact notification minute due to system sleep, restart, or heavy load)
const isInCurrentMinute = notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd;
const isMissedButStillUpcoming = notifyTime < currentMinuteStart && nextTime > now;
if (isInCurrentMinute || isMissedButStillUpcoming) {
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.9.0",
"version": "1.10.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -235,7 +235,7 @@ export function MedDetailModal({
) : (
<div className="med-detail-item">
<span className="med-detail-label">{t("form.totalCapacity")}</span>
<span className="med-detail-value">{selectedMed.totalPills ?? "—"}</span>
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
</div>
)}
{selectedMed.pillWeightMg && (
+76 -46
View File
@@ -729,54 +729,38 @@ export function SharedSchedule() {
<p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : (
<>
{/* Past days toggle — identical to DashboardPage */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
</div>
);
})()}
{/* Past days (when expanded) — identical to DashboardPage */}
{/* Past days (when expanded) — rendered above toggle */}
{showPastDays &&
pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
// Really taken = all doses marked as taken by human (for green "All taken")
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = data.medications.find((m) => m.name === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
item.doses.reduce((doseCount, d) => {
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
if (takenDoses.has(d.id) || dismissedDoses.has(d.id)) return doseCount;
return doseCount + 1;
}, 0)
);
}, 0);
const hasRealMissed = missedNotDismissedCount > 0;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -786,16 +770,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
{allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
{hasRealMissed && (
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
)}
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
@@ -888,6 +874,50 @@ export function SharedSchedule() {
</div>
);
})}
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => m.doses.map((dose) => dose.id)));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
</div>
);
})()}
{/* Today (always visible) */}
{todayDay &&
(() => {
+5 -1
View File
@@ -207,7 +207,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister),
totalPills: med.totalPills ? String(med.totalPills) : "",
totalPills: med.totalPills
? String(med.totalPills)
: med.packageType === "bottle" && med.looseTablets
? String(med.looseTablets)
: "",
looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg",
+91 -60
View File
@@ -5,7 +5,7 @@ import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus } from "../utils/schedule";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
@@ -604,65 +604,37 @@ export function DashboardPage() {
</div>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t("dashboard.schedules.clearMissed")}
>
{t("dashboard.schedules.clearMissed")}
</button>
)}
</div>
);
})()}
{/* Past days (when expanded) */}
{/* Past days (when expanded) — rendered above toggle */}
{showPastDays &&
pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => {
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
})
);
const allDayTaken =
allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
// Really taken = all doses marked as taken by human (for green "All taken")
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
item.doses.reduce((doseCount, d) => {
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
}, 0)
);
}, 0);
const hasRealMissed = missedNotDismissedCount > 0;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
@@ -671,7 +643,7 @@ export function DashboardPage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -681,16 +653,18 @@ export function DashboardPage() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
{allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
{hasRealMissed && (
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
)}
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
@@ -793,6 +767,63 @@ export function DashboardPage() {
</div>
);
})}
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
const totalPastDoses = pastDays.flatMap((d) => d.meds.flatMap((m) => expandDoseIds(m.doses)));
return (
<div className="past-days-header">
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 ? (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
) : totalPastDoses.length > 0 ? (
<span className="past-days-complete" title={t("dashboard.schedules.allTaken")}>
</span>
) : null}
</div>
{missedCount > 0 && (
<button
type="button"
className="clear-missed-btn"
onClick={(e) => {
e.stopPropagation();
setShowClearMissedConfirm(true);
}}
title={t("dashboard.schedules.clearMissed")}
>
{t("dashboard.schedules.clearMissed")}
</button>
)}
</div>
);
})()}
{/* Today - always visible */}
{todayDay &&
(() => {
+77 -40
View File
@@ -3,7 +3,7 @@ import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { expandDoseIds } from "../utils/schedule";
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
@@ -65,6 +65,7 @@ export function SchedulePage() {
pastDays,
futureDays,
takenDoses,
dismissedDoses,
markDoseTaken,
undoDoseTaken,
coverageByMed,
@@ -95,40 +96,37 @@ export function SchedulePage() {
</select>
</div>
<div className="timeline">
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
// Use context's missedPastDoseIds which handles dismissed doses and previous schedule detection
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 && (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
)}
</div>
);
})()}
{/* Past days (when expanded) */}
{/* Past days (when expanded) — rendered above toggle */}
{showPastDays &&
pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => {
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
})
);
// Really taken = all doses marked as taken by human (for green "All taken")
const allReallyTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
item.doses.reduce((doseCount, d) => {
if (isDoseDismissed(d.id, dismissedUntilDate)) return doseCount;
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
const ids = takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id];
return doseCount + ids.filter((id) => !takenDoses.has(id) && !dismissedDoses.has(id)).length;
}, 0)
);
}, 0);
const hasRealMissed = missedNotDismissedCount > 0;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
@@ -136,7 +134,7 @@ export function SchedulePage() {
return (
<div
key={day.dateStr}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allReallyTaken ? "all-taken" : allDoseIds.length > 0 ? "past-missed" : ""}`}
>
<div
className="day-divider clickable"
@@ -146,16 +144,18 @@ export function SchedulePage() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
{allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : (
<>
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })}
>
</span>
{hasRealMissed && (
<span
className="day-warning"
title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
)}
<span className="day-progress">
{takenCount}/{allDoseIds.length}
</span>
@@ -246,6 +246,43 @@ export function SchedulePage() {
</div>
);
})}
{/* Past days toggle */}
{pastDays.length > 0 &&
(() => {
const missedCount = missedPastDoseIds.length;
return (
<div
className={`past-days-toggle ${showPastDays ? "expanded" : ""} ${missedCount > 0 ? "has-missed" : ""}`}
onClick={() => {
const wasCollapsed = !showPastDays;
setShowPastDays(!showPastDays);
if (wasCollapsed) {
setTimeout(() => {
document
.querySelector(".day-block.today")
?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 50);
}
}}
>
<span className="past-days-icon">{showPastDays ? "▼" : "▶"}</span>
<span className="past-days-label">
{showPastDays ? t("dashboard.schedules.hidePastDays") : t("dashboard.schedules.showPastDays")}
</span>
<span className="past-days-count">
({t("dashboard.schedules.pastDaysCount", { count: pastDays.length })})
</span>
{missedCount > 0 && (
<span
className="past-days-warning"
title={t("dashboard.schedules.missedDoses", { count: missedCount })}
>
{missedCount}
</span>
)}
</div>
);
})()}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
+1 -1
View File
@@ -1500,7 +1500,7 @@ textarea.auto-resize {
user-select: none;
}
.day-divider.clickable:hover {
color: var(--accent);
opacity: 0.8;
}
/* Keep warning/danger colors on hover */
.day-block.stock-warning .day-divider.clickable:hover {
@@ -569,6 +569,29 @@ describe("MedDetailModal bottle package type", () => {
expect(screen.queryByText("refill.packs")).not.toBeInTheDocument();
});
it("shows looseTablets as total capacity fallback when totalPills is null (backward compat)", () => {
// Old medications created before totalPills column existed
const oldBottleMed: Medication = {
...bottleMed,
totalPills: null,
looseTablets: 180,
};
const oldCoverage: Coverage = {
name: "Bottle Med",
medsLeft: 138,
daysLeft: 138,
depletionDate: "2024-06-01",
depletionTime: Date.now() + 138 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...bottleProps} selectedMed={oldBottleMed} coverage={{ all: [oldCoverage] }} />);
// Total Capacity should show 180 (looseTablets), not "—"
const capacityLabel = screen.getByText(/form\.totalCapacity/i);
const capacityValue = capacityLabel.closest(".med-detail-item")?.querySelector(".med-detail-value");
expect(capacityValue?.textContent).toBe("180");
});
it("shows total pills input in edit stock modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
@@ -94,6 +94,7 @@ const createMockContext = (overrides = {}) => ({
pastDays: [],
futureDays: [],
takenDoses: new Set(),
dismissedDoses: new Set(),
markDoseTaken: vi.fn(),
undoDoseTaken: vi.fn(),
coverageByMed: {},