feat: Add package type support and per-intake takenBy (#89)

## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
This commit is contained in:
Daniel Volz
2026-01-31 23:49:11 +01:00
committed by GitHub
parent ac4b8151e4
commit 571d94bf7e
37 changed files with 2896 additions and 990 deletions
+119 -16
View File
@@ -2,9 +2,37 @@
// Schedule Building and Coverage Calculations
// =============================================================================
import type { Coverage, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedTotal } from "../types";
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
*/
function getIntakesForMed(med: Medication): Intake[] {
// Use new intakes array if available and non-empty
if (med.intakes && med.intakes.length > 0) {
return med.intakes;
}
// Fallback to legacy blisters (convert to Intake format)
return med.blisters.map((b) => ({
usage: b.usage,
every: b.every,
start: b.start,
takenBy: null, // Legacy format has no per-intake takenBy
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
}));
}
/**
* Get blisters for a medication (for backward compatibility with coverage calculations)
*/
function getBlistersForMed(med: Medication): Blister[] {
if (med.intakes && med.intakes.length > 0) {
return med.intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start }));
}
return med.blisters;
}
/**
* Build schedule preview events for medications
*/
@@ -22,10 +50,11 @@ export function buildSchedulePreview(
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
med.blisters.forEach((blister, idx) => {
const start = new Date(blister.start);
const intakes = getIntakesForMed(med);
intakes.forEach((intake, idx) => {
const start = new Date(intake.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + intake.every)) {
const isPast = d < todayStart;
if (isPast && !includePast) continue;
const whenMs = d.getTime();
@@ -35,8 +64,8 @@ export function buildSchedulePreview(
events.push({
id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name,
takenBy: med.takenBy || [],
usage: blister.usage,
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
usage: intake.usage,
when: whenMs,
isPast,
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
@@ -58,7 +87,7 @@ export function buildSchedulePreview(
events,
today: todayCount,
nextThree: events.length,
totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0),
totalBlisters: meds.reduce((acc, m) => acc + getIntakesForMed(m).length, 0),
};
}
@@ -67,7 +96,7 @@ export function buildSchedulePreview(
*/
export function calculateCoverage(
meds: Medication[],
events: Array<{ medName: string; when: number }>,
events: Array<{ medName: string; when: number; id: string }>,
locale: string,
reminderDaysBefore: number,
stockCalculationMode: "automatic" | "manual",
@@ -77,32 +106,96 @@ export function calculateCoverage(
const now = Date.now();
const coverage: Coverage[] = meds.map((m) => {
const personCount = Math.max(1, m.takenBy?.length || 1);
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
const intakes = getIntakesForMed(m);
const blisters = getBlistersForMed(m);
// Count unique people from all intakes (for per-intake takenBy)
const uniquePeople = new Set<string>();
intakes.forEach((intake) => {
if (intake.takenBy) uniquePeople.add(intake.takenBy);
});
// Also add medication-level takenBy for backward compatibility
m.takenBy?.forEach((person) => uniquePeople.add(person));
const personCount = Math.max(1, uniquePeople.size || m.takenBy?.length || 1);
const dailyRate = blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0) * personCount;
let consumed = 0;
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
if (stockCalculationMode === "automatic") {
m.blisters.forEach((s) => {
// In automatic mode, calculate expected consumption based on time
// but also account for manual corrections (doses marked as not taken)
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY;
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
consumed += occurrences * s.usage * personCount;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
// For legacy (no takenBy), count for all people in medication takenBy
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
// Count how many doses were actually marked as taken for this blister
let actualConsumed = 0;
// Generate all expected dose IDs for this blister up to now
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoses.has(doseId)) {
actualConsumed += s.usage;
}
}
}
// If we have tracking data (any doses marked), use actual consumed
// Otherwise fall back to expected (for backwards compatibility)
const hasTrackingData = Array.from(takenDoses).some((id) => {
const parts = id.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
});
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
});
} else {
// In manual mode, only count doses that are explicitly marked as taken
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const blisterIdx = parseInt(parts[1], 10);
const doseTimestamp = parseInt(parts[2], 10);
if (medId === m.id && m.blisters[blisterIdx]) {
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
consumed += m.blisters[blisterIdx].usage;
if (medId === m.id && blisters[blisterIdx]) {
// Convert blister start to date-only for comparison (dose timestamps are date-only)
const blisterStartDate = new Date(blisters[blisterIdx].start);
const blisterStartDateOnly = new Date(
blisterStartDate.getFullYear(),
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
// Convert stock correction cutoff to date-only as well
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
doseTimestamp >= stockCorrectionDateOnly
) {
consumed += blisters[blisterIdx].usage;
}
}
}
@@ -146,22 +239,32 @@ export function calculateCoverage(
* Get stock status based on days left and thresholds
*/
export function getStockStatus(daysLeft: number | null, medsLeft: number, thresholds: StockThresholds): StockStatus {
// Out of stock or completely depleted = danger (red)
if (medsLeft <= 0 || daysLeft === 0) {
return { level: "out-of-stock", className: "danger", label: "status.outOfStock" };
}
// No schedule, but has stock = normal
if (daysLeft === null) {
return { level: "normal", className: "success", label: "status.noSchedule" };
}
// High stock
if (daysLeft > thresholds.highStockDays) {
return { level: "high", className: "high", label: "status.highStock" };
}
// Normal stock
if (daysLeft >= thresholds.lowStockDays) {
return { level: "normal", className: "success", label: "status.normal" };
}
// Critical: at or below critical threshold = danger (red)
if (daysLeft <= thresholds.criticalStockDays) {
return { level: "critical", className: "danger", label: "status.criticalStock" };
}
// Low stock: below lowStockDays but above critical = warning (yellow)
return { level: "low", className: "warning", label: "status.lowStock" };
}