feat: theme dropdown with system preference and comprehensive bottle-type fixes (#138)

- Replace dark/light toggle with Light/Dark/System dropdown menu
- System theme follows OS prefers-color-scheme setting
- Apply theme dropdown to shared schedule page
- Fix 7 packageType (bottle) bugs across stock calc, share, refills, export/import
- Fix planner bottle-type stock calculation and display
- Fix dailyRate double-counting with per-intake takenBy
- Fix About modal update check stale caching
- Fix intake reminder past-intake seeding and push title
- Fix phantom DB path in drizzle.config.ts
- Fix mobile dose field visibility
- Make medication name clickable in dashboard reminder bar
- Improve planner checkbox UX with inline tooltip
- Add 20+ new tests covering all fixes
This commit is contained in:
Daniel Volz
2026-02-08 20:32:40 +01:00
committed by GitHub
parent b19bcf02c2
commit 8c5deed4c2
29 changed files with 1053 additions and 166 deletions
@@ -82,7 +82,7 @@ describe("AppHeader", () => {
});
});
it("renders theme toggle button", async () => {
it("renders theme menu button", async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
@@ -95,12 +95,33 @@ describe("AppHeader", () => {
);
await waitFor(() => {
const buttons = screen.getAllByRole("button");
const themeBtn = buttons.find((btn) => btn.textContent?.includes("🌙") || btn.textContent?.includes("☀️"));
const themeBtn = screen.getByTitle(/theme\.title/i);
expect(themeBtn).toBeInTheDocument();
});
});
it("opens theme dropdown and shows Light/Dark/System options", async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
render(
<MemoryRouter initialEntries={["/dashboard"]}>
<AuthProvider>
<AppHeader onOpenProfile={mockOnOpenProfile} onOpenAbout={mockOnOpenAbout} />
</AuthProvider>
</MemoryRouter>
);
await waitFor(() => {
const themeBtn = screen.getByTitle(/theme\.title/i);
fireEvent.click(themeBtn);
});
expect(screen.getByText(/theme\.light/i)).toBeInTheDocument();
expect(screen.getByText(/theme\.dark/i)).toBeInTheDocument();
expect(screen.getByText(/theme\.system/i)).toBeInTheDocument();
});
it("renders settings button when auth is disabled", async () => {
const mockOnOpenProfile = vi.fn();
const mockOnOpenAbout = vi.fn();
+89
View File
@@ -184,6 +184,95 @@ describe("useDoses", () => {
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/taken-dose", expect.objectContaining({ method: "DELETE" }));
});
it("reverts undo on error by re-adding the dose", async () => {
const mockDoses = {
doses: [{ doseId: "taken-dose", takenAt: 1710500000000, dismissed: false }],
};
// Initial load returns taken-dose, DELETE fails, re-sync returns taken-dose still there
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) });
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.has("taken-dose")).toBe(true);
});
await act(async () => {
await result.current.undoDoseTaken("taken-dose");
});
// After error, the dose should be re-added (reverted)
await waitFor(() => {
expect(result.current.takenDoses.has("taken-dose")).toBe(true);
});
});
it("populates takenDoseTimestamps from API response", async () => {
const takenAt = 1710500000000;
const mockDoses = {
doses: [{ doseId: "dose-1", takenAt, dismissed: false }],
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockDoses),
});
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoseTimestamps.get("dose-1")).toBe(takenAt);
});
});
it("markDoseTaken sets takenDoseTimestamp optimistically", async () => {
const now = Date.now();
vi.setSystemTime(now);
// Initial load, POST success, re-sync
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [{ doseId: "new-dose", takenAt: now, dismissed: false }] }),
});
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
});
await act(async () => {
await result.current.markDoseTaken("new-dose");
});
await waitFor(() => {
expect(result.current.takenDoseTimestamps.has("new-dose")).toBe(true);
expect(result.current.takenDoseTimestamps.get("new-dose")).toBe(now);
});
vi.useRealTimers();
});
it("keeps state on fetch error during initial load", async () => {
// Initial load fails
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useDoses());
// Should keep empty state, not crash
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
expect(result.current.dismissedDoses.size).toBe(0);
});
});
it("setShowClearMissedConfirm works", () => {
const { result } = renderHook(() => useDoses());
+54 -22
View File
@@ -8,6 +8,20 @@ describe("useTheme", () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue(null);
// Reset mock to default behavior
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {});
// Mock matchMedia to return dark system theme by default
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
})),
});
});
afterEach(() => {
@@ -17,58 +31,76 @@ describe("useTheme", () => {
it("returns dark as default theme", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
expect(result.current.themePreference).toBe("dark");
});
it("reads theme from localStorage", () => {
it("reads theme preference from localStorage", () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue("light");
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("light");
expect(result.current.themePreference).toBe("light");
});
it("toggles theme from dark to light", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.theme).toBe("dark");
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe("light");
});
it("toggles theme from light to dark", () => {
it("toggles theme through light → dark → system → light", () => {
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue("light");
const { result } = renderHook(() => useTheme());
expect(result.current.themePreference).toBe("light");
act(() => {
result.current.toggleTheme();
});
expect(result.current.themePreference).toBe("dark");
act(() => {
result.current.toggleTheme();
});
expect(result.current.themePreference).toBe("system");
act(() => {
result.current.toggleTheme();
});
expect(result.current.themePreference).toBe("light");
});
it("sets theme preference directly", () => {
const { result } = renderHook(() => useTheme());
expect(result.current.themePreference).toBe("dark");
act(() => {
result.current.setThemePreference("light");
});
expect(result.current.themePreference).toBe("light");
expect(result.current.theme).toBe("light");
act(() => {
result.current.toggleTheme();
result.current.setThemePreference("system");
});
expect(result.current.themePreference).toBe("system");
// System resolves to dark (matchMedia returns false for light)
expect(result.current.theme).toBe("dark");
});
it("saves theme to localStorage on change", () => {
it("saves theme preference to localStorage on change", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.toggleTheme();
result.current.setThemePreference("light");
});
expect(window.localStorage.setItem).toHaveBeenCalledWith("theme", "light");
act(() => {
result.current.setThemePreference("system");
});
expect(window.localStorage.setItem).toHaveBeenCalledWith("theme", "system");
});
it("sets data-theme attribute on document", () => {
const { result } = renderHook(() => useTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
act(() => {
result.current.toggleTheme();
result.current.setThemePreference("light");
});
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
});
+120
View File
@@ -289,6 +289,113 @@ describe("calculateCoverage", () => {
expect(result.all[0].daysLeft).toBeNull();
});
it("uses intakes format when available instead of blisters", () => {
// The new intakes format should be used for coverage calculation
// when med.intakes is present, falling through getBlistersForMed
const meds: Medication[] = [
{
id: 1,
name: "IntakesMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [], // Empty blisters — intakes should be used instead
intakes: [
{
usage: 2,
every: 1,
start: "2024-03-10T09:00:00",
takenBy: null,
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// 30 pills, 2 per day consumed. March 10 09:00 to March 15 12:00 = 6 occurrences × 2 = 12 consumed
expect(result.all[0].medsLeft).toBe(18);
expect(result.all[0].daysLeft).toBe(9); // 18 pills / 2 per day = 9 days
});
it("per-intake takenBy counts person correctly in automatic mode", () => {
// When intakes have per-intake takenBy, each person-intake pair is counted
const meds: Medication[] = [
{
id: 1,
name: "PersonMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 60,
looseTablets: 0,
takenBy: ["Alice", "Bob"],
blisters: [],
intakes: [
{
usage: 1,
every: 1,
start: "2024-03-10T09:00:00",
takenBy: "Alice",
intakeRemindersEnabled: false,
},
{
usage: 1,
every: 1,
start: "2024-03-10T09:00:00",
takenBy: "Bob",
intakeRemindersEnabled: false,
},
],
updatedAt: null,
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// 2 intakes × 1 pill/day × 6 occurrences = 12 consumed
// dailyRate = 2 (1/day × 2 people)
// medsLeft = 60 - 12 = 48, daysLeft = 48 / 2 = 24
expect(result.all[0].medsLeft).toBe(48);
expect(result.all[0].daysLeft).toBe(24);
});
it("automatic mode without stock correction counts from blister start", () => {
// Without stock correction, effectiveStart should be the blisterStart itself.
// This tests the `else` branch where effectiveStart = blisterStart.
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-13T09:00:00", // 2 days ago + today = 3 occurrences
},
],
updatedAt: null,
// No lastStockCorrectionAt
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// March 13, 14, 15 at 09:00 — all past (it's 12:00 on March 15) = 3 consumed
expect(result.all[0].medsLeft).toBe(27);
});
it("filters low stock medications", () => {
const meds: Medication[] = [
{
@@ -1046,6 +1153,19 @@ describe("getStockStatus", () => {
expect(result.level).toBe("normal");
expect(result.label).toBe("status.noSchedule");
});
it("returns critical when daysLeft is at or below criticalStockDays", () => {
const thresholdsWithCritical: StockThresholds = {
lowStockDays: 30,
criticalStockDays: 7,
normalStockDays: 90,
highStockDays: 180,
};
const result = getStockStatus(5, 10, thresholdsWithCritical);
expect(result.level).toBe("critical");
expect(result.className).toBe("danger");
});
});
describe("getNextReminderForMed", () => {