Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
};
}

Expand All @@ -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",
Comment on lines +52 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve builder-authored data before backend saves

When this makes the backend commit route available, the create/edit builder starts saving through createFromCreationConfig/updateFromCreationConfig, but the mapped backend input only carries the minimal goal/config data and not state.details or state.structure.sessions. The backend commit then persists the generated expandedPlan, so a user who typed a plan name/description or manually arranged sessions/activity plans can save a different generated plan instead of the one they built; keep the legacy payload route for manual-builder saves or include those builder fields in the backend contract before enabling these commit operations.

Useful? React with 👍 / 👎.

],
reason: "Backend planning input is mapped and ready for authoritative preview.",
}
: {
available: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/lib/training-plan-creation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export function useTrainingPlanCreationService({
500,
);
const backendPreviewInput =
localProjection.backendPlanning.status.available &&
debouncedBackendPlanningFingerprint === localProjection.backendPlanning.contextFingerprint
? localProjection.backendPlanning.previewInput
: null;
Expand All @@ -316,14 +317,16 @@ export function useTrainingPlanCreationService({
() =>
selectActiveTrainingPlanProjection({
backendPreview: authoritativeProjection,
backendPreviewEnabled: backendPreviewInput !== null,
backendPreviewEnabled:
localProjection.backendPlanning.status.available && backendPreviewInput !== null,
isBackendInputStale: isBackendPlanningInputStale,
localChart: localProjection.builderViewModel.dailyTrainingPathChart,
}),
[
authoritativeProjection,
backendPreviewInput,
isBackendPlanningInputStale,
localProjection.backendPlanning.status.available,
localProjection.builderViewModel.dailyTrainingPathChart,
],
);
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions apps/mobile/lib/training-plan-creation/validation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,14 +34,15 @@ 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,
planPreferencesValid: planPreferenceResult.success,
planPreferencesMessage: planPreferenceResult.success
? undefined
: planPreferenceResult.error.issues[0]?.message,
preferences: state.planPreferences,
sessions: state.structure.sessions.map((session) => ({
localId: session.localId,
offsetDays: session.offsetDays,
Expand All @@ -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,
Expand Down
29 changes: 24 additions & 5 deletions apps/mobile/lib/training-plan-form/projectionPreview.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -78,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 [];
Expand Down Expand Up @@ -188,21 +193,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;
Comment on lines +223 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the requested schedule bounds for coverage

When a caller supplies scheduledWindowStart/scheduledWindowEnd that are narrower than the resolved projection guardrail, this predicate treats every date in the larger projection window as covered by fetched calendar data. aggregateScheduledLoadByDate still filters events to the actual requested window, so baseline scheduled_tss is replaced by 0 for dates outside the fetched range; compare against the requested schedule bounds here and use the projection window only to clamp which dates are emitted.

Useful? React with 👍 / 👎.


return [...dates]
.sort((left, right) => left.localeCompare(right))
Expand Down
1 change: 1 addition & 0 deletions packages/core/plan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
95 changes: 95 additions & 0 deletions packages/core/plan/trainingPlanCreationValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
Loading
Loading