fix: phase 1 bugs — CSS tokens, pluralization, error handling, cross-platform tests
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- Add missing --space-1 CSS token used by filter and detail components

- Fix active nav link losing styles on hover (CSS specificity)

- Correct Russian day pluralization (21 день, 22 дня, 25 дней)

- Show filter error banner even when stale race data is present

- Add cross-env for Windows-compatible npm test

- Add global JSON error handler in Express for malformed request bodies

- Replace stateless mock DB with in-memory store for correct DELETE/UPDATE behavior

Made-with: Cursor
This commit is contained in:
Anton
2026-04-07 17:46:46 +03:00
parent b422223c03
commit 3b8f41f905
9 changed files with 283 additions and 15 deletions

View File

@@ -20,6 +20,7 @@
"@types/node": "^22.12.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
@@ -39,6 +40,13 @@
"node": ">=12"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -643,6 +651,7 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -936,6 +945,39 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
@@ -1442,6 +1484,13 @@
"node": ">= 0.10"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1585,6 +1634,16 @@
"node": ">= 0.8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1596,6 +1655,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -1858,6 +1918,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2124,6 +2207,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2173,6 +2257,22 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -8,7 +8,7 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"test": "CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
},
"dependencies": {
"cors": "^2.8.5",
@@ -23,6 +23,7 @@
"@types/node": "^22.12.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",

View File

@@ -1,4 +1,4 @@
import express from "express";
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import { config } from "./config";
import healthRouter from "./routes/health";
@@ -13,5 +13,14 @@ export function createApp(): express.Express {
app.use(healthRouter);
app.use(racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof SyntaxError && "body" in err) {
res.status(400).json({ error: "validation_error", details: ["Invalid JSON in request body"] });
return;
}
console.error("[app] Unhandled error:", err);
res.status(500).json({ error: "unknown_error", details: ["Internal server error"] });
});
return app;
}

View File

@@ -69,6 +69,8 @@ function createMockPool(): Pool {
fields: [],
}) as QueryResult<T>;
const store = new Map<string, RaceRow>();
const mockQuery = async <T extends QueryResultRow>(
text: string,
params?: unknown[],
@@ -76,11 +78,9 @@ function createMockPool(): Pool {
const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? [];
if (sql.includes("DELETE FROM races")) {
return emptyResult();
}
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p);
store.set(row.id, row);
return {
rows: [row as unknown as T],
rowCount: 1,
@@ -89,12 +89,49 @@ function createMockPool(): Pool {
fields: [],
} as QueryResult<T>;
}
if (sql.includes("DELETE FROM races")) {
const id = String(p[0] ?? "");
const existed = store.delete(id);
return {
rows: [],
rowCount: existed ? 1 : 0,
command: "DELETE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
return emptyResult();
const id = String(p[p.length - 1] ?? "");
const existing = store.get(id);
if (!existing) {
return emptyResult();
}
const updated = { ...existing, updated_at: new Date().toISOString() };
store.set(id, updated);
return {
rows: [updated as unknown as T],
rowCount: 1,
command: "UPDATE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("SELECT * FROM races WHERE id =")) {
const id = String(p[0] ?? "");
const row = store.get(id);
return row
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult();
}
if (sql.includes("SELECT * FROM races")) {
return emptyResult();
const rows = Array.from(store.values());
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
}
return emptyResult();
};