Phase 4. `/rust` is the server list and the module's landing page (D12);
`/rust/servers/:id` is one server with four tabs — feed, leaderboard, who is
on, wipes (D13). Everything selectable lives in the URL, so any view of the
page is a link. The feed and the presence list poll every twenty seconds while
the tab is visible and not at all when it is not (D14); the leaderboard and the
wipe list load once. `site.footer.status` is filled with a live server and
player count (D15).
Nothing on these pages calls a game server. Every field comes from this
module's own tables, which is what the phase criterion is about: the site
renders the last thing each server said while every server is off.
Walking that criterion in a browser against a live rig found four defects, two
of them already shipped in phase 3:
* An unreachable refresh called `putState` — the whole-row write — with two
fields, so a host that rebooted lost its hostname, map, size, seed and wipe
id. The list then read "Offline" with nothing beside it, which is not "here
is what we know" but "we have never heard of it". `markUnreachable` now
moves three columns and mentions no others.
* "Last reported" read `updated_at`, which a FAILED poll writes too — so an
offline server claimed it had reported just now, every thirty seconds, for
as long as it stayed down. `last_seen_at` is the new column, moved only by a
frame that arrived.
* Feed rows showed a bare time of day, so three events from six weeks ago all
read as this afternoon once the feed was filtered to a past wipe.
* `/rust/servers/typo` rendered core's ErrorState under its own heading and
read "No such server / Something went wrong", sending a reader who mistyped
a URL looking for an outage.
Also: a detail route (`GET …/servers/:id`), because it is the only route under
that path that can say a server does not exist — the other four answer an empty
list for an id nobody configured, and each of those is a good answer to its own
question.
`useAsync` cannot poll: it blanks its data on every dependency change, so a
twenty-second refresh built on it would clear the killfeed and re-fill it four
times a minute. `hooks/usePolled.js` is the module's own, invisible when it
succeeds and keeping the rows when it fails.
The client test fake was *nearly* core — it prefixed routes without stripping
the trailing separator, so the first module to register an index route failed
the nav check for a link that works in a browser. It now copies core's line
character for character.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
// ── Formatting, with no dependencies and no React ─────────────────────────
|
|
//
|
|
// Every function here is pure and takes what the API answered, so the suite next
|
|
// door can ask all of it without a DOM. That is deliberate: the client half's
|
|
// real failures are timing and resolution (see `test/build.test.js`), which a
|
|
// DOM-less runner cannot see — so the way to have any test coverage at all on
|
|
// this side is to keep the parts that CAN be tested free of React.
|
|
//
|
|
// `Intl` does the work. It is in every browser core supports, it knows the
|
|
// viewer's locale and their clock, and it is one fewer thing in a chunk an
|
|
// operator ships.
|
|
|
|
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
|
|
|
const UNITS = [
|
|
['year', 31536000],
|
|
['month', 2592000],
|
|
['week', 604800],
|
|
['day', 86400],
|
|
['hour', 3600],
|
|
['minute', 60],
|
|
['second', 1],
|
|
]
|
|
|
|
/**
|
|
* "3 minutes ago", from an ISO string or an epoch-millisecond number.
|
|
*
|
|
* Both shapes arrive from this module's own API: `updatedAt` is an ISO string
|
|
* the model produced, and an event's `t` is the millisecond stamp the plugin put
|
|
* on the frame. Accepting both here is what stops every caller remembering which
|
|
* is which.
|
|
*/
|
|
export function ago(value, now = Date.now()) {
|
|
const at = toMillis(value)
|
|
if (at === null) return 'never'
|
|
|
|
const seconds = Math.round((at - now) / 1000)
|
|
const magnitude = Math.abs(seconds)
|
|
|
|
// Under a minute, "in 0 seconds" is what `numeric: 'auto'` produces and it is
|
|
// not what anybody means. Say the thing.
|
|
if (magnitude < 45) return 'just now'
|
|
|
|
const [unit, size] = UNITS.find(([, s]) => magnitude >= s) || ['second', 1]
|
|
return RELATIVE.format(Math.round(seconds / size), unit)
|
|
}
|
|
|
|
/**
|
|
* The stamp on a feed row.
|
|
*
|
|
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
|
* be filtered to a past wipe, and a row from six weeks ago rendered as `02:03 PM`
|
|
* reads as this afternoon — which the page walk found the moment it looked at the
|
|
* previous wipe: three events from August, all apparently a few minutes old.
|
|
*
|
|
* `now` is a parameter so the boundary is testable rather than a property of the
|
|
* machine the test runs on.
|
|
*/
|
|
export function clock(value, now = Date.now()) {
|
|
const at = toMillis(value)
|
|
if (at === null) return ''
|
|
|
|
const when = new Date(at)
|
|
const time = when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
|
|
const today = new Date(now)
|
|
const sameDay =
|
|
when.getFullYear() === today.getFullYear() &&
|
|
when.getMonth() === today.getMonth() &&
|
|
when.getDate() === today.getDate()
|
|
|
|
if (sameDay) return time
|
|
return `${when.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`
|
|
}
|
|
|
|
/** A date, for a wipe: the thing people actually compare wipes by. */
|
|
export function day(value) {
|
|
const at = toMillis(value)
|
|
if (at === null) return 'unknown'
|
|
return new Date(at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
/**
|
|
* A session or a playtime, as `4h 12m`.
|
|
*
|
|
* Seconds are dropped above a minute and kept below it, because a two-hour
|
|
* session reported to the second is noise and a forty-second one reported as
|
|
* "0m" is wrong.
|
|
*/
|
|
export function duration(seconds) {
|
|
const total = Number(seconds)
|
|
if (!Number.isFinite(total) || total <= 0) return '—'
|
|
if (total < 60) return `${Math.round(total)}s`
|
|
|
|
const hours = Math.floor(total / 3600)
|
|
const minutes = Math.round((total % 3600) / 60)
|
|
|
|
if (hours === 0) return `${minutes}m`
|
|
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`
|
|
}
|
|
|
|
/** Thousands separators, in the viewer's locale. */
|
|
export function count(value) {
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? n.toLocaleString() : '0'
|
|
}
|
|
|
|
/**
|
|
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
|
* `rifle.ak` becomes `rifle ak`.
|
|
*
|
|
* Deliberately a light touch rather than a lookup table. A table mapping every
|
|
* Rust prefab to a pretty name is a second copy of the game's item list that
|
|
* goes stale every wipe, and the short name is what a Rust player reads on their
|
|
* own server console anyway.
|
|
*/
|
|
export function prefab(name) {
|
|
if (!name) return ''
|
|
return String(name).replace(/[_.]+/g, ' ').trim()
|
|
}
|
|
|
|
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
|
export function shortId(steamId) {
|
|
const id = String(steamId || '')
|
|
return id.length > 10 ? `…${id.slice(-6)}` : id
|
|
}
|
|
|
|
function toMillis(value) {
|
|
if (value === null || value === undefined || value === '') return null
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
|
|
const parsed = Date.parse(value)
|
|
return Number.isNaN(parsed) ? null : parsed
|
|
}
|
|
|
|
export default { ago, clock, day, duration, count, prefab, shortId }
|