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 (

    Action Items

    - {items.length > 0 ? ( + {actionable.length > 0 ? ( - {items.length} + {actionable.length} ) : null}
    @@ -1156,7 +1245,7 @@ export function ActionItemsPanel({ items }: { items: ActionItem[] }) { {/* Section hairline — more-green ruling. */}
    ); 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({

    Tee sets {tees.length > 0 && · {tees.length}}

    - {course && ( + {course && canManageTeam && ( @@ -451,6 +454,7 @@ export function CourseDetailDrawer({ tee={tee} onEdit={() => handleEditTee(tee.id)} canManageTeam={canManageTeam && isSaved} + canEdit={canManageTeam} canDelete={canManageTeam} isDefault={defaultTeeId === tee.id} settingDefault={defaultTeePending === tee.id} @@ -556,11 +560,13 @@ export function CourseDetailDrawer({ } function TeeRow({ - tee, onEdit, canManageTeam = false, canDelete = false, isDefault = false, settingDefault = false, onSetDefault, onDelete, + tee, onEdit, canManageTeam = false, canEdit = false, canDelete = false, isDefault = false, settingDefault = false, onSetDefault, onDelete, }: { tee: GolfCourseTee; onEdit: () => void; canManageTeam?: boolean; + /** Coach-only — hides the tee-set edit affordance from player sessions. */ + canEdit?: boolean; canDelete?: boolean; isDefault?: boolean; settingDefault?: boolean; @@ -635,9 +641,11 @@ function TeeRow({ {isDefault ? 'Default' : 'Set default'} )} - + {canEdit && ( + + )} {canDelete && onDelete && ( + {canManageTeam && ( + + )} {/* ── Search ───────────────────────────────────────────── */} @@ -179,7 +181,7 @@ export function CourseLibraryClient({ {courses.length === 0 ? ( - setCreateOpen(true)} /> + setCreateOpen(true)} canManage={canManageTeam} /> ) : searching ? (
    {filtered.length === 0 ? ( @@ -316,7 +318,7 @@ function Grid({ children }: { children: React.ReactNode }) { ); } -function EmptyState({ onAdd }: { onAdd: () => void }) { +function EmptyState({ onAdd, canManage }: { onAdd: () => void; canManage: boolean }) { return (
    @@ -324,11 +326,15 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {

    No courses yet

    - 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(); + }); +});