Client api generator
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
11
peakup-frontend/src/api/AthleteClient.ts
Normal file
11
peakup-frontend/src/api/AthleteClient.ts
Normal 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}`),
|
||||
};
|
||||
15
peakup-frontend/src/api/GoalClient.ts
Normal file
15
peakup-frontend/src/api/GoalClient.ts
Normal 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),
|
||||
};
|
||||
17
peakup-frontend/src/api/IntervalsActivityClient.ts
Normal file
17
peakup-frontend/src/api/IntervalsActivityClient.ts
Normal 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`),
|
||||
};
|
||||
28
peakup-frontend/src/api/WeekPlanClient.ts
Normal file
28
peakup-frontend/src/api/WeekPlanClient.ts
Normal 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),
|
||||
};
|
||||
@@ -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}`),
|
||||
}
|
||||
209
peakup-frontend/src/api/hooks.ts
Normal file
209
peakup-frontend/src/api/hooks.ts
Normal 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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
47
peakup-frontend/src/api/httpClient.ts
Normal file
47
peakup-frontend/src/api/httpClient.ts
Normal 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>;
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
28
peakup-frontend/src/types/AbstractActivityDo.ts
Normal file
28
peakup-frontend/src/types/AbstractActivityDo.ts
Normal 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;
|
||||
}
|
||||
5
peakup-frontend/src/types/AbstractEntityDo.ts
Normal file
5
peakup-frontend/src/types/AbstractEntityDo.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export interface AbstractEntityDo {
|
||||
id: number | null;
|
||||
}
|
||||
8
peakup-frontend/src/types/AbstractTimestampedEntityDo.ts
Normal file
8
peakup-frontend/src/types/AbstractTimestampedEntityDo.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
import type { AbstractEntityDo } from './AbstractEntityDo';
|
||||
|
||||
export interface AbstractTimestampedEntityDo extends AbstractEntityDo {
|
||||
modifiedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
12
peakup-frontend/src/types/AthleteDo.ts
Normal file
12
peakup-frontend/src/types/AthleteDo.ts
Normal 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;
|
||||
}
|
||||
12
peakup-frontend/src/types/GoalDo.ts
Normal file
12
peakup-frontend/src/types/GoalDo.ts
Normal 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;
|
||||
}
|
||||
7
peakup-frontend/src/types/GoalMetricEnum.ts
Normal file
7
peakup-frontend/src/types/GoalMetricEnum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type GoalMetricEnum =
|
||||
| 'DISTANCE'
|
||||
| 'TSS'
|
||||
| 'ELEVATION'
|
||||
| 'TIME';
|
||||
6
peakup-frontend/src/types/GoalPeriodEnum.ts
Normal file
6
peakup-frontend/src/types/GoalPeriodEnum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type GoalPeriodEnum =
|
||||
| 'WEEKLY'
|
||||
| 'MONTHLY'
|
||||
| 'YEARLY';
|
||||
7
peakup-frontend/src/types/IntensityReferenceEnum.ts
Normal file
7
peakup-frontend/src/types/IntensityReferenceEnum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type IntensityReferenceEnum =
|
||||
| 'FTP'
|
||||
| 'LTHR'
|
||||
| 'THRESHOLD_PACE'
|
||||
| 'MAX_HR';
|
||||
168
peakup-frontend/src/types/IntervalsActivityDo.ts
Normal file
168
peakup-frontend/src/types/IntervalsActivityDo.ts
Normal 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;
|
||||
}
|
||||
5
peakup-frontend/src/types/SexEnum.ts
Normal file
5
peakup-frontend/src/types/SexEnum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type SexEnum =
|
||||
| 'MALE'
|
||||
| 'FEMALE';
|
||||
7
peakup-frontend/src/types/StreamStatusEnum.ts
Normal file
7
peakup-frontend/src/types/StreamStatusEnum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type StreamStatusEnum =
|
||||
| 'PENDING'
|
||||
| 'IMPORTED'
|
||||
| 'NO_STREAM'
|
||||
| 'ERROR';
|
||||
13
peakup-frontend/src/types/WeekPlanDo.ts
Normal file
13
peakup-frontend/src/types/WeekPlanDo.ts
Normal 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[];
|
||||
}
|
||||
20
peakup-frontend/src/types/WorkoutDo.ts
Normal file
20
peakup-frontend/src/types/WorkoutDo.ts
Normal 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[];
|
||||
}
|
||||
13
peakup-frontend/src/types/WorkoutLoadDo.ts
Normal file
13
peakup-frontend/src/types/WorkoutLoadDo.ts
Normal 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;
|
||||
}
|
||||
8
peakup-frontend/src/types/WorkoutStatusEnum.ts
Normal file
8
peakup-frontend/src/types/WorkoutStatusEnum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type WorkoutStatusEnum =
|
||||
| 'PLANNED'
|
||||
| 'COMPLETED'
|
||||
| 'SKIPPED'
|
||||
| 'ALTERNATIVE'
|
||||
| 'PARTIAL';
|
||||
15
peakup-frontend/src/types/WorkoutStepDo.ts
Normal file
15
peakup-frontend/src/types/WorkoutStepDo.ts
Normal 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[];
|
||||
}
|
||||
7
peakup-frontend/src/types/WorkoutStepTypeEnum.ts
Normal file
7
peakup-frontend/src/types/WorkoutStepTypeEnum.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type WorkoutStepTypeEnum =
|
||||
| 'WARM_UP'
|
||||
| 'WORK'
|
||||
| 'REST'
|
||||
| 'COOL_DOWN';
|
||||
8
peakup-frontend/src/types/WorkoutTypeEnum.ts
Normal file
8
peakup-frontend/src/types/WorkoutTypeEnum.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// GENERATED — do not edit manually.
|
||||
|
||||
export type WorkoutTypeEnum =
|
||||
| 'BIKE'
|
||||
| 'RUN'
|
||||
| 'SWIM'
|
||||
| 'STRENGTH'
|
||||
| 'OTHER';
|
||||
@@ -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 '⚡'
|
||||
}
|
||||
29
peakup-frontend/src/utils/activity.ts
Normal file
29
peakup-frontend/src/utils/activity.ts
Normal 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 '⚡'
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user