Files
2026-06-06 17:14:53 +02:00

245 lines
9.4 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
export interface DayCounter {
date: string; // YYYY-MM-DD
counter_id: number;
counter_name: string;
image_path: string | null;
increments: number;
}
interface Props {
dailyCounters: DayCounter[];
}
// Mon-first: 0=Mon … 6=Sun
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
function pad(n: number) { return String(n).padStart(2, '0'); }
function toDateStr(y: number, m: number, d: number) {
return `${y}-${pad(m + 1)}-${pad(d)}`;
}
// Returns 0 (Mon) … 6 (Sun) for a given date
function mondayDow(year: number, month: number, day: number) {
const dow = new Date(year, month, day).getDay(); // 0=Sun…6=Sat
return (dow + 6) % 7;
}
export default function CalendarView({ dailyCounters }: Props) {
const now = new Date();
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth());
const [selectedDay, setSelectedDay] = useState<{ date: string; counters: DayCounter[] } | null>(null);
// Close modal on Escape
useEffect(() => {
if (!selectedDay) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedDay(null); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedDay]);
const byDate = new Map<string, DayCounter[]>();
for (const dc of dailyCounters) {
if (!byDate.has(dc.date)) byDate.set(dc.date, []);
byDate.get(dc.date)!.push(dc);
}
const todayStr = toDateStr(now.getFullYear(), now.getMonth(), now.getDate());
const isCurrentMonth = viewYear === now.getFullYear() && viewMonth === now.getMonth();
function prev() {
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1);
}
function next() {
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
}
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstOffset = mondayDow(viewYear, viewMonth, 1); // blank cells before day 1
const totalCells = Math.ceil((daysInMonth + firstOffset) / 7) * 7;
const cells = Array.from({ length: totalCells }, (_, i) => {
const dayNum = i - firstOffset + 1;
if (dayNum < 1 || dayNum > daysInMonth) return null;
const date = toDateStr(viewYear, viewMonth, dayNum);
return { dayNum, date, counters: byDate.get(date) ?? [] };
});
return (
<div>
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prev}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text 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" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Prev
</button>
<div className="flex items-center gap-2">
<span className="font-semibold text-ctp-text">
{MONTH_NAMES[viewMonth]} {viewYear}
</span>
{!isCurrentMonth && (
<button
onClick={() => { setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }}
className="text-xs px-2 py-0.5 rounded-full bg-ctp-surface0 text-ctp-subtext1 hover:bg-ctp-surface1 hover:text-ctp-text transition-colors"
>
Today
</button>
)}
</div>
<button
onClick={next}
disabled={isCurrentMonth}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-ctp-subtext1 hover:bg-ctp-surface0 hover:text-ctp-text transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
Next
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
{/* Day-of-week header */}
<div className="grid grid-cols-7 mb-1">
{DOW.map(d => (
<div key={d} className="text-center text-ctp-overlay0 font-medium" style={{ fontSize: '10px' }}>
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-ctp-surface1 rounded-xl overflow-hidden border border-ctp-surface1">
{cells.map((cell, i) =>
cell ? (
<div
key={i}
onClick={() => setSelectedDay({ date: cell.date, counters: cell.counters })}
className={`bg-ctp-base min-h-[4.5rem] p-1 flex flex-col gap-0.5 cursor-pointer hover:bg-ctp-surface0 transition-colors ${
cell.date === todayStr ? 'ring-1 ring-inset ring-ctp-mauve' : ''
}`}
>
<span className={`text-xs font-semibold leading-none mb-0.5 ${
cell.date === todayStr
? 'text-ctp-mauve'
: cell.counters.length > 0
? 'text-ctp-text'
: 'text-ctp-overlay1'
}`}>
{cell.dayNum}
</span>
{cell.counters.slice(0, 3).map(c => (
<div
key={c.counter_id}
className="flex items-center gap-0.5 bg-ctp-surface0 rounded px-1 overflow-hidden"
title={`${c.counter_name}: +${c.increments}`}
>
<span className="text-ctp-subtext1 truncate leading-tight" style={{ fontSize: '9px' }}>
{c.counter_name}
</span>
<span className="text-ctp-mauve font-bold shrink-0 leading-tight" style={{ fontSize: '9px' }}>
+{c.increments}
</span>
</div>
))}
{cell.counters.length > 3 && (
<span className="text-ctp-overlay0 leading-tight" style={{ fontSize: '9px' }}>
+{cell.counters.length - 3} more
</span>
)}
</div>
) : (
<div key={i} className="bg-ctp-mantle min-h-[4.5rem]" />
)
)}
</div>
{/* Day detail modal */}
{selectedDay && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60"
onClick={() => setSelectedDay(null)}
>
<div
className="bg-ctp-surface0 rounded-2xl shadow-xl w-full max-w-sm p-6"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h3 className="font-bold text-ctp-text text-lg">
{new Date(selectedDay.date + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long', month: 'long', day: 'numeric',
})}
</h3>
<button
onClick={() => setSelectedDay(null)}
className="text-ctp-overlay1 hover:text-ctp-text transition-colors"
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>
{/* Counter list */}
{selectedDay.counters.length === 0 ? (
<p className="text-center text-ctp-overlay1 py-4">No activity on this day.</p>
) : (
<div className="space-y-3">
{selectedDay.counters.map(c => (
<div key={c.counter_id} className="flex items-center gap-3 bg-ctp-base rounded-xl p-3">
{c.image_path ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/uploads/${c.image_path}`}
alt=""
className="w-12 h-12 rounded-lg object-cover shrink-0 bg-ctp-surface1"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-ctp-surface1 shrink-0 flex items-center justify-center text-ctp-overlay0 text-xl">
#
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-ctp-text truncate">{c.counter_name}</p>
<p className="text-ctp-mauve font-bold text-lg leading-tight">+{c.increments}</p>
</div>
</div>
))}
</div>
)}
{/* Total */}
{selectedDay.counters.length > 1 && (
<p className="mt-4 text-right text-sm text-ctp-subtext1">
Total: <span className="text-ctp-mauve font-bold">
+{selectedDay.counters.reduce((s, c) => s + c.increments, 0)}
</span>
</p>
)}
</div>
</div>
)}
</div>
);
}