Skip to content
UI Kit
Accent color

Resource scheduler

Planning for people and rooms. Bookings to drag and resize with conflicts, a shift plan with open shifts, rooms with a line for now, capacity and a common free time.

5 variantsOne of 4 components in Tasks and planning

Who works where and when

A resource scheduler shows people and rooms against time, so a lead sees at a glance who is free, who is over capacity and which room is still open. Every booking here moves by pointer and by arrow keys.

Team week lays people across days with bookings as bars, drag one to another day or person, pull the edge to stretch it, and days over capacity are marked. Shift plan shows early and late shifts per person and day, staffing against the need, hours against the contract, open shifts to hand out and a plan to publish. Rooms today lists rooms by the hour with seats and equipment, a line for now and every free gap ready to book in two taps. Capacity shows the load per person and week as a heatmap in percent or hours, with a team average and the projects behind every cell. Find a time picks who needs to be there and how long, then shows where everyone is free and offers the best times.

Plan in the unit your team thinks in, days for projects, hours for rooms, shifts for staff. Mark overload clearly but let people book anyway, a plan has to follow reality. Publish shift plans at a fixed time each week so everyone knows when to look.

Variants

Team weekNew in 1.2

People by days with bookings as bars. Drag them to another day or person, pull the edge to stretch, or use the arrow keys. Days over capacity are marked.

Shift planNew in 1.2

Early and late shifts per person and day, staffing against the need, hours against the contract, open shifts to hand out and a plan to publish.

Rooms todayNew in 1.2

Rooms by the hour with seats and equipment, a line for now, the past shaded, and every free gap ready to book in two taps.

CapacityNew in 1.2

Load per person and week as a heatmap in percent or hours, a team average, filters by team and the projects behind every cell.

Find a timeNew in 1.2

Pick who needs to be there and how long, see where everyone is free across the week and take one of the best times.

Details

What Scheduler brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/scheduler.tsx
Brings along
utilskitavatarbuttondropdown-menuiconmotionsidebar
npm packages
None
Languages
English and German, 92 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The SchedulerProps interface from components/ui/scheduler.tsx, with the comments from the code.

  • variantoptional
    SchedulerVariant

    resource-weekshift-planroom-daycapacityavailability

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • resourcesoptional
    SchedResource[]
  • bookingsoptional
    Booking[]
  • shiftTypesoptional
    ShiftType[]
  • shiftsoptional
    Shift[]
  • loadoptional
    CapacityWeek[]

    capacity: planned hours per person and week

  • busyoptional
    BusyBlock[]

    availability: busy times per person

  • weekStartoptional
    string

    YYYY-MM-DD, Monday of the first week on screen

  • daysoptional
    number

    Days on screen, 5 or 7

  • weeksoptional
    number

    capacity: weeks on screen

  • todayoptional
    string

    YYYY-MM-DD that counts as today

  • nowoptional
    string

    HH:MM that counts as now (room-day)

  • dayStartoptional
    number

    First and last hour of the day grid

  • dayEndoptional
    number
  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on book, move, resize, assign, publish and pick. Without it the block simulates the request.

Built-in texts

