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>
101 lines
3.9 KiB
JavaScript
101 lines
3.9 KiB
JavaScript
// 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.'
|
|
}
|