Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 26 additions & 23 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CssBaseline } from "@mui/material";
import { AppThemeProvider } from "./hooks/useTheme";
import { AuthProvider, useAuth } from "./hooks/useAuth";
import { ChildProvider } from "./hooks/useChildren";
import { DataRefreshProvider } from "./hooks/useDataRefresh";
import { NotificationProvider } from "./hooks/useNotification";
import Layout from "./components/Layout";
import { Box, CircularProgress } from "@mui/material";
Expand Down Expand Up @@ -61,29 +62,31 @@ export default function App() {
<AuthProvider>
<AuthGate>
<ChildProvider>
<NotificationProvider>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/children" element={<ChildrenPage />} />
<Route path="/feedings" element={<FeedingsPage />} />
<Route path="/diapers" element={<DiapersPage />} />
<Route path="/sleep" element={<SleepPage />} />
<Route path="/tummy-time" element={<TummyTimePage />} />
<Route path="/pumping" element={<PumpingPage />} />
<Route path="/growth" element={<GrowthPage />} />
<Route path="/temperature" element={<TemperaturePage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/timers" element={<TimersPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/todos" element={<TodosPage />} />
<Route path="/charts" element={<ChartsPage />} />
</Route>
</Routes>
</Suspense>
</NotificationProvider>
<DataRefreshProvider>
<NotificationProvider>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/children" element={<ChildrenPage />} />
<Route path="/feedings" element={<FeedingsPage />} />
<Route path="/diapers" element={<DiapersPage />} />
<Route path="/sleep" element={<SleepPage />} />
<Route path="/tummy-time" element={<TummyTimePage />} />
<Route path="/pumping" element={<PumpingPage />} />
<Route path="/growth" element={<GrowthPage />} />
<Route path="/temperature" element={<TemperaturePage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/timers" element={<TimersPage />} />
<Route path="/medications" element={<MedicationsPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/todos" element={<TodosPage />} />
<Route path="/charts" element={<ChartsPage />} />
</Route>
</Routes>
</Suspense>
</NotificationProvider>
</DataRefreshProvider>
</ChildProvider>
</AuthGate>
</AuthProvider>
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/QuickLogDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "@mui/material";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NowButton from "./NowButton";

Expand Down Expand Up @@ -107,6 +108,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const { selectedChild } = useChildren();
const { notify } = useNotification();
const { refreshData } = useDataRefresh();
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);

Expand Down Expand Up @@ -184,6 +186,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
});
}
notify("Logged.", "success");
refreshData();
onLogged?.(category);
onClose();
} catch (err) {
Expand Down
53 changes: 53 additions & 0 deletions client/src/hooks/useDataRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";

interface DataRefreshContextType {
/** Bumped whenever tracked data changes — use as a `useEffect` dependency to refetch. */
refreshKey: number;
/** Signal that entries changed and any mounted view should refetch. */
refreshData: () => void;
}

const DataRefreshContext = createContext<DataRefreshContextType>({
refreshKey: 0,
refreshData: () => {},
});

/** Ignore a re-focus refresh this soon after the previous one (ms). */
const FOCUS_REFRESH_THROTTLE_MS = 2000;

export function DataRefreshProvider({ children }: { children: ReactNode }) {
const [refreshKey, setRefreshKey] = useState(0);

const refreshData = useCallback(() => setRefreshKey((k) => k + 1), []);

// Entries are often logged from another device (or the installed PWA sits in
// the background for hours), so refetch whenever the app becomes visible
// again. `visibilitychange` and `focus` both fire when returning to a tab,
// hence the throttle.
const lastFocusRefresh = useRef(0);
useEffect(() => {
const onVisible = () => {
if (document.visibilityState !== "visible") return;
const now = Date.now();
if (now - lastFocusRefresh.current < FOCUS_REFRESH_THROTTLE_MS) return;
lastFocusRefresh.current = now;
refreshData();
};
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", onVisible);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("focus", onVisible);
};
}, [refreshData]);

return (
<DataRefreshContext.Provider value={{ refreshKey, refreshData }}>
{children}
</DataRefreshContext.Provider>
);
}

