Aller au contenu

Planification du bouclage d’une rédaction

Un incendie éclate à 19:18. Mettez un reporter et un photographe dessus, et envoyez la page 1 au clichage avant 21:30.

  • Rédaction
  • The Linden Gazette
  • Arborescence de ressources
  • Colonnes de ligne
  • Dépendances
  • Recherche instantanée
  • Filtres
  • Vues enregistrées
  • Lignes de synthèse
  • Minimap
  • Thèmes
  • Démo avec données fictives

Mission

0 étapes faites sur 3

Mercredi, 19:20. Le fil tombe : incendie à l’entrepôt Old Mill, Canal Street. La page 1 part au clichage à 21:30.

  1. Mettez un reporter sur MILL-FIRE. — À faire

    Faites glisser la coupure rouge MILL-FIRE depuis le fil, tout droit vers Tomás Reyes (Locale), le seul reporter libre à 19:30, ou faites un clic droit et attribuez-la. Plus tard, elle rate l’heure de copie de 20:30.

  2. Envoyez un photographe à Canal Street. — À faire

    Faites glisser les photos de MILL-FIRE vers Sam Achterberg (Photo). Noor Haddad est refusée : depuis le Riverside Stadium, elle ne peut pas arriver avant 19:55.

  3. Relisez la page 1 dès que la copie arrive. — À faire

    La relecture rouge de MILL-FIRE sur la ligne d’Iris Lund (Secrétariat) commence avant la livraison de la copie. Faites-la glisser vers Ben Asante à 20:30, ou cliquez dessus et prenez le premier créneau : Iris relit la critique d’opéra dès 20:45.

Environ 30 secondes. Les étapes se cochent au fur et à mesure.

Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

The Linden Gazette
Fil2 à attribuerLocaleAda ByrneDehors · TribunalTomás ReyesIciPriya NairReposPolitiqueHelen OkaforDehors · MairieSportsJonah FinchIciCultureClara DuvalDehors · Grand OpéraPhotoSam AchterbergIciNoor HaddadDehors · Riverside StadiumSecrétariatIris LundIciBen AsanteIciMise en pageMei TanakaIciOskar BrandtIciRotativesRotative 1ArrêtRotative 2TourneMILL-FIRE · P1Le feu ravage l’entrepôt Old MillMILL-FIRE · P1Canal StreetCOURT-VERDICT · P2Fraude de Westgate : le jury rend son verdictCOUNCIL-VOTE · P5Le conseil vote le budget ce soirCUP-REPORT · P46Match de coupe : le compte renduOPERA-REVIEW · P18Une Tosca audacieuse au Grand OpéraOPERA-REVIEW · P18Grand OpéraCUP-PREVIEW · P48Riverside StadiumCUP-REPORT · P46Riverside StadiumRelecture · P2COURT-VERDICTRelecture · P46CUP-REPORTRelecture · P5COUNCIL-BUDGETRelecture · P48CUP-PREVIEWRelecture · P1MILL-FIRERelecture · P18OPERA-REVIEWRelecture · P5COUNCIL-VOTEPremièrePage 5PremièrePages 2–3PremièrePage 1DernièrePage 2 · Dernière éditionDernièrePage 5 · Dernière éditionPremièrePage 48DernièrePage 18DernièrePage 46Première édition18000 exemplairesLavage des blanchetsDernière édition13000 exemplairesContrat : hebdo gratuitDernière édition11000 exemplaires5501050900Copie livréemots par heuremercredi 21 octobrejeudi 22 octobre18⁰⁰19⁰⁰20⁰⁰21⁰⁰22⁰⁰23⁰⁰00⁰⁰01⁰⁰02⁰⁰03⁰⁰

Aperçu de la première imageEn direct : le vrai moteur Pro, dans votre navigateurEnviron 30 secondes. Les étapes se cochent au fur et à mesure. Clavier : placez le focus sur la grille, puis flèches, Entrée et Alt+flèches pour déplacer.

La situation.

The Linden Gazette est un quotidien régional fictif. Son chef d’édition de nuit planifie la soirée contre les horaires d’impression : chaque sujet a besoin d’un reporter de service, d’un secrétaire de rédaction une fois la copie livrée, d’un maquettiste une fois la copie relue et les photos arrivées, et d’une rotative qui ne démarre que lorsque toutes les pages de l’édition sont parties.

Une information urgente bouscule tout. Un planning qui ne montre que des barres cache la chaîne qui va du reporter à la rotative ; celui-ci la dessine, refuse ce que la rédaction ne peut pas faire (un photographe qui n’arrive pas, une copie qui rate l’édition), signale ce qui ne tient plus et garde sous les yeux, heure par heure, la copie qui arrive au secrétariat.

Ce que vous allez faire

Vous confierez une information urgente au seul reporter libre, enverrez le photographe qui peut atteindre l’incendie à temps et déplacerez la relecture de la une pour que la page 1 parte dans la première édition.

Où s’arrête la bibliothèque et où commence votre application.

SuperScheduler fournit

  • Un arbre de ressources des services et du personnel avec trois rowHeaderColumns (qui, une jauge de copie, où il se trouve), et une ligne figée en haut (frozen: 'top') qu’onBeforeCellRender remplit avec les mots livrés chaque heure.
  • Trois lignes d’en-tête de temps : la date, les bandes d’édition dessinées comme des zones d’en-tête et une ligne d’heures écrite comme un horaire par onBeforeTimeHeaderRender ; le temps masqué (onIncludeTimeCell) limite le planning à la soirée de travail.
  • Des coupures en cartes HTML avec zones (une barre des mots livrés), des cellules désactivées hors service, des trajets peints dans les lignes des photographes, des liens fin-début du reporter à la rotative et des séparateurs aux heures d’édition et à l’horloge de la rédaction.
  • La recherche en direct et le filtre d’état avec control.events.filter() et onEventFilter, le filtre par service avec control.rows.filter() et onRowFilter, et des vues enregistrées (getViewState, applyViewState) en onglets de rubrique du journal.
  • La minicarte de l’arrivée de la copie, la croix complète, les niveaux de zoom, les déplacements au clavier, l’historique d’annulation, la carte au survol et la carte de glisser (super-scheduler/react-render), et un menu contextuel aux couleurs du journal.

