245 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|