diff --git a/src/components/fairway/pages/dashboard/ActionItemsPanel.test.tsx b/src/components/fairway/pages/dashboard/ActionItemsPanel.test.tsx
index 7bb33e3c8..b00044469 100644
--- a/src/components/fairway/pages/dashboard/ActionItemsPanel.test.tsx
+++ b/src/components/fairway/pages/dashboard/ActionItemsPanel.test.tsx
@@ -51,6 +51,55 @@ describe('ActionItemsPanel — count/render coherence', () => {
});
});
+/**
+ * ============================================================================
+ * ActionItemsPanel — announcements are separated, never counted as backlog
+ * ----------------------------------------------------------------------------
+ * Attention model: the "Action Items" badge counts ACTIONABLE work only (tasks
+ * + deadlines). Announcements have no accept/resolve step, so they render in a
+ * clearly-labelled, separately-counted "Announcements" strip and must never
+ * inflate the actionable badge.
+ * ========================================================================== */
+describe('ActionItemsPanel — announcements are informational, not backlog', () => {
+ function mixed(): ActionItem[] {
+ return [
+ { id: 't1', type: 'task', title: 'Task one', date: '2026-07-01' },
+ { id: 't2', type: 'deadline', title: 'Deadline two', date: '2026-07-01', overdue: true },
+ { id: 'a1', type: 'announcement', title: 'Bus leaves at 6am', date: '2026-07-01' },
+ ];
+ }
+
+ it('badge counts only actionable items, not announcements', () => {
+ render();
+ // 2 actionable (task + deadline), announcement excluded from the badge.
+ const heading = screen.getByRole('heading', { name: 'Action Items' });
+ const headerRow = heading.parentElement!;
+ expect(within(headerRow).getByText('2')).toBeInTheDocument();
+ expect(within(headerRow).queryByText('3')).not.toBeInTheDocument();
+ });
+
+ it('renders announcements under their own labelled, separately-counted strip', () => {
+ render();
+ const strip = screen.getByRole('region', { name: 'Announcements' });
+ expect(within(strip).getByText('Bus leaves at 6am')).toBeInTheDocument();
+ expect(within(strip).getByText('1')).toBeInTheDocument();
+ // The announcement must NOT appear in the actionable list.
+ expect(within(strip).queryByText('Task one')).not.toBeInTheDocument();
+ });
+
+ it('an announcement-only payload does not show "All caught up" as if idle', () => {
+ render(
+ ,
+ );
+ // No actionable backlog badge, honest "no tasks" note, announcement shown.
+ expect(screen.getByText('No tasks or deadlines waiting')).toBeInTheDocument();
+ expect(screen.getByText('Team photo Friday')).toBeInTheDocument();
+ expect(screen.queryByText('All caught up')).not.toBeInTheDocument();
+ });
+});
+
/**
* ============================================================================
* ActionItemsPanel — row sized by container, not by content length (audit W2)
diff --git a/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx b/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx
index e157f93e1..09df6c985 100644
--- a/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx
+++ b/src/components/fairway/pages/dashboard/FairwayCoachDashboard.tsx
@@ -99,6 +99,7 @@ import type {
} from '@/app/golf/actions/dashboard-data';
import type { CoachDashboardData } from '@/app/golf/(dashboard)/dashboard/components/coach-dashboard-types';
import { deriveCoachSignal } from './coach-signal';
+import { buildCoachAttentionCounts, splitActionItems } from './attention-queue';
// Fairway TrendChart, lazy + ssr:false (mirrors FairwayPlayerDashboard's
// Scoring Trend chart). recharts' ResponsiveContainer has no real size to
@@ -280,9 +281,25 @@ export function FairwayCoachDashboard({
setTimeout(() => setCopied(false), 2000);
}, [team?.join_code]);
+ // ONE canonical "needs you" count (attention-queue.ts): actionable tasks +
+ // pending roster approvals, announcements excluded. The hero, the approvals
+ // banner and the Action Items panel all now speak from this same total
+ // instead of each telling a different story. Approvals come from the same
+ // pending join-requests already fetched for the banner (no extra query);
+ // getTeamJoinRequests() returns pending-only, but we filter defensively so a
+ // self-fetched fallback can't over-count.
+ const attention = useMemo(
+ () =>
+ buildCoachAttentionCounts(
+ enhancedData?.actionItems ?? [],
+ (joinRequests ?? []).filter((r) => r.status === 'pending').length,
+ ),
+ [enhancedData?.actionItems, joinRequests],
+ );
+
const signal = useMemo(
- () => deriveCoachSignal(enhancedData, stats.rosterSize),
- [enhancedData, stats.rosterSize],
+ () => deriveCoachSignal(enhancedData, stats.rosterSize, attention),
+ [enhancedData, stats.rosterSize, attention],
);
const hasTrend = !!teamScoringTrend && teamScoringTrend.length >= 2;
@@ -1125,8 +1142,77 @@ function formatRelativeDate(dateStr: string, now: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
+/** One action row — reused by the actionable-task list and the announcements
+ * strip below it. `now` is the client clock (null until mounted) so relative
+ * dates stay hydration-safe. */
+function ActionItemRow({ item, now }: { item: ActionItem; now: Date | null }) {
+ const isUrgent = item.priority === 'high' || item.priority === 'urgent';
+ return (
+
+
+ {/* `overflow-hidden` is the hard backstop for the title's `truncate`
+ below — without it, a long single-line title can bleed past this
+ row's own rounded edge instead of ellipsizing at it (#957). */}
+
+
+ {item.overdue ? (
+
+ ) : item.type === 'announcement' ? (
+
+ ) : isUrgent ? (
+
+ ) : (
+
+ )}
+
+
+
+ {item.title}
+
+
+
+ {now ? formatRelativeDate(item.date, now) : ''}
+
+ {item.overdue ? (
+
+ Overdue
+
+ ) : isUrgent ? (
+
+ {item.priority === 'urgent' ? 'Urgent' : 'High'}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
/** Exported for a deterministic render test (W1 count-coherence audit) — the
- * header count badge must always describe exactly what's rendered below it. */
+ * header count badge must always describe exactly what's rendered below it.
+ *
+ * Attention model (audit "five surfaces, one story"): this panel is the
+ * ACTIONABLE backlog only — tasks and deadlines. Its header badge counts
+ * exactly those, matching the hero's "needs you" total (which adds pending
+ * approvals, surfaced in their own banner above). Announcements are NOT
+ * actionable — they have no accept/resolve step — so they render as a clearly
+ * separated, uncounted "Announcements" strip and never inflate the backlog. */
export function ActionItemsPanel({ items }: { items: ActionItem[] }) {
// Defer relative-date computation to the client to avoid a hydration mismatch.
const [now, setNow] = useState(null);
@@ -1134,14 +1220,17 @@ export function ActionItemsPanel({ items }: { items: ActionItem[] }) {
setNow(new Date());
}, []);
+ const { actionable, announcements } = splitActionItems(items);
+ const isEmpty = actionable.length === 0 && announcements.length === 0;
+
return (
@@ -1156,7 +1245,7 @@ export function ActionItemsPanel({ items }: { items: ActionItem[] }) {
{/* Section hairline — more-green ruling. */}
- {items.length === 0 ? (
+ {isEmpty ? (
) : (
-
- {/* W1 count-coherence audit fix: render the FULL `items` list, not a
- slice(0, 6) — the header badge above (and the hero's "N items are
- waiting on you" in coach-signal.ts, which sources the SAME
- `enhancedData.actionItems` array) both state the true count, so a
- truncated render disagreed with its own header on every team with
- more than 6 open items. The upstream builder already bounds this
- list (dashboard-data.ts caps tasks + announcements combined), so
- rendering all of it is still a finite, calm digest — never an
- unbounded list. */}
-
-
- {/* `overflow-hidden` is the hard backstop for the title's `truncate`
- below — without it, a long single-line title can bleed past this
- row's own rounded edge instead of ellipsizing at it (#957). */}
-
-
- {item.overdue ? (
-
- ) : item.type === 'announcement' ? (
-
- ) : isUrgent ? (
-
- ) : (
-
- )}
-
-
-
- {item.title}
-
-
-
- {now ? formatRelativeDate(item.date, now) : ''}
-
- {item.overdue ? (
-
- Overdue
-
- ) : isUrgent ? (
-
- {item.priority === 'urgent' ? 'Urgent' : 'High'}
-
- ) : null}
-
-
-
-
-
- );
- })}
-
-
+
+ {/* Actionable tasks / deadlines — the backlog the header badge counts.
+ Render the FULL list (W1 count-coherence fix: no slice(0, 6) that
+ disagreed with its own header). The upstream builder already caps
+ this list, so it stays a finite, calm digest. */}
+ {actionable.length > 0 ? (
+
+
+ {actionable.map((item) => (
+
+ ))}
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Announcements — informational, NOT part of the "needs you" count.
+ Clearly labelled and separately counted so they never masquerade
+ as backlog the coach has to clear. */}
+ {announcements.length > 0 ? (
+
+
+
+ Announcements
+
+
+ {announcements.length}
+
+
+
+
+ {announcements.map((item) => (
+
+ ))}
+
+
+
+ ) : null}
+
)}
);
diff --git a/src/components/fairway/pages/dashboard/attention-queue.test.ts b/src/components/fairway/pages/dashboard/attention-queue.test.ts
new file mode 100644
index 000000000..d6de2fb98
--- /dev/null
+++ b/src/components/fairway/pages/dashboard/attention-queue.test.ts
@@ -0,0 +1,105 @@
+/**
+ * ============================================================================
+ * attention-queue — canonical coach "needs you" count (audit: one story)
+ * ----------------------------------------------------------------------------
+ * The deepest dashboard finding was that the hero, the approvals banner and the
+ * Action Items panel each told a different story about what needs the coach:
+ * · hero counted actionItems.length (tasks + announcements) and its copy
+ * claimed "approvals" it never counted;
+ * · pending join-request approvals were counted by nothing;
+ * · announcements (no accept/resolve step) inflated the "waiting on you" total.
+ * These tests pin the one honest rule: total = actionable tasks + approvals,
+ * announcements always excluded, and a breakdown string that only ever names
+ * the parts that are actually present.
+ * ========================================================================== */
+import { describe, it, expect } from 'vitest';
+
+import {
+ buildCoachAttentionCounts,
+ splitActionItems,
+ attentionBreakdown,
+} from './attention-queue';
+import type { ActionItem } from '@/app/golf/actions/dashboard-data';
+
+const task = (id: string, over = false): ActionItem => ({
+ id,
+ type: over ? 'deadline' : 'task',
+ title: `Task ${id}`,
+ date: '2026-07-01',
+ overdue: over,
+});
+const announcement = (id: string): ActionItem => ({
+ id,
+ type: 'announcement',
+ title: `Announcement ${id}`,
+ date: '2026-07-01',
+});
+
+describe('splitActionItems', () => {
+ it('routes tasks and deadlines to actionable, announcements aside, preserving order', () => {
+ const items = [task('a'), announcement('b'), task('c', true), announcement('d')];
+ const { actionable, announcements } = splitActionItems(items);
+ expect(actionable.map((i) => i.id)).toEqual(['a', 'c']);
+ expect(announcements.map((i) => i.id)).toEqual(['b', 'd']);
+ });
+
+ it('handles an empty list', () => {
+ expect(splitActionItems([])).toEqual({ actionable: [], announcements: [] });
+ });
+});
+
+describe('buildCoachAttentionCounts', () => {
+ it('total = actionable tasks + approvals; announcements excluded from total', () => {
+ const items = [task('a'), task('b', true), announcement('c')];
+ const counts = buildCoachAttentionCounts(items, 2);
+ expect(counts).toEqual({ tasks: 2, approvals: 2, announcements: 1, total: 4 });
+ });
+
+ it('announcements NEVER inflate the "needs you" total (the old bug)', () => {
+ const items = [announcement('a'), announcement('b'), announcement('c')];
+ const counts = buildCoachAttentionCounts(items, 0);
+ expect(counts.total).toBe(0);
+ expect(counts.announcements).toBe(3);
+ });
+
+ it('counts approvals even when there are no tasks', () => {
+ expect(buildCoachAttentionCounts([], 3)).toEqual({
+ tasks: 0,
+ approvals: 3,
+ announcements: 0,
+ total: 3,
+ });
+ });
+
+ it('clamps a negative / non-integer approvals count to a safe zero-floor', () => {
+ expect(buildCoachAttentionCounts([task('a')], -5).approvals).toBe(0);
+ expect(buildCoachAttentionCounts([task('a')], 2.9).approvals).toBe(2);
+ });
+});
+
+describe('attentionBreakdown', () => {
+ it('names both parts when both are present', () => {
+ expect(attentionBreakdown({ tasks: 3, approvals: 2, announcements: 0, total: 5 })).toBe(
+ '3 tasks and 2 approvals',
+ );
+ });
+
+ it('singularizes correctly', () => {
+ expect(attentionBreakdown({ tasks: 1, approvals: 1, announcements: 0, total: 2 })).toBe(
+ '1 task and 1 approval',
+ );
+ });
+
+ it('names only the non-zero part, so the copy is never a lie', () => {
+ expect(attentionBreakdown({ tasks: 4, approvals: 0, announcements: 0, total: 4 })).toBe(
+ '4 tasks',
+ );
+ expect(attentionBreakdown({ tasks: 0, approvals: 2, announcements: 0, total: 2 })).toBe(
+ '2 approvals',
+ );
+ });
+
+ it('is empty when nothing needs the coach', () => {
+ expect(attentionBreakdown({ tasks: 0, approvals: 0, announcements: 3, total: 0 })).toBe('');
+ });
+});
diff --git a/src/components/fairway/pages/dashboard/attention-queue.ts b/src/components/fairway/pages/dashboard/attention-queue.ts
new file mode 100644
index 000000000..928e5963c
--- /dev/null
+++ b/src/components/fairway/pages/dashboard/attention-queue.ts
@@ -0,0 +1,77 @@
+/**
+ * ============================================================================
+ * Fairway · Coach dashboard — canonical "needs you" attention model (PURE)
+ * ----------------------------------------------------------------------------
+ * ONE source of truth for "what needs the coach." Before this, the dashboard
+ * told the coach three different stories: the hero counted `actionItems.length`
+ * (tasks + announcements) and its copy claimed "approvals" it never counted;
+ * the join-request approvals lived in a separate banner counted by nothing; and
+ * announcements (which have no read/unread and require no action) inflated the
+ * "waiting on you" number as if they were backlog.
+ *
+ * This module derives one honest count from data the dashboard ALREADY holds
+ * (no new query, no I/O): the "needs you" total is actionable tasks + pending
+ * approvals. Announcements are informational and counted separately, never as
+ * backlog. Signals and Team Pulse keep their own, genuinely-different metrics
+ * (analytical signals; roster trend) — this is only the operational "act on it"
+ * queue, so the surfaces stop competing for the word "attention."
+ * ========================================================================== */
+
+import type { ActionItem } from '@/app/golf/actions/dashboard-data';
+
+export interface AttentionCounts {
+ /** Actionable tasks + overdue deadlines (an overdue task is a 'deadline'). */
+ tasks: number;
+ /** Pending roster join-request approvals. */
+ approvals: number;
+ /** Team announcements — informational, NEVER counted as "waiting on you." */
+ announcements: number;
+ /** The honest "needs you" total: actionable tasks + approvals. */
+ total: number;
+}
+
+/** Split the raw dashboard action items into the actionable queue (tasks +
+ * deadlines) and the informational announcements strip. Order is preserved
+ * from the upstream builder (tasks are already due-date sorted). */
+export function splitActionItems(items: ReadonlyArray): {
+ actionable: ActionItem[];
+ announcements: ActionItem[];
+} {
+ const actionable: ActionItem[] = [];
+ const announcements: ActionItem[] = [];
+ for (const it of items) {
+ if (it.type === 'announcement') announcements.push(it);
+ else actionable.push(it); // 'task' | 'deadline'
+ }
+ return { actionable, announcements };
+}
+
+/** The canonical count. `pendingApprovals` is the number of pending roster
+ * join requests (already fetched for the approvals banner). */
+export function buildCoachAttentionCounts(
+ items: ReadonlyArray,
+ pendingApprovals: number,
+): AttentionCounts {
+ const { actionable, announcements } = splitActionItems(items);
+ const approvals = Math.max(0, pendingApprovals | 0);
+ return {
+ tasks: actionable.length,
+ approvals,
+ announcements: announcements.length,
+ total: actionable.length + approvals,
+ };
+}
+
+/** Noun-phrase breakdown of the "needs you" total, e.g. "3 tasks and 2
+ * approvals" — only names the parts that are non-zero, so the copy is always
+ * true. Returns a bare fragment (no verb): the hero composes the sentence so
+ * the body never echoes the "N need you" verb already in the headline. */
+export function attentionBreakdown(c: AttentionCounts): string {
+ const parts: string[] = [];
+ if (c.tasks > 0) parts.push(`${c.tasks} ${c.tasks === 1 ? 'task' : 'tasks'}`);
+ if (c.approvals > 0)
+ parts.push(`${c.approvals} ${c.approvals === 1 ? 'approval' : 'approvals'}`);
+ if (parts.length === 0) return '';
+ if (parts.length === 1) return parts[0]!;
+ return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
+}
diff --git a/src/components/fairway/pages/dashboard/coach-signal.test.ts b/src/components/fairway/pages/dashboard/coach-signal.test.ts
new file mode 100644
index 000000000..b934e7768
--- /dev/null
+++ b/src/components/fairway/pages/dashboard/coach-signal.test.ts
@@ -0,0 +1,71 @@
+/**
+ * ============================================================================
+ * coach-signal — honest hero count sourced from the canonical attention model
+ * ----------------------------------------------------------------------------
+ * Before the attention model, the hero counted actionItems.length (tasks +
+ * announcements) and its body claimed "Tasks, approvals, and deadlines" while
+ * approvals were counted by nothing. These tests pin the fix: when the canonical
+ * AttentionCounts is passed, the hero number IS that total (tasks + approvals,
+ * announcements excluded) and the body only names parts that truly exist.
+ * ========================================================================== */
+import { describe, it, expect } from 'vitest';
+
+import { deriveCoachSignal } from './coach-signal';
+import type { AttentionCounts } from './attention-queue';
+import type { CoachDashboardPayload } from '@/app/golf/actions/dashboard-data';
+
+const counts = (c: Partial): AttentionCounts => ({
+ tasks: 0,
+ approvals: 0,
+ announcements: 0,
+ total: 0,
+ ...c,
+});
+
+// Minimal payload — the attention path only needs actionItems to exist; the
+// canonical count is passed explicitly.
+const payload = (actionItemCount: number): CoachDashboardPayload =>
+ ({
+ actionItems: Array.from({ length: actionItemCount }, (_, i) => ({
+ id: `x${i}`,
+ type: 'task',
+ title: `t${i}`,
+ date: '2026-07-01',
+ })),
+ }) as unknown as CoachDashboardPayload;
+
+describe('deriveCoachSignal — attention-sourced hero', () => {
+ it('hero total = tasks + approvals (approvals finally counted)', () => {
+ const s = deriveCoachSignal(payload(3), 10, counts({ tasks: 3, approvals: 2, total: 5 }));
+ expect(s.priority).toBe('high');
+ expect(s.title).toBe('5 items need you');
+ expect(s.body).toBe('3 tasks and 2 approvals flagged from your roster this week.');
+ expect(s.insufficient).toBe(false);
+ });
+
+ it('announcements do NOT lift the hero into the high state', () => {
+ // 2 announcements present, but zero actionable + zero approvals → total 0,
+ // so the hero must fall THROUGH to a non-action signal, not claim work.
+ const s = deriveCoachSignal(payload(2), 10, counts({ announcements: 2, total: 0 }));
+ expect(s.title).not.toMatch(/need you/);
+ });
+
+ it('singular copy at exactly one item', () => {
+ const s = deriveCoachSignal(payload(1), 10, counts({ tasks: 1, total: 1 }));
+ expect(s.title).toBe('1 item needs you');
+ expect(s.body).toBe('1 task flagged from your roster this week.');
+ });
+
+ it('never claims "approvals" when there are none', () => {
+ const s = deriveCoachSignal(payload(2), 10, counts({ tasks: 2, total: 2 }));
+ expect(s.body).toBe('2 tasks flagged from your roster this week.');
+ expect(s.body).not.toMatch(/approval/);
+ });
+
+ it('legacy call (no attention) falls back to raw count and drops the approvals claim', () => {
+ const s = deriveCoachSignal(payload(4), 10);
+ expect(s.title).toBe('4 items need you');
+ expect(s.body).toBe('Tasks and deadlines flagged from your roster this week.');
+ expect(s.body).not.toMatch(/approval/);
+ });
+});
diff --git a/src/components/fairway/pages/dashboard/coach-signal.ts b/src/components/fairway/pages/dashboard/coach-signal.ts
index a413f8c5a..68663ceb5 100644
--- a/src/components/fairway/pages/dashboard/coach-signal.ts
+++ b/src/components/fairway/pages/dashboard/coach-signal.ts
@@ -18,6 +18,7 @@
* ========================================================================== */
import type { CoachDashboardPayload } from '@/app/golf/actions/dashboard-data';
+import { attentionBreakdown, type AttentionCounts } from './attention-queue';
export interface CoachSignal {
/** Drives the InsightCard priority tint + lead icon. */
@@ -37,30 +38,39 @@ export interface CoachSignal {
/**
* Build the calm CoachHelm signal headline. Greedy by importance:
- * 1. action items waiting (real count) → high
- * 2. a measurable team mover this week → medium
- * 3. rounds logged this week (activity proof) → low
- * 4. today's events on the calendar → info
- * 5. nothing real yet → insufficient
+ * 1. the canonical "needs you" queue (real count) → high
+ * 2. a measurable team mover this week → medium
+ * 3. rounds logged this week (activity proof) → low
+ * 4. today's events on the calendar → info
+ * 5. nothing real yet → insufficient
+ *
+ * The `attention` arg is the ONE canonical count (see attention-queue.ts):
+ * actionable tasks + pending approvals, announcements excluded. When present
+ * the hero sources its number and its honest breakdown from it, so the "N need
+ * you" headline finally ties to the same total the approvals banner and the
+ * Action Items panel show. When omitted (legacy callers / unit tests), it
+ * falls back to the raw action-item count and drops the "approvals" claim it
+ * can no longer back.
*/
export function deriveCoachSignal(
enhanced: CoachDashboardPayload | null | undefined,
rosterSize: number,
+ attention?: AttentionCounts,
): CoachSignal {
- const actionCount = enhanced?.actionItems?.length ?? 0;
+ const actionCount = attention ? attention.total : enhanced?.actionItems?.length ?? 0;
const roundsThisWeek = enhanced?.teamPulse?.roundsThisWeek ?? 0;
const topMover = enhanced?.teamPulse?.topMover;
const todayCount = enhanced?.todayEvents?.length ?? 0;
if (actionCount > 0) {
+ const breakdown = attention ? attentionBreakdown(attention) : '';
return {
priority: 'high',
overline: 'CoachHelm · This week',
- title:
- actionCount === 1
- ? '1 item is waiting on you'
- : `${actionCount} items are waiting on you`,
- body: 'Tasks, approvals, and deadlines flagged from your roster this week.',
+ title: actionCount === 1 ? '1 item needs you' : `${actionCount} items need you`,
+ body: breakdown
+ ? `${breakdown} flagged from your roster this week.`
+ : 'Tasks and deadlines flagged from your roster this week.',
insufficient: false,
};
}
diff --git a/src/components/golf/courses/CourseDetailDrawer.tsx b/src/components/golf/courses/CourseDetailDrawer.tsx
index dafeb751c..95db19f62 100644
--- a/src/components/golf/courses/CourseDetailDrawer.tsx
+++ b/src/components/golf/courses/CourseDetailDrawer.tsx
@@ -238,8 +238,11 @@ export function CourseDetailDrawer({
// #913 part 2 — a course with no human creator shipped with the shared
// library; only a super admin may edit or remove it. Team/user-contributed
// courses (created_by_user_id set) keep the existing open-contribution model.
+ // Course-scoping player gate: every branch requires the coach role first —
+ // a player session must never see edit/upload/remove affordances, library
+ // course or not.
const isLibraryOwned = course ? course.created_by_user_id == null : false;
- const canEditCourse = !isLibraryOwned || isSuperAdmin;
+ const canEditCourse = canManageTeam && (!isLibraryOwned || isSuperAdmin);
return (
<>
@@ -425,7 +428,7 @@ export function CourseDetailDrawer({
- Add the courses your team plays. Each course can hold multiple tee sets with their own pars and yardages.
+ {canManage
+ ? 'Add the courses your team plays. Each course can hold multiple tee sets with their own pars and yardages.'
+ : 'Your coach hasn’t added any courses yet.'}
-
+ {canManage && (
+
+ )}
);
}
diff --git a/src/test/golf/components/CourseDetailDrawerPlayerGate.test.tsx b/src/test/golf/components/CourseDetailDrawerPlayerGate.test.tsx
new file mode 100644
index 000000000..47e37743e
--- /dev/null
+++ b/src/test/golf/components/CourseDetailDrawerPlayerGate.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * Course-scoping player gate (owner decision: keep the course library OPEN
+ * for browsing — players can view courses and pick one for a round — but
+ * hide every create/edit/delete control from PLAYER-role sessions; coaches
+ * keep full access).
+ *
+ * Root cause: `canEditCourse` in CourseDetailDrawer only checked
+ * library-ownership / super-admin status, never the viewer's role, and the
+ * "Add tee" button plus the per-tee "Edit" button had NO gate at all. So a
+ * player viewing any non-library (team/user-contributed) course saw "Edit
+ * course", the photo upload/replace/remove controls, "Add tee", and the
+ * per-tee "Edit" button — this test pins the fix by rendering the drawer
+ * with a non-library course (created_by_user_id set) under both roles.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import type { GolfCourse, GolfCourseTee } from '@/lib/types/golf-course';
+
+vi.mock('@/components/ui/sonner', () => ({ useToast: () => ({ showToast: vi.fn() }) }));
+vi.mock('@/lib/golf/upload-course-image', () => ({ uploadCourseImage: vi.fn() }));
+
+const baseCourse: GolfCourse = {
+ id: 'course-1',
+ name: 'Test Course',
+ city: 'Chapel Hill',
+ state: 'NC',
+ country: 'USA',
+ course_rating: 72.1,
+ slope_rating: 130,
+ default_tee_name: null,
+ default_tee_color: null,
+ total_yardage: 6800,
+ total_par: 72,
+ created_by: 'user-1',
+ is_public: true,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ normalized_name: 'test course',
+ slug: 'test-course',
+ address: '1 Fairway Dr',
+ website: null,
+ // Non-library (team/user-contributed) course: created_by_user_id is set, so
+ // the pre-fix `canEditCourse = !isLibraryOwned || isSuperAdmin` was TRUE
+ // for every viewer, coach or player. Has a photo so "Replace"/"Remove
+ // photo" are both reachable in the coach assertions below.
+ image_url: 'https://example.com/course.jpg',
+ source: 'manual',
+ created_by_user_id: 'user-1',
+ created_by_team_id: null,
+ last_edited_by_user_id: null,
+ last_edited_by_team_id: null,
+ last_edited_at: null,
+ deleted_at: null,
+};
+
+const baseTee: GolfCourseTee = {
+ id: 'tee-1',
+ course_id: 'course-1',
+ tee_name: 'Championship',
+ normalized_tee_name: 'championship',
+ tee_color: 'Blue',
+ category: 'mens',
+ total_yards: 6800,
+ total_par: 72,
+ course_rating: 72.1,
+ slope_rating: 130,
+ holes_count: 18,
+ source: 'manual',
+ is_draft: false,
+ created_by_user_id: 'user-1',
+ created_by_team_id: null,
+ last_edited_by_user_id: null,
+ last_edited_by_team_id: null,
+ last_edited_at: null,
+ deleted_at: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+};
+
+vi.mock('@/app/golf/actions/course-library', () => ({
+ getCourseDetail: vi.fn(async () => ({ course: baseCourse, tees: [baseTee] })),
+ getCourseTeeHoles: vi.fn(async () => ({})),
+ getTeeWithHoles: vi.fn(async () => null),
+ saveTeamCourse: vi.fn(async () => ({ success: true })),
+ unsaveTeamCourse: vi.fn(async () => ({ success: true })),
+ setCourseImageUrl: vi.fn(async () => ({ success: true })),
+ removeCourseImage: vi.fn(async () => ({ success: true })),
+ setTeamCoursePinned: vi.fn(async () => ({ success: true })),
+ setTeamCourseDefaultTee: vi.fn(async () => ({ success: true })),
+ softDeleteCourse: vi.fn(async () => ({ success: true })),
+ softDeleteTee: vi.fn(async () => ({ success: true })),
+}));
+
+import { CourseDetailDrawer } from '@/components/golf/courses/CourseDetailDrawer';
+
+describe('CourseDetailDrawer — course-scoping player gate', () => {
+ it('a player session renders none of the create/edit/delete controls', async () => {
+ render(
+ {}}
+ canManageTeam={false}
+ isSuperAdmin={false}
+ savedCourseIds={new Set()}
+ />,
+ );
+
+ // Wait for the course + tee to load (unique text — the drawer title and
+ // the hero heading both render "Test Course", so anchor on the tee name).
+ expect(await screen.findByText('Championship')).toBeInTheDocument();
+
+ // Course-level mutation controls
+ expect(screen.queryByRole('button', { name: /Edit course/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /Remove course/i })).not.toBeInTheDocument();
+ // Photo controls (upload/replace/remove)
+ expect(screen.queryByRole('button', { name: /Replace|Add photo/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove photo' })).not.toBeInTheDocument();
+ // Tee-set controls
+ expect(screen.queryByRole('button', { name: /Add tee/i })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: `Edit ${baseTee.tee_name}` }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: `Delete ${baseTee.tee_name} tee set` }),
+ ).not.toBeInTheDocument();
+ // Team-scoped actions stay coach-only too
+ expect(screen.queryByRole('button', { name: /Save to team/i })).not.toBeInTheDocument();
+ });
+
+ it('a coach session renders every create/edit/delete control', async () => {
+ render(
+ {}}
+ canManageTeam
+ isSuperAdmin={false}
+ savedCourseIds={new Set()}
+ />,
+ );
+
+ expect(await screen.findByText('Championship')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: /Edit course/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Remove course/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Replace/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Remove photo' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add tee/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: `Edit ${baseTee.tee_name}` })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: `Delete ${baseTee.tee_name} tee set` }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/test/golf/components/CourseLibraryClientPlayerGate.test.tsx b/src/test/golf/components/CourseLibraryClientPlayerGate.test.tsx
new file mode 100644
index 000000000..677a692ac
--- /dev/null
+++ b/src/test/golf/components/CourseLibraryClientPlayerGate.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * Course-scoping player gate (owner decision: keep the course library OPEN
+ * for browsing — players can view courses and pick one for a round — but
+ * hide every create/edit/delete control from PLAYER-role sessions; coaches
+ * keep full access).
+ *
+ * CourseLibraryClient owns the page-level "+ Add course" button and the
+ * empty-state "Add your first course" CTA — this pins both to `canManageTeam`
+ * (the coach-role signal the server page already resolves and passes down),
+ * so a player session never sees a create affordance while the library
+ * itself stays fully browsable.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import type { GolfCourse } from '@/lib/types/golf-course';
+
+vi.mock('@/components/ui/sonner', () => ({ useToast: () => ({ showToast: vi.fn() }) }));
+vi.mock('@/lib/golf/upload-course-image', () => ({ uploadCourseImage: vi.fn() }));
+// Mounted-but-closed children (CourseDetailDrawer, CourseFormDrawer) still
+// import the server-action module at module-eval time, so it must be
+// mocked even though no mutation runs in this test.
+vi.mock('@/app/golf/actions/course-library', () => ({
+ getCourseDetail: vi.fn(async () => null),
+ getCourseTeeHoles: vi.fn(async () => ({})),
+ getTeeWithHoles: vi.fn(async () => null),
+ saveTeamCourse: vi.fn(async () => ({ success: true })),
+ unsaveTeamCourse: vi.fn(async () => ({ success: true })),
+ setCourseImageUrl: vi.fn(async () => ({ success: true })),
+ removeCourseImage: vi.fn(async () => ({ success: true })),
+ setTeamCoursePinned: vi.fn(async () => ({ success: true })),
+ setTeamCourseDefaultTee: vi.fn(async () => ({ success: true })),
+ softDeleteCourse: vi.fn(async () => ({ success: true })),
+ softDeleteTee: vi.fn(async () => ({ success: true })),
+ createCourse: vi.fn(),
+ updateCourse: vi.fn(),
+}));
+
+import { CourseLibraryClient } from '@/components/golf/courses/CourseLibraryClient';
+
+const course: GolfCourse = {
+ id: 'course-1',
+ name: 'Test Course',
+ city: 'Chapel Hill',
+ state: 'NC',
+ country: 'USA',
+ course_rating: 72.1,
+ slope_rating: 130,
+ default_tee_name: null,
+ default_tee_color: null,
+ total_yardage: 6800,
+ total_par: 72,
+ created_by: 'user-1',
+ is_public: true,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ normalized_name: 'test course',
+};
+
+describe('CourseLibraryClient — course-scoping player gate', () => {
+ it('a player session renders no "+ Add course" control, with or without courses', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /Add course/i })).not.toBeInTheDocument();
+ // The library stays browsable.
+ expect(screen.getByRole('button', { name: `Open ${course.name}` })).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText('No courses yet')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /Add your first course/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('a coach session renders the "+ Add course" control, with or without courses', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /Add course/i })).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /Add your first course/i })).toBeInTheDocument();
+ });
+});