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: permissions:
contents: write contents: write
# Prevent parallel badge workflows from racing each other
concurrency:
group: update-test-badges
cancel-in-progress: true
jobs: jobs:
update-badges: update-badges:
name: Update Test Count Badges name: Update Test Count Badges
@@ -99,5 +104,8 @@ jobs:
echo "No badge changes to commit" echo "No badge changes to commit"
else else
git commit -m "chore: update test count badges [skip ci]" 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 git push
fi fi
+2 -2
View File
@@ -18,8 +18,8 @@
</p> </p>
<p align="center"> <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/Backend_Tests-518%2F518-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/Frontend_Tests-709%2F709-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p> </p>
### 🤖 AI-Generated Code ### 🤖 AI-Generated Code
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.9.0", "version": "1.10.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -416,19 +416,29 @@ async function checkAndSendIntakeRemindersForUser(
if (!existingEntry) { if (!existingEntry) {
// New dose - send first reminder // New dose - send first reminder
if (isIntakePast) { if (isIntakePast) {
// Intake time already passed and we have no state entry — this means the scheduler // Intake time already passed and we have no state entry. Check how recently it was missed.
// was not aware of this intake before it happened (e.g., user just enabled reminders). const minutesSinceIntake = (nowMs - intakeTimeMs) / 60000;
// Seed the state as already handled so repeat reminders can track from here, const gracePeriodMinutes = (settings.reminderRepeatIntervalMinutes ?? 30) + REMINDER_MINUTES_BEFORE;
// but do NOT send a notification for intakes that were missed before tracking started.
state.reminders[key] = { if (minutesSinceIntake <= gracePeriodMinutes) {
firstSentAt: nowMs, // Recently missed — scheduler likely recovered from sleep/restart.
lastSentAt: nowMs, // Send a catch-up reminder (counts as first nagging reminder).
sendCount: 0, remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
advanceSent: false, logger.info(
}; `[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
logger.debug( );
`[IntakeReminder] User ${settings.userId}: Seeding state for past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — first detection)` } 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 { } else {
// Upcoming - this is advance reminder (no counter) // Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true }); 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 // Both should be found as they're within the window
expect(result.length).toBeGreaterThanOrEqual(1); 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", () => { describe("getTodaysIntakes", () => {
+14 -2
View File
@@ -432,6 +432,11 @@ export function getUpcomingIntakes(
const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000; const currentNotifyTime = currentOccurrence - minutesBefore * 60 * 1000;
if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) { if (currentNotifyTime >= currentMinuteStart && currentOccurrence > now) {
nextTime = currentOccurrence; 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 { } else {
nextTime = nextOccurrence; nextTime = nextOccurrence;
} }
@@ -440,8 +445,15 @@ export function getUpcomingIntakes(
// Calculate when we should notify for this intake // Calculate when we should notify for this intake
const notifyTime = nextTime - minutesBefore * 60 * 1000; const notifyTime = nextTime - minutesBefore * 60 * 1000;
// Check if notifyTime falls within the current minute (precise matching) // Match if:
if (notifyTime >= currentMinuteStart && notifyTime < currentMinuteEnd) { // 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); const intakeDate = new Date(nextTime);
upcoming.push({ upcoming.push({
medName, medName,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"private": true, "private": true,
"version": "1.9.0", "version": "1.10.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -235,7 +235,7 @@ export function MedDetailModal({
) : ( ) : (
<div className="med-detail-item"> <div className="med-detail-item">
<span className="med-detail-label">{t("form.totalCapacity")}</span> <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> </div>
)} )}
{selectedMed.pillWeightMg && ( {selectedMed.pillWeightMg && (
+76 -46
View File
@@ -729,54 +729,38 @@ export function SharedSchedule() {
<p className="shared-schedule-empty">{t("share.noSchedule")}</p> <p className="shared-schedule-empty">{t("share.noSchedule")}</p>
) : ( ) : (
<> <>
{/* Past days toggle — identical to DashboardPage */} {/* Past days (when expanded) — rendered above 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={() => 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 */}
{showPastDays && {showPastDays &&
pastDays.map((day) => { 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 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)); // Really taken = all doses marked as taken by human (for green "All taken")
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length; 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 isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -786,16 +770,18 @@ export function SharedSchedule() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span> <span className="day-date">{day.dateStr}</span>
<span className="day-summary"> <span className="day-summary">
{allDayTaken ? ( {allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span> <span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : ( ) : (
<> <>
<span {hasRealMissed && (
className="day-warning" <span
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })} className="day-warning"
> title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
</span>
)}
<span className="day-progress"> <span className="day-progress">
{takenCount}/{allDoseIds.length} {takenCount}/{allDoseIds.length}
</span> </span>
@@ -888,6 +874,50 @@ export function SharedSchedule() {
</div> </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) */} {/* Today (always visible) */}
{todayDay && {todayDay &&
(() => { (() => {
+5 -1
View File
@@ -207,7 +207,11 @@ export function useMedicationForm(): UseMedicationFormReturn {
packCount: String(med.packCount), packCount: String(med.packCount),
blistersPerPack: String(med.blistersPerPack), blistersPerPack: String(med.blistersPerPack),
pillsPerBlister: String(med.pillsPerBlister), 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), looseTablets: String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "", pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg", doseUnit: med.doseUnit ?? "mg",
+91 -60
View File
@@ -5,7 +5,7 @@ import { useAuth } from "../components/Auth";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
import type { Coverage } from "../types"; import type { Coverage } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters"; 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 // Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
@@ -604,65 +604,37 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="timeline"> <div className="timeline">
{/* Past days toggle */} {/* Past days (when expanded) — rendered above 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) */}
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
// Get ALL dose IDs for this day (for total count and yellow styling)
const allDoseIds = day.meds.flatMap((item) => const allDoseIds = day.meds.flatMap((item) =>
item.doses.flatMap((d) => { item.doses.flatMap((d) => {
const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : []; const takenByArray = Array.isArray(d.takenBy) ? d.takenBy : [];
return takenByArray.length > 0 ? takenByArray.map((p) => `${d.id}-${p}`) : [d.id]; 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)); // Really taken = all doses marked as taken by human (for green "All taken")
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length; 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 isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
@@ -671,7 +643,7 @@ export function DashboardPage() {
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -681,16 +653,18 @@ export function DashboardPage() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span> <span className="day-date">{day.dateStr}</span>
<span className="day-summary"> <span className="day-summary">
{allDayTaken ? ( {allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span> <span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : ( ) : (
<> <>
<span {hasRealMissed && (
className="day-warning" <span
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })} className="day-warning"
> title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
</span>
)}
<span className="day-progress"> <span className="day-progress">
{takenCount}/{allDoseIds.length} {takenCount}/{allDoseIds.length}
</span> </span>
@@ -793,6 +767,63 @@ export function DashboardPage() {
</div> </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 */} {/* Today - always visible */}
{todayDay && {todayDay &&
(() => { (() => {
+77 -40
View File
@@ -3,7 +3,7 @@ import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth"; import { useAuth } from "../components/Auth";
import { useAppContext } from "../context"; import { useAppContext } from "../context";
import type { Coverage } from "../types"; import type { Coverage } from "../types";
import { expandDoseIds } from "../utils/schedule"; import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys // Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string { function userStorageKey(userId: number | undefined, key: string): string {
@@ -65,6 +65,7 @@ export function SchedulePage() {
pastDays, pastDays,
futureDays, futureDays,
takenDoses, takenDoses,
dismissedDoses,
markDoseTaken, markDoseTaken,
undoDoseTaken, undoDoseTaken,
coverageByMed, coverageByMed,
@@ -95,40 +96,37 @@ export function SchedulePage() {
</select> </select>
</div> </div>
<div className="timeline"> <div className="timeline">
{/* Past days toggle */} {/* Past days (when expanded) — rendered above 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) */}
{showPastDays && {showPastDays &&
pastDays.map((day) => { pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => expandDoseIds(item.doses)); // Get ALL dose IDs for this day (for total count and yellow styling)
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id)); 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; 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 isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded; const isCollapsed = !isManuallyExpanded;
const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings); const worstStatus = getDayStockStatus(day.meds, coverageByMed, settings);
@@ -136,7 +134,7 @@ export function SchedulePage() {
return ( return (
<div <div
key={day.dateStr} 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 <div
className="day-divider clickable" className="day-divider clickable"
@@ -146,16 +144,18 @@ export function SchedulePage() {
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span> <span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span> <span className="day-date">{day.dateStr}</span>
<span className="day-summary"> <span className="day-summary">
{allDayTaken ? ( {allReallyTaken ? (
<span className="day-complete"> {t("dashboard.schedules.allTaken")}</span> <span className="day-complete"> {t("dashboard.schedules.allTaken")}</span>
) : ( ) : (
<> <>
<span {hasRealMissed && (
className="day-warning" <span
title={t("dashboard.schedules.missedDoses", { count: allDoseIds.length - takenCount })} className="day-warning"
> title={t("dashboard.schedules.missedDoses", { count: missedNotDismissedCount })}
>
</span>
</span>
)}
<span className="day-progress"> <span className="day-progress">
{takenCount}/{allDoseIds.length} {takenCount}/{allDoseIds.length}
</span> </span>
@@ -246,6 +246,43 @@ export function SchedulePage() {
</div> </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 */} {/* Current and future days */}
{futureDays.map((day) => { {futureDays.map((day) => {
const today = new Date(); const today = new Date();
+1 -1
View File
@@ -1500,7 +1500,7 @@ textarea.auto-resize {
user-select: none; user-select: none;
} }
.day-divider.clickable:hover { .day-divider.clickable:hover {
color: var(--accent); opacity: 0.8;
} }
/* Keep warning/danger colors on hover */ /* Keep warning/danger colors on hover */
.day-block.stock-warning .day-divider.clickable: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(); 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", () => { it("shows total pills input in edit stock modal for bottle type", () => {
render(<MedDetailModal {...bottleProps} showEditStockModal={true} />); render(<MedDetailModal {...bottleProps} showEditStockModal={true} />);
@@ -94,6 +94,7 @@ const createMockContext = (overrides = {}) => ({
pastDays: [], pastDays: [],
futureDays: [], futureDays: [],
takenDoses: new Set(), takenDoses: new Set(),
dismissedDoses: new Set(),
markDoseTaken: vi.fn(), markDoseTaken: vi.fn(),
undoDoseTaken: vi.fn(), undoDoseTaken: vi.fn(),
coverageByMed: {}, coverageByMed: {},