Le code de cet exemple décide

  • Qui fait quoi : les sujets vont aux reporters, les photos aux photographes, la relecture au secrétariat de rédaction, la mise en page aux maquettistes ; les services, l’horloge et les heures de copie et de pages refusent un dépôt dans onEventMoving, en nommant l’édition ratée.
  • Les trajets : un photographe doit pouvoir aller d’un reportage au suivant, mesuré sur un petit plan de la ville.
  • La chaîne : relecture après livraison, mise en page après relecture et photos, rotatives après la dernière page ; ce qui la casse est signalé, pas refusé.
  • Les suggestions : qui est libre pour la dépêche, et le premier créneau qui garde la chaîne, enregistré en une seule étape d’annulation.
  • Chaque chiffre : mots livrés par reporter, pages dans les temps par édition, copie arrivant au secrétariat par heure face à sa capacité, et les tampons des coupures.

Le code de cette démo.

Ce sont les fichiers exécutés par cette page, pas une copie simplifiée. Ils compilent avec les exports publics de SuperScheduler Pro.

tsx.zip

L’intégration : niveaux de zoom avec trois lignes d’en-tête, temps masqué, rowHeaderColumns, la ligne figée de la copie, cellules désactivées et trajets, séparateurs, liens construits à partir de la chaîne, la recherche en direct et les filtres, la carte au survol, le verdict de glisser et le menu contextuel, et les callbacks où les règles de la rédaction refusent un dépôt ou l’alignent au quart d’heure.

/**
 * The Linden Gazette's night desk: the wire tray, the desks and their staff, and the copy flowing
 * from the reporters to the presses over one evening, against the edition deadlines.
 *
 * Library: a resource tree with three rowHeaderColumns and a frozen copy-flow row filled by
 * onBeforeCellRender, three time-header rows (dateline, edition bands as header areas, timetable
 * hours) written by onBeforeTimeHeaderRender, hidden time (onIncludeTimeCell), clippings as HTML
 * cards with areas, disabled cells, finish-to-start links, separators, the full crosshair, live
 * search and status filters (events.filter, onEventFilter), the desk filter (rows.filter,
 * onRowFilter), zoom levels, keyboard moves and undo history, the event hover card and the drag
 * card (super-scheduler/react-render), conflict highlighting and a context menu. Application: who
 * does which work, shifts, travel, deadlines, what breaks the chain, the stamps, every figure and
 * the quarter-hour snapping of a drop. Those rules live in ./rules; ./paint writes the markup.
 */
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { SchedulerHistory } from 'super-scheduler/history'
import { SuperSchedulerComponent, type SchedulerRenderProps } from 'super-scheduler/react-render'
import { format, plural } from '~/i18n/format'
import { HOUR, MINUTE, civil, parseCivil } from '../../scheduler/civil'
import { createEventMenu, type MenuEntry } from '../kit/context-menu'
import { useEventHover, type HoverCardModel } from '../kit/hover-card'
import { KEYS } from '../kit/keys'
import { useDropRefusal } from '../kit/use-drop-refusal'
import { createDragCard } from '../kit/verdict'
import { skinClass, skinMenuTheme } from '../registry'
import type { DemoCommon } from '../types'
import {
  GRID,
  SUMMARY_ID,
  deskOfRow,
  headlineOf,
  isLaneId,
  isStaffId,
  readItemFields,
  staffName,
  toResources,
  type ItemEvent,
} from './adapter'
import { chainHtml, chainLinks, itemCard } from './card'
import {
  BOARD,
  DESK_IDS,
  EDITIONS,
  EDITION_IDS,
  INITIAL_VIEW_START,
  NOW,
  STAFF,
  WIRE_ROW,
  type DeskId,
  type Item,
} from './model'
import {
  bandAreas,
  cardHtml,
  clock,
  datelineHtml,
  deskHtml,
  flowPainter,
  gaugeHtml,
  hourHtml,
  nowArea,
  offShift,
  percent,
  staffHtml,
  travelAreas,
  travelBands,
  whereHtml,
  wireFlashArea,
} from './paint'
import {
  MIN_MINUTES,
  SNAP_MINUTES,
  chainOf,
  deskCapacity,
  deskOf,
  earliestSlot,
  freeFor,
  issuesOf,
  matchesSearch,
  matchesStatus,
  pageById,
  refusalOf,
  shiftOf,
  stageOf,
  staffById,
  staffFigure,
  travelTo,
  whereAt,
  wordsFiled,
  type Issue,
  type RefusalInfo,
  type StatusFilter,
} from './rules'
import type { GazetteText, ViewId, ZoomId } from './text'

const SLUG = 'newsroom-desk'
const NOW_TICKS = parseCivil(NOW)
const BOARD_START = parseCivil(BOARD.start)
const BOARD_END = parseCivil(BOARD.end)
const SNAP = SNAP_MINUTES * MINUTE

/** What the board asks the demo to do (context menu, hover buttons, keyboard). */
export type ItemAction = 'details' | 'assign' | 'earliest' | 'trace' | 'toggle-links'

/** Row-header layout by stage width: three columns, two, or the staff column alone. */
export type Density = 'wide' | 'compact' | 'narrow'

export interface GazetteSchedulerProps {
  readonly events: ItemEvent[]
  /** The same events as business objects, for rules that look at other items. */
  readonly items: readonly Item[]
  readonly issues: readonly Issue[]
  readonly text: GazetteText
  readonly common: DemoCommon
  /** BCP 47 tag for labels, and the scheduler locale id. */
  readonly tag: string
  readonly schedulerLocale: string
  /** Level on mount. Later changes go through control.zoom or a saved view. */
  readonly initialZoom: ZoomId
  /** The section: what the clippings' page tag says and which kinds step forward. */
  readonly mode: ViewId
  readonly showLinks: boolean
  /** The clipping in the detail sheet: a heavier rule. */
  readonly selected: string | null
  /** The clipping whose chain is pinned forward (the rest steps back). */
  readonly traced: string | null
  readonly search: string
  readonly status: StatusFilter
  readonly desk: DeskId | null
  readonly density: Density
  readonly history: SchedulerHistory
  readonly controlRef: RefObject<SuperScheduler.Scheduler | null>
  readonly boardRef: RefObject<HTMLDivElement | null>
  readonly onEventsChange: (args: SchedulerEventsChangeArgs) => void
  readonly onSelect: (id: string) => void
  readonly onMoved: (id: string) => void
  readonly onResized: (id: string, edge: 'start' | 'end') => void
  readonly onRefused: (id: string, message: string) => void
  readonly onAction: (action: ItemAction, id: string) => void
  readonly onZoomLevel: (level: ZoomId) => void
}