Scheduler writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 92 texts
newBookingEnglish New bookingGerman Neue Buchung
bookEnglish Add bookingGerman Buchung anlegen
bookedEnglish {title} booked for {who} on {day}.German {title} für {who} am {day} gebucht.
bookCellEnglish Book {who} on {day}German {who} am {day} buchen
whatEnglish WhatGerman Was
whatPlaceholderEnglish Project or taskGerman Projekt oder Aufgabe
whoEnglish PersonGerman Person
dayEnglish DayGerman Tag
lengthEnglish DaysGerman Tage
perDayEnglish Hours a dayGerman Stunden am Tag
cancelEnglish CancelGerman Abbrechen
hintEnglish 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.German 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.
roleBookingEnglish bookingGerman Buchung
movedEnglish {title}, {who}, {from} to {to}, {h} h a day.German {title}, {who}, {from} bis {to}, {h} Std. am Tag.
conflictEnglish Over capacity on {days}.German Überbucht am {days}.
conflictShortEnglish Over capacityGerman Überbucht
loadEnglish {h} of {c} hGerman {h} von {c} Std.
removedEnglish {title} removed.German {title} entfernt.
undoEnglish UndoGerman Rückgängig
resizeEnglish Change the length of {title}German Länge von {title} ändern
moveToEnglish Move toGerman Verschieben nach
assignToEnglish Hand over toGerman Übergeben an
removeEnglish RemoveGerman Entfernen
menuEnglish Actions for {title}German Aktionen für {title}
tentativeEnglish TentativeGerman Vorläufig
daysEnglish {n} daysGerman {n} Tage
shiftsEnglish ShiftsGerman Schichten
openEnglish Open shiftsGerman Offene Schichten
openNEnglish {n} openGerman {n} offen
staffedEnglish {n} of {m}German {n} von {m}
assignEnglish Assign {shift} on {day}German {shift} am {day} vergeben
assignedEnglish {who} takes the {shift} shift on {day}.German {who} übernimmt die Schicht {shift} am {day}.
addShiftEnglish Add a shift for {who} on {day}German Schicht für {who} am {day} anlegen
addThisEnglish Add {shift}German {shift} anlegen
changeToEnglish Change to {shift}German In {shift} ändern
toOpenEnglish Back to open shiftsGerman Zurück zu den offenen Schichten
unassignedEnglish {shift} on {day} is open again.German {shift} am {day} ist wieder offen.
busyThatDayEnglish already on shiftGerman hat schon Schicht
hoursWeekEnglish {h} h this weekGerman {h} Std. diese Woche
contractEnglish {h} of {c} hGerman {h} von {c} Std.
publishEnglish Publish planGerman Plan veröffentlichen
publishingEnglish Publishing…German Wird veröffentlicht…
publishedEnglish Plan published. Everyone gets their shifts.German Plan veröffentlicht. Alle bekommen ihre Schichten.
shiftMenuEnglish {shift} shift of {who} on {day}German Schicht {shift} von {who} am {day}
roomsEnglish RoomsGerman Räume
seatsEnglish {n} seatsGerman {n} Plätze
allEnglish AllGerman Alle
seatsMinEnglish {n}+ seatsGerman Ab {n} Plätzen
roomSizeEnglish Room sizeGerman Raumgröße
nowLineEnglish Now {t}German Jetzt {t}
freeEnglish Free {from} to {to}German Frei von {from} bis {to}
bookFreeEnglish Book {room} from {from}German {room} ab {from} buchen
bookRoomEnglish Book roomGerman Raum buchen
bookingForEnglish {room} from {from}German {room} ab {from}
durationEnglish LengthGerman Dauer
roomBookedEnglish {room} booked from {from} to {to}.German {room} von {from} bis {to} gebucht.
nothingFreeEnglish Nothing free for the rest of the day.German Für den Rest des Tages ist nichts frei.
pickGapEnglish Pick a free slot in the plan to book it.German Wähle eine freie Zeit im Plan, um sie zu buchen.
capacityEnglish CapacityGerman Auslastung
unitEnglish Show asGerman Anzeigen als
percentEnglish PercentGerman Prozent
hoursEnglish HoursGerman Stunden
teamEnglish TeamGerman Team
teamAvgEnglish Team averageGerman Teamschnitt
weekOfEnglish Week of {d}German Woche vom {d}
utilizationEnglish {who}, week of {d}, {p} bookedGerman {who}, Woche vom {d}, {p} verplant
overEnglish {h} h overGerman {h} Std. zu viel
underEnglish {h} h freeGerman {h} Std. frei
exactEnglish Fully bookedGerman Voll verplant
pickCellEnglish Pick a cell to see what fills that week.German Wähle eine Zelle, dann steht hier, was die Woche füllt.
legendLowEnglish Room to spareGerman Luft
legendGoodEnglish Well bookedGerman Gut verplant
legendFullEnglish FullGerman Voll
legendOverEnglish Over capacityGerman Überbucht
peopleEnglish PeopleGerman Personen
pickPeopleEnglish Who needs to be thereGerman Wer dabei sein muss
meetingLengthEnglish Meeting lengthGerman Dauer des Termins
minEnglish {n} minGerman {n} Min.
allowOneEnglish Allow one person to missGerman Eine Person darf fehlen
suggestionsEnglish Best timesGerman Beste Zeiten
allFreeEnglish All {n} freeGerman Alle {n} frei
oneMissingEnglish {who} is busyGerman {who} ist belegt
pickEnglish Pick this timeGerman Diese Zeit wählen
pickedEnglish Invite sent for {when}.German Einladung für {when} verschickt.
noSlotEnglish No common time this week. Allow one person to miss or pick a shorter meeting.German Diese Woche gibt es keine gemeinsame Zeit. Lass eine Person fehlen oder wähle einen kürzeren Termin.
busyEnglish BusyGerman Belegt
pastEnglish PastGerman Vorbei
freeAllEnglish Everyone freeGerman Alle frei
freeMostEnglish All but one freeGerman Alle bis auf eine Person frei
needTwoEnglish Pick at least two people.German Wähle mindestens zwei Personen.
slotLabelEnglish {day} {from} to {to}German {day} {from} bis {to}
toEnglish toGerman bis

Accessibility

Read from the source of the component.

  • Scheduler handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Backspace, Delete and Space itself.
  • Sets aria-checked, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-roledescription and aria-selected, so screen readers announce purpose and state.
  • Uses the roles button, grid, gridcell, radio, radiogroup, status and switch.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,874 lines
components/ui/scheduler.tsx
'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>
  );
}

Frequent questions

How do I install Scheduler?

With aiw add @ai-website/scheduler. The command puts components/ui/scheduler.tsx into your project, the code is yours from then on. It brings along avatar, button, dropdown-menu, icon, motion and sidebar. It needs no further npm packages.

Which variants does Scheduler have?

5 variants, Team week, Shift plan, Rooms today, Capacity and Find a time. You pick one with the variant prop, for example variant="shift-plan".

Can I translate the texts of Scheduler?

Yes. Scheduler carries 92 texts of its own in English and German, such as “New booking”, “Add booking” and “What”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Scheduler?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Scheduler with aiw_components and install it with aiw_add @ai-website/scheduler. Name the variant in your prompt, for example variant="resource-week", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard