175 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|