/** Three header rows at every level: the dateline, the edition bands, the hours. */
const HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Day', height: GRID.header.day },
  { groupBy: 'Day', height: GRID.header.edition },
  // A declared format tells the level-of-detail planner how wide the label really is.
  { groupBy: 'Hour', height: GRID.header.hour, format: 'HH:mm' },
]

/** Hour cells at every level (the minimap then counts copy per hour); only their width changes. */
const ZOOM_LEVELS: SuperScheduler.ZoomLevel[] = [
  { id: 'close', properties: { scale: 'Hour', cellWidth: 200, timeHeaders: HEADERS } },
  { id: 'evening', properties: { scale: 'Hour', cellWidth: GRID.cellWidth, timeHeaders: HEADERS } },
  { id: 'day', properties: { scale: 'Hour', cellWidth: 72, timeHeaders: HEADERS } },
]
const ZOOM_IDS: readonly ZoomId[] = ['close', 'evening', 'day']

/** The query and the status chips, handed to `control.events.filter()`. */
export class ClippingFilter {
  constructor(
    readonly query: string,
    readonly status: StatusFilter,
  ) {}
}

/** The desk chips, handed to `control.rows.filter()`. */
export class DeskFilter {
  constructor(readonly desk: DeskId) {}
}

const GLYPH = {
  assign:
    '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h8M8 4.5 11.5 8 8 11.5"/><path d="M13.5 3v10"/></svg>',
  earliest:
    '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8.6" r="5"/><path d="M8 6v2.8l1.8 1.1M6.2 2h3.6"/></svg>',
} as const

/** A sentence for a refusal, with the names and times it needs. */
export function refusalMessage(info: RefusalInfo, text: GazetteText, tag: string): string {
  const staff = info.staff === undefined ? null : staffById(info.staff)
  const values = {
    name: staff === null ? '' : staffName(staff, text),
    time: info.at === undefined ? '' : clock(info.at, tag),
    edition: info.edition === undefined ? '' : text.editions[info.edition].name,
    from: info.from === undefined ? '' : text.locations[info.from],
    to: info.to === undefined ? '' : text.locations[info.to],
    minutes: info.minutes ?? 0,
  }
  switch (info.reason) {
    case 'wrong-desk':
      return info.role === undefined ? text.rules['wire-only'] : text.rules.wrongDesk[info.role]
    case 'off-shift': {
      const shift = staff === null ? null : shiftOf(staff)
      return format(
        shift !== null && info.at === shift.start
          ? text.rules.offShiftStart
          : text.rules.offShiftEnd,
        values,
        tag,
      )
    }
    default:
      return format(text.rules[info.reason], values, tag)
  }
}

/** Menu and sheet label of the wire command: who takes it, or why nobody can. */
export function assignLabel(
  item: Item,
  items: readonly Item[],
  text: GazetteText,
  tag: string,
): { label: string; enabled: boolean } {
  const free = freeFor(item, items, NOW_TICKS)[0]
  if (free === undefined)
    return {
      label: format(text.actions.nobody, { time: clock(parseCivil(item.start), tag) }),
      enabled: false,
    }
  if (item.kind === 'photo')
    return {
      label: format(text.actions.send, {
        name: staffName(free, text),
        minutes: travelTo(free.id, item, items).minutes,
      }),
      enabled: true,
    }
  return {
    label: format(text.actions.assign, {
      name: staffName(free, text),
      time: clock(parseCivil(item.start), tag),
    }),
    enabled: true,
  }
}

/** Menu and sheet label of the earliest slot that keeps the chain. */
export function earliestLabel(
  item: Item,
  items: readonly Item[],
  text: GazetteText,
  tag: string,
): { label: string; enabled: boolean } {
  const next = earliestSlot(item, items, NOW_TICKS)
  if (next === null) return { label: text.actions.noSlot, enabled: false }
  if (next.lane === item.lane && next.start === item.start)
    return { label: text.actions.atEarliest, enabled: false }
  const staff = staffById(next.lane)
  return {
    label: format(text.actions.earliest, {
      name: staff === null ? '' : staffName(staff, text),
      time: clock(parseCivil(next.start), tag),
    }),
    enabled: true,
  }
}

/** The labels a search looks through: slug, headline, byline, place, page and kind. */
export function searchLabels(item: Item, items: readonly Item[], text: GazetteText): string[] {
  const staff = staffById(item.lane)
  const story = item.kind === 'sub' ? items.find((other) => other.id === item.story) : item
  return [
    story?.slug ?? '',
    story === undefined ? '' : headlineOf(story.id, text),
    staff === null ? text.wire.row : staffName(staff, text),
    item.location === undefined ? '' : text.locations[item.location],
    item.page === undefined ? '' : text.pages[item.page],
    story?.page === undefined ? '' : text.pages[story.page],
    text.kinds[item.kind],
    item.edition === undefined ? '' : text.editions[item.edition].name,
  ]
}

/**
 * Brings one clipping's chain forward (`gz-traced`) and lets the rest of the board step back, or
 * clears it. Class writes on hover enter and leave only, never per pointer frame.
 */
function trace(
  root: HTMLElement | null,
  ids: ReadonlySet<string> | null,
  links: readonly SuperScheduler.LinkData[],
): void {
  if (root === null) return
  for (const element of root.querySelectorAll('.gz-traced')) element.classList.remove('gz-traced')
  root.toggleAttribute('data-tracing', ids !== null)
  if (ids === null) return
  for (const id of ids)
    for (const element of root.querySelectorAll(
      `[data-super-scheduler-part="event"][data-id="${CSS.escape(id)}"]`,
    ))
      element.classList.add('gz-traced')
  links.forEach((link, index) => {
    if (!ids.has(String(link.from)) || !ids.has(String(link.to))) return
    for (const element of root.querySelectorAll(`.gz-ln-${index}`))
      element.classList.add('gz-traced')
  })
}

