feat: align docs with code, finish_place, registered status, UI filters, tests, CI
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- Add PLAN.md and sync backend docs, .env.example, API doc (404 details)
- Document mock DB and PORT/API_PORT in docs/backend.md; README monorepo + frontend/.env.example
- Migration 002: finish_place column, status registered; mapper and mock DB updated
- Frontend: registered status, finishPlace, calendar year/month filters, pace sparkline
- Extract createApp for tests; supertest + tsx; GitHub Actions CI

Made-with: Cursor
This commit is contained in:
Vaka.pro
2026-04-06 22:20:31 +03:00
parent 1ffc3a65eb
commit a2dcf67396
27 changed files with 1410 additions and 286 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE races ADD COLUMN IF NOT EXISTS finish_place TEXT;
ALTER TABLE races DROP CONSTRAINT IF EXISTS races_status_check;
ALTER TABLE races ADD CONSTRAINT races_status_check
CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed'));

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev": "ts-node src/index.ts",
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts"
"seed": "ts-node src/seed.ts",
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
},
"dependencies": {
"cors": "^2.8.5",
@@ -21,7 +22,10 @@
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

17
backend/src/app.ts Normal file
View File

@@ -0,0 +1,17 @@
import express from "express";
import cors from "cors";
import { config } from "./config";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
export function createApp(): express.Express {
const app = express();
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
app.use(express.json());
app.use(healthRouter);
app.use(racesRouter);
return app;
}

View File

@@ -29,6 +29,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
bib_pickup: null,
bib_number: null,
finish_time: null,
finish_place: null,
notes: null,
created_at: now,
updated_at: null,
@@ -51,6 +52,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
bib_number: row.bib_number != null ? String(row.bib_number) : null,
finish_time: row.finish_time != null ? String(row.finish_time) : null,
finish_place: row.finish_place != null ? String(row.finish_place) : null,
notes: row.notes != null ? String(row.notes) : null,
created_at: now,
updated_at: null,

View File

@@ -1,16 +1,7 @@
import express from "express";
import cors from "cors";
import { config } from "./config";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
import { createApp } from "./app";
const app = express();
app.use(cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE"] }));
app.use(express.json());
app.use(healthRouter);
app.use(racesRouter);
const app = createApp();
app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`);

View File

@@ -11,6 +11,7 @@ export interface RaceRow {
bib_pickup: string | null;
bib_number: string | null;
finish_time: string | null;
finish_place: string | null;
notes: string | null;
created_at: string;
updated_at: string | null;
@@ -29,6 +30,7 @@ export interface RaceDto {
bibPickup: string | null;
bibNumber: string | null;
finishTime: string | null;
finishPlace: string | null;
notes: string | null;
createdAt: string;
updatedAt: string | null;
@@ -48,6 +50,7 @@ export function rowToDto(row: RaceRow): RaceDto {
bibPickup: row.bib_pickup,
bibNumber: row.bib_number,
finishTime: row.finish_time,
finishPlace: row.finish_place,
notes: row.notes,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -66,6 +69,7 @@ const FIELD_MAP: Record<string, string> = {
bibPickup: "bib_pickup",
bibNumber: "bib_number",
finishTime: "finish_time",
finishPlace: "finish_place",
notes: "notes",
};

39
backend/test/app.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app";
const app = createApp();
test("GET /health returns ok", async () => {
const res = await request(app).get("/health").expect(200);
assert.equal(res.body.status, "ok");
});
test("GET /ready succeeds with mock database", async () => {
const res = await request(app).get("/ready").expect(200);
assert.equal(res.body.status, "ready");
assert.equal(res.body.db, "connected");
});
test("GET /races rejects invalid year", async () => {
const res = await request(app).get("/races?year=bad").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /races rejects month out of range", async () => {
const res = await request(app).get("/races?month=13").expect(400);
assert.equal(res.body.error, "validation_error");
});
test("GET /races accepts year and month", async () => {
const res = await request(app).get("/races?year=2026&month=5").expect(200);
assert.ok(Array.isArray(res.body));
});
test("GET /races/:id returns not_found", async () => {
const res = await request(app).get("/races/does-not-exist").expect(404);
assert.equal(res.body.error, "not_found");
assert.ok(Array.isArray(res.body.details));
});