import { useMemo } from "react"; import type { CSSProperties } from "react"; import { GridData, ResolvedCell, planCategoryLabel, categoryMap, PlanCategory, } from "../lib/queries/plan"; import type { Project } from "../lib/queries/projects"; import PlanRangeChips from "./PlanRangeChips"; interface Props { data: GridData | undefined; canEdit: boolean; projects: Project[]; categories: PlanCategory[]; // The (userId, date) of the most recent successful mutation. The // matching cell gets a one-shot highlight pulse. `nonce` is part of // the value so back-to-back mutations on the same cell re-trigger // the animation (React needs a new key to re-mount the class). pulseKey?: { userId: number; date: string; nonce: number } | null; onCellClick: ( userId: number, date: string, cell: ResolvedCell | null, ) => void; } // Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday). // Used in the date column "stamp" so the day name reads in full rather // than as a 2-letter abbreviation — easier to scan when the planner // spans several weeks. const CZECH_WEEKDAYS = [ "Neděle", "Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota", ]; function eachDay(from: string, to: string): string[] { const out: string[] = []; const a = new Date(from + "T00:00:00.000Z"); const b = new Date(to + "T00:00:00.000Z"); for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) { out.push(d.toISOString().slice(0, 10)); } return out; } function czechWeekday(dateStr: string): string { return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()]; } function isWeekend(dateStr: string): boolean { const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay(); return day === 0 || day === 6; } // Today's local date as YYYY-MM-DD. Used to mark the current row and to // gate past-day editing. Uses local time (matches the project's date // conventions in CLAUDE.md and the server's `assertNotPastDate` guard in // src/services/plan.service.ts). function todayIso(): string { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } // True when the given YYYY-MM-DD cell date is strictly before today (local). // Used to gate the create / edit UI on past days — the server still enforces // the rule with a 403, but the client should never put the user in the // "I clicked, modal opened, error toast" state in the first place. function isPastDate(dateStr: string, today: string): boolean { return dateStr < today; } // Split a full name into first / last for the column header. function splitName(full: string): { first: string; last: string } { const parts = full.trim().split(/\s+/); if (parts.length === 1) return { first: parts[0], last: "" }; return { first: parts[0], last: parts.slice(1).join(" ") }; } // Short role label for the column header sub-line. function shortRole(role: string | null): string { if (!role) return ""; // Keep it brief — column header is tight if (/^admin$/i.test(role)) return "Admin"; if (/^viewer$/i.test(role)) return "Viewer"; return role.length > 10 ? role.slice(0, 9) + "…" : role; } export default function PlanGrid({ data, canEdit, projects, categories, pulseKey, onCellClick, }: Props) { const today = useMemo(() => todayIso(), []); const catMap = useMemo(() => categoryMap(categories), [categories]); if (!data) return (
); const days = eachDay(data.date_from, data.date_to); const users = data.users; // pulseKey?.nonce is included in the data-pulse attribute so a second // mutation on the same cell re-triggers the animation (CSS animations // don't restart unless the keyframe applies to a fresh element/class // binding). const pulseAttr = pulseKey ? `${pulseKey.userId}|${pulseKey.date}|${pulseKey.nonce}` : undefined; return (
{/* The colgroup is what actually controls column width in a table — `min-width` on `{users.map((u) => ( ))} {users.map((u) => { const { first, last } = splitName(u.full_name); return ( ); })} {days.map((date) => { const dow = czechWeekday(date); const dayNum = date.slice(8, 10); const isToday = date === today; const trCls = [ isWeekend(date) ? "plan-grid-weekend" : "", isToday ? "is-today" : "", ] .filter(Boolean) .join(" "); return ( {users.map((u) => { const cell = data.cells[u.id]?.[date] ?? null; const past = isPastDate(date, today); // Past-day cells are read-only in the UI: an empty past // cell is non-interactive (no create modal, no "+" hint), // a past cell with data opens in view mode only. The // server still enforces the past-date rule with a 403 // (defense in depth), but the click path here never // reaches a create/edit submission for a past date. const isLocked = !canEdit || past; // `isPulsing` is true for the single (user, date) cell // that the most recent successful mutation touched. // CSS restarts the keyframe animation whenever the // `nonce` changes (we embed it in data-pulse on the // wrapper, see above), so back-to-back mutations on the // same cell re-trigger the pulse. const isPulsing = !!pulseKey && pulseKey.userId === u.id && pulseKey.date === date; let cls: string; if (cell) { cls = isLocked ? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}` : "plan-cell"; } else { cls = isLocked ? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}` : "plan-cell plan-cell--empty"; } if (isPulsing) cls += " plan-cell--pulse"; return ( ); })} ); })}
`/`` is just a floor that `table-layout: auto` happily blows past. The first column gets a fixed width via the col element so the date stamp doesn't get stretched to share space with person columns. */}
Datum {first} {last ? ` ${last}` : ""} {shortRole(u.role_name) && ( {shortRole(u.role_name)} )}
{dayNum} {dow}
); }