Standardize monorepo pipeline: test, lint, typecheck across all workspaces

Previously only 2-3 workspaces participated in each turbo task. This
expands test, lint, and typecheck to cover all 11 workspaces with
parallel execution and caching.

Test pipeline:
- Move from single root vitest to per-workspace test scripts via turbo
- Shared vitest config (vitest.shared.ts) with passWithNoTests
- Per-workspace configs re-export the shared base
- Mobile uses Jest (jest-expo + React Native Testing Library)
- Add node-environment GPX tests verifying linkedom fallback
- Add i18n mobile init tests
- Exclude apps/mobile from root vitest (uses Jest separately)

Lint pipeline:
- Add eslint lint script to all 9 previously unlinted workspaces
- Standardize all scripts to "eslint ." with shared root config
- Add .expo/ to global ESLint ignores
- Fix lint errors: unused imports in api/types, export parseGpx

Typecheck pipeline:
- Add "typecheck": "tsc" to all 8 packages
- Add @types/node (catalog) to gpx and db packages
- Fix mobile app.config.ts: remove deprecated experiments.monorepo
  and newArchEnabled (both default in Expo SDK 55)
- Add allowImportingTsExtensions to mobile tsconfig

Shared package compatibility (mobile-app Phase 1.3):
- Add linkedom as explicit dependency to @trails-cool/gpx
- Add initI18nMobile() export to @trails-cool/i18n
- Confirm @trails-cool/types is pure interfaces (no DOM deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-13 00:00:25 +02:00
parent 423499d21d
commit 2ac4014521
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
41 changed files with 1787 additions and 36 deletions

View file

@ -4,6 +4,11 @@
"version": "0.0.1",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"zod": "^3.25.0"
}

View file

@ -13,22 +13,16 @@ import {
TokenExchangeRequestSchema,
TokenResponseSchema,
DeviceSchema,
DeviceListResponseSchema,
// Routes
RouteSummarySchema,
RouteDetailSchema,
RouteListResponseSchema,
CreateRouteRequestSchema,
UpdateRouteRequestSchema,
ComputeRouteRequestSchema,
// Activities
ActivitySummarySchema,
ActivityDetailSchema,
ActivityListResponseSchema,
CreateActivityRequestSchema,
// Uploads
PresignedUploadRequestSchema,
PresignedUploadResponseSchema,
} from "./index.ts";
describe("API_VERSION", () => {

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -9,8 +9,16 @@
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"drizzle-orm": "catalog:",
"postgres": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View file

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -7,7 +7,16 @@
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"@trails-cool/types": "workspace:*"
"@trails-cool/types": "workspace:*",
"linkedom": "^0.18.12"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View file

@ -0,0 +1,85 @@
/**
* @vitest-environment node
*
* These tests run WITHOUT jsdom to verify GPX parsing and generation
* work in environments without a native DOMParser (Node.js, React Native).
* The linkedom fallback must handle all XML parsing.
*/
import { describe, it, expect } from "vitest";
import { parseGpxAsync } from "./parse.ts";
import { generateGpx } from "./generate.ts";
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Test Route</name><desc>A test description</desc></metadata>
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="51.84" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
<wpt lat="48.137" lon="11.576"><name>Munich</name></wpt>
<trk>
<trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.84" lon="12.243"><ele>80</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg>
</trk>
</gpx>`;
describe("parseGpxAsync (node environment, no DOMParser)", () => {
it("parses route name and description", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.name).toBe("Test Route");
expect(result.description).toBe("A test description");
});
it("parses waypoints", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.waypoints).toHaveLength(3);
expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" });
});
it("parses isDayBreak from overnight type", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.waypoints[1]!.isDayBreak).toBe(true);
expect(result.waypoints[0]!.isDayBreak).toBeUndefined();
});
it("parses tracks with elevation", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.tracks).toHaveLength(1);
expect(result.tracks[0]).toHaveLength(3);
expect(result.tracks[0]![0]!.ele).toBe(34);
});
it("computes elevation gain and distance", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.elevation.gain).toBe(485);
expect(result.elevation.loss).toBe(0);
expect(result.distance).toBeGreaterThan(400_000);
});
});
describe("generateGpx + parseGpxAsync round-trip (node environment)", () => {
it("round-trips waypoints, tracks, and metadata", async () => {
const gpx = generateGpx({
name: "Round Trip",
description: "Testing",
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Start" },
{ lat: 51.0, lon: 12.0, name: "Camp", isDayBreak: true },
{ lat: 48.137, lon: 11.576, name: "End" },
],
tracks: [[
{ lat: 52.52, lon: 13.405, ele: 34 },
{ lat: 51.0, lon: 12.0, ele: 200 },
{ lat: 48.137, lon: 11.576, ele: 519 },
]],
});
const parsed = await parseGpxAsync(gpx);
expect(parsed.name).toBe("Round Trip");
expect(parsed.description).toBe("Testing");
expect(parsed.waypoints).toHaveLength(3);
expect(parsed.waypoints[1]!.isDayBreak).toBe(true);
expect(parsed.tracks[0]).toHaveLength(3);
});
});

View file

@ -1,3 +1,9 @@
/**
* @vitest-environment jsdom
*
* Tests the browser DOMParser path. The linkedom/node path is covered
* by parse-node.test.ts.
*/
import { describe, it, expect } from "vitest";
import { parseGpxAsync } from "./parse.ts";

View file

@ -15,7 +15,7 @@ async function getDOMParser(): Promise<typeof DOMParser> {
return _LinkedDOMParser;
}
function parseGpx(xml: string): GpxData {
export function parseGpx(xml: string): GpxData {
// Synchronous path for browser
if (typeof DOMParser !== "undefined") {
return parseGpxWithParser(new DOMParser(), xml);
@ -26,7 +26,7 @@ function parseGpx(xml: string): GpxData {
const { DOMParser: LP } = require("linkedom");
return parseGpxWithParser(new LP() as unknown as DOMParser, xml);
} catch {
throw new Error("DOMParser not available — install linkedom for Node.js");
throw new Error("DOMParser not available — install linkedom");
}
}

View file

@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -8,6 +8,11 @@
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"peerDependencies": {
"react": ">=18",
"i18next": ">=26",

View file

@ -0,0 +1,40 @@
/**
* @vitest-environment node
*
* Tests run in node environment (no browser APIs) to verify
* React Native compatibility of initI18nMobile.
*/
import { describe, it, expect } from "vitest";
import { initI18nMobile, i18n, supportedLngs, resources } from "./index.ts";
describe("initI18nMobile", () => {
it("initializes i18next without browser APIs", () => {
// Verify no browser globals exist in this environment
expect(typeof document).toBe("undefined");
expect(typeof localStorage).toBe("undefined");
initI18nMobile("de-AT");
expect(i18n.isInitialized).toBe(true);
// Regional variant should match supported "de"
expect(i18n.language).toBe("de");
});
it("has all translation namespaces loaded", () => {
expect(i18n.hasResourceBundle("en", "common")).toBe(true);
expect(i18n.hasResourceBundle("de", "common")).toBe(true);
expect(i18n.hasResourceBundle("en", "planner")).toBe(true);
expect(i18n.hasResourceBundle("de", "journal")).toBe(true);
});
it("can switch language after init", () => {
i18n.changeLanguage("en");
expect(i18n.language).toBe("en");
});
it("exports supported languages and resources", () => {
expect(supportedLngs).toContain("en");
expect(supportedLngs).toContain("de");
expect(resources.en.common).toBeDefined();
expect(resources.de.common).toBeDefined();
});
});

View file

@ -53,6 +53,33 @@ export function initI18nClient() {
});
}
/**
* Initialize i18next for React Native.
* No browser language detector pass the device locale directly.
* Use `expo-localization` to get the device locale:
*
* ```ts
* import { getLocales } from "expo-localization";
* initI18nMobile(getLocales()[0]?.languageCode ?? "en");
* ```
*/
export function initI18nMobile(deviceLanguage?: string) {
if (i18n.isInitialized) return;
const lng = matchSupportedLng(deviceLanguage);
i18n.use(initReactI18next).init({ ...commonOptions, lng });
}
function matchSupportedLng(lang?: string): SupportedLng {
if (!lang) return "en";
const lower = lang.toLowerCase();
for (const supported of supportedLngs) {
if (lower === supported || lower.startsWith(supported + "-")) {
return supported;
}
}
return "en";
}
/**
* Detect the best supported language from a request's Accept-Language header.
*/

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -6,5 +6,10 @@
".": "./src/index.ts"
},
"main": "./src/index.ts",
"types": "./src/index.ts"
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
}
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -7,6 +7,11 @@
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
},
"dependencies": {
"@trails-cool/map-core": "workspace:*"
},

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -3,5 +3,10 @@
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
}
}

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import type { Waypoint, Route } from "./index.ts";
import type { Waypoint } from "./index.ts";
describe("types", () => {
it("Waypoint type accepts valid data", () => {

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";

View file

@ -7,5 +7,10 @@
"./styles.css": "./src/styles.css"
},
"main": "./src/index.ts",
"types": "./src/index.ts"
"types": "./src/index.ts",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"typecheck": "tsc"
}
}

View file

@ -0,0 +1 @@
export { default } from "../../vitest.shared.ts";