feat: stats & tweak activity view

This commit is contained in:
Evan Reichard 2025-10-18 22:17:05 -04:00
parent 3d24214064
commit 44620d4627
4 changed files with 305 additions and 54 deletions

View File

@ -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" && <ActivityLog />}
{activeTab === "stats" && (
<div className="text-foreground/60 py-12 text-center">
📊 Statistics coming soon...
</div>
)}
{activeTab === "stats" && <StatsPanel />}
</div>
);
}

View File

@ -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<number, Activity[]>();
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<number | null>(null);
const groupedActivities = groupByDay(allActivities as Activity[]);
const [swipedIndex, setSwipedIndex] = useState<string | null>(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 (
<div
className="bg-background border-foreground/10 rounded-2xl border p-5 shadow-lg"
onClick={handleClickOutside}
onClick={() => setContextMenu(null)}
>
<h2 className="text-foreground mb-4 text-xl font-bold">
Recent Activity
</h2>
<div className="space-y-3">
{allActivities.length === 0 ? (
<p className="text-foreground/60 py-8 text-center">
No activities yet
</p>
) : (
allActivities.map((activity, idx) => (
<ActivityItem
key={idx}
activity={activity}
idx={idx}
isSwiped={swipedIndex === idx}
onSwipe={() => setSwipedIndex(idx)}
onSwipeBack={() => setSwipedIndex(null)}
onDelete={() => handleDelete(activity)}
onContextMenu={(e) => handleContextMenu(e, activity)}
/>
))
)}
</div>
{/* Context Menu */}
{groupedActivities.length === 0 ? (
<p className="text-foreground/60 py-8 text-center">No activities yet</p>
) : (
<div className="space-y-6">
{groupedActivities.map(([dayStart, activities]) => (
<div key={dayStart}>
<div className="sticky top-0 z-20 -mx-5 mb-3 px-5">
<div className="text-foreground/80 from-background via-background to-background/0 bg-gradient-to-b py-3 pb-3 text-center text-sm font-semibold">
{formatDayHeader(dayStart)}
</div>
</div>
<div className="space-y-2">
{activities.map((activity) => {
const key = `${dayStart}-${activity.id}`;
return (
<ActivityItem
key={key}
activity={activity}
itemKey={key}
isSwiped={swipedIndex === key}
onSwipe={() => setSwipedIndex(key)}
onSwipeBack={() => setSwipedIndex(null)}
onDelete={() => handleDelete(activity)}
onContextMenu={(e) => handleContextMenu(e, activity)}
/>
);
})}
</div>
</div>
))}
</div>
)}
{contextMenu && (
<div
className="bg-background border-foreground/20 fixed z-50 rounded-lg border shadow-xl"
@ -106,7 +146,7 @@ function ActivityItem({
onContextMenu,
}: {
activity: Activity;
idx: number;
itemKey: string;
isSwiped: boolean;
onSwipe: () => void;
onSwipeBack: () => void;
@ -122,15 +162,13 @@ function ActivityItem({
return (
<div className="relative overflow-hidden rounded-xl">
{/* Delete background */}
<div
className="absolute inset-0.5 flex items-center justify-end rounded-xl bg-red-500 px-4"
className="absolute inset-px flex items-center justify-end rounded-xl bg-red-500 pr-3"
onClick={onDelete}
>
<span className="text-lg font-bold text-white">Delete</span>
</div>
{/* Main content */}
<div
{...handlers}
onContextMenu={onContextMenu}
@ -138,10 +176,10 @@ function ActivityItem({
isSwiped ? "-translate-x-20" : ""
}`}
>
<div className="bg-foreground/5 flex items-start gap-3 rounded-xl p-3">
<div
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${getColor(activity.type)}`}
>
<div
className={`flex items-start gap-3 rounded-xl p-3 ${getColor(activity.type)}`}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
<span className="text-lg">{getIcon(activity.type)}</span>
</div>
<div className="min-w-0 flex-1">
@ -150,22 +188,20 @@ function ActivityItem({
<p className="text-foreground font-medium capitalize">
{activity.type}
</p>
<p className="text-foreground/60 text-sm">
<p className="text-foreground/80 text-sm">
{formatActivityDetails(activity)}
</p>
</div>
<div className="flex flex-col items-end">
{"endTime" in activity && activity.endTime && (
<>
<span className="text-foreground/60 text-xs whitespace-nowrap">
<span className="text-foreground/70 text-xs whitespace-nowrap">
{format(activity.endTime, "h:mm a")}
</span>
<span className="text-foreground/60 self-center text-xs">
</span>
<span className="text-foreground/50 text-xs"></span>
</>
)}
<span className="text-foreground/60 text-xs whitespace-nowrap">
<span className="text-foreground/70 text-xs whitespace-nowrap">
{format(activity.startTime, "h:mm a")}
</span>
</div>

208
src/components/Stats.tsx Normal file
View File

@ -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 (
<div className="bg-background border-foreground/10 rounded-2xl border p-5 shadow-lg">
<h2 className="text-foreground mb-4 text-xl font-bold">Today's Stats</h2>
<div className="space-y-4">
{(stats.breast > 0 || stats.breastSessions > 0) && (
<StatCard
type="breast"
label="Breast"
primary={formatDuration(stats.breast)}
secondary={`${stats.breastSessions} session${stats.breastSessions !== 1 ? "s" : ""}`}
/>
)}
{(stats.pump > 0 || stats.pumpSessions > 0) && (
<StatCard
type="pump"
label="Pumping"
primary={formatDuration(stats.pump)}
secondary={`${stats.pumpSessions} session${stats.pumpSessions !== 1 ? "s" : ""}`}
/>
)}
{(stats.sleep > 0 || stats.sleepSessions > 0) && (
<StatCard
type="sleep"
label="Sleeping"
primary={formatDuration(stats.sleep)}
secondary={`${stats.sleepSessions} session${stats.sleepSessions !== 1 ? "s" : ""}`}
/>
)}
{/* Volume stats */}
{stats.volumePumped > 0 && (
<StatCard
type="pump"
label="Total Pumped"
primary={`${stats.volumePumped.toFixed(1)} oz`}
/>
)}
{stats.volumeDrank > 0 && (
<StatCard
type="bottle"
label="Bottle"
primary={`${stats.volumeDrank.toFixed(1)} oz`}
secondary={
stats.bottleSessions > 0
? `${stats.bottleSessions} bottle${stats.bottleSessions !== 1 ? "s" : ""}`
: undefined
}
/>
)}
{/* Diaper stats */}
{(stats.diaperPee > 0 ||
stats.diaperPoop > 0 ||
stats.diaperBoth > 0) && (
<StatCard
type="diaper"
label="Diapers"
primary={`${stats.diaperPee + stats.diaperPoop + stats.diaperBoth} total`}
secondary={[
stats.diaperPee > 0 && `${stats.diaperPee} pee`,
stats.diaperPoop > 0 && `${stats.diaperPoop} poop`,
stats.diaperBoth > 0 && `${stats.diaperBoth} both`,
]
.filter(Boolean)
.join(" • ")}
/>
)}
{activities.length === 0 && (
<p className="text-foreground/60 py-8 text-center">
No activities today
</p>
)}
</div>
</div>
);
}
function StatCard({
type,
label,
primary,
secondary,
}: {
type: string;
label: string;
primary: string;
secondary?: string;
}) {
return (
<div className={`flex items-center gap-3 rounded-xl p-3 ${getColor(type)}`}>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
<span className="text-lg">{getIcon(type)}</span>
</div>
<div className="flex-1">
<p className="text-foreground/80 text-sm">{label}</p>
<p className="text-foreground text-lg font-semibold">{primary}</p>
{secondary && <p className="text-foreground/70 text-xs">{secondary}</p>}
</div>
</div>
);
}

View File

@ -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<BaseActivity[]> {
return this.activities
.where("startTime")
.between(startTime, endTime, true, true)
.reverse()
.sortBy("startTime");
}
async deleteActivity(id: number): Promise<void> {
await this.activities.delete(id);