From 8f4aff69463f67087f0a783b1c57f55d99a4b60f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:15:54 -0500 Subject: [PATCH] feat(teams): the public Team pages, the two slots and the nav flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 16 ++ client/src/api/client.js | 23 +++ client/src/components/SiteHeader.jsx | 5 + client/src/lib/teams.js | 151 ++++++++++++++++ client/src/main.jsx | 23 ++- client/src/modules/coreFeatures.js | 56 ++++++ .../src/routes/player/PlayerPortalLayout.jsx | 8 + client/src/routes/player/PlayerTeams.jsx | 67 ++++++++ client/src/routes/public/Team.jsx | 126 ++++++++++++++ client/src/routes/public/TeamRoster.jsx | 120 +++++++++++++ client/src/routes/public/Teams.jsx | 107 ++++++++++++ client/test/teams.test.js | 162 ++++++++++++++++++ 12 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 client/src/lib/teams.js create mode 100644 client/src/modules/coreFeatures.js create mode 100644 client/src/routes/player/PlayerTeams.jsx create mode 100644 client/src/routes/public/Team.jsx create mode 100644 client/src/routes/public/TeamRoster.jsx create mode 100644 client/src/routes/public/Teams.jsx create mode 100644 client/test/teams.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 1750361..6353cc5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,6 +18,9 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' +import Teams from './routes/public/Teams.jsx' +import Team from './routes/public/Team.jsx' +import TeamRoster from './routes/public/TeamRoster.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -57,6 +60,7 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' +import PlayerTeams from './routes/player/PlayerTeams.jsx' export default function App() { return ( @@ -93,6 +97,13 @@ export default function App() { } /> } /> } /> + {/* Teams (TEAMS.md §3.1). Core routes, not module ones: a Team is a + core platform entity that a module merely populates, so these + render on bare core too. `/teams` is declared before `/:slug` + below for the same reason every named route is. */} + } /> + } /> + } /> {/* Installed modules' public pages, namespaced `//…` — the registry prefixes the segment, so a module cannot spell its way out of it (docs/website/MODULE_API.md §3.3). Declared before the @@ -216,6 +227,11 @@ export default function App() { } /> } /> } /> + {/* Core's own page under the /player prefix, unlike the module + pages below it. Open to any authenticated account, not role + 'player': staff are a superset of players and a moderator is in + guilds too — RequirePlayer above already draws that line. */} + } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index 64359c4..bd19f4d 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -133,6 +133,29 @@ export const api = { return req(`/public/wiki${withQs(s)}`) }, wikiCategories: () => req('/public/wiki/categories'), + + // ----- Teams (TEAMS.md §2.11, §3.1) ----- + // + // Public reads, but three of them behave differently for a signed-in caller and + // the session rides along on the cookie the shared `req` already sends: the + // roster may widen with the module's audience projection, and the activity feed + // adds members-only items. None of them REQUIRES a session. + teams: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams${withQs(qs.toString())}`) + }, + team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`), + teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`), + teamActivity: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.limit != null) qs.set('limit', String(opts.limit)) + if (opts.offset != null) qs.set('offset', String(opts.offset)) + return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) + }, + myTeams: () => req('/player/teams'), + myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 84706f0..064536d 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -28,6 +28,11 @@ import { useFeatureGate } from '../modules/features.jsx' export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, + // The first CORE row to carry a `feature` since the shard rows left in slice 3 + // (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates + // on whether this deployment has Teams at all, not on who is looking — Team + // pages are public. Fails open, so an unknown answer shows the link. + { label: 'Teams', to: '/teams', feature: 'teams' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, diff --git a/client/src/lib/teams.js b/client/src/lib/teams.js new file mode 100644 index 0000000..e73a208 --- /dev/null +++ b/client/src/lib/teams.js @@ -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), + ) +} diff --git a/client/src/main.jsx b/client/src/main.jsx index c2c3101..8ab9ff7 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -3,7 +3,8 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' import { publishSharedDependencies } from './modules/shared.js' -import { declareSlot } from './modules/registry.js' +import { declareSlot, registerFeatureProvider } from './modules/registry.js' +import { useCoreFlags } from './modules/coreFeatures.js' import './styles/theme.css' // Publish window.__rg BEFORE rendering and before any module chunk evaluates. @@ -18,8 +19,12 @@ publishSharedDependencies() // and namespace `uo`, so that the seam was exercised by real content from the // day it was built. That prediction paid out exactly as written: the extraction // deleted the registration and the hook it named, and SiteHeader was not touched. -// There is nothing for core to register now — no core nav row carries a -// `feature` — and the filter is a correct no-op until a module supplies one. +// +// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav +// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId` +// belongs to core), and this is the provider that answers it — hiding the rows on +// a deployment that has no Teams at all, and failing open everywhere else. +registerFeatureProvider('core', 'core', useCoreFlags) // ── Extension slots (MODULE_API.md §3.7) ─────────────────────────────────── // @@ -50,6 +55,18 @@ declareSlot('admin.users.detail') // over `onDone`. With the slot unfilled the invitee goes straight to the portal, // which is what core's own code did whenever the flag was off. declareSlot('player.invite.accepted') +// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place: +// `team.overview` is the spot under the counts on a Team page, not "where the +// game puts guild stats", and `team.member.row` is the trailing cell of a roster +// row. Core renders the whole Team experience with both unfilled — the pages are +// core's, and a module adds to them rather than supplying them. +// +// `team.overview` is where a live "online now" strip belongs: core's online count +// is the durable floor refreshed at the reconcile interval (§3.3), and a module +// that already holds a live presence feed can render the current number here +// without core acquiring an SSE stack to do it. +declareSlot('team.overview') +declareSlot('team.member.row') // Core filled the first two itself until slice 3, with the components that were // inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills diff --git a/client/src/modules/coreFeatures.js b/client/src/modules/coreFeatures.js new file mode 100644 index 0000000..c797157 --- /dev/null +++ b/client/src/modules/coreFeatures.js @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' + +// Core's own feature provider (TEAMS.md §3.5, MODULE_API.md §3.3). +// +// Core registered one here until the module cutover, under owner id `core` and +// namespace `uo`, and it left with the shard rows. This brings the seam back with +// content that is genuinely core's: `teams` gates the Teams nav rows, and Teams +// are a core platform entity that a module merely populates. +// +// **What the flag actually answers is "does this deployment have Teams at all".** +// Not "may this viewer see them" — Team pages are public (§0.7) and the server +// gates them. On bare core, with no module supplying a Team provider and no rows +// left behind by one, `/teams` is a permanently empty page and a link to it is +// worse than no link. That is the whole job. +// +// It fails OPEN, like every other answer in this seam: while the request is in +// flight, and on any error, the hook returns `null`, which `buildFeatureGate` +// reads as "we do not know yet" and SHOWS the row. The page itself is the gate. +// The one thing a UI mistake must never do here is hide a surface from someone +// entitled to it — and a Teams link that leads somewhere empty is a far cheaper +// mistake than a Team page nobody can find. + +// `limit=1` because only `enabled` is wanted. The endpoint answers it whatever +// the page size, and asking for the default fifty would pull a roster's worth of +// counts into a nav decision. +const TEAMS_URL = '/api/v1/public/teams?limit=1' + +/** + * The hook core registers. Returns a Set-like of visible flags, or `null` while + * the answer is unknown. + * + * Fetched once per mount rather than subscribed: whether a deployment has Teams + * changes when a module is installed, which is a restart, not a session event. + */ +export function useCoreFlags() { + const [flags, setFlags] = useState(null) + + useEffect(() => { + let active = true + fetch(TEAMS_URL, { credentials: 'same-origin' }) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (!active) return + // A body that does not carry `enabled` is an older server or a shape + // change, and both are "unknown" rather than "no". + if (!body || typeof body.enabled !== 'boolean') return + setFlags(new Set(body.enabled ? ['teams'] : [])) + }) + .catch(() => {}) // stays null: unknown shows the row + return () => { active = false } + }, []) + + return flags +} + +export default useCoreFlags diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index 12e9153..cd1c537 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) { } const IconGear = () => const IconShield = () => +const IconTeams = () => // Exported because Admin -> Navigation edits this list. It stays declared here; // the editor may only relabel, reorder and hide what it finds (§7). No CORE row @@ -46,6 +47,12 @@ const IconShield = () =>