// 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.' }