From 83577c5723ea69cf125a332b6103d86a5bb1705f Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 13:39:53 -0400 Subject: [PATCH 1/2] GP-142 GP-143 GP-144: harden training plan guardrails --- .../training-path/usePlanTrainingPathData.ts | 17 +- .../backend-planning/preview-input.ts | 22 +- .../training-plan-creation.test.ts | 41 ++- .../lib/training-plan-creation/types.ts | 2 + .../useTrainingPlanCreationService.ts | 7 +- .../lib/training-plan-creation/validation.ts | 8 +- .../training-plan-form/projectionPreview.ts | 28 +- packages/core/plan/index.ts | 1 + .../trainingPlanCreationValidation.test.ts | 95 ++++++ .../plan/trainingPlanCreationValidation.ts | 273 ++++++++++++------ .../trainingPlanProjectionBudgets.test.ts | 44 +++ .../plan/trainingPlanProjectionBudgets.ts | 57 ++++ 12 files changed, 483 insertions(+), 112 deletions(-) create mode 100644 packages/core/plan/trainingPlanCreationValidation.test.ts create mode 100644 packages/core/plan/trainingPlanProjectionBudgets.test.ts create mode 100644 packages/core/plan/trainingPlanProjectionBudgets.ts diff --git a/apps/mobile/components/plan/training-path/usePlanTrainingPathData.ts b/apps/mobile/components/plan/training-path/usePlanTrainingPathData.ts index 916da9ba..c1fb24d3 100644 --- a/apps/mobile/components/plan/training-path/usePlanTrainingPathData.ts +++ b/apps/mobile/components/plan/training-path/usePlanTrainingPathData.ts @@ -1,3 +1,4 @@ +import { resolveTrainingPlanProjectionWindow } from "@repo/core"; import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "@/lib/api"; import { scheduleAwareReadQueryOptions } from "@/lib/api/scheduleQueryOptions"; @@ -68,16 +69,12 @@ export function usePlanTrainingPathData() { const { data: activePlan, refetch: refetchActivePlan } = activePlanQuery; const today = useMemo(() => new Date(), []); const todayKey = useMemo(() => getDateKey(today), [today]); - const recentWindowStart = useMemo(() => { - const start = new Date(today); - start.setDate(start.getDate() - 45); - return getDateKey(start); - }, [today]); - const upcomingWindowEnd = useMemo(() => { - const end = new Date(today); - end.setDate(end.getDate() + 365); - return getDateKey(end); - }, [today]); + const projectionWindow = useMemo( + () => resolveTrainingPlanProjectionWindow({ anchorDate: todayKey }), + [todayKey], + ); + const recentWindowStart = projectionWindow.startDate; + const upcomingWindowEnd = projectionWindow.endDate; const upcomingPlannedEventsQuery = api.events.list.useQuery( { diff --git a/apps/mobile/lib/training-plan-creation/backend-planning/preview-input.ts b/apps/mobile/lib/training-plan-creation/backend-planning/preview-input.ts index 430436d8..1b4b63be 100644 --- a/apps/mobile/lib/training-plan-creation/backend-planning/preview-input.ts +++ b/apps/mobile/lib/training-plan-creation/backend-planning/preview-input.ts @@ -21,10 +21,13 @@ export { createPlanningContextFingerprint, mapPlanningContextToPreviewCreationCo export function getBackendPlanningClientStatus(): BackendPlanningClientStatus { return { - available: false, - enabledOperations: [], - reason: - "Backend planning adapter scaffolded; local projection remains authoritative for this pass.", + available: true, + enabledOperations: [ + "previewCreationConfig", + "createFromCreationConfig", + "updateFromCreationConfig", + ], + reason: "Backend planning preview and commit routes are available when input mapping succeeds.", }; } @@ -46,10 +49,13 @@ export function deriveBackendPlanningState( return { status: previewMapping.ok ? { - available: false, - enabledOperations: [], - reason: - "Backend planning input is mapped; network preview remains disabled for this pass.", + available: true, + enabledOperations: [ + "previewCreationConfig", + "createFromCreationConfig", + "updateFromCreationConfig", + ], + reason: "Backend planning input is mapped and ready for authoritative preview.", } : { available: false, diff --git a/apps/mobile/lib/training-plan-creation/training-plan-creation.test.ts b/apps/mobile/lib/training-plan-creation/training-plan-creation.test.ts index 80086a0e..bfdd818e 100644 --- a/apps/mobile/lib/training-plan-creation/training-plan-creation.test.ts +++ b/apps/mobile/lib/training-plan-creation/training-plan-creation.test.ts @@ -301,6 +301,28 @@ describe("training plan creation domain", () => { }); }); + it("uses core readiness blockers for incompatible planning preferences", () => { + const fixtures = createTrainingPlanBuilderFixtures(); + const state = { + ...fixtures.readyState, + planPreferences: { + durationWeeks: 4, + weeklySessionCount: 6, + targetWeeklyHours: null, + restDaysPerWeek: 2, + }, + }; + + expect(selectSaveReadiness(state).blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "weekly_session_rest_day_conflict", + target: { type: "assumptions" }, + }), + ]), + ); + }); + it("derives local schedule preview dates and conflict checks without persisting calendar fields", () => { const state = { ...createDefaultTrainingPlanBuilderState(), @@ -660,10 +682,14 @@ describe("training plan creation domain", () => { const context = createTrainingPlanPlanningContext(fixtures.readyState); expect(getBackendPlanningClientStatus()).toEqual({ - available: false, - enabledOperations: [], + available: true, + enabledOperations: [ + "previewCreationConfig", + "createFromCreationConfig", + "updateFromCreationConfig", + ], reason: - "Backend planning adapter scaffolded; local projection remains authoritative for this pass.", + "Backend planning preview and commit routes are available when input mapping succeeds.", }); expect(getPlannedBackendPlanningOperations()).toEqual([ "getCreationSuggestions", @@ -715,8 +741,17 @@ describe("training plan creation domain", () => { const context = createTrainingPlanPlanningContext(state); const result = mapPlanningContextToPreviewCreationConfigInput(context); + const backendState = deriveBackendPlanningState(context); expect(result).toMatchObject({ ok: true }); + expect(backendState.status).toMatchObject({ + available: true, + enabledOperations: [ + "previewCreationConfig", + "createFromCreationConfig", + "updateFromCreationConfig", + ], + }); if (!result.ok) throw new Error(result.reason); expect(result.input.minimal_plan).toMatchObject({ plan_start_date: state.scheduling.startDate, diff --git a/apps/mobile/lib/training-plan-creation/types.ts b/apps/mobile/lib/training-plan-creation/types.ts index 6af85a8d..f1429075 100644 --- a/apps/mobile/lib/training-plan-creation/types.ts +++ b/apps/mobile/lib/training-plan-creation/types.ts @@ -93,6 +93,8 @@ export type TrainingPlanBuilderSaveBlockerCode = | "unpublished_activity_plan" | "invalid_start_time" | "duplicate_session" + | "weekly_session_rest_day_conflict" + | "weekly_hours_session_mismatch" | "canonical_schema_failure"; export type TrainingPlanBuilderSaveBlocker = { diff --git a/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts b/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts index c440504c..0fc4b8b4 100644 --- a/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts +++ b/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts @@ -294,6 +294,7 @@ export function useTrainingPlanCreationService({ 500, ); const backendPreviewInput = + localProjection.backendPlanning.status.available && debouncedBackendPlanningFingerprint === localProjection.backendPlanning.contextFingerprint ? localProjection.backendPlanning.previewInput : null; @@ -316,7 +317,8 @@ export function useTrainingPlanCreationService({ () => selectActiveTrainingPlanProjection({ backendPreview: authoritativeProjection, - backendPreviewEnabled: backendPreviewInput !== null, + backendPreviewEnabled: + localProjection.backendPlanning.status.available && backendPreviewInput !== null, isBackendInputStale: isBackendPlanningInputStale, localChart: localProjection.builderViewModel.dailyTrainingPathChart, }), @@ -372,6 +374,7 @@ export function useTrainingPlanCreationService({ backendPlanningPreviewQuery.isFetching, backendPlanningPreviewQuery.isLoading, localProjection.backendPlanning.previewInput, + localProjection.backendPlanning.status.available, localProjection.backendPlanning.status.reason, trainingPathChartProjection.source, ], @@ -573,7 +576,7 @@ export function useTrainingPlanCreationService({ backendPlanningPreview: { data: backendPlanningPreviewQuery.data, error: backendPlanningPreviewQuery.error, - isEnabled: backendPreviewInput !== null, + isEnabled: localProjection.backendPlanning.status.available && backendPreviewInput !== null, isFetching: backendPlanningPreviewQuery.isFetching, isLoading: backendPlanningPreviewQuery.isLoading, isStaleInput: isBackendPlanningInputStale, diff --git a/apps/mobile/lib/training-plan-creation/validation.ts b/apps/mobile/lib/training-plan-creation/validation.ts index e1a17239..f37eae30 100644 --- a/apps/mobile/lib/training-plan-creation/validation.ts +++ b/apps/mobile/lib/training-plan-creation/validation.ts @@ -1,4 +1,4 @@ -import { isValidDateOnlyUtc, validateTrainingPlanCreationInput } from "@repo/core"; +import { evaluateTrainingPlanCreationReadiness, isValidDateOnlyUtc } from "@repo/core"; import { ZodError } from "zod"; import { toTrainingPlanStructure } from "./mappers"; import { trainingPlanBuilderPlanPreferencesSchema } from "./schemas"; @@ -34,7 +34,7 @@ export function validateTrainingPlanBuilderState( const planPreferenceResult = trainingPlanBuilderPlanPreferencesSchema.safeParse( state.planPreferences, ); - const blockers: TrainingPlanBuilderSaveBlocker[] = validateTrainingPlanCreationInput({ + const readiness = evaluateTrainingPlanCreationReadiness({ name: state.details.name, anchorDateValid: isValidDateOnlyUtc(state.anchorDate), profileBirthDateValid: true, @@ -42,6 +42,7 @@ export function validateTrainingPlanBuilderState( planPreferencesMessage: planPreferenceResult.success ? undefined : planPreferenceResult.error.issues[0]?.message, + preferences: state.planPreferences, sessions: state.structure.sessions.map((session) => ({ localId: session.localId, offsetDays: session.offsetDays, @@ -61,7 +62,8 @@ export function validateTrainingPlanBuilderState( targetDateValid: goal.targetDate ? isValidDateOnlyUtc(goal.targetDate) : true, targetOffsetDays: goal.targetOffsetDays, })), - }).map((issue) => + }); + const blockers: TrainingPlanBuilderSaveBlocker[] = readiness.blockers.map((issue) => createBlocker({ code: issue.code, message: issue.message, diff --git a/apps/mobile/lib/training-plan-form/projectionPreview.ts b/apps/mobile/lib/training-plan-form/projectionPreview.ts index ae1022d1..d9867985 100644 --- a/apps/mobile/lib/training-plan-form/projectionPreview.ts +++ b/apps/mobile/lib/training-plan-form/projectionPreview.ts @@ -1,6 +1,10 @@ import { canonicalizeMinimalTrainingPlanCreate } from "@repo/core/plan/canonicalization"; import type { ProjectionChartPayload } from "@repo/core/plan/projectionTypes"; import { withLegacyTrainingLoadAliases } from "@repo/core/plan/trainingLoadTimeline"; +import { + filterDateKeyedItemsToProjectionWindow, + resolveTrainingPlanProjectionWindow, +} from "@repo/core/plan/trainingPlanProjectionBudgets"; import { type AthleteTrainingSettings, type AthleteTrainingSettingsFormInput, @@ -188,21 +192,35 @@ export function buildTrainingPreferencesLoadTimeline(input: { scheduledWindowStart?: string | null; scheduledWindowEnd?: string | null; }) { + const todayKey = toDateKey(new Date()); + const projectionWindow = resolveTrainingPlanProjectionWindow({ + anchorDate: todayKey, + requestedStartDate: input.scheduledWindowStart, + requestedEndDate: input.scheduledWindowEnd, + }); const baselineTimeline = input.snapshot.insightTimeline?.timeline ?? []; const baselineByDate = new Map(baselineTimeline.map((point) => [point.date, point])); const previewByDate = new Map( (input.projectionChart?.display_points ?? []).map((point) => [point.date, point]), ); - const dateSource = baselineTimeline.length > 0 ? baselineTimeline : [...previewByDate.values()]; - const dates = new Set(dateSource.map((point) => point.date)); + const dateSource: Array<{ date: string }> = + baselineTimeline.length > 0 ? baselineTimeline : [...previewByDate.values()]; + const dates = new Set( + filterDateKeyedItemsToProjectionWindow(dateSource, { + window: projectionWindow, + getDate: (point) => point.date, + }).map((point) => point.date), + ); const scheduledLoadAggregation = aggregateScheduledLoadByDate(input); - for (const date of scheduledLoadAggregation.dates) dates.add(date); + for (const date of scheduledLoadAggregation.dates) { + if (date >= projectionWindow.startDate && date <= projectionWindow.endDate) dates.add(date); + } const hasCalendarScheduleForDate = (date: string) => !!input.scheduledWindowStart && !!input.scheduledWindowEnd && - date >= input.scheduledWindowStart && - date <= input.scheduledWindowEnd; + date >= projectionWindow.startDate && + date <= projectionWindow.endDate; return [...dates] .sort((left, right) => left.localeCompare(right)) diff --git a/packages/core/plan/index.ts b/packages/core/plan/index.ts index eb0226f6..12cd23e8 100644 --- a/packages/core/plan/index.ts +++ b/packages/core/plan/index.ts @@ -61,6 +61,7 @@ export * from "./trainingLoadTimeline"; export * from "./trainingPlanCreationPreview"; export * from "./trainingPlanCreationValidation"; export * from "./trainingPlanPreview"; +export * from "./trainingPlanProjectionBudgets"; export * from "./trainingPlanSchedulingPreview"; export * from "./trainingPlanStructureProposal"; export * from "./trainingSettingsDefaults"; diff --git a/packages/core/plan/trainingPlanCreationValidation.test.ts b/packages/core/plan/trainingPlanCreationValidation.test.ts new file mode 100644 index 00000000..94148698 --- /dev/null +++ b/packages/core/plan/trainingPlanCreationValidation.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateTrainingPlanCreationReadiness, + validateTrainingPlanCreationInput, +} from "./trainingPlanCreationValidation"; + +const publishedAccessiblePlan = { + id: "activity-plan-1", + accessible: true, + published: true, +}; + +describe("trainingPlanCreationValidation", () => { + it("returns a deterministic readiness result with blockers and warnings", () => { + const readiness = evaluateTrainingPlanCreationReadiness({ + name: "", + anchorDateValid: true, + profileBirthDateValid: true, + planPreferencesValid: true, + preferences: { + weeklySessionCount: 6, + targetWeeklyHours: 1, + restDaysPerWeek: 2, + }, + sessions: [ + { + localId: "session-1", + offsetDays: 0, + activityPlan: publishedAccessiblePlan, + startTime: "09:00", + }, + ], + goals: [], + }); + + expect(readiness.canSave).toBe(false); + expect(readiness.blockers.map((issue) => issue.code)).toEqual([ + "missing_plan_name", + "weekly_session_rest_day_conflict", + ]); + expect(readiness.warnings.map((issue) => issue.code)).toEqual([ + "weekly_hours_session_mismatch", + ]); + expect(readiness.issues.every((issue) => issue.severity)).toBe(true); + }); + + it("keeps the legacy validation helper blocker-only", () => { + const issues = validateTrainingPlanCreationInput({ + name: "Base build", + anchorDateValid: true, + profileBirthDateValid: true, + planPreferencesValid: true, + preferences: { + weeklySessionCount: 4, + targetWeeklyHours: 1, + restDaysPerWeek: null, + }, + sessions: [ + { + localId: "session-1", + offsetDays: 0, + activityPlan: publishedAccessiblePlan, + }, + ], + goals: [], + }); + + expect(issues).toEqual([]); + }); + + it("allows a complete creation input", () => { + const readiness = evaluateTrainingPlanCreationReadiness({ + name: "Base build", + anchorDateValid: true, + profileBirthDateValid: true, + planPreferencesValid: true, + preferences: { + weeklySessionCount: 4, + targetWeeklyHours: 5, + restDaysPerWeek: 2, + }, + sessions: [ + { + localId: "session-1", + offsetDays: 0, + activityPlan: publishedAccessiblePlan, + startTime: "09:00", + }, + ], + goals: [{ localId: "goal-1", targetDateValid: true, targetOffsetDays: 28 }], + }); + + expect(readiness).toMatchObject({ canSave: true, blockers: [], warnings: [] }); + }); +}); diff --git a/packages/core/plan/trainingPlanCreationValidation.ts b/packages/core/plan/trainingPlanCreationValidation.ts index fd0e845f..3d69ae6a 100644 --- a/packages/core/plan/trainingPlanCreationValidation.ts +++ b/packages/core/plan/trainingPlanCreationValidation.ts @@ -9,15 +9,26 @@ export type TrainingPlanCreationValidationCode = | "inaccessible_activity_plan" | "unpublished_activity_plan" | "invalid_start_time" - | "duplicate_session"; + | "duplicate_session" + | "weekly_session_rest_day_conflict" + | "weekly_hours_session_mismatch"; + +export type TrainingPlanCreationValidationSeverity = "blocking" | "warning"; export interface TrainingPlanCreationValidationIssue { code: TrainingPlanCreationValidationCode; message: string; + severity: TrainingPlanCreationValidationSeverity; targetType: "overview" | "session" | "goal" | "assumptions"; targetId?: string; } +export interface TrainingPlanCreationValidationPreferences { + weeklySessionCount: number | null; + targetWeeklyHours: number | null; + restDaysPerWeek: number | null; +} + export interface TrainingPlanCreationValidationSession { localId: string; offsetDays: number; @@ -43,54 +54,90 @@ export interface TrainingPlanCreationValidationInput { profileBirthDateValid: boolean; planPreferencesValid: boolean; planPreferencesMessage?: string; + preferences?: TrainingPlanCreationValidationPreferences; sessions: TrainingPlanCreationValidationSession[]; goals: TrainingPlanCreationValidationGoal[]; } +export interface TrainingPlanCreationReadinessResult { + canSave: boolean; + issues: TrainingPlanCreationValidationIssue[]; + blockers: TrainingPlanCreationValidationIssue[]; + warnings: TrainingPlanCreationValidationIssue[]; +} + +function issue( + input: Omit & { + severity?: TrainingPlanCreationValidationSeverity; + }, +): TrainingPlanCreationValidationIssue { + return { ...input, severity: input.severity ?? "blocking" }; +} + export function validateTrainingPlanCreationInput( input: TrainingPlanCreationValidationInput, ): TrainingPlanCreationValidationIssue[] { + return evaluateTrainingPlanCreationReadiness(input).blockers; +} + +export function evaluateTrainingPlanCreationReadiness( + input: TrainingPlanCreationValidationInput, +): TrainingPlanCreationReadinessResult { const issues: TrainingPlanCreationValidationIssue[] = []; if (input.name.trim().length === 0) { - issues.push({ - code: "missing_plan_name", - message: "Add a training plan name before saving.", - targetType: "overview", - }); + issues.push( + issue({ + code: "missing_plan_name", + message: "Add a training plan name before saving.", + targetType: "overview", + }), + ); } if (!input.anchorDateValid) { - issues.push({ - code: "invalid_assumption_date", - message: "Builder anchor date must be a valid date.", - targetType: "overview", - }); + issues.push( + issue({ + code: "invalid_assumption_date", + message: "Builder anchor date must be a valid date.", + targetType: "overview", + }), + ); } if (!input.profileBirthDateValid) { - issues.push({ - code: "invalid_assumption_date", - message: "Profile birth date assumption must be a valid date.", - targetType: "assumptions", - }); + issues.push( + issue({ + code: "invalid_assumption_date", + message: "Profile birth date assumption must be a valid date.", + targetType: "assumptions", + }), + ); } if (!input.planPreferencesValid) { - issues.push({ - code: "invalid_plan_preferences", - message: - input.planPreferencesMessage ?? "Planning constraints must stay within supported ranges.", - targetType: "assumptions", - }); + issues.push( + issue({ + code: "invalid_plan_preferences", + message: + input.planPreferencesMessage ?? "Planning constraints must stay within supported ranges.", + targetType: "assumptions", + }), + ); + } + + if (input.preferences) { + issues.push(...validateTrainingPlanCreationPreferenceConsistency(input.preferences)); } if (input.sessions.length === 0) { - issues.push({ - code: "no_sessions", - message: "Add at least one session before saving.", - targetType: "overview", - }); + issues.push( + issue({ + code: "no_sessions", + message: "Add at least one session before saving.", + targetType: "overview", + }), + ); } const sessionKeys = new Map(); @@ -98,58 +145,70 @@ export function validateTrainingPlanCreationInput( const effectiveOffsetDays = session.plannedOffsetDays ?? session.offsetDays; if (session.plannedDateValid === false) { - issues.push({ - code: "invalid_assumption_date", - message: "Session planned dates must be valid dates.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "invalid_assumption_date", + message: "Session planned dates must be valid dates.", + targetType: "session", + targetId: session.localId, + }), + ); } if (!Number.isInteger(effectiveOffsetDays) || effectiveOffsetDays < 0) { - issues.push({ - code: "invalid_offset_days", - message: "Sessions must use a non-negative relative day offset.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "invalid_offset_days", + message: "Sessions must use a non-negative relative day offset.", + targetType: "session", + targetId: session.localId, + }), + ); } if (session.startTime && !/^([01]\d|2[0-3]):[0-5]\d$/.test(session.startTime)) { - issues.push({ - code: "invalid_start_time", - message: "Session start times must use HH:mm format.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "invalid_start_time", + message: "Session start times must use HH:mm format.", + targetType: "session", + targetId: session.localId, + }), + ); } if (!session.activityPlan) { - issues.push({ - code: "missing_activity_plan", - message: "Assign an activity plan to every training plan session.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "missing_activity_plan", + message: "Assign an activity plan to every training plan session.", + targetType: "session", + targetId: session.localId, + }), + ); continue; } if (!session.activityPlan.accessible) { - issues.push({ - code: "inaccessible_activity_plan", - message: "Use only activity plans available to the athlete creating this plan.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "inaccessible_activity_plan", + message: "Use only activity plans available to the athlete creating this plan.", + targetType: "session", + targetId: session.localId, + }), + ); } if (!session.activityPlan.published) { - issues.push({ - code: "unpublished_activity_plan", - message: "Publish the activity plan before assigning it to a training plan.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "unpublished_activity_plan", + message: "Publish the activity plan before assigning it to a training plan.", + targetType: "session", + targetId: session.localId, + }), + ); } const sessionKey = [effectiveOffsetDays, session.activityPlan.id, session.startTime ?? ""].join( @@ -157,12 +216,14 @@ export function validateTrainingPlanCreationInput( ); const existingSessionId = sessionKeys.get(sessionKey); if (existingSessionId) { - issues.push({ - code: "duplicate_session", - message: "Sessions cannot duplicate the same activity plan, day, and start time.", - targetType: "session", - targetId: session.localId, - }); + issues.push( + issue({ + code: "duplicate_session", + message: "Sessions cannot duplicate the same activity plan, day, and start time.", + targetType: "session", + targetId: session.localId, + }), + ); } else { sessionKeys.set(sessionKey, session.localId); } @@ -170,24 +231,74 @@ export function validateTrainingPlanCreationInput( for (const goal of input.goals) { if (goal.targetDateValid === false) { - issues.push({ - code: "invalid_assumption_date", - message: "Goal target dates must be valid when present in builder state.", - targetType: "goal", - targetId: goal.localId, - }); + issues.push( + issue({ + code: "invalid_assumption_date", + message: "Goal target dates must be valid when present in builder state.", + targetType: "goal", + targetId: goal.localId, + }), + ); } if ( goal.targetOffsetDays !== null && (!Number.isInteger(goal.targetOffsetDays) || goal.targetOffsetDays < 0) ) { - issues.push({ - code: "invalid_goal_target_offset", - message: "Plan goal target offsets must be non-negative relative day values.", - targetType: "goal", - targetId: goal.localId, - }); + issues.push( + issue({ + code: "invalid_goal_target_offset", + message: "Plan goal target offsets must be non-negative relative day values.", + targetType: "goal", + targetId: goal.localId, + }), + ); + } + } + + const blockers = issues.filter((item) => item.severity === "blocking"); + const warnings = issues.filter((item) => item.severity === "warning"); + + return { canSave: blockers.length === 0, issues, blockers, warnings }; +} + +export function validateTrainingPlanCreationPreferenceConsistency( + preferences: TrainingPlanCreationValidationPreferences, +): TrainingPlanCreationValidationIssue[] { + const issues: TrainingPlanCreationValidationIssue[] = []; + const availableTrainingDays = + preferences.restDaysPerWeek === null ? null : 7 - preferences.restDaysPerWeek; + + if ( + preferences.weeklySessionCount !== null && + availableTrainingDays !== null && + preferences.weeklySessionCount > availableTrainingDays + ) { + issues.push( + issue({ + code: "weekly_session_rest_day_conflict", + message: "Sessions per week cannot exceed available training days after rest days.", + targetType: "assumptions", + }), + ); + } + + if ( + preferences.weeklySessionCount !== null && + preferences.weeklySessionCount > 0 && + preferences.targetWeeklyHours !== null + ) { + const averageSessionMinutes = + (preferences.targetWeeklyHours * 60) / preferences.weeklySessionCount; + if (averageSessionMinutes < 20) { + issues.push( + issue({ + code: "weekly_hours_session_mismatch", + message: "Weekly time is too low for the selected session count.", + severity: "warning", + targetType: "assumptions", + }), + ); } } diff --git a/packages/core/plan/trainingPlanProjectionBudgets.test.ts b/packages/core/plan/trainingPlanProjectionBudgets.test.ts new file mode 100644 index 00000000..142344fc --- /dev/null +++ b/packages/core/plan/trainingPlanProjectionBudgets.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + filterDateKeyedItemsToProjectionWindow, + resolveTrainingPlanProjectionWindow, +} from "./trainingPlanProjectionBudgets"; + +describe("trainingPlanProjectionBudgets", () => { + it("resolves the default plan projection budget around an anchor date", () => { + expect(resolveTrainingPlanProjectionWindow({ anchorDate: "2026-07-07" })).toEqual({ + startDate: "2026-05-23", + endDate: "2027-07-07", + wasClamped: false, + }); + }); + + it("clamps pathological requested windows", () => { + expect( + resolveTrainingPlanProjectionWindow({ + anchorDate: "2026-07-07", + requestedStartDate: "2020-01-01", + requestedEndDate: "2030-01-01", + }), + ).toEqual({ + startDate: "2026-05-23", + endDate: "2027-07-07", + wasClamped: true, + }); + }); + + it("filters date-keyed projection inputs to the resolved window", () => { + const window = resolveTrainingPlanProjectionWindow({ anchorDate: "2026-07-07" }); + expect( + filterDateKeyedItemsToProjectionWindow( + [ + { date: "2026-05-22", value: 1 }, + { date: "2026-05-23", value: 2 }, + { date: "2027-07-07", value: 3 }, + { date: "2027-07-08", value: 4 }, + ], + { window, getDate: (item) => item.date }, + ).map((item) => item.value), + ).toEqual([2, 3]); + }); +}); diff --git a/packages/core/plan/trainingPlanProjectionBudgets.ts b/packages/core/plan/trainingPlanProjectionBudgets.ts new file mode 100644 index 00000000..0fee897c --- /dev/null +++ b/packages/core/plan/trainingPlanProjectionBudgets.ts @@ -0,0 +1,57 @@ +import { addDaysDateOnlyUtc, diffDateOnlyUtcDays, isValidDateOnlyUtc } from "./dateOnlyUtc"; + +export const TRAINING_PLAN_PROJECTION_RECENT_HISTORY_DAYS = 45; +export const TRAINING_PLAN_PROJECTION_FUTURE_HORIZON_DAYS = 365; +export const TRAINING_PLAN_PROJECTION_MAX_WINDOW_DAYS = + TRAINING_PLAN_PROJECTION_RECENT_HISTORY_DAYS + TRAINING_PLAN_PROJECTION_FUTURE_HORIZON_DAYS; + +export type TrainingPlanProjectionWindow = { + startDate: string; + endDate: string; + wasClamped: boolean; +}; + +export function resolveTrainingPlanProjectionWindow(input: { + anchorDate: string; + requestedStartDate?: string | null; + requestedEndDate?: string | null; + recentHistoryDays?: number; + futureHorizonDays?: number; +}): TrainingPlanProjectionWindow { + const recentHistoryDays = input.recentHistoryDays ?? TRAINING_PLAN_PROJECTION_RECENT_HISTORY_DAYS; + const futureHorizonDays = input.futureHorizonDays ?? TRAINING_PLAN_PROJECTION_FUTURE_HORIZON_DAYS; + const fallbackStartDate = addDaysDateOnlyUtc(input.anchorDate, -recentHistoryDays); + const fallbackEndDate = addDaysDateOnlyUtc(input.anchorDate, futureHorizonDays); + const requestedStartDate = input.requestedStartDate ?? fallbackStartDate; + const requestedEndDate = input.requestedEndDate ?? fallbackEndDate; + const startDate = isValidDateOnlyUtc(requestedStartDate) ? requestedStartDate : fallbackStartDate; + const endDate = isValidDateOnlyUtc(requestedEndDate) ? requestedEndDate : fallbackEndDate; + const minStartDate = fallbackStartDate; + const maxEndDate = fallbackEndDate; + const boundedStartDate = startDate < minStartDate ? minStartDate : startDate; + const boundedEndDate = endDate > maxEndDate ? maxEndDate : endDate; + const normalizedEndDate = boundedEndDate < boundedStartDate ? boundedStartDate : boundedEndDate; + + return { + startDate: boundedStartDate, + endDate: normalizedEndDate, + wasClamped: + boundedStartDate !== requestedStartDate || + normalizedEndDate !== requestedEndDate || + diffDateOnlyUtcDays(boundedStartDate, normalizedEndDate) > + recentHistoryDays + futureHorizonDays, + }; +} + +export function filterDateKeyedItemsToProjectionWindow( + items: TItem[], + input: { + getDate: (item: TItem) => string | null | undefined; + window: Pick; + }, +): TItem[] { + return items.filter((item) => { + const date = input.getDate(item); + return !!date && date >= input.window.startDate && date <= input.window.endDate; + }); +} From 2e3049ed230030f1a05cb01d8c18446a49103e0a Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 13:40:59 -0400 Subject: [PATCH 2/2] GP-143 GP-144: satisfy training plan hook lint --- .../training-plan-creation/useTrainingPlanCreationService.ts | 2 +- apps/mobile/lib/training-plan-form/projectionPreview.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts b/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts index 0fc4b8b4..dd802ee8 100644 --- a/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts +++ b/apps/mobile/lib/training-plan-creation/useTrainingPlanCreationService.ts @@ -326,6 +326,7 @@ export function useTrainingPlanCreationService({ authoritativeProjection, backendPreviewInput, isBackendPlanningInputStale, + localProjection.backendPlanning.status.available, localProjection.builderViewModel.dailyTrainingPathChart, ], ); @@ -374,7 +375,6 @@ export function useTrainingPlanCreationService({ backendPlanningPreviewQuery.isFetching, backendPlanningPreviewQuery.isLoading, localProjection.backendPlanning.previewInput, - localProjection.backendPlanning.status.available, localProjection.backendPlanning.status.reason, trainingPathChartProjection.source, ], diff --git a/apps/mobile/lib/training-plan-form/projectionPreview.ts b/apps/mobile/lib/training-plan-form/projectionPreview.ts index d9867985..8817fff1 100644 --- a/apps/mobile/lib/training-plan-form/projectionPreview.ts +++ b/apps/mobile/lib/training-plan-form/projectionPreview.ts @@ -82,6 +82,7 @@ function toGoalTargets(goal: TrainingPlanSnapshot["profileGoals"][number]): Goal case "hr": return [{ target_type: "hr_threshold", target_lthr_bpm: objective.value }]; } + return []; } default: return [];