export function useDataRefresh() {
return useContext(DataRefreshContext);
}
4 changes: 3 additions & 1 deletion client/src/pages/ActivityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NoChildPlaceholder from "../components/NoChildPlaceholder";
import {
Expand Down Expand Up @@ -255,6 +256,7 @@ export default function ActivityPage() {
const cat = useMemo(() => buildCategoryColors(isDark), [isDark]);

const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();

const [entries, setEntries] = useState<ActivityEntry[]>([]);
Expand Down Expand Up @@ -300,7 +302,7 @@ export default function ActivityPage() {
if (!selectedChild) return;
setOffset(0);
load(selectedChild.id, 0);
}, [selectedChild, load]);
}, [selectedChild, refreshKey, load]);

const handlePageChange = (newOffset: number) => {
if (!selectedChild) return;
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/ChartsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import BedtimeIcon from "@mui/icons-material/Bedtime";
import OpacityIcon from "@mui/icons-material/Opacity";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import NoChildPlaceholder from "../components/NoChildPlaceholder";
import {
FeedingChart,
Expand Down Expand Up @@ -52,6 +53,7 @@ function rangeSubtitle(range: RangeKey): string {

export default function ChartsPage() {
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const theme = useTheme();
const dark = theme.palette.mode === "dark";
const colors = useMemo(() => buildCategoryColors(dark), [dark]);
Expand All @@ -78,7 +80,7 @@ export default function ChartsPage() {
setPumpings(p);
setLoading(false);
});
}, [selectedChild]);
}, [selectedChild, refreshKey]);

if (!selectedChild) return <NoChildPlaceholder />;

