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 BottleFeedCard from "./components/BottleFeedCard";
|
||||||
import DiaperCard from "./components/DiaperCard";
|
import DiaperCard from "./components/DiaperCard";
|
||||||
import SleepCard from "./components/SleepCard";
|
import SleepCard from "./components/SleepCard";
|
||||||
|
import StatsPanel from "./components/Stats";
|
||||||
import TimedVolumeCard from "./components/TimedVolumeCard";
|
import TimedVolumeCard from "./components/TimedVolumeCard";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
|
||||||
@ -149,11 +150,7 @@ function App() {
|
|||||||
|
|
||||||
{activeTab === "log" && <ActivityLog />}
|
{activeTab === "log" && <ActivityLog />}
|
||||||
|
|
||||||
{activeTab === "stats" && (
|
{activeTab === "stats" && <StatsPanel />}
|
||||||
<div className="text-foreground/60 py-12 text-center">
|
|
||||||
📊 Statistics coming soon...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSwipeable } from "react-swipeable";
|
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 { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { getColor, getIcon } from "../types/helpers";
|
import { getColor, getIcon } from "../types/helpers";
|
||||||
@ -10,24 +10,56 @@ import { gramsToOz } from "../utils/convert";
|
|||||||
const formatActivityDetails = (activity: Activity): string => {
|
const formatActivityDetails = (activity: Activity): string => {
|
||||||
if (!activity.isComplete) return "In progress";
|
if (!activity.isComplete) return "In progress";
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
switch (activity.type) {
|
switch (activity.type) {
|
||||||
case "bottle":
|
case "bottle":
|
||||||
return `${gramsToOz(activity.volumeGrams).toFixed(2)} oz`;
|
parts.push(`${gramsToOz(activity.volumeGrams).toFixed(2)} oz`);
|
||||||
|
break;
|
||||||
case "breast":
|
case "breast":
|
||||||
case "sleep":
|
|
||||||
case "pump":
|
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":
|
case "diaper":
|
||||||
return activity.diaperType;
|
parts.push(activity.diaperType);
|
||||||
default:
|
break;
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function ActivityLog() {
|
||||||
const allActivities =
|
const allActivities =
|
||||||
useLiveQuery(() => db.getRecentActivities(20), []) || [];
|
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<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -45,41 +77,49 @@ export default function ActivityLog() {
|
|||||||
setContextMenu({ x: e.clientX, y: e.clientY, activity });
|
setContextMenu({ x: e.clientX, y: e.clientY, activity });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Close context menu on click outside
|
|
||||||
const handleClickOutside = () => {
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-background border-foreground/10 rounded-2xl border p-5 shadow-lg"
|
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">
|
<h2 className="text-foreground mb-4 text-xl font-bold">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</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 && (
|
{contextMenu && (
|
||||||
<div
|
<div
|
||||||
className="bg-background border-foreground/20 fixed z-50 rounded-lg border shadow-xl"
|
className="bg-background border-foreground/20 fixed z-50 rounded-lg border shadow-xl"
|
||||||
@ -106,7 +146,7 @@ function ActivityItem({
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
idx: number;
|
itemKey: string;
|
||||||
isSwiped: boolean;
|
isSwiped: boolean;
|
||||||
onSwipe: () => void;
|
onSwipe: () => void;
|
||||||
onSwipeBack: () => void;
|
onSwipeBack: () => void;
|
||||||
@ -122,15 +162,13 @@ function ActivityItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl">
|
<div className="relative overflow-hidden rounded-xl">
|
||||||
{/* Delete background */}
|
|
||||||
<div
|
<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}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
<span className="text-lg font-bold text-white">Delete</span>
|
<span className="text-lg font-bold text-white">Delete</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div
|
<div
|
||||||
{...handlers}
|
{...handlers}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
@ -138,10 +176,10 @@ function ActivityItem({
|
|||||||
isSwiped ? "-translate-x-20" : ""
|
isSwiped ? "-translate-x-20" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="bg-foreground/5 flex items-start gap-3 rounded-xl p-3">
|
<div
|
||||||
<div
|
className={`flex items-start gap-3 rounded-xl p-3 ${getColor(activity.type)}`}
|
||||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${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>
|
<span className="text-lg">{getIcon(activity.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -150,22 +188,20 @@ function ActivityItem({
|
|||||||
<p className="text-foreground font-medium capitalize">
|
<p className="text-foreground font-medium capitalize">
|
||||||
{activity.type}
|
{activity.type}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground/60 text-sm">
|
<p className="text-foreground/80 text-sm">
|
||||||
{formatActivityDetails(activity)}
|
{formatActivityDetails(activity)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
{"endTime" in activity && activity.endTime && (
|
{"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")}
|
{format(activity.endTime, "h:mm a")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground/60 self-center text-xs">
|
<span className="text-foreground/50 text-xs">↓</span>
|
||||||
↑
|
|
||||||
</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")}
|
{format(activity.startTime, "h:mm a")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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);
|
// if (data.id) return this.activities.update(data.id, data);
|
||||||
// return this.activities.add(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> {
|
async deleteActivity(id: number): Promise<void> {
|
||||||
await this.activities.delete(id);
|
await this.activities.delete(id);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user