export function GazetteScheduler(props: GazetteSchedulerProps) {
  const { events, items, issues, text, common, tag, schedulerLocale, initialZoom } = props
  const { mode, showLinks, selected, traced, search, status, desk, density } = props
  const { history, controlRef, boardRef } = props
  const { onEventsChange, onSelect, onMoved, onResized, onRefused, onAction, onZoomLevel } = props

  const resources = useMemo(() => toResources(text), [text])
  const dropRefusal = useDropRefusal(onRefused)
  // Hover buttons call the latest handler without rebuilding every card's areas.
  const action = useRef(onAction)
  useEffect(() => {
    action.current = onAction
  }, [onAction])

  // The chain of every clipping, from the reporter's line to the press: links built from the data.
  const links = useMemo<SuperScheduler.LinkData[]>(() => {
    if (!showLinks) return []
    const broken = new Set(issues.map((issue) => `${issue.other ?? ''}>${issue.item}`))
    const pairs: [Item, Item][] = []
    for (const item of items) {
      if (item.kind === 'story') {
        const sub = items.find((other) => other.kind === 'sub' && other.story === item.id)
        if (sub !== undefined) pairs.push([item, sub])
      } else if (item.kind === 'sub' || item.kind === 'photo') {
        const story = item.kind === 'sub' ? items.find((other) => other.id === item.story) : item
        const layout = items.find((other) => other.kind === 'layout' && other.page === story?.page)
        if (layout !== undefined) pairs.push([item, layout])
      } else if (item.kind === 'layout') {
        const edition = pageById(item.page)?.edition
        for (const run of items)
          if (run.kind === 'press' && run.edition === edition) pairs.push([item, run])
      }
    }
    return pairs.map(([from, to], index) => {
      const late = broken.has(`${from.id}>${to.id}`)
      const pending = from.lane === WIRE_ROW
      // Pages to the presses: the last hop, drawn lighter so the copy's path reads first.
      const press = to.kind === 'press'
      return {
        id: `${from.id}>${to.id}`,
        from: from.id,
        to: to.id,
        type: 'FinishToStart',
        // Copy still on the wire: a chain drawn but not yet held by anyone.
        ...(pending ? { style: 'dashed' as const } : {}),
        cssClass: `gz-link gz-ln-${index}${late ? ' ss-link--late gz-link--late' : ''}${pending ? ' gz-link--pending' : ''}${press ? ' gz-link--press' : ''}`,
      }
    })
  }, [items, issues, showLinks])

  // Hover card: the stage, the deadline, the travel and the chain, by the rules.
  const card = useCallback(
    (event: SuperScheduler.Event): HoverCardModel | null => {
      const item = items.find((candidate) => candidate.id === String(event.id()))
      return item === undefined ? null : itemCard(item, items, issues, text, common, tag, NOW_TICKS)
    },
    [items, issues, text, common, tag],
  )
  const { eventHover, closeHover, suppressHover } = useEventHover(card)

  // Drag verdict: the desk's refusal, or the chain it would break or mend, and the chain itself
  // re-evaluated at the proposed position.
  const dragCard = useMemo<SuperScheduler.DragCardOptions>(() => {
    const proposed = (info: SuperScheduler.DragCardInfo) => {
      const item = items.find((candidate) => candidate.id === String(info.eventId))
      if (item === undefined || info.resource === null || !isLaneId(info.resource)) return null
      const moved: Item = {
        ...item,
        lane: info.resource,
        start: civil(info.start.getTime()),
        end: civil(info.end.getTime()),
      }
      const next = items.map((candidate) => (candidate.id === item.id ? moved : candidate))
      return { item: moved, items: next, issues: issuesOf(next) }
    }
    const base = createDragCard({
      tag,
      common,
      rowName: (id) => {
        if (id === WIRE_ROW) return text.wire.row
        const staff = staffById(String(id))
        return staff === null ? null : staffName(staff, text)
      },
      warn: (info) => {
        const next = proposed(info)
        if (next === null) return null
        const chain = chainOf(next.item, next.items)
        const before = issues.filter((issue) => chain.has(issue.item))
        const after = next.issues.filter((issue) => chain.has(issue.item))
        const first = after[0]
        if (first !== undefined)
          return plural(text.warn.breaks, after.length, tag, {
            label: text.status[first.kind].toLowerCase(),
          })
        const mended = before[0]
        return mended === undefined
          ? null
          : format(text.warn.mends, { label: text.status[mended.kind].toLowerCase() })
      },
    })
    return {
      ...base,
      // The header marker names the time the pointer drives, as the newsroom clock does.
      markerFormat: 'HH:mm',
      html: (info) => {
        const html = base.html?.(info) ?? null
        if (html === null || info.forbidden || info.overlapping) return html
        const next = proposed(info)
        if (next === null) return html
        const chain = chainHtml(chainLinks(next.item, next.items, next.issues, text), text)
        return chain === '' ? html : `${html.slice(0, -'</div>'.length)}${chain}</div>`
      },
    }
  }, [items, issues, text, common, tag])

  // Context menu: rebuilt when the plan changes, so its labels name the real consequence.
  const contextMenu = useMemo(
    () =>
      createEventMenu({
        theme: skinMenuTheme(SLUG),
        title: (event) => format(common.kit.menuTitle, { name: event.text() }),
        onOpenChange: (open) => suppressHover(open),
        entries: (event) => {
          const id = String(event.id())
          const item = items.find((candidate) => candidate.id === id)
          if (item === undefined) return []
          const entries: MenuEntry[] = [
            {
              label: common.kit.openDetails,
              icon: 'details',
              keys: KEYS.details,
              run: () => onAction('details', id),
            },
          ]
          if (item.lane === WIRE_ROW) {
            const assign = assignLabel(item, items, text, tag)
            entries.push({
              label: assign.label,
              icon: 'handover',
              primary: true,
              disabled: !assign.enabled,
              run: () => onAction('assign', id),
            })
          } else if (item.kind === 'sub' || item.kind === 'layout' || item.kind === 'press') {
            const earliest = earliestLabel(item, items, text, tag)
            entries.push({
              label: earliest.label,
              icon: 'slot',
              primary: issues.some((issue) => issue.item === id),
              disabled: !earliest.enabled || parseCivil(item.start) < NOW_TICKS,
              run: () => onAction('earliest', id),
            })
          }
          entries.push(
            'separator',
            {
              label: traced === id ? text.actions.untrace : text.actions.trace,
              icon: 'focus',
              run: () => onAction('trace', id),
            },
            {
              label: showLinks ? text.toolbar.hideLinks : text.toolbar.showLinks,
              icon: 'link',
              run: () => onAction('toggle-links', id),
            },
          )
          return entries
        },
      }),
    [items, issues, traced, showLinks, common, text, tag, onAction, suppressHover],
  )

  // Separators: the copy and page deadlines of each edition, its press start, and the clock.
  const separators = useMemo<SuperScheduler.SeparatorData[]>(() => {
    const marks: SuperScheduler.SeparatorData[] = EDITION_IDS.flatMap((id) => {
      const edition = EDITIONS[id]
      const name = text.editions[id].name
      return [
        {
          location: edition.copy,
          layer: 'BelowEvents' as const,
          width: 1,
          cssClass: 'gz-sep gz-sep--copy',
          toolTip: `${name} · ${format(text.header.copyBy, { time: clock(parseCivil(edition.copy), tag) })}`,
        },
        {
          location: edition.pages,
          layer: 'BelowEvents' as const,
          width: 3,
          cssClass: 'gz-sep gz-sep--pages',
          toolTip: `${name} · ${format(text.header.pagesBy, { time: clock(parseCivil(edition.pages), tag) })}`,
        },
        {
          location: edition.press,
          layer: 'BelowEvents' as const,
          width: 1,
          cssClass: 'gz-sep gz-sep--press',
          toolTip: `${name} · ${format(text.header.pressAt, { time: clock(parseCivil(edition.press), tag) })}`,
        },
      ]
    })
    marks.push({
      location: NOW,
      layer: 'BelowEvents',
      width: 2,
      cssClass: 'gz-sep gz-sep--now',
      toolTip: `${text.masthead.clock} · ${clock(NOW_TICKS, tag)}`,
    })
    return marks
  }, [text, tag])

  const columns = useMemo<SuperScheduler.RowHeaderColumnData[]>(
    () =>
      density === 'narrow'
        ? [{ text: text.columns.staff, width: GRID.narrowColumn, nonresizable: true }]
        : density === 'compact'
          ? [
              { text: text.columns.staff, width: GRID.compactStaffColumn, nonresizable: true },
              { text: text.columns.copy, width: GRID.compactCopyColumn, nonresizable: true },
            ]
          : [
              { text: text.columns.staff, width: GRID.staffColumn, nonresizable: true },
              { text: text.columns.copy, width: GRID.copyColumn, nonresizable: true },
              { text: text.columns.where, width: GRID.whereColumn, nonresizable: true },
            ],
    [density, text],
  )

  const config = useMemo<SchedulerRenderProps>(() => {
    const escape = SuperScheduler.Util.escapeHtml
    const flagged = new Set(issues.map((issue) => issue.item))
    const byId = new Map(items.map((item) => [item.id, item]))
    const paintFlow = flowPainter(items)
    const bands = travelBands(STAFF, items)
    const overlapText = (conflicts: readonly SuperScheduler.Event[]) => {
      const first = conflicts[0]
      return first === undefined ? null : format(common.kit.overlaps, { name: first.text() })
    }

    // Desk section heads: what each desk has on tonight.
    const deskMeta = new Map<DeskId, string>(
      DESK_IDS.map((id) => {
        const lanes = new Set<string>(
          STAFF.filter((staff) => staff.desk === id).map((staff) => staff.id),
        )
        const work = items.filter((item) => lanes.has(item.lane) && item.kind !== 'fixed')
        const first = STAFF.find((staff) => staff.desk === id)?.role
        const count = work.length
        if (first === 'reporter') {
          const words = work.reduce((sum, item) => sum + (item.words ?? 0), 0)
          return [
            id,
            `${plural(text.deskMeta.stories, count, tag)} · ${format(text.deskMeta.words, { words: new Intl.NumberFormat(tag).format(words) })}`,
          ]
        }
        const forms =
          first === 'photographer'
            ? text.deskMeta.jobs
            : first === 'sub'
              ? text.deskMeta.slots
              : first === 'designer'
                ? text.deskMeta.pages
                : text.deskMeta.runs
        return [id, plural(forms, count, tag)]
      }),
    )
    const wireItems = items.filter((item) => item.lane === WIRE_ROW)
    const wireStory = wireItems.find((item) => item.kind === 'story')
    const wirePhoto = wireItems.find((item) => item.kind === 'photo')
    const freeReporter =
      wireStory === undefined ? undefined : freeFor(wireStory, items, NOW_TICKS)[0]
    const freePhotographer =
      wirePhoto === undefined ? undefined : freeFor(wirePhoto, items, NOW_TICKS)[0]

    // Header markup per cell, built once per level (the hook runs for every header cell).
    const headerCache = new Map<
      string,
      Pick<SuperScheduler.TimeHeaderRenderHeader, 'html' | 'cssClass' | 'areas' | 'toolTip'>
    >()
    // Cell areas and classes per row and hour, built once per plan.
    const cellCache = new Map<
      string,
      { css: string; areas: SuperScheduler.AreaData[]; disabled: boolean }
    >()
    // Clippings: markup and areas cached per state, so an unchanged card is not rebuilt.
    const cardCache = new Map<string, { html: string; areas: SuperScheduler.AreaData[] }>()

    return {
      locale: schedulerLocale,
      cssClass: skinClass(SLUG),
      startDate: BOARD.day,
      days: 2,
      zoomLevels: ZOOM_LEVELS,
      zoom: initialZoom,
      zoomPosition: 'left',
      zoomGesture: { min: 56, max: 280, scales: false },
      // The working evening only: noon to 04:00.
      onIncludeTimeCell: (args) => {
        const start = args.cell.start.getTime()
        if (start < BOARD_START || start >= BOARD_END) args.cell.visible = false
      },
      // Every hour is newsroom time: nothing is shaded as non-business.
      cellsMarkBusiness: false,
      // Exact times; drops snap to quarter hours in onEventMoving below.
      useEventBoxes: 'Never',
      snapToGridEventMoving: false,
      snapToGridEventResizing: false,
      height: '100%',
      treeEnabled: true,
      treePreventParentUsage: true,
      treeIndent: 0,
      rowHeaderColumns: columns,
      rowHeaderWidthAutoFit: false,
      eventHeight: GRID.eventHeight,
      rowMarginTop: GRID.rowMarginTop,
      rowMarginBottom: GRID.rowMarginBottom,
      allowEventOverlap: false,
      conflictHighlight: true,
      durationBarVisible: false,
      linkShape: 'RightAngled',
      // Under the clippings: a chain reads between them, never across their text.
      linkLayer: 'Below',
      crosshairType: 'Full',
      separators,
      showToolTip: false,
      keyboardEnabled: true,
      keyboardTarget: 'component',
      keyboardMode: 'Full',
      history,
      controlRef,
      onEventsChange,

      // The corner over the row headers: the screen's name, set like a section head.
      onBeforeCornerRender: (args) => {
        const tight = density === 'wide' ? '' : ' gz-corner--tight'
        args.html = `<span class="gz-corner${tight}"><b>${escape(text.header.corner)}</b><span>${escape(text.masthead.edition)}</span></span>`
      },

      onBeforeTimeHeaderRender: (args) => {
        const header = args.header
        const ticks = header.start.getTime()
        const end = header.end.getTime()
        const cellWidth = args.control.cellWidth ?? GRID.cellWidth
        const key = `${Math.round(cellWidth)}:${header.level}:${ticks}:${end}`
        let painted = headerCache.get(key)
        if (painted === undefined) {
          if (header.level === 0) {
            painted = {
              html: datelineHtml(Math.max(ticks, BOARD_START), tag),
              cssClass: 'gz-th gz-th--day',
              areas: [],
              toolTip: '',
            }
          } else if (header.level === 1) {
            // The edition bands, and the newsroom clock as a reversed tag over them.
            const now = nowArea(ticks, end, text, tag)
            painted = {
              html: '',
              cssClass: 'gz-th gz-th--bands',
              areas: [
                ...bandAreas(ticks, end, cellWidth, text, tag),
                ...(now === null ? [] : [now]),
              ],
              toolTip: '',
            }
          } else {
            const classes = ['gz-th', 'gz-th--hour']
            if (end <= NOW_TICKS) classes.push('gz-th--past')
            if (ticks <= NOW_TICKS && NOW_TICKS < end) classes.push('gz-th--now')
            painted = { html: hourHtml(ticks), cssClass: classes.join(' '), areas: [], toolTip: '' }
          }
          headerCache.set(key, painted)
        }
        header.html = painted.html
        header.cssClass = painted.cssClass
        header.areas = painted.areas
        header.toolTip = painted.toolTip
      },

      onBeforeRowHeaderRender: (args) => {
        const row = args.row
        const id = row.id
        const [first, second, third] = row.columns
        if (id === SUMMARY_ID) {
          row.cssClass = 'gz-row gz-row--summary'
          const total = items.reduce(
            (sum, item) => sum + (item.kind === 'story' ? wordsFiled(item, NOW_TICKS) : 0),
            0,
          )
          if (first !== undefined)
            first.html = `<span class="gz-rh gz-rh--summary"><span class="gz-rh__name">${escape(text.summary.row)}</span><span class="gz-rh__beat">${escape(text.summary.rowMeta)}</span></span>`
          if (second !== undefined)
            second.html = `<span class="gz-total"><b>${escape(new Intl.NumberFormat(tag).format(total))}</b><small>${escape(format(text.wordsShort, { count: '' }).trim())}</small></span>`
          // What the bars measure against: the copy desk's capacity at the clock.
          if (third !== undefined)
            third.html =
              `<span class="gz-cap"><small>${escape(text.summary.capacity)}</small>` +
              `<b>${escape(format(text.summary.capacityValue, { words: new Intl.NumberFormat(tag).format(deskCapacity(NOW_TICKS)) }))}</b></span>`
          return
        }
        if (id === WIRE_ROW) {
          row.cssClass = 'gz-row gz-row--wire'
          const count = wireItems.length
          // The app's suggestions: who is free for the wire copy, who can get to the scene.
          const reporter =
            wireStory === undefined
              ? ''
              : suggestHtml(
                  text.wire.reporter,
                  freeReporter === undefined ? null : staffName(freeReporter, text),
                  text,
                )
          const photographer =
            wirePhoto === undefined
              ? ''
              : suggestHtml(
                  freePhotographer === undefined
                    ? text.wire.photographer
                    : `${text.wire.photographer} · ${format(text.minutes, { count: travelTo(freePhotographer.id, wirePhoto, items).minutes })}`,
                  freePhotographer === undefined ? null : staffName(freePhotographer, text),
                  text,
                )
          // Narrower headers fold what their missing columns would say under the tray's name.
          const folded = density === 'narrow' ? reporter : density === 'compact' ? photographer : ''
          if (first !== undefined)
            first.html =
              `<span class="gz-rh gz-rh--wire"><b class="gz-flashbox">${escape(text.wire.row)}</b>` +
              `<span class="gz-rh__beat">${escape(count === 0 ? text.wire.clear : plural(text.wire.toAssign, count, tag))}</span>${folded}</span>`
          // Wide headers give both suggestions the copy and where columns together, as one area.
          if (density === 'wide') {
            if (second !== undefined) second.html = ''
            if (third !== undefined) third.html = ''
            row.areas = [
              {
                left: GRID.staffColumn,
                right: 0,
                top: 0,
                bottom: 0,
                cssClass: 'gz-suggestions',
                html: reporter + photographer,
              },
            ]
          } else if (second !== undefined) second.html = reporter
          return
        }
        const deskId = deskOfRow(id)
        if (deskId !== null) {
          row.cssClass = `ss-rh--group gz-row gz-row--desk gz-desk--${deskId}`
          // Parent rows span the columns: their header is the row's own html.
          row.html = deskHtml(deskId, deskMeta.get(deskId) ?? '', text)
          if (first !== undefined) first.html = row.html
          return
        }
        if (!isStaffId(id)) return
        const staff = staffById(id)
        if (staff === null) return
        row.cssClass = `gz-row gz-row--${staff.role}`
        if (first !== undefined) first.html = staffHtml(staff, text)
        if (second !== undefined)
          second.html = gaugeHtml(staff, staffFigure(staff, items, NOW_TICKS), text, tag)
        if (third !== undefined) third.html = whereHtml(whereAt(staff, items, NOW_TICKS), text)
      },

      onBeforeCellRender: (args) => {
        const cell = args.cell
        const from = cell.start.getTime()
        const to = cell.end.getTime()
        if (cell.resource === SUMMARY_ID) {
          const painted = paintFlow(from, to, args.control.cellWidth ?? GRID.cellWidth, text, tag)
          cell.properties.html = painted.html
          cell.properties.cssClass = painted.css
          return
        }
        const key = `${String(cell.resource)}|${from}`
        let painted = cellCache.get(key)
        if (painted === undefined) {
          const past = to <= NOW_TICKS ? ' gz-cell--past' : ''
          if (cell.resource === WIRE_ROW) {
            const flash = wireFlashArea(from, to, text, tag)
            painted = {
              css: `gz-cell gz-cell--wire${past}`,
              areas: flash === null ? [] : [flash],
              disabled: false,
            }
          } else if (deskOfRow(cell.resource) !== null) {
            painted = { css: `gz-cell gz-cell--desk${past}`, areas: [], disabled: false }
          } else {
            const staff = staffById(String(cell.resource))
            if (staff === null) painted = { css: 'gz-cell', areas: [], disabled: false }
            else if (offShift(from, to, shiftOf(staff)))
              // Off-shift hours are disabled cells: the library refuses drops on them too.
              painted = { css: `gz-cell gz-cell--off${past}`, areas: [], disabled: true }
            else
              painted = {
                css: `gz-cell${past}`,
                areas: travelAreas(bands.get(staff.id) ?? [], from, to, text),
                disabled: false,
              }
          }
          cellCache.set(key, painted)
        }
        cell.properties.cssClass = painted.css
        if (painted.areas.length > 0) cell.properties.areas = painted.areas
        if (painted.disabled) cell.properties.disabled = true
      },

      onBeforeEventRender: (args) => {
        const data = args.data
        const id = String(data.id)
        const item = byId.get(id)
        if (item === undefined) return
        const stage = stageOf(item, items, NOW_TICKS)
        const isFlagged = flagged.has(id)
        const isSelected = id === selected
        const started = parseCivil(item.start) < NOW_TICKS
        const key = `${id}|${item.lane}|${item.start}|${item.end}|${stage}|${isFlagged ? 1 : 0}|${isSelected ? 1 : 0}|${mode}`
        let cached = cardCache.get(key)
        if (cached === undefined) {
          const html = cardHtml(
            item,
            items,
            { stage, flagged: isFlagged, selected: isSelected, mode },
            text,
            tag,
          )
          const areas: SuperScheduler.AreaData[] = []
          // The words on the desk by the clock, as a hairline along the clipping's foot.
          if (item.kind === 'story' && item.lane !== WIRE_ROW && item.words !== undefined) {
            const share = wordsFiled(item, NOW_TICKS) / item.words
            areas.push({
              left: 0,
              bottom: 0,
              height: 3,
              width: percent(share),
              nonFloating: true,
              cssClass: 'gz-wordbar',
              toolTip: format(text.fields.wordsValue, {
                done: new Intl.NumberFormat(tag).format(wordsFiled(item, NOW_TICKS)),
                total: new Intl.NumberFormat(tag).format(item.words),
              }),
            })
          }
          // One hover button: hand the wire copy over, or take the earliest slot that mends the chain.
          const button =
            item.lane === WIRE_ROW
              ? assignLabel(item, items, text, tag)
              : isFlagged &&
                  !started &&
                  (item.kind === 'sub' || item.kind === 'layout' || item.kind === 'press')
                ? earliestLabel(item, items, text, tag)
                : null
          if (button?.enabled === true)
            areas.push({
              right: 4,
              top: 'calc(50% - 12px)',
              width: 24,
              height: 24,
              visibility: 'Hover',
              action: 'None',
              cssClass: 'gz-act',
              html: item.lane === WIRE_ROW ? GLYPH.assign : GLYPH.earliest,
              toolTip: button.label,
              onClick: (click) => {
                click.preventDefault()
                action.current(item.lane === WIRE_ROW ? 'assign' : 'earliest', id)
              },
            })
          cached = { html, areas }
          cardCache.set(key, cached)
        }
        data.html = cached.html
        data.areas = cached.areas
        const classes = ['gz-ev', `gz-ev--${item.kind}`]
        if (item.breaking === true) classes.push('gz-breaking')
        if (item.lane === WIRE_ROW) classes.push('gz-onwire')
        if (isFlagged) classes.push('gz-flagged')
        if (isSelected) classes.push('gz-selected')
        if (started) classes.push('gz-started')
        if (parseCivil(item.end) <= NOW_TICKS) classes.push('gz-done')
        if (traced !== null && id === traced) classes.push('gz-pinned')
        data.cssClass = classes.join(' ')
        // Work under way is not moved by the desk.
        if (started) {
          data.moveDisabled = true
          data.resizeDisabled = true
        }
        const flag = isFlagged ? `, ${text.stamps.hold}` : ''
        data.ariaLabel = `${String(data.text)}, ${text.kinds[item.kind]}, ${text.stages[stage]}${flag}`
      },

      // Live search and the status chips: the clippings whose labels hold every word of the query.
      onEventFilter: (args) => {
        const filter = args.filterParam
        if (!(filter instanceof ClippingFilter)) return
        const item = byId.get(String(args.e.id()))
        if (item === undefined) return
        args.visible =
          matchesSearch(searchLabels(item, items, text), filter.query) &&
          matchesStatus(item, filter.status, flagged, NOW_TICKS)
      },

      // The desk filter: one desk and its staff; the wire and the copy flow always stay.
      onRowFilter: (args) => {
        const filter = args.filterParam
        if (!(filter instanceof DeskFilter)) return
        const id = args.row.id
        if (id === SUMMARY_ID || id === WIRE_ROW) return
        const rowDesk = deskOfRow(id) ?? (isLaneId(id) ? deskOf(id) : null)
        args.visible = rowDesk === filter.desk
      },

      // The desk's rules, at the quarter hour the drop would land on.
      onEventMoving: (args) => {
        const id = String(args.e.id())
        const item = byId.get(id)
        const fields = readItemFields(args.e.data)
        if (item === undefined || fields === null) return
        const length = parseCivil(item.end) - parseCivil(item.start)
        let start = args.start.getTime()
        // Clippings shorter than a cell are dragged by their cell: keep the instant that was grabbed.
        if (length < HOUR) {
          let offset = grabs.get(args)
          if (offset === undefined) {
            offset = start - parseCivil(item.start)
            grabs.set(args, offset)
          }
          start -= offset
        }
        const snapped = Math.round(start / SNAP) * SNAP
        if (snapped !== args.start.getTime()) {
          args.start = args.start.addMilliseconds(snapped - args.start.getTime())
          args.end = args.start.addMilliseconds(length)
        }
        const lane = String(args.resource)
        const reason = isLaneId(lane)
          ? refusalOf(item, lane, snapped, snapped + length, items, NOW_TICKS)
          : null
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          dropRefusal.track(id, message)
          return
        }
        dropRefusal.track(id, overlapText(args.conflicts))
      },

      onEventResizing: (args) => {
        const id = String(args.e.id())
        const item = byId.get(id)
        if (item === undefined) return
        const min = MIN_MINUTES[item.kind] * MINUTE
        if (args.what === 'end') {
          const end = Math.max(
            args.start.getTime() + min,
            Math.round(args.end.getTime() / SNAP) * SNAP,
          )
          args.end = args.end.addMilliseconds(end - args.end.getTime())
        } else {
          const start = Math.min(
            args.end.getTime() - min,
            Math.round(args.start.getTime() / SNAP) * SNAP,
          )
          args.start = args.start.addMilliseconds(start - args.start.getTime())
        }
        const reason = refusalOf(
          item,
          item.lane,
          args.start.getTime(),
          args.end.getTime(),
          items,
          NOW_TICKS,
        )
        if (reason !== null) {
          const message = refusalMessage(reason, text, tag)
          args.allowed = false
          args.message = message
          dropRefusal.track(id, message)
          return
        }
        dropRefusal.track(id, overlapText(args.conflicts))
      },

      // Final checks before the commit (keyboard moves arrive here without a drag).
      onEventMove: (args) => {
        const id = String(args.e.id())
        const item = byId.get(id)
        const lane = String(args.newResource)
        if (item === undefined || !isLaneId(lane)) return
        const reason = refusalOf(
          item,
          lane,
          args.newStart.getTime(),
          args.newEnd.getTime(),
          items,
          NOW_TICKS,
        )
        if (reason === null) return
        args.preventDefault()
        dropRefusal.clear()
        onRefused(id, refusalMessage(reason, text, tag))
      },
      onEventResize: (args) => {
        const id = String(args.e.id())
        const item = byId.get(id)
        if (item === undefined) return
        const reason = refusalOf(
          item,
          item.lane,
          args.newStart.getTime(),
          args.newEnd.getTime(),
          items,
          NOW_TICKS,
        )
        if (reason === null) return
        args.preventDefault()
        dropRefusal.clear()
        onRefused(id, refusalMessage(reason, text, tag))
      },

      onEventMoved: (args) => {
        dropRefusal.clear()
        onMoved(String(args.e.id()))
      },
      onEventResized: (args) => {
        dropRefusal.clear()
        onResized(String(args.e.id()), args.what)
      },
      // A click (or Enter) docks the clipping into the side sheet; the hover card steps aside.
      onEventClick: (args) => {
        closeHover()
        onSelect(String(args.e.id()))
      },

      // Hovering a clipping traces its chain: the rest of the board steps back.
      onEventMouseEnter: (args) => {
        const item = byId.get(String(args.e.id()))
        if (item !== undefined) trace(boardRef.current, chainOf(item, items), links)
      },
      onEventMouseLeave: () => {
        const pinned = traced === null ? undefined : byId.get(traced)
        trace(boardRef.current, pinned === undefined ? null : chainOf(pinned, items), links)
      },

      onZoom: (args) => {
        if (args.phase !== 'end') return
        const next = ZOOM_IDS[args.level]
        if (next === undefined) return
        onZoomLevel(next)
      },
    }
  }, [
    boardRef,
    closeHover,
    columns,
    common,
    controlRef,
    density,
    dropRefusal,
    history,
    initialZoom,
    items,
    issues,
    links,
    mode,
    onEventsChange,
    onMoved,
    onRefused,
    onResized,
    onSelect,
    onZoomLevel,
    schedulerLocale,
    selected,
    separators,
    tag,
    text,
    traced,
  ])

  // Imperative and once: a scroll prop would be re-applied on every update.
  useEffect(() => {
    controlRef.current?.scrollTo(INITIAL_VIEW_START, false, 'left')
  }, [controlRef])

  // The live search and the status chips go through the library's event filtering.
  useEffect(() => {
    const query = search.trim()
    controlRef.current?.events.filter(
      query === '' && status === 'all' ? null : new ClippingFilter(query, status),
    )
  }, [controlRef, search, status])

  // The desk chips go through the library's row filtering.
  useEffect(() => {
    controlRef.current?.rows.filter(desk === null ? null : new DeskFilter(desk))
  }, [controlRef, desk])

  // A pinned chain stays forward until it is released.
  useEffect(() => {
    const pinned = traced === null ? undefined : items.find((item) => item.id === traced)
    trace(boardRef.current, pinned === undefined ? null : chainOf(pinned, items), links)
  }, [boardRef, traced, items, links])

  return (
    <SuperSchedulerComponent
      {...config}
      resources={resources}
      events={events}
      links={links}
      eventHover={eventHover}
      dragCard={dragCard}
      contextMenu={contextMenu}
    />
  )
}

/** A suggestion in the wire tray's header: what for, then who (or nobody, in red). */
function suggestHtml(label: string, name: string | null, text: GazetteText): string {
  const escape = SuperScheduler.Util.escapeHtml
  return (
    `<span class="gz-suggest"${name === null ? ' data-none=""' : ''}><small>${escape(label)}</small>` +
    `<b>${escape(name ?? text.wire.nobody)}</b></span>`
  )
}

/** The grabbed instant of each running drag (its args object lives as long as the gesture). */
const grabs = new WeakMap<object, number>()

L’archive contient les fichiers de cet exemple et un README. Elle n’inclut ni SuperScheduler Pro ni aucune clé de téléchargement.