Expand Down
63 changes: 38 additions & 25 deletions client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import ChecklistIcon from "@mui/icons-material/Checklist";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { api, API_BASE } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NoChildPlaceholder from "../components/NoChildPlaceholder";
import QuickLogDialog, { type QuickLogCategory } from "../components/QuickLogDialog";
Expand Down Expand Up @@ -134,6 +135,7 @@ const CAT_ICONS_SM: CatIcons = {

export default function Dashboard() {
const { selectedChild } = useChildren();
const { refreshKey, refreshData } = useDataRefresh();
const { notify } = useNotification();
const navigate = useNavigate();
const theme = useTheme();
Expand All @@ -151,38 +153,52 @@ export default function Dashboard() {

const [quickLogCategory, setQuickLogCategory] = useState<QuickLogCategory | null>(null);

const reloadAll = async (childId: number) => {
const [f, d, s, t, tt, p, td] = await Promise.all([
api.get<Feeding[]>(`/feedings?child_id=${childId}&limit=500`),
api.get<DiaperChange[]>(`/diaper-changes?child_id=${childId}&limit=500`),
api.get<SleepEntry[]>(`/sleep?child_id=${childId}&limit=500`),
api.get<Timer[]>(`/timers?child_id=${childId}&active=true`),
api.get<TummyTime[]>(`/tummy-time?child_id=${childId}&limit=500`),
api.get<Pumping[]>(`/pumping?child_id=${childId}&limit=500`),
api.get<Todo[]>(`/todos?child_id=${childId}&limit=200`),
]);
setFeedings(f);
setDiapers(d);
setSleeps(s);
setTimers(t);
setTummyTimes(tt);
setPumpings(p);
setTodos(td);
};

const handleTodoToggle = async (todo: Todo) => {
try {
await api.put(`/todos/${todo.id}`, { completed: !todo.completed });
if (selectedChild) await reloadAll(selectedChild.id);
refreshData();
} catch (err) {
notify(err instanceof Error ? err.message : "Failed to update todo.", "error");
}
};

// Refetches on mount, when the child changes, and whenever `refreshKey` is
// bumped — logging an entry from anywhere in the app (including the bottom-nav
// FAB, which renders its dialog outside this page) updates these cards without
// a page reload.
useEffect(() => {
if (!selectedChild) return;
reloadAll(selectedChild.id);
}, [selectedChild]);
const childId = selectedChild.id;
let cancelled = false;

(async () => {
try {
const [f, d, s, t, tt, p, td] = await Promise.all([
api.get<Feeding[]>(`/feedings?child_id=${childId}&limit=500`),
api.get<DiaperChange[]>(`/diaper-changes?child_id=${childId}&limit=500`),
api.get<SleepEntry[]>(`/sleep?child_id=${childId}&limit=500`),
api.get<Timer[]>(`/timers?child_id=${childId}&active=true`),
api.get<TummyTime[]>(`/tummy-time?child_id=${childId}&limit=500`),
api.get<Pumping[]>(`/pumping?child_id=${childId}&limit=500`),
api.get<Todo[]>(`/todos?child_id=${childId}&limit=200`),
]);
if (cancelled) return;
setFeedings(f);
setDiapers(d);
setSleeps(s);
setTimers(t);
setTummyTimes(tt);
setPumpings(p);
setTodos(td);
} catch (err) {
if (!cancelled) notify(err instanceof Error ? err.message : "Failed to load data.", "error");
}
})();

return () => {
cancelled = true;
};
}, [selectedChild, refreshKey]);

if (!selectedChild) return <NoChildPlaceholder />;

Expand Down Expand Up @@ -678,9 +694,6 @@ export default function Dashboard() {
<QuickLogDialog
category={quickLogCategory}
onClose={() => setQuickLogCategory(null)}
onLogged={() => {
if (selectedChild) reloadAll(selectedChild.id);
}}
/>
</Box>
);
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/DiapersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import BabyChangingStationIcon from "@mui/icons-material/BabyChangingStation";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NowButton from "../components/NowButton";
import { FAB_BOTTOM_OFFSET } from "../components/Layout";
Expand Down Expand Up @@ -97,6 +98,7 @@ function dateSectionLabel(iso: string): string {

export default function DiapersPage() {
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
Expand All @@ -122,7 +124,7 @@ export default function DiapersPage() {

useEffect(() => {
load();
}, [selectedChild]);
}, [selectedChild, refreshKey]);

const handleEdit = (entry: DiaperChange) => {
setEditingEntry(entry);
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/FeedingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import RestaurantIcon from "@mui/icons-material/Restaurant";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NowButton from "../components/NowButton";
import { FAB_BOTTOM_OFFSET } from "../components/Layout";
Expand Down Expand Up @@ -121,6 +122,7 @@ export default function FeedingsPage() {
const c = cat.feed;
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();
const [feedings, setFeedings] = useState<Feeding[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
Expand Down Expand Up @@ -148,7 +150,7 @@ export default function FeedingsPage() {

useEffect(() => {
load();
}, [selectedChild]);
}, [selectedChild, refreshKey]);

const openAddDialog = () => {
setEditingEntry(null);
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/NotesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import SearchIcon from "@mui/icons-material/Search";
import FilterListIcon from "@mui/icons-material/FilterList";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NowButton from "../components/NowButton";
import { FAB_BOTTOM_OFFSET } from "../components/Layout";
Expand Down Expand Up @@ -74,6 +75,7 @@ export default function NotesPage() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();
const isDark = theme.palette.mode === "dark";
const cat = useMemo(() => buildCategoryColors(isDark), [isDark]);
Expand Down Expand Up @@ -111,7 +113,7 @@ export default function NotesPage() {

useEffect(() => {
load();
}, [selectedChild]);
}, [selectedChild, refreshKey]);

const filtered = useMemo(() => {
if (!searchQuery.trim()) return entries;
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/PumpingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import OpacityIcon from "@mui/icons-material/Opacity";
import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import NowButton from "../components/NowButton";
import { FAB_BOTTOM_OFFSET } from "../components/Layout";
Expand Down Expand Up @@ -101,6 +102,7 @@ function dateSectionLabel(iso: string): string {

export default function PumpingPage() {
const { selectedChild } = useChildren();
const { refreshKey } = useDataRefresh();
const { notify } = useNotification();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
Expand All @@ -126,7 +128,7 @@ export default function PumpingPage() {

useEffect(() => {
load();
}, [selectedChild]);
}, [selectedChild, refreshKey]);

const handleEdit = (entry: Pumping) => {
setEditingEntry(entry);
Expand Down
Loading