'use client' /** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * Scheduled publication in the document editor. * * The feature is split across three surfaces rather than one block, because * the states differ in how much of the editor's attention they deserve: * * - The **actions** live in the document-actions menu, next to the other * document-level operations, so the status bar's primary Save / Publish * controls keep their weight. * - An **armed** schedule renders as one more metadata cell in the status * bar, alongside Status and Last modified. It is a fact about the * document, presented at the scale of the other facts. * - **Needs re-confirmation**, **overdue** and **failing** schedules * escalate to a non-dismissible Alert below the status bar, carrying * their own actions. The `needs_reconfirm` notice in particular has to * outlive the toast that announced it, which is exactly what an Alert * with `close={false}` does. * * `useScheduledPublication` owns the state and the modal so a single parent * can place the three surfaces independently. */ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from '@byline/i18n/react' import { Alert, Button, CloseIcon, DatePicker, IconButton, Label, Modal, Select, } from '@byline/ui/react' import cx from 'clsx' import styles from './scheduled-publication-control.module.css' import { deriveScheduledPublicationState } from './scheduled-publication-state.js' import { joinWallTime, resolveScheduledPublicationWallTime, wallTimeInZone, } from './scheduled-publication-time.js' import type { ScheduledPublicationCapabilities, ScheduledPublicationState, } from './scheduled-publication-state.js' import type { ScheduledPublicationInstantChoice, ScheduledPublicationWallTime, } from './scheduled-publication-time.js' export type { ScheduledPublicationInfo } from './scheduled-publication-state.js' import type { ScheduledPublicationInfo } from './scheduled-publication-state.js' export interface SchedulePublicationInput { publishAt: string } /** How often the armed → overdue boundary is re-checked while the editor sits open. */ const DUE_POLL_INTERVAL_MS = 30_000 /** Default offset for a fresh schedule — far enough out to be reviewable. */ const DEFAULT_LEAD_MS = 15 * 60_000 function browserTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' } /** * Render a wall time for display without turning it into an instant. * * The calendar day is formatted through a UTC-noon stand-in, which is safe * because only the day's name and number are taken from it; the clock reading * is appended verbatim, since the whole point is to show the reading the editor * chose rather than one a `Date` would have normalized it to. */ function formatWallTime(wall: ScheduledPublicationWallTime, locale: string): string { const [year, month, day] = wall.date.split('-').map(Number) if (year == null || month == null || day == null) { return `${wall.date} ${wall.time}` } if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) { return `${wall.date} ${wall.time}` } const dayLabel = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC', }).format(new Date(Date.UTC(year, month - 1, day, 12))) return `${dayLabel}, ${wall.time}` } function seedScheduleInstant(schedule: ScheduledPublicationInfo | null): Date { if (schedule != null) return new Date(schedule.publishAt) const seed = new Date(Date.now() + DEFAULT_LEAD_MS) seed.setSeconds(0, 0) return seed } export interface UseScheduledPublicationArgs { disabled?: boolean schedule: ScheduledPublicationInfo | null onSchedule?: (input: SchedulePublicationInput) => Promise onConfirm?: () => Promise onCancel?: () => Promise hasUnsavedChanges: boolean onUnsavedChanges: () => void } export interface UseScheduledPublicationReturn { state: ScheduledPublicationState timeZone: string busy: boolean /** True when any surface has something to render for this document. */ isActive: boolean openSchedule: () => void confirm: () => Promise cancel: () => Promise /** The schedule / reschedule modal. Render once, anywhere in the form. */ modal: React.ReactNode } export function useScheduledPublication({ disabled = false, schedule, onSchedule, onConfirm, onCancel, hasUnsavedChanges, onUnsavedChanges, }: UseScheduledPublicationArgs): UseScheduledPublicationReturn { const timeZone = useMemo(browserTimeZone, []) const [now, setNow] = useState(() => Date.now()) const [showSchedule, setShowSchedule] = useState(false) const [busy, setBusy] = useState(false) // Only tick while something is actually scheduled — an editor with no // schedule has no boundary to cross, and a bare form should not re-render // on a timer. useEffect(() => { if (schedule == null) return const timer = setInterval(() => setNow(Date.now()), DUE_POLL_INTERVAL_MS) return () => clearInterval(timer) }, [schedule]) const capabilities: ScheduledPublicationCapabilities = useMemo( () => ({ canSchedule: onSchedule != null, canConfirm: onConfirm != null, canCancel: onCancel != null, }), [onSchedule, onConfirm, onCancel] ) const state = useMemo( () => deriveScheduledPublicationState(schedule, capabilities, now), [schedule, capabilities, now] ) // Scheduling authorizes a specific reviewed version, so an unsaved edit has // to be resolved before any of these operations can name a version. Cancel // is exempt: withdrawing a schedule says nothing about content. const openSchedule = useCallback(() => { if (disabled) return if (hasUnsavedChanges) { onUnsavedChanges() return } setShowSchedule(true) }, [disabled, hasUnsavedChanges, onUnsavedChanges]) const confirm = useCallback(async () => { if (disabled || onConfirm == null) return if (hasUnsavedChanges) { onUnsavedChanges() return } setBusy(true) try { await onConfirm() } catch { // The host reports the failure; keep this view open. } finally { setBusy(false) } }, [disabled, onConfirm, hasUnsavedChanges, onUnsavedChanges]) const cancel = useCallback(async () => { if (disabled || onCancel == null) return setBusy(true) try { await onCancel() } catch { // The host reports the failure; keep this view open. } finally { setBusy(false) } }, [disabled, onCancel]) const modal = showSchedule ? ( { if (disabled || onSchedule == null) return setBusy(true) try { await onSchedule(input) setShowSchedule(false) } catch { // The host reports the failure; keep this view open. } finally { setBusy(false) } }} onDismiss={() => setShowSchedule(false)} busy={disabled || busy} /> ) : null return { state, timeZone, busy, isActive: state.kind !== 'none' || state.actions.schedule, openSchedule, confirm, cancel, modal, } } // --------------------------------------------------------------------------- // Status-bar cell — the quiet, armed presentation // --------------------------------------------------------------------------- function useInstantFormatter(timeZone: string) { const { locale } = useTranslation('byline-admin') return useMemo( () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone, }), [locale, timeZone] ) } /** * One metadata cell for the form's status bar, matching the Status / * Last modified / Created cells in scale, sitting immediately after the status * cell — an armed schedule says where the document is headed, which continues * what Status says about it rather than belonging with the timestamps. * * Written as a single phrase in the success colour rather than the label/value * pair its neighbours use: it is the one cell reporting something pending * rather than something already true, and it should read that way at a glance. * Only an armed schedule appears here; every other state is carried by the * notice instead, so the two never say the same thing twice. */ export function ScheduledPublicationCell({ state, timeZone, }: { state: ScheduledPublicationState timeZone: string }) { const { t } = useTranslation('byline-admin') const format = useInstantFormatter(timeZone) if (state.kind !== 'armed' || state.publishAt == null) return null return ( ) } // --------------------------------------------------------------------------- // Notice — the escalated presentation for every exceptional state // --------------------------------------------------------------------------- /** * The durable notice for a suspended, overdue or failing schedule. Not * dismissible: `needs_reconfirm` has to stay on screen until an editor acts on * it, long after the toast that announced the suspension has gone. */ export function ScheduledPublicationNotice({ state, timeZone, busy, onConfirm, onReschedule, onCancel, }: { state: ScheduledPublicationState timeZone: string busy: boolean onConfirm: () => void onReschedule: () => void onCancel: () => void }) { const { t } = useTranslation('byline-admin') const format = useInstantFormatter(timeZone) if (!state.isExceptional || state.publishAt == null) return null const instant = `${format.format(state.publishAt)} (${timeZone})` const title = state.kind === 'needs_reconfirm' ? t('scheduledPublication.status.needsReconfirm') : t('scheduledPublication.status.overdue') return ( // A landmark rather than a live region: the toast already announces the // change assertively when it happens, and announcing the same thing twice // helps nobody. What the notice needs is to stay *findable* afterwards, // which a named region gives a screen-reader user.

