Client api generator

This commit is contained in:
2026-04-06 09:17:04 +02:00
parent a6a9d0123c
commit 4ba1bba5db
37 changed files with 1802 additions and 314 deletions

View File

@@ -7,7 +7,8 @@ import { Activities } from '@/pages/Activities'
import { WeekPlanPage } from '@/pages/WeekPlanPage'
import { Placeholder } from '@/pages/Placeholder'
import { Settings } from '@/pages/Settings'
import { api } from '@/api/client'
import { athletes } from '@/api/AthleteClient'
import { queryKeys } from '@/api/hooks'
const queryClient = new QueryClient()
@@ -16,8 +17,8 @@ function AppContent() {
const [page, setPage] = useState('dashboard')
const { data: athlete } = useQuery({
queryKey: ['athlete', athleteId],
queryFn: () => api.athlete(athleteId!),
queryKey: queryKeys.athletes.getById(athleteId ?? 0),
queryFn: () => athletes.getById(athleteId!),
enabled: athleteId !== null,
staleTime: Infinity,
})

View File

@@ -0,0 +1,11 @@
// GENERATED — do not edit manually.
import { get } from './httpClient';
import type { AthleteDo } from '../types/AthleteDo';
export const athletes = {
getById: (id: number): Promise<AthleteDo> =>
get<AthleteDo>(`/api/athletes/${id}`),
getByLogin: (login: string): Promise<AthleteDo> =>
get<AthleteDo>(`/api/athletes/by-login/${login}`),
};

View File

@@ -0,0 +1,15 @@
// GENERATED — do not edit manually.
import { buildQuery, post, del, get, put } from './httpClient';
import type { GoalDo } from '../types/GoalDo';
export const goals = {
create: (athleteId: number | undefined, body: GoalDo): Promise<GoalDo> =>
post<GoalDo>(`/api/goals${buildQuery({ athleteId })}`, body),
delete: (id: number): Promise<void> =>
del<void>(`/api/goals/${id}`),
getByAthlete: (athleteId?: number): Promise<GoalDo[]> =>
get<GoalDo[]>(`/api/goals${buildQuery({ athleteId })}`),
update: (id: number, body: GoalDo): Promise<GoalDo> =>
put<GoalDo>(`/api/goals/${id}`, body),
};

View File

@@ -0,0 +1,17 @@
// GENERATED — do not edit manually.
import { buildQuery, get, put } from './httpClient';
import type { IntervalsActivityDo } from '../types/IntervalsActivityDo';
export const activities = {
getByAthlete: (athleteId?: number, page = 0, size = 50): Promise<IntervalsActivityDo[]> =>
get<IntervalsActivityDo[]>(`/api/activities${buildQuery({ athleteId, page, size })}`),
getByDay: (athleteId?: number, date?: string): Promise<IntervalsActivityDo[]> =>
get<IntervalsActivityDo[]>(`/api/activities/by-day${buildQuery({ athleteId, date })}`),
getById: (id: number): Promise<IntervalsActivityDo> =>
get<IntervalsActivityDo>(`/api/activities/${id}`),
linkWorkout: (id: number, workoutId: number): Promise<IntervalsActivityDo> =>
put<IntervalsActivityDo>(`/api/activities/${id}/link-workout/${workoutId}`),
unlinkWorkout: (id: number): Promise<IntervalsActivityDo> =>
put<IntervalsActivityDo>(`/api/activities/${id}/unlink-workout`),
};

View File

@@ -0,0 +1,28 @@
// GENERATED — do not edit manually.
import { buildQuery, post, del, get, put } from './httpClient';
import type { WeekPlanDo } from '../types/WeekPlanDo';
import type { WorkoutDo } from '../types/WorkoutDo';
export const weekPlans = {
create: (body: WeekPlanDo): Promise<WeekPlanDo> =>
post<WeekPlanDo>('/api/week-plans', body),
delete: (id: number): Promise<void> =>
del<void>(`/api/week-plans/${id}`),
exportPlan: (id: number): Promise<WeekPlanDo> =>
get<WeekPlanDo>(`/api/week-plans/${id}/export`),
getByAthlete: (athleteId?: number): Promise<WeekPlanDo[]> =>
get<WeekPlanDo[]>(`/api/week-plans${buildQuery({ athleteId })}`),
getById: (id: number): Promise<WeekPlanDo> =>
get<WeekPlanDo>(`/api/week-plans/${id}`),
getImportPrompt: (): Promise<unknown> =>
get<unknown>('/api/week-plans/import/prompt'),
getImportSchema: (): Promise<unknown> =>
get<unknown>('/api/week-plans/import/schema'),
importPlan: (athleteId: number | undefined, body: WeekPlanDo): Promise<WeekPlanDo> =>
post<WeekPlanDo>(`/api/week-plans/import${buildQuery({ athleteId })}`, body),
update: (id: number, body: WeekPlanDo): Promise<WeekPlanDo> =>
put<WeekPlanDo>(`/api/week-plans/${id}`, body),
updateWorkout: (planId: number, workoutId: number, body: WorkoutDo): Promise<WorkoutDo> =>
put<WorkoutDo>(`/api/week-plans/${planId}/workouts/${workoutId}`, body),
};

View File

@@ -1,77 +0,0 @@
import type { Activity, Athlete, Goal, WeekPlan, WeekPlanImport, Workout } from '@/types/api'
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8111'
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`)
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`)
return res.json() as Promise<T>
}
async function del(path: string): Promise<void> {
const res = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' })
if (!res.ok) {
const text = await res.text()
throw new Error(`HTTP ${res.status}: ${text}`)
}
}
async function put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`HTTP ${res.status}: ${text}`)
}
return res.json() as Promise<T>
}
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`HTTP ${res.status}: ${text}`)
}
return res.json() as Promise<T>
}
export const api = {
activities: (athleteId: number): Promise<Activity[]> =>
get(`/api/activities?athleteId=${athleteId}&size=500`),
activity: (id: number): Promise<Activity> => get(`/api/activities/${id}`),
athlete: (id: number): Promise<Athlete> => get(`/api/athletes/${id}`),
athleteByLogin: (login: string): Promise<Athlete> =>
get(`/api/athletes/by-login/${login}`),
weekPlans: (athleteId: number): Promise<WeekPlan[]> =>
get(`/api/week-plans?athleteId=${athleteId}`),
weekPlan: (id: number): Promise<WeekPlan> =>
get(`/api/week-plans/${id}`),
importWeekPlan: (athleteId: number, body: WeekPlanImport): Promise<WeekPlan> =>
post(`/api/week-plans/import?athleteId=${athleteId}`, body),
importSchema: (): Promise<unknown> => get('/api/week-plans/import/schema'),
deleteWeekPlan: (id: number): Promise<void> =>
del(`/api/week-plans/${id}`),
updateWorkout: (planId: number, workoutId: number, body: Workout): Promise<Workout> =>
put(`/api/week-plans/${planId}/workouts/${workoutId}`, body),
activitiesByDay: (athleteId: number, date: string): Promise<Activity[]> =>
get(`/api/activities/by-day?athleteId=${athleteId}&date=${date}`),
linkActivity: (activityId: number, workoutId: number): Promise<Activity> =>
put(`/api/activities/${activityId}/link-workout/${workoutId}`, null),
unlinkActivity: (activityId: number): Promise<Activity> =>
put(`/api/activities/${activityId}/unlink-workout`, null),
goals: (athleteId: number): Promise<Goal[]> =>
get(`/api/goals?athleteId=${athleteId}`),
createGoal: (athleteId: number, body: Pick<Goal, 'period' | 'metric' | 'targetValue'>): Promise<Goal> =>
post(`/api/goals?athleteId=${athleteId}`, body),
updateGoal: (id: number, targetValue: number): Promise<Goal> =>
put(`/api/goals/${id}`, { targetValue }),
deleteGoal: (id: number): Promise<void> =>
del(`/api/goals/${id}`),
}

View File

@@ -0,0 +1,209 @@
// GENERATED — do not edit manually.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { activities } from './IntervalsActivityClient';
import { athletes } from './AthleteClient';
import { goals } from './GoalClient';
import { weekPlans } from './WeekPlanClient';
import type { IntervalsActivityDo } from '../types/IntervalsActivityDo';
import type { GoalDo } from '../types/GoalDo';
import type { WeekPlanDo } from '../types/WeekPlanDo';
import type { WorkoutDo } from '../types/WorkoutDo';
export const queryKeys = {
activities: {
getByAthlete: (athleteId?: number, page?: number, size?: number) => ['activities', 'getByAthlete', athleteId, page, size] as const,
getByDay: (athleteId?: number, date?: string) => ['activities', 'getByDay', athleteId, date] as const,
getById: (id: number) => ['activities', 'getById', id] as const,
},
athletes: {
getById: (id: number) => ['athletes', 'getById', id] as const,
getByLogin: (login: string) => ['athletes', 'getByLogin', login] as const,
},
goals: {
getByAthlete: (athleteId?: number) => ['goals', 'getByAthlete', athleteId] as const,
},
weekPlans: {
exportPlan: (id: number) => ['weekPlans', 'exportPlan', id] as const,
getByAthlete: (athleteId?: number) => ['weekPlans', 'getByAthlete', athleteId] as const,
getById: (id: number) => ['weekPlans', 'getById', id] as const,
getImportPrompt: () => ['weekPlans', 'getImportPrompt'] as const,
getImportSchema: () => ['weekPlans', 'getImportSchema'] as const,
},
};
// ── activities ────────────────────────────────────
export function useActivitiesGetByAthlete(athleteId?: number, page = 0, size = 50) {
return useQuery({
queryKey: queryKeys.activities.getByAthlete(athleteId, page, size),
queryFn: () => activities.getByAthlete(athleteId, page, size),
});
}
export function useActivitiesGetByDay(athleteId?: number, date?: string) {
return useQuery({
queryKey: queryKeys.activities.getByDay(athleteId, date),
queryFn: () => activities.getByDay(athleteId, date),
});
}
export function useActivitiesGetById(id: number) {
return useQuery({
queryKey: queryKeys.activities.getById(id),
queryFn: () => activities.getById(id),
});
}
export function useActivitiesLinkWorkout() {
const queryClient = useQueryClient();
return useMutation<IntervalsActivityDo, Error, { id: number; workoutId: number }>({
mutationFn: ({ id, workoutId }) =>
activities.linkWorkout(id, workoutId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['activities'] }),
});
}
export function useActivitiesUnlinkWorkout() {
const queryClient = useQueryClient();
return useMutation<IntervalsActivityDo, Error, number>({
mutationFn: (id) =>
activities.unlinkWorkout(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['activities'] }),
});
}
// ── athletes ────────────────────────────────────
export function useAthletesGetById(id: number) {
return useQuery({
queryKey: queryKeys.athletes.getById(id),
queryFn: () => athletes.getById(id),
});
}
export function useAthletesGetByLogin(login: string) {
return useQuery({
queryKey: queryKeys.athletes.getByLogin(login),
queryFn: () => athletes.getByLogin(login),
});
}
// ── goals ────────────────────────────────────
export function useGoalsCreate() {
const queryClient = useQueryClient();
return useMutation<GoalDo, Error, { athleteId?: number; body: GoalDo }>({
mutationFn: ({ athleteId, body }) =>
goals.create(athleteId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }),
});
}
export function useGoalsDelete() {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (id) =>
goals.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }),
});
}
export function useGoalsGetByAthlete(athleteId?: number) {
return useQuery({
queryKey: queryKeys.goals.getByAthlete(athleteId),
queryFn: () => goals.getByAthlete(athleteId),
});
}
export function useGoalsUpdate() {
const queryClient = useQueryClient();
return useMutation<GoalDo, Error, { id: number; body: GoalDo }>({
mutationFn: ({ id, body }) =>
goals.update(id, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['goals'] }),
});
}
// ── weekPlans ────────────────────────────────────
export function useWeekPlansCreate() {
const queryClient = useQueryClient();
return useMutation<WeekPlanDo, Error, WeekPlanDo>({
mutationFn: (body) =>
weekPlans.create(body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['weekPlans'] }),
});
}
export function useWeekPlansDelete() {
const queryClient = useQueryClient();
return useMutation<void, Error, number>({
mutationFn: (id) =>
weekPlans.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['weekPlans'] }),
});
}
export function useWeekPlansExportPlan(id: number) {
return useQuery({
queryKey: queryKeys.weekPlans.exportPlan(id),
queryFn: () => weekPlans.exportPlan(id),
});
}
export function useWeekPlansGetByAthlete(athleteId?: number) {
return useQuery({
queryKey: queryKeys.weekPlans.getByAthlete(athleteId),
queryFn: () => weekPlans.getByAthlete(athleteId),
});
}
export function useWeekPlansGetById(id: number) {
return useQuery({
queryKey: queryKeys.weekPlans.getById(id),
queryFn: () => weekPlans.getById(id),
});
}
export function useWeekPlansGetImportPrompt() {
return useQuery({
queryKey: queryKeys.weekPlans.getImportPrompt(),
queryFn: () => weekPlans.getImportPrompt(),
});
}
export function useWeekPlansGetImportSchema() {
return useQuery({
queryKey: queryKeys.weekPlans.getImportSchema(),
queryFn: () => weekPlans.getImportSchema(),
});
}
export function useWeekPlansImportPlan() {
const queryClient = useQueryClient();
return useMutation<WeekPlanDo, Error, { athleteId?: number; body: WeekPlanDo }>({
mutationFn: ({ athleteId, body }) =>
weekPlans.importPlan(athleteId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['weekPlans'] }),
});
}
export function useWeekPlansUpdate() {
const queryClient = useQueryClient();
return useMutation<WeekPlanDo, Error, { id: number; body: WeekPlanDo }>({
mutationFn: ({ id, body }) =>
weekPlans.update(id, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['weekPlans'] }),
});
}
export function useWeekPlansUpdateWorkout() {
const queryClient = useQueryClient();
return useMutation<WorkoutDo, Error, { planId: number; workoutId: number; body: WorkoutDo }>({
mutationFn: ({ planId, workoutId, body }) =>
weekPlans.updateWorkout(planId, workoutId, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['weekPlans'] }),
});
}

