All files / src/routes/dashboard +page.server.ts

93.87% Statements 46/49
88.67% Branches 47/53
100% Functions 3/3
93.87% Lines 46/49

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237                                                                            19x             19x   19x   19x 1x     18x 1x     17x 17x   1x       16x 16x             16x           19x             16x 19x   19x 19x 11x       11x     16x 19x                                     19x 19x 7x     7x 1x         1x 6x 3x   3x                   16x 16x                             16x 16x 4x             4x     1x           4x                                 16x             16x   1x           16x     3x 2x           19x                                    
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { selectNextWorkout, type UpcomingWorkout } from './selectNextWorkout';
// Single source of truth for the placeholder-phase discriminant set. Realises
// spec/programming.allium's `Mesocycle.prep_state` (issue #138). Priority order
// (stalled > replanning > in_flight > unknown) is enforced by the SECURITY
// DEFINER `get_dashboard_prep_state` SQL function; any unrecognised string
// (including NULL or an RPC error) collapses to `unknown` below so the dashboard
// degrades to the optimistic copy rather than blocking the athlete.
import type { PrepState } from './prepState';
 
/**
 * Programme source discriminant (#267).
 * 'preset' — athlete is in preset mode; direct to preset library for adoption.
 * 'ai'     — athlete is in AI-generated mode; existing behaviour unchanged.
 */
export type ProgrammeSource = 'ai' | 'preset';
 
/**
 * Dashboard load function.
 *
 * Surfaces:
 *  - Onboarding redirects (PARQ, assessment) when athlete is not yet ready.
 *  - `hasActiveCycleWithBlocks`: true when the athlete has an active mesocycle
 *    with at least one block. Drives the "programme active" view.
 *  - `hasPlaceholderCycle`: true when the athlete has an active mesocycle with
 *    zero blocks. Drives the "preparing your programme" polling state.
 *    The adopt-placeholder branch in create-mesocycle turns a placeholder into
 *    a populated cycle; once blocks exist the dashboard transitions to the
 *    active view (via client-side invalidation).
 *  - `prepState`: when `hasPlaceholderCycle === true`, the discriminant for
 *    the four-way placeholder fallback. Always `'unknown'` when no
 *    placeholder is in play (we skip the RPC entirely in that case).
 *
 * Declares a custom invalidation dependency (`dashboard:cycle-status`) so the
 * dashboard's polling effect can re-run only this load without invalidating the
 * entire layout. See +page.svelte for the polling $effect.
 */