{state.kind === 'needs_reconfirm' ? t('scheduledPublication.status.contentChanged') : t('scheduledPublication.status.overdueBody')}

{t('scheduledPublication.status.authorizedFor', { dateTime: instant })} {state.kind === 'needs_reconfirm' && state.isPastDue && ( <> {t('scheduledPublication.status.pastDueNote')} )}

{state.attemptCount > 0 && (

{t('scheduledPublication.status.attempts', { count: state.attemptCount })}

)} {state.lastError != null &&

{state.lastError}

}
{state.actions.confirm && ( )} {state.actions.reschedule && ( )} {state.actions.cancel && ( )}
) } // --------------------------------------------------------------------------- // Schedule / reschedule modal // --------------------------------------------------------------------------- /** * The picker's wall time is read, not its `Date`. * * `DatePicker` reports both: `onDateChange` gives an instant, and * `onWallTimeChange` gives the day and clock reading the editor actually * selected. Only the second one can express "02:30 on a day when 02:30 does not * exist" — the instant has already been normalized to 03:30 by then, and an * ambiguous 01:30 has already been resolved to the earlier of its two instants * without asking. So the wall time goes to * `resolveScheduledPublicationWallTime`, which is the only thing here allowed * to turn a wall time into an instant, and which refuses or asks as required. */ function ScheduleModal({ schedule, timeZone, onSubmit, onDismiss, busy, }: { schedule: ScheduledPublicationInfo | null timeZone: string onSubmit: (input: SchedulePublicationInput) => Promise onDismiss: () => void busy: boolean }) { const { t, locale } = useTranslation('byline-admin') // The instant the picker opens on. Always a real instant — either the one // already authorized, or a lead time from now — so seeding it never has to // construct a `Date` from a wall time. const [seedInstant] = useState(() => seedScheduleInstant(schedule)) // Held, not recomputed per render, so the calendar's disabled range does not // shift under the editor while the modal is open. const [today] = useState(() => new Date()) const [wall, setWall] = useState(() => wallTimeInZone(seedInstant, timeZone) ) const [instantChoices, setInstantChoices] = useState([]) const [selectedInstant, setSelectedInstant] = useState('') const [validationError, setValidationError] = useState(null) const resetResolution = () => { setInstantChoices([]) setSelectedInstant('') setValidationError(null) } const submit = async () => { const value = joinWallTime(wall) if (value == null) { setValidationError(t('scheduledPublication.form.invalid')) return } const resolution = resolveScheduledPublicationWallTime(value, timeZone) if (resolution.status === 'invalid') { setValidationError(t('scheduledPublication.form.invalid')) return } // Name the wall time in both daylight-saving messages. The picker's own // field cannot be trusted to show it: having normalized the selection, it // displays 03:30 for a 02:30 that does not exist, so a message that just // said "that time" would appear to be rejecting the time on screen. const offending = formatWallTime(wall, locale) if (resolution.status === 'nonexistent') { setValidationError(t('scheduledPublication.form.nonexistent', { wallTime: offending })) return } if ( resolution.choices.length > 1 && !resolution.choices.some((c) => c.iso === selectedInstant) ) { setInstantChoices(resolution.choices) setValidationError(t('scheduledPublication.form.ambiguous', { wallTime: offending })) return } const publishAtIso = selectedInstant || resolution.choices[0]?.iso if (publishAtIso == null) return // Checked on the resolved instant rather than the wall time, because an // ambiguous overlap has two instants an hour apart and only one of them may // still be ahead. // // This is an affordance, not the guarantee. The server compares against // database time and refuses `publish_at_not_future` regardless of what the // browser's clock believes — catching it here just replaces a raw server // error in a danger toast with a message next to the field that caused it. if (Date.parse(publishAtIso) <= Date.now()) { setValidationError(t('scheduledPublication.form.notFuture', { wallTime: offending })) return } await onSubmit({ publishAt: publishAtIso }) } const title = schedule == null ? t('scheduledPublication.form.scheduleTitle') : t('scheduledPublication.form.rescheduleTitle') return ( { if (!busy) onDismiss() }} >

{title}

{ if (value == null) return setWall(value) resetResolution() }} />

{t('scheduledPublication.form.timeZone', { timeZone })}

{instantChoices.length > 1 && (
)} {validationError != null && (

{validationError}

)}

{t('scheduledPublication.form.editWarning')}

) }