feat(teams): project rosters by audience rung, and add to the Team page
The module's half of TEAMS.md phase 3. `projectRoster` is the optional fourth provider method and the only one core calls on a request path. Core holds the roster and owns its public shape; the question that is this module's is who is allowed to look, because the audience rungs and their configuration live here. The answer is all-or-nothing, which is the honest translation rather than a shortcut: a rung is a property of the FEATURE, and there is no configuration in which some members of a guild are public and others are not. The refusal semantics INVERT here, and the tests say so. For the other three methods a refusal means "change nothing" and an empty array would be destructive. Core fails CLOSED on this one, so the dangerous answer is the opposite — returning every key because the config could not be read would publish a roster an operator gated to staff. Every path that cannot reach a confident answer refuses, including the catch. The anonymous case is answered directly rather than by handing `viewerLevel` a synthetic request. Given one with no `req.user` it falls through to `auth.getUserFromRequest`, which expects real cookies and throws on a fake — and that throw would have become a refusal, so every anonymous visitor would have been served an empty roster on a shard whose guilds are public. Caught by the tests, not by reading. `team.overview` gets a live population reading beside core's stored one. Core's number comes from the last roster sync and is coarse by construction; this is the `presence.online` feed this module already holds. It is explicitly not a per-Team presence figure — the shard publishes a global aggregate and no per-guild breakdown exists on the wire, so claiming one would be inventing a number — and it renders nothing at all when it has nothing true to say. `team.member.row` is left unfilled. The useful thing to put there is a link to the character behind a row, and the props core can supply do not identify one: the member key and the site account id are withheld from every public roster. An empty cell beats a guess. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
55
client/src/components/TeamOverviewStrip.jsx
Normal file
55
client/src/components/TeamOverviewStrip.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
|
||||
// What this module contributes to a core Team page (`team.overview`,
|
||||
// TEAMS.md §3.3, §3.4).
|
||||
//
|
||||
// **Core already renders an online count, and this does not replace it.** Core's
|
||||
// number is written by the Team sync from the roster the module answered, so it
|
||||
// is durable and refreshed at the reconcile interval — coarse by construction.
|
||||
// This one is live: the same `presence.online` feed the site header's widget
|
||||
// already consumes, which this module holds and core does not. Core's is the
|
||||
// floor; this is the current reading, and it says which it is rather than
|
||||
// silently disagreeing with the number three lines above it.
|
||||
//
|
||||
// It is emphatically NOT a per-Team presence feed. The shard publishes a global
|
||||
// online aggregate and no per-guild breakdown exists on the wire, so claiming one
|
||||
// here would be inventing a number. What it can honestly say is how many players
|
||||
// are on the shard right now, next to a roster whose own online marks are as old
|
||||
// as the last sync — which is the context a reader of that roster is missing.
|
||||
//
|
||||
// Renders nothing at all until the feed produces something. An empty slot is the
|
||||
// correct output when there is nothing true to add (§3.7): core's page is
|
||||
// complete without it, and a panel reading "unavailable" would be this module
|
||||
// making core's page worse than it is with no module installed.
|
||||
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function TeamOverviewStrip() {
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 })
|
||||
const snapshot = events[0]
|
||||
|
||||
const total = useMemo(() => {
|
||||
const n = Number(snapshot?.count)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}, [snapshot])
|
||||
|
||||
// No feed yet, a disabled integration, or a shard that is down. All three are
|
||||
// "nothing to add", and none of them is worth a box saying so.
|
||||
if (total == null) return null
|
||||
|
||||
return (
|
||||
<p
|
||||
className="sans dim"
|
||||
style={{ margin: '0 0 8px', fontSize: '0.86rem' }}
|
||||
>
|
||||
{total === 0
|
||||
? 'Nobody is on the shard right now.'
|
||||
: `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
|
||||
{' '}
|
||||
<span style={{ opacity: 0.7 }}>
|
||||
The per-member marks above are as recent as the last roster sync.
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||
import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
@@ -180,6 +181,18 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
// The fourth, and the first that was never core's: `team.overview` is new in
|
||||
// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This
|
||||
// adds a live reading beside core's stored one, and renders nothing when it has
|
||||
// nothing true to say.
|
||||
//
|
||||
// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its
|
||||
// useful contents would be a link to the character behind a roster row, and the
|
||||
// props core can supply do not identify one: the member key and the site account
|
||||
// id are withheld from every public roster (§3.2), so this module would be
|
||||
// guessing from a display name. Filling it with a guess is worse than an empty
|
||||
// cell.
|
||||
registry.registerExtension(ID, 'team.overview', TeamOverviewStrip)
|
||||
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check here. It is logged because a
|
||||
|
||||
@@ -166,11 +166,15 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
|
||||
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('fills the three extension slots, each with a component', () => {
|
||||
it('fills the four extension slots, each with a component', () => {
|
||||
const { extensions } = registered
|
||||
// `team.member.row` is core-declared and deliberately absent: the props core
|
||||
// can supply do not identify a character, because the member key and the site
|
||||
// account id are withheld from every public roster (TEAMS.md §3.2). An empty
|
||||
// cell beats a guess.
|
||||
assert.deepEqual(
|
||||
[...extensions.keys()].sort(),
|
||||
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
|
||||
['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'],
|
||||
)
|
||||
for (const [slot, { id, Component }] of extensions) {
|
||||
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
|
||||
|
||||
Reference in New Issue
Block a user