TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.
These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.
`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.
The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:
- "37 members · 21 linked" — the gap is information (a character with no site
account behind it), and the header says what each number IS rather than
showing both and hoping;
- an empty roster has three unrelated causes — nobody in the Team, a rung
that shows nobody, and a module that could not be asked — and reporting the
last as the first is a statement about the game that happens to be false;
- a stale projection says how old it is rather than presenting itself as
current.
`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.
Co-Authored-By: Claude <noreply@anthropic.com>
163 lines
7.0 KiB
JavaScript
163 lines
7.0 KiB
JavaScript
// What the public Team pages say (docs/website/TEAMS.md §3.2, §3.3, §4.3).
|
|
//
|
|
// lib/teams.js is plain JS precisely so these can be asserted without a DOM. The
|
|
// cases worth protecting are the ones where a wrong sentence is a false statement
|
|
// about the game rather than a cosmetic slip — an empty roster reported as "no
|
|
// members" when the module could not be asked being the clearest.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import {
|
|
LINK_STATE,
|
|
activityScopeNote,
|
|
emptyRosterReason,
|
|
filterTeams,
|
|
freshnessNote,
|
|
groupByDay,
|
|
linkStateOf,
|
|
relativeTime,
|
|
rosterSummary,
|
|
sortTeams,
|
|
} from '../src/lib/teams.js'
|
|
|
|
// ── The roster header ──────────────────────────────────────────────────────
|
|
|
|
test('the header states what each number is, so the gap reads as information', () => {
|
|
assert.equal(rosterSummary({ members: 37, linked: 21 }), '37 members · 21 linked')
|
|
})
|
|
|
|
test('one member is not "1 members"', () => {
|
|
assert.equal(rosterSummary({ members: 1, linked: 1 }), '1 member · 1 linked')
|
|
})
|
|
|
|
test('forum guests appear only once there are some', () => {
|
|
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 0 }), '37 members · 21 linked')
|
|
assert.equal(rosterSummary({ members: 37, linked: 21, guests: 4 }), '37 members · 21 linked · 4 forum guests')
|
|
assert.equal(rosterSummary({ members: 2, linked: 1, guests: 1 }), '2 members · 1 linked · 1 forum guest')
|
|
})
|
|
|
|
test('an absent roster still produces a sentence rather than NaN', () => {
|
|
assert.equal(rosterSummary(), '0 members · 0 linked')
|
|
})
|
|
|
|
test('link state is a value, not an absence', () => {
|
|
assert.equal(linkStateOf({ linked: true }), LINK_STATE.linked)
|
|
assert.equal(linkStateOf({ linked: false }), LINK_STATE.unlinked)
|
|
assert.equal(linkStateOf(undefined), LINK_STATE.unlinked)
|
|
})
|
|
|
|
// ── Freshness ──────────────────────────────────────────────────────────────
|
|
|
|
const NOW = new Date('2026-08-17T12:00:00Z').getTime()
|
|
const ago = (ms) => new Date(NOW - ms).toISOString()
|
|
|
|
test('a deployment with no provider is not stale, it is uninvolved', () => {
|
|
assert.equal(freshnessNote({ configured: false }, NOW), null)
|
|
})
|
|
|
|
test('never synced is a warning, and never reads as a confirmed empty shard', () => {
|
|
const note = freshnessNote({ configured: true, lastSyncAt: null }, NOW)
|
|
assert.equal(note.tone, 'warn')
|
|
assert.match(note.text, /Not yet confirmed/)
|
|
})
|
|
|
|
test('a stale projection says how old it is and that the game may have moved on', () => {
|
|
const note = freshnessNote({ configured: true, lastSyncAt: ago(14 * 60_000), stale: true }, NOW)
|
|
assert.equal(note.tone, 'warn')
|
|
assert.equal(note.text, 'Last confirmed 14 minutes ago — the game may have moved on.')
|
|
})
|
|
|
|
test('a current projection is stated quietly', () => {
|
|
const note = freshnessNote({ configured: true, lastSyncAt: ago(90_000), stale: false }, NOW)
|
|
assert.equal(note.tone, 'idle')
|
|
assert.equal(note.text, 'Last confirmed 1 minute ago.')
|
|
})
|
|
|
|
test('relative time singularises and steps through the units', () => {
|
|
assert.equal(relativeTime(ago(5_000), NOW), 'just now')
|
|
assert.equal(relativeTime(ago(60_000), NOW), '1 minute ago')
|
|
assert.equal(relativeTime(ago(3 * 3_600_000), NOW), '3 hours ago')
|
|
assert.equal(relativeTime(ago(2 * 86_400_000), NOW), '2 days ago')
|
|
assert.equal(relativeTime(null, NOW), null)
|
|
assert.equal(relativeTime('not a date', NOW), null)
|
|
})
|
|
|
|
// ── Why a roster is empty ──────────────────────────────────────────────────
|
|
|
|
test('a populated roster has no explaining to do', () => {
|
|
assert.equal(emptyRosterReason({ members: [{}] }), null)
|
|
})
|
|
|
|
test('a module that could not be asked is never reported as an empty guild', () => {
|
|
// The failure this function exists to prevent: saying something false about
|
|
// the game because core could not reach the module.
|
|
const reason = emptyRosterReason({ members: [], projectionUnavailable: true })
|
|
assert.match(reason, /could not be reached/)
|
|
})
|
|
|
|
test('an unconfirmed projection says so rather than claiming the Team is empty', () => {
|
|
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: null })
|
|
assert.match(reason, /not been confirmed/)
|
|
})
|
|
|
|
test('a genuinely empty, confirmed roster says the plain thing', () => {
|
|
const reason = emptyRosterReason({ members: [], configured: true, lastSyncAt: ago(1000) })
|
|
assert.equal(reason, 'Nobody is in this Team.')
|
|
})
|
|
|
|
// ── The activity feed ──────────────────────────────────────────────────────
|
|
|
|
test('items group into days, newest day first, order kept within a day', () => {
|
|
const days = groupByDay([
|
|
{ id: 3, occurredAt: '2026-08-17T09:00:00' },
|
|
{ id: 2, occurredAt: '2026-08-17T08:00:00' },
|
|
{ id: 1, occurredAt: '2026-08-16T22:00:00' },
|
|
], 'en-US')
|
|
assert.equal(days.length, 2)
|
|
assert.deepEqual(days[0].items.map((i) => i.id), [3, 2])
|
|
assert.deepEqual(days[1].items.map((i) => i.id), [1])
|
|
})
|
|
|
|
test('an unparseable timestamp is skipped rather than making a day called Invalid Date', () => {
|
|
const days = groupByDay([{ id: 1, occurredAt: 'nonsense' }], 'en-US')
|
|
assert.deepEqual(days, [])
|
|
})
|
|
|
|
test('a caller who saw everything is told nothing', () => {
|
|
assert.equal(activityScopeNote({ scope: 'members' }, true), null)
|
|
})
|
|
|
|
test('a filtered feed says so, and invites an anonymous caller to sign in', () => {
|
|
assert.match(activityScopeNote({ scope: 'public' }, false), /Sign in/)
|
|
assert.match(activityScopeNote({ scope: 'public' }, true), /members of this Team only/)
|
|
})
|
|
|
|
// ── The index ──────────────────────────────────────────────────────────────
|
|
|
|
test('teams sort by size then by name', () => {
|
|
const sorted = sortTeams([
|
|
{ name: 'Zephyr', memberCount: 3 },
|
|
{ name: 'Anvil', memberCount: 10 },
|
|
{ name: 'Bell', memberCount: 3 },
|
|
])
|
|
assert.deepEqual(sorted.map((t) => t.name), ['Anvil', 'Bell', 'Zephyr'])
|
|
})
|
|
|
|
test('sorting does not mutate its input', () => {
|
|
const input = [{ name: 'B', memberCount: 1 }, { name: 'A', memberCount: 9 }]
|
|
sortTeams(input)
|
|
assert.equal(input[0].name, 'B')
|
|
})
|
|
|
|
test('search matches the two things a visitor knows a Team by', () => {
|
|
const teams = [{ name: 'The Silver Hand', abbr: 'TSH' }, { name: 'Anvil', abbr: 'ANV' }]
|
|
assert.deepEqual(filterTeams(teams, 'silver').map((t) => t.abbr), ['TSH'])
|
|
assert.deepEqual(filterTeams(teams, 'anv').map((t) => t.abbr), ['ANV'])
|
|
assert.equal(filterTeams(teams, ' ').length, 2)
|
|
assert.equal(filterTeams(teams, 'nothing').length, 0)
|
|
})
|
|
|
|
test('search survives a team with no abbreviation', () => {
|
|
assert.doesNotThrow(() => filterTeams([{ name: 'Anvil', abbr: null }], 'a'))
|
|
})
|