Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# dependencies
/node_modules
/.pnpm-store
/.pnp
.pnp.*
.yarn/*
Expand Down
6 changes: 6 additions & 0 deletions app/[locale]/(app)/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WorkoutSessionHeatmap } from "@/features/workout-session/ui/workout-ses
import { useWorkoutSessions } from "@/features/workout-session/model/use-workout-sessions";
import { env } from "@/env";
import { BodyWeightInput } from "@/features/profile/ui/body-weight-input";
import { WorkoutEquipmentInput } from "@/features/profile/ui/workout-equipment-input";
import { useCurrentSession } from "@/entities/user/model/useCurrentSession";
import { LocalAlert } from "@/components/ui/local-alert";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -46,6 +47,11 @@ export default function ProfilePage() {
<BodyWeightInput />
</div>
)}
{session && (
<div className="mt-4">
<WorkoutEquipmentInput />
</div>
)}

<div className="mt-4">
<WorkoutSessionHeatmap until={until} values={values} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function HeartRateZonesCalculatorClient({ defaultAge = 30, defaultResults
{/* Global zones visualization */}
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-2xl">
<div className="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
{t("tools.heart-rate-zones.results.overview")}
{t("tools.heart-rate-zones.results.overview", {})}
</div>
<div className="relative h-8 bg-white dark:bg-gray-600 rounded-full overflow-hidden">
{results.zones.map((zone) => (
Expand Down
61 changes: 57 additions & 4 deletions app/api/user/preferences/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";

import { prisma } from "@/shared/lib/prisma";
import { getMobileCompatibleSession } from "@/shared/api/mobile-auth";
import {
normalizeWorkoutPreferences,
type EquipmentMode,
type WorkoutEnvironment,
} from "@/shared/lib/user-preferences";

const preferencesSchema = z.object({
goals: z.array(z.string()),
Expand All @@ -21,7 +27,23 @@ const bodyWeightSchema = z.object({
bodyWeightUnit: z.enum(["kg", "lbs"]).optional(),
});

const patchSchema = preferencesSchema.merge(bodyWeightSchema).partial();
const preferenceShape = preferencesSchema.merge(
z.object({
equipmentMode: z.enum(["all", "bodyweight_only", "custom"]).optional(),
availableEquipment: z.array(z.string()).optional(),
workoutEnvironment: z.enum(["office", "unrestricted"]).optional(),
quickTimeBudget: z.number().min(5).max(25).optional(),
planSessionMinutes: z.number().min(20).max(50).optional(),
restIntervalSeconds: z.number().min(15).max(60).optional(),
warmupRoutineEnabled: z.boolean().optional(),
warmupExerciseCount: z.number().min(1).max(4).optional(),
warmupReps: z.number().min(6).max(15).optional(),
cooldownRoutineEnabled: z.boolean().optional(),
cooldownExerciseCount: z.number().min(1).max(4).optional(),
cooldownHoldSeconds: z.number().min(20).max(40).optional(),
}).merge(bodyWeightSchema),
);
const patchSchema = preferenceShape.partial();

export async function GET(req: NextRequest) {
try {
Expand All @@ -36,8 +58,14 @@ export async function GET(req: NextRequest) {
select: { onboardingPreferences: true },
});

const rawPreferences = asRecord(user?.onboardingPreferences);
const normalized = normalizeWorkoutPreferences(rawPreferences);
const preferences = {
...rawPreferences,
...normalized,
};
return NextResponse.json({
preferences: user?.onboardingPreferences ?? null,
preferences,
});
} catch (error) {
console.error("Get preferences error:", error);
Expand Down Expand Up @@ -67,13 +95,31 @@ export async function PUT(req: NextRequest) {
select: { onboardingPreferences: true },
});
const prev = (existing?.onboardingPreferences as Record<string, unknown>) ?? {};
const next: Record<string, unknown> = { ...rest, ...prev };
const next: Record<string, unknown> = { ...prev, ...rest };
if (parsed.data.bodyWeight !== undefined) next.bodyWeight = parsed.data.bodyWeight;
if (parsed.data.bodyWeightUnit !== undefined) next.bodyWeightUnit = parsed.data.bodyWeightUnit;
const normalized = normalizeWorkoutPreferences(next as Record<string, unknown>);
const payload: Prisma.InputJsonValue = JSON.parse(
JSON.stringify({
...next,
equipmentMode: normalized.equipmentMode as EquipmentMode,
availableEquipment: normalized.availableEquipment,
workoutEnvironment: normalized.workoutEnvironment as WorkoutEnvironment,
quickTimeBudget: normalized.prescription.quickTimeBudget,
planSessionMinutes: normalized.prescription.planSessionMinutes,
restIntervalSeconds: normalized.prescription.restIntervalSeconds,
warmupRoutineEnabled: normalized.prescription.warmupRoutineEnabled,
warmupExerciseCount: normalized.prescription.warmupExerciseCount,
warmupReps: normalized.prescription.warmupReps,
cooldownRoutineEnabled: normalized.prescription.cooldownRoutineEnabled,
cooldownExerciseCount: normalized.prescription.cooldownExerciseCount,
cooldownHoldSeconds: normalized.prescription.cooldownHoldSeconds,
}),
);

await prisma.user.update({
where: { id: session.user.id },
data: { onboardingPreferences: next },
data: { onboardingPreferences: payload },
});

return NextResponse.json({ success: true });
Expand All @@ -82,3 +128,10 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: "INTERNAL_SERVER_ERROR" }, { status: 500 });
}
}

function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
85 changes: 78 additions & 7 deletions locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1545,21 +1545,60 @@ export default {
time_label: "How much time do you have?",
hint: "Great for a movement snack during work or on rest days.",
generate: "Generate quick workout",
generate_with_duration: "Generate quick workout ({duration} min)",
generating: "Generating...",
time_option_label: "{minutes} min",
},
goal_split: {
selection_hint: "Tap a day to load its muscles. You can fine-tune them in the next step.",
},
plan_page: {
create_title: "Create your training plan",
create_subtitle: "Set your goal and schedule. We'll generate a science-based split.",
sign_in_prompt_suffix: "to save your plan and track progress.",
save_error_auth: "Please sign in to save your plan.",
save_error_generic: "Something went wrong. Please try again.",
edit_title: "Edit your plan",
preserve_progress: "Your progress ({sessions} sessions) will be preserved.",
my_plan_title: "My Training Plan",
progress_title: "Progress",
progress_sessions: "{count} sessions completed",
next_recommended: "Next recommended: Day {day} · Cycle {cycle}",
weekly_split_title: "Weekly Split",
days_per_week: "{days} days / week",
next_up: "Next up",
save_plan: "Save plan",
save_changes: "Save changes",
train_button: "Train",
},
weekly_adjustment: {
title: "Next week recommendation",
apply: "Apply",
applied: "Applied",
days_per_week: "days/week",
},
plan_session: {
title: "Quick plan session",
subtitle: "One-click today's full workout — advances your plan",
plan_session: {
title: "Quick plan session",
subtitle: "One-click today's full workout — advances your plan",
today_title: "Today: Day {day}",
reason_no_recent_training: "No recent training history. Start from Day 1.",
reason_cycle_reset: "You finished the cycle. Start next cycle from Day {day} ({restDays}d since last session).",
reason_continue: "Last trained Day {lastDay} ({restDays}d ago). Continue with Day {nextDay}.",
target_duration: "Target duration: {duration} min",
duration_badge: "Suggested duration: {duration} min",
duration_range: "Recommended range: {min}-{max} min",
hint: "Select your equipment below, then generate instantly.",
generate: "Start today's training",
generating: "Generating...",
},
generate: "Start today's training",
generate_with_duration: "Generate this day's training ({duration} min)",
start_today_with_duration: "Start today's training ({duration} minutes)",
configure_equipment: "Select equipment to start",
requires_equipment: "Select at least one equipment (or enable Bodyweight-only mode) before generating.",
manage_plan: "Manage plan & progress",
generating: "Generating...",
},
stepper_bodyweight_mode_notice: "Bodyweight mode is active. BODY_ONLY equipment is auto-applied; change it in your preferences if needed.",
stepper_bodyweight_preference_notice: "Bodyweight preference is active and BODY_ONLY is auto-selected.",
stepper_split_day_hint: "Day {day} muscles loaded from your split. Adjust if needed.",
steps: {
equipment: {
title: "Equipment",
Expand Down Expand Up @@ -1594,9 +1633,16 @@ export default {
},
exercise: {
watch_video: "Watch video",
image_alt: "Exercise image",
add_with_name: "Add {name}",
description_title: "Description",
equipment_title: "Equipment",
types_title: "Exercise Types",
shuffle: "Shuffle",
pick: "Pick",
remove: "Remove",
cancel: "Cancel",
confirm_pick: "Confirm Pick",
no_video_available: "No video available.",
},
loading: {
Expand All @@ -1607,6 +1653,7 @@ export default {
},
no_exercises_found: "No exercises found. Try to change your equipment or muscles selection.",
addExercise: "Add exercise",
add_exercise_mascot_alt: "Workout mascot",
exerciseAdded: "{name} added to workout",
exercises: "exercises",
equipment: {
Expand Down Expand Up @@ -1678,17 +1725,38 @@ export default {
},
session: {
back_to_workout: "Back to workout",
congrats_image_alt: "Trophy",
congrats: "Congratulations, workout finished! 🎉",
congrats_subtitle: "You've done it !",
see_instructions: "See instructions",
finish_set: "Finish Set",
finish_session: "Finish Session",
rest_label: "Rest",
rest_countdown: "{seconds} s",
skip_rest: "Skip rest",
error_delete_session: "Failed to delete the session. Please try again.",
error_shuffling_exercise: "Failed to shuffle exercise. Please try again.",
error_picking_exercise: "Failed to pick exercise. Please try again.",
warmup_title: "Warm-up Stretches",
warmup_subtitle: "Dynamic stretches to activate your muscles before training. Tap to mark done.",
cooldown_title: "Cool-down Stretches",
cooldown_subtitle: "Static stretches to relax your muscles and aid recovery. Hold each for the duration shown.",
prescription_card_title: "Session setup",
prescription_rest_label: "Rest interval",
prescription_rest_value: "{seconds}s",
prescription_warmup_label: "Warm-up",
prescription_warmup_value: "{sets} x {reps} reps",
prescription_cooldown_label: "Cool-down",
prescription_cooldown_value: "{sets} x {seconds}s hold",
prescription_feature_disabled: "Not included",
stretch_reps: "reps per side",
stretch_hold: "hold each side",
exercise_completed: "Exercise completed",
exercise_pending: "Upcoming exercise",
toggle_complete: "Toggle complete",
delete_set: "Delete set",
time_unit_min: "min",
time_unit_seconds: "s",
bodyweight: "Bodyweight",
weight: "Weight",
reps: "Reps",
Expand All @@ -1704,11 +1772,15 @@ export default {
set_number_plural_singular: "Sets {number}",
workout_in_progress: "Workout in Progress",
started_at: "Started at",
target_duration_label: "Recommended duration",
remaining_time: "Remaining: {time}",
target_time_exceeded: "Over target by {time}",
quit_workout: "Quit Workout",
elapsed_time: "Elapsed Time",
chronometer: "Chronometer",
exercise_progress: "Exercise Progress",
total_volume: "Total Volume",
exercise_image_alt: "Exercise image",
current_exercise: "Current Exercise",
complete: "Complete",
active: "Active",
Expand Down Expand Up @@ -1977,7 +2049,6 @@ export default {
premium_required: "Premium required to access statistics",

total_workouts: "Total Workouts",
total_volume: "Total Volume",
total_sets: "Total Sets",
total_time: "Total Time",
top_exercises: "Top Exercises",
Expand Down
Loading
Loading