feat(team-reports): match the maintainer report site layout (#141384)

* feat(team-reports): match the maintainer report site layout

* fix(team-reports): address review findings on the site layout

* fix(team-reports): ship the crab artwork and resolve assets from the plugin root

* fix(team-reports): drop repeated fallback highlights and empty role breakdowns
This commit is contained in:
Peter Steinberger 2026-09-07 13:01:26 -07:00 • committed by GitHub
parent 13c7d0b0b6
commit b7528507af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2366 additions and 365 deletions

View file

@ -120,13 +120,28 @@ page includes **Open in a new window** with that page's own URL. If the browser
blocks that action too, copy the link into a new tab. Gateway authentication
still applies there.
Pages render on the server and work without JavaScript. They use the Carapace
visual contract, with a dark default and a light palette when your operating
system prefers light mode. The embedded page follows that preference independently
of the Control UI theme. The index shows recent reports and a 28-day activity
trend; people pages show each member's recent daily history. Closed and partial
periods have distinct status badges, and coverage and summary warnings remain
visible alongside the report.
Pages mirror the maintainer report site layout: a banner and activity dateline,
latest-period quick cards, day/week/month history, people timelines, and a
per-person calendar. The generation panel shows scheduler and source health.
Closed and partial periods remain distinct, with coverage and summary warnings
alongside the report.
The theme choice follows you through in-tab navigation via the page fragment
(`#theme=light` or `#theme=dark`). Inside the sandboxed Reports tab, it lasts for
that visit. It persists per browser only where storage is available, such as in
a separate window. Pages choose the theme from the fragment first, then browser
storage when available, then the operating system's light or dark preference,
independently of the Control UI theme.
Relative times and open-day countdowns refresh with a small
inline script that the Content Security Policy allows by nonce. The same script
enables history toggles, member filtering, and the quiet-member switch. Pages
still work without JavaScript: all history rows and members remain available,
and timestamps have server-rendered fallbacks.
The crab artwork and icon are served from the plugin's own `assets` route under
the same authentication and scope checks as report pages. Those assets use a
private one-day cache; report pages and exports remain uncached.
Roster members and other GitHub actors show GitHub avatars from
`avatars.githubusercontent.com`, using the primary GitHub login. Images load
@ -352,11 +367,11 @@ With the default `basePath`, authenticated readers can use:
| `/reports/day/<key>/report.md` | Markdown export; also available for weeks and months. |
| `/reports/day/<key>/data.json` | Structured report; also available for weeks and months. |
| `/reports/people/` | Roster index. |
| `/reports/people/<login>/` | Per-person history and 28-day trend. |
| `/reports/people/<login>/` | Per-person history, calendar, and 30-day trend. |
| `/reports/index.json` | Latest keys and stored-period index. |
| `/reports/status` | Run status, schedule, and source warnings as JSON. |
These routes accept only `GET` and `HEAD` and send `Cache-Control: private,
Report and export routes accept only `GET` and `HEAD` and send `Cache-Control: private,
no-store`. Use the CLI or authenticated Gateway method to generate reports;
reading a report page does not trigger collection.

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View file

@ -1,3 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { parseTeamReportsConfig, resolveTeamReportsConfig } from "./src/config.js";
import { registerTeamReportsGatewayMethods } from "./src/gateway-methods.js";
@ -116,8 +118,12 @@ export default definePluginEntry({
handler: createTeamReportsHttpHandler({
basePath: initial.basePath,
displayTimezone: initial.displayTimezone,
// Source checkouts, the flattened dist bundle, and installed packages all keep assets/ at the plugin root.
assetsDir: path.join(api.rootDir ?? path.dirname(fileURLToPath(import.meta.url)), "assets"),
getStore: () => store,
status: () => requireScheduler().status(),
health: () => requireScheduler().health(),
orgs: () => scheduler?.orgs() ?? initial.github.orgs,
people: () => scheduler?.people() ?? initial.people ?? [],
}),
});

View file

@ -37,7 +37,13 @@
},
"build": {
"openclawVersion": "2026.9.2",
"bundledDist": false
"bundledDist": false,
"staticAssets": [
{
"source": "./assets/crab.avif",
"output": "assets/crab.avif"
}
]
},
"release": {
"publishToClawHub": true,

View file

@ -3,6 +3,7 @@ import fs from "node:fs";
import { createServer, request, type IncomingHttpHeaders, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createTeamReportsHttpHandler } from "./http.js";
import { describePeriod } from "./periods.js";
@ -82,11 +83,13 @@ let store: TeamReportsStore;
let server: Server;
let port: number;
let available = true;
let currentOrgs = ["configured-example"];
const getStore = vi.fn(() => (available ? store : undefined));
beforeEach(() => {
runtimeScopeMock.mockReturnValue({ client: { connect: { scopes: ["operator.read"] } } });
getStore.mockClear();
currentOrgs = ["configured-example"];
});
function fetchPath(
@ -141,8 +144,11 @@ beforeAll(async () => {
const handler = createTeamReportsHttpHandler({
basePath: "/reports",
displayTimezone: "UTC",
assetsDir: fileURLToPath(new URL("../assets", import.meta.url)),
getStore,
status: () => ({ running: false, lastRun: "fixture-run" }),
health: () => ({ running: false, warnings: 1 }),
orgs: () => currentOrgs,
people: () => [
{
github: ["alice", "alice-alias"],
@ -183,6 +189,8 @@ describe("Team Reports HTTP responses", () => {
runtimeScopeMock.mockReturnValue(scopes ? { client: { connect: { scopes } } } : undefined);
for (const url of [
"/reports/",
"/reports/assets/crab.avif",
"/reports/assets/icon.png",
"/reports/status",
"/reports/index.json",
"/reports/latest/",
@ -230,7 +238,7 @@ describe("Team Reports HTTP responses", () => {
},
);
it("serves no-script escaped HTML with nonce-based CSP and safe navigation", async () => {
it("serves escaped HTML with one nonce-authorized script and safe navigation", async () => {
const response = await fetchPath("/reports/day/2026-08-20/", "GET", {
"x-forwarded-proto": "https",
});
@ -244,31 +252,27 @@ describe("Team Reports HTTP responses", () => {
const nonce = typeof csp === "string" ? /style-src 'nonce-([^']+)'/.exec(csp)?.[1] : undefined;
expect(nonce).toBeTruthy();
expect(csp).toBe(
`default-src 'none'; style-src 'nonce-${nonce}'; img-src https://avatars.githubusercontent.com data:; base-uri 'none'; form-action 'none'`,
`default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src 'self' https://avatars.githubusercontent.com data:; base-uri 'none'; form-action 'none'`,
);
expect(response.body).toContain(`<style nonce="${nonce}">`);
expect(response.body).toContain("&lt;script&gt;alert(&quot;report&quot;)&lt;/script&gt;");
expect(response.body).not.toContain("<script>");
expect(response.body.match(/<script\b/g)).toHaveLength(1);
expect(response.body).toContain(`<script nonce="${nonce}">`);
expect(response.body).not.toContain('href="javascript:');
expect(response.body).toContain(
`href="https://127.0.0.1:${port}/reports/day/2026-08-20/" target="_blank" rel="noopener">Open in a new window`,
`href="https://127.0.0.1:${port}/reports/day/2026-08-20/" target="_blank" rel="noopener" data-report-open-window aria-label="Open in a new window"`,
);
expect(response.body).toContain('href="/reports/people/alice/"');
expect(response.body).toContain("Deterministic summary");
expect(response.body).toContain("Fixture coverage warning");
expect(response.body).toContain("Model summary unavailable: completion failed");
expect(response.body).toMatch(
/class="[^"]*oc-banner-warning[^"]*"[^>]*>[\s\S]*?Coverage notes/,
);
expect(response.body).toMatch(
/class="[^"]*oc-banner-info[^"]*"[^>]*>[\s\S]*?<p>Deterministic summary/,
);
expect(response.body).toContain("GitHub coverage is incomplete");
});
it.each([
{ url: "/reports/day/2026-08-20/", login: "alice", size: 40, variant: "md" },
{ url: "/reports/day/2026-08-20/", login: "bob", size: 20, variant: "xs" },
{ url: "/reports/people/", login: "alice", size: 40, variant: "md" },
{ url: "/reports/people/", login: "alice", size: 36, variant: "sm" },
{ url: "/reports/people/alice-alias/", login: "alice", size: 72, variant: "xl" },
])(
"renders a $size px GitHub avatar for $login on $url",
@ -338,16 +342,6 @@ describe("Team Reports HTTP responses", () => {
expect(response.body).not.toContain(hostileDisplay);
});
it.each([
{ key: "2026-08-20", status: "closed", variant: "success" },
{ key: "2026-08-21", status: "partial", variant: "warning" },
])("renders the $status report status as a $variant badge", async ({ key, status, variant }) => {
const response = await fetchPath(`/reports/day/${key}/`);
expect(response.body).toMatch(
new RegExp(`class="oc-badge oc-badge-${variant}">${status}</span>`, "i"),
);
});
it("serves dark and light semantic tokens with CSS-only avatar initials", async () => {
const response = await fetchPath("/reports/");
const styles = /<style nonce="[^"]+">([\s\S]*?)<\/style>/.exec(response.body)?.[1];
@ -369,6 +363,25 @@ describe("Team Reports HTTP responses", () => {
expect(styles).not.toMatch(/@import|@font-face/);
});
it.each([
["crab.avif", "image/avif"],
["icon.png", "image/png"],
])("serves authenticated %s bytes with private caching and HEAD", async (asset, contentType) => {
const get = await fetchPath(`/reports/assets/${asset}`);
const head = await fetchPath(`/reports/assets/${asset}`, "HEAD");
for (const response of [get, head]) {
expect(response.status).toBe(200);
expect(response.headers["content-type"]).toBe(contentType);
expect(response.headers["cache-control"]).toBe("private, max-age=86400");
expect(Number(response.headers["content-length"])).toBe(
fs.statSync(new URL(`../assets/${asset}`, import.meta.url)).size,
);
}
expect(get.body.length).toBeGreaterThan(0);
expect(head.body).toBe("");
expect(head.headers["content-length"]).toBe(get.headers["content-length"]);
});
it("supports HEAD without a body and rejects writes", async () => {
const head = await fetchPath("/reports/day/2026-08-20/", "HEAD");
expect(head.status).toBe(200);
@ -381,6 +394,8 @@ describe("Team Reports HTTP responses", () => {
});
it.each([
"/reports/assets/unknown.png",
"/reports/assets/crab.avif/extra",
"/reports/missing/",
"/reports/day/2026-02-30/",
"/reports/week/2026-W54/",
@ -423,15 +438,15 @@ describe("Team Reports HTTP responses", () => {
it("renders stored trends, history, archived people, index, and status", async () => {
const index = await fetchPath("/reports/");
expect(index.status).toBe(200);
expect(index.body).toMatch(/<svg\b[^>]*viewBox="0 0 780 185"/);
expect(index.body).toContain('aria-label="Activity dateline"');
expect(index.body).toContain('href="/reports/week/2026-W34/"');
const people = await fetchPath("/reports/people/");
expect(people.body).toContain('class="oc-table"');
expect(people.body).toContain("Member Activity Timelines");
expect(people.body).toMatch(/class="oc-badge oc-badge-neutral">Archived<\/span>/);
const person = await fetchPath("/reports/people/alice-alias/");
expect(person.status).toBe(200);
expect(person.body).toContain("Archived on 2026-08-22");
expect(person.body).toContain('href="/reports/day/2026-08-20/"');
expect(person.body).toContain('href="/reports/day/2026-08-20/?person=alice"');
const machineIndex = await fetchPath("/reports/index.json");
expect(JSON.parse(machineIndex.body)).toMatchObject({
latest: { day: "2026-08-21", week: "2026-W34", month: "2026-08" },
@ -440,6 +455,26 @@ describe("Team Reports HTTP responses", () => {
expect(JSON.parse(status.body)).toEqual({ running: false, lastRun: "fixture-run" });
});
it("reads current overview organizations and prefers the displayed report's organizations", async () => {
const emptyStore = createTeamReportsStore({ stateDir: path.join(directory, "empty") });
try {
for (const name of ["first-organization", "new <organization>"]) {
currentOrgs = [name];
getStore.mockReturnValueOnce(emptyStore);
const response = await fetchPath("/reports/");
expect(response.status).toBe(200);
expect(response.body).toContain(
name.replaceAll("<", "&lt;").replaceAll(">", "&gt;") + " · team",
);
}
const stored = await fetchPath("/reports/");
expect(stored.body).toContain("example · team");
expect(stored.body).not.toContain("new &lt;organization&gt; · team");
} finally {
emptyStore.close();
}
});
it("reports unavailable service state without touching a closed store", async () => {
available = false;
try {

View file

@ -1,5 +1,7 @@
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { join } from "node:path";
import { TLSSocket } from "node:tls";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { DAY_MS, describePeriod } from "./periods.js";
@ -11,17 +13,33 @@ import {
type PageContext,
type PeriodIndex,
} from "./render/html.js";
import type { TeamReportsHealth } from "./scheduler.js";
import type { TeamReportsStore } from "./store.js";
import type { Period, Person, PersonReport } from "./types.js";
type TeamReportsHttpOptions = {
basePath: string;
displayTimezone: string;
/** The plugin's shipped `assets` directory; the dist bundle flattens `src/`, so callers resolve it from the plugin root. */
assetsDir: string;
getStore: () => TeamReportsStore | undefined;
status: () => unknown;
health: () => TeamReportsHealth;
orgs: () => string[];
people: () => Person[];
};
const assets = new Map<string, Buffer>();
function readAsset(assetsDir: string, name: "crab.avif" | "icon.png"): Buffer {
let asset = assets.get(name);
if (!asset) {
asset = readFileSync(join(assetsDir, name));
assets.set(name, asset);
}
return asset;
}
const KEY_PATTERNS: Record<Period, RegExp> = {
day: /^\d{4}-\d{2}-\d{2}$/,
week: /^\d{4}-W\d{2}$/,
@ -103,14 +121,14 @@ export function createTeamReportsHttpHandler(options: TeamReportsHttpOptions) {
const send = (
status: number,
contentType: string,
body: string,
body: string | Buffer,
headers: Record<string, string> = {},
) => {
res.writeHead(status, {
"Content-Type": `${contentType}; charset=utf-8`,
"Content-Type": typeof body === "string" ? `${contentType}; charset=utf-8` : contentType,
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "private, no-store",
"Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'; img-src https://avatars.githubusercontent.com data:; base-uri 'none'; form-action 'none'`,
"Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src 'self' https://avatars.githubusercontent.com data:; base-uri 'none'; form-action 'none'`,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
...headers,
@ -135,6 +153,20 @@ export function createTeamReportsHttpHandler(options: TeamReportsHttpOptions) {
if (!route) {
return notFound();
}
const [first, key, format] = route.segments;
if (first === "assets") {
if (route.segments.length !== 2 || (key !== "crab.avif" && key !== "icon.png")) {
return notFound();
}
return send(
200,
key === "crab.avif" ? "image/avif" : "image/png",
readAsset(options.assetsDir, key),
{
"Cache-Control": "private, max-age=86400",
},
);
}
const store = options.getStore();
if (!store) {
return send(
@ -143,13 +175,12 @@ export function createTeamReportsHttpHandler(options: TeamReportsHttpOptions) {
"Team Reports is not running. Start or restart the Gateway service.\n",
);
}
const [first, key, format] = route.segments;
const json = (body: unknown) => send(200, "application/json", JSON.stringify(body));
if (first === "status" && route.segments.length === 1) {
return json(options.status());
}
const index = (): PeriodIndex => ({
day: store.listPeriods({ period: "day", limit: 60 }),
day: store.listPeriods({ period: "day", limit: 400 }),
week: store.listPeriods({ period: "week", limit: 60 }),
month: store.listPeriods({ period: "month", limit: 60 }),
});
@ -187,27 +218,31 @@ export function createTeamReportsHttpHandler(options: TeamReportsHttpOptions) {
if (route.segments.length === 0) {
const periods = index();
const latest = periods.day[0];
const days = latest ? store.getDayReports(latest.sinceMs - 27 * DAY_MS, latest.untilMs) : [];
return html(renderIndexPage(ctx, periods, days));
const stored = latest ? store.getPeriod("day", latest.key) : undefined;
return html(
renderIndexPage(ctx, periods, {
orgs: stored?.report.orgs ?? options.orgs(),
latest: stored,
health: options.health(),
}),
);
}
if (first === "people" && route.segments.length <= 2) {
const latest = store.listPeriods({ period: "day", limit: 1 })[0];
const recent = latest ? (store.getPeriod("day", latest.key)?.report.members ?? []) : [];
const people = visiblePeople(options.people(), recent);
if (!key) {
return html(renderPeoplePage(ctx, people));
const endKey = latest?.key ?? new Date().toISOString().slice(0, 10);
const since = new Date(Date.parse(`${endKey}T00:00:00Z`) - 27 * DAY_MS)
.toISOString()
.slice(0, 10);
return html(renderPeoplePage(ctx, people, store.listPersonDaysSince(since), endKey));
}
const person = people.find((candidate) =>
candidate.github.some((login) => login.toLowerCase() === key.toLowerCase()),
);
const login = person?.github[0] ?? key;
const latestDay = store.listPersonDays(login, { limit: 1 })[0];
const since = latestDay
? new Date(Date.parse(`${latestDay.dayKey}T00:00:00Z`) - 27 * DAY_MS)
.toISOString()
.slice(0, 10)
: undefined;
const days = latestDay ? store.listPersonDays(login, { since, limit: 28 }) : [];
const days = store.listPersonDays(login, { limit: 400 });
if (!person && days.length === 0) {
return notFound();
}
@ -239,7 +274,14 @@ export function createTeamReportsHttpHandler(options: TeamReportsHttpOptions) {
if (format === "report.md") {
return send(200, "text/markdown", stored.markdown);
}
return html(renderReportPage(ctx, stored.report, stored.summary));
return html(
renderReportPage(
ctx,
stored.report,
stored.summary,
store.listPeriods({ period: first, limit: 400 }).filter((entry) => entry.key <= key),
),
);
}
return notFound();
};

View file

@ -0,0 +1,64 @@
import { escapeHtml } from "./shared.js";
export function sparklineSvg(values: number[], label: string, large = false): string {
if (values.length < 2) {
return "";
}
const width = large ? 600 : 120;
const height = large ? 48 : 28;
const minimum = Math.min(...values);
const span = Math.max(...values) - minimum || 1;
const y = (value: number) =>
Number((height - 3 - ((value - minimum) / span) * (height - 6)).toFixed(1));
const path = values
.map(
(value, index) =>
`${index ? `V${y(value)}` : `M0 ${y(value)}`} H${Number((((index + 1) * width) / values.length).toFixed(1))}`,
)
.join(" ");
const endpoint = Math.min(Math.max(y(values.at(-1) ?? 0) - 2, 0), height - 4);
return `<svg class="oc-sparkline"${large ? ' data-size="lg"' : ""} viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(label)}"><path class="oc-sparkline-line" d="${path}"/><rect class="oc-sparkline-endpoint" x="${width - 3}" y="${endpoint}" width="3" height="4"/></svg>`;
}
export function deltaMarkup(
current: number,
previous: number | undefined,
label: string,
badUp = false,
): string {
if (previous === undefined) {
return "";
}
const change = current - previous;
const direction = change > 0 ? "up" : change < 0 ? "down" : "flat";
const arrow = change > 0 ? "&#9650;" : change < 0 ? "&#9660;" : "&#8212;";
const body =
previous === 0
? change === 0
? "no change"
: `+${change}`
: `${change > 0 ? "+" : ""}${Math.round((change / previous) * 100)}%`;
const tone = badUp && change !== 0 ? ` data-tone="${change > 0 ? "negative" : "positive"}"` : "";
return `<span class="oc-delta" data-direction="${direction}"${tone}><span class="oc-delta-arrow" aria-hidden="true">${arrow}</span>${body} vs ${escapeHtml(label)}</span>`;
}
export function splitMarkup(items: { label: string; value: number; tone: string }[]): string {
const shown = items.filter((item) => item.value > 0);
const total = shown.reduce((sum, item) => sum + item.value, 0);
if (!total) {
return "";
}
let x = 0;
const rects = shown
.map((item) => {
const width = (item.value / total) * 100;
const rect = `<rect class="oc-split-segment${item.tone ? ` oc-split-${escapeHtml(item.tone)}` : ""}" x="${x.toFixed(2)}" y="0" width="${width.toFixed(2)}" height="8"/>`;
x += width;
return rect;
})
.join("");
const label = shown
.map((item) => `${item.label} ${Math.round((item.value / total) * 100)}%`)
.join(", ");
return `<svg class="oc-split" viewBox="0 0 100 8" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(`Composition: ${label}`)}">${rects}</svg><ul class="oc-split-legend">${shown.map((item) => `<li><span class="oc-split-key${item.tone ? ` oc-split-${escapeHtml(item.tone)}` : ""}" aria-hidden="true"></span>${escapeHtml(item.label)} ${Math.round((item.value / total) * 100)}%</li>`).join("")}</ul>`;
}

View file

@ -1,214 +1,139 @@
import { DAY_MS } from "../periods.js";
import type { PersonDay, PeriodListEntry } from "../store.js";
import type { Period, Person, ReportDocument, SummaryDocument } from "../types.js";
import type { TeamReportsHealth } from "../scheduler.js";
import type { PeriodListEntry } from "../store.js";
import type { Period, ReportDocument, SummaryDocument } from "../types.js";
import { deltaMarkup, sparklineSvg } from "./charts.js";
import {
countDescription,
escapeHtml,
ITEM_LABELS,
memberSummary,
renderAvatar,
safeExternalUrl,
} from "./shared.js";
import { REPORT_STYLES } from "./styles.js";
banner,
formatWindow,
href,
isOpen,
openPeriodStatus,
periodTitle,
relativeTime,
shell,
sourceBanners,
type PageContext,
} from "./page.js";
import { escapeHtml } from "./shared.js";
export type PageContext = {
basePath: string;
nonce: string;
absoluteUrl: string;
displayTimezone: string;
};
export { renderPeoplePage, renderPersonPage } from "./people.js";
export { renderReportPage } from "./report.js";
export type { PageContext } from "./page.js";
export type PeriodIndex = Record<Period, PeriodListEntry[]>;
type TrendDay = { key: string; github: number; discord: number };
type OverviewOptions = {
orgs: string[];
latest?: { report: ReportDocument; summary: SummaryDocument | null };
health: TeamReportsHealth;
};
const PERIODS: Period[] = ["day", "week", "month"];
const SERIES_LENGTH = { day: 28, week: 12, month: 6 };
const combined = (entry: PeriodListEntry) => entry.githubTotal + entry.discordMessages;
const number = (value: number) => value.toLocaleString("en-US");
function href(basePath: string, ...segments: string[]): string {
return `${basePath}/${segments.map(encodeURIComponent).join("/")}/`;
function completenessBadge(ctx: PageContext, entry: PeriodListEntry): string {
if (entry.status !== "partial") {
return "";
}
if ((ctx.nowMs ?? Date.now()) >= entry.untilMs) {
return '<span class="oc-badge oc-badge-warning partial-report-badge">Incomplete</span>';
}
return entry.period === "day" && isOpen(ctx, entry) && entry.generatedAtMs < entry.untilMs
? '<span class="oc-badge oc-badge-info partial-report-badge">Intraday</span>'
: "";
}
function date(value: number, timezone: string): string {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: timezone,
}).format(value);
function quickTrend(ctx: PageContext, index: PeriodIndex, entry: PeriodListEntry): string {
const ascending = index[entry.period]
.filter((candidate) => candidate.key <= entry.key)
.toSorted((a, b) => a.key.localeCompare(b.key));
const values = ascending.slice(-SERIES_LENGTH[entry.period]).map(combined);
const previous = ascending.at(-2);
let comparison = previous ? combined(previous) : undefined;
let label = `prev ${entry.period}`;
if (
previous &&
entry.period !== "day" &&
isOpen(ctx, entry) &&
entry.generatedAtMs < entry.untilMs
) {
// Compare an aggregate's own snapshot against equally many prior stored days.
const elapsedDays = Math.max(1, Math.ceil((entry.generatedAtMs - entry.sinceMs) / DAY_MS));
const priorDays = index.day
.filter((day) => day.sinceMs >= previous.sinceMs && day.sinceMs < previous.untilMs)
.toSorted((a, b) => a.key.localeCompare(b.key))
.slice(0, elapsedDays);
if (priorDays.length === elapsedDays) {
comparison = priorDays.reduce((sum, day) => sum + combined(day), 0);
label += " to date";
}
}
return `<span class="quick-trend">${deltaMarkup(combined(entry), comparison, label)}${sparklineSvg(values, `Combined activity, last ${values.length} ${entry.period}s`)}</span>`;
}
function externalLink(url: string, label: string): string {
const safe = safeExternalUrl(url);
return safe
? `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
: escapeHtml(label);
function quickCard(ctx: PageContext, index: PeriodIndex, period: Period): string {
const entry = index[period][0];
if (!entry) {
return `<section class="quick-card oc-card"><span class="oc-eyebrow">${period}</span><span class="quick-title">No ${period} reports yet</span><p class="oc-empty">Generate a report to see activity.</p></section>`;
}
const open = isOpen(ctx, entry);
return `<a class="quick-card oc-card oc-card-interactive${entry.status === "partial" ? " partial" : ""}" href="${escapeHtml(href(ctx.basePath, period, entry.key))}"><span class="oc-eyebrow">${open && period === "day" ? "today" : period}</span><span class="quick-title">${escapeHtml(periodTitle(entry))} ${completenessBadge(ctx, entry)}</span><span class="quick-meta">${escapeHtml(formatWindow(entry))}<br>${entry.activeMembers}/${entry.memberCount} active · ${number(entry.githubTotal)} GitHub · ${number(entry.discordMessages)} Discord</span>${openPeriodStatus(ctx, entry)}${quickTrend(ctx, index, entry)}</a>`;
}
function markdownBlocks(value: string): string {
return value
.split(/\n\s*\n/)
.map((block) => {
const lines = block.split("\n");
const inline = (line: string) =>
escapeHtml(line).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
if (lines.every((line) => /^\s*[-*] /.test(line))) {
return `<ul>${lines.map((line) => `<li>${inline(line.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`;
}
return `<p>${lines.map(inline).join("<br>")}</p>`;
function history(ctx: PageContext, entries: PeriodListEntry[], period: Period): string {
const visible = period === "day" ? 7 : 12;
const slots = period === "month" ? 6 : 12;
const ascending = entries.toSorted((a, b) => a.key.localeCompare(b.key));
const rows = entries
.map((entry, index) => {
const position = ascending.findIndex((candidate) => candidate.key === entry.key);
const window = ascending.slice(Math.max(0, position - slots + 1), position + 1);
const values = [...Array<number>(slots - window.length).fill(0), ...window.map(combined)];
const trend =
window.length >= 2 ? sparklineSvg(values, `Combined activity through ${entry.key}`) : "";
const subline =
period === "day"
? new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" }).format(
entry.sinceMs,
)
: formatWindow(entry);
return `<a class="row" href="${escapeHtml(href(ctx.basePath, period, entry.key))}"${index >= visible ? " data-extra hidden" : ""}><span><span class="row-title-line"><span class="title">${escapeHtml(periodTitle(entry))}</span>${completenessBadge(ctx, entry)}</span><br><span class="date">${escapeHtml(subline)}</span></span><span class="row-trend" aria-hidden="true">${trend}</span><span class="stats"><strong>${entry.activeMembers}/${entry.memberCount}</strong> active<br>${number(entry.githubTotal)} GitHub / ${number(entry.discordMessages)} Discord</span></a>`;
})
.join("");
return `<section class="oc-section" id="${period}"><div class="oc-section-header"><div><div class="oc-eyebrow">${period}</div><h2>${period[0]?.toUpperCase()}${period.slice(1)} History</h2></div>${entries.length > visible ? `<button class="toggle oc-action oc-action-ghost js-only" type="button" data-toggle="${period}" aria-expanded="false">Show all ${entries.length}</button>` : ""}</div><div class="list" data-list="${period}">${rows || `<p class="oc-empty">No ${period} reports yet.</p>`}</div></section>`;
}
function shell(ctx: PageContext, title: string, body: string): string {
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)} · Team Reports</title><style nonce="${escapeHtml(ctx.nonce)}">${REPORT_STYLES}</style></head><body class="oc-app-surface"><header class="oc-app-surface report-toolbar"><div class="report-toolbar-inner"><nav aria-label="Main"><a class="brand" href="${escapeHtml(ctx.basePath)}/">Team Reports</a><a class="oc-action oc-action-ghost" href="${escapeHtml(ctx.basePath)}/people/">People</a><a class="oc-action oc-action-ghost" href="${escapeHtml(ctx.basePath)}/latest/">Latest closed day</a></nav><a class="oc-action oc-action-ghost" href="${escapeHtml(ctx.absoluteUrl)}" target="_blank" rel="noopener">Open in a new window</a></div></header><main>${body}</main><footer>Report windows use UTC. Generation times are shown in ${escapeHtml(ctx.displayTimezone)}. Links may not open inside the Control UI frame.</footer></body></html>`;
function homeMetric(label: string, value: string | number): string {
return `<div class="oc-summary-metric"><div class="oc-summary-metric-copy"><small>${escapeHtml(label)}</small><strong>${value}</strong></div></div>`;
}
function renderTrend(days: TrendDay[]): string {
if (days.length === 0) {
return '<p class="muted">No stored daily activity yet.</p>';
}
const maximum = Math.max(1, ...days.flatMap((day) => [day.github, day.discord]));
const timestamps = days.map((day) => Date.parse(`${day.key}T00:00:00Z`));
const startMs = timestamps[0] ?? 0;
const spanMs = Math.max(DAY_MS, (timestamps.at(-1) ?? startMs) - startMs);
const x = (index: number) => 42 + (((timestamps[index] ?? startMs) - startMs) / spanMs) * 696;
const y = (value: number) => 152 - (value / maximum) * 128;
const line = (kind: "github" | "discord") => {
const path = days
.map((day, index) => {
const previous = timestamps[index - 1];
const connected = previous !== undefined && (timestamps[index] ?? 0) - previous === DAY_MS;
return `${connected ? "L" : "M"}${x(index).toFixed(1)},${y(day[kind]).toFixed(1)}`;
})
.join(" ");
return `<path class="oc-sparkline-line ${kind}-line" d="${path}"/>${days.map((day, index) => `<circle class="oc-sparkline-line ${kind}-line" cx="${x(index).toFixed(1)}" cy="${y(day[kind]).toFixed(1)}" r="2"/>`).join("")}`;
};
return `<div class="chart"><svg class="oc-sparkline" viewBox="0 0 780 185" role="img" aria-label="Daily GitHub events and Discord messages; shared scale zero to ${maximum}"><title>GitHub events and Discord messages per stored day</title><line class="chart-axis" x1="42" y1="152" x2="738" y2="152"/><text class="chart-label" x="4" y="28">${maximum}</text><text class="chart-label" x="20" y="156">0</text>${line("github")}${line("discord")}<text class="chart-label" x="42" y="178">${escapeHtml(days[0]?.key ?? "")}</text><text class="chart-label" x="738" y="178" text-anchor="end">${escapeHtml(days.at(-1)?.key ?? "")}</text></svg><div class="legend"><span class="github-key">GitHub events</span><span class="discord-key">Discord messages</span></div></div>`;
}
function statusBadge(status: ReportDocument["status"]): string {
return `<span class="oc-badge oc-badge-${status === "closed" ? "success" : "warning"}">${escapeHtml(status)}</span>`;
}
function pills(person: Pick<Person, "affiliation" | "roleLabel" | "roleGroup">): string {
return [person.affiliation, person.roleLabel ?? person.roleGroup]
.filter((value): value is string => Boolean(value))
.map((value) => `<span class="oc-pill">${escapeHtml(value)}</span>`)
.join("");
}
function banner(tone: "warning" | "info" | "neutral", content: string): string {
return `<div class="oc-banner oc-banner-${tone}"><span class="oc-banner-indicator" aria-hidden="true"></span><div class="oc-banner-content">${content}</div></div>`;
}
function sectionHeading(title: string): string {
return `<div class="oc-section-header"><h2 class="oc-section-title">${escapeHtml(title)}</h2></div>`;
}
function history(ctx: PageContext, entries: PeriodListEntry[]): string {
if (entries.length === 0) {
return '<p class="muted">No reports generated yet.</p>';
}
return `<ul class="oc-resource-list">${entries.map((entry) => `<li class="oc-resource-list-item"><a class="oc-resource-list-link" href="${escapeHtml(href(ctx.basePath, entry.period, entry.key))}"><span class="oc-resource-list-title">${escapeHtml(entry.key)}</span>${statusBadge(entry.status)}</a></li>`).join("")}</ul>`;
}
export function renderIndexPage(
ctx: PageContext,
index: PeriodIndex,
days: ReportDocument[],
options: OverviewOptions,
): string {
const periods: Period[] = ["day", "week", "month"];
const cards = periods
.map((period) => {
const entry = index[period][0];
return `<div class="oc-card"><div class="stat-heading"><h3>Latest ${period}</h3>${entry ? statusBadge(entry.status) : ""}</div>${entry ? `<a class="stat-date" href="${escapeHtml(href(ctx.basePath, period, entry.key))}">${escapeHtml(entry.key)}</a><p class="details">Generated ${escapeHtml(date(entry.generatedAtMs, ctx.displayTimezone))}</p>` : '<p class="muted">No reports yet.</p>'}</div>`;
})
.join("");
const days = index.day.toSorted((a, b) => a.key.localeCompare(b.key));
const latestDay = index.day[0];
const generated = Math.max(
0,
...PERIODS.flatMap((period) => index[period].map((entry) => entry.generatedAtMs)),
);
const orgs = options.orgs.join(", ");
const dateline =
days.length >= 2
? `<section class="home-dateline" aria-label="Activity dateline"><div class="oc-eyebrow">dateline</div>${sparklineSvg(days.map(combined), `Combined GitHub and Discord activity across ${days.length} report days`, true)}<div class="home-dateline-scale"><span>${escapeHtml(days[0]?.key ?? "")}</span><span>${days.length} report days</span><span>${escapeHtml(days.at(-1)?.key ?? "")}</span></div></section>`
: "";
const open = PERIODS.filter((period) => index[period][0] && isOpen(ctx, index[period][0]));
const openBanner = open.length
? banner(
"info",
`<strong class="oc-banner-title">Open reporting windows</strong><p>The current UTC ${open.join(", ")} ${open.length === 1 ? "remains" : "remain"} open. ${open.length === 1 ? "Its total is a snapshot" : "Their totals are snapshots"} and update as new activity arrives.</p>`,
)
: "";
const health = options.health;
const lastRun = health.lastRun
? `<span class="oc-badge oc-badge-${health.lastRun.status === "ok" ? "success" : "warning"}">${health.lastRun.status}</span> ${relativeTime(ctx, health.lastRun.finishedAtMs)}`
: "—";
return shell(
ctx,
"Overview",
`<section class="oc-section"><div class="oc-section-header"><div class="oc-section-heading"><p class="oc-eyebrow">Team activity</p><h1 class="oc-section-title">Overview</h1><p class="oc-section-copy">GitHub contributions and Discord discussion, by day, week, and month.</p></div></div><div class="stats">${cards}</div></section><section class="oc-section">${sectionHeading("Daily activity · last 28 days")}${renderTrend(days.map((day) => ({ key: day.period.key, github: day.totals.github.total, discord: day.totals.discord.messages })))}<p class="details">Only stored days are shown. Missing days are not counted as zero activity.</p></section><section class="oc-section">${sectionHeading("Report history")}<div class="history-columns">${periods.map((period) => `<div><h3>${period === "day" ? "Days" : period === "week" ? "Weeks" : "Months"}</h3>${history(ctx, index[period])}</div>`).join("")}</div></section><div class="actions"><a class="oc-action oc-action-ghost" href="${escapeHtml(ctx.basePath)}/index.json">Machine-readable index</a><a class="oc-action oc-action-ghost" href="${escapeHtml(ctx.basePath)}/status">Generation status</a></div>`,
);
}
export function renderReportPage(
ctx: PageContext,
report: ReportDocument,
summary: SummaryDocument | null,
): string {
const path = href(ctx.basePath, report.period.period, report.period.key);
const warnings = [...report.sources.github.warnings, ...(report.sources.discord?.warnings ?? [])];
if (!report.sources.github.ok || report.sources.github.stale) {
warnings.unshift("GitHub coverage is incomplete. Counts may be lower than actual activity.");
}
if (report.sources.discord && (!report.sources.discord.ok || report.sources.discord.stale)) {
warnings.push("Discord coverage is incomplete. Counts may be lower than actual activity.");
}
if (report.truncated) {
warnings.push("Item lists were truncated; aggregate counts are preserved.");
}
const summaryNotices = (summary?.warnings ?? [])
.map((warning) => banner("warning", `<p>${escapeHtml(warning)}</p>`))
.join("");
const notices = [
report.status === "partial"
? banner(
"warning",
"<p>This period is still open. Activity and summaries may change as new reports are generated.</p>",
)
: "",
!summary || summary.source === "fallback"
? banner(
"info",
"<p>Deterministic summary: model summaries are disabled, pending, or unavailable.</p>",
)
: "",
warnings.length
? banner(
"warning",
`<h2 class="oc-banner-title">Coverage notes</h2><ul>${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("")}</ul>`,
)
: "",
].join("");
const members = report.members
.map(
(member) =>
`<li class="member">${renderAvatar(member.login, member.display, "md")}<article class="member-content"><div class="identity-line"><h3><a href="${escapeHtml(href(ctx.basePath, "people", member.login))}">${escapeHtml(member.display)}</a></h3><span class="muted">@${escapeHtml(member.login)}</span>${pills(member)}</div><p class="details">${escapeHtml(countDescription(member.github))} · ${member.discord.total} Discord messages</p><p>${escapeHtml(memberSummary(member))}</p>${member.areas.length ? `<p class="details">Areas: ${member.areas.map(escapeHtml).join(", ")}</p>` : ""}${member.access.length ? `<p class="details">Access: ${member.access.map(escapeHtml).join(", ")}</p>` : ""}${member.github.items.length ? `<details><summary>${member.github.items.length} GitHub items</summary><ul class="oc-resource-list member-items">${member.github.items.map((item) => `<li class="oc-resource-list-item resource-row"><span class="oc-resource-list-meta">${ITEM_LABELS[item.kind]} · ${escapeHtml(item.repo)}</span> — ${externalLink(item.url, item.title)}</li>`).join("")}</ul></details>` : ""}${member.discord.excerpts.length ? `<details><summary>Discord excerpts</summary>${member.discord.excerpts.map((excerpt) => `<blockquote><small>#${escapeHtml(excerpt.channel)} · ${escapeHtml(date(excerpt.atMs, ctx.displayTimezone))}</small><br>${escapeHtml(excerpt.excerpt)}</blockquote>`).join("")}</details>` : ""}</article></li>`,
)
.join("");
const other = report.otherActors.length
? `<section class="oc-section">${sectionHeading("Other GitHub actors")}<p class="details">Activity by accounts outside the current roster.</p><ul class="oc-resource-list">${report.otherActors.map((actor) => `<li class="oc-resource-list-item resource-row"><span class="person-identity">${renderAvatar(actor.login, actor.login, "xs")}<span class="oc-resource-list-title">@${escapeHtml(actor.login)}</span></span><span class="oc-resource-list-meta">${actor.github.total} GitHub events</span></li>`).join("")}</ul></section>`
: "";
const unmatched = report.unmatchedDiscord.length
? `<section class="oc-section">${sectionHeading("Unmatched Discord authors")}<p class="muted">These messages count toward Discord totals. No message content is included.</p><ul class="muted">${report.unmatchedDiscord.map((actor) => `<li>${escapeHtml(actor.authorId)}: ${actor.messages} messages</li>`).join("")}</ul></section>`
: "";
const periodLabel = { day: "Day", week: "Week", month: "Month" }[report.period.period];
return shell(
ctx,
report.period.title,
`<section class="oc-section"><div class="oc-section-header"><div class="oc-section-heading"><p class="oc-eyebrow">${periodLabel} · ${escapeHtml(report.orgs.join(", "))}</p><div class="identity-line"><h1 class="oc-section-title">${escapeHtml(report.period.title)}</h1>${statusBadge(report.status)}</div><p class="details">Generated ${escapeHtml(date(report.generatedAtMs, ctx.displayTimezone))}</p><p class="details">UTC window: ${escapeHtml(new Date(report.period.sinceMs).toISOString())} – ${escapeHtml(new Date(report.period.untilMs).toISOString())} (exclusive)</p></div><div class="actions"><a class="oc-action oc-action-ghost" href="${escapeHtml(path)}report.md">Markdown</a><a class="oc-action oc-action-ghost" href="${escapeHtml(path)}data.json">JSON</a></div></div><div class="stats"><div class="oc-card"><strong class="stat-value">${report.totals.github.total}</strong><span class="details">GitHub events</span></div><div class="oc-card"><strong class="stat-value">${report.totals.discord.messages}</strong><span class="details">Discord messages</span></div><div class="oc-card"><strong class="stat-value">${report.activeMembers} / ${report.memberCount}</strong><span class="details">Active members</span></div></div></section>${notices ? `<div class="notices">${notices}</div>` : ""}${summary ? `<section class="oc-section prose">${sectionHeading("Summary")}${summaryNotices ? `<div class="notices">${summaryNotices}</div>` : ""}${markdownBlocks(summary.globalSummary)}<h3>Highlights</h3><ul>${summary.highlights.map((highlight) => `<li>${escapeHtml(highlight)}</li>`).join("")}</ul></section>` : ""}<section class="oc-section">${sectionHeading("Members")}${members ? `<ul class="member-list">${members}</ul>` : "<p>No members are configured for this period.</p>"}</section>${other}${unmatched}`,
);
}
export function renderPeoplePage(ctx: PageContext, people: Person[]): string {
const rows = people
.map((person) => {
const login = person.github[0] ?? "";
const display = person.display ?? login;
const path = escapeHtml(href(ctx.basePath, "people", login));
return `<tr><td><a class="person-identity" href="${path}">${renderAvatar(login, display, "md")}<span>${escapeHtml(display)}</span></a></td><td>@${escapeHtml(login)}</td><td>${escapeHtml(person.affiliation ?? "—")}</td><td>${escapeHtml(person.roleLabel ?? person.roleGroup ?? "—")}</td><td><span class="oc-badge oc-badge-${person.status === "archived" ? "neutral" : "success"}">${person.status === "archived" ? "Archived" : "Active"}</span></td><td><a href="${path}" aria-label="History for ${escapeHtml(display)}">History</a></td></tr>`;
})
.join("");
return shell(
ctx,
"People",
`<section class="oc-section"><div class="oc-section-header"><div class="oc-section-heading"><p class="oc-eyebrow">Team activity</p><h1 class="oc-section-title">People</h1><p class="oc-section-copy">Current roster and archived members with retained history.</p></div></div><div class="oc-table-wrap" tabindex="0" role="region" aria-label="People"><table class="oc-table"><thead><tr><th scope="col">Person</th><th scope="col">Login</th><th scope="col">Affiliation</th><th scope="col">Role</th><th scope="col">Status</th><th scope="col">History</th></tr></thead><tbody>${rows}</tbody></table></div></section>`,
);
}
export function renderPersonPage(ctx: PageContext, person: Person, days: PersonDay[]): string {
const login = person.github[0] ?? "";
const trend = days
.toSorted((a, b) => a.dayKey.localeCompare(b.dayKey))
.map((day) => ({ key: day.dayKey, github: day.githubTotal, discord: day.discordMessages }));
return shell(
ctx,
person.display ?? login,
`<section class="oc-section"><div class="oc-section-header"><div class="person-identity">${renderAvatar(login, person.display ?? login, "xl")}<div class="person-heading"><h1 class="oc-section-title">${escapeHtml(person.display ?? login)}</h1><span class="muted">@${escapeHtml(login)}</span><div class="pills">${pills(person)}</div></div></div></div>${person.github.length > 1 ? `<p class="details">Aliases: ${person.github.slice(1).map(escapeHtml).join(", ")}</p>` : ""}</section>${person.status === "archived" ? banner("neutral", `<p>Archived${person.archivedAt ? ` on ${escapeHtml(person.archivedAt)}` : ""}. Historical reports remain available.</p>`) : ""}<section class="oc-section">${sectionHeading("Daily activity")}${renderTrend(trend)}</section><section class="oc-section">${sectionHeading("Last 28 days with retained history")}${days.length ? `<div class="oc-table-wrap" tabindex="0" role="region" aria-label="Daily activity history"><table class="oc-table"><thead><tr><th scope="col">UTC day</th><th scope="col" class="number">GitHub events</th><th scope="col" class="number">Commits</th><th scope="col" class="number">PRs merged</th><th scope="col" class="number">Discord messages</th></tr></thead><tbody>${days.map((day) => `<tr><td><a href="${escapeHtml(href(ctx.basePath, "day", day.dayKey))}">${escapeHtml(day.dayKey)}</a></td><td class="number">${day.githubTotal}</td><td class="number">${day.commits}</td><td class="number">${day.prsMerged}</td><td class="number">${day.discordMessages}</td></tr>`).join("")}</tbody></table></div>` : "<p>No stored daily reports for this person yet.</p>"}</section>`,
`<div class="home-grid" aria-label="Report overview"><section class="oc-brand-banner home-banner" data-asset="crab" data-anchor="top" data-effect="fade" data-size="hero"><div class="oc-brand-banner-art" aria-hidden="true"><img src="${escapeHtml(ctx.basePath)}/assets/crab.avif" alt="" draggable="false"></div><div class="oc-brand-banner-content"><p class="oc-eyebrow">${escapeHtml(orgs)} · team</p><h1>Team Reports</h1><p>Daily, weekly, and monthly GitHub and Discord activity for ${escapeHtml(orgs)}. Access is enforced by the Gateway; history is stored by the plugin.</p></div><div class="home-banner-stamp">generated ${generated ? relativeTime(ctx, generated) : "—"}</div></section>${dateline}${openBanner}${options.latest ? sourceBanners(options.latest.report, options.latest.summary) : ""}${PERIODS.map((period) => quickCard(ctx, index, period)).join("")}<section class="home-card people-teaser oc-card"><div class="home-card-top"><div><div class="oc-eyebrow">people</div><h2>People Archive</h2><p>Activity timelines and repository history by team member.</p></div><div class="home-actions-row"><a class="panel-link oc-action" href="${escapeHtml(ctx.basePath)}/people/">Open people archive <span aria-hidden="true">→</span></a></div></div><div class="oc-summary-strip">${homeMetric("People", latestDay?.memberCount ?? 0)}${homeMetric("Active today", latestDay?.activeMembers ?? 0)}${homeMetric("Report days", days.length)}</div></section><section class="home-card generation-panel oc-card"><div class="home-card-top"><div><div class="oc-eyebrow">runs</div><h2>Generation Status</h2><p>Scheduler and source health.</p></div><div class="home-actions-row"><a class="panel-link oc-action" href="${escapeHtml(ctx.basePath)}/status">Open status JSON <span aria-hidden="true">→</span></a></div></div><div class="oc-summary-strip">${homeMetric("Last run", lastRun)}${homeMetric("Next due", health.nextDueMs === undefined ? "—" : relativeTime(ctx, health.nextDueMs))}${homeMetric("Source warnings", health.warnings)}</div></section></div><div class="grid">${PERIODS.map((period) => history(ctx, index[period], period)).join("")}</div>`,
"home",
);
}

View file

@ -0,0 +1,166 @@
import type { PeriodDescriptor, Person, ReportDocument, SummaryDocument } from "../types.js";
import { REPORT_SCRIPT } from "./script.js";
import { escapeHtml, safeExternalUrl } from "./shared.js";
import { REPORT_STYLES } from "./styles.js";
export type PageContext = {
basePath: string;
nonce: string;
absoluteUrl: string;
displayTimezone: string;
nowMs?: number;
};
type Window = Pick<PeriodDescriptor, "period" | "key" | "sinceMs" | "untilMs">;
type Snapshot = Window & { generatedAtMs: number; status: ReportDocument["status"] };
export function href(basePath: string, ...segments: string[]): string {
return `${basePath}/${segments.map(encodeURIComponent).join("/")}/`;
}
export function date(value: number, timezone: string): string {
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: timezone,
}).format(value);
}
export function relativeTime(ctx: PageContext, ms: number): string {
const exact = escapeHtml(date(ms, ctx.displayTimezone));
return `<time data-relative-time datetime="${new Date(ms).toISOString()}" title="${exact}">${exact}</time>`;
}
export function periodTitle(entry: Window): string {
if (entry.period === "week") {
return `Week ${entry.key.replace(/^\d+-W/, "")}`;
}
return new Intl.DateTimeFormat("en-US", {
month: entry.period === "month" ? "long" : "short",
...(entry.period === "day" ? { day: "numeric" } : {}),
year: "numeric",
timeZone: "UTC",
}).format(entry.sinceMs);
}
export function formatWindow(entry: Pick<Window, "sinceMs" | "untilMs">): string {
const start = new Date(entry.sinceMs);
const end = new Date(entry.untilMs - 1);
const day = (value: Date) =>
new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(value);
if (start.toISOString().slice(0, 10) === end.toISOString().slice(0, 10)) {
return day(start);
}
if (
start.getUTCFullYear() === end.getUTCFullYear() &&
start.getUTCMonth() === end.getUTCMonth()
) {
const monthDay = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
}).format(start);
return `${monthDay}-${end.getUTCDate()}, ${end.getUTCFullYear()}`;
}
return `${day(start)}-${day(end)}`;
}
export function isOpen(ctx: PageContext, entry: Pick<Window, "sinceMs" | "untilMs">): boolean {
const now = ctx.nowMs ?? Date.now();
return now >= entry.sinceMs && now < entry.untilMs;
}
export function openPeriodStatus(ctx: PageContext, entry: Snapshot): string {
if (!isOpen(ctx, entry)) {
return "";
}
const minutes = Math.max(1, Math.ceil((entry.untilMs - (ctx.nowMs ?? Date.now())) / 60000));
const remaining = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
const until = new Date(entry.untilMs).toISOString();
const asOf = new Date(entry.generatedAtMs).toISOString();
const label =
entry.period === "day" ? "Today" : entry.period === "week" ? "Week to date" : "Month to date";
return `<span class="open-period-status"><span>${label}, as of <time datetime="${asOf}">${asOf.slice(11, 16)} UTC</time> · ${relativeTime(ctx, entry.generatedAtMs)}</span>${entry.period === "day" ? `<span aria-hidden="true">·</span><span data-day-countdown data-until="${until}">closes in ${remaining} (${until.slice(11, 16)} UTC)</span>` : ""}</span>`;
}
export function banner(tone: "warning" | "info" | "neutral", content: string): string {
return `<section class="oc-banner oc-banner-${tone}"><span class="oc-banner-indicator" aria-hidden="true"></span><div class="oc-banner-content">${content}</div></section>`;
}
export function sourceBanners(report: ReportDocument, summary: SummaryDocument | null): string {
const banners: string[] = [];
const source = (title: string, warnings: string[]) =>
banner(
"warning",
`<strong class="oc-banner-title">${title}</strong><p>${warnings.length ? warnings.map(escapeHtml).join(" · ") : "Counts may be lower than actual activity."}</p>`,
);
const github = report.sources.github;
if (!github.ok || github.stale || github.warnings.length) {
banners.push(
source(
!github.ok ? "GitHub sources unavailable" : "GitHub coverage is incomplete",
github.warnings,
),
);
}
const discord = report.sources.discord;
if (discord && (!discord.ok || discord.stale || discord.warnings.length)) {
banners.push(source("Discord coverage is incomplete", discord.warnings));
}
const warnings = [
...(summary?.warnings ?? []),
...(report.truncated ? ["Item lists were truncated; aggregate counts are preserved."] : []),
];
if (warnings.length) {
banners.push(source("Summary and coverage notes", warnings));
}
return banners.slice(0, 3).join("");
}
export function sectionHeading(title: string, eyebrow?: string, note?: string): string {
return `<div class="oc-section-header"><div>${eyebrow ? `<div class="oc-eyebrow">${escapeHtml(eyebrow)}</div>` : ""}<h2>${escapeHtml(title)}</h2></div>${note ? `<span class="section-note">${escapeHtml(note)}</span>` : ""}</div>`;
}
export function affiliation(person: Pick<Person, "affiliation" | "roleGroup">): string {
const variant =
person.roleGroup === "readonly"
? "is-readonly"
: person.roleGroup === "volunteer"
? "is-na"
: !person.affiliation || person.affiliation === "Independent"
? "is-independent"
: "";
return `<span class="affiliation ${variant}">${escapeHtml(person.affiliation || (person.roleGroup === "readonly" ? "Read-only" : person.roleGroup === "volunteer" ? "Volunteer" : "Independent"))}</span>`;
}
export function externalLink(url: string, label: string): string {
const safe = safeExternalUrl(url);
return safe
? `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
: escapeHtml(label);
}
export function markdownBlocks(value: string): string {
return value
.split(/\n\s*\n/)
.map((block) => {
const lines = block.split("\n");
const inline = (line: string) =>
escapeHtml(line).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
return lines.every((line) => /^\s*[-*] /.test(line))
? `<ul>${lines.map((line) => `<li>${inline(line.replace(/^\s*[-*] /, ""))}</li>`).join("")}</ul>`
: `<p>${lines.map(inline).join("<br>")}</p>`;
})
.join("");
}
export function shell(
ctx: PageContext,
title: string,
body: string,
page: "home" | "report" | "people" | "person",
): string {
const active = page === "home" || page === "report" ? "Reports" : "People";
const links = [
["Reports", `${ctx.basePath}/`],
["Latest", `${ctx.basePath}/latest/`],
["People", `${ctx.basePath}/people/`],
]
.map(
([label, url]) =>
`<a class="oc-segmented-item${label === active ? " is-active" : ""}" href="${escapeHtml(url ?? "")}"${label === active ? ' aria-current="page"' : ""}>${label}</a>`,
)
.join("");
return `<!doctype html><html lang="en" data-report-base-path="${escapeHtml(ctx.basePath)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)} · Team Reports</title><script nonce="${escapeHtml(ctx.nonce)}">${REPORT_SCRIPT}</script><style nonce="${escapeHtml(ctx.nonce)}">${REPORT_STYLES}</style></head><body class="oc-app-surface" data-report-page="${page}"><nav class="site-nav" aria-label="Report navigation"><div class="site-nav-inner"><a class="site-brand" href="${escapeHtml(ctx.basePath)}/"><span class="brand-mark" aria-hidden="true"><img src="${escapeHtml(ctx.basePath)}/assets/icon.png" width="26" height="26" alt=""></span><span>Reports</span></a><div class="site-links oc-segmented">${links}<button class="theme-toggle oc-action oc-action-ghost oc-action-icon" type="button" data-theme-toggle aria-label="Toggle theme" title="Toggle theme"><svg class="theme-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2m-17-7 2 2m12 12 2 2M5 19l2-2M17 7l2-2"/></svg><svg class="theme-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.7 6.7 0 0 0 9.8 9.8Z"/></svg></button><a class="theme-toggle oc-action oc-action-ghost oc-action-icon" href="${escapeHtml(ctx.absoluteUrl)}" target="_blank" rel="noopener" data-report-open-window aria-label="Open in a new window" title="Open in a new window"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 3h7v7M21 3 10 14M10 3H3v18h18v-7"/></svg></a></div></div></nav><main class="shell">${body}</main><footer>Report windows use UTC. Generation times are shown in ${escapeHtml(ctx.displayTimezone)}. Links may not open inside the Control UI frame.</footer></body></html>`;
}

View file

@ -0,0 +1,317 @@
import { DAY_MS } from "../periods.js";
import type { PersonDay } from "../store.js";
import type { Person } from "../types.js";
import { affiliation, href, type PageContext, sectionHeading, shell } from "./page.js";
import { escapeHtml, renderAvatar } from "./shared.js";
function activityLevel(count: number): number {
if (count <= 0) {
return 0;
}
if (count < 5) {
return 1;
}
if (count < 15) {
return 2;
}
if (count < 40) {
return 3;
}
return 4;
}
function total(day: PersonDay): number {
return day.githubTotal + day.discordMessages;
}
function dayTitle(key: string): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(Date.parse(`${key}T00:00:00Z`));
}
function peopleMetric(label: string, value: number | string): string {
return `<div class="oc-summary-metric"><span class="oc-summary-metric-copy"><small>${escapeHtml(label)}</small><strong>${escapeHtml(String(value))}</strong></span></div>`;
}
function roleBadge(person: Person): string {
const role = person.roleLabel ?? person.roleGroup;
return `${role ? `<span class="oc-badge oc-badge-${person.roleGroup === "readonly" ? "neutral" : "info"}">${escapeHtml(role)}</span>` : ""}${person.status === "archived" ? '<span class="oc-badge oc-badge-neutral">Archived</span>' : ""}`;
}
function rankingStats(days: PersonDay[]) {
return days.reduce(
(result, day) => ({
github: result.github + day.githubTotal,
discord: result.discord + day.discordMessages,
active: result.active + (total(day) > 0 ? 1 : 0),
}),
{ github: 0, discord: 0, active: 0 },
);
}
export function renderPeoplePage(
ctx: PageContext,
people: Person[],
days: PersonDay[] = [],
endKey?: string,
): string {
const last =
endKey ??
days
.map((day) => day.dayKey)
.toSorted()
.at(-1) ??
new Date(ctx.nowMs ?? Date.now()).toISOString().slice(0, 10);
const end = Date.parse(`${last}T00:00:00Z`);
const keys = Array.from({ length: 28 }, (_, index) =>
new Date(end - (27 - index) * DAY_MS).toISOString().slice(0, 10),
);
const first = keys[0] ?? last;
const rankedDays = days.filter((day) => day.dayKey >= first && day.dayKey <= last);
const dayCount = new Set(rankedDays.map((day) => day.dayKey)).size;
const byLogin = new Map<string, PersonDay[]>();
for (const day of rankedDays) {
const login = day.login.toLowerCase();
const entries = byLogin.get(login) ?? [];
entries.push(day);
byLogin.set(login, entries);
}
const ranked = people
.map((person) => {
const entries = byLogin.get((person.github[0] ?? "").toLowerCase()) ?? [];
return { person, entries, stats: rankingStats(entries) };
})
.toSorted(
(a, b) =>
b.stats.github + b.stats.discord - a.stats.github - a.stats.discord ||
b.stats.active - a.stats.active ||
(a.person.display ?? a.person.github[0] ?? "").localeCompare(
b.person.display ?? b.person.github[0] ?? "",
),
);
const groups = [
{
key: "active",
label: "Active members",
rows: ranked.filter(
({ person, stats }) =>
person.status !== "archived" && person.roleGroup !== "readonly" && stats.active > 0,
),
},
{
key: "quiet",
label: "No visible activity in the ranking window",
rows: ranked.filter(
({ person, stats }) =>
person.status !== "archived" && person.roleGroup !== "readonly" && stats.active === 0,
),
},
{
key: "readonly",
label: "Read-only access",
rows: ranked.filter(
({ person }) => person.status !== "archived" && person.roleGroup === "readonly",
),
},
{
key: "archived",
label: "Former members",
rows: ranked.filter(({ person }) => person.status === "archived"),
},
];
const cards = groups
.filter((group) => group.rows.length)
.map((group) => {
const heading = `<div class="people-break${group.key === "archived" ? " is-archived" : group.key === "readonly" ? " is-readonly" : ""}" data-people-group="${group.key}"${group.key === "quiet" ? ' data-inactive="true"' : ""}><span>${group.label}</span><span>${group.rows.length} ${group.key === "quiet" ? "quiet" : group.key === "archived" ? "archived" : "people"}</span></div>`;
return `${heading}${group.rows
.map(({ person, entries, stats }) => {
const login = person.github[0] ?? "";
const display = person.display ?? login;
const archived = person.status === "archived";
const inactive = stats.active === 0;
const points = new Map(entries.map((day) => [day.dayKey, day]));
const strip = keys
.map((key) => {
const point = points.get(key);
const count = point ? total(point) : 0;
const title = `${key}: ${point ? `${count} events` : "No stored report"}`;
return `<span class="day-dot level-${activityLevel(count)}" title="${escapeHtml(title)}"></span>`;
})
.join("");
return `<a class="person-card oc-card oc-card-interactive${inactive ? " is-inactive" : ""}${archived ? " is-archived" : ""}" href="${escapeHtml(href(ctx.basePath, "people", login))}" data-inactive="${inactive && !archived}"><span class="person-card-head">${renderAvatar(login, display, "sm")}<span class="person-card-identity"><strong>${escapeHtml(display)}</strong><span class="person-handle">@${escapeHtml(login)}</span>${affiliation(person)}</span></span><span class="mini-strip mini-strip-combined" aria-label="Activity over 28 UTC days">${strip}</span><span class="person-card-meta">${roleBadge(person)}<span>${stats.github + stats.discord} events · ${stats.github} GitHub · ${stats.discord} Discord</span><span class="person-card-days">${stats.active} out of ${dayCount} days active</span></span></a>`;
})
.join("")}`;
})
.join("");
const archived = people.filter((person) => person.status === "archived").length;
const quiet = groups.find((group) => group.key === "quiet")?.rows.length ?? 0;
return shell(
ctx,
"People",
`<header class="people-header"><div><div class="oc-eyebrow">people</div><h1>Member Activity Timelines</h1><p>Current members ranked by the last 28 days, with former members retained in the archive. Darker cells mean heavier GitHub or Discord activity. Open a person to explore their daily reports.</p></div><div class="oc-card oc-summary-metric"><span class="oc-summary-metric-copy"><small>Ranking Window</small><strong>28 days</strong><small>${people.length - archived} current, ${quiet} quiet, ${archived} archived · ${dayCount} report days</small></span></div></header><section class="people-toolbar oc-card" aria-label="Timeline display"><label class="source-toggle js-only"><input class="oc-switch" type="checkbox" data-hide-inactive-toggle aria-label="Hide people with zero activity"><span>Hide quiet</span></label><div class="source-key"><span><i class="source-swatch github" aria-hidden="true"></i>GitHub</span><span><i class="source-swatch discord" aria-hidden="true"></i>Discord</span></div></section><section class="people-grid" aria-label="Member timelines">${cards || '<div class="oc-empty"><p class="oc-empty-description">No member activity yet.</p></div>'}</section>`,
"people",
);
}
function personActivityChart(login: string, days: PersonDay[]): string {
const latest = days.at(-1);
if (!latest) {
return `<section class="archive-panel person-activity-panel oc-section">${sectionHeading("Last 30 Days", "activity")}<p>No daily report data is available.</p></section>`;
}
const end = Date.parse(`${latest.dayKey}T00:00:00Z`);
const start = end - 29 * DAY_MS;
const points = days.filter((day) => Date.parse(`${day.dayKey}T00:00:00Z`) >= start);
const maximum = (field: "githubTotal" | "discordMessages") => {
const value = Math.max(0, ...points.map((day) => day[field]));
if (value <= 4) {
return 4;
}
const magnitude = 10 ** Math.floor(Math.log10(value / 4));
const normalized = value / 4 / magnitude;
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
return Math.ceil(value / step) * step;
};
const githubMax = maximum("githubTotal");
const discordMax = maximum("discordMessages");
const x = (day: PersonDay) =>
78 + ((Date.parse(`${day.dayKey}T00:00:00Z`) - start) / (29 * DAY_MS)) * 782;
const y = (value: number, max: number) => 298 - (value / max) * 234;
const fixed = (value: number) => value.toFixed(1);
const series = (field: "githubTotal" | "discordMessages", source: string, max: number) => {
const path = points
.map((day, index) => {
const previous = points[index - 1];
const connected =
previous &&
Date.parse(`${day.dayKey}T00:00:00Z`) - Date.parse(`${previous.dayKey}T00:00:00Z`) ===
DAY_MS;
return connected
? `H${fixed(x(day))} V${fixed(y(day[field], max))}`
: `M${fixed(x(day))} ${fixed(y(day[field], max))}`;
})
.join(" ");
return `<path class="person-activity-line person-activity-line-${source}" d="${path}"/>${points.map((day) => `<circle class="person-activity-point person-activity-point-${source}" cx="${fixed(x(day))}" cy="${fixed(y(day[field], max))}" r="2.5"><title>${escapeHtml(`${day.dayKey}: ${day[field]} ${source === "github" ? "GitHub events" : "Discord messages"}`)}</title></circle>`).join("")}`;
};
const ticks = Array.from({ length: 5 }, (_, index) => {
const fraction = index / 4;
const yy = fixed(y(fraction, 1));
return `<line class="person-activity-gridline" x1="78" y1="${yy}" x2="860" y2="${yy}"/><text class="person-activity-axis-tick person-activity-axis-tick-left" x="66" y="${fixed(Number(yy) + 4)}">${Math.round(githubMax * fraction)}</text><text class="person-activity-axis-tick person-activity-axis-tick-right" x="872" y="${fixed(Number(yy) + 4)}">${Math.round(discordMax * fraction)}</text>`;
}).join("");
const dateTicks = [0, 7, 14, 21, 29]
.map((offset) => {
const time = start + offset * DAY_MS;
const label = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
}).format(time);
return `<text class="person-activity-axis-tick" x="${fixed(78 + (offset / 29) * 782)}" y="342" text-anchor="${offset === 0 ? "start" : offset === 29 ? "end" : "middle"}">${escapeHtml(label)}</text>`;
})
.join("");
const svg = `<svg class="person-activity-svg" viewBox="0 0 960 360" role="img" aria-label="${escapeHtml(`Daily activity for @${login}: GitHub events on the left axis, Discord messages on the right axis`)}"><title>${escapeHtml(`Last 30 Days for @${login}`)}</title><desc>Only stored daily values are plotted. Gaps indicate missing reports, not zero activity.</desc>${ticks}<path class="person-activity-axis" d="M78 64V298H860V64"/><text class="person-activity-axis-title person-activity-axis-title-github" x="78" y="26">GitHub events · left axis</text><text class="person-activity-axis-title person-activity-axis-title-discord" x="860" y="26" text-anchor="end">Discord messages · right axis</text>${series("githubTotal", "github", githubMax)}${series("discordMessages", "discord", discordMax)}${dateTicks}</svg>`;
return `<section class="archive-panel person-activity-panel oc-section">${sectionHeading("Last 30 Days", "activity", "Daily GitHub events and report-scoped Discord messages. UTC.")}<ul class="person-activity-legend" aria-label="Chart series and axes"><li><span class="person-activity-swatch is-github" aria-hidden="true"></span>GitHub events · left axis</li><li><span class="person-activity-swatch is-discord" aria-hidden="true"></span>Discord messages · right axis</li></ul><div class="person-activity-chart">${svg}</div><p class="person-activity-quality-note">Only stored days are shown. Missing days are not counted as zero activity.</p><details class="person-activity-values"><summary>Exact daily values</summary><div class="person-activity-table-wrap"><table class="person-activity-table"><thead><tr><th scope="col">UTC day</th><th scope="col">GitHub events</th><th scope="col">Discord messages</th></tr></thead><tbody>${points.map((day) => `<tr><th scope="row"><time datetime="${escapeHtml(day.dayKey)}">${escapeHtml(day.dayKey)}</time></th><td>${day.githubTotal}</td><td>${day.discordMessages}</td></tr>`).join("")}</tbody></table></div></details></section>`;
}
function reportHref(ctx: PageContext, login: string, key: string): string {
return escapeHtml(`${href(ctx.basePath, "day", key)}?person=${encodeURIComponent(login)}`);
}
function archiveTimeline(ctx: PageContext, login: string, days: PersonDay[]): string {
const months = new Map<string, PersonDay[]>();
for (const day of days) {
const month = day.dayKey.slice(0, 7);
const entries = months.get(month) ?? [];
entries.push(day);
months.set(month, entries);
}
return [...months.entries()]
.toReversed()
.map(([month, entries]) => {
const first = new Date(`${month}-01T00:00:00Z`);
const offset = (first.getUTCDay() + 6) % 7;
const monthEnd = new Date(first);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1);
const length = (monthEnd.getTime() - first.getTime()) / DAY_MS;
const points = new Map(entries.map((day) => [day.dayKey, day]));
const cells = Array.from({ length }, (_, index) => {
const key = `${month}-${String(index + 1).padStart(2, "0")}`;
const point = points.get(key);
if (!point) {
return `<span class="day-cell is-missing" title="${escapeHtml(`${key}: No stored report`)}">${index + 1}</span>`;
}
const label = `${key}: ${total(point)} events for @${login}`;
return `<a class="day-cell level-${activityLevel(total(point))}" href="${reportHref(ctx, login, key)}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${index + 1}</a>`;
}).join("");
const label = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(first);
return `<div class="month-row"><div class="month-label">${escapeHtml(label)}</div><div class="calendar"><div class="weekday-row" aria-hidden="true"><span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span><span>Su</span></div><div class="day-grid">${Array.from({ length: offset }, () => '<span class="day-spacer" aria-hidden="true"></span>').join("")}${cells}</div></div></div>`;
})
.join("");
}
export function renderPersonPage(ctx: PageContext, person: Person, days: PersonDay[]): string {
const login = person.github[0] ?? "";
const display = person.display ?? login;
const ordered = days.toSorted((a, b) => a.dayKey.localeCompare(b.dayKey));
const active = ordered.filter((day) => total(day) > 0);
const latest = active.at(-1);
const sum = (value: (day: PersonDay) => number) =>
days.reduce((result, day) => result + value(day), 0);
const totals = [
peopleMetric(
"GitHub",
sum((day) => day.githubTotal),
),
peopleMetric(
"Comments",
sum((day) => day.issueComments + day.reviewComments),
),
peopleMetric(
"Discord",
sum((day) => day.discordMessages),
),
peopleMetric(
"Commits",
sum((day) => day.commits),
),
peopleMetric(
"PRs",
sum((day) => day.prsOpened + day.prsMerged + day.prsClosed),
),
peopleMetric(
"Issues",
sum((day) => day.issuesOpened + day.issuesClosed),
),
].join("");
const lifecycle =
person.status === "archived"
? `<p class="person-lifecycle">Archived${person.archivedAt ? ` on ${escapeHtml(person.archivedAt)}` : ""}. Historical reports remain available.</p>`
: "";
const rows = active
.toReversed()
.map(
(day) =>
`<a class="activity-row" href="${reportHref(ctx, login, day.dayKey)}"><span class="activity-date">${escapeHtml(dayTitle(day.dayKey))}</span><span><strong>${total(day)} events</strong><span>${day.githubTotal} GitHub · ${day.issueComments + day.reviewComments} comments · ${day.discordMessages} Discord · ${day.commits} commits · ${day.prsOpened + day.prsMerged + day.prsClosed} PRs</span></span></a>`,
)
.join("");
return shell(
ctx,
display,
`<header class="people-header"><div class="person-hero">${renderAvatar(login, display, "xl")}<div><div class="breadcrumbs"><a href="${escapeHtml(href(ctx.basePath, "people"))}">People</a><span>/</span><span>@${escapeHtml(login)}</span></div><h1>${escapeHtml(display)}</h1><div class="person-company-line">${affiliation(person)}<span class="person-handle">@${escapeHtml(login)}</span>${roleBadge(person)}</div>${lifecycle}${
person.github.length > 1
? `<p class="alias-line">Aliases: ${person.github
.slice(1)
.map((alias) => `@${escapeHtml(alias)}`)
.join(" · ")}</p>`
: ""
}</div></div><div class="oc-card oc-summary-metric"><span class="oc-summary-metric-copy"><small>Active Days</small><strong>${active.length}/${days.length}</strong><small>${latest ? `Latest activity ${escapeHtml(dayTitle(latest.dayKey))}` : "No active days"}</small></span></div></header><section class="oc-summary-strip" aria-label="Member totals over retained days">${totals}</section>${personActivityChart(login, ordered)}<section class="archive-panel oc-section">${sectionHeading("Daily Archive", "timeline")}<div class="legend" aria-label="Activity intensity, low to high"><span class="level-0"></span><span class="level-1"></span><span class="level-2"></span><span class="level-3"></span><span class="level-4"></span></div>${archiveTimeline(ctx, login, ordered) || '<div class="oc-empty"><p class="oc-empty-description">No stored daily reports for this person yet.</p></div>'}</section><section class="archive-panel oc-section">${sectionHeading("Active Days", "history")}<div class="activity-list">${rows || '<p class="muted">No active days recorded.</p>'}</div></section>`,
"person",
);
}

View file

@ -0,0 +1,236 @@
import type { PeriodListEntry } from "../store.js";
import type { GithubCounts, PersonReport, ReportDocument, SummaryDocument } from "../types.js";
import { deltaMarkup, sparklineSvg, splitMarkup } from "./charts.js";
import {
affiliation,
banner,
date,
externalLink,
formatWindow,
href,
isOpen,
markdownBlocks,
openPeriodStatus,
type PageContext,
periodTitle,
relativeTime,
sectionHeading,
shell,
sourceBanners,
} from "./page.js";
import { escapeHtml, ITEM_LABELS, memberSummary, renderAvatar } from "./shared.js";
function activitySegments(github: GithubCounts, discord: number) {
const prs = github.prsOpened + github.prsMerged + github.prsClosed;
const issues = github.issuesOpened + github.issuesClosed;
const comments = github.issueComments + github.reviewComments;
return [
{ label: "Commits", value: github.commits, tone: "primary" },
{ label: "PRs", value: prs, tone: "secondary" },
{ label: "Issues", value: issues, tone: "tertiary" },
{ label: "Comments", value: comments, tone: "quaternary" },
{ label: "GHSAs", value: github.securityAdvisories, tone: "error" },
{
label: "Other GitHub",
value: Math.max(
0,
github.total - github.commits - prs - issues - comments - github.securityAdvisories,
),
tone: "neutral",
},
{ label: "Discord", value: discord, tone: "muted" },
].filter((segment) => segment.value > 0);
}
function metric(label: string, value: string | number, detail = "", trend = ""): string {
return `<div class="oc-summary-metric"><span class="oc-summary-metric-copy"><small>${escapeHtml(label)}</small><strong>${escapeHtml(String(value))}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ""}${trend}</span></div>`;
}
function distribution(ctx: PageContext, members: PersonReport[]): string {
const ranked = members
.filter((member) => member.github.total + member.discord.total > 0)
.toSorted(
(a, b) =>
b.github.total + b.discord.total - a.github.total - a.discord.total ||
a.login.localeCompare(b.login),
);
const total = ranked.reduce((sum, member) => sum + member.github.total + member.discord.total, 0);
if (total === 0) {
return '<div class="oc-empty"><p class="oc-empty-description">No member activity recorded in this window.</p></div>';
}
const row = (member: PersonReport) => {
const value = member.github.total + member.discord.total;
const segments = activitySegments(member.github, member.discord.total);
let offset = 0;
const rectangles = segments
.map((segment) => {
const width = (segment.value / total) * 100;
const rect = `<rect class="distribution-segment oc-split-${segment.tone}" x="${offset.toFixed(3)}" y="0" width="${width.toFixed(3)}" height="8"><title>${escapeHtml(segment.label)}: ${segment.value}</title></rect>`;
offset += width;
return rect;
})
.join("");
const breakdown = segments
.map(
(segment) =>
`<span class="distribution-breakdown-item"><span class="distribution-breakdown-key oc-split-${segment.tone}" aria-hidden="true"></span>${escapeHtml(segment.label)} ${segment.value}</span>`,
)
.join("");
return `<li><a href="${escapeHtml(href(ctx.basePath, "people", member.login))}" aria-label="${escapeHtml(`@${member.login}: ${value} activities, ${Math.round((value / total) * 100)}%`)}"><span class="distribution-label"><span class="distribution-label-primary">@${escapeHtml(member.login)}</span><span class="distribution-label-detail">${escapeHtml(member.display)}</span></span><svg class="distribution-track" viewBox="0 0 100 8" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(`Activity types for @${member.login}`)}">${rectangles}</svg><strong class="distribution-total">${value}</strong><span class="distribution-share">${Math.round((value / total) * 100)}%</span><span class="distribution-breakdown">${breakdown}</span></a></li>`;
};
return `<div class="ranked-distribution" aria-label="Activity by Member"><ol class="ranked-distribution-list">${ranked.slice(0, 12).map(row).join("")}</ol>${ranked.length > 12 ? `<details class="distribution-more"><summary>Show remaining ${ranked.length - 12}</summary><ol class="ranked-distribution-list" start="13">${ranked.slice(12).map(row).join("")}</ol></details>` : ""}</div>`;
}
function roleBadges(member: PersonReport): string {
const role = member.roleLabel ?? member.roleGroup;
return `${role ? `<span class="oc-badge oc-badge-${member.roleGroup === "readonly" ? "neutral" : "info"}">${escapeHtml(role)}</span>` : ""}${member.access.map((access) => `<span class="oc-badge oc-badge-neutral">${escapeHtml(access)}</span>`).join("")}`;
}
function searchText(member: PersonReport): string {
return escapeHtml(
[member.login, member.display, ...member.aliases, member.affiliation, ...member.areas]
.filter(Boolean)
.join(" ")
.toLowerCase(),
);
}
function personRow(ctx: PageContext, member: PersonReport): string {
const top = (values: Record<string, number>, limit: number) =>
Object.entries(values)
.toSorted(([a, countA], [b, countB]) => countB - countA || a.localeCompare(b))
.slice(0, limit);
const chips = [
...top(member.github.repos, 4).map(([repo, count]) => `${repo}: ${count}`),
...top(member.discord.channels, 3).map(([channel, count]) => `#${channel}: ${count}`),
...member.areas.map((area) => `owns: ${area}`),
]
.map((text) => `<span class="oc-badge oc-badge-neutral">${escapeHtml(text)}</span>`)
.join("");
const themes = member.github.items
.slice(0, 3)
.map(
(item) =>
`<div class="theme"><span class="theme-kind">${ITEM_LABELS[item.kind]}</span> ${externalLink(item.url, item.title)}</div>`,
)
.join("");
const counts: Array<[string, number]> = [
["GitHub", member.github.total],
["Commits", member.github.commits],
["GHSAs", member.github.securityAdvisories],
["PRs", member.github.prsOpened + member.github.prsMerged + member.github.prsClosed],
["Issues", member.github.issuesOpened + member.github.issuesClosed],
["Comments", member.github.issueComments + member.github.reviewComments],
["Discord", member.discord.total],
];
const excerpts = member.discord.excerpts.length
? `<details><summary>Discord excerpts</summary>${member.discord.excerpts.map((excerpt) => `<blockquote><small>#${escapeHtml(excerpt.channel)} · ${escapeHtml(date(excerpt.atMs, ctx.displayTimezone))}</small><br>${escapeHtml(excerpt.excerpt)}</blockquote>`).join("")}</details>`
: "";
const items = member.github.items.length
? `<details><summary>${member.github.items.length} GitHub items</summary><ul class="oc-resource-list">${member.github.items.map((item) => `<li class="oc-resource-list-item resource-row"><span class="theme-kind">${ITEM_LABELS[item.kind]} · ${escapeHtml(item.repo)}</span>${externalLink(item.url, item.title)}</li>`).join("")}</ul></details>`
: "";
return `<article class="person oc-card" data-maintainer-card data-maintainer-search="${searchText(member)}"><div class="person-title"><div class="person-heading">${renderAvatar(member.login, member.display, "md")}<div><a class="handle" href="${escapeHtml(href(ctx.basePath, "people", member.login))}">@${escapeHtml(member.login)}</a><div class="person-name">${escapeHtml(member.display)}</div>${member.aliases.length ? `<div class="alias-line">${member.aliases.map((alias) => `@${escapeHtml(alias)}`).join(" · ")}</div>` : ""}</div></div>${affiliation(member)}<div class="role-line">${roleBadges(member)}</div></div><div class="person-body"><div class="chips">${chips}</div><p class="focus">${escapeHtml(memberSummary(member))}</p>${themes}${items}${excerpts}</div><div class="person-numbers">${counts.map(([label, count]) => `<div class="number-line"><span>${label}</span><strong>${count}</strong></div>`).join("")}</div></article>`;
}
export function renderReportPage(
ctx: PageContext,
report: ReportDocument,
summary: SummaryDocument | null,
history: PeriodListEntry[] = [],
): string {
const entry = { ...report.period, status: report.status, generatedAtMs: report.generatedAtMs };
const open = isOpen(ctx, entry);
const period = report.period.period;
const path = href(ctx.basePath, period, report.period.key);
const incomplete =
open || (report.status === "partial" && report.generatedAtMs < report.period.untilMs);
const partialBanner = incomplete
? banner(
"info",
`<strong class="oc-banner-title">Partial report</strong><span class="oc-badge oc-badge-info partial-report-badge">Incomplete snapshot</span><p>${open ? `This UTC ${period} is still in progress.` : `This UTC ${period} was still in progress when this snapshot was generated.`} Totals include activity collected through ${relativeTime(ctx, report.generatedAtMs)}. ${open ? "Activity may increase before the window closes." : "The window has since closed, so these totals remain incomplete."}</p>`,
)
: "";
const github = report.totals.github;
const prior = history
.filter((row) => row.period === period && row.sinceMs < report.period.sinceMs)
.toSorted((a, b) => a.sinceMs - b.sinceMs)
.slice(-({ day: 28, week: 12, month: 6 }[period] - 1));
const trend = (
field: "activeMembers" | "githubTotal" | "discordMessages" | "prsOpened" | "securityAdvisories",
value: number,
label: string,
badUp = false,
) => {
const values = [...prior.map((row) => row[field]), value];
return `<small>${deltaMarkup(value, prior.at(-1)?.[field], `prev ${period}${open ? " (in progress)" : ""}`, badUp)}</small>${sparklineSvg(values, `${label} by ${period}`)}`;
};
const active = report.members.filter((member) => member.github.total + member.discord.total > 0);
const quiet = report.members.filter((member) => member.github.total + member.discord.total === 0);
const roleCounts = ["core", "volunteer", "readonly"].map(
(role) => active.filter((member) => member.roleGroup === role).length,
);
const repositories =
Number(report.sources.github.stats.reposScanned) || Object.keys(github.repos).length;
const channels =
Number(report.sources.discord?.stats.channelsScanned) ||
Object.keys(report.totals.discord.channels).length;
const totals = [
metric(
"Active",
`${report.activeMembers}/${report.memberCount}`,
roleCounts.some((count) => count > 0)
? `${roleCounts[0]} Core / ${roleCounts[1]} Community / ${roleCounts[2]} Read-only`
: "members with activity",
trend("activeMembers", report.activeMembers, "Active members"),
),
metric("GitHub", github.total, "events", trend("githubTotal", github.total, "GitHub events")),
metric(
"Discord",
report.totals.discord.messages,
"messages",
trend("discordMessages", report.totals.discord.messages, "Discord messages"),
),
metric(
"PRs Opened",
github.prsOpened,
`${github.prsMerged} merged`,
trend("prsOpened", github.prsOpened, "PRs opened"),
),
metric(
"Security",
github.securityAdvisories,
"advisories",
trend("securityAdvisories", github.securityAdvisories, "Security advisories", true),
),
metric(
"Repos",
Object.entries(github.repos).filter(([, count]) => count > 0).length,
"active repositories",
),
].join("");
const mix = [
metric("Commits", github.commits),
metric("GHSAs", github.securityAdvisories),
metric("Issue/PR comments", github.issueComments),
metric("PR review comments", github.reviewComments),
metric("Discord messages", report.totals.discord.messages),
].join("");
const quietBlock = quiet.length
? `<div class="quiet-maintainers"><div class="quiet-title"><h3>No visible activity</h3><span class="small">${quiet.length} members</span></div><ul class="quiet-list">${quiet.map((member) => `<li data-maintainer-quiet data-maintainer-search="${searchText(member)}"><a href="${escapeHtml(href(ctx.basePath, "people", member.login))}">@${escapeHtml(member.login)} — ${escapeHtml(member.display)}${member.affiliation ? ` — ${escapeHtml(member.affiliation)}` : ""}</a></li>`).join("")}</ul></div>`
: "";
const other = report.otherActors.length
? `<section class="oc-section">${sectionHeading("Other GitHub actors", "Outside the roster")}<ul class="oc-resource-list">${report.otherActors.map((actor) => `<li class="oc-resource-list-item resource-row"><span class="person-identity">${renderAvatar(actor.login, actor.login, "xs")}<span>@${escapeHtml(actor.login)}</span></span><span>${actor.github.total} GitHub events</span></li>`).join("")}</ul></section>`
: "";
const unmatched = report.unmatchedDiscord.length
? `<section class="oc-section">${sectionHeading("Unmatched Discord authors", "Coverage")}<p class="section-note">These messages count toward Discord totals. No message content is included.</p><ul class="oc-resource-list">${report.unmatchedDiscord.map((actor) => `<li class="oc-resource-list-item resource-row"><span>${escapeHtml(actor.authorId)}</span><span>${actor.messages} messages</span></li>`).join("")}</ul></section>`
: "";
// Fallback summaries restate their bullets as highlights; only list what the overview text lacks.
const highlights =
summary?.highlights.filter((highlight) => !summary.globalSummary.includes(highlight)) ?? [];
return shell(
ctx,
periodTitle(report.period),
`<header><div><h1>${escapeHtml(periodTitle(report.period))}</h1><p class="subtitle">Evidence report across ${repositories} GitHub repositories and ${channels} Discord channels for ${escapeHtml(report.orgs.join(", "))}. Roster: ${report.memberCount} people.</p><div class="actions"><a class="oc-action oc-action-ghost" href="${escapeHtml(path)}report.md">Markdown</a><a class="oc-action oc-action-ghost" href="${escapeHtml(path)}data.json">JSON</a></div></div><div class="oc-card oc-summary-metric"><span class="oc-summary-metric-copy"><small>Window</small><strong>${escapeHtml(formatWindow(report.period))}</strong><small>${open ? openPeriodStatus(ctx, entry) : `As of ${relativeTime(ctx, report.generatedAtMs)}`}</small></span></div></header>${partialBanner}${sourceBanners(report, summary)}<section class="oc-summary-strip" aria-label="Report totals">${totals}</section><section class="oc-section">${sectionHeading("Global Summary", "Overview", summary?.source === "model" ? "Model summary" : "Deterministic summary")}<div class="summary-markdown">${summary ? `${markdownBlocks(summary.globalSummary)}${highlights.length ? `<ul>${highlights.map((highlight) => `<li>${escapeHtml(highlight)}</li>`).join("")}</ul>` : ""}` : `<p>${report.activeMembers} of ${report.memberCount} members recorded ${github.total} GitHub events and ${report.totals.discord.messages} Discord messages in this window.</p>`}</div></section><section class="oc-section">${sectionHeading("Activity Mix", "Hard Numbers")}<div class="oc-summary-strip">${mix}</div><div class="mix-split">${splitMarkup(activitySegments(github, report.totals.discord.messages))}</div><div class="maintainer-distribution"><div class="distribution-heading"><div><div class="oc-eyebrow">Mapped activity</div><h3>Activity by Member</h3></div><p class="section-note">Bar length shows share; colors show activity type</p></div>${distribution(ctx, report.members)}</div></section><section class="oc-section collection-section" data-maintainer-filter-root><div class="oc-section-header people-head"><div><div class="oc-eyebrow">Members</div><h2>Members</h2></div><div class="people-tools js-only"><input class="person-filter oc-input" type="search" autocomplete="off" placeholder="Filter by name or handle" aria-label="Filter members by name, handle, or alias" data-maintainer-filter><div class="filter-status" data-maintainer-filter-status aria-live="polite">showing ${active.length} of ${active.length} active</div></div></div><div class="people">${active.map((member) => personRow(ctx, member)).join("") || '<div class="oc-empty"><p class="oc-empty-description">No visible GitHub or Discord activity in this window.</p></div>'}</div><div class="oc-empty" hidden data-maintainer-filter-empty><p class="oc-empty-description">No matching member in this report.</p></div>${quietBlock}</section>${other}${unmatched}`,
"report",
);
}

View file

@ -0,0 +1,98 @@
// One head script initializes the theme before paint, then enhances server-rendered content.
export const REPORT_SCRIPT = `(() => {
const root = document.documentElement;
const query = matchMedia('(prefers-color-scheme: light)');
const storedTheme = () => {
try { const value = localStorage.getItem('theme'); return value === 'light' || value === 'dark' ? value : ''; } catch { return ''; }
};
const fragmentTheme = () => {
const value = new URLSearchParams(location.hash.slice(1)).get('theme');
return value === 'light' || value === 'dark' ? value : '';
};
root.dataset.theme = fragmentTheme() || storedTheme() || (query.matches ? 'light' : 'dark');
document.addEventListener('DOMContentLoaded', () => {
root.dataset.js = 'true';
const applyTheme = (theme, persist) => {
root.dataset.theme = theme;
if (persist) {
try { localStorage.setItem('theme', theme); } catch {}
history.replaceState(null, '', '#theme=' + theme);
}
// Opaque-origin frames cannot use storage; carry their choice through report links.
const basePath = root.dataset.reportBasePath;
for (const link of document.querySelectorAll('a[href]')) {
const href = link.getAttribute('href');
const target = new URL(href, location.href);
const internal = target.origin === location.origin &&
(target.pathname === basePath || target.pathname.startsWith(basePath + '/'));
if (internal || link.hasAttribute('data-report-open-window')) {
link.setAttribute('href', href.split('#')[0] + '#theme=' + theme);
}
}
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
button.setAttribute('aria-label', 'Switch to ' + (theme === 'light' ? 'dark' : 'light') + ' theme');
}
};
applyTheme(root.dataset.theme, false);
query.addEventListener('change', event => { if (!fragmentTheme() && !storedTheme()) applyTheme(event.matches ? 'light' : 'dark', false); });
for (const button of document.querySelectorAll('[data-theme-toggle]')) {
button.addEventListener('click', () => applyTheme(root.dataset.theme === 'light' ? 'dark' : 'light', true));
}
const relative = new Intl.RelativeTimeFormat('en', {numeric:'auto'});
const units = [['year',31536000000],['month',2592000000],['week',604800000],['day',86400000],['hour',3600000],['minute',60000],['second',1000]];
const refresh = () => {
for (const node of document.querySelectorAll('[data-relative-time]')) {
const diff = Date.parse(node.getAttribute('datetime')) - Date.now();
if (!Number.isFinite(diff)) continue;
const [unit, ms] = units.find(([unit, ms]) => Math.abs(diff) >= ms || unit === 'second');
node.textContent = relative.format(Math.round(diff / ms), unit);
}
for (const node of document.querySelectorAll('[data-day-countdown]')) {
const until = Date.parse(node.dataset.until);
if (!Number.isFinite(until)) continue;
const minutes = Math.max(1, Math.ceil((until - Date.now()) / 60000));
const remaining = minutes >= 60 ? Math.floor(minutes / 60) + 'h ' + minutes % 60 + 'm' : minutes + 'm';
const close = new Date(until).toISOString().slice(11,16) + ' UTC';
node.textContent = until <= Date.now() ? 'day closed at ' + close : 'closes in ' + remaining + ' (' + close + ')';
}
};
refresh();
setInterval(refresh, 60000);
const nav = document.querySelector('.site-nav');
const scrolled = () => { if (nav) nav.dataset.scrolled = window.scrollY > 0 ? 'true' : 'false'; };
scrolled();
window.addEventListener('scroll', scrolled, {passive:true});
for (const button of document.querySelectorAll('[data-toggle]')) {
const list = document.querySelector('[data-list="' + button.dataset.toggle + '"]');
if (!list) continue;
button.addEventListener('click', () => {
const rows = [...list.querySelectorAll('[data-extra]')];
const show = rows.some(row => row.hidden);
for (const row of rows) row.hidden = !show;
button.setAttribute('aria-expanded', String(show));
button.textContent = show ? 'Show latest only' : 'Show all ' + list.querySelectorAll('.row').length;
});
}
const filter = document.querySelector('[data-maintainer-filter]');
const status = document.querySelector('[data-maintainer-filter-status]');
const empty = document.querySelector('[data-maintainer-filter-empty]');
if (filter) {
const applyFilter = () => {
const value = filter.value.trim().replace(/^@/, '').toLocaleLowerCase();
const rows = [...document.querySelectorAll('[data-maintainer-search]')];
for (const row of rows) row.hidden = !row.dataset.maintainerSearch.toLocaleLowerCase().includes(value);
const shown = rows.filter(row => !row.hidden).length;
if (status) status.textContent = shown + ' of ' + rows.length + ' members shown';
if (empty) empty.hidden = shown > 0;
};
const params = new URLSearchParams(location.search);
filter.value = params.get('person') || params.get('q') || '';
filter.addEventListener('input', applyFilter);
applyFilter();
}
const quiet = document.querySelector('[data-hide-inactive-toggle]');
if (quiet) quiet.addEventListener('change', () => {
root.dataset.peopleHideInactive = quiet.checked ? 'true' : 'false';
});
});
})();`;

View file

@ -17,7 +17,11 @@ export function escapeHtml(value: string): string {
});
}
export function renderAvatar(login: string, display: string, size: "xs" | "md" | "xl"): string {
export function renderAvatar(
login: string,
display: string,
size: "xs" | "sm" | "md" | "xl",
): string {
const words = (display.trim() || login).split(/\s+/);
const initials = (
words.length > 1
@ -26,7 +30,7 @@ export function renderAvatar(login: string, display: string, size: "xs" | "md" |
.slice(0, 2)
.join("")
).toUpperCase();
const pixels = { xs: 20, md: 40, xl: 72 }[size];
const pixels = { xs: 20, sm: 36, md: 40, xl: 72 }[size];
const image = /^[A-Za-z0-9-]{1,39}$/.test(login)
? `<img src="https://avatars.githubusercontent.com/${escapeHtml(login)}?s=${pixels}" width="${pixels}" height="${pixels}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer">`
: "";

View file

@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest";
import { DAY_MS, describePeriod } from "../periods.js";
import { githubCounts } from "../reports.fixtures.js";
import type { PeriodListEntry, PersonDay } from "../store.js";
import type { Period, Person, ReportDocument } from "../types.js";
import {
renderIndexPage,
renderPeoplePage,
renderPersonPage,
renderReportPage,
type PageContext,
type PeriodIndex,
} from "./html.js";
const ctx: PageContext = {
basePath: "/reports",
nonce: "fixture",
absoluteUrl: "https://example.test/reports/",
displayTimezone: "America/Los_Angeles",
nowMs: Date.parse("2026-09-07T16:44:00Z"),
};
function entry(period: Period, key: string, total = 10): PeriodListEntry {
const window = describePeriod(period, key);
return {
...window,
status: window.untilMs > (ctx.nowMs ?? 0) ? "partial" : "closed",
generatedAtMs: Math.min(window.untilMs, ctx.nowMs ?? 0),
activeMembers: 1,
memberCount: 2,
githubTotal: total,
discordMessages: total,
commits: total,
prsOpened: 0,
prsMerged: 0,
securityAdvisories: 0,
};
}
function document(period: Period = "day", key = "2026-09-07"): ReportDocument {
const row = entry(period, key);
return {
version: 1,
period: describePeriod(period, key),
status: row.status,
generatedAtMs: row.generatedAtMs,
orgs: ["example"],
memberCount: 2,
activeMembers: 1,
totals: { github: githubCounts(10), discord: { messages: 10, channels: { general: 10 } } },
members: [
{
login: "alice",
display: "Alice",
aliases: [],
access: [],
areas: [],
github: { ...githubCounts(10), items: [] },
discord: { total: 10, channels: { general: 10 }, excerpts: [] },
},
],
otherActors: [],
unmatchedDiscord: [],
sources: { github: { ok: true, warnings: [], stats: { reposScanned: 1 } } },
};
}
function home(index: Partial<PeriodIndex>): string {
return renderIndexPage(
ctx,
{ day: [], week: [], month: [], ...index },
{ orgs: ["example"], health: { running: true, warnings: 0 } },
);
}
function personDay(login: string, dayKey: string, total: number): PersonDay {
return {
login,
dayKey,
githubTotal: total,
discordMessages: total,
commits: total,
prsOpened: 0,
prsMerged: 0,
prsClosed: 0,
issuesOpened: 0,
issuesClosed: 0,
issueComments: 0,
reviewComments: 0,
};
}
describe("Team Reports site behavior", () => {
it("shows a dateline only with multiple stored days and keeps the whole retained range", () => {
expect(home({ day: [entry("day", "2026-09-07")] })).not.toContain(
'aria-label="Activity dateline"',
);
const days = Array.from({ length: 400 }, (_, index) =>
entry(
"day",
new Date(Date.parse("2026-09-07T00:00:00Z") - index * DAY_MS).toISOString().slice(0, 10),
),
);
const html = home({ day: days });
expect(html).toContain('aria-label="Activity dateline"');
expect(html).toContain("400 report days");
expect(html).toContain(days.at(-1)?.key);
expect(html).toContain('href="/reports/day/2026-09-07/"');
});
it("uses UTC windows for open guidance, countdowns and intraday badges", () => {
const closed = entry("day", "2026-09-06");
const today = entry("day", "2026-09-07");
const html = home({ day: [today, closed] });
expect(html).toContain("Open reporting windows");
expect(html).toContain("Today, as of");
expect(html).toContain("16:44 UTC");
expect(html).toContain("closes in 7h 16m (00:00 UTC)");
expect(html).toContain(">Intraday</span>");
expect(home({ day: [closed] })).not.toContain("Open reporting windows");
expect(home({ day: [{ ...today, generatedAtMs: today.untilMs }] })).not.toContain(
">Intraday</span>",
);
expect(
home({ day: [{ ...closed, status: "partial", generatedAtMs: closed.untilMs - 1000 }] }),
).not.toContain(">Intraday</span>");
const closedToday = home({ day: [{ ...today, status: "closed" }] });
expect(closedToday).not.toContain(">Intraday</span>");
expect(closedToday).not.toContain('class="quick-card oc-card oc-card-interactive partial"');
});
it.each<{ period: Period; key: string }>([
{ period: "day", key: "2026-09-06" },
{ period: "week", key: "2026-W36" },
{ period: "month", key: "2026-08" },
])("preserves stored $period completeness in overview cards and history", ({ period, key }) => {
const closed = entry(period, key);
const incomplete = home({ [period]: [{ ...closed, status: "partial" }] });
expect(incomplete.match(/oc-badge-warning[^>]*>Incomplete<\/span>/g)).toHaveLength(2);
expect(incomplete).toContain('class="quick-card oc-card oc-card-interactive partial"');
expect(incomplete).not.toContain(">Intraday</span>");
expect(incomplete).not.toContain("Open reporting windows");
const complete = home({ [period]: [closed] });
expect(complete).not.toContain(">Incomplete</span>");
expect(complete).not.toContain(">Intraday</span>");
expect(complete).not.toContain('class="quick-card oc-card oc-card-interactive partial"');
});
it("compares an open aggregate snapshot to elapsed prior days instead of the full period", () => {
const previous = entry("week", "2026-W36", 100);
const current = entry("week", "2026-W37", 15);
const html = home({ day: [entry("day", "2026-08-31", 10)], week: [current, previous] });
expect(html).toContain("+50% vs prev week to date");
const monthDays = Array.from({ length: 7 }, (_, index) =>
entry("day", `2026-08-${String(index + 1).padStart(2, "0")}`, 1),
);
expect(
home({
day: monthDays,
month: [entry("month", "2026-09", 14), entry("month", "2026-08", 100)],
}),
).toContain("+100% vs prev month to date");
expect(home({ week: [current, previous] })).toContain("-85% vs prev week");
expect(
home({ day: [entry("day", "2026-09-07", 20), entry("day", "2026-09-06", 10)] }),
).toContain("+100% vs prev day");
});
it.each<Period>(["day", "week", "month"])(
"offers Show all only above the visible %s history limit",
(period) => {
const count = period === "day" ? 7 : 12;
const entries = Array.from({ length: count + 1 }, (_, index) =>
entry(
period,
period === "day"
? `2026-08-${String(31 - index).padStart(2, "0")}`
: period === "week"
? `2026-W${String(34 - index).padStart(2, "0")}`
: `${2026 - Math.floor(index / 12)}-${String(12 - (index % 12)).padStart(2, "0")}`,
),
);
expect(home({ [period]: entries.slice(0, count) })).not.toContain(`data-toggle="${period}"`);
const html = home({ [period]: entries });
expect(html).toContain(`Show all ${count + 1}`);
expect(html).toContain(" data-extra hidden");
expect(html).toContain(`href="/reports/${period}/${entries.at(-1)?.key}/"`);
},
);
it("renders metric deltas only against preceding periods and labels in-progress totals", () => {
const report = document();
const html = renderReportPage(ctx, report, null, [
entry("day", "2026-09-08", 100),
entry("day", "2026-09-07", 10),
entry("day", "2026-09-06", 5),
]);
expect(html).toContain("+100% vs prev day (in progress)");
const closed = renderReportPage(ctx, document("day", "2026-09-06"), null, [
entry("day", "2026-09-06", 10),
entry("day", "2026-09-05", 5),
]);
expect(closed).toContain("+100% vs prev day");
expect(closed).not.toContain("(in progress)");
});
it("ranks member distribution by activity and exposes members after the first twelve", () => {
const report = document();
const member = report.members[0]!;
report.members = Array.from({ length: 14 }, (_, index) => ({
...member,
login: `member-${index}`,
display: `Member ${index}`,
github: { ...githubCounts(index + 1), items: [] },
discord: { total: 0, channels: {}, excerpts: [] },
}));
const html = renderReportPage(ctx, report, null, []);
const distribution = html.slice(
html.indexOf("Activity by Member"),
html.indexOf("data-maintainer-filter-root"),
);
expect(distribution.indexOf("member-13")).toBeLessThan(distribution.indexOf("member-12"));
expect(distribution).toContain("Show remaining 2");
});
it("keeps every retained GitHub item accessible beyond the three-item preview", () => {
const report = document();
const member = report.members[0]!;
member.github.items = ["First change", "Second change", "Third change", "Fourth <change>"].map(
(title, index) => ({
kind: "pr_opened",
repo: `example/repo-${index}`,
title,
url: `https://github.com/example/repo-${index}/pull/1`,
atMs: report.generatedAtMs,
actor: member.login,
}),
);
member.github.items[3]!.repo = "example/<hostile-repo>";
member.github.items[3]!.url = "javascript:alert(1)";
const html = renderReportPage(ctx, report, null, []);
expect(html.match(/class="theme"/g)).toHaveLength(3);
const details = html.match(/<details><summary>4 GitHub items<\/summary>(.*?)<\/details>/s)?.[1];
expect(details).toBeDefined();
for (const title of [
"First change",
"Second change",
"Third change",
"Fourth &lt;change&gt;",
]) {
expect(details).toContain(title);
}
expect(details?.match(/PR opened/g)).toHaveLength(4);
expect(details).toContain("example/repo-0");
expect(details).toContain("example/&lt;hostile-repo&gt;");
expect(details).toContain(
'href="https://github.com/example/repo-0/pull/1" target="_blank" rel="noopener"',
);
expect(details).not.toContain("javascript:");
expect(details).not.toContain("<hostile-repo>");
expect(details).not.toContain("Fourth <change>");
});
it("keeps source warning banners bounded and escapes hostile data on every page", () => {
const hostile = 'bad"><img src=x onerror=alert(1)>';
const report = document();
report.orgs = [hostile];
report.sources.github = { ok: false, warnings: [hostile], stats: {} };
report.sources.discord = { ok: true, stale: true, warnings: [hostile], stats: {} };
const member = report.members[0]!;
member.login = hostile;
member.display = hostile;
member.affiliation = hostile;
member.areas = [hostile];
member.github.repos = { [hostile]: 10 };
member.discord.channels = { [hostile]: 10 };
const summary = {
source: "fallback" as const,
generatedAtMs: report.generatedAtMs,
globalSummary: hostile,
highlights: [hostile],
fingerprint: "fixture",
warnings: [hostile, hostile],
};
const person: Person = { github: [hostile], display: hostile, affiliation: hostile };
const days = [personDay(hostile, "2026-09-07", 10), personDay(hostile, "2026-09-05", 5)];
const overview = renderIndexPage(
ctx,
{ day: [entry("day", "2026-09-07")], week: [], month: [] },
{ orgs: [hostile], latest: { report, summary }, health: { running: true, warnings: 3 } },
);
const reportHtml = renderReportPage(ctx, report, summary, []);
for (const html of [
overview,
reportHtml,
renderPeoplePage(ctx, [person], days, "2026-09-07"),
renderPersonPage(ctx, person, days),
]) {
expect(html).not.toContain(hostile);
expect(html).not.toContain("<img src=x");
expect(html).toContain("bad&quot;&gt;&lt;img src=x onerror=alert(1)&gt;");
expect(html.match(/<script\b/g)).toHaveLength(1);
}
expect(overview.match(/<section class="oc-banner oc-banner-warning"/g)).toHaveLength(3);
expect(reportHtml).toContain('data-maintainer-search="bad&quot;');
});
it("groups active, quiet, read-only and archived people with ranked activity buckets", () => {
const people: Person[] = [
{ github: ["quiet"] },
{ github: ["reader"], roleGroup: "readonly" },
{ github: ["archived"], status: "archived" },
{ github: ["active"] },
];
const days = [
personDay("active", "2026-09-07", 20),
personDay("quiet", "2026-09-07", 0),
personDay("reader", "2026-09-07", 3),
personDay("archived", "2026-09-07", 50),
];
const html = renderPeoplePage(ctx, people, days, "2026-09-07");
const grid = html.slice(html.indexOf('class="people-grid"'));
expect(grid.indexOf("/people/active/")).toBeLessThan(grid.indexOf("/people/quiet/"));
expect(grid.indexOf("/people/quiet/")).toBeLessThan(grid.indexOf("/people/reader/"));
expect(grid.indexOf("/people/reader/")).toBeLessThan(grid.indexOf("/people/archived/"));
expect(grid).toContain("level-0");
expect(grid).toContain("level-4");
expect(grid).toContain("is-archived");
expect(html).toContain("28 days");
});
it("keeps closed intraday snapshots visibly incomplete", () => {
const report = document("day", "2026-09-06");
report.status = "partial";
report.generatedAtMs = report.period.untilMs - 3_600_000;
const html = renderReportPage(ctx, report, null, []);
expect(html).toContain("Incomplete snapshot");
expect(html).toContain("The window has since closed, so these totals remain incomplete.");
expect(html).not.toContain("data-day-countdown data-until=");
});
it("shades activity at the site bucket boundaries within the ranking window", () => {
const totals = [0, 1, 4, 5, 14, 15, 39, 40];
const levels = [0, 1, 1, 2, 2, 3, 3, 4];
const days = totals.map((total, index) => ({
...personDay("alice", `2026-09-${String(index + 1).padStart(2, "0")}`, 0),
githubTotal: total,
}));
days.push({ ...personDay("alice", "2026-07-01", 1000) });
const html = renderPeoplePage(ctx, [{ github: ["alice"] }], days, "2026-09-08");
for (const [index, level] of levels.entries()) {
const key = `2026-09-${String(index + 1).padStart(2, "0")}`;
expect(html).toContain(
`class="day-dot level-${level}" title="${key}: ${totals[index]} events"`,
);
}
expect(html).toContain("118 events · 118 GitHub · 0 Discord");
expect(html).toContain("7 out of 8 days active");
});
it("links retained calendar cells to daily reports and leaves missing chart days as gaps", () => {
const html = renderPersonPage(ctx, { github: ["alice"], display: "Alice" }, [
personDay("alice", "2026-09-07", 12),
personDay("alice", "2026-09-05", 6),
]);
expect(html).toMatch(/class="day-cell[^"\n]*"[^>]*href="\/reports\/day\/2026-09-07\//);
expect(html).not.toContain('href="/reports/day/2026-09-06/');
expect(html).toContain("GitHub events · left axis");
expect(html).toContain("Discord messages · right axis");
const paths = [...html.matchAll(/class="person-activity-line[^"]*" d="([^"]*)"/g)];
expect(paths).toHaveLength(2);
for (const path of paths) {
expect(path[1]?.match(/M/g)).toHaveLength(2);
}
expect(html).toContain("Exact daily values");
});
});

View file

@ -1,22 +1,30 @@
/*
* Vendored Carapace v0.6.2 subset: styles/tokens.css, styles/themes.css,
* styles/themes/product.css, styles/typography.css, styles/components.css,
* and styles/candidate/{data,feedback,embed}.css. Theme values include the
* neutral application overrides. Use embed font stacks and the skill's square
* surface/control/inset geometry; no external CSS or fonts under the nonce CSP.
* Vendored Carapace v0.6.2 (MIT) primitives from styles/tokens.css,
* themes.css, themes/product.css, components.css, and candidate/{controls,
* feedback,data,application,embed}.css. Uses the neutral application palette,
* product radii, shadows, and motion with embed system font stacks.
* Page composition follows the maintainer report site's home/report/people
* styles, scoped here so each self-contained page works under the nonce CSP.
*/
const LIGHT_THEME = `
color-scheme: light;
--oc-bg-page: oklch(0.985 0 0);
--oc-bg-surface: oklch(0.97 0 0);
--oc-bg-elevated: oklch(1 0 0);
--oc-bg-recessed: color-mix(in oklch, var(--oc-bg-page) 94%, var(--oc-text-primary));
--oc-text-primary: oklch(0.205 0 0);
--oc-text-secondary: oklch(0.269 0 0);
--oc-text-muted: oklch(0.555 0 0);
--oc-text-link: var(--oc-accent-primary);
--oc-accent-primary: color-mix(in srgb, #c24028 60%, #9c3222 40%);
--oc-accent-primary-hover: color-mix(in srgb, #c24028 30%, #9c3222 70%);
--oc-accent-primary-deep: #9c3222;
--oc-accent-secondary: #14806e;
--oc-accent-secondary-deep: #0f6355;
--oc-text-on-accent: oklch(1 0 0);
--oc-border-accent: rgb(216 74 49 / 0.42);
--oc-status-error-bg: rgb(239 68 68 / 0.1);
--oc-status-error-fg: #b91c1c;
--oc-border-subtle: oklch(0.922 0 0);
--oc-border-strong: color-mix(in oklch, var(--oc-text-primary) 28%, transparent);
--oc-surface-card: oklch(1 0 0);
@ -42,13 +50,20 @@ export const REPORT_STYLES = `
--oc-bg-page: oklch(0.135 0 0);
--oc-bg-surface: oklch(0.178 0 0);
--oc-bg-elevated: oklch(0.205 0 0);
--oc-bg-recessed: color-mix(in oklch, var(--oc-bg-page) 86%, oklch(0 0 0));
--oc-text-primary: oklch(0.985 0 0);
--oc-text-secondary: oklch(0.87 0 0);
--oc-text-muted: oklch(0.716 0 0);
--oc-text-link: var(--oc-accent-primary);
--oc-accent-primary: #f5654a;
--oc-accent-primary-hover: #e05540;
--oc-accent-primary-deep: #b23a28;
--oc-accent-secondary: #4fc8ae;
--oc-accent-secondary-deep: #2fa48d;
--oc-text-on-accent: oklch(0.135 0 0);
--oc-border-accent: rgb(245 101 74 / 0.4);
--oc-status-error-bg: rgb(239 68 68 / 0.12);
--oc-status-error-fg: #f87171;
--oc-border-subtle: oklch(0.269 0 0);
--oc-border-strong: color-mix(in oklch, var(--oc-text-primary) 32%, transparent);
--oc-surface-card: oklch(0.178 0 0 / 0.82);
@ -82,10 +97,21 @@ export const REPORT_STYLES = `
--oc-font-size-xl: 1.25rem;
--oc-font-size-2xl: 1.5rem;
--oc-font-size-3xl: 2rem;
--oc-radius-surface: 0;
--oc-radius-control: 0;
--oc-radius-inset: 0;
--oc-radius-surface: 0.5rem;
--oc-radius-control: 0.5rem;
--oc-radius-inset: 0.25rem;
--oc-radius-round: 999px;
--oc-radius-full: 999px;
--oc-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.12);
--oc-shadow-md: 0 8px 24px -6px rgb(0 0 0 / 0.28);
--oc-shadow-lg: 0 24px 48px -12px rgb(0 0 0 / 0.42);
--oc-duration-fast: 160ms;
--oc-duration-ui: 200ms;
--oc-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--oc-control-min-height: 2.75rem;
--oc-content-max: 75rem;
--oc-input-bg: var(--oc-bg-elevated);
--oc-input-border: var(--oc-border-subtle);
--oc-font-embed-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--oc-font-embed-mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--oc-font-body: var(--oc-font-embed-sans);
@ -97,113 +123,483 @@ export const REPORT_STYLES = `
}
html[data-theme="light"] {${LIGHT_THEME}}
* { box-sizing: border-box; }
body { margin: 0; font: var(--oc-font-size-base)/1.6 var(--oc-font-body); }
::selection { background: var(--oc-selection-bg); }
a { color: var(--oc-text-link); overflow-wrap: anywhere; }
a:hover { color: var(--oc-accent-primary-hover); }
:where(a, summary, [tabindex]):focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 3px; }
h1, h2, h3, p { margin: 0; }
h3 { font-size: var(--oc-font-size-md); line-height: 1.4; }
p + p { margin-top: var(--oc-space-2); }
ul { padding-left: var(--oc-space-5); }
li { overflow-wrap: anywhere; }
small, .muted { color: var(--oc-text-muted); }
.details { color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); }
.oc-app-surface { min-width: 0; min-height: 100%; background: var(--oc-bg-page); color: var(--oc-text-primary); font-family: var(--oc-font-body); }
.report-toolbar { border-bottom: 1px solid var(--oc-border-subtle); background: var(--oc-bg-surface); }
.report-toolbar-inner, main, footer { max-width: 75rem; margin: auto; padding: var(--oc-space-5) var(--oc-space-6); }
.report-toolbar-inner { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--oc-space-2); padding-block: var(--oc-space-2); }
nav, .actions, .identity-line, .pills { display: flex; flex-wrap: wrap; align-items: center; gap: var(--oc-space-2); }
.brand { margin-right: var(--oc-space-4); color: var(--oc-text-primary); font-weight: 750; text-decoration: none; }
main { display: grid; gap: var(--oc-space-6); overflow-wrap: anywhere; }
footer { color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); border-top: 1px solid var(--oc-border-subtle); }
.oc-section { width: 100%; min-width: 0; color: var(--oc-text-primary); }
.oc-section-header { display: flex; align-items: end; justify-content: space-between; gap: var(--oc-space-5); margin-bottom: var(--oc-space-4); }
.oc-app-surface { --oc-component-surface: var(--oc-surface-card); --oc-component-surface-strong: var(--oc-surface-card-strong); --oc-component-surface-hover: var(--oc-surface-interactive); --oc-component-border: var(--oc-border-subtle); --oc-component-border-hover: color-mix( in srgb, var(--oc-text-primary) 28%, var(--oc-border-subtle) ); --oc-component-shadow: var(--oc-shadow-sm); --oc-component-shadow-hover: var(--oc-shadow-md); min-width: 0; min-height: 100%; background: var(--oc-bg-page); color: var(--oc-text-primary); font-family: var(--oc-font-body); }
.oc-section { width: 100%; color: var(--oc-text-primary); }
.oc-section-header { display: flex; align-items: end; justify-content: space-between; gap: var(--oc-space-5); }
.oc-section-header > * { min-width: 0; }
.oc-section-heading { display: grid; gap: var(--oc-space-2); }
.oc-eyebrow { color: var(--oc-accent-primary); font: 700 var(--oc-font-size-xs)/1.4 var(--oc-font-mono); text-transform: uppercase; }
.oc-section-title { color: var(--oc-text-primary); font-family: var(--oc-font-display); font-size: var(--oc-font-size-2xl); font-weight: 750; line-height: 1.2; overflow-wrap: anywhere; }
h2.oc-section-title { font-size: var(--oc-font-size-lg); }
.oc-section-copy { max-width: 60ch; color: var(--oc-text-secondary); overflow-wrap: anywhere; }
.oc-card { padding: var(--oc-space-4); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-card); color: var(--oc-text-primary); }
.stats, .history-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--oc-space-3); }
.stat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--oc-space-2); margin-bottom: var(--oc-space-2); }
.stat-value { display: block; font-size: var(--oc-font-size-2xl); font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.4; }
.stat-date { font: 650 var(--oc-font-size-xl)/1.5 var(--oc-font-mono); }
.oc-action { display: inline-flex; min-height: 2.5rem; align-items: center; justify-content: center; gap: var(--oc-space-2); padding: var(--oc-space-2) var(--oc-space-3); border: 1px solid transparent; border-radius: var(--oc-radius-control); font-size: var(--oc-font-size-base); font-weight: 650; line-height: 1.2; text-decoration: none; touch-action: manipulation; }
.oc-action-ghost { background: transparent; color: var(--oc-text-secondary); }
.oc-action-ghost:hover { background: var(--oc-surface-interactive-hover); color: var(--oc-text-primary); }
.oc-pill { display: inline-flex; max-width: 100%; min-width: 0; min-height: 1.75rem; align-items: center; padding: var(--oc-space-1) var(--oc-space-3); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-control); background: var(--oc-surface-card); color: var(--oc-text-secondary); font-size: var(--oc-font-size-xs); font-weight: 650; line-height: 1.2; overflow-wrap: anywhere; }
.oc-badge { display: inline-flex; max-width: 100%; min-width: 0; min-height: 1.5rem; align-items: center; gap: var(--oc-space-2); padding: var(--oc-space-1) var(--oc-space-2); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-control); color: var(--oc-text-secondary); background: var(--oc-bg-elevated); font-size: var(--oc-font-size-xs); font-weight: 650; line-height: 1; white-space: nowrap; vertical-align: middle; }
.oc-eyebrow { margin: 0; color: var(--oc-accent-primary); font-family: var(--oc-font-mono); font-size: var(--oc-font-size-xs); font-weight: 700; line-height: 1.4; letter-spacing: 0; text-transform: uppercase; }
.oc-card { border: 1px solid var(--oc-component-border); border-radius: var(--oc-radius-surface); background: var(--oc-component-surface); color: var(--oc-text-primary); box-shadow: var(--oc-component-shadow); }
.oc-card-interactive { transition: background var(--oc-duration-ui) var(--oc-ease-out), border-color var(--oc-duration-ui) var(--oc-ease-out), box-shadow var(--oc-duration-ui) var(--oc-ease-out), transform var(--oc-duration-ui) var(--oc-ease-out); }
.oc-card-interactive:hover, .oc-card-interactive:focus-visible { border-color: var(--oc-component-border-hover); background: var(--oc-component-surface-hover); box-shadow: var(--oc-component-shadow-hover); transform: translateY(-1px); }
.oc-card-interactive:focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 3px; }
.oc-card-interactive:active { transform: translateY(0) scale(0.995); }
.oc-action { display: inline-flex; min-height: 2.5rem; align-items: center; justify-content: center; gap: var(--oc-space-2); padding: var(--oc-space-2) var(--oc-space-4); border: 1px solid transparent; border-radius: var(--oc-radius-control); font: inherit; font-size: var(--oc-font-size-base); font-weight: 700; line-height: 1.2; text-decoration: none; touch-action: manipulation; cursor: pointer; transition: background var(--oc-duration-fast) var(--oc-ease-out), border-color var(--oc-duration-fast) var(--oc-ease-out), color var(--oc-duration-fast) var(--oc-ease-out), transform var(--oc-duration-fast) var(--oc-ease-out); }
.oc-action:hover { text-decoration: none; }
.oc-action:focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 3px; }
.oc-action:active:not(:disabled):not([aria-disabled="true"]) { transform: scale(0.98); }
.oc-action:disabled, .oc-action[aria-disabled="true"] { cursor: not-allowed; opacity: 0.5; }
.oc-action[aria-disabled="true"] { pointer-events: none; }
.oc-action-ghost { border-color: transparent; background: transparent; color: var(--oc-text-secondary); }
.oc-action-ghost:hover { background: var(--oc-surface-interactive); color: var(--oc-text-primary); }
.oc-action-icon { width: 2.5rem; padding: 0; }
.oc-segmented { display: inline-flex; max-width: 100%; align-items: center; gap: var(--oc-space-1); overflow-x: auto; padding: var(--oc-space-1); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-control); background: var(--oc-surface-card); }
.oc-segmented-item { display: inline-flex; min-height: 2rem; align-items: center; justify-content: center; gap: var(--oc-space-2); padding: var(--oc-space-1) var(--oc-space-3); border: 0; border-radius: var(--oc-radius-inset); background: transparent; color: var(--oc-text-secondary); font: inherit; font-size: var(--oc-font-size-base); font-weight: 650; line-height: 1; white-space: nowrap; touch-action: manipulation; cursor: pointer; transition: background var(--oc-duration-fast) var(--oc-ease-out), color var(--oc-duration-fast) var(--oc-ease-out), transform var(--oc-duration-fast) var(--oc-ease-out); }
.oc-segmented-item:active:not(:disabled) { transform: scale(0.98); }
.oc-segmented-item:disabled { cursor: not-allowed; opacity: 0.5; }
.oc-segmented-item:hover { color: var(--oc-text-primary); }
.oc-segmented-item[aria-selected="true"], .oc-segmented-item[aria-pressed="true"], .oc-segmented-item.is-active { background: var(--oc-surface-interactive-hover); color: var(--oc-text-primary); }
.oc-segmented-item:focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.oc-card-interactive { transition: none; }
.oc-card-interactive:hover, .oc-card-interactive:focus-visible, .oc-card-interactive:active { transform: none; }
}
@media (max-width: 42rem) {
.oc-section-header { align-items: start; flex-direction: column; }
}
.oc-badge { display: inline-flex; box-sizing: border-box; max-width: 100%; min-width: 0; min-height: 1.5rem; align-items: center; gap: var(--oc-space-2); padding: var(--oc-space-1) var(--oc-space-2); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-round); background: var(--oc-surface-secondary-soft); color: var(--oc-text-secondary); font-size: var(--oc-font-size-xs); font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oc-badge::before { width: 0.375rem; height: 0.375rem; flex: 0 0 auto; border-radius: var(--oc-radius-round); background: currentColor; content: ""; }
.oc-badge-neutral::before { display: none; }
.oc-badge-success { border-color: var(--oc-status-success-fg); background: var(--oc-status-success-bg); color: var(--oc-status-success-fg); }
.oc-badge-warning { border-color: var(--oc-status-warning-fg); background: var(--oc-status-warning-bg); color: var(--oc-status-warning-fg); }
.notices { display: grid; gap: var(--oc-space-3); }
.oc-banner { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: var(--oc-space-3); padding: var(--oc-space-4); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-card-strong); color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); line-height: 1.45; }
.oc-badge-info { border-color: var(--oc-status-info-fg); background: var(--oc-status-info-bg); color: var(--oc-status-info-fg); }
.oc-banner { display: grid; box-sizing: border-box; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: var(--oc-space-3); padding: var(--oc-space-4); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-card-strong); color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); line-height: 1.45; }
.oc-banner-indicator { width: 0.375rem; height: 0.375rem; margin-top: 0.45em; border-radius: var(--oc-radius-round); background: var(--oc-text-muted); }
.oc-banner-content { display: grid; min-width: 0; gap: var(--oc-space-1); }
.oc-banner-title { color: var(--oc-text-primary); font-weight: 650; }
.oc-banner p { margin: 0; overflow-wrap: anywhere; }
.oc-banner-warning .oc-banner-indicator { background: var(--oc-status-warning-fg); }
.oc-banner-info .oc-banner-indicator { background: var(--oc-status-info-fg); }
.oc-banner-content { display: grid; min-width: 0; gap: var(--oc-space-1); overflow-wrap: anywhere; }
.oc-banner-title { color: var(--oc-text-primary); font-size: inherit; font-weight: 650; }
.oc-banner ul { margin: 0; }
.oc-table-wrap { width: 100%; overflow-x: auto; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); overscroll-behavior-inline: contain; scrollbar-gutter: stable; }
.oc-table { width: 100%; min-width: 36rem; border-collapse: collapse; background: var(--oc-surface-card); color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); }
.oc-table :is(th, td) { padding: var(--oc-space-3) var(--oc-space-4); border-bottom: 1px solid var(--oc-border-subtle); text-align: left; }
.oc-table th { color: var(--oc-text-muted); font-family: var(--oc-font-mono); font-size: var(--oc-font-size-xs); font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; }
.oc-table tbody tr:last-child td { border-bottom: 0; }
.oc-table tbody tr:focus-within, .oc-table tbody tr:hover { background: var(--oc-surface-interactive); }
.oc-table td:first-child { color: var(--oc-text-primary); font-weight: 650; }
.oc-table .number { text-align: right; font-variant-numeric: tabular-nums; }
.oc-resource-list { width: 100%; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-card); list-style: none; }
.oc-resource-list-item { margin: 0; }
.oc-resource-list-item + .oc-resource-list-item { border-top: 1px solid var(--oc-border-subtle); }
.oc-resource-list-link, .resource-row { display: flex; align-items: center; justify-content: space-between; gap: var(--oc-space-3); padding: var(--oc-space-2) var(--oc-space-3); }
.oc-resource-list-link { color: inherit; text-decoration: none; }
.oc-resource-list-link:hover { background: var(--oc-surface-interactive); color: var(--oc-text-primary); }
.oc-resource-list-link:focus-visible { outline-offset: -2px; }
.oc-resource-list-title { color: var(--oc-text-primary); font-size: var(--oc-font-size-sm); overflow-wrap: anywhere; }
.oc-resource-list-meta { color: var(--oc-text-muted); font-size: var(--oc-font-size-sm); }
.history-columns h3 { margin-bottom: var(--oc-space-3); }
.member-list { margin: 0; padding: 0; list-style: none; }
.member { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--oc-space-3); padding: var(--oc-space-4) 0; border-top: 1px solid var(--oc-border-subtle); }
.member-content { display: grid; gap: var(--oc-space-2); min-width: 0; overflow-wrap: anywhere; }
.member-content p { margin: 0; }
.member-items .resource-row { display: block; }
.oc-empty { display: grid; box-sizing: border-box; min-height: 12rem; place-items: center; padding: var(--oc-space-5); text-align: center; }
.oc-empty-description { max-width: 42ch; margin: 0; color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); line-height: 1.5; overflow-wrap: anywhere; text-wrap: pretty; }
@media (max-width: 42rem) {
.oc-banner, .oc-banner:has(.oc-banner-action):has(.oc-banner-dismiss) { grid-template-columns: auto minmax(0, 1fr) auto; }
.oc-banner > :is(button, a):not(.oc-banner-dismiss) { grid-column: 2 / -1; grid-row: 2; justify-self: start; margin-left: 0; }
}
.oc-sparkline { display: block; width: 100%; max-width: 8.5rem; height: 1.5rem; }
.oc-sparkline-line { fill: none; stroke: var(--oc-text-muted); stroke-width: 1.5; stroke-linecap: butt; stroke-linejoin: miter; vector-effect: non-scaling-stroke; }
.oc-sparkline[data-tone="accent"] .oc-sparkline-line { stroke: var(--oc-accent-primary); }
.oc-sparkline-endpoint { fill: var(--oc-accent-primary); stroke: none; }
.oc-delta { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); font-variant-numeric: tabular-nums; white-space: nowrap; }
.oc-delta-arrow { font-size: 0.625rem; line-height: 1; }
.oc-delta[data-tone="positive"] { color: var(--oc-status-success-fg); }
.oc-delta[data-tone="negative"] { color: var(--oc-status-error-fg); }
.oc-delta:not([data-tone])[data-direction="up"] .oc-delta-arrow { color: var(--oc-status-success-fg); }
.oc-delta:not([data-tone])[data-direction="down"] .oc-delta-arrow { color: var(--oc-status-error-fg); }
.oc-sparkline[data-size="lg"] { max-width: none; height: 3rem; }
.oc-split { display: block; overflow: hidden; width: 100%; height: 0.5rem; border-radius: 2px; }
.oc-split-segment { fill: var(--oc-chart-color, var(--oc-accent-primary)); }
.oc-split-secondary { --oc-chart-color: var(--oc-accent-secondary); }
.oc-split-error { --oc-chart-color: var(--oc-status-error-fg); }
.oc-split-muted { --oc-chart-color: var(--oc-border-strong); }
.oc-split-legend { display: flex; flex-wrap: wrap; gap: var(--oc-space-1) var(--oc-space-3); margin: 0; padding: 0; color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); list-style: none; }
.oc-split-legend li { display: inline-flex; align-items: center; gap: var(--oc-space-1); }
.oc-split-key { width: 0.5rem; height: 0.5rem; border-radius: 2px; background: var(--oc-chart-color, var(--oc-accent-primary)); }
.oc-switch { position: relative; box-sizing: border-box; width: 2.25rem; height: 1.25rem; flex: 0 0 auto; appearance: none; border: 1px solid var(--oc-input-border); border-radius: var(--oc-radius-round); margin: 0; background: var(--oc-input-bg); cursor: inherit; touch-action: manipulation; transition: background var(--oc-duration-fast) var(--oc-ease-out), border-color var(--oc-duration-fast) var(--oc-ease-out); }
.oc-switch::before { position: absolute; top: 0.1875rem; left: 0.1875rem; width: 0.75rem; height: 0.75rem; border-radius: var(--oc-radius-round); background: var(--oc-text-muted); content: ""; transition: background var(--oc-duration-fast) var(--oc-ease-out), transform var(--oc-duration-fast) var(--oc-ease-out); }
.oc-switch:hover:not(:disabled) { border-color: var(--oc-border-strong); }
.oc-switch:checked { border-color: var(--oc-accent-primary); background: var(--oc-accent-primary); }
.oc-switch:checked::before { background: var(--oc-text-on-accent); transform: translateX(1rem); }
.oc-switch:focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 2px; }
@media (forced-colors: active) {
.oc-switch { appearance: auto; forced-color-adjust: auto; }
.oc-switch::before { display: none; }
}
.oc-summary-strip { display: grid; gap: 1px; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-border-subtle); grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); overflow: hidden; }
.oc-summary-metric { display: flex; align-items: center; gap: var(--oc-space-2); padding: var(--oc-space-2) var(--oc-space-3); background: var(--oc-bg-surface); }
.oc-summary-metric-copy { display: grid; min-width: 0; line-height: 1.3; }
.oc-summary-metric-copy strong { font-size: var(--oc-font-size-md); font-variant-numeric: tabular-nums; }
.oc-summary-metric-copy small { overflow: hidden; color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); text-overflow: ellipsis; white-space: nowrap; }
.oc-brand-banner { position: relative; display: grid; min-height: 16rem; align-content: end; overflow: hidden; padding: var(--oc-space-6); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-bg-surface); }
.oc-brand-banner-art { position: absolute; z-index: 0; inset: 0; overflow: hidden; pointer-events: none; }
.oc-brand-banner-art img { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: cover; opacity: 0.82; }
.oc-brand-banner[data-anchor="top"] .oc-brand-banner-art img { object-position: 50% 0%; }
.oc-brand-banner[data-effect="fade"][data-anchor="top"] .oc-brand-banner-art img { -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 36%, transparent 78%); mask-image: linear-gradient(to bottom, #000 0%, #000 36%, transparent 78%); }
.oc-brand-banner-content { position: relative; z-index: 1; display: grid; max-width: 36rem; gap: var(--oc-space-2); }
.oc-brand-banner-content h3 { margin: 0; font-size: var(--oc-font-size-2xl); }
.oc-brand-banner-content p { margin: 0; color: var(--oc-text-secondary); }
* { box-sizing: border-box; }
body { margin: 0; color: var(--oc-text-secondary); background: var(--oc-bg-page); -webkit-font-smoothing: antialiased; }
main { width: min(var(--oc-content-max), calc(100vw - 36px)); margin: 0 auto; padding: var(--oc-space-6) 0 var(--oc-space-8); }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--oc-text-primary); text-decoration: underline; text-underline-offset: 3px; }
@media (max-width: 760px) {
main { width: min(100vw - 24px, var(--oc-content-max)); padding-top: var(--oc-space-5); }
}
:root { --activity-0-bg: var(--oc-bg-elevated); --activity-0-border: var(--oc-border-subtle); --activity-1-bg: color-mix(in srgb, var(--oc-accent-primary) 16%, var(--oc-bg-elevated)); --activity-1-border: color-mix(in srgb, var(--oc-accent-primary) 30%, var(--oc-border-subtle)); --activity-2-bg: color-mix(in srgb, var(--oc-accent-primary) 34%, var(--oc-bg-elevated)); --activity-2-border: color-mix(in srgb, var(--oc-accent-primary) 50%, var(--oc-border-subtle)); --activity-3-bg: color-mix(in srgb, var(--oc-accent-primary) 68%, var(--oc-bg-elevated)); --activity-3-border: var(--oc-accent-primary-hover); --activity-4-bg: var(--oc-accent-primary); --activity-4-border: var(--oc-accent-primary-deep); --activity-3-text: var(--oc-text-primary); --activity-4-text: var(--oc-text-on-accent); font-family: var(--oc-font-body); }
.open-period-status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; color: var(--oc-text-muted); font-family: var(--oc-font-mono); font-size: 0.75rem; line-height: 1.4; }
.open-period-status time { color: inherit; }
.open-period-status [data-day-countdown] { color: var(--oc-text-secondary); }
.partial-report-badge { min-height: 1.25rem; padding: 0.2rem 0.4rem; font-family: var(--oc-font-body); font-size: 0.6875rem; }
.site-nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--oc-border-subtle); background: var(--oc-bg-page); }
.site-nav[data-scrolled="true"] { box-shadow: var(--oc-shadow-md, 0 6px 16px rgb(0 0 0 / 0.28)); }
.site-nav-inner { width: min(var(--oc-content-max), calc(100vw - 36px)); min-height: 58px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: var(--oc-space-5); }
.site-brand { display: inline-flex; align-items: center; gap: var(--oc-space-3); color: var(--oc-text-primary); font: 750 15px/1 var(--oc-font-display); }
.site-brand:hover { text-decoration: none; color: var(--oc-text-primary); }
.brand-mark { display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 26px; }
.brand-mark img { display: block; width: 100%; height: 100%; object-fit: contain; }
.site-links { display: flex; align-items: center; justify-content: flex-end; gap: var(--oc-space-1); overflow-x: auto; white-space: nowrap; }
.site-links a { min-height: 32px; padding: var(--oc-space-2) var(--oc-space-3); color: var(--oc-text-muted); font: 650 13px/1 var(--oc-font-body); }
.site-links a:hover { color: var(--oc-text-primary); text-decoration: none; }
.site-links a.is-active { color: var(--oc-text-primary); background: var(--oc-surface-interactive-hover); }
.theme-toggle { flex: 0 0 auto; width: 34px; min-height: 34px; height: 34px; padding: 0; color: var(--oc-text-primary); cursor: pointer; }
.theme-toggle svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; display: block; }
@media (max-width: 760px) {
.site-nav-inner { width: min(100vw - 24px, var(--oc-content-max)); height: auto; min-height: 58px; padding: var(--oc-space-3) 0; align-items: flex-start; flex-direction: column; }
.site-links { width: 100%; justify-content: flex-start; padding-bottom: 2px; }
}
body { font: var(--oc-font-size-base)/1.5 var(--oc-font-body); }
::selection { background: var(--oc-selection-bg); }
:where(a, button, input, summary, [tabindex]):focus-visible { outline: 2px solid var(--oc-focus-ring); outline-offset: 3px; }
[hidden] { display: none !important; }
:root:not([data-js]) .js-only, :root:not([data-js]) [data-toggle], :root:not([data-js]) [data-theme-toggle] { display: none !important; }
:root:not([data-js]) [data-extra][hidden] { display: grid !important; }
:root[data-people-hide-inactive="true"] [data-inactive="true"] { display: none !important; }
.theme-toggle .theme-moon { display: none; }
:root[data-theme="light"] .theme-toggle .theme-sun { display: none; }
:root[data-theme="light"] .theme-toggle .theme-moon { display: block; }
h1, h2, h3, p { margin: 0; }
h3 { color: var(--oc-text-primary); font-size: var(--oc-font-size-lg); line-height: 1.4; }
a, li, p { overflow-wrap: anywhere; }
small, .muted { color: var(--oc-text-muted); }
.details { color: var(--oc-text-secondary); font-size: var(--oc-font-size-sm); }
.actions, .identity-line, .pills { display: flex; flex-wrap: wrap; align-items: center; gap: var(--oc-space-2); }
.person-identity { display: flex; align-items: center; gap: var(--oc-space-3); min-width: 0; }
.oc-banner-content { overflow-wrap: anywhere; }
.oc-banner-content > .oc-badge { justify-self: start; }
.oc-banner ul { margin: 0; padding-left: var(--oc-space-5); }
.oc-section { min-width: 0; }
.oc-section-header { margin-bottom: var(--oc-space-4); }
main > .oc-section { margin-block: var(--oc-space-7); }
main > .oc-section:first-child { margin-top: 0; }
footer { width: min(var(--oc-content-max), calc(100vw - 36px)); margin: 28px auto 0; padding: 14px 0 var(--oc-space-5); color: var(--oc-text-muted); font-size: 12px; line-height: 1.5; border-top: 1px solid var(--oc-border-strong); }
.oc-action-icon svg { display: block; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
summary { width: fit-content; max-width: 100%; color: var(--oc-text-secondary); cursor: pointer; font-size: var(--oc-font-size-sm); }
details[open] > summary { margin-bottom: var(--oc-space-2); }
blockquote { border-left: 2px solid var(--oc-border-strong); padding: var(--oc-space-2) var(--oc-space-4); margin: var(--oc-space-2) 0; overflow-wrap: anywhere; }
.person-identity { display: flex; align-items: center; gap: var(--oc-space-3); min-width: 0; }
.person-heading { display: grid; gap: var(--oc-space-2); min-width: 0; }
.oc-avatar { position: relative; display: inline-grid; flex: 0 0 auto; place-items: center; overflow: hidden; border-radius: var(--oc-radius-round); background: var(--oc-surface-secondary-soft); color: var(--oc-accent-secondary); font-weight: 650; line-height: 1; }
.oc-table :is(th, td) { padding: var(--oc-space-3) var(--oc-space-4); border-bottom: 1px solid var(--oc-border-subtle); text-align: left; }
.oc-resource-list { width: 100%; margin: 0; padding: 0; overflow: hidden; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-card); list-style: none; }
.oc-resource-list-item + .oc-resource-list-item { border-top: 1px solid var(--oc-border-subtle); }
.oc-resource-list-link, .resource-row { display: flex; align-items: center; justify-content: space-between; gap: var(--oc-space-3); padding: var(--oc-space-2) var(--oc-space-3); }
.oc-avatar { position: relative; display: inline-grid; flex: 0 0 auto; place-items: center; overflow: hidden; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-surface-secondary-soft); color: var(--oc-accent-secondary); font-weight: 650; line-height: 1; }
.oc-avatar::before { content: attr(data-initials); }
/* Transparent failed images leave the CSS initials beneath them visible. */
.oc-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; color: transparent; font-size: 0; }
.oc-avatar-xs { width: 20px; height: 20px; font-size: var(--oc-font-size-xs); }
.oc-avatar-md { width: 40px; height: 40px; font-size: var(--oc-font-size-sm); }
.oc-avatar-sm { width: 36px; height: 36px; font-size: var(--oc-font-size-sm); }
.oc-avatar-xl { width: 72px; height: 72px; font-size: var(--oc-font-size-2xl); }
.chart { padding-block: var(--oc-space-3); border-block: 1px solid var(--oc-border-subtle); }
.chart .oc-sparkline { display: block; width: 100%; height: auto; }
.oc-sparkline-line { fill: none; stroke-width: 1.5; stroke-linecap: butt; stroke-linejoin: miter; vector-effect: non-scaling-stroke; }
.github-line { stroke: var(--oc-accent-primary); }
.discord-line { stroke: var(--oc-accent-secondary); }
.chart-axis { stroke: var(--oc-chart-line); stroke-width: 1; }
.chart-label { fill: var(--oc-text-muted); font: var(--oc-font-size-xs) var(--oc-font-mono); }
.legend { display: flex; flex-wrap: wrap; gap: var(--oc-space-4); font-size: var(--oc-font-size-xs); }
.github-key { color: var(--oc-accent-primary); background: var(--oc-surface-accent-soft); }
.discord-key { color: var(--oc-accent-secondary); background: var(--oc-surface-secondary-soft); }
.legend span { padding-inline: var(--oc-space-2); border-radius: var(--oc-radius-inset); }
.prose { overflow-wrap: anywhere; }
.prose h3 { margin-top: var(--oc-space-4); }
.prose .notices { margin-bottom: var(--oc-space-3); }
@media (max-width: 42rem) {
.report-toolbar-inner, main, footer { padding-inline: var(--oc-space-4); }
.report-toolbar-inner, .oc-section-header { align-items: start; flex-direction: column; }
.stats, .history-columns { grid-template-columns: 1fr; }
.oc-section-header { gap: var(--oc-space-3); }
.brand { width: 100%; }
.report-toolbar nav { gap: var(--oc-space-1); }
.report-toolbar .oc-action { padding-inline: var(--oc-space-2); }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }
@media (max-width: 760px) { footer { width: calc(100vw - 24px); } }
body[data-report-page="home"] main { width:min(var(--oc-content-max),calc(100vw - 36px)); margin:0 auto; padding:var(--oc-space-6) 0 var(--oc-space-8); }
body[data-report-page="home"] header { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:var(--oc-space-6); align-items:end; border-bottom:1px solid var(--oc-border-subtle); padding-bottom:var(--oc-space-5); margin-bottom:var(--oc-space-4); }
body[data-report-page="home"] h1 { margin:0; color:var(--oc-text-primary); font:740 34px/1.05 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; letter-spacing:0; }
body[data-report-page="home"] h2 { margin:4px 0 16px; color:var(--oc-text-primary); font:740 22px/1.2 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; letter-spacing:0; }
body[data-report-page="home"] .subtitle, body[data-report-page="home"] .meta { color:var(--oc-text-muted); line-height:1.45; }
body[data-report-page="home"] .grid { display:grid; gap:var(--oc-space-7); }
body[data-report-page="home"] .quick-card { display:grid; align-content:start; gap:var(--oc-space-1); min-height:104px; padding:var(--oc-space-4); }
body[data-report-page="home"] .quick-card.partial { border-color:var(--oc-border-accent); }
body[data-report-page="home"] .quick-card:hover { text-decoration:none; border-color:var(--oc-component-border-hover); background:var(--oc-surface-interactive); }
body[data-report-page="home"] .quick-title { display:block; margin-top:4px; font-size:20px; font-weight:800; line-height:1.15; }
body[data-report-page="home"] .quick-meta { display:block; margin-top:2px; text-wrap:balance; color:var(--oc-text-muted); font-size:13px; line-height:1.35; }
body[data-report-page="home"] .panel { padding:0; }
body[data-report-page="home"] .toggle { appearance:none; border-radius:var(--oc-radius-control); font-family:var(--oc-font-mono); cursor:pointer; }
body[data-report-page="home"] .toggle:hover { background:var(--oc-surface-accent-soft); }
body[data-report-page="home"] .row { display:grid; grid-template-columns:minmax(0,1fr) auto 15.5rem; gap:16px; align-items:center; padding:12px 0; border-top:1px solid var(--oc-border-subtle); }
body[data-report-page="home"] .row-trend { display:block; }
body[data-report-page="home"] .row-trend .oc-sparkline { width:6.5rem; }
body[data-report-page="home"] .row:first-child { border-top:0; }
body[data-report-page="home"] .row[hidden] { display:none; }
body[data-report-page="home"] .title { color:var(--oc-text-primary); font-size:17px; font-weight:800; }
body[data-report-page="home"] .date { color:var(--oc-text-muted); font-size:14px; line-height:1.35; }
body[data-report-page="home"] .row-title-line { display:inline-flex; flex-wrap:wrap; align-items:center; gap:var(--oc-space-2); }
body[data-report-page="home"] .stats { color:var(--oc-text-muted); font-size:13px; text-align:right; }
body[data-report-page="home"] .stats strong { color:var(--oc-text-primary); }
body[data-report-page="home"] .home-banner { margin: 0; }
body[data-report-page="home"] .home-banner .oc-brand-banner-content h1 { margin: 0; color: var(--oc-text-primary); font: 740 34px/1.05 var(--oc-font-display); }
body[data-report-page="home"] .home-banner .oc-brand-banner-content p:last-child { color: var(--oc-text-secondary); }
body[data-report-page="home"] .home-banner-stamp { position: absolute; top: var(--oc-space-4); right: var(--oc-space-4); z-index: 1; padding: var(--oc-space-1) var(--oc-space-3); border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-full, 999px); background: color-mix(in srgb, var(--oc-bg-page) 72%, transparent); backdrop-filter: blur(6px); color: var(--oc-text-muted); font-family: var(--oc-font-mono); font-size: 11px; line-height: 1.6; text-transform: lowercase; letter-spacing: 0.02em; }
body[data-report-page="home"] .home-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; margin-bottom: var(--oc-space-7); overflow: hidden; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-border-subtle); }
body[data-report-page="home"] .home-grid > * { border: 0; border-radius: 0; background: var(--oc-bg-surface); }
body[data-report-page="home"] .home-grid > .home-banner { grid-column: 1 / -1; }
body[data-report-page="home"] .home-grid > .oc-banner { grid-column: 1 / -1; }
body[data-report-page="home"] .home-grid > .quick-card { grid-column: span 2; min-height: 0; }
body[data-report-page="home"] .home-grid > .home-card { grid-column: span 3; }
body[data-report-page="home"] .home-grid .oc-summary-strip { background: transparent; }
body[data-report-page="home"] .home-grid .oc-summary-strip { grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr)); }
body[data-report-page="home"] .home-grid .oc-summary-strip { border: 0; border-radius: 0; margin-top: auto; }
body[data-report-page="home"] .home-grid .oc-summary-metric { padding-inline: 0; }
body[data-report-page="home"] .home-grid .oc-summary-metric-copy small:last-child { overflow: visible; white-space: normal; }
body[data-report-page="home"] .home-grid > .home-card { display: flex; flex-direction: column; gap: var(--oc-space-3); padding: var(--oc-space-4); }
body[data-report-page="home"] .panel-link { min-height: 2rem; padding: var(--oc-space-1) var(--oc-space-3); border-color: var(--oc-border-subtle); color: var(--oc-text-secondary); font-size: var(--oc-font-size-xs); font-weight: 600; }
body[data-report-page="home"] .panel-link:hover { border-color: var(--oc-border-strong); background: var(--oc-surface-interactive-hover); color: var(--oc-text-primary); text-decoration: none; }
body[data-report-page="home"] .panel-link span { transition: transform var(--oc-duration-fast, 140ms) var(--oc-ease-out, ease); }
body[data-report-page="home"] .panel-link:hover span { transform: translateX(2px); }
body[data-report-page="home"] .home-dateline { display: grid; grid-column: 1 / -1; gap: var(--oc-space-2); padding: var(--oc-space-4); }
body[data-report-page="home"] .home-dateline-scale { display: flex; justify-content: space-between; color: var(--oc-text-muted); font-family: var(--oc-font-mono); font-size: 11px; }
body[data-report-page="home"] .quick-trend { display: grid; gap: var(--oc-space-1); margin-top: var(--oc-space-2); }
body[data-report-page="home"] .quick-trend .oc-sparkline { max-width: none; }
body[data-report-page="home"] .home-card-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; }
body[data-report-page="home"] .home-card-top h2 { margin:4px 0 0; font-size:18px; }
body[data-report-page="home"] .home-card-top p { margin:4px 0 0; color:var(--oc-text-muted); font-size:13px; line-height:1.45; }
body[data-report-page="home"] .home-actions-row { flex:0 0 auto; display:flex; justify-content:flex-end; }
@media (max-width: 860px) {
body[data-report-page="home"] .home-grid { grid-template-columns: minmax(0, 1fr); }
body[data-report-page="home"] .home-grid > .quick-card, body[data-report-page="home"] .home-grid > .home-card { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
body[data-report-page="home"] main { width:min(100vw - 24px,1120px); padding-top:24px; }
body[data-report-page="home"] h1 { font-size:30px; }
body[data-report-page="home"] header { grid-template-columns:1fr; }
body[data-report-page="home"] .quick { grid-template-columns:1fr; }
body[data-report-page="home"] .home-banner-stamp { position: static; margin-bottom: var(--oc-space-3); width: fit-content; }
body[data-report-page="home"] .home-banner .oc-brand-banner-content h1 { font-size: 28px; }
body[data-report-page="home"] .home-card-top { flex-direction:column; }
body[data-report-page="home"] .home-actions-row { justify-content:flex-start; }
body[data-report-page="home"] .row { grid-template-columns:1fr; gap:6px; }
body[data-report-page="home"] .row-trend { display:none; }
body[data-report-page="home"] .stats { text-align:left; }
}
body[data-report-page="report"] .oc-summary-strip .oc-summary-metric { align-items: flex-start; }
body[data-report-page="report"] .oc-summary-strip .oc-summary-metric-copy small { overflow: visible; white-space: normal; }
body[data-report-page="report"] .mix-split { display: grid; gap: var(--oc-space-2); margin-top: var(--oc-space-3); }
body[data-report-page="report"] .maintainer-distribution { display:grid; gap:var(--oc-space-3); margin-top:var(--oc-space-5); padding-top:var(--oc-space-5); border-top:1px solid var(--oc-border-subtle); }
body[data-report-page="report"] .distribution-heading { display:flex; align-items:end; justify-content:space-between; gap:var(--oc-space-4); }
body[data-report-page="report"] .distribution-heading h3 { margin:var(--oc-space-1) 0 0; color:var(--oc-text-primary); font:740 18px/1.2 var(--oc-font-display); }
body[data-report-page="report"] .ranked-distribution-list { display:grid; gap:var(--oc-space-1); margin:0; padding:0; list-style:none; counter-reset:distribution-rank; }
body[data-report-page="report"] .distribution-more .ranked-distribution-list { margin-top:var(--oc-space-2); }
body[data-report-page="report"] .ranked-distribution-list li { counter-increment:distribution-rank; }
body[data-report-page="report"] .ranked-distribution-list li > a, body[data-report-page="report"] .ranked-distribution-list li > span { display:grid; grid-template-columns:minmax(8rem, 14rem) minmax(5rem, 1fr) 5rem 3rem; align-items:center; gap:var(--oc-space-3); min-height:2rem; color:var(--oc-text-secondary); }
body[data-report-page="report"] .ranked-distribution-list li > a:hover { color:var(--oc-text-primary); text-decoration:none; }
body[data-report-page="report"] .distribution-label { display:grid; min-width:0; overflow:hidden; align-content:center; gap:1px; }
body[data-report-page="report"] .distribution-label-primary, body[data-report-page="report"] .distribution-label-detail { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
body[data-report-page="report"] .distribution-label-primary { color:var(--oc-text-primary); font-weight:650; }
body[data-report-page="report"] .distribution-label-detail { color:var(--oc-text-muted); font-size:var(--oc-font-size-xs); font-weight:500; line-height:1.2; }
body[data-report-page="report"] .distribution-track { height:0.55rem; overflow:hidden; border-radius:var(--oc-radius-round); background:var(--oc-surface-secondary-soft); }
body[data-report-page="report"] .distribution-segment { display:block; min-width:1px; height:100%; background:var(--oc-chart-color,var(--oc-accent-primary)); }
body[data-report-page="report"] .oc-split-tertiary { --oc-chart-color:color-mix(in srgb,var(--oc-accent-primary) 48%,var(--oc-accent-secondary)); }
body[data-report-page="report"] .oc-split-quaternary { --oc-chart-color:var(--oc-accent-secondary-deep); }
body[data-report-page="report"] .oc-split-neutral { --oc-chart-color:color-mix(in srgb,var(--oc-border-strong) 58%,var(--oc-text-muted)); }
body[data-report-page="report"] .distribution-total { color:var(--oc-text-primary); font-variant-numeric:tabular-nums; text-align:right; }
body[data-report-page="report"] .distribution-share { color:var(--oc-text-muted); font-family:var(--oc-font-mono); font-size:var(--oc-font-size-xs); text-align:right; }
body[data-report-page="report"] .distribution-breakdown { grid-column:2 / -1; display:flex; flex-wrap:wrap; gap:var(--oc-space-1) var(--oc-space-3); color:var(--oc-text-muted); font-size:var(--oc-font-size-xs); line-height:1.25; }
body[data-report-page="report"] .distribution-breakdown-item { display:inline-flex; align-items:center; gap:var(--oc-space-1); white-space:nowrap; }
body[data-report-page="report"] .distribution-breakdown-key { width:0.45rem; height:0.45rem; flex:0 0 auto; border-radius:2px; background:var(--oc-chart-color,var(--oc-accent-primary)); }
body[data-report-page="report"] .distribution-more { border-top:1px solid var(--oc-border-subtle); padding-top:var(--oc-space-2); }
body[data-report-page="report"] .distribution-more summary { width:fit-content; color:var(--oc-text-secondary); font-size:var(--oc-font-size-sm); cursor:pointer; }
body[data-report-page="report"] { margin: 0; background: var(--oc-bg-page); color: var(--oc-text-secondary); -webkit-font-smoothing: antialiased; }
body[data-report-page="report"] .shell { width: min(var(--oc-content-max), calc(100vw - 36px)); margin: 0 auto; padding: var(--oc-space-6) 0 var(--oc-space-8); }
body[data-report-page="report"] header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--oc-space-6); align-items: end; padding: 0 0 var(--oc-space-5); border-bottom: 1px solid var(--oc-border-subtle); }
body[data-report-page="report"] h1 { margin: 0; color: var(--oc-text-primary); font: 740 34px/1.05 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; }
body[data-report-page="report"] .subtitle { margin: 12px 0 0; color: var(--oc-text-muted); font-size: 14px; line-height: 1.5; max-width: 760px; }
body[data-report-page="report"] .stamp .label, body[data-report-page="report"] .metric .label, body[data-report-page="report"] .section-kicker, body[data-report-page="report"] .person-meta, body[data-report-page="report"] .small { color: var(--oc-text-muted); font-family: var(--oc-font-mono); font-size: 12px; line-height: 1.35; text-transform: uppercase; }
body[data-report-page="report"] .panel { margin: var(--oc-space-7) 0; padding: 0; }
body[data-report-page="report"] .collection-section { padding: 0; border: 0; background: transparent; box-shadow: none; }
body[data-report-page="report"] .people-head { align-items: start; }
body[data-report-page="report"] .people-tools { width: min(390px, 100%); display: grid; gap: 8px; }
body[data-report-page="report"] .person-filter { width: 100%; min-height: 42px; padding: 9px 12px; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-control); background: var(--oc-surface-card-strong); color: var(--oc-text-primary); font: 740 14px/1.2 var(--oc-font-mono); outline: none; box-shadow: inset 0 0 0 1px rgba(24, 23, 20, 0); }
body[data-report-page="report"] .person-filter:focus { box-shadow: inset 0 0 0 1px var(--oc-accent-primary), 0 0 0 3px color-mix(in srgb, var(--oc-accent-primary) 18%, transparent); }
body[data-report-page="report"] .filter-status { min-height: 17px; color: var(--oc-text-muted); font-size: 12px; line-height: 1.35; text-transform: uppercase; }
body[data-report-page="report"] h2 { margin: 0; color: var(--oc-text-primary); font: 740 20px/1.2 var(--oc-font-display); letter-spacing: 0; }
body[data-report-page="report"] .shell > .oc-section { margin: var(--oc-space-7) 0; }
body[data-report-page="report"] .shell > .oc-section:first-of-type { margin-top: var(--oc-space-5); }
body[data-report-page="report"] .oc-section-header { margin-bottom: var(--oc-space-4); }
body[data-report-page="report"] .section-note { max-width: 46ch; margin: 0; color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); line-height: 1.45; text-align: right; }
body[data-report-page="report"] .summary-markdown { color: var(--oc-text-primary); font-size: 15px; line-height: 1.48; }
body[data-report-page="report"] .summary-markdown p { margin: 0 0 12px; }
body[data-report-page="report"] .summary-markdown p:last-child { margin-bottom: 0; }
body[data-report-page="report"] .summary-markdown ul { display: grid; gap: 8px; margin: 14px 0 0; padding: 0; list-style: none; }
body[data-report-page="report"] .summary-markdown li { position: relative; padding-left: 18px; }
body[data-report-page="report"] .summary-markdown li::before { content: ""; position: absolute; left: 0; top: 0.72em; width: 7px; height: 7px; border-radius: 999px; background: var(--oc-status-error-fg); }
body[data-report-page="report"] .summary-markdown strong { font-weight: 800; }
body[data-report-page="report"] .summary-markdown code { padding: 1px 5px; border: 1px solid var(--oc-border-subtle); border-radius: 5px; background: color-mix(in srgb, var(--oc-surface-card) 84%, transparent); font: 0.92em ui-monospace, SFMono-Regular, Menlo, monospace; }
body[data-report-page="report"] .summary-markdown a { color: inherit; text-decoration-color: var(--oc-border-strong); text-underline-offset: 3px; }
body[data-report-page="report"] .people { display: grid; gap: 12px; }
body[data-report-page="report"] .person { display: grid; grid-template-columns: 198px minmax(0, 1fr) 150px; gap: 16px; padding: 14px; }
body[data-report-page="report"] .person:hover { border-color: color-mix(in srgb, var(--oc-accent-primary) 42%, var(--oc-border-strong)); background: color-mix(in srgb, var(--oc-surface-card) 82%, var(--oc-accent-primary) 8%); }
body[data-report-page="report"] .person[hidden] { display: none; }
body[data-report-page="report"] .quiet-maintainers { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--oc-border-subtle); }
body[data-report-page="report"] .quiet-title { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 14px; margin-bottom: 10px; }
body[data-report-page="report"] .quiet-title h3 { margin: 0; font-size: 15px; line-height: 1.25; letter-spacing: 0; }
body[data-report-page="report"] .quiet-list { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; }
body[data-report-page="report"] .quiet-list li { max-width: 100%; padding: 4px 8px; border: 1px solid var(--oc-border-strong); border-radius: 999px; background: color-mix(in srgb, var(--oc-surface-accent-soft) 72%, transparent); color: var(--oc-text-muted); font-size: 12px; line-height: 1.25; overflow-wrap: anywhere; }
body[data-report-page="report"] .person-title { min-width: 0; }
body[data-report-page="report"] .person-heading { display: flex; align-items: center; gap: 10px; min-width: 0; }
body[data-report-page="report"] .person-heading > div { min-width: 0; }
body[data-report-page="report"] .handle { color: var(--oc-text-primary); font: 800 17px/1.15 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; overflow-wrap: anywhere; }
body[data-report-page="report"] .person-name { margin-top: 3px; color: var(--oc-text-muted); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
body[data-report-page="report"] .affiliation { margin-top: 7px; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 8px; border: 1px solid var(--oc-border-strong); border-radius: 999px; background: color-mix(in srgb, var(--oc-status-success-fg) 14%, var(--oc-surface-card-strong)); color: var(--oc-status-success-fg); font-size: 12px; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; }
body[data-report-page="report"] .affiliation.is-na { border-color: var(--oc-border-strong); background: color-mix(in srgb, var(--oc-bg-elevated) 82%, var(--oc-surface-card-strong)); color: var(--oc-text-muted); }
body[data-report-page="report"] .affiliation.is-readonly { border-color: color-mix(in srgb, var(--oc-status-info-fg) 46%, var(--oc-border-strong)); background: color-mix(in srgb, var(--oc-status-info-fg) 13%, var(--oc-surface-card-strong)); color: color-mix(in srgb, var(--oc-status-info-fg) 76%, var(--oc-text-primary)); }
body[data-report-page="report"] .affiliation.is-independent { border-color: color-mix(in srgb, var(--oc-status-warning-fg) 46%, var(--oc-border-strong)); background: color-mix(in srgb, var(--oc-status-warning-fg) 16%, var(--oc-surface-card-strong)); color: color-mix(in srgb, var(--oc-status-warning-fg) 88%, var(--oc-text-primary)); }
body[data-report-page="report"] .role-line { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
body[data-report-page="report"] .person-body { min-width: 0; display: grid; gap: 10px; }
body[data-report-page="report"] .chips { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 7px; }
body[data-report-page="report"] .theme { margin: 0; color: var(--oc-text-primary); font-size: 13px; line-height: 1.42; }
body[data-report-page="report"] .theme + .theme { color: var(--oc-text-muted); }
body[data-report-page="report"] .focus { margin: 0; color: var(--oc-text-primary); font-size: 14px; line-height: 1.48; }
body[data-report-page="report"] .person-numbers { display: grid; gap: 8px; align-content: start; }
body[data-report-page="report"] .number-line { display: flex; justify-content: space-between; gap: 8px; color: var(--oc-text-muted); font-size: 13px; border-bottom: 1px solid var(--oc-border-subtle); padding-bottom: 5px; }
body[data-report-page="report"] .number-line strong { color: var(--oc-text-primary); }
body[data-report-page="report"] footer { margin-top: 28px; color: var(--oc-text-muted); font-size: 12px; line-height: 1.5; border-top: 1px solid var(--oc-border-strong); padding-top: 14px; }
@media (max-width: 980px) {
body[data-report-page="report"] header, body[data-report-page="report"] .mix { grid-template-columns: 1fr; }
body[data-report-page="report"] .person { grid-template-columns: 1fr; }
body[data-report-page="report"] .people-head { align-items: stretch; }
}
@media (max-width: 640px) {
body[data-report-page="report"] .shell { width: min(100vw - 24px, 1180px); padding-top: 20px; }
body[data-report-page="report"] h1 { font-size: 28px; }
body[data-report-page="report"] .distribution-heading { align-items:start; flex-direction:column; }
body[data-report-page="report"] .distribution-heading .section-note { text-align:left; }
body[data-report-page="report"] .ranked-distribution-list li > a, body[data-report-page="report"] .ranked-distribution-list li > span { grid-template-columns:minmax(7rem,1fr) 5rem 2.5rem; gap:var(--oc-space-2); }
body[data-report-page="report"] .distribution-track { grid-column:1 / -1; grid-row:2; }
body[data-report-page="report"] .distribution-breakdown { grid-column:1 / -1; grid-row:3; }
}
body[data-report-page="people"] .people-header, body[data-report-page="person"] .people-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--oc-space-6); align-items: end; padding-bottom: var(--oc-space-5); margin-bottom: var(--oc-space-5); border-bottom: 1px solid var(--oc-border-subtle); }
body[data-report-page="people"] h1, body[data-report-page="person"] h1 { margin: 0; color: var(--oc-text-primary); font: 740 34px/1.05 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body[data-report-page="people"] h2, body[data-report-page="person"] h2 { margin: 4px 0 0; color: var(--oc-text-primary); font: 740 22px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body[data-report-page="people"] p, body[data-report-page="person"] p { margin: 10px 0 0; color: var(--oc-text-muted); }
body[data-report-page="people"] .people-grid, body[data-report-page="person"] .people-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(330px, 100%), 1fr)); gap: 12px; }
body[data-report-page="people"] .people-break, body[data-report-page="person"] .people-break { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 6px 0 -2px; padding: 12px 2px 2px; border-top: 1px solid var(--oc-border-subtle); color: var(--oc-text-muted); font-size: 12px; line-height: 1.35; text-transform: uppercase; }
body[data-report-page="people"] .people-break span:first-child, body[data-report-page="person"] .people-break span:first-child { color: var(--oc-text-primary); font: 800 13px/1.2 ui-sans-serif, system-ui, sans-serif; text-transform: none; }
body[data-report-page="people"] .person-card, body[data-report-page="person"] .person-card { display: grid; gap: 10px; padding: 14px; }
body[data-report-page="people"] .person-card:hover, body[data-report-page="person"] .person-card:hover { border-color: var(--oc-accent-primary); background: color-mix(in srgb, var(--oc-surface-card) 82%, var(--oc-accent-primary) 8%); }
body[data-report-page="people"] .person-card.is-inactive, body[data-report-page="person"] .person-card.is-inactive { background: color-mix(in srgb, var(--oc-surface-card) 78%, var(--oc-bg-elevated)); }
body[data-report-page="people"] .person-card-head, body[data-report-page="person"] .person-card-head, body[data-report-page="people"] .person-hero, body[data-report-page="person"] .person-hero { display: flex; align-items: center; gap: 10px; min-width: 0; }
body[data-report-page="people"] .person-card-head > span, body[data-report-page="person"] .person-card-head > span, body[data-report-page="people"] .person-hero > div, body[data-report-page="person"] .person-hero > div { min-width: 0; }
body[data-report-page="people"] .person-card-identity, body[data-report-page="person"] .person-card-identity { display: grid; gap: 2px; min-width: 0; }
body[data-report-page="people"] .person-card strong, body[data-report-page="person"] .person-card strong { display: block; color: var(--oc-text-primary); font: 760 17px/1.2 ui-sans-serif, system-ui, sans-serif; overflow-wrap: anywhere; }
body[data-report-page="people"] .person-handle, body[data-report-page="person"] .person-handle, body[data-report-page="people"] .person-card-meta, body[data-report-page="person"] .person-card-meta { color: var(--oc-text-muted); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
body[data-report-page="people"] .person-company, body[data-report-page="person"] .person-company, body[data-report-page="people"] .person-company-line, body[data-report-page="person"] .person-company-line { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; min-width: 0; color: var(--oc-text-secondary); font-size: 13px; line-height: 1.35; }
body[data-report-page="people"] .person-company span, body[data-report-page="person"] .person-company span, body[data-report-page="people"] .person-company-line span, body[data-report-page="person"] .person-company-line span { min-width: 0; overflow-wrap: anywhere; }
body[data-report-page="people"] .person-card-meta, body[data-report-page="person"] .person-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
body[data-report-page="people"] .mini-strip, body[data-report-page="person"] .mini-strip { display: grid; grid-template-columns: repeat(28, minmax(0, 1fr)); gap: 3px; align-items: center; }
body[data-report-page="people"] .legend, body[data-report-page="person"] .legend { display: flex; gap: 4px; align-items: center; }
body[data-report-page="people"] .day-dot, body[data-report-page="person"] .day-dot, body[data-report-page="people"] .legend span, body[data-report-page="person"] .legend span { min-width: 0; height: 10px; border: 1px solid var(--activity-0-border); border-radius: 3px; background: var(--activity-0-bg); }
body[data-report-page="people"] .legend span, body[data-report-page="person"] .legend span { width: 13px; height: 13px; }
body[data-report-page="people"] .archive-panel, body[data-report-page="person"] .archive-panel { padding: 0; margin: 32px 0; }
body[data-report-page="people"] .month-row, body[data-report-page="person"] .month-row { display: grid; grid-template-columns: 128px minmax(0, 1fr); gap: 18px; align-items: start; padding: 14px 0; border-top: 1px solid var(--oc-border-subtle); }
body[data-report-page="people"] .month-row:first-child, body[data-report-page="person"] .month-row:first-child { border-top: 0; }
body[data-report-page="people"] .month-label, body[data-report-page="person"] .month-label { color: var(--oc-text-primary); font-weight: 760; padding-top: 26px; }
body[data-report-page="people"] .calendar, body[data-report-page="person"] .calendar { width: min(100%, 268px); --calendar-gap: 5px; }
body[data-report-page="people"] .weekday-row, body[data-report-page="person"] .weekday-row, body[data-report-page="people"] .day-grid, body[data-report-page="person"] .day-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: var(--calendar-gap); width: 100%; }
body[data-report-page="people"] .weekday-row, body[data-report-page="person"] .weekday-row { margin-bottom: 6px; color: var(--oc-text-muted); font-size: 10px; text-align: center; text-transform: uppercase; }
body[data-report-page="people"] .day-grid, body[data-report-page="person"] .day-grid { align-items: center; }
body[data-report-page="people"] .day-spacer, body[data-report-page="person"] .day-spacer, body[data-report-page="people"] .day-cell, body[data-report-page="person"] .day-cell { width: 100%; aspect-ratio: 1; min-width: 0; }
body[data-report-page="people"] .day-cell, body[data-report-page="person"] .day-cell { display: grid; place-items: center; min-height: 24px; border: 1px solid var(--activity-0-border); border-radius: 6px; color: var(--oc-text-muted); font-size: 11px; font-weight: 760; }
body[data-report-page="people"] .day-cell:hover, body[data-report-page="person"] .day-cell:hover { border-color: var(--oc-accent-primary); color: var(--oc-text-primary); outline: 2px solid color-mix(in srgb, var(--oc-accent-primary) 24%, transparent); outline-offset: 1px; }
body[data-report-page="people"] .level-0, body[data-report-page="person"] .level-0 { background: var(--activity-0-bg); border-color: var(--activity-0-border); }
body[data-report-page="people"] .level-1, body[data-report-page="person"] .level-1, body[data-report-page="person"] .legend .level-1 { background: var(--activity-1-bg); border-color: var(--activity-1-border); color: var(--oc-text-primary); }
body[data-report-page="people"] .level-2, body[data-report-page="person"] .level-2, body[data-report-page="person"] .legend .level-2 { background: var(--activity-2-bg); border-color: var(--activity-2-border); color: var(--oc-text-primary); }
body[data-report-page="people"] .level-3, body[data-report-page="person"] .level-3, body[data-report-page="person"] .legend .level-3 { background: var(--activity-3-bg); border-color: var(--activity-3-border); color: var(--activity-3-text); }
body[data-report-page="people"] .level-4, body[data-report-page="person"] .level-4, body[data-report-page="person"] .legend .level-4 { background: var(--activity-4-bg); border-color: var(--activity-4-border); color: var(--activity-4-text); }
body[data-report-page="people"] .activity-list, body[data-report-page="person"] .activity-list { display: grid; gap: 10px; }
body[data-report-page="people"] .activity-row, body[data-report-page="person"] .activity-row { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 16px; padding: 14px; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-bg-elevated); }
body[data-report-page="people"] .activity-row:hover, body[data-report-page="person"] .activity-row:hover { border-color: var(--oc-accent-primary); }
body[data-report-page="people"] .activity-date, body[data-report-page="person"] .activity-date { color: var(--oc-text-primary); font-weight: 760; }
body[data-report-page="people"] .activity-row strong, body[data-report-page="person"] .activity-row strong, body[data-report-page="people"] .activity-row span span, body[data-report-page="person"] .activity-row span span { display: block; }
body[data-report-page="people"] .activity-row strong, body[data-report-page="person"] .activity-row strong { color: var(--oc-text-primary); }
body[data-report-page="people"] .activity-row span span, body[data-report-page="person"] .activity-row span span { color: var(--oc-text-muted); font-size: 13px; line-height: 1.45; }
body[data-report-page="people"] .activity-row p, body[data-report-page="person"] .activity-row p { margin-top: 6px; color: var(--oc-text-secondary); font-size: 13px; }
body[data-report-page="people"] .person-activity-panel, body[data-report-page="person"] .person-activity-panel { min-width: 0; }
body[data-report-page="people"] .person-activity-legend, body[data-report-page="person"] .person-activity-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px 16px; margin: 4px 0 0; padding: 0; color: var(--oc-text-muted); font-size: 12px; line-height: 1.35; list-style: none; }
body[data-report-page="people"] .person-activity-legend li, body[data-report-page="person"] .person-activity-legend li { display: inline-flex; align-items: center; gap: 7px; }
body[data-report-page="people"] .person-activity-swatch, body[data-report-page="person"] .person-activity-swatch { width: 18px; height: 3px; flex: 0 0 auto; border-radius: 2px; }
body[data-report-page="people"] .person-activity-swatch.is-github, body[data-report-page="person"] .person-activity-swatch.is-github { background: var(--oc-status-success-fg); }
body[data-report-page="people"] .person-activity-swatch.is-discord, body[data-report-page="person"] .person-activity-swatch.is-discord { background: var(--oc-status-info-fg); }
body[data-report-page="people"] .person-activity-chart, body[data-report-page="person"] .person-activity-chart { min-width: 0; margin-top: 16px; border: 1px solid var(--oc-border-subtle); border-radius: var(--oc-radius-surface); background: var(--oc-bg-elevated); touch-action: pan-y; }
body[data-report-page="people"] .person-activity-chart:focus-visible, body[data-report-page="person"] .person-activity-chart:focus-visible { outline: 2px solid var(--oc-accent-primary); outline-offset: 2px; }
body[data-report-page="people"] .person-activity-svg, body[data-report-page="person"] .person-activity-svg { display: block; width: 100%; height: clamp(250px, 36vw, 390px); overflow: visible; }
body[data-report-page="people"] .person-activity-axis, body[data-report-page="person"] .person-activity-axis, body[data-report-page="people"] .person-activity-gridline, body[data-report-page="person"] .person-activity-gridline, body[data-report-page="people"] .person-activity-line, body[data-report-page="person"] .person-activity-line { vector-effect: non-scaling-stroke; }
body[data-report-page="people"] .person-activity-axis, body[data-report-page="person"] .person-activity-axis { fill: none; stroke: var(--oc-border-strong); stroke-width: 1; }
body[data-report-page="people"] .person-activity-gridline, body[data-report-page="person"] .person-activity-gridline { stroke: var(--oc-border-subtle); stroke-width: 1; }
body[data-report-page="people"] .person-activity-axis-tick, body[data-report-page="person"] .person-activity-axis-tick { fill: var(--oc-text-muted); font: 11px/1 var(--oc-font-mono); }
body[data-report-page="people"] .person-activity-axis-tick-left, body[data-report-page="person"] .person-activity-axis-tick-left { text-anchor: end; }
body[data-report-page="people"] .person-activity-axis-tick-right, body[data-report-page="person"] .person-activity-axis-tick-right { text-anchor: start; }
body[data-report-page="people"] .person-activity-axis-title, body[data-report-page="person"] .person-activity-axis-title { font: 800 12px/1 var(--oc-font-mono); }
body[data-report-page="people"] .person-activity-axis-title-github, body[data-report-page="person"] .person-activity-axis-title-github { fill: var(--oc-status-success-fg); }
body[data-report-page="people"] .person-activity-axis-title-discord, body[data-report-page="person"] .person-activity-axis-title-discord { fill: var(--oc-status-info-fg); }
body[data-report-page="people"] .person-activity-line, body[data-report-page="person"] .person-activity-line { fill: none; stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; }
body[data-report-page="people"] .person-activity-line-github, body[data-report-page="person"] .person-activity-line-github { stroke: var(--oc-status-success-fg); }
body[data-report-page="people"] .person-activity-line-discord, body[data-report-page="person"] .person-activity-line-discord { stroke: var(--oc-status-info-fg); }
body[data-report-page="people"] .person-activity-point, body[data-report-page="person"] .person-activity-point { stroke: var(--oc-bg-elevated); stroke-width: 1; vector-effect: non-scaling-stroke; }
body[data-report-page="people"] .person-activity-point-github, body[data-report-page="person"] .person-activity-point-github { fill: var(--oc-status-success-fg); }
body[data-report-page="people"] .person-activity-point-discord, body[data-report-page="person"] .person-activity-point-discord { fill: var(--oc-status-info-fg); }
body[data-report-page="people"] .person-activity-quality-note, body[data-report-page="person"] .person-activity-quality-note { margin-top: 10px; font-size: 12px; line-height: 1.45; }
body[data-report-page="people"] .person-activity-values, body[data-report-page="person"] .person-activity-values { margin-top: 12px; border-top: 1px solid var(--oc-border-subtle); }
body[data-report-page="people"] .person-activity-values summary, body[data-report-page="person"] .person-activity-values summary { padding: 12px 0 4px; color: var(--oc-text-primary); font-weight: 760; cursor: pointer; }
body[data-report-page="people"] .person-activity-table-wrap, body[data-report-page="person"] .person-activity-table-wrap { max-width: 100%; overflow-x: auto; }
body[data-report-page="people"] .person-activity-table, body[data-report-page="person"] .person-activity-table { width: 100%; min-width: 700px; border-collapse: collapse; margin-top: 8px; }
body[data-report-page="people"] .person-activity-table th, body[data-report-page="person"] .person-activity-table th, body[data-report-page="people"] .person-activity-table td, body[data-report-page="person"] .person-activity-table td { padding: 9px 10px; border-bottom: 1px solid var(--oc-border-subtle); text-align: left; vertical-align: top; }
body[data-report-page="people"] .person-activity-table thead th, body[data-report-page="person"] .person-activity-table thead th { color: var(--oc-text-muted); font-size: 11px; text-transform: uppercase; }
body[data-report-page="people"] .person-activity-table tbody th, body[data-report-page="person"] .person-activity-table tbody th { color: var(--oc-text-primary); font: 760 12px/1.35 var(--oc-font-mono); }
body[data-report-page="people"] .person-activity-table td, body[data-report-page="person"] .person-activity-table td { color: var(--oc-text-secondary); font-size: 12px; line-height: 1.4; }
body[data-report-page="people"] .breadcrumbs, body[data-report-page="person"] .breadcrumbs { display: flex; gap: 8px; color: var(--oc-text-muted); font-size: 12px; margin-bottom: 12px; }
body[data-report-page="people"] .breadcrumbs a, body[data-report-page="person"] .breadcrumbs a { color: var(--oc-accent-primary); }
@media (max-width: 760px) {
body[data-report-page="people"] .people-header, body[data-report-page="person"] .people-header, body[data-report-page="people"] .month-row, body[data-report-page="person"] .month-row, body[data-report-page="people"] .activity-row, body[data-report-page="person"] .activity-row { grid-template-columns: 1fr; }
body[data-report-page="people"] .month-label, body[data-report-page="person"] .month-label { padding-top: 0; }
body[data-report-page="people"] .archive-panel, body[data-report-page="person"] .archive-panel { padding: 16px; }
body[data-report-page="people"] .calendar, body[data-report-page="person"] .calendar { width: 100%; }
body[data-report-page="people"] .day-cell, body[data-report-page="person"] .day-cell { font-size: 10px; }
body[data-report-page="people"] .person-activity-legend, body[data-report-page="person"] .person-activity-legend { justify-content: flex-start; }
body[data-report-page="people"] .person-activity-svg, body[data-report-page="person"] .person-activity-svg { height: auto; aspect-ratio: 8 / 3; }
body[data-report-page="people"] .person-activity-axis-tick, body[data-report-page="person"] .person-activity-axis-tick { font-size: 24px; }
body[data-report-page="people"] .person-activity-axis-title, body[data-report-page="person"] .person-activity-axis-title { display: none; }
}
@media print {
body[data-report-page="people"] .person-activity-chart, body[data-report-page="person"] .person-activity-chart { break-inside: avoid; }
body[data-report-page="people"] .person-activity-svg, body[data-report-page="person"] .person-activity-svg { height: 300px; }
body[data-report-page="people"] .person-activity-values, body[data-report-page="person"] .person-activity-values { break-inside: avoid; }
}
body[data-report-page="home"] .home-grid > .quick-card { box-shadow: none; }
body[data-report-page="home"] .home-card h2 { margin: 4px 0 0; font-size: 18px; }
body[data-report-page="home"] .home-card > p:not(.oc-eyebrow) { color: var(--oc-text-muted); font-size: 13px; line-height: 1.45; }
body[data-report-page="home"] .home-card > .panel-link { align-self: flex-start; }
body[data-report-page="home"] .home-grid .oc-summary-metric { min-width: 0; }
body[data-report-page="home"] .home-grid .oc-summary-metric + .oc-summary-metric { padding-left: var(--oc-space-3); border-left: 1px solid var(--oc-border-subtle); }
body[data-report-page="home"] .home-grid .oc-summary-metric-copy { gap: var(--oc-space-1); }
body[data-report-page="home"] .home-grid .oc-summary-metric-copy small { overflow: visible; white-space: normal; }
body[data-report-page="report"] .distribution-track { width: 100%; }
body[data-report-page="report"] .distribution-segment { fill: var(--oc-chart-color, var(--oc-accent-primary)); }
body[data-report-page="report"] header .oc-summary-metric { max-width: 27rem; }
body[data-report-page="report"] header .oc-summary-metric-copy small { white-space: normal; }
body[data-report-page="report"] .oc-banner-title { font-size: inherit; line-height: inherit; font-weight: 650; }
body[data-report-page="report"] main > .oc-banner { margin-block: var(--oc-space-3); }
body[data-report-page="report"] main > .oc-summary-strip { margin-top: var(--oc-space-5); }
body[data-report-page="report"] .alias-line, body[data-report-page="report"] .theme-kind { color: var(--oc-text-muted); font-size: var(--oc-font-size-xs); }
body[data-report-page="report"] .person-body details { min-width: 0; }
body[data-report-page="people"] .people-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin: -4px 0 18px; padding: 10px 12px; }
body[data-report-page="people"] .source-key { display: inline-flex; align-items: center; gap: 12px; margin-left: auto; color: var(--oc-text-muted); font-size: 12px; }
body[data-report-page="people"] .source-key span { display: inline-flex; align-items: center; gap: 5px; }
body[data-report-page="people"] .source-swatch { width: 10px; height: 10px; border-radius: 3px; border: 1px solid var(--oc-border-strong); }
body[data-report-page="people"] .source-swatch.github { background: var(--oc-status-success-fg); }
body[data-report-page="people"] .source-swatch.discord { background: var(--oc-status-info-fg); }
body[data-report-page="people"] .people-break.is-archived { margin-top: 18px; }
body[data-report-page="people"] .person-card.is-archived { border-style: dashed; opacity: .82; }
body[data-report-page="people"] .person-card-head .oc-avatar { width: 36px; height: 36px; }
body[data-report-page="people"] .person-card-days { flex-basis: 100%; }
body[data-report-page="people"] .person-card:hover { text-decoration: none; }
body[data-report-page="person"] .person-hero .oc-avatar { width: 72px; height: 72px; }
body[data-report-page="person"] .person-lifecycle, body[data-report-page="person"] .person-lifecycle-line { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; min-width: 0; color: var(--oc-status-warning-fg); font-size: 12px; font-weight: 800; line-height: 1.35; }
body[data-report-page="person"] .oc-summary-strip { margin-block: var(--oc-space-4) var(--oc-space-7); }
@media (max-width: 760px) {
body[data-report-page="people"] .people-toolbar { align-items: flex-start; flex-direction: column; }
body[data-report-page="people"] .source-key { margin-left: 0; }
}
`;

View file

@ -0,0 +1,113 @@
import { JSDOM } from "jsdom";
import { afterEach, describe, expect, it } from "vitest";
import { shell } from "./page.js";
const pages: JSDOM[] = [];
afterEach(() => {
for (const page of pages.splice(0)) {
page.window.close();
}
});
async function openPage(
options: {
hash?: string;
stored?: string;
blockedStorage?: boolean;
systemLight?: boolean;
url?: string;
} = {},
) {
const html = shell(
{
basePath: "/team/reports",
nonce: "fixture",
absoluteUrl: "https://public.example/team/reports/day/2026-09-07/?person=alice",
displayTimezone: "UTC",
},
"Theme fixture",
`<a id="history" href="/team/reports/day/2026-09-06/?person=alice#theme=dark">History</a><a id="absolute" href="https://gateway.example/team/reports/people/">People</a><a id="external" href="https://external.example/team/reports/">External</a><a id="outside" href="/team/reports-other/">Outside</a>`,
"home",
);
const page = new JSDOM(html, {
url: options.url ?? `https://gateway.example/team/reports/${options.hash ?? ""}`,
runScripts: "dangerously",
beforeParse(window) {
Object.defineProperty(window, "matchMedia", {
value: () => ({ matches: options.systemLight ?? false, addEventListener() {} }),
});
if (options.blockedStorage) {
Object.defineProperty(window, "localStorage", {
get() {
throw new window.DOMException("Storage blocked by sandbox", "SecurityError");
},
});
} else if (options.stored) {
window.localStorage.setItem("theme", options.stored);
}
},
});
pages.push(page);
// The head script must choose the theme before body parsing and enhancement.
const headTheme = page.window.document.documentElement.dataset.theme;
await new Promise<void>((resolve) => {
page.window.addEventListener("load", () => resolve(), { once: true });
});
return { page, headTheme };
}
function link(page: JSDOM, selector: string): string | null | undefined {
return page.window.document.querySelector(selector)?.getAttribute("href");
}
function toggle(page: JSDOM): void {
page.window.document.querySelector<HTMLButtonElement>("[data-theme-toggle]")?.click();
}
describe("Team Reports theme navigation", () => {
it.each([
{ hash: "#theme=light", stored: "dark", systemLight: false, expected: "light" },
{ hash: "#theme=dark", stored: "light", systemLight: true, expected: "dark" },
{ hash: "#theme=invalid", stored: "light", systemLight: false, expected: "light" },
{ hash: "", stored: "invalid", systemLight: true, expected: "light" },
])("resolves the head theme from fragment, storage, then OS ($hash/$stored)", async (options) => {
const { headTheme } = await openPage(options);
expect(headTheme).toBe(options.expected);
});
it("carries a sandboxed theme across internal navigation without changing external destinations", async () => {
const { page } = await openPage({ blockedStorage: true });
toggle(page);
expect(page.window.document.documentElement.dataset.theme).toBe("light");
expect(page.window.location.hash).toBe("#theme=light");
expect(link(page, "#history")).toBe("/team/reports/day/2026-09-06/?person=alice#theme=light");
expect(link(page, "#absolute")).toBe(
"https://gateway.example/team/reports/people/#theme=light",
);
expect(link(page, 'a[aria-current="page"]')).toBe("/team/reports/#theme=light");
expect(link(page, 'a[aria-label="Open in a new window"]')).toBe(
"https://public.example/team/reports/day/2026-09-07/?person=alice#theme=light",
);
expect(link(page, "#external")).toBe("https://external.example/team/reports/");
expect(link(page, "#outside")).toBe("/team/reports-other/");
const next = await openPage({
url: new URL(link(page, "#history") ?? "", page.window.location.href).href,
blockedStorage: true,
});
expect(next.headTheme).toBe("light");
expect(link(next.page, 'a[aria-current="page"]')).toBe("/team/reports/#theme=light");
toggle(next.page);
expect(next.page.window.location.hash).toBe("#theme=dark");
expect(link(next.page, "#history")).toBe(
"/team/reports/day/2026-09-06/?person=alice#theme=dark",
);
});
it("persists a separate-window theme when browser storage is available", async () => {
const { page } = await openPage({ stored: "dark" });
toggle(page);
expect(page.window.localStorage.getItem("theme")).toBe("light");
const next = await openPage({ stored: page.window.localStorage.getItem("theme") ?? undefined });
expect(next.headTheme).toBe("light");
});
});

