Files
tally-counter/app/components/CounterDetailModal.tsx
T
2026-06-06 17:14:53 +02:00

175 lines
6.2 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Counter } from './CounterCard';
import HistoryChart, { DayValue } from './HistoryChart';
import CalendarView, { DayCounter } from './CalendarView';
export interface HistoryData {
counter: Counter;
dailyActivity: DayValue[];
allTimeTotal: number;
}
interface Props {
counterId: number | null;
cache?: Map<number, HistoryData>;
onClose: () => void;
}
function toLocalDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function computeCurrentStreak(dateSet: Set<string>): number {
const today = new Date();
let streak = 0;
const startOffset = dateSet.has(toLocalDateStr(today)) ? 0 : 1;
for (let i = startOffset; i < 400; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
if (dateSet.has(toLocalDateStr(d))) streak++;
else break;
}
return streak;
}
function computeLongestStreak(sortedDates: string[]): number {
if (sortedDates.length === 0) return 0;
let best = 1, cur = 1;
for (let i = 1; i < sortedDates.length; i++) {
const prev = new Date(sortedDates[i - 1] + 'T00:00:00');
const curr = new Date(sortedDates[i] + 'T00:00:00');
const diff = (curr.getTime() - prev.getTime()) / 86400000;
if (diff === 1) { cur++; if (cur > best) best = cur; }
else cur = 1;
}
return best;
}
export default function CounterDetailModal({ counterId, cache, onClose }: Props) {
const [data, setData] = useState<HistoryData | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!counterId) return;
const cached = cache?.get(counterId);
if (cached) { setData(cached); setLoading(false); return; }
setData(null);
setLoading(true);
fetch(`/api/counters/${counterId}/history`)
.then(r => r.json())
.then((d: HistoryData) => { cache?.set(counterId, d); setData(d); setLoading(false); })
.catch(() => setLoading(false));
}, [counterId, cache]);
useEffect(() => {
if (!counterId) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [counterId, onClose]);
if (!counterId) return null;
const dateSet = new Set(data?.dailyActivity.map(d => d.date) ?? []);
const sortedDates = [...dateSet].sort();
const currentStreak = computeCurrentStreak(dateSet);
const longestStreak = computeLongestStreak(sortedDates);
const calendarData: DayCounter[] = (data?.dailyActivity ?? []).map(d => ({
date: d.date,
counter_id: data!.counter.id,
counter_name: data!.counter.name,
image_path: data!.counter.image_path,
increments: d.value,
}));
const stats = data ? [
{ label: 'Current value', value: data.counter.value.toLocaleString() },
{ label: 'All-time net', value: data.allTimeTotal >= 0 ? `+${data.allTimeTotal}` : String(data.allTimeTotal) },
{ label: 'Current streak', value: `${currentStreak}d` },
{ label: 'Longest streak', value: `${longestStreak}d` },
] : [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="bg-ctp-mantle rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-ctp-surface1 shrink-0">
{data?.counter.image_path && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${data.counter.image_path}`}
alt=""
className="w-9 h-9 rounded-lg object-cover bg-ctp-surface0 shrink-0"
/>
)}
<h2 className="text-lg font-bold text-ctp-text flex-1 truncate">
{data?.counter.name ?? '…'}
</h2>
<button
onClick={onClose}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors shrink-0"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{loading && (
<div className="flex items-center justify-center py-16">
<p className="text-ctp-overlay1 animate-pulse">Loading</p>
</div>
)}
{!loading && data && (
<>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div key={label} className="bg-ctp-base rounded-xl p-3 text-center">
<p className="text-xl font-extrabold text-ctp-mauve tabular-nums">{value}</p>
<p className="text-xs text-ctp-subtext1 mt-0.5">{label}</p>
</div>
))}
</div>
{data.dailyActivity.length === 0 ? (
<div className="text-center py-10 space-y-1">
<p className="text-ctp-overlay1">No activity in the last year.</p>
<p className="text-ctp-overlay0 text-sm">Start tapping + to see history here.</p>
</div>
) : (
<>
{/* 90-day bar chart */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-3">Last 90 Days</h3>
<HistoryChart data={data.dailyActivity} days={90} />
</div>
{/* Calendar */}
<div>
<h3 className="text-sm font-bold text-ctp-text mb-4">Activity Calendar</h3>
<CalendarView dailyCounters={calendarData} />
</div>
</>
)}
</>
)}
</div>
</div>
</div>
);
}