export const load: PageServerLoad = async ({
	parent,
	depends,
	locals: { supabase, safeGetSession },
}) => {
	// Declare custom dependency — allows client to call invalidate('dashboard:cycle-status')
	// to re-run only this load function (not the layout load).
	depends('dashboard:cycle-status');
 
	const { athlete } = await parent();
 
	if (athlete && !athlete.onboarding_status) {
		redirect(303, '/onboarding');
	}
 
	if (athlete?.onboarding_status === 'parq_complete') {
		redirect(303, '/onboarding/assessment');
	}
 
	const { user } = await safeGetSession();
	if (!user) {
		// hooks.server.ts should already have redirected; defensive fallback.
		redirect(303, '/login');
	}
 
	// Read programme_source once per load. Defaults to 'ai' on error (fail-safe per TB1).
	const { data: rawSource, error: sourceError } = await supabase.rpc('get_programme_source');
	Iif (sourceError) {
		console.error({
			event: 'get_programme_source_failed',
			code: sourceError.code,
			message: sourceError.message,
		});
	}
	const programmeSource: ProgrammeSource = rawSource === 'preset' ? 'preset' : 'ai';
 
	// Look up the athlete's most recent active mesocycle and count its blocks.
	// A "populated" active cycle has at least one block; a "placeholder" has zero.
	// `created_at` is the durable clock for the placeholder fallback threshold —
	// see `placeholderSince` below.
	const { data: activeCycles } = await supabase
		.from('mesocycles')
		.select('id, created_at')
		.eq('athlete_id', user.id)
		.eq('status', 'active')
		.limit(1);
 
	const activeCycleId = activeCycles?.[0]?.id ?? null;
	const activeCycleCreatedAt = activeCycles?.[0]?.created_at ?? null;
 
	let blockCount = 0;
	if (activeCycleId) {
		const { count } = await supabase
			.from('blocks')
			.select('id', { count: 'exact', head: true })
			.eq('mesocycle_id', activeCycleId);
		blockCount = count ?? 0;
	}
 
	const hasActiveCycleWithBlocks = activeCycleId !== null && blockCount > 0;
	const hasPlaceholderCycle = activeCycleId !== null && blockCount === 0;
 
	// Preset mode: adopted cycles have blocks immediately (no prep_state polling needed).
	// - No active cycle → invite athlete to preset library.
	// - Active cycle with blocks → render populated dashboard (same as ai path).
	// In preset mode, hasPlaceholderCycle should never be true (adopt_preset always
	// materialises blocks), but if it somehow is, fall through to existing placeholder UI
	// rather than silently hiding a problem.
 
	// Derive the placeholder discriminant via SECURITY DEFINER RPC. Skip the
	// round trip entirely when:
	//   - the cycle is populated (no placeholder copy to gate, and we don't want
	//     to burn an RPC on every 3s poll for athletes with a working programme).
	//   - programme_source = 'preset' with no cycle (no placeholder cycle to probe).
	//
	// Treat NULL data, an unrecognised string, AND any RPC error as 'unknown'
	// — the dashboard's optimistic copy is the safe degrade target. Structured
	// log shape mirrors the `complete_assessment_failed` precedent in
	// onboarding/assessment/+page.server.ts.
	let prepState: PrepState = 'unknown';
	if (hasPlaceholderCycle && activeCycleId) {
		const { data, error } = await supabase.rpc('get_dashboard_prep_state', {
			p_cycle_id: activeCycleId,
		});
		if (error) {
			console.error({
				event: 'get_dashboard_prep_state_failed',
				code: error.code,
				message: error.message,
			});
			prepState = 'unknown';
		} else if (data === 'in_flight' || data === 'replanning' || data === 'stalled') {
			prepState = data;
		} else {
			prepState = 'unknown';
		}
	}
 
	// Preset-mode cycle-continuation signal (#267 Slice 3).
	// When a preset-mode athlete completes a cycle review, the DB rule
	// PromptNextPresetOnCycleReview inserts a row into next_preset_adoption_needed.
	// We surface this as a prompt for the athlete to return to the preset library.
	// RLS: SELECT policy scoped to own athlete_id — safe to query unconditionally.
	// We only query when in preset mode to avoid a round-trip in ai mode.
	let hasNextPresetSignal = false;
	Iif (programmeSource === 'preset') {
		const { data: signalRows } = await supabase
			.from('next_preset_adoption_needed')
			.select('id')
			.limit(1);
		hasNextPresetSignal = (signalRows?.length ?? 0) > 0;
	}
 
	// Next-workout navigation affordance (#261). Only meaningful for a populated
	// active cycle — skip the query for placeholder/preset/onboarding states.
	// `.eq('status','ready')` + `.order` selects at the DB; `selectNextWorkout`
	// re-applies the rule on the returned rows so it is independently
	// unit-testable. Select mirrors workout/[id]/+page.server.ts. RLS scopes
	// `workouts` by athlete_id; the explicit `.eq('athlete_id', user.id)` mirrors
	// the mesocycle/block queries above as defence-in-depth.
	let nextWorkout: UpcomingWorkout | null = null;
	if (hasActiveCycleWithBlocks) {
		const { data: rows, error: workoutsError } = await supabase
			.from('workouts')
			.select('id, scheduled_date, session_type, status, location:training_locations(name)')
			.eq('athlete_id', user.id)
			.eq('status', 'ready')
			.order('scheduled_date', { ascending: true })
			.order('id', { ascending: true });
		if (workoutsError) {
			// Degrade to "no upcoming workout" rather than failing the dashboard.
			// Structured log mirrors the get_dashboard_prep_state precedent above.
			console.error({
				event: 'dashboard_next_workout_query_failed',
				code: workoutsError.code,
				message: workoutsError.message,
			});
		}
		nextWorkout = selectNextWorkout((rows as UpcomingWorkout[] | null) ?? []);
	}
 
	// Pending post-workout reviews (#77). A completed workout creates a
	// `workout_review` (pending) synchronously; finishing a workout returns the
	// athlete to this dashboard, where the review is *offered* (not forced) via
	// the surface below. RLS scopes `reviews` to the athlete; the explicit
	// `.eq('athlete_id', user.id)` mirrors the queries above as defence-in-depth.
	//
	// Only surface a review once its AI-generated prompts are ready. The prompts
	// are produced asynchronously after review creation (edge function, with a
	// static fallback on refusal — spec CreateFallbackReviewPrompts), so a freshly
	// created review can briefly have zero prompts. Surfacing it then would land
	// the athlete on the review page's "preparing your questions" spinner — the
	// exact half-baked state we want to avoid. We embed the prompts (left join,
	// gated by the review_prompts_select RLS policy) and keep only reviews with
	// at least one. `in_progress` reviews always have prompts by construction.
	const { data: reviewRows, error: reviewsError } = await supabase
		.from('reviews')
		.select('id, workout_id, created_at, review_prompts(id)')
		.eq('athlete_id', user.id)
		.eq('scope', 'workout_review')
		.in('status', ['pending', 'in_progress'])
		.order('created_at', { ascending: false });
	if (reviewsError) {
		// Degrade to "no pending reviews" rather than failing the dashboard.
		console.error({
			event: 'dashboard_pending_reviews_query_failed',
			code: reviewsError.code,
			message: reviewsError.message,
		});
	}
	const pendingReviews = (reviewRows ?? [])
		// Only reviews whose prompts have been generated (or fell back) are ready
		// to answer — hide the transient prompt-less window after creation.
		.filter((r) => Array.isArray(r.review_prompts) && r.review_prompts.length > 0)
		.map((r) => ({
			id: r.id,
			workout_id: r.workout_id,
			created_at: r.created_at,
		}));
 
	return {
		hasActiveCycleWithBlocks,
		hasPlaceholderCycle,
		prepState,
		// Durable clock for the >60s placeholder fallback (see +page.svelte).
		// The threshold MUST derive from this server timestamp, never from a
		// client-side "first observed at" — otherwise a refresh resets the
		// clock and an athlete whose generation has been stuck for hours keeps
		// seeing the optimistic under-60s copy on every reload, masking the
		// fallback that exists to surface a stuck cascade. Null when there is
		// no placeholder cycle (the fallback only renders in that branch).
		placeholderSince: hasPlaceholderCycle ? activeCycleCreatedAt : null,
		programmeSource,
		hasNextPresetSignal,
		nextWorkout,
		pendingReviews,
	};
};