diff --git a/scripts/refresh-golf-demo-realism.ts b/scripts/refresh-golf-demo-realism.ts new file mode 100644 index 000000000..9c0694104 --- /dev/null +++ b/scripts/refresh-golf-demo-realism.ts @@ -0,0 +1,400 @@ +/** + * refresh-golf-demo-realism.ts — data-realism freshness pass for the + * "Demo University Golf" team (see scripts/refresh-demo-nick-rini.ts and + * scripts/seed-demo-team-ops.ts, which this script is a companion to). + * + * Why (Fixes the data-realism portion of #910): a live click-through of the + * demo account surfaced several things that read as obviously fake / stale: + * + * 1. Team-channel messages are all exactly 1 hour apart starting at + * 08:00 UTC (04:00 America/New_York) — seed-demo-team-ops.ts's + * predecessor picked one hour per day with no variation, and because + * it never intended those hours as UTC, they land pre-dawn once + * rendered in the team's timezone. Reads as a bot, not a team chat. + * 2. golf_events was seeded with day offsets relative to "whenever the + * seed last ran" (mid-June). Real time keeps moving, so today the + * "near future" events it created are now in the past while still + * marked `status: 'scheduled'` — the calendar shows "N upcoming" in + * its header while the agenda underneath is full of April/May dates. + * Several of those events also used a raw UTC hour where an Eastern + * local hour was intended (e.g. "07:00 UTC" for a tee time reads as + * 3:00 AM once converted to America/New_York). + * 3. golf_rounds.course_name is free text with ~20 typo'd/casing + * variants of the team's two real library courses ("poplar grove", + * "Poplar Grove (real)", "Poplar Grove GC", "Golden horshoe", + * "golden horeshoe", "Golden Horseshoe Gold", ...) instead of the + * canonical golf_courses rows the course library already has for + * them (Poplar Grove (real) / Golden Horseshoe gold course). + * 4. The newest golf_rounds row is from whenever the base seed last + * ran — currently weeks old — so CoachHelm's rolling-window insights + * and any "updated X ago" copy read as stale the moment the demo + * sits untouched for more than a few days. + * + * What this does (idempotent, dry-run by default). The date/time math for + * (A), (B), (D) lives in src/lib/golf/demo-realism-schedule.ts (pure, + * unit-tested) — this file owns the Supabase reads/writes only. + * + * A. Re-times every message in the team channel to a natural + * coach/player hour (7-9am or 3-6pm America/New_York, varied + * minutes), preserving each message's relative day-spacing but + * re-anchored so the most recent message lands yesterday (never in + * the future). + * B. Re-schedules the team's known named events using the SAME + * day-offsets-from-now the base seed used (so 6 stay in the past / + * 6 stay in the future no matter when this runs — a rolling + * schedule), but treats every event hour as an America/New_York + * local time (correctly converted to UTC) instead of a raw UTC + * hour, so tee times/practices no longer land at 3 AM. + * C. Canonicalizes every golf_rounds.course_name variant of the team's + * two real library courses (Poplar Grove, Golden Horseshoe) to the + * exact name + course_id + city/state the course library already + * has for them. Every other course on the team (Pebble Beach, + * Forest Creek, Savannah Harbor, Jekyll Island, Cardinal, ...) is + * left untouched — those weren't the courses flagged as messy. + * D. If the newest golf_rounds.round_date on the team is more than 5 + * days old, shifts EVERY team round forward by the same number of + * days (preserving relative spacing between rounds) so the newest + * round lands ~1 day ago. CoachHelm's rolling-window reads and any + * "last round" / "updated" copy freshen automatically because + * they're computed live off round_date, not stored separately. + * + * Scope & safety: + * - STRICTLY scoped to the hard-coded DEMO_TEAM_ID below. Every mutating + * query filters on team_id = DEMO_TEAM_ID (golf_events, golf_rounds) + * or a team-owned conversation id resolved FROM that team_id + * (golf_messages). Nothing outside this team is ever touched. + * - assertDemoTeamIdentity() reads the team row (+ confirms the demo + * coach is on staff) before any write and refuses to proceed unless + * they match what's hard-coded here — a guard against DEMO_TEAM_ID + * silently getting reassigned to a different row in some future + * migration. + * - Every write is an UPDATE against rows this script discovers by + * querying team-scoped tables at runtime — no DELETE, no destructive + * rewrite. Re-running is always safe: dates/times get recomputed + * relative to "now" and simply overwrite the same columns again. + * - REVIEWED-ONLY: this script is NOT executed as part of this PR. It + * runs with --confirm against prod after a human reviews it (see PR + * body for the proposed cadence). + * + * Known gap (documented, not fixed here — out of scope for this pass): + * One golf_rounds row (qualifier_id set) is loosely coupled to a + * golf_qualifiers.start_date/end_date and a golf_events row via + * target_tournament_id. Shifting round_date (D) does not cascade to + * golf_qualifiers dates. Pre-existing drift (round_date already sits a + * day outside its qualifier's date range) is not introduced by this + * script; fixing that coupling is a separate, more invasive change. + * + * Usage: + * DOTENV_CONFIG_PATH=.vercel/.env.production.local \ + * npx tsx -r dotenv/config scripts/refresh-golf-demo-realism.ts # dry run (default) + * DOTENV_CONFIG_PATH=.vercel/.env.production.local \ + * npx tsx -r dotenv/config scripts/refresh-golf-demo-realism.ts --confirm # write + * + * Requires env: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +import 'dotenv/config'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import { + addDaysIso, + computeEventSchedule, + computeMessageSchedule, + computeRoundDateShift, + todayIsoInTz, + type EventDef, +} from '../src/lib/golf/demo-realism-schedule'; + +// --------------------------------------------------------------------------- +// Hard-coded demo identity (verified against prod — see assertDemoTeamIdentity) +// --------------------------------------------------------------------------- + +const DEMO_TEAM_ID = '6ecdd1a6-63fe-4beb-b094-00118f334163'; +const DEMO_TEAM_NAME = 'Demo University Golf'; +const DEMO_TEAM_JOIN_CODE = 'DEMO01'; +// user_id of the demo coach login (demo@golfhelmdemo.com) — the sender of +// the coach-side messages/events this script re-times. Used only as an +// extra identity check, not as a write scope (writes are team_id-scoped). +const DEMO_COACH_USER_ID = '6db574a1-c72f-478a-95ee-44cd0ba3f107'; + +const TEAM_TZ = 'America/New_York'; + +// The two real golf_courses library rows the team's messy course_name +// variants should collapse onto (see header comment, section C). +const POPLAR_GROVE = { + courseId: 'a5e38378-62a4-4cbf-ba2a-edc88df0fdc8', + name: 'Poplar Grove (real)', + city: 'Amherst', + state: 'VA', + // Case-insensitive prefix match — catches every observed variant + // ("poplar grove", "Poplar Grove", "Poplar Grove GC", "Poplar Grove ", + // "Poplar Grove (real)") without touching any other course. + ilikePattern: 'poplar grove%', +} as const; +const GOLDEN_HORSESHOE = { + courseId: '8dfc10c6-4e50-4b84-afa6-276b8e08cb7c', + name: 'Golden Horseshoe gold course', + city: 'Williamsburg', + state: 'VA', + // Catches "Golden horshoe", "golden horeshoe", "Golden Horseshoe GC", + // "Golden Horseshoe Gold", "Golden Horseshoe gold course" — every + // observed variant shares this prefix regardless of the horse{,s,e}shoe + // typo in the middle. + ilikePattern: 'golden hor%', +} as const; +const COURSE_CANON_GROUPS = [POPLAR_GROVE, GOLDEN_HORSESHOE] as const; + +// Same 12 named events + day-offsets-from-now as scripts/seed-demo-team-ops.ts +// originally seeded (6 past, 6 future) — the fix here is (1) recomputing the +// offset relative to the CURRENT run time instead of a frozen seed-time +// "now" (rolling schedule), and (2) treating every hour below as an +// America/New_York LOCAL hour, correctly converted to UTC, instead of the +// raw-UTC hour the base seed used (which is what put a tee time at 3 AM). +const EVENT_DEFS: EventDef[] = [ + { title: 'Fall Swing Technique Clinic', startOffsetDays: -42, endOffsetDays: -42, startHour: 14, startMinute: 0, endHour: 17, endMinute: 0 }, + { title: 'Coastal Collegiate Invitational', startOffsetDays: -35, endOffsetDays: -34, startHour: 7, startMinute: 0, endHour: 18, endMinute: 0 }, + { title: 'Short-Game Lab — Bunker & Chipping', startOffsetDays: -28, endOffsetDays: -28, startHour: 15, startMinute: 0, endHour: 17, endMinute: 30 }, + { title: 'Academic Eligibility Check-In', startOffsetDays: -21, endOffsetDays: -21, startHour: 12, startMinute: 0, endHour: 13, endMinute: 0 }, + { title: 'Palmetto Qualifier — Travel to Greenville', startOffsetDays: -14, endOffsetDays: -12, startHour: 6, startMinute: 0, endHour: 20, endMinute: 0 }, + { title: 'Putting Lab — Gate Drill & Speed Control', startOffsetDays: -7, endOffsetDays: -7, startHour: 15, startMinute: 0, endHour: 17, endMinute: 0 }, + { title: 'Weekly Practice — Course Management Focus', startOffsetDays: 2, endOffsetDays: 2, startHour: 14, startMinute: 0, endHour: 17, endMinute: 0 }, + { title: 'Spring Preview Tournament', startOffsetDays: 10, endOffsetDays: 11, startHour: 8, startMinute: 0, endHour: 18, endMinute: 0 }, + { title: 'Pre-Season Qualifier — Spring Roster Selection', startOffsetDays: 17, endOffsetDays: 17, startHour: 9, startMinute: 0, endHour: 15, endMinute: 0 }, + { title: 'Team Film Session — Tournament Debrief', startOffsetDays: 25, endOffsetDays: 25, startHour: 18, startMinute: 0, endHour: 19, endMinute: 30 }, + { title: 'Tri-State Invitational', startOffsetDays: 38, endOffsetDays: 40, startHour: 7, startMinute: 30, endHour: 17, endMinute: 0 }, + { title: 'End-of-Season Team Banquet', startOffsetDays: 55, endOffsetDays: 55, startHour: 19, startMinute: 0, endHour: 22, endMinute: 0 }, +]; + +// Round-date freshness thresholds (section D). +const STALE_THRESHOLD_DAYS = 5; +const TARGET_MOST_RECENT_OFFSET_DAYS = 1; // land the newest round "yesterday", not suspiciously exactly "today" + +// --------------------------------------------------------------------------- +// Runtime state +// --------------------------------------------------------------------------- + +let DRY = true; +const counts: Record = {}; +function record(label: string, n: number) { + counts[label] = (counts[label] ?? 0) + n; +} + +async function assertDemoTeamIdentity(supabase: SupabaseClient): Promise { + const { data, error } = await supabase + .from('golf_teams') + .select('id, name, join_code') + .eq('id', DEMO_TEAM_ID) + .maybeSingle(); + if (error) throw new Error(`assertDemoTeamIdentity: ${error.message}`); + if (!data) { + throw new Error(`Safety check failed: golf_teams row ${DEMO_TEAM_ID} not found. Refusing to run.`); + } + if (data.name !== DEMO_TEAM_NAME || data.join_code !== DEMO_TEAM_JOIN_CODE) { + throw new Error( + `Safety check failed: golf_teams ${DEMO_TEAM_ID} does not match the expected demo identity ` + + `(name="${data.name}", join_code="${data.join_code}"; expected "${DEMO_TEAM_NAME}"/"${DEMO_TEAM_JOIN_CODE}"). ` + + `Refusing to write — this id may no longer point at the demo team.`, + ); + } + const { data: staff, error: staffErr } = await supabase + .from('golf_team_coach_staff') + .select('coach_id, golf_coaches!inner(user_id)') + .eq('team_id', DEMO_TEAM_ID); + if (staffErr) throw new Error(`assertDemoTeamIdentity (staff): ${staffErr.message}`); + const staffUserIds = ((staff ?? []) as Array<{ golf_coaches: { user_id: string } }>).map( + (s) => s.golf_coaches.user_id, + ); + if (!staffUserIds.includes(DEMO_COACH_USER_ID)) { + throw new Error( + `Safety check failed: expected demo coach user ${DEMO_COACH_USER_ID} is not staff on team ${DEMO_TEAM_ID}. Refusing to write.`, + ); + } +} + +// =========================================================================== +// A. Messages — natural hours, rolling recency +// =========================================================================== + +async function refreshMessages(supabase: SupabaseClient, todayIso: string): Promise { + console.log('\n[A] Team-channel messages — natural hours + rolling recency'); + + const { data: convo, error: convoErr } = await supabase + .from('golf_conversations') + .select('id') + .eq('team_id', DEMO_TEAM_ID) + .eq('is_team_chat', true) + .maybeSingle(); + if (convoErr) throw new Error(`refreshMessages (conversation lookup): ${convoErr.message}`); + if (!convo) { + console.log(' no team-chat conversation found for demo team — skipping'); + return; + } + + const { data: messages, error: msgErr } = await supabase + .from('golf_messages') + .select('id, created_at') + .eq('conversation_id', convo.id) + .order('created_at', { ascending: true }); + if (msgErr) throw new Error(`refreshMessages (message fetch): ${msgErr.message}`); + const list = messages ?? []; + if (list.length === 0) { + console.log(' no messages in team-chat conversation — skipping'); + return; + } + + const newTimestamps = computeMessageSchedule( + list.map((m) => m.created_at as string), + todayIso, + TEAM_TZ, + ); + console.log(` ${list.length} messages, re-anchoring so most recent lands ${addDaysIso(todayIso, -1)}`); + record('golf_messages', list.length); + if (DRY) return; + + for (let i = 0; i < list.length; i++) { + const { error } = await supabase.from('golf_messages').update({ created_at: newTimestamps[i] }).eq('id', list[i].id); + if (error) throw new Error(`refreshMessages (update ${list[i].id}): ${error.message}`); + } +} + +// =========================================================================== +// B. Events — rolling schedule, sane (ET-correct) start times +// =========================================================================== + +async function refreshEvents(supabase: SupabaseClient, todayIso: string): Promise { + console.log('\n[B] Calendar events — rolling schedule + ET-correct start times'); + + const { data: events, error } = await supabase + .from('golf_events') + .select('id, title') + .eq('team_id', DEMO_TEAM_ID); + if (error) throw new Error(`refreshEvents (fetch): ${error.message}`); + const byTitle = new Map((events ?? []).map((e) => [e.title as string, e.id as string])); + + const scheduled = computeEventSchedule(todayIso, EVENT_DEFS, TEAM_TZ); + let matched = 0; + for (const s of scheduled) { + const id = byTitle.get(s.title); + if (!id) { + console.log(` ⚠ event "${s.title}" not found on demo team — skipping (seed may have changed)`); + continue; + } + matched++; + console.log(` ${s.title}: ${s.startTime} → ${s.endTime} (${s.status})`); + if (DRY) continue; + const { error: updErr } = await supabase + .from('golf_events') + .update({ start_time: s.startTime, end_time: s.endTime, status: s.status }) + .eq('id', id); + if (updErr) throw new Error(`refreshEvents (update ${id}): ${updErr.message}`); + } + record('golf_events', matched); + console.log(` ${matched}/${EVENT_DEFS.length} known events matched + rescheduled`); +} + +// =========================================================================== +// C. Course-name canonicalization +// =========================================================================== + +async function canonicalizeCourses(supabase: SupabaseClient): Promise { + console.log('\n[C] Round course-name canonicalization'); + + for (const group of COURSE_CANON_GROUPS) { + if (DRY) { + const { count, error } = await supabase + .from('golf_rounds') + .select('id', { count: 'exact', head: true }) + .eq('team_id', DEMO_TEAM_ID) + .ilike('course_name', group.ilikePattern); + if (error) throw new Error(`canonicalizeCourses (count, ${group.name}): ${error.message}`); + console.log(` [dry] would canonicalize ${count ?? 0} rounds matching "${group.ilikePattern}" → "${group.name}"`); + record(`golf_rounds:course→${group.name}`, count ?? 0); + continue; + } + const { data, error } = await supabase + .from('golf_rounds') + .update({ + course_id: group.courseId, + course_name: group.name, + course_city: group.city, + course_state: group.state, + }) + .eq('team_id', DEMO_TEAM_ID) + .ilike('course_name', group.ilikePattern) + .select('id'); + if (error) throw new Error(`canonicalizeCourses (update, ${group.name}): ${error.message}`); + const n = data?.length ?? 0; + console.log(` canonicalized ${n} rounds matching "${group.ilikePattern}" → "${group.name}"`); + record(`golf_rounds:course→${group.name}`, n); + } +} + +// =========================================================================== +// D. Round-date freshness — keep the newest round within ~5 days of "now" +// =========================================================================== + +async function refreshRoundFreshness(supabase: SupabaseClient, todayIso: string): Promise { + console.log('\n[D] Round-date freshness (keeps CoachHelm rolling-window + "updated X" fresh)'); + + const { data: rounds, error } = await supabase + .from('golf_rounds') + .select('id, round_date') + .eq('team_id', DEMO_TEAM_ID); + if (error) throw new Error(`refreshRoundFreshness (fetch): ${error.message}`); + const list = rounds ?? []; + if (list.length === 0) { + console.log(' no rounds on demo team — skipping'); + return; + } + + const roundDates = list.map((r) => r.round_date as string); + const shiftDays = computeRoundDateShift(roundDates, todayIso, STALE_THRESHOLD_DAYS, TARGET_MOST_RECENT_OFFSET_DAYS); + const maxDate = roundDates.reduce((a, b) => (a > b ? a : b)); + if (shiftDays === null) { + console.log(` newest round is ${maxDate} — within the ${STALE_THRESHOLD_DAYS}d threshold, no shift needed`); + return; + } + + console.log( + ` newest round is ${maxDate} — shifting all ${list.length} rounds forward by ${shiftDays}d ` + + `(newest lands ${addDaysIso(maxDate, shiftDays)})`, + ); + record('golf_rounds:date-shift', list.length); + if (DRY) return; + + for (const r of list) { + const newDate = addDaysIso(r.round_date as string, shiftDays); + const { error: updErr } = await supabase.from('golf_rounds').update({ round_date: newDate }).eq('id', r.id); + if (updErr) throw new Error(`refreshRoundFreshness (update ${r.id}): ${updErr.message}`); + } +} + +// =========================================================================== +async function main() { + DRY = !process.argv.includes('--confirm'); + 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 = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }); + + console.log(`${DRY ? '[DRY RUN] ' : ''}Refreshing Demo University Golf demo realism (team ${DEMO_TEAM_ID})...`); + if (DRY) console.log('Printing plan, writing NOTHING. Re-run with --confirm.'); + + await assertDemoTeamIdentity(supabase); + console.log('Safety check passed: team identity + demo coach staff membership confirmed.'); + + const todayIso = todayIsoInTz(TEAM_TZ); + console.log(`"Today" in ${TEAM_TZ}: ${todayIso}`); + + await refreshMessages(supabase, todayIso); + await refreshEvents(supabase, todayIso); + await canonicalizeCourses(supabase); + await refreshRoundFreshness(supabase, todayIso); + + console.log(`\n${DRY ? '[DRY RUN] would touch' : 'Touched'} rows:`); + for (const [label, n] of Object.entries(counts)) console.log(` ${label.padEnd(36)} ${n}`); + console.log(`\nDone.${DRY ? ' (dry run — no writes)' : ''}`); +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/src/lib/golf/__tests__/demo-realism-schedule.test.ts b/src/lib/golf/__tests__/demo-realism-schedule.test.ts new file mode 100644 index 000000000..35497cd04 --- /dev/null +++ b/src/lib/golf/__tests__/demo-realism-schedule.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { + addDaysIso, + computeEventSchedule, + computeMessageSchedule, + computeRoundDateShift, + daysBetween, + localToUtcIso, + naturalTimeForIndex, + todayIsoInTz, + tzOffsetHours, + type EventDef, +} from '../demo-realism-schedule'; + +const TZ = 'America/New_York'; + +describe('demo-realism-schedule', () => { + describe('tzOffsetHours / localToUtcIso', () => { + it('uses EDT (-4) in summer and EST (-5) in winter for America/New_York', () => { + expect(tzOffsetHours('2026-07-17', TZ)).toBe(-4); + expect(tzOffsetHours('2026-01-15', TZ)).toBe(-5); + }); + + it('converts a 7am ET tee time to 11:00 UTC in summer, not a raw 07:00 UTC', () => { + // This is the exact regression #910 flagged: "Coastal Collegiate + // Invitational" showing a 3:00 AM start because 07:00 was written to + // the DB as a raw UTC hour instead of an ET-local hour. + const iso = localToUtcIso('2026-06-12', 7, 0, TZ); + expect(iso).toBe('2026-06-12T11:00:00.000Z'); + }); + + it('converts an 8am ET time to 13:00 UTC in winter (EST)', () => { + const iso = localToUtcIso('2026-01-15', 8, 0, TZ); + expect(iso).toBe('2026-01-15T13:00:00.000Z'); + }); + }); + + describe('addDaysIso / daysBetween', () => { + it('adds and subtracts days across month boundaries', () => { + expect(addDaysIso('2026-07-30', 5)).toBe('2026-08-04'); + expect(addDaysIso('2026-07-05', -10)).toBe('2026-06-25'); + }); + + it('is the inverse of daysBetween', () => { + expect(daysBetween(addDaysIso('2026-07-17', 42), '2026-07-17')).toBe(42); + expect(daysBetween('2026-07-17', '2026-07-17')).toBe(0); + }); + }); + + describe('naturalTimeForIndex', () => { + it('never a flat hourly progression like the stale seed (08,09,10,...)', () => { + const hours = Array.from({ length: 15 }, (_, i) => naturalTimeForIndex(i).hour); + // The stale bug was hours[i] === 8 + i for every i. Assert that never holds. + expect(hours.some((h, i) => h !== 8 + i)).toBe(true); + }); + + it('every hour falls in the 7-9am or 3-6pm window', () => { + for (let i = 0; i < 30; i++) { + const { hour, minute } = naturalTimeForIndex(i); + const inMorning = hour >= 7 && hour <= 9; + const inAfternoon = hour >= 15 && hour <= 18; + expect(inMorning || inAfternoon).toBe(true); + expect(minute).toBeGreaterThanOrEqual(0); + expect(minute).toBeLessThan(60); + } + }); + + it('minutes are varied, not all :00', () => { + const minutes = Array.from({ length: 10 }, (_, i) => naturalTimeForIndex(i).minute); + expect(minutes.some((m) => m !== 0)).toBe(true); + }); + }); + + describe('computeMessageSchedule', () => { + it('returns [] for an empty list', () => { + expect(computeMessageSchedule([], '2026-07-17', TZ)).toEqual([]); + }); + + it('preserves relative day-spacing (same-day pairs stay same-day)', () => { + const original = [ + '2026-05-28T08:00:00+00:00', + '2026-05-29T09:00:00+00:00', + '2026-05-29T10:00:00+00:00', // same day as previous + '2026-06-10T12:00:00+00:00', // 13 days after the first + ]; + const result = computeMessageSchedule(original, '2026-07-17', TZ); + expect(result).toHaveLength(4); + const dates = result.map((iso) => iso.slice(0, 10)); + // message 1 and 2 remain on the same calendar day + expect(dates[1]).toBe(dates[2]); + // spacing between message 0 and message 3 is preserved (13 days) + expect(daysBetween(dates[3]!, dates[0]!)).toBe(13); + }); + + it('always lands the most recent message on "yesterday", never in the future', () => { + const original = ['2026-01-01T08:00:00+00:00', '2026-01-02T09:00:00+00:00']; + const today = '2026-07-17'; + const result = computeMessageSchedule(original, today, TZ); + const lastDate = result[result.length - 1]!.slice(0, 10); + expect(lastDate).toBe(addDaysIso(today, -1)); + for (const iso of result) { + expect(Date.parse(iso)).toBeLessThan(Date.parse(`${today}T23:59:59Z`)); + } + }); + + it('is idempotent: re-running on its own output (same "today") is a no-op', () => { + const original = [ + '2026-05-28T08:00:00+00:00', + '2026-05-29T09:00:00+00:00', + '2026-06-10T12:00:00+00:00', + ]; + const today = '2026-07-17'; + const once = computeMessageSchedule(original, today, TZ); + const twice = computeMessageSchedule(once, today, TZ); + expect(twice).toEqual(once); + }); + }); + + describe('computeEventSchedule', () => { + const defs: EventDef[] = [ + { title: 'Past Practice', startOffsetDays: -7, endOffsetDays: -7, startHour: 14, startMinute: 0, endHour: 17, endMinute: 0 }, + { title: 'Coastal Collegiate Invitational', startOffsetDays: -35, endOffsetDays: -34, startHour: 7, startMinute: 0, endHour: 18, endMinute: 0 }, + { title: 'Future Practice', startOffsetDays: 2, endOffsetDays: 2, startHour: 14, startMinute: 0, endHour: 17, endMinute: 0 }, + ]; + + it('marks negative-offset events completed and positive-offset events scheduled', () => { + const result = computeEventSchedule('2026-07-17', defs, TZ); + expect(result.find((e) => e.title === 'Past Practice')?.status).toBe('completed'); + expect(result.find((e) => e.title === 'Coastal Collegiate Invitational')?.status).toBe('completed'); + expect(result.find((e) => e.title === 'Future Practice')?.status).toBe('scheduled'); + }); + + it('produces a sane (non-3am) local start time for the invitational tee-off', () => { + const result = computeEventSchedule('2026-07-17', defs, TZ); + const invitational = result.find((e) => e.title === 'Coastal Collegiate Invitational')!; + // 2026-07-17 - 35d = 2026-06-12 (EDT, offset -4) → 07:00 local = 11:00 UTC + expect(invitational.startTime).toBe('2026-06-12T11:00:00.000Z'); + }); + + it('rolls forward with "today" — the same defs always land on the correct side of now', () => { + const dayA = computeEventSchedule('2026-07-17', defs, TZ); + const dayB = computeEventSchedule('2026-08-17', defs, TZ); + for (const day of [dayA, dayB]) { + expect(day.find((e) => e.title === 'Future Practice')?.status).toBe('scheduled'); + expect(day.find((e) => e.title === 'Past Practice')?.status).toBe('completed'); + } + // Absolute dates shift with "today" — this is the whole point of a + // rolling schedule instead of frozen seed-time offsets. + expect(dayA.find((e) => e.title === 'Future Practice')?.startTime).not.toBe( + dayB.find((e) => e.title === 'Future Practice')?.startTime, + ); + }); + }); + + describe('computeRoundDateShift', () => { + it('returns null when the newest round is within the stale threshold', () => { + const dates = ['2026-07-10', '2026-07-14', '2026-07-15']; + expect(computeRoundDateShift(dates, '2026-07-17', 5, 1)).toBeNull(); + }); + + it('returns null for an empty list', () => { + expect(computeRoundDateShift([], '2026-07-17', 5, 1)).toBeNull(); + }); + + it('shifts forward so the newest round lands at the target offset', () => { + const dates = ['2026-04-08', '2026-06-29', '2026-07-11']; + const shift = computeRoundDateShift(dates, '2026-07-17', 5, 1); + expect(shift).not.toBeNull(); + const newest = addDaysIso('2026-07-11', shift!); + expect(newest).toBe(addDaysIso('2026-07-17', -1)); + }); + + it('preserves relative spacing between rounds under a uniform shift', () => { + const dates = ['2026-04-08', '2026-06-29', '2026-07-11']; + const shift = computeRoundDateShift(dates, '2026-07-17', 5, 1)!; + const shifted = dates.map((d) => addDaysIso(d, shift)); + expect(daysBetween(shifted[2]!, shifted[0]!)).toBe(daysBetween(dates[2]!, dates[0]!)); + expect(daysBetween(shifted[1]!, shifted[0]!)).toBe(daysBetween(dates[1]!, dates[0]!)); + }); + }); + + describe('todayIsoInTz', () => { + it('formats as YYYY-MM-DD', () => { + const iso = todayIsoInTz(TZ, new Date('2026-07-17T12:00:00Z')); + expect(iso).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(iso).toBe('2026-07-17'); + }); + }); +}); diff --git a/src/lib/golf/demo-realism-schedule.ts b/src/lib/golf/demo-realism-schedule.ts new file mode 100644 index 000000000..eb9161d99 --- /dev/null +++ b/src/lib/golf/demo-realism-schedule.ts @@ -0,0 +1,168 @@ +/** + * Pure date/time + scheduling math for scripts/refresh-golf-demo-realism.ts. + * + * Extracted so the trickiest parts of that script — timezone-correct local + * hours, rolling day-offsets-from-now, and message/round re-anchoring that + * preserves relative spacing — are unit-testable without a Supabase + * connection. No I/O in this file; the script owns all the reads/writes. + */ + +// --------------------------------------------------------------------------- +// Date/time primitives +// --------------------------------------------------------------------------- + +/** 'YYYY-MM-DD' for "now" as seen in `tz`. */ +export function todayIsoInTz(tz: string, now: Date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const y = parts.find((p) => p.type === 'year')!.value; + const m = parts.find((p) => p.type === 'month')!.value; + const d = parts.find((p) => p.type === 'day')!.value; + return `${y}-${m}-${d}`; +} + +/** UTC offset in whole hours (e.g. -4 or -5) for `tz` on a given 'YYYY-MM-DD'. */ +export function tzOffsetHours(dateIso: string, tz: string): number { + const probe = new Date(`${dateIso}T12:00:00Z`); // noon avoids DST-edge day rollover + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + timeZoneName: 'shortOffset', + }).formatToParts(probe); + const tzName = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT-5'; + const m = /GMT([+-]\d+)/.exec(tzName); + const offsetStr = m?.[1]; + return offsetStr ? parseInt(offsetStr, 10) : -5; +} + +/** Convert a local wall-clock time in `tz` on `dateIso` to a UTC ISO string. */ +export function localToUtcIso(dateIso: string, hourLocal: number, minuteLocal: number, tz: string): string { + const offset = tzOffsetHours(dateIso, tz); + const base = new Date(`${dateIso}T00:00:00Z`); + base.setUTCHours(base.getUTCHours() - offset + hourLocal, minuteLocal, 0, 0); + return base.toISOString(); +} + +/** Add `days` (may be negative) to a 'YYYY-MM-DD' string, return 'YYYY-MM-DD'. */ +export function addDaysIso(dateIso: string, days: number): string { + const d = new Date(`${dateIso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +/** Whole-day difference (a - b) for two 'YYYY-MM-DD' strings. */ +export function daysBetween(aIso: string, bIso: string): number { + return Math.round((Date.parse(`${aIso}T00:00:00Z`) - Date.parse(`${bIso}T00:00:00Z`)) / 86_400_000); +} + +// --------------------------------------------------------------------------- +// A. Message re-timing (natural hours, spacing-preserving recency anchor) +// --------------------------------------------------------------------------- + +// Alternates a 7-9am window with a 3-6pm window, varied minutes — deliberately +// not a flat hour-by-hour progression like the stale seed data it replaces. +const MORNING_HOURS = [7, 8, 9] as const; +const AFTERNOON_HOURS = [15, 16, 17, 18] as const; +const MINUTES = [6, 52, 18, 41, 33, 9, 24, 47, 12, 58, 3, 29, 44, 16, 37, 51, 8, 22] as const; + +export function naturalTimeForIndex(i: number): { hour: number; minute: number } { + const hour = + i % 2 === 0 + ? MORNING_HOURS[Math.floor(i / 2) % MORNING_HOURS.length]! + : AFTERNOON_HOURS[Math.floor(i / 2) % AFTERNOON_HOURS.length]!; + const minute = MINUTES[i % MINUTES.length]!; + return { hour, minute }; +} + +/** + * Given messages' current `created_at` (ascending), returns new `created_at` + * values (same order/length) that: + * - preserve each message's relative day-offset from the first message + * (same-day exchanges stay same-day; multi-day gaps stay the same size) + * - land the LAST message on `todayIso` minus 1 day (never in the future, + * regardless of what hour "now" happens to be) + * - use a natural coach/player hour (see naturalTimeForIndex) instead of a + * flat hourly progression + * Returns `[]` if `createdAtList` is empty. + */ +export function computeMessageSchedule(createdAtList: string[], todayIso: string, tz: string): string[] { + if (createdAtList.length === 0) return []; + const firstDate = createdAtList[0]!.slice(0, 10); + const offsets = createdAtList.map((iso) => daysBetween(iso.slice(0, 10), firstDate)); + const maxOffset = Math.max(...offsets); + const targetLastDate = addDaysIso(todayIso, -1); + const anchorDate = addDaysIso(targetLastDate, -maxOffset); + return offsets.map((offset, i) => { + const date = addDaysIso(anchorDate, offset); + const { hour, minute } = naturalTimeForIndex(i); + return localToUtcIso(date, hour, minute, tz); + }); +} + +// --------------------------------------------------------------------------- +// B. Event rescheduling (rolling day-offsets-from-now, ET-correct hours) +// --------------------------------------------------------------------------- + +export type EventDef = { + title: string; + startOffsetDays: number; + endOffsetDays: number; + startHour: number; + startMinute: number; + endHour: number; + endMinute: number; +}; + +export type ScheduledEvent = { + title: string; + startTime: string; + endTime: string; + status: 'completed' | 'scheduled'; +}; + +/** + * Resolves each EventDef's fixed day-offset-from-now into an absolute + * start/end timestamp relative to `todayIso`, treating every hour as a + * `tz`-local wall-clock time (correctly converted to UTC). Offsets stay + * negative/positive across every run, so status stays consistent too — + * this is what makes the schedule "roll" forward with real time instead of + * freezing at whenever the seed last ran. + */ +export function computeEventSchedule(todayIso: string, defs: EventDef[], tz: string): ScheduledEvent[] { + return defs.map((def) => { + const startDate = addDaysIso(todayIso, def.startOffsetDays); + const endDate = addDaysIso(todayIso, def.endOffsetDays); + return { + title: def.title, + startTime: localToUtcIso(startDate, def.startHour, def.startMinute, tz), + endTime: localToUtcIso(endDate, def.endHour, def.endMinute, tz), + status: def.startOffsetDays < 0 ? 'completed' : 'scheduled', + }; + }); +} + +// --------------------------------------------------------------------------- +// D. Round-date freshness shift +// --------------------------------------------------------------------------- + +/** + * Returns the number of days to shift EVERY round forward so the newest + * round lands `targetMostRecentOffsetDays` before `todayIso`, or `null` if + * the newest round is already within `staleThresholdDays` and no shift is + * needed. A uniform shift across all rounds preserves relative spacing. + */ +export function computeRoundDateShift( + roundDates: string[], + todayIso: string, + staleThresholdDays: number, + targetMostRecentOffsetDays: number, +): number | null { + if (roundDates.length === 0) return null; + const maxDate = roundDates.reduce((a, b) => (a > b ? a : b)); + const staleDays = daysBetween(todayIso, maxDate); + if (staleDays <= staleThresholdDays) return null; + return staleDays - targetMostRecentOffsetDays; +}