Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c50e9395f | |||
| e335729399 | |||
| 399d63caec | |||
| ffbe957f41 | |||
| 749e92b135 | |||
| 5093f96e8a | |||
| bd6eccdb22 | |||
| 9d289d45c9 |
@@ -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
|
||||||
|
|||||||
@@ -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,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 });
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 &&
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 &&
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user