'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, formatPercent, formatTime, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type SchedulerVariant = 'resource-week' | 'shift-plan' | 'room-day' | 'capacity' | 'availability';
export interface SchedResource {
id: string;
name: string;
/** Role or kind, e.g. Designer or Meeting room */
role?: string;
/** Team for filters */
team?: string;
/** Hours a day (people) or a week (capacity). Default 8 and 40. */
capacity?: number;
/** Rooms: seats */
seats?: number;
/** Rooms: equipment as icon names */
features?: IconName[];
}
export interface Booking {
id: string;
/** Resource id */
resource: string;
title: string;
/** YYYY-MM-DD, first day */
date: string;
/** Days it spans, default 1 */
days?: number;
/** Hours a day it takes, default 8 */
hours?: number;
/** room-day: HH:MM */
start?: string;
end?: string;
/** Colour slot 0 to 3 */
tone?: number;
tentative?: boolean;
/** Who booked it */
by?: string;
}
export interface ShiftType {
id: string;
label: string;
/** HH:MM */
start: string;
end: string;
/** People needed on each day */
needed?: number;
}
export interface Shift {
id: string;
/** Person id, null for an open shift */
resource: string | null;
date: string;
/** ShiftType id */
type: string;
}
export interface CapacityWeek {
/** Resource id */
resource: string;
/** YYYY-MM-DD, Monday */
week: string;
projects: { name: string; hours: number }[];
}
export interface BusyBlock {
resource: string;
date: string;
start: string;
end: string;
}
export interface SchedulerProps {
variant?: SchedulerVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
resources?: SchedResource[];
bookings?: Booking[];
shiftTypes?: ShiftType[];
shifts?: Shift[];
/** capacity: planned hours per person and week */
load?: CapacityWeek[];
/** availability: busy times per person */
busy?: BusyBlock[];
/** YYYY-MM-DD, Monday of the first week on screen */
weekStart?: string;
/** Days on screen, 5 or 7 */
days?: number;
/** capacity: weeks on screen */
weeks?: number;
/** YYYY-MM-DD that counts as today */
today?: string;
/** HH:MM that counts as now (room-day) */
now?: string;
/** First and last hour of the day grid */
dayStart?: number;
dayEnd?: number;
/** Called on book, move, resize, assign, publish and pick. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
newBooking: 'New booking',
book: 'Add booking',
booked: '{title} booked for {who} on {day}.',
bookCell: 'Book {who} on {day}',
what: 'What',
whatPlaceholder: 'Project or task',
who: 'Person',
day: 'Day',
length: 'Days',
perDay: 'Hours a day',
cancel: 'Cancel',
hint: 'Arrow keys move a booking by a day or to another person. Shift with left or right makes it shorter or longer. Delete removes it.',
roleBooking: 'booking',
moved: '{title}, {who}, {from} to {to}, {h} h a day.',
conflict: 'Over capacity on {days}.',
conflictShort: 'Over capacity',
load: '{h} of {c} h',
removed: '{title} removed.',
undo: 'Undo',
resize: 'Change the length of {title}',
moveTo: 'Move to',
assignTo: 'Hand over to',
remove: 'Remove',
menu: 'Actions for {title}',
tentative: 'Tentative',
days: '{n} days',
shifts: 'Shifts',
open: 'Open shifts',
openN: '{n} open',
staffed: '{n} of {m}',
assign: 'Assign {shift} on {day}',
assigned: '{who} takes the {shift} shift on {day}.',
addShift: 'Add a shift for {who} on {day}',
addThis: 'Add {shift}',
changeTo: 'Change to {shift}',
toOpen: 'Back to open shifts',
unassigned: '{shift} on {day} is open again.',
busyThatDay: 'already on shift',
hoursWeek: '{h} h this week',
contract: '{h} of {c} h',
publish: 'Publish plan',
publishing: 'Publishing…',
published: 'Plan published. Everyone gets their shifts.',
shiftMenu: '{shift} shift of {who} on {day}',
rooms: 'Rooms',
seats: '{n} seats',
all: 'All',
seatsMin: '{n}+ seats',
roomSize: 'Room size',
nowLine: 'Now {t}',
free: 'Free {from} to {to}',
bookFree: 'Book {room} from {from}',
bookRoom: 'Book room',
bookingFor: '{room} from {from}',
duration: 'Length',
roomBooked: '{room} booked from {from} to {to}.',
nothingFree: 'Nothing free for the rest of the day.',
pickGap: 'Pick a free slot in the plan to book it.',
capacity: 'Capacity',
unit: 'Show as',
percent: 'Percent',
hours: 'Hours',
team: 'Team',
teamAvg: 'Team average',
weekOf: 'Week of {d}',
utilization: '{who}, week of {d}, {p} booked',
over: '{h} h over',
under: '{h} h free',
exact: 'Fully booked',
pickCell: 'Pick a cell to see what fills that week.',
legendLow: 'Room to spare',
legendGood: 'Well booked',
legendFull: 'Full',
legendOver: 'Over capacity',
people: 'People',
pickPeople: 'Who needs to be there',
meetingLength: 'Meeting length',
min: '{n} min',
allowOne: 'Allow one person to miss',
suggestions: 'Best times',
allFree: 'All {n} free',
oneMissing: '{who} is busy',
pick: 'Pick this time',
picked: 'Invite sent for {when}.',
noSlot: 'No common time this week. Allow one person to miss or pick a shorter meeting.',
busy: 'Busy',
past: 'Past',
freeAll: 'Everyone free',
freeMost: 'All but one free',
needTwo: 'Pick at least two people.',
slotLabel: '{day} {from} to {to}',
to: 'to',
},
de: {
newBooking: 'Neue Buchung',
book: 'Buchung anlegen',
booked: '{title} für {who} am {day} gebucht.',
bookCell: '{who} am {day} buchen',
what: 'Was',
whatPlaceholder: 'Projekt oder Aufgabe',
who: 'Person',
day: 'Tag',
length: 'Tage',
perDay: 'Stunden am Tag',
cancel: 'Abbrechen',
hint: 'Die Pfeiltasten verschieben eine Buchung um einen Tag oder zu einer anderen Person. Umschalt mit links oder rechts macht sie kürzer oder länger. Entfernen löscht sie.',
roleBooking: 'Buchung',
moved: '{title}, {who}, {from} bis {to}, {h} Std. am Tag.',
conflict: 'Überbucht am {days}.',
conflictShort: 'Überbucht',
load: '{h} von {c} Std.',
removed: '{title} entfernt.',
undo: 'Rückgängig',
resize: 'Länge von {title} ändern',
moveTo: 'Verschieben nach',
assignTo: 'Übergeben an',
remove: 'Entfernen',
menu: 'Aktionen für {title}',
tentative: 'Vorläufig',
days: '{n} Tage',
shifts: 'Schichten',
open: 'Offene Schichten',
openN: '{n} offen',
staffed: '{n} von {m}',
assign: '{shift} am {day} vergeben',
assigned: '{who} übernimmt die Schicht {shift} am {day}.',
addShift: 'Schicht für {who} am {day} anlegen',
addThis: '{shift} anlegen',
changeTo: 'In {shift} ändern',
toOpen: 'Zurück zu den offenen Schichten',
unassigned: '{shift} am {day} ist wieder offen.',
busyThatDay: 'hat schon Schicht',
hoursWeek: '{h} Std. diese Woche',
contract: '{h} von {c} Std.',
publish: 'Plan veröffentlichen',
publishing: 'Wird veröffentlicht…',
published: 'Plan veröffentlicht. Alle bekommen ihre Schichten.',
shiftMenu: 'Schicht {shift} von {who} am {day}',
rooms: 'Räume',
seats: '{n} Plätze',
all: 'Alle',
seatsMin: 'Ab {n} Plätzen',
roomSize: 'Raumgröße',
nowLine: 'Jetzt {t}',
free: 'Frei von {from} bis {to}',
bookFree: '{room} ab {from} buchen',
bookRoom: 'Raum buchen',
bookingFor: '{room} ab {from}',
duration: 'Dauer',
roomBooked: '{room} von {from} bis {to} gebucht.',
nothingFree: 'Für den Rest des Tages ist nichts frei.',
pickGap: 'Wähle eine freie Zeit im Plan, um sie zu buchen.',
capacity: 'Auslastung',
unit: 'Anzeigen als',
percent: 'Prozent',
hours: 'Stunden',
team: 'Team',
teamAvg: 'Teamschnitt',
weekOf: 'Woche vom {d}',
utilization: '{who}, Woche vom {d}, {p} verplant',
over: '{h} Std. zu viel',
under: '{h} Std. frei',
exact: 'Voll verplant',
pickCell: 'Wähle eine Zelle, dann steht hier, was die Woche füllt.',
legendLow: 'Luft',
legendGood: 'Gut verplant',
legendFull: 'Voll',
legendOver: 'Überbucht',
people: 'Personen',
pickPeople: 'Wer dabei sein muss',
meetingLength: 'Dauer des Termins',
min: '{n} Min.',
allowOne: 'Eine Person darf fehlen',
suggestions: 'Beste Zeiten',
allFree: 'Alle {n} frei',
oneMissing: '{who} ist belegt',
pick: 'Diese Zeit wählen',
picked: 'Einladung für {when} verschickt.',
noSlot: 'Diese Woche gibt es keine gemeinsame Zeit. Lass eine Person fehlen oder wähle einen kürzeren Termin.',
busy: 'Belegt',
past: 'Vorbei',
freeAll: 'Alle frei',
freeMost: 'Alle bis auf eine Person frei',
needTwo: 'Wähle mindestens zwei Personen.',
slotLabel: '{day} {from} bis {to}',
to: 'bis',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
const CSS =
'@keyframes sch-up{from{opacity:0;transform:translateY(10px)}}@keyframes sch-wipe{from{clip-path:inset(0 100% 0 0)}}@keyframes sch-flash{from{box-shadow:0 0 0 3px color-mix(in oklab,var(--primary) 45%,transparent)}}@keyframes sch-pop{from{opacity:0;transform:scale(.85)}}@keyframes sch-pulse{0%{transform:scale(1);opacity:.6}80%,100%{transform:scale(2.4);opacity:0}}' +
'.sch-stripes{background-image:repeating-linear-gradient(135deg,color-mix(in oklab,var(--destructive) 14%,transparent) 0 6px,transparent 6px 12px)}' +
'@media (prefers-reduced-motion:no-preference){[data-sch=play] .sch-up{animation:sch-up .55s var(--ease-kit) var(--sch-d,0ms) both}[data-sch=wait] .sch-up{opacity:0}[data-sch=play] .sch-wipe{animation:sch-wipe .9s var(--ease-kit) var(--sch-d,0ms) both}[data-sch=wait] .sch-wipe{clip-path:inset(0 100% 0 0)}.sch-flash{animation:sch-flash 1.4s ease-out}.sch-in{animation:sch-up .4s var(--ease-kit) both}.sch-pop{animation:sch-pop .35s var(--ease-kit) both}.sch-pulse{animation:sch-pulse 2s var(--ease-kit) infinite}}';
function Style() {
return (
<style href="kitb5-scheduler" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
});
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-sch': state }] as const;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const dayMs = 86400000;
const dayNum = (iso: string) => Math.round(Date.parse(iso.slice(0, 10) + 'T00:00:00Z') / dayMs);
const isoOf = (n: number) => new Date(n * dayMs).toISOString().slice(0, 10);
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const fmtDay = (iso: string, l: Locale, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tagOf(l), { ...o, timeZone: 'UTC' }).format(new Date(dayNum(iso) * dayMs));
const toMin = (hhmm: string) => {
const [h, m] = hhmm.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const hhmm = (min: number) => `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(Math.round(min % 60)).padStart(2, '0')}`;
const TONES = [
'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--foreground)_22%,var(--border))] bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--warning)_55%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_18%,var(--card))] text-foreground',
'border-[color-mix(in_oklab,var(--success)_50%,var(--border))] bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-foreground',
];
const TONE_BAR = ['bg-primary', 'bg-[color-mix(in_oklab,var(--foreground)_45%,var(--card))]', 'bg-warning', 'bg-success'];
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-30 sm:size-8', ring);
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full min-w-0 max-w-[96rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,var(--background))] p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn('relative inline-flex h-11 flex-1 max-sm:min-w-11 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[color,background-color,box-shadow] duration-200 sm:h-8', ring, value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground')}
>
{o.label}
</button>
))}
</div>
);
}
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
const id = useId();
return (
<span className={cn('grid min-w-0 gap-1.5', className)}>
<label htmlFor={id} className="text-[12px] font-medium text-muted-foreground">
{label}
</label>
<span className="relative block">
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</span>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Toast({ text, onUndo, t }: { text: string; onUndo?: () => void; t: Texts }) {
if (!text) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
<div key={text} className="sch-in pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 ps-4 pe-2 text-[13.5px] text-popover-foreground shadow-[var(--shadow-lg)]">
<Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1 text-pretty">{text}</span>
{onUndo && (
<button type="button" onClick={onUndo} className={cn('h-11 shrink-0 cursor-pointer rounded-md px-3 text-[13px] font-semibold text-foreground hover:bg-accent sm:h-8', ring)}>
{t.undo}
</button>
)}
</div>
</div>
);
}
function useToast() {
const [toast, setToast] = useState<{ text: string; undo?: () => void } | null>(null);
const timer = useRef(0);
const show = (text: string, undo?: () => void) => {
setToast({ text, undo });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setToast(null), 5000);
};
return [toast, show, () => setToast(null)] as const;
}
/** Day tabs for phones: a row of days as a radiogroup */
function DayTabs({ days, value, onChange, label, locale, today, extra }: { days: string[]; value: number; onChange: (i: number) => void; label: string; locale: Locale; today: string; extra?: (i: number) => React.ReactNode }) {
return (
<div role="radiogroup" aria-label={label} className="grid gap-1" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }}>
{days.map((d, i) => (
<button
key={d}
type="button"
role="radio"
aria-checked={value === i}
tabIndex={value === i ? 0 : -1}
aria-label={fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'long' })}
onClick={() => onChange(i)}
onKeyDown={(e) => {
const k = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!k) return;
e.preventDefault();
const n = (i + k + days.length) % days.length;
onChange(n);
(e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
}}
className={cn('relative flex h-14 cursor-pointer flex-col items-center justify-center rounded-lg border transition-colors', ring, value === i ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-foreground')}
>
<span className={cn('text-[11.5px] font-medium', value === i ? 'opacity-80' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}</span>
<span className="text-[14px] font-semibold tabular-nums">{fmtDay(d, locale, { day: 'numeric' })}</span>
{d === today && <span aria-hidden className={cn('absolute bottom-1 size-1 rounded-full', value === i ? 'bg-background' : 'bg-primary')} />}
{extra?.(i)}
</button>
))}
</div>
);
}
type Ctx = { t: Texts; locale: Locale; res: (id: string | null) => SchedResource | undefined };
type VP = Omit<SchedulerProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { ctx: Ctx; resources: SchedResource[]; today: string; weekStart: string; days: number };
/* ------------------------------------------------------------------ */
/* resource-week */
/* ------------------------------------------------------------------ */
function ResourceWeek({ ctx, title, subtitle, resources, bookings: initial = [], weekStart, days: n, today, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: n }, (_, i) => isoOf(w0 + i));
const [items, setItems] = useState<Booking[]>(initial);
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [dragging, setDragging] = useState<string | null>(null);
const [form, setForm] = useState<{ who: string; day: number; title: string; span: number; hours: number } | null>(null);
const [mday, setMday] = useState(Math.max(0, Math.min(n - 1, dayNum(today) - w0)));
const [toast, show, hide] = useToast();
const [scope, enter] = useEnter<HTMLDivElement>();
const formRef = useRef<HTMLFormElement>(null);
const idx = (b: Booking) => dayNum(b.date) - w0;
const span = (b: Booking) => b.days ?? 1;
const cap = (r: SchedResource) => r.capacity ?? 8;
const dayName = (i: number, o: Intl.DateTimeFormatOptions = { weekday: 'long' }) => fmtDay(days[Math.max(0, Math.min(n - 1, i))], locale, o);
const loadOf = (rid: string, d: number, list = items) => list.filter((b) => b.resource === rid && idx(b) <= d && idx(b) + span(b) > d).reduce((a, b) => a + (b.hours ?? 8), 0);
const overDays = (b: Booking, list = items) => {
const r = ctx.res(b.resource);
if (!r) return [];
return Array.from({ length: span(b) }, (_, k) => idx(b) + k).filter((d) => d >= 0 && d < n && loadOf(b.resource, d, list) > cap(r));
};
const describe = (b: Booking, list = items) => {
const od = overDays(b, list);
return fill(t.moved, { title: b.title, who: ctx.res(b.resource)?.name ?? '', from: dayName(idx(b)), to: dayName(idx(b) + span(b) - 1), h: b.hours ?? 8 }) + (od.length ? ` ${fill(t.conflict, { days: od.map((d) => dayName(d)).join(', ') })}` : '');
};
// Stack bookings of a person in lanes so overlapping ones sit on top of each other
const lanesOf = (rid: string) => {
const mine = items.filter((b) => b.resource === rid).sort((a, b) => idx(a) - idx(b) || span(b) - span(a));
const ends: number[] = [];
const lane: Record<string, number> = {};
for (const b of mine) {
let l = ends.findIndex((e) => e <= idx(b));
if (l < 0) l = ends.length;
ends[l] = idx(b) + span(b);
lane[b.id] = l;
}
return { mine, lane, count: Math.max(1, ends.length) };
};
const change = (b: Booking, patch: Partial<Booking>, announce = true) => {
const next = items.map((x) => (x.id === b.id ? { ...x, ...patch } : x));
setItems(next);
if (announce) setSay(describe({ ...b, ...patch }, next));
return next;
};
const moveBy = (b: Booking, dd: number, dr: number, ds: number) => {
const s = Math.max(1, Math.min(n, span(b) + ds));
const d = Math.max(0, Math.min(n - s, idx(b) + dd));
const ri = resources.findIndex((r) => r.id === b.resource) + dr;
if (ri < 0 || ri >= resources.length) return;
change(b, { date: days[d], days: s, resource: resources[ri].id });
void onAction?.('move', { id: b.id, date: days[d], days: s, resource: resources[ri].id });
};
const removeB = (b: Booking) => {
const before = items;
setItems((x) => x.filter((y) => y.id !== b.id));
const text = fill(t.removed, { title: b.title });
setSay(text);
show(text, () => (setItems(before), hide()));
void onAction?.('delete', { id: b.id });
};
const onKey = (e: React.KeyboardEvent, b: Booking) => {
if (e.key === 'Delete' || e.key === 'Backspace') return (e.preventDefault(), removeB(b));
const m: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const v = m[e.key];
if (!v) return;
e.preventDefault();
if (e.shiftKey && v[0]) moveBy(b, 0, 0, v[0]);
else moveBy(b, v[0], v[1], 0);
requestAnimationFrame(() => document.getElementById(`${id}-b-${b.id}`)?.focus());
};
const onPointer = (e: React.PointerEvent<HTMLElement>, b: Booking, mode: 'move' | 'size') => {
if (e.button !== 0 || (mode === 'move' && (e.target as HTMLElement).closest('[data-handle],button'))) return;
const track = (e.currentTarget.closest('[data-track]') as HTMLElement | null)?.getBoundingClientRect();
if (!track) return;
e.preventDefault();
const colW = track.width / n;
const sx = e.clientX;
const orig = { ...b };
const before = items;
let began = false;
let cur = b;
const move = (ev: PointerEvent) => {
if (!began && Math.abs(ev.clientX - sx) < 4 && Math.abs(ev.clientY - e.clientY) < 4) return;
if (!began) {
began = true;
setDragging(b.id);
document.body.style.userSelect = 'none';
}
const dx = Math.round((ev.clientX - sx) / colW);
let patch: Partial<Booking>;
if (mode === 'size') {
patch = { days: Math.max(1, Math.min(n - idx(orig), span(orig) + dx)) };
} else {
const d = Math.max(0, Math.min(n - span(orig), idx(orig) + dx));
const row = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-res]');
patch = { date: days[d], resource: row?.dataset.res ?? cur.resource };
}
const next = { ...orig, ...patch };
if (next.date !== cur.date || next.resource !== cur.resource || next.days !== cur.days) {
cur = next;
setItems((x) => x.map((y) => (y.id === b.id ? next : y)));
}
};
const end = (ok: boolean) => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
document.removeEventListener('pointercancel', cancel);
document.removeEventListener('keydown', key, true);
document.body.style.userSelect = '';
setDragging(null);
if (!began) return;
if (!ok) {
setItems(before);
return;
}
setFresh(b.id);
window.setTimeout(() => setFresh((f) => (f === b.id ? null : f)), 1500);
setItems((x) => {
setSay(describe(cur, x));
return x;
});
show(describe(cur), () => (setItems(before), hide()));
void onAction?.(mode === 'size' ? 'resize' : 'move', { id: b.id, date: cur.date, days: cur.days, resource: cur.resource });
};
const up = () => end(true);
const cancel = () => end(false);
const key = (ev: KeyboardEvent) => ev.key === 'Escape' && (ev.preventDefault(), end(false));
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
document.addEventListener('pointercancel', cancel);
document.addEventListener('keydown', key, true);
};
const openForm = (who: string, day: number) => {
setForm({ who, day, title: '', span: 1, hours: 4 });
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
formRef.current?.querySelector<HTMLInputElement>('input')?.focus({ preventScroll: true });
});
};
const menuFor = (b: Booking): MenuItem[] => [
{ type: 'label', label: t.moveTo },
...days.map((d, i) => ({ type: 'radio' as const, group: 'day', value: d, checked: idx(b) === i, label: dayName(i, { weekday: 'long', day: 'numeric', month: 'short' }), onSelect: () => (change(b, { date: days[Math.min(i, n - span(b))] }), void onAction?.('move', { id: b.id, date: d })) })),
{ type: 'separator' },
{ type: 'label', label: t.assignTo },
...resources.filter((r) => r.id !== b.resource).map((r) => ({ label: r.name, icon: 'user' as const, onSelect: () => (change(b, { resource: r.id }), void onAction?.('move', { id: b.id, resource: r.id })) })),
{ type: 'separator' },
{ label: t.remove, icon: 'trash', destructive: true, onSelect: () => removeB(b) },
];
const totalOf = (r: SchedResource) => days.reduce((a, _, d) => a + loadOf(r.id, d), 0);
const weekCap = (r: SchedResource) => cap(r) * Math.min(n, 5);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" iconLeft="plus" magnetic className="max-sm:w-full" onClick={() => openForm(resources[0]?.id ?? '', mday)}>
{t.newBooking}
</Button>
</Header>
<p id={`${id}-hint`} className="sr-only">
{t.hint}
</p>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
{/* Tablet and desktop: people by days */}
<div className="sch-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-md:hidden">
<div className="grid grid-cols-[12rem_minmax(0,1fr)] border-b border-border bg-surface lg:grid-cols-[14rem_minmax(0,1fr)]">
<span className="px-4 py-2.5 text-[12px] font-medium text-muted-foreground">{t.who}</span>
<div className="grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{days.map((d) => (
<span key={d} className={cn('flex items-baseline justify-center gap-1.5 border-s border-border py-2.5 text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}
<span className={cn('tabular-nums', d === today ? 'grid size-6 place-items-center rounded-full bg-primary text-primary-foreground' : 'text-subtle-foreground')}>{fmtDay(d, locale, { day: 'numeric' })}</span>
</span>
))}
</div>
</div>
{resources.map((r, ri) => {
const { mine, lane, count } = lanesOf(r.id);
const total = totalOf(r);
const wc = weekCap(r);
return (
<div key={r.id} data-res={r.id} className="grid grid-cols-[12rem_minmax(0,1fr)] border-b border-border last:border-b-0 lg:grid-cols-[14rem_minmax(0,1fr)]">
<div className="flex min-w-0 items-center gap-2.5 px-4 py-3">
<Avatar name={r.name} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-foreground">{r.name}</p>
<p className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span className="truncate">{r.role}</span>
</p>
<span className="mt-1 flex items-center gap-2">
<span aria-hidden className="relative h-1 flex-1 overflow-hidden rounded-full bg-track">
<span className={cn('sch-wipe absolute inset-y-0 start-0 rounded-full transition-[width] duration-500', total > wc ? 'bg-destructive' : total === wc ? 'bg-success' : 'bg-primary')} style={{ width: `${round(Math.min(100, (total / wc) * 100), 1)}%`, '--sch-d': `${ri * 60}ms` } as React.CSSProperties} />
</span>
<span className={cn('shrink-0 text-[11.5px] tabular-nums', total > wc ? 'font-semibold text-destructive' : 'text-muted-foreground')}>{fill(t.load, { h: total, c: wc })}</span>
</span>
</div>
</div>
<div data-track className="relative min-w-0">
{/* Day cells: background, load per day, and a way to book an empty day */}
<div className="absolute inset-0 grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{days.map((d, di) => {
const l = loadOf(r.id, di);
const over = l > cap(r);
return (
<button key={d} type="button" tabIndex={-1} aria-label={fill(t.bookCell, { who: r.name, day: dayName(di) })} onClick={() => openForm(r.id, di)} className={cn('group/cell relative cursor-pointer border-s border-border transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]', d === today && 'bg-[color-mix(in_oklab,var(--primary)_4%,transparent)]', over && 'sch-stripes', di >= 5 && 'bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
<span aria-hidden className="pointer-events-none absolute bottom-1.5 end-2 text-[11px] tabular-nums text-subtle-foreground opacity-0 transition-opacity group-hover/cell:opacity-100">
{l}/{cap(r)}
</span>
{!l && <Icon name="plus" size={14} className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-subtle-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" />}
</button>
);
})}
</div>
<div className="pointer-events-none relative grid gap-y-1.5 px-1 py-2" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${count}, 2.75rem)` }}>
{mine.map((b) => {
const od = overDays(b);
const tone = b.tone ?? 0;
return (
<div
key={b.id}
id={`${id}-b-${b.id}`}
role="button"
tabIndex={0}
aria-roledescription={t.roleBooking}
aria-describedby={`${id}-hint`}
aria-label={describe(b)}
onPointerDown={(e) => onPointer(e, b, 'move')}
onKeyDown={(e) => onKey(e, b)}
className={cn(
'pointer-events-auto group/bar relative mx-0.5 flex min-w-0 cursor-grab items-center gap-2 overflow-hidden rounded-lg border ps-2.5 pe-1 shadow-[var(--shadow-sm)] transition-[box-shadow,transform,opacity] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-md)] active:cursor-grabbing',
ring,
TONES[tone % 4],
b.tentative && 'border-dashed bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))]',
od.length > 0 && 'border-[color-mix(in_oklab,var(--destructive)_60%,var(--border))]',
dragging === b.id && 'z-10 scale-[1.02] shadow-[var(--shadow-lg)]',
fresh === b.id && 'sch-flash',
)}
style={{ gridColumn: `${idx(b) + 1} / span ${span(b)}`, gridRow: lane[b.id] + 1 }}
>
<span aria-hidden className={cn('absolute inset-y-1.5 start-1 w-[3px] rounded-full', TONE_BAR[tone % 4])} />
<span className="min-w-0 flex-1 ps-1">
<span className="block truncate text-[12.5px] font-semibold leading-tight">{b.title}</span>
<span className="flex min-w-0 items-center gap-1 overflow-hidden whitespace-nowrap text-[11.5px] leading-tight text-muted-foreground">
{od.length > 0 && <Icon name="alert-circle" size={11} className="shrink-0 text-destructive" />}
<span className="shrink-0 tabular-nums">{b.hours ?? 8} h</span>
{(od.length > 0 || b.tentative) && <span aria-hidden>·</span>}
{od.length > 0 ? <span className="truncate font-medium text-destructive">{t.conflictShort}</span> : b.tentative ? <span className="truncate">{t.tentative}</span> : null}
</span>
</span>
<DropdownMenu
align="end"
items={menuFor(b)}
trigger={
<button type="button" tabIndex={-1} aria-label={fill(t.menu, { title: b.title })} className={cn('grid size-7 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground opacity-70 transition-opacity hover:bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] hover:opacity-100', span(b) === 1 && 'max-xl:absolute max-xl:end-0.5 max-xl:top-1/2 max-xl:-translate-y-1/2 max-xl:bg-card max-xl:opacity-0 max-xl:group-hover/bar:opacity-100 max-xl:group-focus-within/bar:opacity-100')}>
<Icon name="more-vertical" size={14} />
</button>
}
/>
<span data-handle aria-hidden title={fill(t.resize, { title: b.title })} onPointerDown={(e) => (e.stopPropagation(), onPointer(e, b, 'size'))} className="absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-lg transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)]" />
</div>
);
})}
</div>
</div>
</div>
);
})}
</div>
{/* Phone: one day at a time */}
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} extra={(i) => (resources.some((r) => loadOf(r.id, i) > cap(r)) ? <span aria-hidden className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-destructive" /> : null)} />
<ul key={mday} className="sch-in grid gap-2">
{resources.map((r) => {
const list = items.filter((b) => b.resource === r.id && idx(b) <= mday && idx(b) + span(b) > mday);
const l = loadOf(r.id, mday);
const over = l > cap(r);
return (
<li key={r.id} className={cn('rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]', over ? 'border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))]' : 'border-border')}>
<div className="flex items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-foreground">{r.name}</span>
<span className={cn('block text-[12px] tabular-nums', over ? 'font-semibold text-destructive' : 'text-muted-foreground')}>{over ? `${t.conflictShort} · ` : ''}{fill(t.load, { h: l, c: cap(r) })}</span>
</span>
<button type="button" aria-label={fill(t.bookCell, { who: r.name, day: dayName(mday) })} onClick={() => openForm(r.id, mday)} className={iconBtn}>
<Icon name="plus" size={16} />
</button>
</div>
{list.length > 0 && (
<ul className="mt-2 grid gap-1.5">
{list.map((b) => (
<li key={b.id} className={cn('flex items-center gap-2 rounded-lg border ps-3 pe-1', TONES[(b.tone ?? 0) % 4], b.tentative && 'border-dashed', fresh === b.id && 'sch-flash')}>
<span className="min-w-0 flex-1 py-2">
<span className="block truncate text-[13px] font-semibold">{b.title}</span>
<span className="block text-[12px] text-muted-foreground">
{span(b) > 1 ? `${dayName(idx(b), { weekday: 'short' })}–${dayName(idx(b) + span(b) - 1, { weekday: 'short' })} · ` : ''}
{b.hours ?? 8} h
</span>
</span>
<DropdownMenu
align="end"
items={menuFor(b)}
trigger={
<button type="button" aria-label={fill(t.menu, { title: b.title })} className={iconBtn}>
<Icon name="more-vertical" size={15} />
</button>
}
/>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
{form && (
<form
ref={formRef}
className="sch-in grid grid-cols-2 gap-3 rounded-xl border border-border-strong bg-card p-4 shadow-[var(--shadow-md)] sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto] sm:items-end sm:p-5"
onSubmit={(e) => {
e.preventDefault();
if (!form.title.trim()) return;
const b: Booking = { id: `b${Date.now()}`, resource: form.who, title: form.title.trim(), date: days[Math.min(form.day, n - form.span)], days: form.span, hours: form.hours, tone: items.length % 4 };
const next = [...items, b];
setItems(next);
setFresh(b.id);
setForm(null);
const text = fill(t.booked, { title: b.title, who: ctx.res(b.resource)?.name ?? '', day: dayName(idx(b)) }) + (overDays(b, next).length ? ` ${fill(t.conflict, { days: overDays(b, next).map((d) => dayName(d)).join(', ') })}` : '');
setSay(text);
show(text);
void onAction?.('book', { booking: b });
}}
>
<label className="col-span-2 grid gap-1.5 sm:col-span-1">
<span className="text-[12px] font-medium text-muted-foreground">{t.what}</span>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder={t.whatPlaceholder} className={field} />
</label>
<Pick label={t.who} value={form.who} onChange={(v) => setForm({ ...form, who: v })}>
{resources.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Pick label={t.day} value={String(form.day)} onChange={(v) => setForm({ ...form, day: Number(v) })}>
{days.map((d, i) => (
<option key={d} value={i}>
{dayName(i, { weekday: 'long', day: 'numeric', month: 'short' })}
</option>
))}
</Pick>
<label className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.length}</span>
<input type="number" min={1} max={n} value={form.span} onChange={(e) => setForm({ ...form, span: Math.max(1, Math.min(n, Number(e.target.value) || 1)) })} className={cn(field, 'tabular-nums')} />
</label>
<label className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.perDay}</span>
<input type="number" min={1} max={12} value={form.hours} onChange={(e) => setForm({ ...form, hours: Math.max(1, Math.min(12, Number(e.target.value) || 1)) })} className={cn(field, 'tabular-nums')} />
</label>
<div className="col-span-2 flex justify-end gap-2 sm:col-span-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setForm(null)} className="max-sm:h-11">
{t.cancel}
</Button>
<Button type="submit" size="sm" iconLeft="check" disabled={!form.title.trim()} className="max-sm:h-11">
{t.book}
</Button>
</div>
</form>
)}
</div>
<Live text={say} />
<Toast t={t} text={toast?.text ?? ''} onUndo={toast?.undo} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* shift-plan */
/* ------------------------------------------------------------------ */
const SHIFT_TONE = [
'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-foreground border-[color-mix(in_oklab,var(--warning)_50%,var(--border))]',
'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]',
'bg-[color-mix(in_oklab,var(--foreground)_80%,var(--card))] text-background border-transparent',
];
function ShiftPlan({ ctx, title, subtitle, resources, shiftTypes = [], shifts: initial = [], weekStart, days: n, today, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: n }, (_, i) => isoOf(w0 + i));
const [shifts, setShifts] = useState<Shift[]>(initial);
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [pub, setPub] = useState<'idle' | 'busy' | 'done'>('idle');
const [mday, setMday] = useState(Math.max(0, Math.min(n - 1, dayNum(today) - w0)));
const [scope, enter] = useEnter<HTMLDivElement>();
const type = (tid: string) => shiftTypes.find((x) => x.id === tid);
const ti = (tid: string) => Math.max(0, shiftTypes.findIndex((x) => x.id === tid));
const len = (s: Shift) => {
const ty = type(s.type);
if (!ty) return 0;
let m = toMin(ty.end) - toMin(ty.start);
if (m <= 0) m += 1440;
return m / 60;
};
const hoursOf = (rid: string) => shifts.filter((s) => s.resource === rid).reduce((a, s) => a + len(s), 0);
const dayName = (d: string) => fmtDay(d, locale, { weekday: 'long', day: 'numeric', month: 'short' });
const range = (ty?: ShiftType) => (ty ? `${formatTime(ty.start, locale)}–${formatTime(ty.end, locale)}` : '');
const flash = (sid: string) => {
setFresh(sid);
window.setTimeout(() => setFresh((f) => (f === sid ? null : f)), 1500);
};
const assign = (s: Shift, rid: string) => {
setShifts((x) => x.map((y) => (y.id === s.id ? { ...y, resource: rid } : y)));
flash(s.id);
setSay(fill(t.assigned, { who: ctx.res(rid)?.name ?? '', shift: type(s.type)?.label ?? '', day: dayName(s.date) }));
setPub('idle');
void onAction?.('assign', { id: s.id, resource: rid });
};
const unassign = (s: Shift) => {
setShifts((x) => x.map((y) => (y.id === s.id ? { ...y, resource: null } : y)));
flash(s.id);
setSay(fill(t.unassigned, { shift: type(s.type)?.label ?? '', day: dayName(s.date) }));
setPub('idle');
void onAction?.('unassign', { id: s.id });
};
const peopleFor = (s: Shift): MenuItem[] => [
{ type: 'label', label: fill(t.assign, { shift: type(s.type)?.label ?? '', day: fmtDay(s.date, locale, { weekday: 'short' }) }) },
...resources
.map((r) => ({ r, h: hoursOf(r.id), taken: shifts.some((x) => x.resource === r.id && x.date === s.date) }))
.sort((a, b) => Number(a.taken) - Number(b.taken) || a.h - b.h)
.map(({ r, h, taken }) => ({ label: r.name, description: taken ? t.busyThatDay : fill(t.contract, { h, c: r.capacity ?? 40 }), icon: 'user' as const, disabled: taken, onSelect: () => assign(s, r.id) })),
];
const shiftMenu = (s: Shift): MenuItem[] => [
...shiftTypes.filter((x) => x.id !== s.type).map((x) => ({ label: fill(t.changeTo, { shift: x.label }), description: range(x), icon: 'repeat' as const, onSelect: () => (setShifts((y) => y.map((z) => (z.id === s.id ? { ...z, type: x.id } : z))), flash(s.id), setPub('idle')) })),
{ type: 'separator' },
{ label: t.toOpen, icon: 'user-minus', onSelect: () => unassign(s) },
{ label: t.remove, icon: 'trash', destructive: true, onSelect: () => (setShifts((y) => y.filter((z) => z.id !== s.id)), setSay(fill(t.removed, { title: type(s.type)?.label ?? '' })), setPub('idle')) },
];
const addMenu = (rid: string, d: string): MenuItem[] => shiftTypes.map((x) => ({ label: fill(t.addThis, { shift: x.label }), description: range(x), icon: 'plus' as const, onSelect: () => {
const s: Shift = { id: `s${Date.now()}`, resource: rid, date: d, type: x.id };
setShifts((y) => [...y, s]);
flash(s.id);
setSay(fill(t.assigned, { who: ctx.res(rid)?.name ?? '', shift: x.label, day: dayName(d) }));
setPub('idle');
} }));
const chip = (s: Shift, who?: string, open = false) => {
const ty = type(s.type);
return (
<DropdownMenu
items={open ? peopleFor(s) : shiftMenu(s)}
trigger={
<button type="button" aria-label={open ? fill(t.assign, { shift: ty?.label ?? '', day: dayName(s.date) }) : fill(t.shiftMenu, { shift: ty?.label ?? '', who: who ?? '', day: dayName(s.date) })} className={cn('sch-pop flex min-h-11 w-full cursor-pointer flex-col items-start justify-center rounded-md border px-2 py-1 text-left transition-[transform,box-shadow] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-sm)] sm:min-h-10', ring, open ? 'border-dashed border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] text-foreground' : SHIFT_TONE[ti(s.type) % 3], fresh === s.id && 'sch-flash')}>
<span className="flex w-full items-center gap-1 text-[12px] font-semibold leading-tight">
{open && <Icon name="user-plus" size={11} className="shrink-0 text-destructive" />}
<span className="truncate">{ty?.label}</span>
</span>
<span className="text-[11px] tabular-nums leading-tight opacity-75">{range(ty)}</span>
</button>
}
/>
);
};
const open = shifts.filter((s) => !s.resource);
const staffed = (d: string, tid: string) => shifts.filter((s) => s.date === d && s.type === tid && s.resource).length;
const short = (d: string) => shiftTypes.reduce((a, ty) => a + Math.max(0, (ty.needed ?? 0) - staffed(d, ty.id)), 0);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-9 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium', open.length ? 'bg-[color-mix(in_oklab,var(--destructive)_10%,var(--card))] text-destructive' : 'bg-[color-mix(in_oklab,var(--success)_13%,var(--card))] text-success-foreground')}>
<Icon name={open.length ? 'alert-circle' : 'check-circle'} size={14} />
{fill(t.openN, { n: open.length })}
</span>
<Button
size="sm"
iconLeft={pub === 'busy' ? 'loader' : pub === 'done' ? 'check' : 'send'}
disabled={pub !== 'idle'}
onClick={async () => {
setPub('busy');
if (onAction) await onAction('publish', { shifts });
else await new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : 800));
setPub('done');
setSay(t.published);
}}
>
{pub === 'busy' ? t.publishing : t.publish}
</Button>
</Header>
{pub === 'done' && (
<p role="status" className="sch-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{t.published}
</p>
)}
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
<div className="sch-up flex flex-wrap gap-x-4 gap-y-2 text-[12.5px] text-muted-foreground">
{shiftTypes.map((x, i) => (
<span key={x.id} className="inline-flex items-center gap-1.5">
<span aria-hidden className={cn('size-3 rounded-[4px] border', SHIFT_TONE[i % 3])} />
<span className="font-medium text-foreground">{x.label}</span>
<span className="tabular-nums">{range(x)}</span>
</span>
))}
</div>
{/* Tablet and desktop: people by days */}
<div className="sch-up overflow-x-auto rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:60ms] max-md:hidden">
<table className="w-full min-w-[44rem] table-fixed border-collapse">
<caption className="sr-only">{title}</caption>
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="w-48 px-4 py-2.5 text-left text-[12px] font-medium text-muted-foreground lg:w-56">
{t.who}
</th>
{days.map((d) => {
const sh = short(d);
return (
<th key={d} scope="col" className={cn('border-s border-border px-1.5 py-2 text-center font-normal', d === today && 'bg-[color-mix(in_oklab,var(--primary)_5%,transparent)]')}>
<span className={cn('block text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')} {fmtDay(d, locale, { day: 'numeric' })}</span>
<span className={cn('mt-0.5 inline-flex items-center gap-1 text-[11px] font-medium tabular-nums', sh ? 'text-destructive' : 'text-success-foreground')}>
<Icon name={sh ? 'alert-circle' : 'check'} size={10} />
{fill(t.staffed, { n: shiftTypes.reduce((a, ty) => a + staffed(d, ty.id), 0), m: shiftTypes.reduce((a, ty) => a + (ty.needed ?? 0), 0) })}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{resources.map((r) => {
const h = hoursOf(r.id);
const c = r.capacity ?? 40;
return (
<tr key={r.id} className="border-b border-border">
<th scope="row" className="px-4 py-2 text-left font-normal">
<span className="flex min-w-0 items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className={cn('block text-[12px] tabular-nums', h > c ? 'font-semibold text-destructive' : h === c ? 'text-success-foreground' : 'text-muted-foreground')}>{fill(t.contract, { h, c })}</span>
</span>
</span>
</th>
{days.map((d) => {
const mine = shifts.filter((s) => s.resource === r.id && s.date === d);
return (
<td key={d} className={cn('border-s border-border p-1 align-middle', d === today && 'bg-[color-mix(in_oklab,var(--primary)_3%,transparent)]')}>
{mine.length ? (
<div className="grid gap-1">{mine.map((s) => <div key={s.id} className="[&>span]:w-full">{chip(s, r.name)}</div>)}</div>
) : (
<div className="[&>span]:w-full">
<DropdownMenu
items={addMenu(r.id, d)}
trigger={
<button type="button" aria-label={fill(t.addShift, { who: r.name, day: dayName(d) })} className={cn('group/add grid min-h-10 w-full cursor-pointer place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] hover:text-foreground', ring)}>
<Icon name="plus" size={14} className="opacity-40 transition-opacity group-hover/add:opacity-100 group-focus-visible/add:opacity-100" />
</button>
}
/>
</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-[color-mix(in_oklab,var(--destructive)_3%,var(--surface))]">
<th scope="row" className="px-4 py-2 text-left">
<span className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
<span className="grid size-8 place-items-center rounded-full border border-dashed border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] text-destructive">
<Icon name="user-plus" size={14} />
</span>
{t.open}
</span>
</th>
{days.map((d) => {
const mine = open.filter((s) => s.date === d);
return (
<td key={d} className="border-s border-border p-1 align-top">
<div className="grid gap-1">{mine.map((s) => <div key={s.id} className="[&>span]:w-full">{chip(s, undefined, true)}</div>)}</div>
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
{/* Phone: one day, grouped by shift */}
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} extra={(i) => (short(days[i]) ? <span aria-hidden className="absolute end-1.5 top-1.5 size-1.5 rounded-full bg-destructive" /> : null)} />
<div key={mday} className="sch-in grid gap-3">
{shiftTypes.map((ty, i) => {
const d = days[mday];
const list = shifts.filter((s) => s.date === d && s.type === ty.id);
const st = staffed(d, ty.id);
return (
<section key={ty.id} aria-labelledby={`${id}-${ty.id}`} className="rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center gap-2">
<span aria-hidden className={cn('size-3 rounded-[4px] border', SHIFT_TONE[i % 3])} />
<h2 id={`${id}-${ty.id}`} className="text-[14px] font-semibold text-foreground">
{ty.label}
</h2>
<span className="text-[12.5px] tabular-nums text-muted-foreground">{range(ty)}</span>
<span className={cn('ms-auto text-[12px] font-semibold tabular-nums', st < (ty.needed ?? 0) ? 'text-destructive' : 'text-success-foreground')}>{fill(t.staffed, { n: st, m: ty.needed ?? st })}</span>
</div>
<ul className="mt-2 grid gap-1.5">
{list.map((s) => {
const r = ctx.res(s.resource);
return (
<li key={s.id} className={cn('flex items-center gap-2 rounded-lg', fresh === s.id && 'sch-flash')}>
{r ? (
<>
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">{r.name}</span>
<DropdownMenu
align="end"
items={shiftMenu(s)}
trigger={
<button type="button" aria-label={fill(t.shiftMenu, { shift: ty.label, who: r.name, day: dayName(d) })} className={iconBtn}>
<Icon name="more-vertical" size={15} />
</button>
}
/>
</>
) : (
<div className="w-full [&>span]:w-full">{chip(s, undefined, true)}</div>
)}
</li>
);
})}
</ul>
</section>
);
})}
</div>
</div>
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* room-day */
/* ------------------------------------------------------------------ */
function RoomDay({ ctx, title, subtitle, resources, bookings: initial = [], today, now = '10:40', dayStart = 8, dayEnd = 19, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const [items, setItems] = useState<Booking[]>(initial.filter((b) => b.date === today));
const [minSeats, setMinSeats] = useState('0');
const [sel, setSel] = useState<{ room: string; from: number; to: number } | null>(null);
const [len, setLen] = useState(60);
const [what, setWhat] = useState('');
const [say, setSay] = useState('');
const [fresh, setFresh] = useState<string | null>(null);
const [mroom, setMroom] = useState(resources[0]?.id ?? '');
const [scope, enter] = useEnter<HTMLDivElement>();
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const formRef = useRef<HTMLFormElement>(null);
const S = dayStart * 60;
const E = dayEnd * 60;
const nowM = toMin(now);
const pct = (m: number) => round(((Math.max(S, Math.min(E, m)) - S) / (E - S)) * 100, 3);
const rooms = resources.filter((r) => (r.seats ?? 0) >= Number(minSeats));
const hours = Array.from({ length: dayEnd - dayStart }, (_, i) => dayStart + i);
const tm = (m: number) => formatTime(hhmm(m), locale);
// Free gaps of at least 30 minutes from now on, between the bookings of a room
const gapsOf = (rid: string) => {
const list = items.filter((b) => b.resource === rid).map((b) => [toMin(b.start ?? '00:00'), toMin(b.end ?? '00:00')] as const).sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [];
let cur = Math.max(S, Math.ceil(nowM / 15) * 15);
for (const [a, b] of list) {
if (a - cur >= 30) out.push([cur, a]);
cur = Math.max(cur, b);
}
if (E - cur >= 30) out.push([cur, E]);
return out;
};
const choose = (room: string, from: number, to: number) => {
setSel({ room, from, to });
setLen(Math.min(60, to - from));
setWhat('');
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
formRef.current?.querySelector('input')?.focus({ preventScroll: true });
});
};
const roomName = (rid: string) => ctx.res(rid)?.name ?? rid;
const lens = [30, 60, 90, 120];
return (
<Main>
<Header title={title} subtitle={subtitle ?? fmtDay(today, locale, { weekday: 'long', day: 'numeric', month: 'long' })}>
<Segmented
label={t.roomSize}
value={minSeats}
onChange={setMinSeats}
options={[
{ value: '0', label: t.all },
{ value: '4', label: fill(t.seatsMin, { n: 4 }) },
{ value: '8', label: fill(t.seatsMin, { n: 8 }) },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
{/* Tablet and desktop: rooms by hours with a line for now */}
<div className="sch-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-xl:hidden">
<div className="grid grid-cols-[11rem_minmax(0,1fr)] border-b border-border bg-surface lg:grid-cols-[13rem_minmax(0,1fr)]">
<span className="px-4 py-2.5 text-[12px] font-medium text-muted-foreground">{t.rooms}</span>
<div className="relative h-full">
{hours.map((h) => (
<span key={h} className="absolute top-1/2 -translate-y-1/2 ps-1.5 text-[11.5px] tabular-nums text-muted-foreground" style={{ left: `${pct(h * 60)}%` }}>
{formatTime(`${h}:00`, locale).replace(':00', '')}
</span>
))}
{nowM > S && nowM < E && (
<span aria-hidden className="absolute bottom-0 h-2 w-px bg-destructive" style={{ left: `${pct(nowM)}%` }}>
<span className="absolute -top-1 left-1/2 size-2 -translate-x-1/2 rounded-full bg-destructive" />
</span>
)}
</div>
</div>
<div className="relative">
{rooms.map((r, ri) => {
const list = items.filter((b) => b.resource === r.id);
const gaps = gapsOf(r.id);
return (
<div key={r.id} className="grid grid-cols-[11rem_minmax(0,1fr)] border-b border-border last:border-b-0 lg:grid-cols-[13rem_minmax(0,1fr)]">
<div className="min-w-0 px-4 py-3">
<p className="truncate text-[13.5px] font-medium text-foreground">{r.name}</p>
<p className="mt-0.5 flex items-center gap-2 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1 tabular-nums">
<Icon name="users" size={12} />
{r.seats}
</span>
{(r.features ?? []).map((f) => (
<Icon key={f} name={f} size={12} />
))}
</p>
</div>
<div className="relative h-[4.25rem]">
{hours.map((h) => (
<span key={h} aria-hidden className="absolute inset-y-0 border-s border-border" style={{ left: `${pct(h * 60)}%` }} />
))}
{/* The past is shaded */}
<span aria-hidden className="absolute inset-y-0 start-0 bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_5px,transparent_5px_10px)]" style={{ width: `${pct(nowM)}%` }} />
{nowM > S && nowM < E && <span aria-hidden className="absolute inset-y-0 w-px bg-destructive" style={{ left: `${pct(nowM)}%` }} />}
{gaps.map(([a, b]) => (
<button
key={a}
type="button"
aria-label={`${fill(t.free, { from: tm(a), to: tm(b) })}, ${fill(t.bookFree, { room: r.name, from: tm(a) })}`}
onClick={() => choose(r.id, a, b)}
className={cn('group/gap absolute inset-y-2 cursor-pointer rounded-lg border border-dashed border-transparent text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))] transition-colors hover:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))]', ring, sel?.room === r.id && sel.from === a && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,transparent)]')}
style={{ left: `${pct(a)}%`, width: `${round(pct(b) - pct(a), 3)}%` }}
>
<span className="flex h-full items-center justify-center gap-1 opacity-0 transition-opacity group-hover/gap:opacity-100 group-focus-visible/gap:opacity-100">
<Icon name="plus" size={13} />
{b - a >= 60 && tm(a)}
</span>
</button>
))}
{list.map((b, bi) => {
const a = toMin(b.start ?? '00:00');
const e = toMin(b.end ?? '00:00');
const live = a <= nowM && e > nowM;
return (
<div key={b.id} className={cn('sch-wipe absolute inset-y-2 z-[2] flex min-w-0 flex-col justify-center overflow-hidden rounded-lg border', e - a <= 45 ? 'px-1.5' : 'px-2.5', TONES[(b.tone ?? 0) % 4], e <= nowM && 'opacity-55', fresh === b.id && 'sch-flash')} style={{ left: `${pct(a)}%`, width: `calc(${round(pct(e) - pct(a), 3)}% - 2px)`, '--sch-d': `${ri * 60 + bi * 40}ms` } as React.CSSProperties} title={`${b.title} ${tm(a)}–${tm(e)}`}>
<span className="flex min-w-0 items-center gap-1.5">
{live && (
<span aria-hidden className="relative grid size-1.5 shrink-0 place-items-center">
<span className="sch-pulse absolute size-1.5 rounded-full bg-destructive" />
<span className="size-1.5 rounded-full bg-destructive" />
</span>
)}
<span className="truncate text-[12.5px] font-semibold">{b.title}</span>
</span>
<span className={cn('truncate text-[11.5px] tabular-nums text-muted-foreground', e - a <= 45 && 'hidden')}>
{tm(a)}–{tm(e)}
{b.by ? ` · ${b.by}` : ''}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
<p className="sch-up text-[12.5px] text-muted-foreground max-xl:hidden">
<span className="me-1.5 inline-block h-2.5 w-px translate-y-0.5 bg-destructive" />
{fill(t.nowLine, { t: formatTime(now, locale) })} · {t.pickGap}
</p>
{/* Phone: one room as an agenda */}
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 xl:hidden">
<div ref={rowRef} role="radiogroup" aria-label={t.rooms} className={cn('kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4', fade)}>
{rooms.map((r) => (
<button key={r.id} type="button" role="radio" aria-checked={mroom === r.id} onClick={() => setMroom(r.id)} className={cn('flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium', ring, mroom === r.id ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground')}>
{r.name}
<span className="tabular-nums opacity-70">{r.seats}</span>
</button>
))}
</div>
<ol key={mroom} className="sch-in relative grid gap-2">
{[...items.filter((b) => b.resource === mroom).map((b) => ({ kind: 'b' as const, a: toMin(b.start ?? '00:00'), e: toMin(b.end ?? '00:00'), b })), ...gapsOf(mroom).map(([a, e]) => ({ kind: 'g' as const, a, e, b: null }))]
.sort((x, y) => x.a - y.a)
.map((x) =>
x.kind === 'b' && x.b ? (
<li key={x.b.id} className={cn('flex gap-3 rounded-xl border p-3', TONES[(x.b.tone ?? 0) % 4], x.e <= nowM && 'opacity-60', fresh === x.b.id && 'sch-flash')}>
<span className="w-14 shrink-0 text-[12.5px] font-semibold tabular-nums">{tm(x.a)}</span>
<span className="min-w-0">
<span className="block text-[14px] font-semibold">{x.b.title}</span>
<span className="block text-[12.5px] tabular-nums text-muted-foreground">
{tm(x.a)}–{tm(x.e)}
{x.b.by ? ` · ${x.b.by}` : ''}
</span>
</span>
</li>
) : (
<li key={`g${x.a}`}>
<button type="button" onClick={() => choose(mroom, x.a, x.e)} className={cn('flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-xl border border-dashed border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] p-3 text-left text-[13.5px] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]', ring)}>
<Icon name="plus-circle" size={16} />
<span className="flex-1">{fill(t.free, { from: tm(x.a), to: tm(x.e) })}</span>
<Icon name="chevron-right" size={15} />
</button>
</li>
),
)}
{!gapsOf(mroom).length && <li className="py-3 text-center text-[13px] text-muted-foreground">{t.nothingFree}</li>}
</ol>
</div>
{sel && (
<form
ref={formRef}
className="sch-in grid gap-4 rounded-xl border border-border-strong bg-card p-4 shadow-[var(--shadow-md)] sm:p-5 md:grid-cols-[minmax(0,1fr)_auto_auto] md:items-end"
onSubmit={(e) => {
e.preventDefault();
const b: Booking = { id: `r${Date.now()}`, resource: sel.room, title: what.trim() || t.bookRoom, date: today, start: hhmm(sel.from), end: hhmm(sel.from + len), tone: 0, by: undefined };
setItems((x) => [...x, b]);
setFresh(b.id);
setSel(null);
setSay(fill(t.roomBooked, { room: roomName(b.resource), from: tm(sel.from), to: tm(sel.from + len) }));
void onAction?.('book', { booking: b });
}}
>
<label className="grid gap-1.5">
<span className="text-[13px] font-semibold text-foreground">{fill(t.bookingFor, { room: roomName(sel.room), from: tm(sel.from) })}</span>
<input value={what} onChange={(e) => setWhat(e.target.value)} placeholder={t.whatPlaceholder} className={field} />
</label>
<div className="grid gap-1.5">
<span id={`${id}-len`} className="text-[12px] font-medium text-muted-foreground">
{t.duration}
</span>
<Segmented label={t.duration} value={String(len)} onChange={(v) => setLen(Number(v))} className="w-full" options={lens.filter((l) => l <= sel.to - sel.from).map((l) => ({ value: String(l), label: fill(t.min, { n: l }) }))} />
</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="ghost" onClick={() => setSel(null)} className="max-md:h-11 max-md:flex-1">
{t.cancel}
</Button>
<Button type="submit" size="sm" iconLeft="check" className="max-md:h-11 max-md:flex-1">
{t.bookRoom}
</Button>
</div>
</form>
)}
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* capacity */
/* ------------------------------------------------------------------ */
function heat(p: number) {
if (p > 100) return 'bg-[color-mix(in_oklab,var(--destructive)_22%,var(--card))] text-destructive font-semibold';
if (p >= 90) return 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))] text-foreground font-semibold';
if (p >= 65) return 'bg-[color-mix(in_oklab,var(--primary)_28%,var(--card))] text-foreground';
if (p > 0) return 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-muted-foreground';
return 'bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))] text-subtle-foreground';
}
function Capacity({ ctx, title, subtitle, resources, load = [], weekStart, weeks: nw = 6 }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const weeks = Array.from({ length: nw }, (_, i) => isoOf(w0 + i * 7));
const teams = [...new Set(resources.map((r) => r.team).filter((x): x is string => !!x))];
const [team, setTeam] = useState('all');
const [unit, setUnit] = useState<'p' | 'h'>('p');
const [cell, setCell] = useState<[number, number]>([0, 0]);
const [picked, setPicked] = useState<[string, string] | null>(() => {
const over = load.find((x) => x.projects.reduce((a, p) => a + p.hours, 0) > (resources.find((r) => r.id === x.resource)?.capacity ?? 40));
return over ? [over.resource, over.week] : null;
});
const [mweek, setMweek] = useState(0);
const [scope, enter] = useEnter<HTMLDivElement>();
const people = resources.filter((r) => team === 'all' || r.team === team);
const hoursOf = (rid: string, w: string) => (load.find((x) => x.resource === rid && x.week === w)?.projects ?? []).reduce((a, p) => a + p.hours, 0);
const capOf = (r: SchedResource) => r.capacity ?? 40;
const pctOf = (r: SchedResource, w: string) => Math.round((hoursOf(r.id, w) / capOf(r)) * 100);
const wk = (w: string, short = true) => fmtDay(w, locale, short ? { day: 'numeric', month: 'short' } : { day: 'numeric', month: 'long' });
const show = (r: SchedResource, w: string) => (unit === 'p' ? formatPercent(pctOf(r, w), locale) : `${hoursOf(r.id, w)} h`);
const avg = (w: string) => Math.round(people.reduce((a, r) => a + pctOf(r, w), 0) / Math.max(1, people.length));
const move = (e: React.KeyboardEvent, r: number, c: number) => {
const m: Record<string, [number, number]> = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], Home: [0, -99], End: [0, 99] };
const v = m[e.key];
if (!v) return;
e.preventDefault();
const nr = Math.max(0, Math.min(people.length - 1, r + v[0]));
const nc = Math.max(0, Math.min(weeks.length - 1, c + v[1]));
setCell([nr, nc]);
document.getElementById(`${id}-${nr}-${nc}`)?.focus();
};
const pr = picked ? resources.find((r) => r.id === picked[0]) : undefined;
const pw = picked?.[1];
const detail = pr && pw && (
<div key={`${pr.id}${pw}`} className="sch-in">
<div className="flex items-center gap-3">
<Avatar name={pr.name} size="md" />
<div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-foreground">{pr.name}</p>
<p className="text-[12.5px] text-muted-foreground">{fill(t.weekOf, { d: wk(pw, false) })}</p>
</div>
</div>
{(() => {
const h = hoursOf(pr.id, pw);
const c = capOf(pr);
const p = pctOf(pr, pw);
return (
<>
<p className="mt-4 flex items-baseline gap-2">
<span className={cn('text-[30px] font-semibold tabular-nums tracking-[-0.02em]', p > 100 ? 'text-destructive' : 'text-foreground')}>{formatPercent(p, locale)}</span>
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.contract, { h, c })}</span>
</p>
<p className={cn('text-[13px] font-medium', h > c ? 'text-destructive' : h === c ? 'text-success-foreground' : 'text-muted-foreground')}>{h > c ? fill(t.over, { h: h - c }) : h === c ? t.exact : fill(t.under, { h: c - h })}</p>
<ul className="mt-4 grid gap-3">
{(load.find((x) => x.resource === pr.id && x.week === pw)?.projects ?? []).map((p, i) => (
<li key={p.name} className="grid gap-1.5">
<span className="flex items-center justify-between gap-3 text-[13px]">
<span className="truncate text-foreground">{p.name}</span>
<span className="tabular-nums text-muted-foreground">{p.hours} h</span>
</span>
<span aria-hidden className="relative h-1.5 overflow-hidden rounded-full bg-track">
<span className={cn('absolute inset-y-0 start-0 rounded-full', TONE_BAR[i % 4])} style={{ width: `${round(Math.min(100, (p.hours / c) * 100), 1)}%` }} />
</span>
</li>
))}
</ul>
</>
);
})()}
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle}>
{teams.length > 1 && (
<Segmented
label={t.team}
value={team}
onChange={(v) => (setTeam(v), setCell([0, 0]))}
options={[{ value: 'all', label: t.all }, ...teams.map((x) => ({ value: x, label: x }))]}
/>
)}
<Segmented
label={t.unit}
value={unit}
onChange={setUnit}
options={[
{ value: 'p', label: '%' },
{ value: 'h', label: t.hours },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
<div className="grid min-w-0 gap-3">
<div className="sch-up overflow-x-auto rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] max-md:hidden">
<table role="grid" aria-label={t.capacity} className="w-full min-w-[38rem] table-fixed border-collapse">
<thead>
<tr className="border-b border-border bg-surface">
<th scope="col" className="w-48 px-4 py-2.5 text-left text-[12px] font-medium text-muted-foreground">
{t.who}
</th>
{weeks.map((w) => (
<th key={w} scope="col" className="px-1 py-2.5 text-center text-[12px] font-medium tabular-nums text-muted-foreground">
{wk(w)}
</th>
))}
</tr>
</thead>
<tbody>
{people.map((r, ri) => (
<tr key={r.id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-4 py-2 text-left font-normal">
<span className="flex min-w-0 items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className="block truncate text-[12px] text-muted-foreground">{r.role}</span>
</span>
</span>
</th>
{weeks.map((w, wi) => {
const p = pctOf(r, w);
const on = picked?.[0] === r.id && picked[1] === w;
return (
<td key={w} className="p-1">
<button
id={`${id}-${ri}-${wi}`}
type="button"
role="gridcell"
tabIndex={cell[0] === ri && cell[1] === wi ? 0 : -1}
aria-selected={on}
aria-label={fill(t.utilization, { who: r.name, d: wk(w, false), p: formatPercent(p, locale) })}
onClick={() => (setPicked([r.id, w]), setCell([ri, wi]))}
onKeyDown={(e) => move(e, ri, wi)}
onFocus={() => setCell([ri, wi])}
className={cn('sch-up grid h-12 w-full cursor-pointer place-items-center rounded-md text-[13px] tabular-nums transition-[transform,box-shadow] duration-200 hover:-translate-y-px hover:shadow-[var(--shadow-sm)]', ring, heat(p), p > 100 && 'sch-stripes', on && 'shadow-[0_0_0_2px_var(--foreground)]')}
style={{ '--sch-d': `${ri * 40 + wi * 30}ms` } as React.CSSProperties}
>
{show(r, w)}
</button>
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border bg-surface">
<th scope="row" className="px-4 py-2.5 text-left text-[12.5px] font-semibold text-foreground">
{t.teamAvg}
</th>
{weeks.map((w) => (
<td key={w} className={cn('px-1 py-2.5 text-center text-[13px] font-semibold tabular-nums', avg(w) > 100 ? 'text-destructive' : 'text-foreground')}>
{formatPercent(avg(w), locale)}
</td>
))}
</tr>
</tfoot>
</table>
</div>
<div className="sch-up flex flex-wrap gap-x-4 gap-y-2 text-[12px] text-muted-foreground max-md:hidden">
{[
[10, t.legendLow],
[70, t.legendGood],
[95, t.legendFull],
[120, t.legendOver],
].map(([p, l]) => (
<span key={l as string} className="inline-flex items-center gap-1.5">
<span aria-hidden className={cn('size-3 rounded-[4px]', heat(p as number), (p as number) > 100 && 'sch-stripes')} />
{l}
</span>
))}
</div>
{/* Phone: one week, a bar per person */}
<div className="grid gap-3 md:hidden">
<div role="radiogroup" aria-label={t.capacity} className="kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4">
{weeks.map((w, i) => (
<button key={w} type="button" role="radio" aria-checked={mweek === i} onClick={() => setMweek(i)} className={cn('flex h-11 shrink-0 cursor-pointer items-center rounded-lg border px-3 text-[13px] font-medium tabular-nums', ring, mweek === i ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground')}>
{wk(w)}
</button>
))}
</div>
<ul key={mweek} className="sch-in overflow-hidden rounded-xl border border-border bg-card">
{people.map((r) => {
const w = weeks[mweek];
const p = pctOf(r, w);
const on = picked?.[0] === r.id && picked[1] === w;
return (
<li key={r.id} className="border-b border-border last:border-b-0">
<button type="button" aria-expanded={on} onClick={() => setPicked(on ? null : [r.id, w])} className={cn('flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-left', ring)}>
<Avatar name={r.name} size="sm" />
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{r.name}</span>
<span className={cn('text-[13px] font-semibold tabular-nums', p > 100 ? 'text-destructive' : 'text-foreground')}>{show(r, w)}</span>
</span>
<span aria-hidden className="relative mt-1.5 block h-2 overflow-hidden rounded-full bg-track">
<span className={cn('absolute inset-y-0 start-0 rounded-full', p > 100 ? 'bg-destructive' : p >= 90 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_55%,var(--card))]')} style={{ width: `${round(Math.min(100, p), 1)}%` }} />
</span>
</span>
</button>
{on && <div className="border-t border-border bg-surface p-4">{detail}</div>}
</li>
);
})}
</ul>
</div>
</div>
<aside aria-label={t.capacity} className="sch-up rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:120ms] sm:p-5 max-md:hidden lg:sticky lg:top-6" aria-live="polite">
{detail ?? <p className="py-6 text-center text-[13px] text-muted-foreground">{t.pickCell}</p>}
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* availability */
/* ------------------------------------------------------------------ */
function Availability({ ctx, title, subtitle, resources, busy = [], weekStart, days: n, today, now = '10:40', dayStart = 8, dayEnd = 18, onAction }: VP) {
const { t, locale } = ctx;
const id = useId();
const w0 = dayNum(weekStart);
const days = Array.from({ length: Math.min(n, 5) }, (_, i) => isoOf(w0 + i));
const [who, setWho] = useState<string[]>(resources.map((r) => r.id));
const [len, setLen] = useState(60);
const [allowOne, setAllowOne] = useState(false);
const [picked, setPicked] = useState<string | null>(null);
const [say, setSay] = useState('');
const [mday, setMday] = useState(Math.max(0, Math.min(days.length - 1, dayNum(today) - w0)));
const [scope, enter] = useEnter<HTMLDivElement>();
const step = 30;
const slots = Array.from({ length: ((dayEnd - dayStart) * 60) / step }, (_, i) => dayStart * 60 + i * step);
const past = (d: string, a: number) => dayNum(d) < dayNum(today) || (d === today && a < toMin(now));
const isBusy = (rid: string, d: string, a: number, b: number) => busy.some((x) => x.resource === rid && x.date === d && toMin(x.start) < b && toMin(x.end) > a);
const freeCount = (d: string, a: number, b: number) => who.filter((r) => !isBusy(r, d, a, b)).length;
const tm = (m: number) => formatTime(hhmm(m), locale);
const need = who.length;
const enough = need >= 2;
const suggestions = useMemo(() => {
if (!enough) return [];
const out: { d: string; a: number; free: number; missing: string[] }[] = [];
for (const d of days) {
if (dayNum(d) < dayNum(today)) continue;
for (const a of slots) {
const b = a + len;
if (b > dayEnd * 60 || past(d, a)) continue;
const f = freeCount(d, a, b);
if (f === need || (allowOne && f === need - 1)) out.push({ d, a, free: f, missing: who.filter((r) => isBusy(r, d, a, b)) });
}
}
// Everyone first, then the earliest, at most one suggestion per hour
const seen = new Set<string>();
return out
.sort((x, y) => y.free - x.free || dayNum(x.d) - dayNum(y.d) || x.a - y.a)
.filter((x) => {
const k = `${x.d}-${Math.floor(x.a / 60)}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.slice(0, 6);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [who, len, allowOne, busy, enough]);
const label = (s: { d: string; a: number }) => fill(t.slotLabel, { day: fmtDay(s.d, locale, { weekday: 'short', day: 'numeric', month: 'short' }), from: tm(s.a), to: tm(s.a + len) });
const grid = (ds: string[]) => (
<div className="grid grid-cols-[3rem_minmax(0,1fr)] gap-x-2">
<div aria-hidden className="grid" style={{ gridTemplateRows: `2.25rem repeat(${slots.length}, 0.875rem)` }}>
<span />
{slots.map((m) => (
<span key={m} className="-translate-y-1.5 text-right text-[11px] leading-none tabular-nums text-subtle-foreground">
{m % 60 === 0 ? tm(m).replace(':00', '') : ''}
</span>
))}
</div>
<div className="grid gap-x-1.5" style={{ gridTemplateColumns: `repeat(${ds.length}, minmax(0, 1fr))` }}>
{ds.map((d, di) => (
<div key={d} className="grid" style={{ gridTemplateRows: `2.25rem repeat(${slots.length}, 0.875rem)` }}>
<span className={cn('text-center text-[12.5px]', d === today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
{fmtDay(d, locale, { weekday: 'short' }).replace('.', '')}
<span className="ms-1 tabular-nums">{fmtDay(d, locale, { day: 'numeric' })}</span>
</span>
{slots.map((m, si) => {
const gone = past(d, m);
const f = enough && !gone ? freeCount(d, m, m + step) : 0;
const all = enough && f === need;
const most = enough && f === need - 1;
const inPick = picked && picked.startsWith(d) && m >= Number(picked.split('|')[1]) && m < Number(picked.split('|')[1]) + len;
return (
<span
key={m}
aria-hidden
className={cn('sch-up border-x border-border', si === 0 && 'rounded-t-md border-t', si === slots.length - 1 && 'rounded-b-md border-b', m % 60 === 0 && si > 0 && 'border-t border-t-[color-mix(in_oklab,var(--border)_60%,transparent)]', gone ? 'bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]' : all ? 'bg-[color-mix(in_oklab,var(--primary)_32%,var(--card))]' : most ? 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))]' : 'bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_6%,transparent)_0_4px,transparent_4px_8px)]', inPick && 'bg-primary')}
style={{ '--sch-d': `${di * 50}ms` } as React.CSSProperties}
/>
);
})}
</div>
))}
</div>
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 xl:grid-cols-[minmax(0,1fr)_21rem] xl:items-start">
<div className="grid min-w-0 gap-4">
<section aria-labelledby={`${id}-who`} className="sch-up grid gap-4 rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
<div>
<h2 id={`${id}-who`} className="text-[13px] font-semibold text-foreground">
{t.pickPeople}
</h2>
<div className="mt-2 flex flex-wrap gap-1.5">
{resources.map((r) => {
const on = who.includes(r.id);
return (
<button key={r.id} type="button" aria-pressed={on} onClick={() => (setWho((w) => (on ? w.filter((x) => x !== r.id) : [...w, r.id])), setPicked(null))} className={cn('inline-flex h-11 cursor-pointer items-center gap-2 rounded-full border ps-1 pe-3.5 text-[13px] font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-[0.97] sm:h-9', ring, on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
<span className={cn('rounded-full transition-opacity', !on && 'opacity-50')}>
<Avatar name={r.name} size="xs" />
</span>
{r.name}
{on && <Icon name="check" size={13} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />}
</button>
);
})}
</div>
{!enough && <p className="sch-in mt-2 text-[13px] font-medium text-warning-foreground">{t.needTwo}</p>}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid gap-1.5">
<span className="text-[12px] font-medium text-muted-foreground">{t.meetingLength}</span>
<Segmented label={t.meetingLength} value={String(len)} onChange={(v) => (setLen(Number(v)), setPicked(null))} className="w-full sm:w-auto" options={[30, 45, 60, 90].map((l) => ({ value: String(l), label: fill(t.min, { n: l }) }))} />
</div>
<button type="button" role="switch" aria-checked={allowOne} onClick={() => setAllowOne(!allowOne)} className={cn('inline-flex h-11 cursor-pointer items-center gap-2.5 rounded-lg px-1 text-[13px] font-medium text-foreground sm:h-9', ring)}>
<span aria-hidden className={cn('relative h-5 w-9 rounded-full transition-colors', allowOne ? 'bg-primary' : 'bg-track')}>
<span className={cn('absolute top-0.5 size-4 rounded-full bg-background shadow-[var(--shadow-sm)] transition-[left] duration-200', allowOne ? 'left-[18px]' : 'left-0.5')} />
</span>
{t.allowOne}
</button>
</div>
</section>
<section aria-label={t.freeAll} className="sch-up rounded-xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:80ms] sm:p-4">
<div className="max-md:hidden">{grid(days)}</div>
<div className="grid gap-3 md:hidden">
<DayTabs days={days} value={mday} onChange={setMday} label={t.day} locale={locale} today={today} />
{grid([days[mday]])}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_32%,var(--card))]" />
{t.freeAll}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))]" />
{t.freeMost}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] border border-border bg-[repeating-linear-gradient(135deg,color-mix(in_oklab,var(--foreground)_10%,transparent)_0_3px,transparent_3px_6px)]" />
{t.busy}
</span>
<span className="inline-flex items-center gap-1.5">
<span aria-hidden className="size-3 rounded-[4px] border border-border bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]" />
{t.past}
</span>
</div>
</section>
</div>
<aside aria-labelledby={`${id}-sug`} className="sch-up rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--sch-d:140ms] max-xl:order-first xl:sticky xl:top-6">
<h2 id={`${id}-sug`} className="flex items-center gap-2 border-b border-border px-4 py-3.5 text-[14px] font-semibold text-foreground sm:px-5">
<Icon name="sparkle" size={15} className="text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />
{t.suggestions}
</h2>
{suggestions.length ? (
<ul key={`${who.join()}${len}${allowOne}`}>
{suggestions.map((s, i) => {
const k = `${s.d}|${s.a}`;
const on = picked === k;
return (
<li key={k} className="sch-in flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ animationDelay: `${i * 50}ms` }}>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium text-foreground">{label(s)}</span>
<span className={cn('mt-0.5 flex items-center gap-1 text-[12px]', s.free === need ? 'text-success-foreground' : 'text-warning-foreground')}>
<Icon name={s.free === need ? 'check-circle' : 'alert-circle'} size={12} />
{s.free === need ? fill(t.allFree, { n: need }) : fill(t.oneMissing, { who: s.missing.map((m) => ctx.res(m)?.name.split(' ')[0]).join(', ') })}
</span>
</span>
{on ? (
<span className="sch-pop inline-flex h-9 items-center gap-1 text-[13px] font-medium text-success-foreground">
<Icon name="check" size={15} />
</span>
) : (
<Button
size="sm"
variant={i === 0 ? 'primary' : 'outline'}
aria-label={`${t.pick}, ${label(s)}`}
className="max-sm:h-11"
onClick={() => {
setPicked(k);
setSay(fill(t.picked, { when: label(s) }));
void onAction?.('pick', { date: s.d, start: hhmm(s.a), end: hhmm(s.a + len), people: who });
}}
>
{t.pick}
</Button>
)}
</li>
);
})}
</ul>
) : (
<p className="px-5 py-8 text-center text-[13px] text-muted-foreground">{enough ? t.noSlot : t.needTwo}</p>
)}
{picked && (
<p role="status" className="sch-in m-3 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
<Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
{fill(t.picked, { when: label({ d: picked.split('|')[0], a: Number(picked.split('|')[1]) }) })}
</p>
)}
</aside>
</div>
<Live text={say} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* Scheduler */
/* ------------------------------------------------------------------ */
/**
* Planning screens for teams and rooms: bookings per person and day you drag
* and resize, a shift plan with open shifts, rooms by the hour with a line for
* now, capacity as a heatmap and the best common time for a meeting.
* <Scheduler variant="resource-week" title="Team plan" resources={…} bookings={…} weekStart="2026-10-19" />
*/
export function Scheduler({ variant = 'resource-week', sidebar, labels, className, resources = [], today = '2026-10-21', weekStart = '2026-10-19', days = 5, ...props }: SchedulerProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const ctx: Ctx = useMemo(() => ({ t, locale, res: (i) => (i ? resources.find((r) => r.id === i) : undefined) }), [t, locale, resources]);
const p: VP = { ...props, ctx, resources, today, weekStart, days };
const body = (() => {
switch (variant) {
case 'shift-plan':
return <ShiftPlan {...p} />;
case 'room-day':
return <RoomDay {...p} />;
case 'capacity':
return <Capacity {...p} />;
case 'availability':
return <Availability {...p} />;
default:
return <ResourceWeek {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}