feat(rust): slash commands, the next wipe, and the Admin → Rust servers page (phase 16) #18

Merged
whitlocktech merged 2 commits from feat/phase-16-commands into edge 2026-09-25 19:18:33 +00:00
8 changed files with 450 additions and 11 deletions
Showing only changes of commit cddba957d3 - Show all commits

View File

@@ -43,11 +43,13 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
| Pages | `/admin/rust/servers` — add, edit, test and remove servers, and set each one's wipe schedule |
| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
@@ -73,8 +75,18 @@ at most 100 clans per server, and a server at that ceiling answers core partiall
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
one module**: core's installer refuses a second.
The rest of the module — notifications,
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it
**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced
wipe only, weekly or every other week, each in the server's own time zone and always including the
forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
**The Discord commands answer from the tables, never from a game server**, inside core's three-second
budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
the same for a clan roster. Everything else is posted where it was asked.
The rest of the module arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one.
@@ -181,8 +193,8 @@ directory; a symlink answers no and the module is skipped in complete silence.
Either way, the module appears when the process restarts: the volume is read at require time.
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the
sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
stored encrypted through core's own secret box and never returned to any client; the panel reports
only whether one is set.

View File

@@ -25,9 +25,10 @@ import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
import ServerSettings from './routes/admin/ServerSettings.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx'
import { IconEye, IconKey, IconLink, IconServer, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -104,6 +105,10 @@ registry.registerRoutes(ID, {
// nothing names who is online by default; this is where an operator widens
// it on purpose, fleet-wide or per server.
{ path: 'visibility', element: <Visibility /> },
// The servers themselves (phase 16, D133): the page that was missing. Until
// it, a server row was written only through the API, and D130's wipe
// schedule needed somewhere to be typed.
{ path: 'servers', element: <ServerSettings /> },
],
})
@@ -153,6 +158,7 @@ registry.registerNav(ID, {
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
],
})

View File

@@ -95,4 +95,19 @@ export const IconEye = () => (
</Icon>
)
export default { IconLink, IconKey, IconSliders, IconEye }
/**
* Two stacked units — the admin sidebar's row for the servers themselves.
*
* The one row that is about the machines rather than what happens on them: the
* sidecar each one answers through, and when each one wipes.
*/
export const IconServer = () => (
<Icon>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<path d="M7 7.5h.01" />
<path d="M7 16.5h.01" />
</Icon>
)
export default { IconLink, IconKey, IconSliders, IconEye, IconServer }

View File

@@ -125,6 +125,29 @@ export function shortId(steamId) {
return id.length > 10 ? `…${id.slice(-6)}` : id
}
/**
* The next wipe (phase 16, D130), as `Thu, Oct 1, 7:00 PM (in 6 days)` in the
* VIEWER's locale and clock — or `null` when the server has no schedule.
*
* The server computes the instant from the operator's rule in the operator's
* zone; the page only says it the reader's way. A `once` source is an operator
* moving a wipe, and says so, because "the wipe is not when it usually is" is
* the thing a regular needs to notice.
*/
export function nextWipe(value, now = Date.now()) {
if (!value || !value.at) return null
const at = toMillis(value.at)
if (at === null) return null
const when = new Date(at).toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
return `${when} (${ago(at, now)})${value.source === 'once' ? ' — rescheduled' : ''}`
}
function toMillis(value) {
if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') return Number.isFinite(value) ? value : null
@@ -133,4 +156,4 @@ function toMillis(value) {
return Number.isNaN(parsed) ? null : parsed
}
export default { ago, clock, day, duration, count, prefab, shortId }
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }

View File

