Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 115 additions & 14 deletions src/app/admin/_components/FeatureDotGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ function byStatusRank(a: FeatureHealth, b: FeatureHealth): number {
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
}

/** Mirrors the group header's own "N healthy · M no data" phrasing (this
* file, header rollup below) so the below-`md` disclosure CTA never claims
* a flat "healthy" count for a set that also folds in neutral (no
* feature-tagged data) chips. */
function formatCollapsedSummary(counts: Record<FeatureStatus, number>): string {
const parts: string[] = [];
if (counts.green > 0) parts.push(`${counts.green} healthy`);
if (counts.neutral > 0) parts.push(`${counts.neutral} no data`);
return parts.join(' · ');
}

function FeatureChip({
feature,
selected,
Expand All @@ -68,19 +79,32 @@ function FeatureChip({
aria-pressed={selected}
aria-label={`${feature.label}: ${STATUS_WORD[feature.status]} — ${feature.reason}`}
className={cn(
'h-auto min-h-0 w-full justify-start gap-2 rounded-xl border px-3 py-2 text-left normal-case',
'h-auto min-h-0 w-full rounded-xl border px-3 py-2 text-left normal-case',
richGreen
? 'border-accent-500/35 bg-accent-50/60 hover:bg-accent-50'
: 'border-border-subtle bg-surface',
Comment on lines +82 to 85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the canonical design-token families.

src/app/admin/_components/FeatureDotGrid.tsx:82-85,159-179,202-203 introduces accent-* classes, while the repository rules require primary-*, destructive, warm-*, and cream-* tokens. Replace these with the corresponding canonical tokens.

[details]

- 'border-accent-500/35 bg-accent-50/60 hover:bg-accent-50'
+ 'border-primary-600/35 bg-cream-50/60 hover:bg-cream-50'

[/details]

As per coding guidelines, only canonical design-system color families are permitted.
As per path instructions, src/**/*.{tsx,css} must use canonical design-system tokens.

Also applies to: 159-179, 202-203

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/FeatureDotGrid.tsx` around lines 82 - 85, Replace
all noncanonical accent-* color classes in FeatureDotGrid with the appropriate
canonical design-token families, using primary-* for positive/accent states and
destructive, warm-*, or cream-* where semantically applicable. Update the
affected class strings and conditional styling around the identified button/grid
sections, ensuring no accent-* tokens remain.

Sources: Coding guidelines, Path instructions

)}
>
<StatusPill tone={TONE_FOR_STATUS[feature.status]} dot size="sm" className="min-w-0">
<Icon size={12} aria-hidden />
<span className="truncate">{feature.label}</span>
</StatusPill>
<span className="ml-auto flex flex-shrink-0 items-center gap-1 font-fw-mono text-xs tabular-nums text-warm-500" aria-hidden>
<span>{TREND_ARROW[feature.trend]}</span>
<span>{feature.drillIn.warnings24h + feature.topSignatures.reduce((n, s) => n + s.count, 0)}</span>
{/* Button always wraps its `children` in a single inner `<span>`
(src/components/fairway/controls/button.tsx) — passing two
sibling elements there (as this row used to) mixes an inline
box (StatusPill) with a block-level one (a `flex` span), which
the CSS inline-formatting-context rules split onto its own line,
silently breaking the trailing trend/count out from the label AND
making `ml-auto` a no-op (no flex ancestor to push against). One
wrapping row here — passed as Button's ONE child — owns the flex
context itself instead, so identity (icon + label) and the 2
key stats (trend, incident count) render as one true row, not two
stacked ones — the card-ify treatment doctrine rule 8 asks for. */}
<span className="flex w-full min-w-0 items-center justify-between gap-2">
<StatusPill tone={TONE_FOR_STATUS[feature.status]} dot size="sm" className="min-w-0">
<Icon size={12} aria-hidden />
<span className="truncate">{feature.label}</span>
</StatusPill>
<span className="flex flex-shrink-0 items-center gap-1 font-fw-mono text-xs tabular-nums text-warm-500" aria-hidden>
<span>{TREND_ARROW[feature.trend]}</span>
<span>{feature.drillIn.warnings24h + feature.topSignatures.reduce((n, s) => n + s.count, 0)}</span>
</span>
</span>
</Button>
);
Expand All @@ -99,16 +123,93 @@ function FeatureGroup({
}) {
const selected = features.find((f) => f.key === selectedKey) ?? null;
const sorted = [...features].sort(byStatusRank);
// Below `md` only: green AND neutral chips collapse behind one disclosure
// per group (Mobile Doctrine rule 3 — BaseballHelm alone registers ~48
// features; a flat single-column list at 390px would run 15+
// screen-heights). Neutral (no feature-tagged data yet) must fold too:
// feature tagging only began 2026-07-02, so a group can still be mostly
// neutral this soon after instrumentation — leaving it uncollapsed blows
// the cap even with green hidden. Toggling shows them at every
// breakpoint; `md:block` below always wins at `md` and up, so desktop
// keeps rendering every chip, untouched.
const [showHealthy, setShowHealthy] = useState(false);

const counts = sorted.reduce(
(acc, f) => {
acc[f.status] += 1;
return acc;
},
{ red: 0, amber: 0, neutral: 0, green: 0 } as Record<FeatureStatus, number>,
);
const needsEyes = counts.red + counts.amber;
// Collapsed-by-default set = green AND neutral (REPAIR: neutral used to
// stay in the flat list uncollapsed — feature tagging only began
// 2026-07-02, so a group can be mostly neutral this soon after
// instrumentation and still blow the rule-3 ~3-screen-height cap even
// with green folded away). The header rollup above already reports both
// counts ("N healthy · M no data"), so the CTA mirrors that phrasing.
const collapsedCount = counts.green + counts.neutral;

return (
<section aria-label={heading} className="min-w-0">
<Eyebrow as="h2" tone="secondary">
{heading}
</Eyebrow>
{/* Rollup header: the app label plus an at-a-glance status count, so
a thumb scanning the Health tab knows whether a lane needs eyes
before reading a single chip — the whole point of a daily triage
surface (rule 1's spirit applied to this sub-view). */}
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 border-b border-accent-600/25 pb-2">
<Eyebrow as="h2" tone="secondary">
{heading}
</Eyebrow>
<p className="font-fw-mono text-xs tabular-nums text-warm-500">
{needsEyes > 0 ? (
<>
{counts.red > 0 ? <span className="font-semibold text-fw-danger">{counts.red} red</span> : null}
{counts.red > 0 && counts.amber > 0 ? ' · ' : null}
{counts.amber > 0 ? <span className="font-semibold text-fw-warning-ink">{counts.amber} amber</span> : null}
</>
) : counts.neutral > 0 ? (
// Neutral (no feature-tagged data yet) is never relabeled
// "healthy" — same honesty rule the page's own copy states.
<span>
{counts.green} healthy · {counts.neutral} no data
</span>
) : (
<span className="text-accent-700">{counts.green} healthy</span>
)}
</p>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((f) => (
<FeatureChip key={f.key} feature={f} selected={f.key === selectedKey} onSelect={onSelect} />
))}
{sorted.map((f) => {
const collapsedOnPhone = f.status !== 'red' && f.status !== 'amber' && !showHealthy;
return (
<div key={f.key} className={cn('min-w-0', collapsedOnPhone && 'hidden md:block')}>
<FeatureChip feature={f} selected={f.key === selectedKey} onSelect={onSelect} />
</div>
);
})}
</div>
{collapsedCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
fullWidth
onClick={() => setShowHealthy((s) => !s)}
aria-expanded={showHealthy}
className={cn(
'mt-2 justify-start whitespace-normal rounded-xl px-3 py-2 text-left text-xs font-medium md:hidden',
needsEyes === 0 && !showHealthy
? 'bg-fw-success-bg text-accent-700 hover:bg-fw-success-bg/80'
: 'text-warm-500 hover:text-warm-700',
)}
>
{showHealthy
? 'Hide healthy & no-data features'
: needsEyes === 0
? `All ${formatCollapsedSummary(counts)} — show list →`
: `Show ${formatCollapsedSummary(counts)} →`}
Comment on lines +199 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not style an all-neutral group as healthy.

At src/app/admin/_components/FeatureDotGrid.tsx:199-210, a group with 0 green and only neutral features satisfies needsEyes === 0, producing a success-colored CTA and the text All 0 healthy · N no data. Use the success treatment only when counts.green > 0 && counts.neutral === 0.

[details]

- needsEyes === 0 && !showHealthy
+ needsEyes === 0 && counts.green > 0 && counts.neutral === 0 && !showHealthy
...
- needsEyes === 0
+ needsEyes === 0 && counts.green > 0 && counts.neutral === 0

[/details]

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
className={cn(
'mt-2 justify-start whitespace-normal rounded-xl px-3 py-2 text-left text-xs font-medium md:hidden',
needsEyes === 0 && !showHealthy
? 'bg-fw-success-bg text-accent-700 hover:bg-fw-success-bg/80'
: 'text-warm-500 hover:text-warm-700',
)}
>
{showHealthy
? 'Hide healthy & no-data features'
: needsEyes === 0
? `All ${formatCollapsedSummary(counts)} — show list →`
: `Show ${formatCollapsedSummary(counts)} →`}
className={cn(
'mt-2 justify-start whitespace-normal rounded-xl px-3 py-2 text-left text-xs font-medium md:hidden',
needsEyes === 0 && counts.green > 0 && counts.neutral === 0 && !showHealthy
? 'bg-fw-success-bg text-accent-700 hover:bg-fw-success-bg/80'
: 'text-warm-500 hover:text-warm-700',
)}
>
{showHealthy
? 'Hide healthy & no-data features'
: needsEyes === 0 && counts.green > 0 && counts.neutral === 0
? `All ${formatCollapsedSummary(counts)} — show list →`
: `Show ${formatCollapsedSummary(counts)} →`}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/FeatureDotGrid.tsx` around lines 199 - 210, Update
the CTA styling and label logic in the FeatureDotGrid render block so an
all-neutral group is not treated as healthy: apply the success class and “All …”
healthy wording only when counts.green > 0 && counts.neutral === 0, while
retaining the existing needsEyes handling for other groups.

</Button>
) : null}
{selected ? <FeatureHealthCard feature={selected} /> : null}
</section>
);
Expand Down
10 changes: 8 additions & 2 deletions src/app/admin/_components/LocalTime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from 'react';

export type LocalTimeVariant = 'time' | 'datetime';
export type LocalTimeVariant = 'time' | 'datetime' | 'date';

/**
* Renders a timestamp in the VIEWER's local timezone without causing a
Expand Down Expand Up @@ -40,7 +40,13 @@ export function LocalTime({

useEffect(() => {
const d = new Date(iso);
setLabel(variant === 'time' ? d.toLocaleTimeString() : d.toLocaleString());
setLabel(
variant === 'time'
? d.toLocaleTimeString()
: variant === 'date'
? d.toLocaleDateString()
: d.toLocaleString(),
);
}, [iso, variant]);

return <span suppressHydrationWarning>{label ?? fallback}</span>;
Expand Down
7 changes: 6 additions & 1 deletion src/app/admin/_components/PanelStates.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CheckCircle2, Inbox, CloudOff } from 'lucide-react';
import { LocalTime } from './LocalTime';

/** All-clear ≠ no-data ≠ fetch-failed. Three distinct states so a silent
* dashboard is never mistaken for a healthy system. */
Expand All @@ -9,7 +10,11 @@ export function PanelAllClear({ label, checkedAt }: { label: string; checkedAt:
<CheckCircle2 size={20} className="text-fw-success" aria-hidden />
<p className="text-sm font-medium text-accent-700">{label}</p>
<p className="font-fw-mono text-xs tabular-nums text-warm-500">
checked {new Date(checkedAt).toLocaleTimeString()}
{/* LocalTime, not toLocaleTimeString(): rendered from Server
Components, the raw call bakes in the server's UTC clock — the
viewer sees a wrong-timezone time with no marker (same bug class
LocalTime.tsx documents). */}
checked <LocalTime iso={checkedAt} />
</p>
</div>
);
Expand Down
58 changes: 50 additions & 8 deletions src/app/admin/_components/TeamHealthTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from 'next/link';
import { StatusPill } from '@/components/fairway';
import { cn } from '@/lib/utils';
import type { TeamHealth } from '@/lib/admin/data/golf';
import { LocalTime } from './LocalTime';

const HEALTH_TONE: Record<TeamHealth, 'success' | 'warning' | 'danger'> = {
active: 'success',
Expand All @@ -24,11 +25,12 @@ export interface TeamHealthEntry {
/**
* Shared with W9 (baseball) / W10 (users) — sport-agnostic.
*
* PHONE-FORMAT RESPONSIVE (owner directive 2026-07-02): `overflow-x-auto`
* scopes the horizontal scroll to the table itself (never the page), and the
* first column stays `sticky` so the team's identity is never scrolled out
* of view on a 375px viewport. Mirrors the cron-board table pattern in
* `/admin/jobs`.
* MOBILE (doctrine Rule 8, 2026-07-10): below `md` each team renders as a
* full-width tap-through card row (identity + roster/last-activity line +
* health pill + honest error count) — the min-w table would otherwise force
* a horizontal scroller on a phone-primary reading surface, which Rule 8
* bans even when scroll-contained. The table (sticky identity column,
* `overflow-x-auto` scoped to itself) still owns `md` and up, unchanged.
*
* GREEN CONTRACT (Bridge V2, 2026-07-02): a hairline helm-green rule under
* the header, heavy graphite (never green) numerals for roster/error counts,
Expand All @@ -40,7 +42,46 @@ export interface TeamHealthEntry {
*/
export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) {
return (
<div className="overflow-x-auto">
<>
{/* Phone: doctrine-8 card rows, whole row is the link. */}
<div className="divide-y divide-warm-200/60 md:hidden">
{teams.map((t) => {
const isLeader = t.health === 'active' && t.errors7d === 0;
return (
<Link
key={t.teamId}
href={t.href ?? `/admin/teams/${t.teamId}`}
className={cn(
'block rounded-fw-md px-2 py-3 transition-colors hover:bg-surface-sunken',
isLeader && 'bg-accent-50',
)}
>
{/* Dateline rule — replaces the retired border-l-2 leader stripe. */}
{isLeader && <span aria-hidden className="mb-1 block h-[2px] w-7 rounded-full bg-accent-500" />}
Comment on lines +55 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use canonical tokens for the mobile leader treatment.

src/app/admin/_components/TeamHealthTable.tsx:55-60 adds bg-accent-50 and bg-accent-500. Use the approved primary-* or cream-* equivalents instead.

As per coding guidelines, only canonical design-system color families are permitted.
As per path instructions, src/**/*.{tsx,css} must use canonical design-system tokens.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/TeamHealthTable.tsx` around lines 55 - 60, Replace
the noncanonical accent color classes in the TeamHealthTable leader treatment:
update isLeader’s `bg-accent-50` and the dateline span’s `bg-accent-500` to the
appropriate approved `primary-*` or `cream-*` design-system tokens, preserving
the existing styling and behavior.

Sources: Coding guidelines, Path instructions

<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-warm-900">{t.name}</p>
<p className="mt-1 font-fw-mono text-xs tabular-nums text-warm-500">
{t.playerCount} players · last{' '}
{t.lastActivity ? <LocalTime iso={t.lastActivity} variant="date" fallback="never" /> : 'never'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use a non-semantic hydration fallback for real timestamps.

LocalTime at src/app/admin/_components/LocalTime.tsx:30-53 renders fallback during SSR and the first client render. With fallback="never", every non-null timestamp initially appears as “last never”; reserve “never” for the existing null branches and use or loading… here.

[details]

- <LocalTime iso={t.lastActivity} variant="date" fallback="never" />
+ <LocalTime iso={t.lastActivity} variant="date" fallback="—" />

[/details]

Also applies to: 112-112

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/TeamHealthTable.tsx` at line 66, Update the
non-null timestamp branches in TeamHealthTable’s last-activity rendering to pass
a non-semantic hydration fallback such as “—” or “loading…” to LocalTime, while
retaining “never” only for null lastActivity branches.

</p>
</div>
<StatusPill tone={HEALTH_TONE[t.health]} dot size="sm" className="shrink-0">
{t.health}
</StatusPill>
</div>
{t.errors7d > 0 ? (
<p className="mt-2 font-fw-mono text-xs font-semibold tabular-nums text-fw-danger">
{t.errors7d} errors this week
</p>
) : null}
</Link>
);
})}
</div>

{/* md+: the original sticky-identity table, byte-for-byte. */}
<div className="hidden overflow-x-auto md:block">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-accent-600/25 text-left text-xs uppercase tracking-widest text-warm-500">
Expand Down Expand Up @@ -68,7 +109,7 @@ export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) {
</td>
<td className="px-3 font-fw-mono font-semibold tabular-nums text-warm-900">{t.playerCount}</td>
<td className="px-3 font-fw-mono text-xs tabular-nums text-warm-600">
{t.lastActivity ? new Date(t.lastActivity).toLocaleDateString() : 'never'}
{t.lastActivity ? <LocalTime iso={t.lastActivity} variant="date" fallback="never" /> : 'never'}
</td>
<td className="px-3">
<StatusPill tone={HEALTH_TONE[t.health]} dot size="sm">
Expand All @@ -88,6 +129,7 @@ export function TeamHealthTable({ teams }: { teams: TeamHealthEntry[] }) {
})}
</tbody>
</table>
</div>
</div>
</>
);
}
17 changes: 13 additions & 4 deletions src/app/admin/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fetchAuthTab, fetchActiveSessions } from '@/lib/admin/data/auth';
import {
StatusPill,
MetricCard,
StatStrip,
TrendChart,
InlineNotice,
Surface,
Expand All @@ -13,6 +14,7 @@ import { SportBadge, type BridgeSport } from '../_components/SportBadge';
import { PanelBoundary } from '../_components/PanelBoundary';
import { PanelAllClear, PanelNoData } from '../_components/PanelStates';
import { AutoRefresh } from '../_components/AutoRefresh';
import { LocalTime } from '../_components/LocalTime';

export const dynamic = 'force-dynamic';

Expand All @@ -37,7 +39,14 @@ async function AuthBody() {
</InlineNotice>
) : null}

<section className="grid gap-3 md:grid-cols-3">
{/* StatStrip (docs/MOBILE_DOCTRINE.md rule 11): below md this was a bare
`grid` with no column count, so each MetricCard fell back to one
full-bleed row — three stacked monolith cards on a 390px phone.
StatStrip's count=3 phone shape (2-col + a full-width 3rd cell) plus
mdColumns=3 reproduces the original `md:grid-cols-3` desktop recipe
byte-for-byte, matching the same migration already done in
admin/golf, admin/baseball, and admin/page.tsx. */}
<StatStrip count={3} columns={3} mdColumns={3} ariaLabel="Signup funnel KPIs">
<MetricCard label="Signups · 7d" value={tab.funnel.signups7d} tone="neutral" />
<MetricCard label="Activated within 7d" value={tab.funnel.activated7d} tone="neutral" />
<MetricCard
Expand All @@ -47,7 +56,7 @@ async function AuthBody() {
decimals={1}
tone="neutral"
/>
</section>
</StatStrip>

<TrendChart
title="Sign-ins, last 7 days"
Expand Down Expand Up @@ -89,7 +98,7 @@ async function AuthBody() {
</span>
{isLocked && lockedUntilDate ? (
<span className="font-fw-mono text-xs tabular-nums text-fw-danger">
until {lockedUntilDate.toLocaleTimeString()}
until <LocalTime iso={lockedUntilDate.toISOString()} variant="time" />
</span>
) : null}
</li>
Expand Down Expand Up @@ -131,7 +140,7 @@ async function AuthBody() {
</span>
<SportBadge sport={(row.sport as BridgeSport) ?? null} />
<span className="font-fw-mono text-xs tabular-nums text-warm-500">
{new Date(row.created_at).toLocaleString()}
<LocalTime iso={row.created_at} variant="datetime" />
</span>
</li>
))}
Expand Down
Loading
Loading