245 lines
9.7 KiB
TypeScript
245 lines
9.7 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;
|
|
onLog?: (counter: Counter) => 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, onLog }: Props) {
|
|
const [data, setData] = useState<HistoryData | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [logDate, setLogDate] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return toLocalDateStr(d); });
|
|
const [logging, setLogging] = useState(false);
|
|
const [logError, setLogError] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!counterId) return;
|
|
const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1);
|
|
setLogDate(toLocalDateStr(yesterday));
|
|
setLogError('');
|
|
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]);
|
|
|
|
async function handleLog(delta: number) {
|
|
if (!counterId || !logDate) return;
|
|
setLogging(true);
|
|
setLogError('');
|
|
try {
|
|
const res = await fetch(`/api/counters/${counterId}/log`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ date: logDate, delta }),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json();
|
|
setLogError(body.error ?? 'Failed to log entry');
|
|
return;
|
|
}
|
|
const updated: Counter = await res.json();
|
|
cache?.delete(counterId);
|
|
const histRes = await fetch(`/api/counters/${counterId}/history`);
|
|
const d: HistoryData = await histRes.json();
|
|
cache?.set(counterId, d);
|
|
setData(d);
|
|
onLog?.(updated);
|
|
} finally {
|
|
setLogging(false);
|
|
}
|
|
}
|
|
|
|
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>
|
|
</>
|
|
)}
|
|
|
|
{/* Log past entry */}
|
|
<div className="border-t border-ctp-surface1 pt-4">
|
|
<h3 className="text-sm font-bold text-ctp-text mb-3">Log past entry</h3>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<input
|
|
type="date"
|
|
value={logDate}
|
|
max={toLocalDateStr(new Date())}
|
|
onChange={e => setLogDate(e.target.value)}
|
|
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 text-ctp-text px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ctp-mauve"
|
|
/>
|
|
<button
|
|
onClick={() => handleLog(-1)}
|
|
disabled={logging || !logDate || (data?.dailyActivity.find(d => d.date === logDate)?.value ?? 0) <= 0}
|
|
aria-label="Log decrement"
|
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-red hover:bg-ctp-red/20 active:bg-ctp-red/30 disabled:opacity-40 transition-colors"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => handleLog(1)}
|
|
disabled={logging || !logDate}
|
|
aria-label="Log increment"
|
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-ctp-surface1 text-ctp-green hover:bg-ctp-green/20 active:bg-ctp-green/30 disabled:opacity-40 transition-colors"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
<line x1="5" y1="12" x2="19" y2="12" />
|
|
</svg>
|
|
</button>
|
|
{logError && <p className="text-xs text-ctp-red">{logError}</p>}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|