feat: stats & tweak activity view
This commit is contained in:
parent
3d24214064
commit
44620d4627
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
208
src/components/Stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/db.ts
10
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<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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user