From 44620d46272c13300387aa43078d0cdb4c325ba3 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 18 Oct 2025 22:17:05 -0400 Subject: [PATCH] feat: stats & tweak activity view --- src/App.tsx | 7 +- src/components/ActivityLog.tsx | 134 +++++++++++++-------- src/components/Stats.tsx | 208 +++++++++++++++++++++++++++++++++ src/db.ts | 10 ++ 4 files changed, 305 insertions(+), 54 deletions(-) create mode 100644 src/components/Stats.tsx diff --git a/src/App.tsx b/src/App.tsx index 4a75731..bfbeda6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import ActivityLog from "./components/ActivityLog"; import BottleFeedCard from "./components/BottleFeedCard"; import DiaperCard from "./components/DiaperCard"; import SleepCard from "./components/SleepCard"; +import StatsPanel from "./components/Stats"; import TimedVolumeCard from "./components/TimedVolumeCard"; import { db } from "./db"; @@ -149,11 +150,7 @@ function App() { {activeTab === "log" && } - {activeTab === "stats" && ( -
- 📊 Statistics coming soon... -
- )} + {activeTab === "stats" && } ); } diff --git a/src/components/ActivityLog.tsx b/src/components/ActivityLog.tsx index 6070af7..4eba042 100644 --- a/src/components/ActivityLog.tsx +++ b/src/components/ActivityLog.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useSwipeable } from "react-swipeable"; -import { format } from "date-fns"; +import { format, isToday, isYesterday, startOfDay } from "date-fns"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../db"; import { getColor, getIcon } from "../types/helpers"; @@ -10,24 +10,56 @@ import { gramsToOz } from "../utils/convert"; const formatActivityDetails = (activity: Activity): string => { if (!activity.isComplete) return "In progress"; + const parts: string[] = []; + switch (activity.type) { case "bottle": - return `${gramsToOz(activity.volumeGrams).toFixed(2)} oz`; + parts.push(`${gramsToOz(activity.volumeGrams).toFixed(2)} oz`); + break; case "breast": - case "sleep": case "pump": - return `${Math.round((activity.durationSeconds || 0) / 60)} min`; + const duration = Math.round((activity.durationSeconds || 0) / 60); + parts.push(`${duration} min`); + if (activity.volumeGrams) { + parts.push(`${gramsToOz(activity.volumeGrams).toFixed(2)} oz`); + } + break; + case "sleep": + parts.push(`${Math.round((activity.durationSeconds || 0) / 60)} min`); + break; case "diaper": - return activity.diaperType; - default: - return ""; + parts.push(activity.diaperType); + break; } + + return parts.join(" • "); +}; + +const formatDayHeader = (date: number): string => { + if (isToday(date)) return "Today"; + if (isYesterday(date)) return "Yesterday"; + return format(date, "EEEE, MMM d"); +}; + +const groupByDay = (activities: Activity[]) => { + const groups = new Map(); + + activities.forEach((activity) => { + const dayStart = startOfDay(activity.startTime).getTime(); + if (!groups.has(dayStart)) { + groups.set(dayStart, []); + } + groups.get(dayStart)!.push(activity); + }); + + return Array.from(groups.entries()).sort((a, b) => b[0] - a[0]); }; export default function ActivityLog() { const allActivities = useLiveQuery(() => db.getRecentActivities(20), []) || []; - const [swipedIndex, setSwipedIndex] = useState(null); + const groupedActivities = groupByDay(allActivities as Activity[]); + const [swipedIndex, setSwipedIndex] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; @@ -45,41 +77,49 @@ export default function ActivityLog() { setContextMenu({ x: e.clientX, y: e.clientY, activity }); }; - // Close context menu on click outside - const handleClickOutside = () => { - setContextMenu(null); - }; - return (
setContextMenu(null)} >

Recent Activity

