feat(teams): the public Team pages, the two slots and the nav flag

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>
This commit is contained in:
2026-08-17 20:15:54 -05:00
parent 03631d7d40
commit 8f4aff6946
12 changed files with 861 additions and 3 deletions

151
client/src/lib/teams.js Normal file
View File

@@ -0,0 +1,151 @@
// What the public Team pages SAY, separated from how they render
// (docs/website/TEAMS.md §3.2, §3.3, §4.3).
//
// Plain JS with tests, following lib/teamAdmin.js. It is worth splitting here for
// the same reason it was there: these pages have to state differences that look
// like bugs unless they are worded deliberately. A roster header reading
// "37 members · 21 linked" is information; the same numbers with no explanation
// is a support ticket. And an empty roster has three unrelated causes — a Team
// with nobody in it, an audience rung that shows nobody, and a module that could
// not be asked — which is logic, not markup.
/** How the roster describes a row's relationship to a site account (§3.2). */
export const LINK_STATE = { linked: 'linked', unlinked: 'unlinked' }
export function linkStateOf(member) {
return member && member.linked ? LINK_STATE.linked : LINK_STATE.unlinked
}
/**
* The roster header line.
*
* The gap between the two numbers is the surfaced divergence Part 2 asks for: it
* must read as information rather than as a discrepancy, which is why the line
* says what each number IS instead of showing them side by side and hoping.
*
* `guests` is phase 4's forum grants and is omitted while there are none, so the
* line does not carry a permanent zero for a feature that has not shipped.
*/
export function rosterSummary({ members = 0, linked = 0, guests = 0 } = {}) {
const parts = [`${members} ${members === 1 ? 'member' : 'members'}`, `${linked} linked`]
if (guests > 0) parts.push(`${guests} forum ${guests === 1 ? 'guest' : 'guests'}`)
return parts.join(' · ')
}
const MINUTE = 60_000
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */
export function relativeTime(when, now = Date.now()) {
if (!when) return null
const ms = now - new Date(when).getTime()
if (!Number.isFinite(ms)) return null
if (ms < MINUTE) return 'just now'
if (ms < HOUR) {
const n = Math.floor(ms / MINUTE)
return `${n} ${n === 1 ? 'minute' : 'minutes'} ago`
}
if (ms < DAY) {
const n = Math.floor(ms / HOUR)
return `${n} ${n === 1 ? 'hour' : 'hours'} ago`
}
const n = Math.floor(ms / DAY)
return `${n} ${n === 1 ? 'day' : 'days'} ago`
}
/**
* How a public page describes the projection's freshness (§2.4).
*
* Distinct from `teamAdmin.freshnessOf`, which is worded for an operator
* debugging a sync. A visitor needs one sentence about whether what they are
* looking at is current, and specifically must never be shown an unconfirmed
* empty projection as though it were a confirmed empty shard.
*/
export function freshnessNote(sync = {}, now = Date.now()) {
// Nothing supplies Teams here, so there is nothing to be stale ABOUT. A
// deployment with no game module is not a broken one.
if (!sync.configured) return null
if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' }
const ago = relativeTime(sync.lastSyncAt, now)
if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` }
return { tone: 'idle', text: `Last confirmed ${ago}.` }
}
/**
* Why a roster is empty, in the viewer's terms.
*
* Returns null when it is not empty. The three causes are genuinely different and
* reporting the wrong one is the failure this function exists to prevent: telling
* someone a guild has no members when in fact the module could not be asked is a
* statement about the game that happens to be false.
*/
export function emptyRosterReason(roster = {}) {
const members = roster.members || []
if (members.length) return null
if (roster.projectionUnavailable) {
return 'The roster cannot be shown right now — the game module could not be reached.'
}
if (roster.configured && !roster.lastSyncAt) {
return 'This roster has not been confirmed against the game yet.'
}
return 'Nobody is in this Team.'
}
/**
* Group feed items into days, newest first, preserving order within a day (§4.3).
*
* Keyed by local calendar date rather than by a UTC slice: "yesterday" is a
* property of where the reader is sitting, and a shard's evening raid landing at
* 00:30 UTC belongs on the day the players experienced it.
*/
export function groupByDay(items = [], locale = undefined) {
const days = []
const byKey = new Map()
for (const item of items) {
const date = new Date(item.occurredAt)
if (Number.isNaN(date.getTime())) continue
const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
if (!byKey.has(key)) {
const day = {
key,
label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }),
items: [],
}
byKey.set(key, day)
days.push(day)
}
byKey.get(key).items.push(item)
}
return days
}
/**
* What to say under a feed that has been filtered.
*
* Only when there is something to say: a caller who saw everything is told
* nothing, and an anonymous caller is invited to sign in rather than simply
* informed that items exist which they cannot have.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members of this Team only.'
: 'Sign in as a member of this Team to see its members-only entries.'
}
/** Sort for the index: most members first, then alphabetically. */
export function sortTeams(teams = []) {
return [...teams].sort(
(a, b) => (b.memberCount || 0) - (a.memberCount || 0) || String(a.name).localeCompare(String(b.name)),
)
}
/** The index's search, over the two things a visitor knows a Team by. */
export function filterTeams(teams = [], query = '') {
const q = query.trim().toLowerCase()
if (!q) return teams
return teams.filter(
(t) => String(t.name || '').toLowerCase().includes(q) || String(t.abbr || '').toLowerCase().includes(q),
)
}