Skip to content
Closed
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
7 changes: 6 additions & 1 deletion src/app/baseball/actions/stat-visual-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ function normalizeVisualKey(key: string): string | null {

export const getStatVisualViews = withBaseballAction(
'getStatVisualViews',
FEATURE,
// Pure SELECT — no write a demo visitor could use to alter state another
// visitor would then see, so it's safe (and correct) for the shared
// Baseball demo coach session. Without this, the demo account hit the
// fail-closed default and every load threw BaseballDemoReadOnlyError,
// surfacing as a permanent "Saved views unavailable" toast in the gallery.
{ ...FEATURE, demoSafe: true },
async (
ctx,
input?: { playerId?: string | null },
Expand Down
13 changes: 12 additions & 1 deletion src/app/golf/(dashboard)/dashboard/coachhelm/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,18 @@ export default async function PlayerCoachHelmPage() {
const { coach, player } = session;

if (!player) {
if (coach) return <NotPlayerState />;
if (coach) {
// A coach landing on the PLAYER CoachHelm dashboard has their own
// coach-facing Brief — send them straight there instead of an
// interstitial that just points at "the roster page". The interstitial
// stays as a fallback for the genuine players-only edge case: a coach
// record with no resolved organization yet (mid-onboarding), where the
// Brief route can't resolve a team either.
if (coach.organization_id) {
redirect('/golf/dashboard/intelligence');
}
return <NotPlayerState />;
}
return redirect('/golf/player');
}

Expand Down
43 changes: 37 additions & 6 deletions src/components/baseball/stat-visuals/use-stat-visual-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ import {
} from '@/app/baseball/actions/stat-visual-views';
import type { StatVisualSavedView } from './StatVisualsSection';

/**
* A load failure is expected/non-actionable — not "your saved views are
* broken" — when it's a permission denial (sanitizeDbError's RLS-denial
* message) or the shared demo-account read-only guard. Neither is something
* the viewer can act on, so it stays silent; a genuine load failure (network,
* unexpected DB error) still toasts for the owner. Matched case-insensitively
* against a substring since a thrown server-action error crossing the RSC
* boundary can lose its exact message in production.
*/
function isSilentLoadFailure(message: string | null | undefined): boolean {
if (!message) return false;
const m = message.toLowerCase();
return m.includes('permission') || m.includes('live demo');
}

export interface UseStatVisualViewsResult {
savedViews: StatVisualSavedView[];
onSaveView: (input: {
Expand Down Expand Up @@ -67,14 +82,23 @@ export function useStatVisualViews(playerId?: string | null): UseStatVisualViews

useEffect(() => {
let cancelled = false;
// Toast id of any "load failed" warning this effect fired, so the
// cleanup below can dismiss it — a route change unmounts this hook's
// owner but sonner's Toaster is mounted once at the app root, so a
// toast fired on the way out would otherwise sit on screen through the
// navigation instead of disappearing with the surface that raised it.
let pendingToastId: string | number | null = null;

void getStatVisualViews({ playerId: playerId ?? null })
.then((res) => {
if (cancelled) return;
if (!res.success || !res.data) {
toast.warning(
'Saved views unavailable',
res.error ?? 'Could not load your saved chart settings.',
);
if (!isSilentLoadFailure(res.error)) {
pendingToastId = toast.warning(
'Saved views unavailable',
res.error ?? 'Could not load your saved chart settings.',
);
}
return;
}
const loaded: StatVisualSavedView[] = res.data.map((v) => ({
Expand All @@ -85,13 +109,20 @@ export function useStatVisualViews(playerId?: string | null): UseStatVisualViews
setSavedViews(loaded);
savedViewsRef.current = loaded;
})
.catch(() => {
.catch((err: unknown) => {
if (cancelled) return;
// Network-level or unexpected throw — non-fatal, gallery still renders.
toast.warning('Saved views unavailable', 'Could not load your saved chart settings.');
const message = err instanceof Error ? err.message : null;
if (!isSilentLoadFailure(message)) {
pendingToastId = toast.warning(
'Saved views unavailable',
'Could not load your saved chart settings.',
);
}
});
return () => {
cancelled = true;
if (pendingToastId !== null) toast.dismiss(pendingToastId);
};
}, [playerId]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ export function StatsCenterClient({
{ label: 'On the Record', value: model.summary.playersWithData, emphasis: model.summary.playersWithData > 0 },
{ label: 'Official Games', value: model.summary.officialGames },
{ label: 'Scrimmages', value: model.summary.scrimmages },
{ label: 'Needs Recalc', value: model.summary.unreconciled, emphasis: model.summary.unreconciled > 0 },
{ label: 'Pending Updates', value: model.summary.unreconciled, emphasis: model.summary.unreconciled > 0 },
]}
/>
</div>
Expand Down
22 changes: 19 additions & 3 deletions src/components/fairway/pages/coachhelm/AskConversationRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ const ORIGIN_ICON: Record<AskOriginKind, React.ComponentType<{ size?: number; cl
round: Flag,
};

/**
* The rail's own "Threads" bezel heading — a ReactNode, NOT a plain string,
* so InstrumentPanel skips its default `truncate` class (only applied when
* `header` is a string). This rail is a narrow sidebar column that always
* hits InstrumentPanel's `sm:flex-row` side-by-side bezel (that breakpoint
* reads the VIEWPORT, not this panel's own rendered width), so on a desktop
* viewport the heading was squeezed against the thread-count readout down to
* "Thr…" even though "Threads" easily fits on its own line. Dropping
* `truncate` lets it wrap instead of hard-ellipsizing.
*/
const THREADS_HEADER = (
<h3 className="font-fw-display text-h3 font-semibold leading-tight text-text-primary">
Threads
</h3>
);

/** Relative date — only ever called with a real ISO string (never fabricated). */
function formatRelativeDate(iso: string): string {
const then = new Date(iso).getTime();
Expand Down Expand Up @@ -149,7 +165,7 @@ export function AskConversationRail({
<InstrumentPanel
depth="base"
padding="md"
header="Threads"
header={THREADS_HEADER}
className={cn('flex flex-col', className)}
aria-busy="true"
>
Expand All @@ -173,7 +189,7 @@ export function AskConversationRail({
<InstrumentPanel
depth="base"
padding="md"
header="Threads"
header={THREADS_HEADER}
readout={countReadout}
className={cn('flex flex-col', className)}
>
Expand All @@ -192,7 +208,7 @@ export function AskConversationRail({
as="nav"
depth="base"
padding="md"
header="Threads"
header={THREADS_HEADER}
readout={countReadout}
aria-label="Conversations"
className={cn('flex flex-col', className)}
Expand Down
21 changes: 9 additions & 12 deletions src/components/fairway/pages/coachhelm/AskWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { cn } from '@/lib/utils';
import type { ChatConversation, ChatMessage } from '@/lib/coachhelm/v3/chat/types';
import {
CoachHelmShell,
type CoachHelmCrumb,
} from './CoachHelmShell';
import { CoachHelmShell } from './CoachHelmShell';
import { useCoachChatSend } from './useCoachChatSend';
import {
AskConversationRail,
Expand Down Expand Up @@ -169,13 +166,14 @@ export function AskWorkspace({
// Resolve the open thread's origin for the thread-pane context header.
const activeOrigin = conversationId ? originById?.[conversationId] : undefined;

// Shell breadcrumb leaf: CoachHelm > Ask > <thread title> (when one is open).
const activeTitle = conversationId
? conversations.find((c) => c.id === conversationId)?.title?.trim()
: undefined;
const breadcrumbs: CoachHelmCrumb[] | undefined = activeTitle
? [{ label: activeTitle }]
: undefined;
// NOTE: this used to also pass a single-crumb `breadcrumbs={[{ label: activeTitle }]}`
// leaf (the open thread's title) to CoachHelmShell. With only one crumb and no
// href, CoachHelmShell's breadcrumb nav renders it with none of its usual
// affordances (no separator chevron, no linked ancestor) — just the thread's
// title as a bare span sitting between the masthead and the sub-nav tab bar,
// reading as a stray unstyled echo of whatever question opened the thread
// rather than a real breadcrumb trail. The rail already highlights the open
// thread, so it carried no navigational value — dropped rather than restyled.

const description =
conversations.length === 0
Expand All @@ -192,7 +190,6 @@ export function AskWorkspace({
signalCount={signalCount}
title="Ask CoachHelm"
description={description}
breadcrumbs={breadcrumbs}
className={className}
>
{/* ── The two-pane inbox: a conversation rail + a thread pane, both flat
Expand Down
9 changes: 7 additions & 2 deletions src/components/fairway/pages/rounds/FairwayRoundCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ export function FairwayRoundCard({ round, isBestOfPeriod, userRole }: FairwayRou
const hasPutts = round.total_putts !== null;
const hasAnyMicroStat = hasPutts || fir !== null || gir !== null;

const city = [round.course_city, round.course_state].filter(Boolean).join(', ');
// A bare state code with no city ("Va") reads as a stray, unlabeled
// fragment — only render a location when there's an actual city to anchor
// it (course_state alone is dropped, not shown bare).
const city = round.course_city
? [round.course_city, round.course_state].filter(Boolean).join(', ')
: null;

return (
<Link
Expand Down Expand Up @@ -186,7 +191,7 @@ export function FairwayRoundCard({ round, isBestOfPeriod, userRole }: FairwayRou
)}
</div>
<Badge tone="neutral" size="sm" numeric className="flex-shrink-0">
{holesPlayed}h
{holesPlayed} {holesPlayed === 1 ? 'hole' : 'holes'}
</Badge>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,9 @@ function ScorecardNine({
const scoreTotal =
total ?? holes.reduce((s, h) => s + (finite(h.score) ?? 0), 0);
const puttTotal = holes.reduce((s, h) => s + (finite(h.putts) ?? 0), 0);
// Golf convention: the front nine's total column reads "Out", the back
// nine's reads "In" — both nines were previously hard-coded to "Out".
const totalColumnLabel = label === 'Front' ? 'Out' : 'In';

return (
<div className="flex flex-col">
Expand Down Expand Up @@ -650,7 +653,7 @@ function ScorecardNine({
{holes.map((h) => (
<Th key={h.hole_number}>{h.hole_number}</Th>
))}
<Th className="bg-surface-tint">Out</Th>
<Th className="bg-surface-tint">{totalColumnLabel}</Th>
</tr>
</thead>
<tbody>
Expand Down
11 changes: 9 additions & 2 deletions src/components/fairway/pages/rounds/FairwayRoundRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ export function FairwayRoundRow({ round, isBestOfPeriod, userRole }: FairwayRoun
const putts = round.total_putts;
const hasAnyMicroStat = putts !== null || fir !== null || gir !== null;

const city = [round.course_city, round.course_state].filter(Boolean).join(', ');
// A bare state code with no city ("Va") reads as a stray, unlabeled
// fragment next to the type chip — only render a location when there's an
// actual city to anchor it (course_state alone is dropped, not shown bare).
const city = round.course_city
? [round.course_city, round.course_state].filter(Boolean).join(', ')
: null;

return (
<Link
Expand Down Expand Up @@ -110,7 +115,9 @@ export function FairwayRoundRow({ round, isBestOfPeriod, userRole }: FairwayRoun
{getRoundTypeLabel(round.round_type)}
</Chip>
{city && <span className="truncate">{city}</span>}
<span className="flex-shrink-0 tabular-nums">· {holesPlayed}h</span>
<span className="flex-shrink-0 tabular-nums">
· {holesPlayed} {holesPlayed === 1 ? 'hole' : 'holes'}
</span>
</div>

{/* Mobile-only condensed stat line — the quick stats are hidden on phones
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ function UnfinishedRow({
const holesTarget = round.holes_played ?? 18;
const currentHole = round.current_hole ?? 0;
const isSetup = !currentHole;
const city = [round.course_city, round.course_state].filter(Boolean).join(', ');
// A bare state code with no city ("Va") reads as a stray, unlabeled
// fragment — only render a location when there's an actual city to anchor
// it (course_state alone is dropped, not shown bare).
const city = round.course_city
? [round.course_city, round.course_state].filter(Boolean).join(', ')
: null;
const timeAgo = relativeTime(round);

const handleContinue = () => {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/baseball/read-models/__tests__/command-center.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ describe('getCommandCenter — game-context via the #379 shared legacy adapter',
expect(pulse!.careerAvg).toBeCloseTo(0.318, 3);
expect(pulse!.recentTrend).toBe('improving');
expect(pulse!.totalSessions).toBe(12);
// "On the Record" (summary.playersWithData) is the OFFICIAL-record count
// (Stats Center's definition), not the broader box-score+practice
// `rosterPulse` — this player has legacy/practice sessions only (no
// box-score games), so `pulse.noData` is false but the KPI is honestly
// 0. Before this fix, summary.playersWithData was derived from
// rosterPulse and would have read 1 here, drifting from Stats Center's
// own "0 players with data" for the identical roster.
expect(result.summary.playersWithData).toBe(0);
});

it('no-data: a player with neither legacy nor box-score rows is honestly no-data, never a fabricated average', async () => {
Expand Down Expand Up @@ -366,6 +374,9 @@ describe('getCommandCenter — game-context via the #379 shared legacy adapter',
// recentTrend has no canonical replacement yet — the permanent legacy
// carve-out still surfaces it even for a box-score-sourced player.
expect(pulse!.recentTrend).toBe('stable');
// This player DOES have an official box-score game, so "On the Record"
// agrees with Stats Center's own count for the identical roster.
expect(result.summary.playersWithData).toBe(1);
});

it('pitching-only box-score data this season must NOT mask a real legacy career_avg fallback', async () => {
Expand Down
14 changes: 13 additions & 1 deletion src/lib/baseball/read-models/command-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,19 @@ export async function getCommandCenter(
}
}

const playersWithData = rosterPulse.filter((p) => !p.noData).length;
// "On the Record" is the SAME figure as Stats Center's own KPI — both now
// read getStatsCenter()'s summary rather than each deriving its own count.
// Previously this counted `rosterPulse`'s broader box-score+practice
// `totalSessions`, so a team with only practice sessions logged (zero
// official games) showed a nonzero Command Center figure against Stats
// Center's honest 0 — the two surfaces drifted on what "on the record"
// means. `getStatsCenter()`'s `noData` is officially-recorded games/
// box-score events only (batting/pitching/catching/fielding/baserunning),
// which is what "on the record" is meant to convey; that's the one
// definition both surfaces now share.
const playersWithData = statsCenterModel.authorized
? statsCenterModel.summary.playersWithData
: 0;
const criticalRisks = riskFeed.filter((r) => r.severity === 'critical').length;

return {
Expand Down
Loading