Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- Remove dead frontend/src/features/index.ts (empty export, unused) - RaceRow: created_at/updated_at typed as Date to match pg TIMESTAMPTZ runtime - rowToDto: explicit toISOString() conversion instead of relying on JSON.stringify - Mock DB: return Date objects for timestamp fields to match real pg behavior Made-with: Cursor
100 lines
2.5 KiB
TypeScript
100 lines
2.5 KiB
TypeScript
/**
|
|
* Row shape returned by PostgreSQL (snake_case).
|
|
* pg returns DATE as string, NUMERIC as string, TIMESTAMPTZ as Date.
|
|
*/
|
|
export interface RaceRow {
|
|
id: string;
|
|
race_date: string;
|
|
title: string;
|
|
distance_km: string;
|
|
status: string | null;
|
|
official_url: string | null;
|
|
start_time: string | null;
|
|
cluster_schedule: string | null;
|
|
bib_pickup: string | null;
|
|
bib_number: string | null;
|
|
finish_time: string | null;
|
|
finish_place: string | null;
|
|
notes: string | null;
|
|
created_at: Date;
|
|
updated_at: Date | null;
|
|
}
|
|
|
|
/** API shape (camelCase). */
|
|
export interface RaceDto {
|
|
id: string;
|
|
date: string;
|
|
title: string;
|
|
distanceKm: number;
|
|
status: string | null;
|
|
officialUrl: string | null;
|
|
startTime: string | null;
|
|
clusterSchedule: string | null;
|
|
bibPickup: string | null;
|
|
bibNumber: string | null;
|
|
finishTime: string | null;
|
|
finishPlace: string | null;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
updatedAt: string | null;
|
|
}
|
|
|
|
function toISOString(value: Date | string): string {
|
|
return value instanceof Date ? value.toISOString() : String(value);
|
|
}
|
|
|
|
/** Convert a DB row to the API DTO (camelCase). */
|
|
export function rowToDto(row: RaceRow): RaceDto {
|
|
return {
|
|
id: row.id,
|
|
date: row.race_date,
|
|
title: row.title,
|
|
distanceKm: parseFloat(row.distance_km),
|
|
status: row.status,
|
|
officialUrl: row.official_url,
|
|
startTime: row.start_time,
|
|
clusterSchedule: row.cluster_schedule,
|
|
bibPickup: row.bib_pickup,
|
|
bibNumber: row.bib_number,
|
|
finishTime: row.finish_time,
|
|
finishPlace: row.finish_place,
|
|
notes: row.notes,
|
|
createdAt: toISOString(row.created_at),
|
|
updatedAt: row.updated_at ? toISOString(row.updated_at) : null,
|
|
};
|
|
}
|
|
|
|
/** Map incoming camelCase body fields to snake_case column names. */
|
|
const FIELD_MAP: Record<string, string> = {
|
|
date: "race_date",
|
|
title: "title",
|
|
distanceKm: "distance_km",
|
|
status: "status",
|
|
officialUrl: "official_url",
|
|
startTime: "start_time",
|
|
clusterSchedule: "cluster_schedule",
|
|
bibPickup: "bib_pickup",
|
|
bibNumber: "bib_number",
|
|
finishTime: "finish_time",
|
|
finishPlace: "finish_place",
|
|
notes: "notes",
|
|
};
|
|
|
|
/**
|
|
* Convert a partial camelCase body into { columns, values, placeholders }
|
|
* suitable for SQL INSERT / UPDATE.
|
|
*/
|
|
export function bodyToColumns(body: Record<string, unknown>) {
|
|
const columns: string[] = [];
|
|
const values: unknown[] = [];
|
|
|
|
for (const [camel, snake] of Object.entries(FIELD_MAP)) {
|
|
if (camel in body) {
|
|
columns.push(snake);
|
|
values.push(body[camel]);
|
|
}
|
|
}
|
|
|
|
return { columns, values };
|
|
}
|