feat: reports, timeline toggles, and stock correction improvements (#236)

* refactor(frontend): modularize styles and polish modal/ui interactions

* feat: add report workflow and timeline/settings improvements

* fix: resolve CI failures for backend typing, lint, and playwright config
This commit is contained in:
Daniel Volz
2026-02-20 18:52:59 +01:00
committed by GitHub
parent 89d565bc9d
commit 052751b2ba
74 changed files with 8815 additions and 4027 deletions
+2
View File
@@ -190,6 +190,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setEditingId(med.id);
setTakenByInput(""); // Clear tag input when starting edit
setFormSaved(true); // Existing medication is already saved
setFieldErrors({}); // Prevent one-frame stale error highlight from previous/default form state
// Parse intakes - prefer new format, fallback to legacy blisters
const intakesFromApi =
@@ -259,6 +260,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
setPendingImage(null);
setPendingImagePreview(null);
setTakenByInput("");
setFieldErrors({});
setFormSaved(false);
const newForm = defaultForm();
setForm(newForm);
+82 -30
View File
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import { getMedTotal, getPackageSize } from "../types";
@@ -24,7 +24,10 @@ export interface UseRefillReturn {
setEditStockFullBlisters: React.Dispatch<React.SetStateAction<number>>;
editStockPartialBlisterPills: number;
setEditStockPartialBlisterPills: React.Dispatch<React.SetStateAction<number>>;
editStockLoosePills: number;
setEditStockLoosePills: React.Dispatch<React.SetStateAction<number>>;
editStockSaving: boolean;
editStockMedication: Medication | null;
// Actions
loadRefillHistory: (medId: number) => Promise<void>;
@@ -56,7 +59,9 @@ export function useRefill(): UseRefillReturn {
const [showEditStockModal, setShowEditStockModal] = useState(false);
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
const [editStockLoosePills, setEditStockLoosePills] = useState(0);
const [editStockSaving, setEditStockSaving] = useState(false);
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
@@ -132,42 +137,60 @@ export function useRefill(): UseRefillReturn {
if (!selectedMed) return;
setEditStockSaving(true);
try {
// Auto-convert: handle full blister and negative partial blister
let finalFullBlisters = editStockFullBlisters;
let finalPartialPills = editStockPartialBlisterPills;
// Clamp all fields to non-negative values.
let finalFullBlisters = Math.max(0, editStockFullBlisters);
let finalPartialPills =
selectedMed.packageType === "bottle"
? Math.max(0, editStockPartialBlisterPills)
: Math.max(0, editStockPartialBlisterPills);
const finalLoosePills = Math.max(0, editStockLoosePills);
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
if (finalPartialPills >= selectedMed.pillsPerBlister) {
finalFullBlisters += 1;
finalPartialPills = 0;
// Canonicalize blister values: partial overflow becomes additional full blisters.
if (selectedMed.packageType !== "bottle" && selectedMed.pillsPerBlister > 0) {
finalFullBlisters += Math.floor(finalPartialPills / selectedMed.pillsPerBlister);
finalPartialPills %= selectedMed.pillsPerBlister;
}
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
if (finalPartialPills < 0 && finalFullBlisters > 0) {
finalFullBlisters -= 1;
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
}
// Structural max = sealed package capacity only (no looseTablets offset).
const structuralMax =
selectedMed.packageType === "bottle"
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
// Ensure we don't go negative
if (finalPartialPills < 0) finalPartialPills = 0;
if (finalFullBlisters < 0) finalFullBlisters = 0;
// What the user says they have RIGHT NOW = the new DB total
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
// The "base" from DB structure (without any stockAdjustment)
// Use getPackageSize() which handles both blister and bottle types correctly
const baseTotal = getPackageSize(selectedMed);
// For blister meds, only sealed pills are capped to package size.
// Loose pills are extra and can be above package size.
const desiredTotal =
selectedMed.packageType === "bottle"
? Math.min(structuralMax, Math.max(0, finalPartialPills))
: Math.min(structuralMax, finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills) +
finalLoosePills;
// The "base" from DB structure used to compute stockAdjustment differs by type:
// - Bottle: looseTablets is the base (not changed during correction)
// - Blister: use structuralMax + finalLoosePills as the new base so that
// updating looseTablets in the DB doesn't cause a stale-split display bug.
const baseTotal =
selectedMed.packageType === "bottle"
? getPackageSize(selectedMed) // bottle: stockAdjustment relative to fixed looseTablets base
: structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
const newStockAdjustment = desiredTotal - baseTotal;
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
// For blister corrections also send the new looseTablets value so the DB
// reflects the actual loose count (avoids stale-split display on reload).
const patchBody: { stockAdjustment: number; looseTablets?: number } = {
stockAdjustment: newStockAdjustment,
};
if (selectedMed.packageType !== "bottle") {
patchBody.looseTablets = finalLoosePills;
}
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
body: JSON.stringify(patchBody),
});
if (res.ok) {
// Close edit stock modal via history back
@@ -182,7 +205,7 @@ export function useRefill(): UseRefillReturn {
}
setEditStockSaving(false);
},
[editStockFullBlisters, editStockPartialBlisterPills, showEditStockModal]
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
);
const openRefillModal = useCallback(() => {
@@ -198,25 +221,51 @@ export function useRefill(): UseRefillReturn {
const openEditStockModal = useCallback((selectedMed: Medication, coverage: { all: Coverage[] }) => {
if (!selectedMed) return;
setEditStockMedication(selectedMed);
// Get current stock from coverage (after consumption)
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const dbTotal = getMedTotal(selectedMed);
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
const currentStock = Math.max(0, medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal);
// Simply divide into full blisters and partial
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
const partialPills = currentStock % selectedMed.pillsPerBlister;
// Bottle correction uses only total pills input.
// For blister, keep loose pills separated from sealed blister/partial counts.
const knownLoose = Math.min(currentStock, Math.max(0, selectedMed.looseTablets));
const sealedPills = Math.max(0, currentStock - knownLoose);
const fullBlisters =
selectedMed.packageType === "bottle" ? 0 : Math.floor(sealedPills / selectedMed.pillsPerBlister);
const partialPills =
selectedMed.packageType === "bottle" ? Math.max(0, currentStock) : sealedPills % selectedMed.pillsPerBlister;
// Pre-fill with current values
setEditStockFullBlisters(fullBlisters);
setEditStockPartialBlisterPills(partialPills);
setEditStockLoosePills(selectedMed.packageType === "bottle" ? 0 : knownLoose);
setShowEditStockModal(true);
window.history.pushState({ modal: "editStock" }, "");
}, []);
const closeEditStockModal = useCallback(() => {
if (showEditStockModal) {
let popstateHandled = false;
const handlePopstate = () => {
popstateHandled = true;
};
window.addEventListener("popstate", handlePopstate, { once: true });
window.history.back();
// Fallback for cases where no history entry exists for edit stock.
window.setTimeout(() => {
if (!popstateHandled) {
window.removeEventListener("popstate", handlePopstate);
setShowEditStockModal(false);
}
}, 150);
}
}, [showEditStockModal]);
useEffect(() => {
if (!showEditStockModal) {
setEditStockMedication(null);
}
}, [showEditStockModal]);
@@ -239,7 +288,10 @@ export function useRefill(): UseRefillReturn {
setEditStockFullBlisters,
editStockPartialBlisterPills,
setEditStockPartialBlisterPills,
editStockLoosePills,
setEditStockLoosePills,
editStockSaving,
editStockMedication,
loadRefillHistory,
submitRefill,
submitStockCorrection,
+9
View File
@@ -46,6 +46,9 @@ export interface Settings {
shoutrrrPrescriptionReminders: boolean;
stockCalculationMode: "automatic" | "manual";
shareStockStatus: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
expiryWarningDays: number;
}
@@ -90,6 +93,9 @@ const defaultSettings: Settings = {
shoutrrrPrescriptionReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
upcomingTodayOnly: false,
shareScheduleTodayOnly: false,
swapDashboardMainSections: false,
expiryWarningDays: 30,
};
@@ -224,6 +230,9 @@ export function useSettings(): UseSettingsReturn {
shoutrrrPrescriptionReminders: settingsToSave.shoutrrrPrescriptionReminders,
stockCalculationMode: settingsToSave.stockCalculationMode,
shareStockStatus: settingsToSave.shareStockStatus,
upcomingTodayOnly: settingsToSave.upcomingTodayOnly,
shareScheduleTodayOnly: settingsToSave.shareScheduleTodayOnly,
swapDashboardMainSections: settingsToSave.swapDashboardMainSections,
language: i18n.language,
smtpHost: settingsToSave.smtpHost,
smtpPort: settingsToSave.smtpPort,