-
- {allActivities.length === 0 ? ( -

- No activities yet -

- ) : ( - allActivities.map((activity, idx) => ( - setSwipedIndex(idx)} - onSwipeBack={() => setSwipedIndex(null)} - onDelete={() => handleDelete(activity)} - onContextMenu={(e) => handleContextMenu(e, activity)} - /> - )) - )} -
- {/* Context Menu */} + {groupedActivities.length === 0 ? ( +

No activities yet

+ ) : ( +
+ {groupedActivities.map(([dayStart, activities]) => ( +
+
+
+ {formatDayHeader(dayStart)} +
+
+ +
+ {activities.map((activity) => { + const key = `${dayStart}-${activity.id}`; + return ( + setSwipedIndex(key)} + onSwipeBack={() => setSwipedIndex(null)} + onDelete={() => handleDelete(activity)} + onContextMenu={(e) => handleContextMenu(e, activity)} + /> + ); + })} +
+
+ ))} +
+ )} + {contextMenu && (
void; onSwipeBack: () => void; @@ -122,15 +162,13 @@ function ActivityItem({ return (
- {/* Delete background */}
Delete
- {/* Main content */}
-
-
+
+
{getIcon(activity.type)}
@@ -150,22 +188,20 @@ function ActivityItem({

{activity.type}

-

+

{formatActivityDetails(activity)}

{"endTime" in activity && activity.endTime && ( <> - + {format(activity.endTime, "h:mm a")} - - ↑ - + ↓ )} - + {format(activity.startTime, "h:mm a")}
diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx new file mode 100644 index 0000000..7e0d491 --- /dev/null +++ b/src/components/Stats.tsx @@ -0,0 +1,208 @@ +import { useMemo } from "react"; +import { endOfDay, startOfDay } from "date-fns"; +import { useLiveQuery } from "dexie-react-hooks"; +import { db } from "../db"; +import { getColor, getIcon } from "../types/helpers"; +import type { Activity } from "../types/types"; +import { gramsToOz } from "../utils/convert"; + +interface DayStats { + // Time-based totals (in minutes) + breast: number; + pump: number; + sleep: number; + + // Volume totals (in oz) + volumePumped: number; + volumeDrank: number; + + // Session counts + breastSessions: number; + pumpSessions: number; + sleepSessions: number; + bottleSessions: number; + diaperPee: number; + diaperPoop: number; + diaperBoth: number; +} + +const calculateStats = (activities: Activity[]): DayStats => { + const stats: DayStats = { + breast: 0, + pump: 0, + sleep: 0, + volumePumped: 0, + volumeDrank: 0, + breastSessions: 0, + pumpSessions: 0, + sleepSessions: 0, + bottleSessions: 0, + diaperPee: 0, + diaperPoop: 0, + diaperBoth: 0, + }; + + activities.forEach((activity) => { + if (!activity.isComplete) return; + + switch (activity.type) { + case "breast": + stats.breast += (activity.durationSeconds || 0) / 60; + stats.volumeDrank += gramsToOz(activity.volumeGrams || 0); + stats.breastSessions++; + break; + case "pump": + stats.pump += (activity.durationSeconds || 0) / 60; + stats.volumePumped += gramsToOz(activity.volumeGrams || 0); + stats.pumpSessions++; + break; + case "sleep": + stats.sleep += (activity.durationSeconds || 0) / 60; + stats.sleepSessions++; + break; + case "bottle": + stats.volumeDrank += gramsToOz(activity.volumeGrams); + stats.bottleSessions++; + break; + case "diaper": + if (activity.diaperType === "pee") stats.diaperPee++; + else if (activity.diaperType === "poop") stats.diaperPoop++; + else stats.diaperBoth++; + break; + } + }); + + return stats; +}; + +const formatDuration = (minutes: number): string => { + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +}; + +export default function StatsPanel() { + const selectedDate = useMemo(() => new Date(), []); // TODO: Make this changeable + + const activitiesQuery = useLiveQuery( + () => + db.getActivitiesBetween( + startOfDay(selectedDate).getTime(), + endOfDay(selectedDate).getTime(), + ), + [selectedDate], + ); + const activities = useMemo(() => activitiesQuery || [], [activitiesQuery]); + + const stats = useMemo( + () => calculateStats(activities as Activity[]), + [activities], + ); + + return ( +
+

Today's Stats

+ +
+ {(stats.breast > 0 || stats.breastSessions > 0) && ( + + )} + + {(stats.pump > 0 || stats.pumpSessions > 0) && ( + + )} + + {(stats.sleep > 0 || stats.sleepSessions > 0) && ( + + )} + + {/* Volume stats */} + {stats.volumePumped > 0 && ( + + )} + + {stats.volumeDrank > 0 && ( + 0 + ? `${stats.bottleSessions} bottle${stats.bottleSessions !== 1 ? "s" : ""}` + : undefined + } + /> + )} + + {/* Diaper stats */} + {(stats.diaperPee > 0 || + stats.diaperPoop > 0 || + stats.diaperBoth > 0) && ( + 0 && `${stats.diaperPee} pee`, + stats.diaperPoop > 0 && `${stats.diaperPoop} poop`, + stats.diaperBoth > 0 && `${stats.diaperBoth} both`, + ] + .filter(Boolean) + .join(" • ")} + /> + )} + + {activities.length === 0 && ( +

+ No activities today +

+ )} +
+
+ ); +} + +function StatCard({ + type, + label, + primary, + secondary, +}: { + type: string; + label: string; + primary: string; + secondary?: string; +}) { + return ( +
+
+ {getIcon(type)} +
+
+

{label}

+

{primary}

+ {secondary &&

{secondary}

} +
+
+ ); +} diff --git a/src/db.ts b/src/db.ts index 636b062..0010256 100644 --- a/src/db.ts +++ b/src/db.ts @@ -29,6 +29,16 @@ export class ActivityDB extends Dexie { // if (data.id) return this.activities.update(data.id, data); // return this.activities.add(data); // } + async getActivitiesBetween( + startTime: number, + endTime: number, + ): Promise { + return this.activities + .where("startTime") + .between(startTime, endTime, true, true) + .reverse() + .sortBy("startTime"); + } async deleteActivity(id: number): Promise { await this.activities.delete(id);