View File

@@ -0,0 +1,47 @@
// GENERATED — do not edit manually.
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8111';
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const p = new URLSearchParams();
for (const [key, val] of Object.entries(params)) {
if (val !== undefined) p.set(key, String(val));
}
const query = p.toString();
return query ? `?${query}` : '';
}
export async function get<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
return res.json() as Promise<T>;
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export async function put<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export async function del<T>(path: string): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { api } from '@/api/client'
import { athletes } from '@/api/AthleteClient'
export function LoginDialog({ onLogin }: { onLogin: (athleteId: number) => void }) {
const [login, setLogin] = useState('')
@@ -13,8 +13,8 @@ export function LoginDialog({ onLogin }: { onLogin: (athleteId: number) => void
setLoading(true)
setError(null)
try {
const athlete = await api.athleteByLogin(trimmed)
onLogin(athlete.id)
const athlete = await athletes.getByLogin(trimmed)
onLogin(athlete.id!)
} catch (err) {
const msg = err instanceof Error ? err.message : ''
setError(msg.includes('404') ? 'Unknown athlete' : 'Something went wrong')

View File

@@ -1,6 +1,6 @@
import {Activity, BarChart2, Calendar, Home, LogOut, Settings, Zap} from 'lucide-react'
import PeakupLogo from '@/components/PeakupLogo'
import {type Athlete} from '@/types/api'
import {type AthleteDo} from '@/types/AthleteDo'
interface NavItem {
icon: React.ReactNode
@@ -21,7 +21,7 @@ const navItems: NavItem[] = [
interface SidebarProps {
currentPage: string
onNavigate: (page: string) => void
athlete?: Athlete
athlete?: AthleteDo
onLogout: () => void
}

View File

@@ -4,8 +4,41 @@ import { getISOWeek, getISOWeekYear, parseISO, format } from 'date-fns'
import { X, AlertCircle, CheckCircle2 } from 'lucide-react'
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020'
import addFormats from 'ajv-formats'
import { api } from '@/api/client'
import { type WeekPlan, type WeekPlanImport, type WorkoutImport, type WorkoutStepImport, type WorkoutLoadImport } from '@/types/api'
import { weekPlans } from '@/api/WeekPlanClient'
import { queryKeys } from '@/api/hooks'
import { type WeekPlanDo } from '@/types/WeekPlanDo'
interface WorkoutLoadImport {
stepType: string
durationSeconds: number
targetValuePctStart?: number
targetValuePctEnd?: number
}
interface WorkoutStepImport {
label: string
intensityReference: string
repeat: number
subSteps: WorkoutLoadImport[]
}
interface WorkoutImport {
title: string
workoutAt: string
workoutType: string
estimatedTimeSeconds?: number
estimatedTss?: number
statusNote?: string
steps: WorkoutStepImport[]
}
interface WeekPlanImport {
year: number
calendarWeek: number
weekLabel: string
weekDescription: string
workouts: WorkoutImport[]
}
// ── AJV setup ─────────────────────────────────────────────────────────────────
@@ -238,7 +271,7 @@ function ImportPreview({ data }: { data: WeekPlanImport }) {
interface Props {
athleteId: number
onClose: () => void
onImported: (plan: WeekPlan) => void
onImported: (plan: WeekPlanDo) => void
}
export function WeekPlanImportDialog({ athleteId, onClose, onImported }: Props) {
@@ -246,8 +279,8 @@ export function WeekPlanImportDialog({ athleteId, onClose, onImported }: Props)
const queryClient = useQueryClient()
const { data: schema } = useQuery({
queryKey: ['weekPlanImportSchema'],
queryFn: api.importSchema,
queryKey: queryKeys.weekPlans.getImportSchema(),
queryFn: () => weekPlans.getImportSchema(),
staleTime: Infinity,
})
@@ -260,10 +293,10 @@ export function WeekPlanImportDialog({ athleteId, onClose, onImported }: Props)
}
const { mutate, isPending, error: submitError } = useMutation({
mutationFn: (body: WeekPlanImport) => api.importWeekPlan(athleteId, body),
mutationFn: (body: WeekPlanImport) => weekPlans.importPlan(athleteId, body as unknown as WeekPlanDo),
onSuccess: (plan) => {
queryClient.invalidateQueries({ queryKey: ['weekPlans', athleteId] })
queryClient.invalidateQueries({ queryKey: ['weekPlan', plan.id] })
queryClient.invalidateQueries({ queryKey: queryKeys.weekPlans.getByAthlete(athleteId) })
queryClient.invalidateQueries({ queryKey: queryKeys.weekPlans.getById(plan.id!) })
onImported(plan)
},
})

View File

@@ -1,18 +1,21 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, Unlink, X } from 'lucide-react'
import { api } from '@/api/client'
import { type Activity, type Workout } from '@/types/api'
import { activities } from '@/api/IntervalsActivityClient'
import { weekPlans } from '@/api/WeekPlanClient'
import { queryKeys } from '@/api/hooks'
import { type IntervalsActivityDo } from '@/types/IntervalsActivityDo'
import { type WorkoutDo } from '@/types/WorkoutDo'
interface Props {
workout: Workout
workout: WorkoutDo
planId: number
athleteId: number
onClose: () => void
}
const WORKOUT_TYPES: Workout['workoutType'][] = ['BIKE', 'RUN', 'SWIM', 'STRENGTH', 'OTHER']
const WORKOUT_STATUSES: Workout['status'][] = ['PLANNED', 'COMPLETED', 'SKIPPED', 'ALTERNATIVE', 'PARTIAL']
const WORKOUT_TYPES: WorkoutDo['workoutType'][] = ['BIKE', 'RUN', 'SWIM', 'STRENGTH', 'OTHER']
const WORKOUT_STATUSES: WorkoutDo['status'][] = ['PLANNED', 'COMPLETED', 'SKIPPED', 'ALTERNATIVE', 'PARTIAL']
function toLocalDatetimeValue(iso: string | null): string {
if (!iso) return ''
@@ -29,7 +32,7 @@ function extractDate(localDatetime: string): string | null {
return localDatetime.slice(0, 10) // "YYYY-MM-DD"
}
function formatActivityLabel(a: Activity): string {
function formatActivityLabel(a: IntervalsActivityDo): string {
const time = new Date(a.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
const dist = a.distanceM > 0 ? ` · ${(a.distanceM / 1000).toFixed(1)} km` : ''
const dur = a.movingTimeS > 0
@@ -57,42 +60,42 @@ export function WorkoutEditDialog({ workout, planId, athleteId, onClose }: Props
const day = extractDate(workoutAt)
const { data: dayActivities = [] } = useQuery({
queryKey: ['activitiesByDay', athleteId, day],
queryFn: () => api.activitiesByDay(athleteId, day!),
queryKey: queryKeys.activities.getByDay(athleteId, day ?? undefined),
queryFn: () => activities.getByDay(athleteId, day ?? undefined),
enabled: day !== null,
staleTime: 5 * 60 * 1000,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['weekPlan', planId] })
queryClient.invalidateQueries({ queryKey: ['activitiesByDay', athleteId, day] })
queryClient.invalidateQueries({ queryKey: queryKeys.weekPlans.getById(planId) })
queryClient.invalidateQueries({ queryKey: queryKeys.activities.getByDay(athleteId, day ?? undefined) })
}
const { mutate: link, isPending: isLinking } = useMutation({
mutationFn: (activityId: number) => api.linkActivity(activityId, workout.id),
mutationFn: (activityId: number) => activities.linkWorkout(activityId, workout.id!),
onSuccess: invalidate,
})
const { mutate: unlink, isPending: isUnlinking } = useMutation({
mutationFn: (activityId: number) => api.unlinkActivity(activityId),
mutationFn: (activityId: number) => activities.unlinkWorkout(activityId),
onSuccess: invalidate,
})
const { mutate: save, isPending: isSaving } = useMutation({
mutationFn: () =>
api.updateWorkout(planId, workout.id, {
weekPlans.updateWorkout(planId, workout.id!, {
...workout,
title,
description: description || null,
workoutAt: toIsoInstant(workoutAt),
description: description || '',
workoutAt: toIsoInstant(workoutAt) ?? workout.workoutAt,
workoutType,
status,
statusNote: statusNote || null,
estimatedTimeSeconds: estimatedMinutes ? Math.round(Number(estimatedMinutes) * 60) : null,
estimatedTimeSeconds: estimatedMinutes ? Math.round(Number(estimatedMinutes) * 60) : workout.estimatedTimeSeconds,
estimatedTss: estimatedTss ? Number(estimatedTss) : null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['weekPlan', planId] })
queryClient.invalidateQueries({ queryKey: queryKeys.weekPlans.getById(planId) })
onClose()
},
})
@@ -129,12 +132,12 @@ export function WorkoutEditDialog({ workout, planId, athleteId, onClose }: Props
<div className="grid grid-cols-2 gap-3">
<Field label="TYPE">
<select value={workoutType} onChange={(e) => setWorkoutType(e.target.value as Workout['workoutType'])} className={inputCls}>
<select value={workoutType} onChange={(e) => setWorkoutType(e.target.value as WorkoutDo['workoutType'])} className={inputCls}>
{WORKOUT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<Field label="STATUS">
<select value={status} onChange={(e) => setStatus(e.target.value as Workout['status'])} className={inputCls}>
<select value={status} onChange={(e) => setStatus(e.target.value as WorkoutDo['status'])} className={inputCls}>
{WORKOUT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</Field>
@@ -209,7 +212,7 @@ export function WorkoutEditDialog({ workout, planId, athleteId, onClose }: Props
>
<span className="text-[11px] text-zinc-800 truncate">{formatActivityLabel(a)}</span>
<button
onClick={() => unlink(a.id)}
onClick={() => unlink(a.id!)}
disabled={isUnlinking}
title="Unlink"
className="shrink-0 text-zinc-400 hover:text-red-500 transition-colors disabled:opacity-40"
@@ -227,7 +230,7 @@ export function WorkoutEditDialog({ workout, planId, athleteId, onClose }: Props
>
<span className="text-[11px] text-zinc-500 truncate">{formatActivityLabel(a)}</span>
<button
onClick={() => link(a.id)}
onClick={() => link(a.id!)}
disabled={isLinking || unlinkable.length > 0}
title="Link"
className="shrink-0 text-zinc-400 hover:text-orange-500 transition-colors disabled:opacity-30"

View File

@@ -1,5 +1,4 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
createColumnHelper,
flexRender,
@@ -11,10 +10,11 @@ import {
type SortingState,
} from '@tanstack/react-table'
import { ArrowUpDown, ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { api } from '@/api/client'
import { type Activity, formatDistance, formatDuration, sportIcon } from '@/types/api'
import { useActivitiesGetByAthlete } from '@/api/hooks'
import type { IntervalsActivityDo } from '@/types/IntervalsActivityDo'
import { formatDistance, formatDuration, sportIcon } from '@/utils/activity'
const col = createColumnHelper<Activity>()
const col = createColumnHelper<IntervalsActivityDo>()
const columns = [
col.accessor('startTime', {
@@ -87,11 +87,7 @@ const columns = [
]
export function Activities({ athleteId }: { athleteId: number }) {
const { data: activities = [], isLoading } = useQuery({
queryKey: ['activities', athleteId],
queryFn: () => api.activities(athleteId),
staleTime: 5 * 60 * 1000,
})
const { data: activities = [], isLoading } = useActivitiesGetByAthlete(athleteId)
const [sorting, setSorting] = useState<SortingState>([
{ id: 'startTime', desc: true },

View File

@@ -1,4 +1,3 @@
import {useQuery} from '@tanstack/react-query'
import {
addWeeks,
eachDayOfInterval,
@@ -15,8 +14,11 @@ import {
import {ChevronLeft, ChevronRight} from 'lucide-react'
import {useSearchParams} from 'react-router-dom'
import {Area, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis,} from 'recharts'
import {api} from '@/api/client'
import {type Activity, formatDistance, formatDuration, type Goal, sportIcon} from '@/types/api'
import {useActivitiesGetByAthlete, useGoalsGetByAthlete} from '@/api/hooks'
import type {IntervalsActivityDo} from '@/types/IntervalsActivityDo'
import type {GoalDo} from '@/types/GoalDo'
import type {GoalMetricEnum} from '@/types/GoalMetricEnum'
import {formatDistance, formatDuration, sportIcon} from '@/utils/activity'
// ── Week helpers ──────────────────────────────────────────────────────────────
@@ -68,7 +70,7 @@ function ProgressBar({value, max}: { value: number; max: number }) {
// ── Goal helpers ──────────────────────────────────────────────────────────────
type Metric = Goal['metric']
type Metric = GoalMetricEnum
type Actuals = Record<Metric, number>
function secondsToHhmm(seconds: number): string {
@@ -94,10 +96,12 @@ function formatGoalValue(metric: Metric, value: number): string {
return `${Math.round(value)} m`
case 'TIME':
return secondsToHhmm(value)
default:
return String(value)
}
}
function computeActuals(acts: Activity[]): Actuals {
function computeActuals(acts: IntervalsActivityDo[]): Actuals {
return {
DISTANCE: acts.reduce((s, a) => s + a.distanceM, 0) / 1000,
TSS: acts.reduce((s, a) => s + (a.trainingLoad ?? 0), 0),
@@ -146,7 +150,7 @@ function GoalPeriodColumn({
}: {
title: string
subtitle: string
goals: Goal[]
goals: GoalDo[]
actuals: Actuals
}) {
return (
@@ -183,7 +187,7 @@ type LoadPoint = {
tsb: number | null
}
function buildLoadData(activities: Activity[], anchor: Date): LoadPoint[] {
function buildLoadData(activities: IntervalsActivityDo[], anchor: Date): LoadPoint[] {
const anchorWeekStart = startOfISOWeek(anchor)
const weekMondays: Date[] = Array.from({length: 12}, (_, i) =>
addWeeks(anchorWeekStart, i - 11),
@@ -217,7 +221,7 @@ function buildLoadData(activities: Activity[], anchor: Date): LoadPoint[] {
// ── DayCard ───────────────────────────────────────────────────────────────────
function DayCard({day, activities}: { day: Date; activities: Activity[] }) {
function DayCard({day, activities}: { day: Date; activities: IntervalsActivityDo[] }) {
const today = isToday(day)
const dayActivities = activities.filter((a) => isSameDay(new Date(a.startTime), day))
return (
@@ -242,7 +246,7 @@ function DayCard({day, activities}: { day: Date; activities: Activity[] }) {
) : (
<ul className="space-y-1">
{dayActivities.map((a) => (
<li key={a.id} className="flex items-center gap-1.5">
<li key={a.originId} className="flex items-center gap-1.5">
<span className="text-sm leading-none">{sportIcon(a.type)}</span>
<div className="min-w-0">
<p className="text-xs font-medium text-zinc-700 truncate leading-none">{a.name}</p>
@@ -312,17 +316,9 @@ export function Dashboard({athleteId}: { athleteId: number }) {
setSearchParams({year: String(currentISOYear), week: String(currentISOWeek)})
}
const {data: activities = [], isLoading} = useQuery({
queryKey: ['activities', athleteId],
queryFn: () => api.activities(athleteId),
staleTime: 5 * 60 * 1000,
})
const {data: activities = [], isLoading} = useActivitiesGetByAthlete(athleteId)
const {data: goals = []} = useQuery({
queryKey: ['goals', athleteId],
queryFn: () => api.goals(athleteId),
staleTime: 60_000,
})
const {data: goals = []} = useGoalsGetByAthlete(athleteId)
if (isLoading) {
return (

View File

@@ -1,12 +1,15 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect } from 'react'
import { api } from '@/api/client'
import type { Goal } from '@/types/api'
import { goals } from '@/api/GoalClient'
import { queryKeys } from '@/api/hooks'
import type { GoalDo } from '@/types/GoalDo'
import type { GoalPeriodEnum } from '@/types/GoalPeriodEnum'
import type { GoalMetricEnum } from '@/types/GoalMetricEnum'
// ── Constants ─────────────────────────────────────────────────────────────────
type Period = 'WEEKLY' | 'MONTHLY' | 'YEARLY'
type Metric = 'DISTANCE' | 'TSS' | 'ELEVATION' | 'TIME'
type Period = GoalPeriodEnum
type Metric = GoalMetricEnum
const PERIODS: { key: Period; label: string; description: string }[] = [
{ key: 'WEEKLY', label: 'Weekly', description: 'Short-term consistency targets' },
@@ -58,10 +61,10 @@ function parseInputValue(metric: Metric, raw: string): ParseResult {
type AllValues = Record<Period, Record<Metric, string>>
type AllErrors = Record<Period, Record<Metric, string | null>>
function initAllValues(goals: Goal[]): AllValues {
function initAllValues(goalList: GoalDo[]): AllValues {
const empty = (): Record<Metric, string> => ({ DISTANCE: '', TSS: '', ELEVATION: '', TIME: '' })
const map: AllValues = { WEEKLY: empty(), MONTHLY: empty(), YEARLY: empty() }
for (const g of goals) {
for (const g of goalList) {
map[g.period][g.metric] = formatDisplayValue(g.metric, g.targetValue)
}
return map
@@ -135,21 +138,21 @@ function PeriodCard({
export function Settings({ athleteId }: { athleteId: number }) {
const qc = useQueryClient()
const { data: goals = [], isLoading } = useQuery({
queryKey: ['goals', athleteId],
queryFn: () => api.goals(athleteId),
const { data: goalList = [], isLoading } = useQuery({
queryKey: queryKeys.goals.getByAthlete(athleteId),
queryFn: () => goals.getByAthlete(athleteId),
staleTime: 60_000,
})
const [values, setValues] = useState<AllValues>(() => initAllValues(goals))
const [values, setValues] = useState<AllValues>(() => initAllValues(goalList))
const [errors, setErrors] = useState<AllErrors>(initAllErrors)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
setValues(initAllValues(goals))
setValues(initAllValues(goalList))
setErrors(initAllErrors())
}, [goals])
}, [goalList])
function handleChange(period: Period, metric: Metric, raw: string) {
setValues((prev) => ({ ...prev, [period]: { ...prev[period], [metric]: raw } }))
@@ -182,15 +185,15 @@ export function Settings({ athleteId }: { athleteId: number }) {
const result = parseInputValue(m.key, values[p.key][m.key])
if (result === 'empty' || result === null) continue
const existing = goals.find((g) => g.period === p.key && g.metric === m.key)
const existing = goalList.find((g) => g.period === p.key && g.metric === m.key)
if (existing) {
if (result !== existing.targetValue) await api.updateGoal(existing.id, result)
if (result !== existing.targetValue) await goals.update(existing.id!, {...existing, targetValue: result})
} else {
await api.createGoal(athleteId, { period: p.key, metric: m.key, targetValue: result })
await goals.create(athleteId, {period: p.key, metric: m.key, targetValue: result} as unknown as GoalDo)
}
}
}
await qc.invalidateQueries({ queryKey: ['goals', athleteId] })
await qc.invalidateQueries({ queryKey: queryKeys.goals.getByAthlete(athleteId) })
setSaved(true)
} catch {
setErrors((prev) => ({

View File

@@ -13,8 +13,10 @@ import {
} from 'date-fns'
import {ChevronLeft, ChevronRight, Download, Trash2, Upload} from 'lucide-react'
import {useSearchParams} from 'react-router-dom'
import {api} from '@/api/client'
import {type WeekPlan, type WeekPlanImport, type Workout} from '@/types/api'
import {weekPlans} from '@/api/WeekPlanClient'
import {queryKeys} from '@/api/hooks'
import {type WeekPlanDo} from '@/types/WeekPlanDo'
import {type WorkoutDo} from '@/types/WorkoutDo'
import {WeekPlanImportDialog} from '@/components/WeekPlanImportDialog'
import {WorkoutEditDialog} from '@/components/WorkoutEditDialog'
@@ -36,8 +38,8 @@ function weekStart(year: number, week: number): Date {
// ── Workout helpers ───────────────────────────────────────────────────────────
type WorkoutType = Workout['workoutType']
type WorkoutStatus = Workout['status']
type WorkoutType = WorkoutDo['workoutType']
type WorkoutStatus = WorkoutDo['status']
function workoutIcon(type: WorkoutType): string {
switch (type) {
@@ -89,7 +91,7 @@ function blockTop(workoutAt: string): number {
return Math.max(0, (d.getHours() - START_HOUR) * 60 + d.getMinutes()) * PX_PER_MINUTE
}
function blockHeight(workout: Workout): number {
function blockHeight(workout: WorkoutDo): number {
if (workout.estimatedTimeSeconds !== null) {
return Math.max((workout.estimatedTimeSeconds / 60) * PX_PER_MINUTE, 30)
}
@@ -111,7 +113,7 @@ function currentTimeTop(): number {
// ── WorkoutBlock ──────────────────────────────────────────────────────────────
function WorkoutBlock({workout, onClick}: { workout: Workout; onClick: () => void }) {
function WorkoutBlock({workout, onClick}: { workout: WorkoutDo; onClick: () => void }) {
if (!workout.workoutAt) return null
const top = blockTop(workout.workoutAt)
const height = blockHeight(workout)
@@ -181,7 +183,7 @@ function WorkoutBlock({workout, onClick}: { workout: Workout; onClick: () => voi
// ── WeekSummaryBar ────────────────────────────────────────────────────────────
function WeekSummaryBar({workouts}: { workouts: Workout[] }) {
function WeekSummaryBar({workouts}: { workouts: WorkoutDo[] }) {
const active = workouts.filter((w) => w.status !== 'SKIPPED')
const totalSeconds = active.reduce((s, w) => s + (w.estimatedTimeSeconds ?? 0), 0)
const totalTss = active.reduce((s, w) => s + (w.estimatedTss ?? 0), 0)
@@ -215,7 +217,7 @@ function WeekSummaryBar({workouts}: { workouts: Workout[] }) {
export function WeekPlanPage({athleteId}: { athleteId: number }) {
const [importOpen, setImportOpen] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [selectedWorkout, setSelectedWorkout] = useState<Workout | null>(null)
const [selectedWorkout, setSelectedWorkout] = useState<WorkoutDo | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient()
@@ -242,8 +244,8 @@ export function WeekPlanPage({athleteId}: { athleteId: number }) {
// Step 1: fetch all plan metadata for athlete
const {data: plans = []} = useQuery({
queryKey: ['weekPlans', athleteId],
queryFn: () => api.weekPlans(athleteId),
queryKey: queryKeys.weekPlans.getByAthlete(athleteId),
queryFn: () => weekPlans.getByAthlete(athleteId),
staleTime: 5 * 60 * 1000,
})
@@ -251,23 +253,23 @@ export function WeekPlanPage({athleteId}: { athleteId: number }) {
// Step 2: fetch full plan with workouts only when ID is known
const {data: weekPlan} = useQuery({
queryKey: ['weekPlan', planMeta?.id],
queryFn: () => api.weekPlan(planMeta!.id),
queryKey: queryKeys.weekPlans.getById(planMeta?.id ?? 0),
queryFn: () => weekPlans.getById(planMeta!.id!),
enabled: planMeta !== undefined,
staleTime: 5 * 60 * 1000,
})
const isPastWeek = year < currentISOYear || (year === currentISOYear && week < currentISOWeek)
function handleImported(plan: WeekPlan) {
function handleImported(plan: WeekPlanDo) {
setImportOpen(false)
setSearchParams({year: String(plan.year), week: String(plan.calendarWeek)})
queryClient.invalidateQueries({queryKey: ['weekPlans', athleteId]})
queryClient.invalidateQueries({queryKey: queryKeys.weekPlans.getByAthlete(athleteId)})
}
function handleExport() {
if (!weekPlan) return
const exportData: WeekPlanImport = {
const exportData = {
year: weekPlan.year,
calendarWeek: weekPlan.calendarWeek,
weekLabel: weekPlan.weekLabel ?? '',
@@ -292,11 +294,11 @@ export function WeekPlanPage({athleteId}: { athleteId: number }) {
}
const {mutate: deletePlan, isPending: isDeleting} = useMutation({
mutationFn: () => api.deleteWeekPlan(planMeta!.id),
mutationFn: () => weekPlans.delete(planMeta!.id!),
onSuccess: () => {
setConfirmDelete(false)
queryClient.invalidateQueries({queryKey: ['weekPlans', athleteId]})
queryClient.removeQueries({queryKey: ['weekPlan', planMeta?.id]})
queryClient.invalidateQueries({queryKey: queryKeys.weekPlans.getByAthlete(athleteId)})
queryClient.removeQueries({queryKey: queryKeys.weekPlans.getById(planMeta?.id ?? 0)})
},
})
@@ -537,7 +539,7 @@ export function WeekPlanPage({athleteId}: { athleteId: number }) {
{selectedWorkout && planMeta && (
<WorkoutEditDialog
workout={selectedWorkout}
planId={planMeta.id}
planId={planMeta.id!}
athleteId={athleteId}
onClose={() => setSelectedWorkout(null)}
/>

View File

@@ -0,0 +1,28 @@
// GENERATED — do not edit manually.
import type { AbstractTimestampedEntityDo } from './AbstractTimestampedEntityDo';
export interface AbstractActivityDo extends AbstractTimestampedEntityDo {
athleteId: number;
originId: string;
name: string;
type: string;
sportType: string;
startTime: string;
timezone: string;
distanceM: number;
movingTimeS: number;
elapsedTimeS: number;
totalElevationGainM: number;
averageSpeedMps: number;
maxSpeedMps: number;
averageHeartrateBpm: number;
maxHeartrateBPM: number;
averageCadenceRPM: number;
averagePowerWatt: number;
maxPowerWatt: number;
normalizedPowerWatt: number;
totalWorkKJ: number;
calories: number;
linkedWorkoutId: number | null;
}

View File

@@ -0,0 +1,5 @@
// GENERATED — do not edit manually.
export interface AbstractEntityDo {
id: number | null;
}

View File

@@ -0,0 +1,8 @@
// GENERATED — do not edit manually.
import type { AbstractEntityDo } from './AbstractEntityDo';
export interface AbstractTimestampedEntityDo extends AbstractEntityDo {
modifiedAt: string;
createdAt: string;
}

View File

@@ -0,0 +1,12 @@
// GENERATED — do not edit manually.
import type { AbstractTimestampedEntityDo } from './AbstractTimestampedEntityDo';
import type { SexEnum } from './SexEnum';
export interface AthleteDo extends AbstractTimestampedEntityDo {
firstName: string;
lastName: string;
login: string;
weight: number;
sex: SexEnum | null;
}

View File

@@ -0,0 +1,12 @@
// GENERATED — do not edit manually.
import type { AbstractTimestampedEntityDo } from './AbstractTimestampedEntityDo';
import type { GoalPeriodEnum } from './GoalPeriodEnum';
import type { GoalMetricEnum } from './GoalMetricEnum';
export interface GoalDo extends AbstractTimestampedEntityDo {
athleteId: number;
period: GoalPeriodEnum;
metric: GoalMetricEnum;
targetValue: number;
}

View File

@@ -0,0 +1,7 @@
// GENERATED — do not edit manually.
export type GoalMetricEnum =
| 'DISTANCE'
| 'TSS'
| 'ELEVATION'
| 'TIME';

View File

@@ -0,0 +1,6 @@
// GENERATED — do not edit manually.
export type GoalPeriodEnum =
| 'WEEKLY'
| 'MONTHLY'
| 'YEARLY';

View File

@@ -0,0 +1,7 @@
// GENERATED — do not edit manually.
export type IntensityReferenceEnum =
| 'FTP'
| 'LTHR'
| 'THRESHOLD_PACE'
| 'MAX_HR';

View File

@@ -0,0 +1,168 @@
// GENERATED — do not edit manually.
import type { AbstractActivityDo } from './AbstractActivityDo';
import type { StreamStatusEnum } from './StreamStatusEnum';
export interface IntervalsActivityDo extends AbstractActivityDo {
stravaOriginId: string;
description: string | null;
trainer: boolean | null;
commute: boolean | null;
race: boolean | null;
gearId: string;
gearName: string | null;
gearDistanceM: number | null;
gearPrimary: boolean | null;
totalAscentM: number;
totalDescentM: number;
minAltitudeM: number;
maxAltitudeM: number;
trainingLoad: number;
intensityFactor: number;
efficiencyFactor: number;
ftp: number | null;
atl: number;
ctl: number;
hrrBpmDrop: number | null;
hrrStartBpm: number | null;
hrrEndBpm: number | null;
hrrStartTimeSec: number | null;
hrrEndTimeSec: number | null;
hrrAverageWatts: number | null;
variabilityIndex: number;
feel: number | null;
perceivedExertion: number;
averageStrideLengthM: number;
averageTempC: number;
recordingStops: number[] | null;
minTempC: number | null;
maxTempC: number | null;
avgLrBalance: number | null;
gapMps: number | null;
gapModel: string | null;
deviceName: string | null;
deviceWatts: boolean | null;
powerMeter: string | null;
powerMeterSerial: string | null;
powerMeterBattery: string | null;
crankLengthMm: number | null;
powerField: string | null;
useElevationCorrection: boolean | null;
carbsUsedG: number | null;
icuDistanceM: number | null;
icuIgnoreTime: boolean | null;
icuRecordingTimeS: number | null;
coastingTimeS: number | null;
icuWarmupTimeS: number | null;
icuCooldownTimeS: number | null;
subType: string | null;
pairedEventId: string | null;
icuWeightKg: number | null;
lthr: number | null;
icuRestingHr: number | null;
thresholdPaceMps: number | null;
icuPowerSpikeThreshold: number | null;
icuSweetSpotMin: number | null;
icuSweetSpotMax: number | null;
trimp: number | null;
icuWPrimeJ: number | null;
pMaxWatt: number | null;
icuPmCp: number | null;
icuPmWPrimeJ: number | null;
icuPmPMaxWatt: number | null;
icuPmFtp: number | null;
icuPmFtpSecs: number | null;
icuPmFtpWatts: number | null;
icuRollingCp: number | null;
icuRollingWPrimeJ: number | null;
icuRollingPMaxWatt: number | null;
icuRollingFtp: number | null;
icuRollingFtpDelta: number | null;
ssCp: number | null;
ssWPrimeJ: number | null;
ssPMaxWatt: number | null;
icuIgnorePower: boolean | null;
icuIgnoreHr: boolean | null;
ignoreVelocity: boolean | null;
ignorePace: boolean | null;
hasHeartrate: boolean | null;
analyzed: string | null;
hasWeather: boolean | null;
hasSegments: boolean | null;
fileType: string | null;
fileSportIndex: number | null;
icuAthleteId: string | null;
icuCreated: string | null;
icuSyncDate: string | null;
icuChatId: number | null;
startDateLocal: string | null;
icuHrZones: string | null;
paceZones: string | null;
icuPowerZones: string | null;
icuTrainingLoadData: string | null;
intervalSummary: string | null;
streamTypes: string[] | null;
skylineChartBytes: string | null;
icuZoneTimes: string | null;
icuHrZoneTimes: string | null;
paceZoneTimes: string | null;
gapZoneTimes: string | null;
useGapZoneTimes: boolean | null;
tizOrder: string | null;
polarizationIndex: number | null;
decoupling: number | null;
icuLapCount: number | null;
icuJoulesAboveFtp: number | null;
icuMaxWbalDepletion: number | null;
icuPowerHrZ2: number | null;
icuPowerHrZ2Mins: number | null;
icuCadenceZ2: number | null;
icuRpe: number | null;
icuPowerHr: number | null;
icuMedianTimeDelta: number | null;
p30sExponent: number | null;
icuIntervalsEdited: boolean | null;
lockIntervals: boolean | null;
workoutShiftSecs: number | null;
poolLengthM: number | null;
lengths: string | null;
powerLoad: number | null;
hrLoad: number | null;
paceLoad: number | null;
hrLoadType: string | null;
paceLoadType: string | null;
sessionRpe: number | null;
strainScore: number | null;
kgLifted: number | null;
averageAltitudeM: number | null;
averageWeatherTempC: number | null;
minWeatherTempC: number | null;
maxWeatherTempC: number | null;
averageFeelsLikeC: number | null;
minFeelsLikeC: number | null;
maxFeelsLikeC: number | null;
averageWindSpeedMps: number | null;
averageWindGustMps: number | null;
prevailingWindDeg: number | null;
headwindPercent: number | null;
tailwindPercent: number | null;
averageClouds: number | null;
maxRainMm: number | null;
maxSnowMm: number | null;
carbsIngestedG: number | null;
paceMps: number | null;
athleteMaxHr: number | null;
compliance: string | null;
coachTick: boolean | null;
activityGroup: string | null;
icuSyncError: string | null;
icuColor: string | null;
source: string | null;
oauthClientId: string | null;
oauthClientName: string | null;
stravaId: number | null;
routeId: number | null;
powerFieldNames: string | null;
streamStatus: StreamStatusEnum;
streamImportError: string | null;
}

View File

@@ -0,0 +1,5 @@
// GENERATED — do not edit manually.
export type SexEnum =
| 'MALE'
| 'FEMALE';

View File

@@ -0,0 +1,7 @@
// GENERATED — do not edit manually.
export type StreamStatusEnum =
| 'PENDING'
| 'IMPORTED'
| 'NO_STREAM'
| 'ERROR';

View File

@@ -0,0 +1,13 @@
// GENERATED — do not edit manually.
import type { AbstractTimestampedEntityDo } from './AbstractTimestampedEntityDo';
import type { WorkoutDo } from './WorkoutDo';
export interface WeekPlanDo extends AbstractTimestampedEntityDo {
athleteId: number;
year: number;
calendarWeek: number;
weekLabel: string;
weekDescription: string;
workouts: WorkoutDo[];
}

View File

@@ -0,0 +1,20 @@
// GENERATED — do not edit manually.
import type { AbstractEntityDo } from './AbstractEntityDo';
import type { WorkoutTypeEnum } from './WorkoutTypeEnum';
import type { WorkoutStatusEnum } from './WorkoutStatusEnum';
import type { WorkoutStepDo } from './WorkoutStepDo';
export interface WorkoutDo extends AbstractEntityDo {
weekPlanId: number;
title: string;
description: string;
workoutAt: string;
workoutType: WorkoutTypeEnum;
sortOrder: number;
status: WorkoutStatusEnum;
statusNote: string | null;
estimatedTimeSeconds: number;
estimatedTss: number | null;
steps: WorkoutStepDo[];
}

View File

@@ -0,0 +1,13 @@
// GENERATED — do not edit manually.
import type { AbstractEntityDo } from './AbstractEntityDo';
import type { WorkoutStepTypeEnum } from './WorkoutStepTypeEnum';
export interface WorkoutLoadDo extends AbstractEntityDo {
stepId: number;
sortIndex: number;
stepType: WorkoutStepTypeEnum;
durationSeconds: number;
targetValuePctStart: number | null;
targetValuePctEnd: number | null;
}

View File

@@ -0,0 +1,8 @@
// GENERATED — do not edit manually.
export type WorkoutStatusEnum =
| 'PLANNED'
| 'COMPLETED'
| 'SKIPPED'
| 'ALTERNATIVE'
| 'PARTIAL';

View File

@@ -0,0 +1,15 @@
// GENERATED — do not edit manually.
import type { AbstractEntityDo } from './AbstractEntityDo';
import type { IntensityReferenceEnum } from './IntensityReferenceEnum';
import type { WorkoutLoadDo } from './WorkoutLoadDo';
export interface WorkoutStepDo extends AbstractEntityDo {
workoutId: number;
sortIndex: number;
label: string;
description: string;
intensityReference: IntensityReferenceEnum;
repeat: number;
subSteps: WorkoutLoadDo[];
}

View File

@@ -0,0 +1,7 @@
// GENERATED — do not edit manually.
export type WorkoutStepTypeEnum =
| 'WARM_UP'
| 'WORK'
| 'REST'
| 'COOL_DOWN';

View File

@@ -0,0 +1,8 @@
// GENERATED — do not edit manually.
export type WorkoutTypeEnum =
| 'BIKE'
| 'RUN'
| 'SWIM'
| 'STRENGTH'
| 'OTHER';

View File

@@ -1,137 +0,0 @@
export interface Activity {
id: number
athleteId: number
originId: string
name: string
type: string
sportType: string
startTime: string
timezone: string | null
distanceM: number
movingTimeS: number
elapsedTimeS: number
averageSpeedMps: number
maxSpeedMps: number
averageHeartrateBpm: number
maxHeartrateBPM: number
averageCadenceRPM: number
averagePowerWatt: number
normalizedPowerWatt: number
totalWorkKJ: number
calories: number
totalElevationGainM: number
trainingLoad: number
intensityFactor: number
ftp: number | null
atl: number
ctl: number
streamStatus: number
linkedWorkoutId: number | null
}
export interface Workout {
id: number
weekPlanId: number
title: string
description: string | null
workoutAt: string | null
workoutType: 'BIKE' | 'RUN' | 'SWIM' | 'STRENGTH' | 'OTHER'
sortOrder: number
status: 'PLANNED' | 'COMPLETED' | 'SKIPPED' | 'ALTERNATIVE' | 'PARTIAL'
statusNote: string | null
estimatedTimeSeconds: number | null
estimatedTss: number | null
}
export interface WeekPlan {
id: number
athleteId: number
year: number
calendarWeek: number
weekLabel: string | null
weekDescription: string | null
workouts: Workout[]
}
export interface WorkoutLoadImport {
stepType: 'WARM_UP' | 'WORK' | 'RAMP' | 'RECOVERY' | 'REST' | 'COOL_DOWN'
durationSeconds: number
targetValuePctStart?: number
targetValuePctEnd?: number
}
export interface WorkoutStepImport {
label: string
description?: string
intensityReference: 'FTP' | 'LTHR' | 'THRESHOLD_PACE' | 'MAX_HR'
repeat: number
subSteps: WorkoutLoadImport[]
}
export interface WorkoutImport {
title: string
workoutAt: string
workoutType: 'BIKE' | 'RUN' | 'SWIM' | 'STRENGTH' | 'OTHER'
estimatedTimeSeconds?: number
estimatedTss?: number
statusNote?: string
steps: WorkoutStepImport[]
}
export interface WeekPlanImport {
year: number
calendarWeek: number
weekLabel: string
weekDescription: string
workouts: WorkoutImport[]
}
export interface Goal {
id: number
athleteId: number
period: 'WEEKLY' | 'MONTHLY' | 'YEARLY'
metric: 'DISTANCE' | 'TSS' | 'ELEVATION' | 'TIME'
targetValue: number
}
export interface Athlete {
id: number
firstName: string
lastName: string
login: string
city: string | null
country: string | null
sex: string | null
weight: number | null
}
// Helpers
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
export function formatDistance(meters: number): string {
return (meters / 1000).toFixed(1) + ' km'
}
export function formatPace(mps: number): string {
if (mps === 0) return '—'
const sPerKm = 1000 / mps
const m = Math.floor(sPerKm / 60)
const s = Math.round(sPerKm % 60)
return `${m}:${String(s).padStart(2, '0')} /km`
}
export function sportIcon(type: string): string {
const t = type.toLowerCase()
if (t.includes('ride') || t.includes('cycling')) return '🚴'
if (t.includes('run')) return '🏃'
if (t.includes('swim')) return '🏊'
if (t.includes('walk') || t.includes('hike')) return '🥾'
if (t.includes('ski')) return '⛷️'
return '⚡'
}

View File

@@ -0,0 +1,29 @@
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
export function formatDistance(meters: number): string {
return (meters / 1000).toFixed(1) + ' km'
}
export function formatPace(mps: number): string {
if (mps === 0) return '—'
const sPerKm = 1000 / mps
const minute = Math.floor(sPerKm / 60)
const second = Math.round(sPerKm % 60)
return `${minute}:${String(second).padStart(2, '0')} /km`
}
export function sportIcon(type: string): string {
const lowered = type.toLowerCase()
if (lowered.includes('ride') || lowered.includes('cycling')) return '🚴'
if (lowered.includes('run')) return '🏃'
if (lowered.includes('swim')) return '🏊'
if (lowered.includes('walk') || lowered.includes('hike')) return '🥾'
if (lowered.includes('ski')) return '⛷️'
return '⚡'
}

View File

@@ -0,0 +1,963 @@
package com.peakup.tools.generator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import java.io.File;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Generates TypeScript REST client and entity types from peakup-backend REST resources.
*
* <p>Scans all classes extending {@code AbstractResource}, reads JAX-RS annotations and
* {@code RestResponse<T>} return types via reflection, then emits into
* {@code peakup-frontend/src/lib/api/}:
* <ul>
* <li>{@code httpClient.ts} — shared fetch helpers</li>
* <li>{@code {Entity}Do.ts} — one interface per domain entity, mirroring the Java class hierarchy</li>
* <li>{@code {Name}.ts} — union type per enum</li>
* <li>{@code {Resource}Client.ts} — one client file per JAX-RS resource</li>
* <li>{@code hooks.ts} — TanStack Query hooks</li>
* </ul>
*
* <p>Run from the project root (backend must be compiled first):
* <pre>
* $MVN -f peakup-backend/pom.xml install -DskipTests -q
* $MVN -f peakup-tools/pom.xml compile exec:java \
* -Dexec.mainClass=com.peakup.tools.generator.TypeScriptClientGenerator
* </pre>
*/
public class TypeScriptClientGenerator {
private static final String BACKEND_JAVA_PREFIX = "com.peakup";
private static final String OUTPUT_DIR_API = "peakup-frontend/src/api";
private static final String OUTPUT_DIR_TYPES = "peakup-frontend/src/types";
// ── Main ─────────────────────────────────────────────────────────────────
public static void main(final String[] args) throws Exception {
final Path outputDirApi = Paths.get(System.getProperty("user.dir")).resolve(OUTPUT_DIR_API);
final Path outputDirTypes = Paths.get(System.getProperty("user.dir")).resolve(OUTPUT_DIR_TYPES);
Files.createDirectories(outputDirApi);
Files.createDirectories(outputDirTypes);
System.out.println("Scanning for REST resources...");
final List<Class<?>> resourceClasses = findResourceClasses();
System.out.println("Found: " + resourceClasses.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
final List<ResourceInfo> resources = resourceClasses.stream()
.map(TypeScriptClientGenerator::extractResource)
.filter(r -> !r.endpoints().isEmpty())
.sorted(Comparator.comparing(ResourceInfo::resourceKey))
.collect(Collectors.toList());
System.out.println("Collecting entity types...");
final Set<Class<?>> entityTypes = collectEntityTypes(resources);
System.out.println("Types: " + entityTypes.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
writeFile(outputDirApi.resolve("httpClient.ts"), generateHttpClient());
for (final ResourceInfo resource : resources) {
final String clientFileName = resource.resourceClass().getSimpleName().replace("Resource", "") + "Client.ts";
writeFile(outputDirApi.resolve(clientFileName), generateResourceClient(resource, entityTypes));
}
for (final Class<?> entityType : topoSort(entityTypes)) {
final String doFileName = entityType.isEnum()
? entityType.getSimpleName() + ".ts"
: entityType.getSimpleName() + "Do.ts";
writeFile(outputDirTypes.resolve(doFileName), generateEntityDoFile(entityType, entityTypes));
}
writeFile(outputDirApi.resolve("hooks.ts"), generateHooks(resources, entityTypes));
System.out.println("Done → api: " + outputDirApi.toAbsolutePath() + ", types: " + outputDirTypes.toAbsolutePath());
}
// ── Classpath Scanning ────────────────────────────────────────────────────
private static List<Class<?>> findResourceClasses() throws Exception {
final Class<?> abstractResource = Class.forName("com.peakup.common.AbstractResource");
final List<Class<?>> result = new ArrayList<>();
// Collect classpath entries from the system property and the context classloader (exec:java
// uses an isolated URLClassLoader that is not reflected in java.class.path)
final Set<String> classpathEntries = new LinkedHashSet<>();
classpathEntries.addAll(Arrays.asList(System.getProperty("java.class.path").split(File.pathSeparator)));
collectUrlsFromClassLoader(Thread.currentThread().getContextClassLoader(), classpathEntries);
for (final String entry : classpathEntries) {
final Path entryPath = Paths.get(entry);
if (Files.isDirectory(entryPath)) {
scanDirectory(entryPath, abstractResource, result);
} else if (entry.endsWith(".jar") && entry.contains("peakup-backend")) {
scanJar(entryPath, abstractResource, result);
}
}
result.sort(Comparator.comparing(Class::getSimpleName));
return result;
}
private static void collectUrlsFromClassLoader(final ClassLoader classLoader, final Set<String> entries) {
if (classLoader == null) return;
try {
final java.lang.reflect.Method getURLs = classLoader.getClass().getMethod("getURLs");
final java.net.URL[] urls = (java.net.URL[]) getURLs.invoke(classLoader);
for (final java.net.URL url : urls) {
if ("file".equals(url.getProtocol())) {
entries.add(new java.io.File(url.toURI()).getAbsolutePath());
}
}
} catch (final Exception ignored) {
}
collectUrlsFromClassLoader(classLoader.getParent(), entries);
}
private static void scanDirectory(final Path root, final Class<?> abstractResource, final List<Class<?>> result)
throws IOException {
try (final Stream<Path> walk = Files.walk(root)) {
walk.filter(p -> p.toString().endsWith(".class"))
.map(p -> toClassName(root.relativize(p).toString()))
.filter(name -> name.startsWith(BACKEND_JAVA_PREFIX)
&& !name.contains("$") && !name.endsWith("package-info"))
.forEach(name -> tryLoadResource(name, abstractResource, result));
}
}
private static void scanJar(final Path jarPath, final Class<?> abstractResource, final List<Class<?>> result)
throws IOException {
try (final JarFile jar = new JarFile(jarPath.toFile())) {
jar.entries().asIterator().forEachRemaining(entry -> {
final String name = entry.getName();
if (!name.endsWith(".class") || !name.startsWith("com/peakup")
|| name.contains("$") || name.contains("package-info")) {
return;
}
tryLoadResource(toClassName(name), abstractResource, result);
});
}
}
private static void tryLoadResource(final String className, final Class<?> abstractResource,
final List<Class<?>> result) {
try {
final Class<?> cls = Class.forName(className);
if (abstractResource.isAssignableFrom(cls)
&& !cls.equals(abstractResource)
&& !Modifier.isAbstract(cls.getModifiers())) {
result.add(cls);
}
} catch (final Throwable ignored) {
}
}
private static String toClassName(final String pathName) {
return pathName.replace(File.separatorChar, '.').replace('/', '.').replaceAll("\\.class$", "");
}
// ── Resource Extraction ───────────────────────────────────────────────────
private static ResourceInfo extractResource(final Class<?> cls) {
final jakarta.ws.rs.Path pathAnn = cls.getAnnotation(jakarta.ws.rs.Path.class);
final String basePath = pathAnn != null ? pathAnn.value() : "";
final String resourceKey = deriveResourceKey(basePath, cls);
final List<EndpointInfo> endpoints = Arrays.stream(cls.getDeclaredMethods())
.filter(TypeScriptClientGenerator::hasHttpAnnotation)
.sorted(Comparator.comparing(Method::getName))
.map(m -> extractEndpoint(m, basePath))
.collect(Collectors.toList());
return new ResourceInfo(cls, basePath, resourceKey, endpoints);
}
private static EndpointInfo extractEndpoint(final Method method, final String basePath) {
final String httpMethod = getHttpMethod(method);
final jakarta.ws.rs.Path subPathAnn = method.getAnnotation(jakarta.ws.rs.Path.class);
final String subPath = subPathAnn != null ? subPathAnn.value() : "";
final Type javaReturnType = unwrapRestResponse(method.getGenericReturnType());
final String tsReturnType = toTsType(javaReturnType);
final List<ParamInfo> pathParams = new ArrayList<>();
final List<ParamInfo> queryParams = new ArrayList<>();
ParamInfo bodyParam = null;
final Parameter[] params = method.getParameters();
final Annotation[][] allAnnotations = method.getParameterAnnotations();
final Type[] genericTypes = method.getGenericParameterTypes();
for (int i = 0; i < params.length; i++) {
final Annotation[] ann = allAnnotations[i];
final Type javaType = genericTypes[i];
final boolean nullable = isNullable(ann);
final DefaultValue defVal = getAnnotation(ann, DefaultValue.class);
final PathParam pathParam = getAnnotation(ann, PathParam.class);
final QueryParam queryParam = getAnnotation(ann, QueryParam.class);
if (pathParam != null) {
pathParams.add(new ParamInfo(pathParam.value(), javaType, toTsType(javaType), false, null));
} else if (queryParam != null) {
final boolean optional = nullable || defVal != null;
queryParams.add(new ParamInfo(queryParam.value(), javaType, toTsType(javaType), optional,
defVal != null ? defVal.value() : null));
} else if (!isJaxRsSpecialParam(ann)) {
bodyParam = new ParamInfo("body", javaType, toTsType(javaType), false, null);
}
}
return new EndpointInfo(httpMethod, subPath, method.getName(),
pathParams, queryParams, bodyParam, javaReturnType, tsReturnType);
}
private static Type unwrapRestResponse(final Type type) {
if (type instanceof final ParameterizedType pt) {
final Type rawType = pt.getRawType();
if (rawType instanceof final Class<?> rawClass
&& rawClass.getSimpleName().equals("RestResponse")) {
final Type[] args = pt.getActualTypeArguments();
if (args.length == 1) return args[0];
}
}
return type;
}
private static boolean hasHttpAnnotation(final Method method) {
return method.isAnnotationPresent(GET.class) || method.isAnnotationPresent(POST.class)
|| method.isAnnotationPresent(PUT.class) || method.isAnnotationPresent(DELETE.class)
|| method.isAnnotationPresent(PATCH.class);
}
private static String getHttpMethod(final Method method) {
if (method.isAnnotationPresent(GET.class)) return "GET";
if (method.isAnnotationPresent(POST.class)) return "POST";
if (method.isAnnotationPresent(PUT.class)) return "PUT";
if (method.isAnnotationPresent(DELETE.class)) return "DELETE";
if (method.isAnnotationPresent(PATCH.class)) return "PATCH";
return "GET";
}
// ── Type Collection ───────────────────────────────────────────────────────
private static Set<Class<?>> collectEntityTypes(final List<ResourceInfo> resources) {
final Set<Class<?>> visited = new LinkedHashSet<>();
final Queue<Class<?>> queue = new ArrayDeque<>();
for (final ResourceInfo resource : resources) {
for (final EndpointInfo endpoint : resource.endpoints()) {
seedFromType(endpoint.javaReturnType(), queue, visited);
if (endpoint.bodyParam() != null) {
seedFromType(endpoint.bodyParam().javaType(), queue, visited);
}
}
}
while (!queue.isEmpty()) {
final Class<?> cls = queue.poll();
if (visited.contains(cls)) continue;
visited.add(cls);
if (cls.isEnum()) continue;
for (final Field field : getAllFields(cls)) {
if (Modifier.isStatic(field.getModifiers())) continue;
if (isJsonIgnored(field)) continue;
seedFromType(field.getGenericType(), queue, visited);
}
}
// Also include abstract com.peakup.* superclasses for DO hierarchy generation
for (final Class<?> cls : new ArrayList<>(visited)) {
if (!cls.isEnum()) {
addAbstractSuperclasses(cls, visited);
}
}
return visited;
}
private static void addAbstractSuperclasses(final Class<?> cls, final Set<Class<?>> visited) {
Class<?> parent = cls.getSuperclass();
while (parent != null && parent != Object.class) {
if (parent.getName().startsWith(BACKEND_JAVA_PREFIX) && visited.add(parent)) {
addAbstractSuperclasses(parent, visited);
}
parent = parent.getSuperclass();
}
}
private static void seedFromType(final Type type, final Queue<Class<?>> queue, final Set<Class<?>> visited) {
if (type instanceof final Class<?> cls) {
if (isDomainType(cls) && !visited.contains(cls)) queue.add(cls);
} else if (type instanceof final ParameterizedType pt) {
seedFromType(pt.getRawType(), queue, visited);
for (final Type arg : pt.getActualTypeArguments()) seedFromType(arg, queue, visited);
} else if (type instanceof final GenericArrayType gat) {
seedFromType(gat.getGenericComponentType(), queue, visited);
}
}
private static boolean isDomainType(final Class<?> cls) {
if (!cls.getName().startsWith(BACKEND_JAVA_PREFIX)) return false;
if (cls.isAnnotation() || cls.isSynthetic() || cls.isInterface()) return false;
if (cls.getEnclosingClass() != null) return false;
return !Modifier.isAbstract(cls.getModifiers());
}
// ── TypeScript Type Mapping ───────────────────────────────────────────────
private static String toTsType(final Type type) {
if (type == null) return "unknown";
if (type instanceof final Class<?> cls) {
if (cls == void.class || cls == Void.class) return "void";
if (cls == String.class) return "string";
if (cls == boolean.class || cls == Boolean.class) return "boolean";
if (cls == long.class || cls == Long.class
|| cls == int.class || cls == Integer.class
|| cls == double.class || cls == Double.class
|| cls == float.class || cls == Float.class
|| cls == short.class || cls == Short.class) {
return "number";
}
if (cls.getName().equals("java.time.Instant")) return "string";
if (cls.isArray()) return toTsType(cls.getComponentType()) + "[]";
if (cls.isEnum()) return cls.getSimpleName();
if (cls.getName().startsWith(BACKEND_JAVA_PREFIX)) return cls.getSimpleName() + "Do";
if (cls == Object.class) return "unknown";
return "unknown";
}
if (type instanceof final ParameterizedType pt) {
final Type rawType = pt.getRawType();
if (rawType instanceof final Class<?> rawClass) {
final String rawName = rawClass.getName();
if (rawName.equals("java.util.List") || rawName.equals("java.util.Collection")
|| rawName.equals("java.util.Set")) {
final Type[] args = pt.getActualTypeArguments();
if (args.length == 1) return toTsType(args[0]) + "[]";
}
}
return "unknown";
}
if (type instanceof final GenericArrayType gat) {
return toTsType(gat.getGenericComponentType()) + "[]";
}
return "unknown";
}
// ── httpClient.ts ─────────────────────────────────────────────────────────
private static String generateHttpClient() {
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
sb.append("const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8111';\n\n");
sb.append("export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {\n");
sb.append(" const p = new URLSearchParams();\n");
sb.append(" for (const [key, val] of Object.entries(params)) {\n");
sb.append(" if (val !== undefined) p.set(key, String(val));\n");
sb.append(" }\n");
sb.append(" const query = p.toString();\n");
sb.append(" return query ? `?${query}` : '';\n");
sb.append("}\n\n");
sb.append("export async function get<T>(path: string): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`);\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function post<T>(path: string, body?: unknown): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, {\n");
sb.append(" method: 'POST',\n");
sb.append(" headers: { 'Content-Type': 'application/json' },\n");
sb.append(" body: body !== undefined ? JSON.stringify(body) : undefined,\n");
sb.append(" });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function put<T>(path: string, body?: unknown): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, {\n");
sb.append(" method: 'PUT',\n");
sb.append(" headers: { 'Content-Type': 'application/json' },\n");
sb.append(" body: body !== undefined ? JSON.stringify(body) : undefined,\n");
sb.append(" });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function del<T>(path: string): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n");
return sb.toString();
}
// ── {Entity}Do.ts / {Enum}.ts ─────────────────────────────────────────────
private static String generateEntityDoFile(final Class<?> cls, final Set<Class<?>> allTypes) {
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
if (cls.isEnum()) {
final Object[] constants = cls.getEnumConstants();
sb.append("export type ").append(cls.getSimpleName()).append(" =\n");
for (int i = 0; i < constants.length; i++) {
sb.append(" | '").append(((Enum<?>) constants[i]).name()).append("'");
sb.append(i < constants.length - 1 ? "\n" : ";\n");
}
return sb.toString();
}
final Set<String> importStatements = new LinkedHashSet<>();
// Resolve parent for extends clause
final Class<?> superClass = cls.getSuperclass();
final String extendsClause;
if (superClass != null && superClass != Object.class && allTypes.contains(superClass)) {
final String parentDoName = superClass.getSimpleName() + "Do";
importStatements.add("import type { " + parentDoName + " } from './" + parentDoName + "';");
extendsClause = " extends " + parentDoName;
} else {
extendsClause = "";
}
// Own declared fields only — each parent gets its own DO file
final List<Field> ownFields = Arrays.stream(cls.getDeclaredFields())
.filter(f -> !Modifier.isStatic(f.getModifiers()))
.filter(f -> !isJsonIgnored(f))
.collect(Collectors.toList());
for (final Field field : ownFields) {
collectImportsFromType(field.getGenericType(), allTypes, importStatements, "./");
}
if (!importStatements.isEmpty()) {
for (final String importStatement : importStatements) {
sb.append(importStatement).append("\n");
}
sb.append("\n");
}
sb.append("export interface ").append(cls.getSimpleName()).append("Do").append(extendsClause).append(" {\n");
for (final Field field : ownFields) {
final boolean nullable = isNullableField(field);
final String tsType = toTsType(field.getGenericType());
sb.append(" ").append(field.getName()).append(": ").append(tsType);
if (nullable) sb.append(" | null");
sb.append(";\n");
}
sb.append("}\n");
return sb.toString();
}
private static void collectImportsFromType(final Type type, final Set<Class<?>> allTypes,
final Set<String> importStatements, final String typeImportPrefix) {
if (type instanceof final Class<?> cls) {
if (allTypes.contains(cls)) {
final String tsName = cls.isEnum() ? cls.getSimpleName() : cls.getSimpleName() + "Do";
importStatements.add("import type { " + tsName + " } from '" + typeImportPrefix + tsName + "';");
}
} else if (type instanceof final ParameterizedType pt) {
collectImportsFromType(pt.getRawType(), allTypes, importStatements, typeImportPrefix);
for (final Type arg : pt.getActualTypeArguments()) {
collectImportsFromType(arg, allTypes, importStatements, typeImportPrefix);
}
} else if (type instanceof final GenericArrayType gat) {
collectImportsFromType(gat.getGenericComponentType(), allTypes, importStatements, typeImportPrefix);
}
}
// ── {Resource}Client.ts ───────────────────────────────────────────────────
private static String generateResourceClient(final ResourceInfo resource, final Set<Class<?>> allTypes) {
final Set<String> typeImportStatements = new LinkedHashSet<>();
for (final EndpointInfo endpoint : resource.endpoints()) {
collectImportsFromTsTypeName(endpoint.tsReturnType(), allTypes, typeImportStatements, "../types/");
if (endpoint.bodyParam() != null) {
collectImportsFromTsTypeName(endpoint.bodyParam().tsType(), allTypes, typeImportStatements, "../types/");
}
}
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
final Set<String> httpHelpers = resolveHttpHelpers(resource.endpoints());
sb.append("import { ").append(String.join(", ", httpHelpers)).append(" } from './httpClient';\n");
for (final String typeImport : typeImportStatements) {
sb.append(typeImport).append("\n");
}
sb.append("\n");
sb.append("export const ").append(resource.resourceKey()).append(" = {\n");
for (final EndpointInfo endpoint : resource.endpoints()) {
sb.append(generateClientFunction(endpoint, resource.basePath()));
}
sb.append("};\n");
return sb.toString();
}
private static Set<String> resolveHttpHelpers(final List<EndpointInfo> endpoints) {
final Set<String> helpers = new LinkedHashSet<>();
boolean needsBuildQuery = false;
for (final EndpointInfo endpoint : endpoints) {
if (!endpoint.queryParams().isEmpty()) needsBuildQuery = true;
switch (endpoint.httpMethod()) {
case "GET" -> helpers.add("get");
case "POST" -> helpers.add("post");
case "PUT", "PATCH" -> helpers.add("put");
case "DELETE" -> helpers.add("del");
default -> helpers.add("get");
}
}
if (needsBuildQuery) {
final Set<String> ordered = new LinkedHashSet<>();
ordered.add("buildQuery");
ordered.addAll(helpers);
return ordered;
}
return helpers;
}
private static void collectImportsFromTsTypeName(final String tsTypeName, final Set<Class<?>> allTypes,
final Set<String> importStatements,
final String typeImportPrefix) {
if (tsTypeName == null || tsTypeName.isBlank()) return;
final String baseName = tsTypeName.replace("[]", "").trim();
if (Set.of("string", "number", "boolean", "void", "unknown").contains(baseName)) return;
for (final Class<?> cls : allTypes) {
final String tsName = cls.isEnum() ? cls.getSimpleName() : cls.getSimpleName() + "Do";
if (tsName.equals(baseName)) {
importStatements.add("import type { " + tsName + " } from '" + typeImportPrefix + tsName + "';");
return;
}
}
}
private static String generateClientFunction(final EndpointInfo endpoint, final String basePath) {
final List<String> paramList = new ArrayList<>();
for (final ParamInfo p : endpoint.pathParams()) {
paramList.add(p.name() + ": " + p.tsType());
}
final List<ParamInfo> queryParams = endpoint.queryParams();
for (int i = 0; i < queryParams.size(); i++) {
final ParamInfo p = queryParams.get(i);
if (p.optional() && p.defaultValue() != null) {
paramList.add(p.name() + " = " + tsDefaultValue(p.defaultValue(), p.tsType()));
} else if (p.optional()) {
// Use `name: type | undefined` instead of `name?: type` when a required param follows,
// because TypeScript forbids required params after optional params (TS1016).
final boolean requiredParamFollows = endpoint.bodyParam() != null
|| queryParams.subList(i + 1, queryParams.size()).stream().anyMatch(q -> !q.optional());
if (requiredParamFollows) {
paramList.add(p.name() + ": " + p.tsType() + " | undefined");
} else {
paramList.add(p.name() + "?: " + p.tsType());
}
} else {
paramList.add(p.name() + ": " + p.tsType());
}
}
if (endpoint.bodyParam() != null) {
paramList.add("body: " + endpoint.bodyParam().tsType());
}
final String tsReturn = endpoint.tsReturnType();
final String returnType = tsReturn.equals("void") ? "Promise<void>" : "Promise<" + tsReturn + ">";
final String genericArg = tsReturn.equals("void") ? "void" : tsReturn;
final String tsPath = pathToTsTemplate(basePath + endpoint.subPath());
final boolean hasTemplate = tsPath.contains("${");
final boolean hasQuery = !endpoint.queryParams().isEmpty();
final String urlExpr;
if (hasQuery) {
final String qpNames = endpoint.queryParams().stream()
.map(ParamInfo::name).collect(Collectors.joining(", "));
urlExpr = "`" + tsPath + "${buildQuery({ " + qpNames + " })}`";
} else if (hasTemplate) {
urlExpr = "`" + tsPath + "`";
} else {
urlExpr = "'" + tsPath + "'";
}
final String bodyArg = endpoint.bodyParam() != null ? ", body" : "";
final String call = switch (endpoint.httpMethod()) {
case "GET" -> "get<" + genericArg + ">(" + urlExpr + ")";
case "POST" -> "post<" + genericArg + ">(" + urlExpr + bodyArg + ")";
case "PUT", "PATCH" -> "put<" + genericArg + ">(" + urlExpr + bodyArg + ")";
case "DELETE" -> "del<" + genericArg + ">(" + urlExpr + ")";
default -> "get<" + genericArg + ">(" + urlExpr + ")";
};
return " " + endpoint.functionName() + ": ("
+ String.join(", ", paramList) + "): " + returnType + " =>\n"
+ " " + call + ",\n";
}
// ── hooks.ts ──────────────────────────────────────────────────────────────
private static String generateHooks(final List<ResourceInfo> resources, final Set<Class<?>> allTypes) {
// Only collect type names from mutation endpoints: query hooks infer their return type
// from queryFn, so they never reference Do types by name in the generated code.
// Including GET return types causes unused-import warnings (e.g. AthleteDo).
final Set<String> neededTsTypeNames = new LinkedHashSet<>();
for (final ResourceInfo resource : resources) {
for (final EndpointInfo endpoint : resource.endpoints()) {
if (!endpoint.httpMethod().equals("GET")) {
extractTsTypeNames(endpoint.tsReturnType(), neededTsTypeNames);
if (endpoint.bodyParam() != null) {
extractTsTypeNames(endpoint.bodyParam().tsType(), neededTsTypeNames);
}
}
}
}
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
sb.append("import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\n");
for (final ResourceInfo resource : resources) {
final String clientFile = "./" + resource.resourceClass().getSimpleName().replace("Resource", "") + "Client";
sb.append("import { ").append(resource.resourceKey()).append(" } from '").append(clientFile).append("';\n");
}
final Set<String> typeImports = new LinkedHashSet<>();
for (final String tsTypeName : neededTsTypeNames) {
collectImportsFromTsTypeName(tsTypeName, allTypes, typeImports, "../types/");
}
for (final String typeImport : typeImports) {
sb.append(typeImport).append("\n");
}
sb.append("\n");
sb.append("export const queryKeys = {\n");
for (final ResourceInfo resource : resources) {
final List<EndpointInfo> gets = resource.endpoints().stream()
.filter(e -> e.httpMethod().equals("GET")).collect(Collectors.toList());
if (gets.isEmpty()) continue;
sb.append(" ").append(resource.resourceKey()).append(": {\n");
for (final EndpointInfo endpoint : gets) {
final List<ParamInfo> allParams = new ArrayList<>();
allParams.addAll(endpoint.pathParams());
allParams.addAll(endpoint.queryParams());
final String paramSig = allParams.stream()
.map(p -> p.name() + (p.optional() ? "?" : "") + ": " + p.tsType())
.collect(Collectors.joining(", "));
final String keyItems = allParams.stream().map(ParamInfo::name).collect(Collectors.joining(", "));
final String keyArray = "'" + resource.resourceKey() + "', '" + endpoint.functionName() + "'"
+ (keyItems.isEmpty() ? "" : ", " + keyItems);
sb.append(" ").append(endpoint.functionName()).append(": (")
.append(paramSig).append(") => [").append(keyArray).append("] as const,\n");
}
sb.append(" },\n");
}
sb.append("};\n\n");
for (final ResourceInfo resource : resources) {
sb.append("// ── ").append(resource.resourceKey()).append(" ────────────────────────────────────\n\n");
for (final EndpointInfo endpoint : resource.endpoints()) {
if (endpoint.httpMethod().equals("GET")) {
sb.append(generateQueryHook(endpoint, resource));
} else {
sb.append(generateMutationHook(endpoint, resource));
}
}
}
return sb.toString();
}
private static String generateQueryHook(final EndpointInfo endpoint, final ResourceInfo resource) {
final String hookName = "use" + capitalize(resource.resourceKey()) + capitalize(endpoint.functionName());
final List<ParamInfo> allParams = new ArrayList<>();
allParams.addAll(endpoint.pathParams());
allParams.addAll(endpoint.queryParams());
final String paramSig = allParams.stream().map(p -> {
if (p.optional() && p.defaultValue() != null) {
return p.name() + " = " + tsDefaultValue(p.defaultValue(), p.tsType());
} else if (p.optional()) {
return p.name() + "?: " + p.tsType();
}
return p.name() + ": " + p.tsType();
}).collect(Collectors.joining(", "));
final String queryKeyArgs = allParams.stream().map(ParamInfo::name).collect(Collectors.joining(", "));
final String queryKeyCall = "queryKeys." + resource.resourceKey() + "." + endpoint.functionName()
+ "(" + queryKeyArgs + ")";
final String apiArgs = allParams.stream().map(ParamInfo::name).collect(Collectors.joining(", "));
return "export function " + hookName + "(" + paramSig + ") {\n"
+ " return useQuery({\n"
+ " queryKey: " + queryKeyCall + ",\n"
+ " queryFn: () => " + resource.resourceKey() + "." + endpoint.functionName()
+ "(" + apiArgs + "),\n"
+ " });\n"
+ "}\n\n";
}
private static String generateMutationHook(final EndpointInfo endpoint, final ResourceInfo resource) {
final String hookName = "use" + capitalize(resource.resourceKey()) + capitalize(endpoint.functionName());
final String tsReturn = endpoint.tsReturnType();
final String returnGeneric = tsReturn.equals("void") ? "void" : tsReturn;
final List<ParamInfo> mutationParams = new ArrayList<>();
mutationParams.addAll(endpoint.pathParams());
mutationParams.addAll(endpoint.queryParams());
if (endpoint.bodyParam() != null) mutationParams.add(endpoint.bodyParam());
final String variablesType;
final String destructure;
final String apiArgs = buildApiCallArgs(endpoint);
if (mutationParams.isEmpty()) {
variablesType = "void";
destructure = "_";
} else if (mutationParams.size() == 1) {
final ParamInfo single = mutationParams.get(0);
variablesType = single.tsType();
destructure = single.name();
} else {
variablesType = "{ " + mutationParams.stream()
.map(p -> p.name() + (p.optional() ? "?" : "") + ": " + p.tsType())
.collect(Collectors.joining("; ")) + " }";
destructure = "{ " + mutationParams.stream()
.map(ParamInfo::name).collect(Collectors.joining(", ")) + " }";
}
return "export function " + hookName + "() {\n"
+ " const queryClient = useQueryClient();\n"
+ " return useMutation<" + returnGeneric + ", Error, " + variablesType + ">({\n"
+ " mutationFn: (" + destructure + ") =>\n"
+ " " + resource.resourceKey() + "." + endpoint.functionName() + "(" + apiArgs + "),\n"
+ " onSuccess: () => queryClient.invalidateQueries({ queryKey: ['"
+ resource.resourceKey() + "'] }),\n"
+ " });\n"
+ "}\n\n";
}
// ── Topological Sort ──────────────────────────────────────────────────────
private static List<Class<?>> topoSort(final Set<Class<?>> types) {
final List<Class<?>> sorted = new ArrayList<>();
final Set<Class<?>> remaining = new LinkedHashSet<>(types);
while (!remaining.isEmpty()) {
final int sizeBefore = remaining.size();
remaining.removeIf(cls -> {
final Class<?> parent = cls.getSuperclass();
if (parent == null || parent == Object.class || !types.contains(parent) || sorted.contains(parent)) {
sorted.add(cls);
return true;
}
return false;
});
if (remaining.size() == sizeBefore) {
sorted.addAll(remaining);
break;
}
}
return sorted;
}
// ── Utilities ─────────────────────────────────────────────────────────────
private static List<Field> getAllFields(final Class<?> cls) {
final LinkedList<Field> fields = new LinkedList<>();
Class<?> current = cls;
while (current != null && current != Object.class) {
fields.addAll(0, Arrays.asList(current.getDeclaredFields()));
current = current.getSuperclass();
}
return new ArrayList<>(fields);
}
private static boolean isJsonIgnored(final Field field) {
final JsonIgnore ann = field.getAnnotation(JsonIgnore.class);
return ann != null && ann.value();
}
private static boolean isNullable(final Annotation[] annotations) {
for (final Annotation ann : annotations) {
if (ann.annotationType().getSimpleName().equals("Nullable")) return true;
}
return false;
}
private static boolean isNullableField(final Field field) {
return isNullable(field.getAnnotations());
}
private static boolean isJaxRsSpecialParam(final Annotation[] annotations) {
for (final Annotation ann : annotations) {
final String name = ann.annotationType().getSimpleName();
if (name.equals("Context") || name.equals("HeaderParam")
|| name.equals("CookieParam") || name.equals("FormParam")
|| name.equals("BeanParam") || name.equals("Suspended")) {
return true;
}
}
return false;
}
@SuppressWarnings("unchecked")
private static <A extends Annotation> A getAnnotation(final Annotation[] annotations, final Class<A> type) {
for (final Annotation ann : annotations) {
if (type.isInstance(ann)) return (A) ann;
}
return null;
}
private static void extractTsTypeNames(final String tsType, final Set<String> names) {
if (tsType == null || tsType.isBlank()) return;
final String base = tsType.replace("[]", "").trim();
if (!Set.of("string", "number", "boolean", "void", "unknown").contains(base) && !base.isEmpty()) {
names.add(base);
}
}
private static String buildApiCallArgs(final EndpointInfo endpoint) {
final List<String> args = new ArrayList<>();
endpoint.pathParams().forEach(p -> args.add(p.name()));
endpoint.queryParams().forEach(p -> args.add(p.name()));
if (endpoint.bodyParam() != null) args.add(endpoint.bodyParam().name());
return String.join(", ", args);
}
private static String deriveResourceKey(final String basePath, final Class<?> cls) {
if (!basePath.isEmpty()) {
final String[] segments = basePath.split("/");
final String lastSegment = segments[segments.length - 1];
return kebabToCamel(lastSegment);
}
final String name = cls.getSimpleName().replace("Resource", "");
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
private static String kebabToCamel(final String kebab) {
final String[] parts = kebab.split("-");
final StringBuilder sb = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
if (!parts[i].isEmpty()) {
sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
}
}
return sb.toString();
}
private static String capitalize(final String s) {
if (s == null || s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
private static String pathToTsTemplate(final String path) {
final StringBuilder result = new StringBuilder();
int i = 0;
while (i < path.length()) {
if (path.charAt(i) == '{') {
final int close = path.indexOf('}', i);
if (close > i) {
result.append("${").append(path, i + 1, close).append("}");
i = close + 1;
} else {
result.append(path.charAt(i++));
}
} else {
result.append(path.charAt(i++));
}
}
return result.toString();
}
private static String tsDefaultValue(final String javaDefault, final String tsType) {
if ("number".equals(tsType)) {
try {
Double.parseDouble(javaDefault);
return javaDefault;
} catch (final NumberFormatException e) {
return "0";
}
}
if ("boolean".equals(tsType)) {
return Boolean.parseBoolean(javaDefault) ? "true" : "false";
}
return "'" + javaDefault + "'";
}
private static void writeFile(final Path path, final String content) throws IOException {
Files.writeString(path, content, StandardCharsets.UTF_8);
System.out.println("Written: " + path.getFileName());
}
// ── Records ───────────────────────────────────────────────────────────────
private record ResourceInfo(Class<?> resourceClass, String basePath, String resourceKey,
List<EndpointInfo> endpoints) {
}
private record EndpointInfo(
String httpMethod,
String subPath,
String functionName,
List<ParamInfo> pathParams,
List<ParamInfo> queryParams,
ParamInfo bodyParam,
Type javaReturnType,
String tsReturnType
) {
}
private record ParamInfo(
String name,
Type javaType,
String tsType,
boolean optional,
String defaultValue
) {
}
}