refactor(teams)!: Teams is a contract, not a surface — invert the slots
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 35s

Org lead's correction, and it changes what this phase ships.

TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.

Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.

Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.

**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.

Three things about the inverted direction are load-bearing:

  - the name is namespaced under the declaring module and that is enforced, not
    conventional: it is the only thing keeping two modules off one name;
  - core's fills are applied at MOUNT rather than eagerly. Core's bundle
    evaluates before every module chunk, so when core registers a fill the slot
    does not exist yet — filling eagerly would silently do nothing;
  - a fill for a slot nobody declared is a no-op, never an error. The declaring
    module is simply not installed, which is the ordinary case. That is the
    opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
    there, core declares first, so an unknown name is always a typo.

`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.

`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:58:07 -05:00
parent 203ce9c654
commit 5d9d10b245
25 changed files with 585 additions and 854 deletions

View File

@@ -0,0 +1,100 @@
// What core's Team activity feed SAYS, separated from how it renders
// (docs/website/TEAMS.md §4.3).
//
// Core renders this feed into a slot a MODULE declares on its own page, because
// Teams is a contract primitive and not a surface: core owns the feed, its
// visibility rules and its wording; the module owns the page and the vocabulary
// around it. So this file is deliberately narrow — the roster and index
// presentation that once lived here went with the core Team pages, to whichever
// module renders them.
//
// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same
// reason it was there: a feed that is filtered, or a projection that is stale,
// has to say so in words, and getting that wording right is logic rather than
// markup.
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 surface 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}.` }
}
/**
* 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 entries exist which they cannot have.
*
* The wording avoids core's own noun. The reader is looking at a page the module
* titled — a guild, a clan — and "this Team" would be core's vocabulary leaking
* onto a surface that deliberately does not use it.
*/
export function activityScopeNote(feed = {}, signedIn = false) {
if (feed.scope !== 'public') return null
return signedIn
? 'Some entries are visible to members only.'
: 'Sign in as a member to see the members-only entries.'
}

View File

@@ -1,151 +0,0 @@
// 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),
)
}