Redaktionsschluss planen
Um 19:18 bricht ein Feuer aus. Setzen Sie Reporter und Fotograf darauf an und bringen Sie Seite 1 bis 21:30 in die Plattenkopie.
- Redaktion
- The Linden Gazette
- Ressourcenbaum
- Zeilenspalten
- Abhängigkeiten
- Live-Suche
- Filter
- Gespeicherte Ansichten
- Summenzeilen
- Minimap
- Theming
- Demo mit synthetischen Daten
Aufgabe
0 von 3 Schritten erledigtMittwoch, 19:20. Der Ticker meldet: Feuer im Lagerhaus Old Mill, Canal Street. Seite 1 geht um 21:30 in die Plattenkopie.
Setzen Sie einen Reporter auf MILL-FIRE an. — Offen
Ziehen Sie den roten Ausschnitt MILL-FIRE aus dem Ticker senkrecht nach unten zu Tomás Reyes (Lokales), dem einzigen um 19:30 freien Reporter, oder vergeben Sie ihn per Rechtsklick. Später verpasst er den Textschluss um 20:30.
Schicken Sie einen Fotografen zur Canal Street. — Offen
Ziehen Sie die Fotos zu MILL-FIRE zu Sam Achterberg (Foto). Noor Haddad wird abgelehnt: Vom Riverside Stadium ist sie nicht vor 19:55 dort.
Redigieren Sie Seite 1, sobald der Text da ist. — Offen
Das rote Redigat MILL-FIRE bei Iris Lund (Schlussredaktion) beginnt, bevor der Text geliefert ist. Ziehen Sie es zu Ben Asante um 20:30 oder klicken Sie es an und nehmen Sie den frühesten Termin: Iris redigiert ab 20:45 die Opernkritik.
Etwa 30 Sekunden. Erledigte Schritte werden abgehakt.
Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Vorschau des ersten BildesLive: die echte Pro-Engine, in Ihrem BrowserEtwa 30 Sekunden. Erledigte Schritte werden abgehakt. Tastatur: Raster fokussieren, dann Pfeiltasten, Eingabe und Alt+Pfeile zum Verschieben.
Die Ausgangslage.
The Linden Gazette ist eine fiktive Regionalzeitung. Ihr Spätdienst plant den Abend gegen die Andruckzeiten: Jeder Artikel braucht einen Reporter im Dienst, eine Schlussredaktion, sobald der Text geliefert ist, einen Layouter, sobald der Text redigiert ist und die Fotos da sind, und eine Rotation, die erst läuft, wenn alle Seiten der Ausgabe abgegeben sind.
Eine Eilmeldung wirft alles um. Ein Plan aus bloßen Balken verbirgt die Kette vom Reporter bis zur Rotation; dieser zeichnet sie, lehnt ab, was die Redaktion nicht leisten kann (ein Fotograf, der es nicht schafft, ein Text, der die Ausgabe verpasst), markiert, was nicht mehr passt, und zeigt Stunde für Stunde, wie viel Text auf dem Desk landet.
Was Sie tun werden
Sie vergeben eine Eilmeldung an den einzigen freien Reporter, schicken den Fotografen, der rechtzeitig am Brand ist, und verschieben das Redigat der Titelseite, damit Seite 1 die Fernausgabe erreicht.
Wo die Bibliothek endet und Ihre Anwendung beginnt.
SuperScheduler liefert
- Ein Ressourcenbaum aus Ressorts und Personal mit drei rowHeaderColumns (wer, eine Textanzeige, wo), und eine fixierte Zeile oben (frozen: 'top'), die onBeforeCellRender mit den pro Stunde gelieferten Wörtern füllt.
- Drei Zeitkopfzeilen: die Datumszeile, Ausgabebänder als Kopfbereiche und eine Stundenzeile im Stil eines Fahrplans, geschrieben von onBeforeTimeHeaderRender; ausgeblendete Zeit (onIncludeTimeCell) beschränkt den Plan auf den Arbeitsabend.
- Ausschnitte als HTML-Karten mit Bereichen (ein Balken der gelieferten Wörter), gesperrte Zellen außerhalb der Dienstzeit, Wege in den Zeilen der Fotografen, Ende-Anfang-Verknüpfungen vom Reporter bis zur Rotation und Trennlinien bei den Ausgabeschlüssen und der Redaktionsuhr.
- Live-Suche und Statusfilter über control.events.filter() und onEventFilter, Ressortfilter über control.rows.filter() und onRowFilter und gespeicherte Ansichten (getViewState, applyViewState) als Rubrikenreiter der Zeitung.
- Die Minimap des Textflusses, das volle Fadenkreuz, Zoomstufen, Tastaturbewegungen, Rückgängig-Verlauf, die Hover-Karte und die Drag-Karte (super-scheduler/react-render) sowie ein Kontextmenü im Look der Zeitung.
Der Code dieses Beispiels entscheidet
- Wer macht was: Artikel gehen an Reporter, Fotos an Fotografen, das Redigieren an die Schlussredaktion, der Umbruch an Layouter; Dienstzeiten, die Uhr sowie Text- und Seitenschluss lehnen eine Ablage in onEventMoving ab und nennen die verpasste Ausgabe.
- Wege: Ein Fotograf muss von einem Termin zum nächsten kommen, gemessen auf einem kleinen Stadtplan.
- Die Kette: Redigieren nach der Lieferung, Umbruch nach Redigat und Fotos, Druck nach der letzten Seite; was sie bricht, wird markiert, nicht abgelehnt.
- Vorschläge: wer für die Tickermeldung frei ist und der früheste Termin, der die Kette hält, als ein einziger Rückgängig-Schritt erfasst.
- Jede Zahl: gelieferte Wörter je Reporter, Seiten im Plan je Ausgabe, der Textfluss pro Stunde gegen die Kapazität des Desks und die Stempel auf den Ausschnitten.
Der Code hinter dieser Demo.
Das sind die Dateien, die diese Seite ausführt, keine vereinfachte Kopie. Sie kompilieren gegen die öffentlichen Exporte von SuperScheduler Pro.
Die Integration: Zoomstufen mit drei Kopfzeilen, ausgeblendete Zeit, rowHeaderColumns, die fixierte Textfluss-Zeile, gesperrte Zellen und Wege, Trennlinien, aus der Kette gebaute Verknüpfungen, Live-Suche und Filter, Hover-Karte, Drag-Urteil und Kontextmenü sowie die Callbacks, in denen die Regeln der Redaktion eine Ablage ablehnen oder auf die Viertelstunde setzen.
/**
* 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>()Das Archiv enthält die Dateien dieses Beispiels und eine README. SuperScheduler Pro und Download-Schlüssel sind nicht enthalten.