View file

@ -217,6 +217,41 @@ describe("Team Reports schedule boundaries", () => {
});
describe("Team Reports scheduler lifecycle", () => {
it("reports the earliest due time and last completed run while another run is active", async () => {
const { scheduler, github } = setup({
caughtUp: false,
schedule: { intradayEveryHours: 4 },
});
expect(scheduler.health()).toEqual({ running: false, warnings: 0 });
scheduler.start();
expect(scheduler.health()).toEqual({
running: true,
nextDueMs: Date.parse("2026-08-20T12:01:00Z"),
warnings: 0,
});
scheduler.generate();
expect(scheduler.health().lastRun).toBeUndefined();
await vi.advanceTimersByTimeAsync(60_000);
expect(scheduler.health()).toEqual({
running: true,
lastRun: { status: "ok", kind: "manual", finishedAtMs: Date.parse("2026-08-20T12:00:00Z") },
nextDueMs: Date.parse("2026-08-20T16:00:00Z"),
warnings: 0,
});
const blocked = createDeferred<Awaited<ReturnType<GithubSource["collect"]>>>();
github.collect.mockImplementationOnce(() => blocked.promise);
scheduler.generate({ intraday: true });
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.health().lastRun).toEqual({
status: "ok",
kind: "manual",
finishedAtMs: Date.parse("2026-08-20T12:00:00Z"),
});
blocked.resolve({ items: [], status: healthy });
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.health().lastRun?.finishedAtMs).toBe(Date.parse("2026-08-20T12:01:00Z"));
});
it("catches up yesterday once after the startup delay and also publishes today's partial", async () => {
const { scheduler, store, github } = setup({ caughtUp: false });
scheduler.start();
@ -438,11 +473,13 @@ describe("Team Reports scheduler lifecycle", () => {
expect(stored?.summary?.warnings).toEqual([reason]);
expect(stored?.markdown).toContain(`> ${reason}`);
expect(scheduler.status().sourceWarnings).toEqual(["Roster coverage warning", reason]);
expect(scheduler.health().warnings).toBe(2);
expect(context.logger.warn.mock.calls).toEqual([[reason]]);
complete.mockResolvedValue(modelResponse());
scheduler.generate({ intraday: true });
await vi.advanceTimersByTimeAsync(0);
expect(scheduler.status().sourceWarnings).toEqual(["Roster coverage warning"]);
expect(scheduler.health().warnings).toBe(1);
});
it("reports source failures with redacted errors and clears health on the next successful run", async () => {
@ -456,6 +493,11 @@ describe("Team Reports scheduler lifecycle", () => {
error: "Access failed for [redacted]",
});
expect(context.serviceHealth.reportFailure).toHaveBeenCalledOnce();
expect(scheduler.health().lastRun).toEqual({
status: "error",
kind: "manual",
finishedAtMs: Date.now(),
});
expect(JSON.stringify(context.logger.error.mock.calls)).not.toContain("fixture-github-token");
const succeeded = scheduler.generate();
await vi.advanceTimersByTimeAsync(0);

View file

@ -19,6 +19,12 @@ const RUN_DEADLINE_MS = 45 * 60_000;
const STOP_TIMEOUT_MS = 30_000;
type RunKind = "closed-day" | "intraday" | "manual";
type ActiveRun = { id: string; controller: AbortController; done: Promise<void> };
export type TeamReportsHealth = {
running: boolean;
lastRun?: { status: "ok" | "error"; kind: RunKind; finishedAtMs: number };
nextDueMs?: number;
warnings: number;
};
function nextClosedDayDue(nowMs: number, schedule: TeamReportsConfig["schedule"]): number {
const random = Math.random();
@ -97,6 +103,10 @@ export class TeamReportsScheduler {
}
}
orgs(): string[] {
return this.options.config.github.orgs;
}
people(): Person[] {
return this.roster;
}
@ -108,20 +118,45 @@ export class TeamReportsScheduler {
nextDue: { ...this.due },
runs: this.options.store.listRuns(),
periods: this.options.store.listPeriods(),
sourceWarnings: this.options.store
.listPeriods({ period: "day", limit: 1 })
.flatMap((entry) => {
const stored = this.options.store.getPeriod("day", entry.key);
return stored
? stored.report.sources.github.warnings.concat(
stored.report.sources.discord?.warnings ?? [],
stored.summary?.warnings ?? [],
)
: [];
}),
sourceWarnings: this.sourceWarnings(),
};
}
health(): TeamReportsHealth {
const finished = [
...this.options.store.listRuns(1, { status: "ok" }),
...this.options.store.listRuns(1, { status: "error" }),
].toSorted(
(a, b) => (b.finishedAtMs ?? 0) - (a.finishedAtMs ?? 0) || b.startedAtMs - a.startedAtMs,
)[0];
const due = Object.values(this.due).filter((value) => value !== undefined);
return {
running: this.accepting,
...(finished && finished.status !== "running" && finished.finishedAtMs !== null
? {
lastRun: {
status: finished.status,
kind: finished.kind,
finishedAtMs: finished.finishedAtMs,
},
}
: {}),
...(due.length ? { nextDueMs: Math.min(...due) } : {}),
warnings: this.sourceWarnings().length,
};
}
private sourceWarnings(): string[] {
const latest = this.options.store.listPeriods({ period: "day", limit: 1 })[0];
const stored = latest ? this.options.store.getPeriod("day", latest.key) : undefined;
return stored
? stored.report.sources.github.warnings.concat(
stored.report.sources.discord?.warnings ?? [],
stored.summary?.warnings ?? [],
)
: [];
}
generate(params: { date?: string; intraday?: boolean } = {}): string {
const now = Date.now();
const day = describePeriod("day", params.date ?? now - (params.intraday ? 0 : DAY_MS));

View file

@ -136,6 +136,88 @@ describe("Team Reports storage", () => {
expect(store.listPersonDays("alice")).toHaveLength(1);
});
it("lists stored totals for each period kind and reflects regenerated documents", () => {
const { store } = openStore();
for (const [period, key] of [
["day", "2026-08-20"],
["week", "2026-W34"],
["month", "2026-08"],
] as const) {
const document = report();
document.period = { ...document.period, period, key };
document.activeMembers = 1;
document.totals.github = {
...counts(21),
commits: 3,
prsOpened: 4,
prsMerged: 5,
securityAdvisories: 6,
};
document.totals.discord.messages = 7;
store.upsertPeriod({ report: document, markdown: key });
expect(store.listPeriods({ period })).toEqual([
{
period,
key,
sinceMs: Date.parse("2026-08-20T00:00:00Z"),
untilMs: Date.parse("2026-08-21T00:00:00Z"),
generatedAtMs: Date.parse("2026-08-21T00:00:00Z"),
status: "closed",
activeMembers: 1,
memberCount: 2,
githubTotal: 21,
discordMessages: 7,
commits: 3,
prsOpened: 4,
prsMerged: 5,
securityAdvisories: 6,
},
]);
}
store.upsertPeriod({ report: report("2026-08-20", []), markdown: "refreshed" });
expect(store.listPeriods({ period: "day" })[0]).toMatchObject({
activeMembers: 0,
memberCount: 0,
githubTotal: 0,
discordMessages: 0,
commits: 0,
prsOpened: 0,
prsMerged: 0,
securityAdvisories: 0,
});
});
it("lists all logins since an inclusive day without the individual timeline limit", () => {
const { store } = openStore();
const logins = Array.from(
{ length: 30 },
(_, index) => `member-${String(index).padStart(2, "0")}`,
);
store.upsertPeriod({ report: report("2026-08-18", ["older"]), markdown: "older" });
store.upsertPeriod({ report: report("2026-08-19", logins), markdown: "boundary" });
store.upsertPeriod({ report: report("2026-08-20", ["latest"]), markdown: "latest" });
const days = store.listPersonDaysSince("2026-08-19");
expect(days.map(({ dayKey, login }) => [dayKey, login])).toEqual([
["2026-08-20", "latest"],
...logins.map((login) => ["2026-08-19", login]),
]);
expect(days[1]).toEqual({
dayKey: "2026-08-19",
login: "member-00",
githubTotal: 1,
commits: 1,
prsOpened: 0,
prsMerged: 0,
prsClosed: 0,
issuesOpened: 0,
issuesClosed: 0,
issueComments: 0,
reviewComments: 0,
discordMessages: 2,
});
expect(store.listPersonDaysSince("2026-08-21")).toEqual([]);
});
it("reads half-open day ranges and indexes newest first without projecting week rows onto people", () => {
const { store } = openStore();
for (const key of ["2026-08-18", "2026-08-19", "2026-08-20"]) {

View file

@ -36,6 +36,14 @@ export type PeriodListEntry = {
untilMs: number;
status: "partial" | "closed";
generatedAtMs: number;
activeMembers: number;
memberCount: number;
githubTotal: number;
discordMessages: number;
commits: number;
prsOpened: number;
prsMerged: number;
securityAdvisories: number;
};
export type PersonDay = {
dayKey: string;
@ -230,6 +238,29 @@ export class TeamReportsStore {
"status",
"generated_at_ms as generatedAtMs",
])
// SQLite extracts only the chart totals instead of materializing every report in JavaScript.
.select((eb) => [
eb.fn<number>("json_extract", ["data_json", eb.val("$.activeMembers")]).as("activeMembers"),
eb.fn<number>("json_extract", ["data_json", eb.val("$.memberCount")]).as("memberCount"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.github.total")])
.as("githubTotal"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.discord.messages")])
.as("discordMessages"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.github.commits")])
.as("commits"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.github.prsOpened")])
.as("prsOpened"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.github.prsMerged")])
.as("prsMerged"),
eb
.fn<number>("json_extract", ["data_json", eb.val("$.totals.github.securityAdvisories")])
.as("securityAdvisories"),
])
.orderBy("since_ms", "desc")
.orderBy("period", "asc");
if (options.period) {
@ -260,7 +291,31 @@ export class TeamReportsStore {
options: { since?: string; until?: string; limit?: number } = {},
): PersonDay[] {
this.assertOpen();
let query = this.query
let query = this.selectPersonDays()
.where("login", "=", login.toLowerCase())
.orderBy("day_key", "desc");
if (options.since) {
query = query.where("day_key", ">=", options.since);
}
if (options.until) {
query = query.where("day_key", "<", options.until);
}
return executeSqliteQuerySync(this.db, query.limit(options.limit ?? 28)).rows;
}
listPersonDaysSince(since: string): PersonDay[] {
this.assertOpen();
return executeSqliteQuerySync(
this.db,
this.selectPersonDays()
.where("day_key", ">=", since)
.orderBy("day_key", "desc")
.orderBy("login", "asc"),
).rows;
}
private selectPersonDays() {
return this.query
.selectFrom("team_reports_person_days")
.select([
"day_key as dayKey",
@ -275,16 +330,7 @@ export class TeamReportsStore {
"issue_comments as issueComments",
"review_comments as reviewComments",
"discord_messages as discordMessages",
])
.where("login", "=", login.toLowerCase())
.orderBy("day_key", "desc");
if (options.since) {
query = query.where("day_key", ">=", options.since);
}
if (options.until) {
query = query.where("day_key", "<", options.until);
}
return executeSqliteQuerySync(this.db, query.limit(options.limit ?? 28)).rows;
]);
}
startRun(run: {