@@ -0,0 +1,366 @@
// ── Admin · Rust · Servers (phase 16, D133) ───────────────────────────────
//
// The page the module did not have. Until phase 16 a server row was written only
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
// schedule needed somewhere to be typed, and the org lead chose to build the
// missing page rather than hang the schedule on the visibility page (D133).
//
// One form for adding and editing, because they are one `PUT`. Three rules it
// keeps from the API:
//
// 1. **The token is write-only.** The field is blank on every edit, a blank
// save leaves the stored one alone, and the row says whether one is stored.
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
// an omitted protocol to this build's, so an edit sends the stored value back
// rather than silently re-stamping the row.
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
// shows what was stored; the row shows what it computes to, from the same
// answer the public pages read.
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
{ id: 'forced', label: 'Forced wipe only', hint: 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' },
{ id: 'weekly', label: 'Weekly', hint: 'Every week on the day and time below, and the forced wipe.' },
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
/** The browser's own zone — the likeliest answer for an operator typing a time. */
const localZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
const ZONES = (() => {
try {
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
} catch {
return []
}
})()
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
function toLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function blankForm() {
return {
isNew: true,
id: '',
name: '',
sidecarBaseUrl: '',
sidecarToken: '',
enabled: true,
sortOrder: 0,
protocol: undefined,
wipeRule: 'none',
wipeDay: 4,
wipeTime: '19:00',
wipeTz: localZone(),
wipeAnchor: '',
wipeOnce: '',
}
}
function formFrom(server) {
const s = server.schedule || { rule: 'none' }
return {
isNew: false,
id: server.id,
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl,
sidecarToken: '',
enabled: server.enabled,
sortOrder: server.sortOrder,
protocol: server.protocol,
wipeRule: s.rule || 'none',
wipeDay: s.day == null ? 4 : s.day,
wipeTime: s.time || '19:00',
wipeTz: s.tz || localZone(),
wipeAnchor: s.anchor || '',
wipeOnce: toLocalInput(s.onceAt),
storedOnceAt: s.onceAt || null,
}
}
/** The PUT body. The token only when one was typed; the schedule always. */
function bodyFrom(form) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const onceUnchanged = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt)
const body = {
name: form.name.trim(),
sidecarBaseUrl: form.sidecarBaseUrl.trim(),
enabled: form.enabled,
sortOrder: Number(form.sortOrder) || 0,
wipeRule: form.wipeRule,
wipeDay: weekly ? Number(form.wipeDay) : null,
wipeTime: weekly ? form.wipeTime : null,
wipeTz: weekly ? form.wipeTz.trim() : null,
wipeAnchor: form.wipeRule === 'biweekly' ? form.wipeAnchor : null,
// A past one-off date that was not touched is dropped rather than sent back:
// the server refuses a past date on save (it says nothing about the next
// wipe), and an operator editing the name must not be stopped by it.
wipeOnceAt: !form.wipeOnce || (onceUnchanged && Date.parse(form.storedOnceAt) <= Date.now())
? null
: new Date(form.wipeOnce).toISOString(),
}
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
if (form.protocol !== undefined) body.protocol = form.protocol
return body
}
export default function ServerSettings() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.admin.listServers(), [reloads])
const [form, setForm] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [confirming, setConfirming] = useState(null)
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const set = (key) => (e) => {
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
setForm((f) => ({ ...f, [key]: value }))
}
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.admin.saveServer(form.id.trim(), bodyFrom(form))
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const test = async (server) => {
setNotes((n) => ({ ...n, [server.id]: 'Asking the sidecar…' }))
try {
const r = await api.admin.testServer(server.id)
const sidecar = r.sidecar || {}
const text = r.ok
? `The sidecar answered${sidecar.protocol ? ` (protocol ${sidecar.protocol})` : ''}${sidecar.plugin_connected === false ? ', but the game’s plugin is not connected to it' : ''}.`
: `The sidecar did not answer: ${r.status}.`
setNotes((n) => ({ ...n, [server.id]: text }))
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'The test did not run.' }))
}
}
const remove = async (server) => {
setConfirming(null)
try {
await api.admin.deleteServer(server.id)
setReloads((n) => n + 1)
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not delete.' }))
}
}
return (
<div style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Each Rust server this site follows, the sidecar it answers through, and when it wipes. A server’s sidecar is
installed on its own game host; this page tells the site where to find it.
</p>
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
{servers.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
)}
{servers.map((s) => (
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
</span>
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{s.nextWipe
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
: 'No wipe schedule set.'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
{confirming === s.id ? (
<>
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
</>
) : (
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
)}
</div>
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
</div>
))}
{!form && (
<div style={{ marginTop: 12 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
</div>
)}
</section>
{form && (
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
)}
</div>
)
}
function Field({ label, hint, children }) {
return (
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{label}</span>
{children}
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
</label>
)
}
function ServerForm({ form, set, busy, error, onSave, onCancel }) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const rule = RULES.find((r) => r.id === form.wipeRule) || RULES[0]
const oncePast = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt) && Date.parse(form.storedOnceAt) <= Date.now()
return (
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{form.isNew ? 'Add a server' : `Edit ${form.name}`}
</h2>
{form.isNew && (
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
</Field>
)}
<Field label="Name">
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
</Field>
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
</Field>
<Field
label="Sidecar token"
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
>
<input
type="password"
autoComplete="new-password"
value={form.sidecarToken}
onChange={set('sidecarToken')}
required={form.isNew}
maxLength={512}
style={inputStyle}
/>
</Field>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
</label>
<Field label="Order">
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
</Field>
</div>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
<Field label="Rule" hint={rule.hint}>
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
{weekly && (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Field label="Day">
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</Field>
<Field label="Time">
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
</Field>
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
</Field>
</div>
)}
{form.wipeRule === 'biweekly' && (
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
</Field>
)}
<Field
label="One-off wipe (optional)"
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
>
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
</span>
</Field>
{oncePast && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
That date has passed, so it no longer changes anything. Saving will clear it.
</p>
)}
</fieldset>
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '5px 8px',
fontSize: '0.84rem',
}

View File

@@ -31,7 +31,7 @@ import Online from '../../components/Online.jsx'
import Tabs from '../../components/Tabs.jsx'
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
import Wipes from '../../components/Wipes.jsx'
import { ago, count, day } from '../../lib/format.js'
import { ago, count, day, nextWipe } from '../../lib/format.js'
import api from '../../api.js'
const TABS = [
@@ -188,6 +188,7 @@ function describeWorld(server) {
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.seed ? `seed ${server.seed}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'

View File

@@ -24,7 +24,7 @@
import { Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx'
import { ago, count, day } from '../../lib/format.js'
import { ago, count, day, nextWipe } from '../../lib/format.js'
import api from '../../api.js'
/** The "last reported" line, which has three cases and not one. */
@@ -93,6 +93,9 @@ export default function Servers() {
server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
// Phase 16 (D131): absent from an older module, and null for a
// server with no schedule — either way the part is left out.
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
]
.filter(Boolean)
.join(' · ')}

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js'
import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -94,3 +94,16 @@ test("a feed row from another day carries its date, not just a time", () => {
const earlyToday = Date.parse('2026-09-16T00:20:00')
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
})
test('the next wipe: the reader’s own clock, how far away, and whether it moved', () => {
const now = Date.parse('2026-09-25T12:00:00Z')
const forced = nextWipe({ at: '2026-10-01T18:00:00.000Z', source: 'forced' }, now)
assert.match(forced, /\(in 6 days\)$/)
assert.ok(!forced.includes('rescheduled'))
assert.match(nextWipe({ at: '2026-09-27T17:00:00.000Z', source: 'once' }, now), /\(in 2 days\) — rescheduled$/)
// No schedule is no line, not "unknown": the page leaves the part out.
assert.equal(nextWipe(null, now), null)
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
})