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
151 changes: 151 additions & 0 deletions scripts/backfill-qualifier-round-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* backfill-qualifier-round-tags.ts — one-time backfill for
* `golf_rounds.round_type` on rounds that ARE linked to a qualifier
* (`qualifier_id IS NOT NULL`) but were never tagged `round_type = 'qualifier'`.
*
* Symptom (#916): the Qualifiers tab correctly shows completed qualifiers with
* results — `updateQualifierEntryStats()` (src/app/golf/actions/golf.ts)
* aggregates a qualifier's results purely by `qualifier_id` + `status =
* 'completed'`, ignoring `round_type` entirely — but the Rounds list's
* "Qualifier" format filter (src/components/fairway/pages/rounds/
* FairwayRoundsLibrary.tsx) filters purely by `round_type`, so a round that
* has `qualifier_id` set but `round_type` stuck at something else (`practice`,
* `tournament`, or the legacy `qualifying` spelling — the filter already
* accepts `qualifying` too) never shows under "Qualifier" there, even though
* it counts toward the qualifier's results.
*
* Root cause (write path, fixed alongside this script): the legacy/offline
* draft-save action `saveRoundDraft` (src/app/golf/actions/round-drafts.ts)
* never wrote `qualifier_id`/`qualifier_round_number` to `golf_rounds` at all
* — any round that passed through that write path before the fix could end up
* with the qualifier link set by an earlier/later save but a stale
* `round_type`. This script reconciles rows that already drifted before the
* fix landed; it does NOT need to run again for rounds created after it.
*
* Idempotent: only touches rows where `qualifier_id IS NOT NULL AND
* round_type NOT IN ('qualifier', 'qualifying')`. Re-running after a first
* successful pass finds zero matching rows and is a no-op. UPDATE only (one
* column, `round_type`) — no inserts, no deletes, no other columns touched.
*
* Connection modeled on scripts/backfill-baseball-slash-lines.ts: env from
* .env.local, `createClient(url, key, { auth: { persistSession: false,
* autoRefreshToken: false } })`. Dry-run by default (prints the plan, writes
* nothing) — pass --confirm to write.
*
* SCRIPT ONLY — per repo policy this is reviewed in the PR and is NOT run by
* the author. A human runs it (dry-run first) after review.
*
* Run:
* DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/backfill-qualifier-round-tags.ts # dry run
* DOTENV_CONFIG_PATH=.env.local npx tsx -r dotenv/config scripts/backfill-qualifier-round-tags.ts --confirm # write
*
* Requires env: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
*/
import { config as loadEnv } from 'dotenv';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';

loadEnv({ path: '.env.local' });

const DRY = !process.argv.includes('--confirm');

interface MistaggedRoundRow {
id: string;
player_id: string;
qualifier_id: string;
round_type: string | null;
status: string | null;
round_date: string;
}

/** Page through a table past PostgREST's 1000-row default cap, with a stable order. */
async function fetchAllRows<T>(
build: (from: number, to: number) => PromiseLike<{ data: T[] | null; error: { message: string } | null }>,
): Promise<T[]> {
const PAGE = 1000;
const out: T[] = [];
for (let from = 0; ; from += PAGE) {
const { data, error } = await build(from, from + PAGE - 1);
if (error) throw error;
const rows = data ?? [];
out.push(...rows);
if (rows.length < PAGE) break;
}
return out;
}

async function main() {
const url = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').trim();
const key = (process.env.SUPABASE_SERVICE_ROLE_KEY ?? '').trim();
if (!url || !key) throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
const supabase: SupabaseClient = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});

console.log(
`${DRY ? '[DRY RUN] printing plan, writing NOTHING. Re-run with --confirm.\n' : ''}` +
`Backfilling golf_rounds.round_type for qualifier-linked rounds...\n`,
);

// Fetch every round with a qualifier_id set, then filter client-side for a
// round_type that isn't already 'qualifier'/'qualifying' — keeps the query
// a single simple .not.is filter (no OR-of-NOT-IN needed against PostgREST).
const rows = await fetchAllRows<MistaggedRoundRow>((from, to) =>
supabase
.from('golf_rounds')
.select('id, player_id, qualifier_id, round_type, status, round_date')
.not('qualifier_id', 'is', null)
.order('id', { ascending: true })
.range(from, to)
.returns<MistaggedRoundRow[]>(),
);

const mistagged = rows.filter(
(r) => r.round_type !== 'qualifier' && r.round_type !== 'qualifying',
);

if (mistagged.length === 0) {
console.log(`Scanned ${rows.length} qualifier-linked round(s). All already tagged 'qualifier' — nothing to backfill.`);
return;
}

console.log(`Found ${mistagged.length} of ${rows.length} qualifier-linked round(s) with the wrong round_type.\n`);

let changed = 0;
let errors = 0;

for (const row of mistagged) {
const label = `round ${row.id.slice(0, 8)} (player ${row.player_id.slice(0, 8)}, qualifier ${row.qualifier_id.slice(0, 8)}, ${row.round_date}, status=${row.status ?? 'unknown'})`;
console.log(` ${DRY ? '[DRY] would update' : '✓ updating'} ${label}: round_type '${row.round_type ?? 'null'}' → 'qualifier'`);

if (!DRY) {
const { error: updateError } = await supabase
.from('golf_rounds')
.update({ round_type: 'qualifier' })
.eq('id', row.id)
// Idempotency guard against a concurrent write between the SELECT
// above and this UPDATE: only touch the row if it's still mistagged.
.not('round_type', 'in', '(qualifier,qualifying)');

if (updateError) {
console.warn(` ⚠ ${label}: update failed — ${updateError.message}`);
errors++;
continue;
}
}

changed++;
}

console.log(`\n${DRY ? '[DRY RUN] ' : ''}Done. mistagged=${mistagged.length} ${DRY ? 'would-update' : 'updated'}=${changed} errors=${errors}`);

if (errors > 0) process.exitCode = 1;

if (DRY) {
console.log('\nRe-run with --confirm to write.');
}
}

main().catch((err) => {
console.error('Backfill failed:', err);
process.exit(1);
});
7 changes: 6 additions & 1 deletion src/app/golf/(dashboard)/dashboard/development/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import type { FairwayGoalCardData } from '@/components/fairway/pages/coachhelm/F
import { surfaceName } from '@/lib/golf/surface-registry';

export const metadata: Metadata = {
title: `${surfaceName('development')} | Helm Golf`,
// The browser tab should match the masthead tab identity the user actually
// clicked ('Players', the coachhelm-tab surface-registry entry) — not the
// page-content identity ('Development Plans', the 'page' group entry for
// the SAME href). Both entries are intentional per surface-registry.ts's
// two-name-level design; only the <title> was still reading the wrong one (#917).
title: `${surfaceName('players-tab')} | Helm Golf`,
description: 'Manage player development plans and focus areas for your team.',
};

Expand Down
9 changes: 5 additions & 4 deletions src/app/golf/(dashboard)/dashboard/stats/team/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ViewHeader, EmptyState, Button } from '@/components/fairway';
import { fetchAllRows, fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows';
import { getTeamLeakMaps } from '@/app/golf/actions/stats-leak-maps';
import { loadPlayersStandingMap } from '@/lib/coachhelm/v3/standing/loader';
import { calculatePuttsPerRound } from '@/lib/golf/putts-per-round';
import type { Metadata } from 'next';

export const metadata: Metadata = {
Expand Down Expand Up @@ -328,10 +329,10 @@ export default async function TeamStatsPage() {
// carry a putts value (totalHolesWithPutts), NOT every scored hole — the
// numerator only summed holes with a non-null putts, so dividing by all
// scored holes (Σ holes_played) understated putts/round whenever some holes
// lacked a recorded putt count.
const puttsPerRound = totalHolesWithPutts > 0 && totalPutts > 0
? (totalPutts / totalHolesWithPutts) * 18
: null;
// lacked a recorded putt count. Shared with the player stats cockpit
// (src/lib/utils/golf-stats-calculator-shots.ts) via calculatePuttsPerRound
// so the two surfaces can never disagree on the same player again (#917).
const puttsPerRound = calculatePuttsPerRound(totalPutts, totalHolesWithPutts);

// Birdies per round: normalize to 18-hole equivalent
// golf_holes.score is stored per-hole — null values indicate pre-score-tracking rounds
Expand Down
14 changes: 14 additions & 0 deletions src/app/golf/actions/round-drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ async function saveRoundDraftImpl(
total_score: null as null,
score_to_par: null as null,
total_putts: null as null,
// Qualifier linkage (#916): `data.selectedQualifierId`/`selectedRoundNumber`
// carry the qualifier a draft round belongs to (set by the round-setup
// UI's qualifier picker), but this record never wrote them to
// golf_rounds — so any round that passed through this (legacy/offline-
// sync) draft-save path silently lost its qualifier_id even when
// round_type correctly said 'qualifier', and never showed up in the
// qualifier's results. Only include the field when the caller
// explicitly supplied it, so a draft-save that doesn't carry qualifier
// context (e.g. an older offline queue entry) can never clobber a
// qualifier_id an earlier save already set.
...(data.selectedQualifierId !== undefined ? { qualifier_id: data.selectedQualifierId } : {}),
...(data.selectedRoundNumber !== undefined
? { qualifier_round_number: data.selectedRoundNumber }
: {}),
};

const hasTrackedRoundData = async (roundId: string): Promise<boolean> => {
Expand Down
14 changes: 11 additions & 3 deletions src/components/fairway/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,24 @@ const TableCheckbox = React.forwardRef<
);
});

/** Sort affordance — three states (none / asc / desc) drawn with chevrons. */
/**
* Sort affordance — three states (none / asc / desc) drawn with two stacked
* chevrons (▲ over ▼). The two SVGs must keep a visible gap between them: with
* NO gap (the previous `-mb-px` overlap) and both triangles the same muted
* color in the default unsorted state, the up-triangle's wide base and the
* down-triangle's wide base touch seamlessly and read as one solid diamond
* glyph rather than two chevrons — the stray "♦" reported on every sortable
* column header (#917), most visible on the right-aligned numeric columns.
*/
function SortGlyph({ dir }: { dir: false | 'asc' | 'desc' }) {
return (
<span aria-hidden="true" className="ml-1.5 inline-flex flex-col leading-none">
<span aria-hidden="true" className="ml-1.5 inline-flex flex-col gap-0.5 leading-none">
<svg
width="8"
height="5"
viewBox="0 0 8 5"
className={cn(
'-mb-px transition-colors [transition-duration:180ms]',
'transition-colors [transition-duration:180ms]',
dir === 'asc' ? 'text-accent-600' : 'text-text-tertiary/50',
)}
>
Expand Down
19 changes: 8 additions & 11 deletions src/components/fairway/pages/rounds/FairwayRoundDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
Button,
} from '@/components/fairway';
import { cn } from '@/lib/utils';
import { formatDateOnlyWeekdayLong, formatDateOnlyFull } from '@/lib/golf/date-only';

/* ───────────────────────────────────────────────────────────────────────────
* Props — fully-resolved, serializable data from the server page.
Expand Down Expand Up @@ -181,17 +182,13 @@ export function FairwayRoundDetail({
const reviewHref = `/golf/dashboard/rounds/${round.id}/review`;

// ── Masthead copy ──────────────────────────────────────────────────────────
// round_date is a DATE column ('YYYY-MM-DD') → new Date() = midnight UTC.
// Pin the formatters to UTC so SSR (server TZ) and hydration (client TZ) agree —
// without this, west-of-UTC clients render the previous day (React #418 + off-by-one).
const roundDate = new Date(round.round_date);
const dayOfWeek = roundDate.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
const dateLabel = roundDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
// round_date is a DATE column ('YYYY-MM-DD'). Parsed + formatted through the
// shared date-only helper (pinned to UTC) so SSR (server TZ) and hydration
// (client TZ) agree, AND this header can never disagree with the rounds-list
// row on the calendar day (#916: a sibling surface's un-pinned formatter
// read the previous day west of UTC).
const dayOfWeek = formatDateOnlyWeekdayLong(round.round_date);
const dateLabel = formatDateOnlyFull(round.round_date);
const heroTitle = `${dayOfWeek} at ${shortCourse(round.course_name)}`;
const holesPlayed = round.holes_played ?? 18;
const contextLine = `${roundTypeLabel(round.round_type)} · ${holesPlayed} holes · ${playerName}`;
Expand Down
10 changes: 7 additions & 3 deletions src/components/fairway/pages/rounds/FairwayRoundRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Badge, Chip } from '@/components/fairway/controls/badge';
import { Avatar } from '@/components/fairway/controls/avatar';
import type { RoundLibraryRound } from './FairwayRoundsLibrary';
import { scoreToParTone, formatToPar, getRoundTypeLabel } from './FairwayRoundCard';
import { formatDateOnlyWeekdayShort, formatDateOnlyShort } from '@/lib/golf/date-only';

export interface FairwayRoundRowProps {
round: RoundLibraryRound;
Expand All @@ -36,11 +37,14 @@ export interface FairwayRoundRowProps {
userRole: 'coach' | 'player';
}

// round_date is a DATE column ('YYYY-MM-DD') — parsed + formatted through the
// shared date-only helper so this row can never disagree with the round detail
// header on the calendar day (#916: `new Date(iso).toLocaleDateString()` with
// no timeZone pin read the previous day west of UTC).
function dateParts(iso: string): { weekday: string; md: string } {
const d = new Date(iso);
return {
weekday: d.toLocaleDateString('en-US', { weekday: 'short' }),
md: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
weekday: formatDateOnlyWeekdayShort(iso),
md: formatDateOnlyShort(iso),
};
}

Expand Down
85 changes: 85 additions & 0 deletions src/lib/golf/date-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import {
parseDateOnly,
dateOnlyToUtcDate,
formatDateOnly,
formatDateOnlyWeekdayShort,
formatDateOnlyWeekdayLong,
formatDateOnlyShort,
formatDateOnlyFull,
} from './date-only';

describe('parseDateOnly', () => {
it('parses a bare YYYY-MM-DD string', () => {
expect(parseDateOnly('2026-06-02')).toEqual({ year: 2026, month: 6, day: 2 });
});

it('parses the leading date out of a full timestamp', () => {
expect(parseDateOnly('2026-06-02T00:00:00.000Z')).toEqual({ year: 2026, month: 6, day: 2 });
});

it('returns null for null/undefined/empty input', () => {
expect(parseDateOnly(null)).toBeNull();
expect(parseDateOnly(undefined)).toBeNull();
expect(parseDateOnly('')).toBeNull();
});

it('returns null for garbage input', () => {
expect(parseDateOnly('not-a-date')).toBeNull();
expect(parseDateOnly('2026/06/02')).toBeNull();
});

it('returns null for an out-of-range month or day', () => {
expect(parseDateOnly('2026-13-01')).toBeNull();
expect(parseDateOnly('2026-01-32')).toBeNull();
});
});

describe('dateOnlyToUtcDate', () => {
it('anchors at UTC midnight for the given calendar day', () => {
const d = dateOnlyToUtcDate({ year: 2026, month: 6, day: 2 });
expect(d.getUTCFullYear()).toBe(2026);
expect(d.getUTCMonth()).toBe(5); // 0-indexed
expect(d.getUTCDate()).toBe(2);
expect(d.getUTCHours()).toBe(0);
});
});

describe('formatDateOnly / boundary case — the #916 off-by-one', () => {
// The exact bug: a naive `new Date('2026-06-02').toLocaleDateString()` (no
// timeZone pin) parses as UTC midnight, then reads back in the *local*
// timezone — which prints June 1 anywhere west of UTC (every US zone).
// Every formatter here must print June 2 regardless of host TZ, because
// Node/vitest runs these tests in whatever TZ the CI/dev machine has.
it('formats a first-of-a-run date-only string as the correct calendar day', () => {
expect(formatDateOnlyShort('2026-06-02')).toBe('Jun 2');
expect(formatDateOnlyFull('2026-06-02')).toBe('June 2, 2026');
});

it('formats the correct weekday for the boundary date (Tuesday, not Monday)', () => {
// 2026-06-02 is a Tuesday. The reported bug showed "Mon Jun 1" in one
// surface and "June 2" in the other for this exact date.
expect(formatDateOnlyWeekdayShort('2026-06-02')).toBe('Tue');
expect(formatDateOnlyWeekdayLong('2026-06-02')).toBe('Tuesday');
});

it('is stable across a full-timestamp variant of the same date', () => {
expect(formatDateOnlyShort('2026-06-02T00:00:00.000Z')).toBe(
formatDateOnlyShort('2026-06-02'),
);
});

it('handles a year boundary correctly (Dec 31 never becomes Jan 1 or vice versa)', () => {
expect(formatDateOnlyFull('2025-12-31')).toBe('December 31, 2025');
expect(formatDateOnlyFull('2026-01-01')).toBe('January 1, 2026');
});

it('falls back to the em dash for unparseable input', () => {
expect(formatDateOnly(null, { month: 'short', day: 'numeric' })).toBe('—');
expect(formatDateOnly('garbage', { month: 'short', day: 'numeric' })).toBe('—');
});

it('honors a custom fallback string', () => {
expect(formatDateOnly(null, { month: 'short', day: 'numeric' }, 'N/A')).toBe('N/A');
});
});
Loading
Loading