Merge branch 'main' into remove-stray-root-deps
This commit is contained in:
commit
45d61082f2
47 changed files with 1250 additions and 424 deletions
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:25-slim AS base
|
||||
FROM node:26-slim AS base
|
||||
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
|
||||
# trixie-slim and ships neither curl nor wget by default).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* only touches users whose public_key IS NULL, so re-runs are no-ops.
|
||||
* New users get keys at registration and never appear in this workload.
|
||||
*/
|
||||
export const backfillUserKeypairsJob: JobDefinition = {
|
||||
export const backfillUserKeypairsJob = defineJournalJob({
|
||||
name: "backfill-user-keypairs",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
|
|
@ -23,4 +23,4 @@ export const backfillUserKeypairsJob: JobDefinition = {
|
|||
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
|
||||
return { candidates: ids.length, generated };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { lt } from "drizzle-orm";
|
||||
import { consumedJwtJti } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
|
|
@ -13,7 +13,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
*
|
||||
* See planner-audit #2 Phase B.
|
||||
*/
|
||||
export const consumedJtiSweepJob: JobDefinition = {
|
||||
export const consumedJtiSweepJob = defineJournalJob({
|
||||
name: "consumed-jti-sweep",
|
||||
cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge)
|
||||
retryLimit: 1,
|
||||
|
|
@ -28,4 +28,4 @@ export const consumedJtiSweepJob: JobDefinition = {
|
|||
logger.info({ purged }, "consumed-jti-sweep");
|
||||
return { purged };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { activities, users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
|
|
@ -39,12 +39,12 @@ async function paceHost(host: string): Promise<void> {
|
|||
* failed and pg-boss retries; exhausting the budget is the permanent
|
||||
* failure, logged by the final catch.
|
||||
*/
|
||||
export const deliverActivityJob: JobDefinition = {
|
||||
export const deliverActivityJob = defineJournalJob({
|
||||
name: "deliver-activity",
|
||||
expireInSeconds: 60,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
const p = job.data as DeliveryPayload;
|
||||
const p = job.data;
|
||||
try {
|
||||
await deliverOne(p);
|
||||
} catch (err) {
|
||||
|
|
@ -56,7 +56,7 @@ export const deliverActivityJob: JobDefinition = {
|
|||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
async function deliverOne(p: DeliveryPayload): Promise<void> {
|
||||
const federation = getFederation();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import {
|
||||
DEMO_BACKFILL_TARGET,
|
||||
DEMO_DAILY_CAP,
|
||||
|
|
@ -21,7 +21,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
|
||||
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
|
||||
*/
|
||||
export const demoBotGenerateJob: JobDefinition = {
|
||||
export const demoBotGenerateJob = defineJournalJob({
|
||||
name: "demo-bot-generate",
|
||||
cron: "0,30 * * * *",
|
||||
retryLimit: 1,
|
||||
|
|
@ -57,4 +57,4 @@ export const demoBotGenerateJob: JobDefinition = {
|
|||
await refreshDemoBotGauges();
|
||||
return { mode: "single", routeId: id };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import {
|
||||
demoRetentionDays,
|
||||
isDemoBotEnabled,
|
||||
|
|
@ -11,7 +11,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* Daily prune. Deletes synthetic rows older than
|
||||
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
|
||||
*/
|
||||
export const demoBotPruneJob: JobDefinition = {
|
||||
export const demoBotPruneJob = defineJournalJob({
|
||||
name: "demo-bot-prune",
|
||||
cron: "15 3 * * *",
|
||||
retryLimit: 1,
|
||||
|
|
@ -24,4 +24,4 @@ export const demoBotPruneJob: JobDefinition = {
|
|||
await refreshDemoBotGauges();
|
||||
return { days, ...counts };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { PostgresKvStore } from "../lib/federation-kv.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* nonces and caches carry TTLs; reads already filter expired rows, this
|
||||
* keeps the table from growing unbounded).
|
||||
*/
|
||||
export const federationKvSweepJob: JobDefinition = {
|
||||
export const federationKvSweepJob = defineJournalJob({
|
||||
name: "federation-kv-sweep",
|
||||
cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps)
|
||||
retryLimit: 1,
|
||||
|
|
@ -17,4 +17,4 @@ export const federationKvSweepJob: JobDefinition = {
|
|||
logger.info({ purged }, "federation-kv-sweep");
|
||||
return { purged };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
import {
|
||||
runGarminActivityImport,
|
||||
type GarminImportData,
|
||||
} from "../lib/connected-services/providers/garmin/import.server.ts";
|
||||
import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts";
|
||||
|
||||
// Garmin webhook notifications enqueue here (spec: garmin-import,
|
||||
// "Push-notification activity import"): the webhook answers 200
|
||||
|
|
@ -11,14 +8,14 @@ import {
|
|||
// download, FIT→GPX, activity creation. Backfill bursts deliver many
|
||||
// notifications at once; the queue absorbs them and pg-boss retries
|
||||
// transient download failures.
|
||||
export const garminImportActivityJob: JobDefinition = {
|
||||
export const garminImportActivityJob = defineJournalJob({
|
||||
name: "garmin-import-activity",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
async handler(jobs) {
|
||||
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
||||
for (const job of batch) {
|
||||
const data = job.data as GarminImportData;
|
||||
const data = job.data;
|
||||
try {
|
||||
await runGarminActivityImport(data);
|
||||
} catch (err) {
|
||||
|
|
@ -30,4 +27,4 @@ export const garminImportActivityJob: JobDefinition = {
|
|||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, lt, inArray, count } from "drizzle-orm";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
|
||||
|
|
@ -7,7 +7,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
const STALE_MS = 10 * 60 * 1000;
|
||||
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
|
||||
|
||||
export const importBatchesSweepJob: JobDefinition = {
|
||||
export const importBatchesSweepJob = defineJournalJob({
|
||||
name: "import-batches-sweep",
|
||||
cron: "* * * * *",
|
||||
retryLimit: 0,
|
||||
|
|
@ -39,4 +39,4 @@ export const importBatchesSweepJob: JobDefinition = {
|
|||
|
||||
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
62
apps/journal/app/jobs/komoot-bulk-import.test.ts
Normal file
62
apps/journal/app/jobs/komoot-bulk-import.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../lib/logger.server.ts", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const withFreshCredentials = vi.fn();
|
||||
vi.mock("../lib/connected-services/manager.ts", () => ({
|
||||
withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args),
|
||||
}));
|
||||
|
||||
const runKomootBulkImport = vi.fn();
|
||||
const markBatchFailed = vi.fn();
|
||||
vi.mock("../lib/komoot-bulk-import.server.ts", () => ({
|
||||
runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args),
|
||||
markBatchFailed: (...args: unknown[]) => markBatchFailed(...args),
|
||||
}));
|
||||
|
||||
import { komootBulkImportJob } from "./komoot-bulk-import.ts";
|
||||
|
||||
type HandlerJobs = Parameters<typeof komootBulkImportJob.handler>[0];
|
||||
|
||||
function jobWith(data: unknown): HandlerJobs {
|
||||
return [{ id: "j1", data }] as unknown as HandlerJobs;
|
||||
}
|
||||
|
||||
const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" };
|
||||
|
||||
describe("komoot-bulk-import job", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves credentials through the manager — only the serviceId crosses the queue", async () => {
|
||||
const creds = { mode: "public", komootUserId: "k1" };
|
||||
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds));
|
||||
runKomootBulkImport.mockResolvedValue(undefined);
|
||||
|
||||
await komootBulkImportJob.handler(jobWith(PAYLOAD));
|
||||
|
||||
expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function));
|
||||
expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds);
|
||||
expect(markBatchFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the batch failed and rethrows when credential resolution fails", async () => {
|
||||
withFreshCredentials.mockRejectedValue(new Error("needs relink"));
|
||||
|
||||
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink");
|
||||
|
||||
expect(runKomootBulkImport).not.toHaveBeenCalled();
|
||||
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink");
|
||||
});
|
||||
|
||||
it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => {
|
||||
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" }));
|
||||
runKomootBulkImport.mockRejectedValue(new Error("komoot 500"));
|
||||
|
||||
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500");
|
||||
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,36 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
import { runKomootBulkImport } from "../lib/komoot-bulk-import.server.ts";
|
||||
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
|
||||
import {
|
||||
markBatchFailed,
|
||||
runKomootBulkImport,
|
||||
type KomootCreds,
|
||||
} from "../lib/komoot-bulk-import.server.ts";
|
||||
|
||||
type KomootCreds =
|
||||
| { mode: "public"; komootUserId: string }
|
||||
| { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string };
|
||||
|
||||
interface KomootBulkImportData {
|
||||
batchId: string;
|
||||
userId: string;
|
||||
creds: KomootCreds;
|
||||
}
|
||||
|
||||
export const komootBulkImportJob: JobDefinition = {
|
||||
export const komootBulkImportJob = defineJournalJob({
|
||||
name: "komoot-bulk-import",
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 1800,
|
||||
async handler(jobs) {
|
||||
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
||||
for (const job of batch) {
|
||||
// pg-boss serialized payload — caller (enqueueOptional) wrote it
|
||||
// as KomootBulkImportData. Narrow at the boundary.
|
||||
const { batchId, userId, creds } = job.data as KomootBulkImportData;
|
||||
const { batchId, userId, serviceId } = job.data;
|
||||
logger.info({ batchId, userId }, "komoot bulk import job started");
|
||||
await runKomootBulkImport(batchId, userId, creds);
|
||||
try {
|
||||
// Credentials are resolved through the ConnectedServiceManager at
|
||||
// execution time — the payload carries only the serviceId, so
|
||||
// nothing credential-shaped sits in the job table and a relink
|
||||
// between enqueue and execution is picked up here.
|
||||
await withFreshCredentials(serviceId, (creds) =>
|
||||
runKomootBulkImport(batchId, userId, creds as KomootCreds),
|
||||
);
|
||||
} catch (err) {
|
||||
// runKomootBulkImport marks its own failures; this covers errors
|
||||
// before it ran (service missing/not active/needs relink), where
|
||||
// the batch would otherwise stay "pending" forever.
|
||||
await markBatchFailed(batchId, err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { activities, follows, users } from "@trails-cool/db/schema/journal";
|
||||
import { createNotification } from "../lib/notifications.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
interface FanoutData {
|
||||
activityId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out an `activity_published` notification to every accepted
|
||||
* follower of the activity's owner. Idempotent at the DB level via the
|
||||
* `(recipient_user_id, type, subject_id)` unique partial index — a
|
||||
* retry after partial failure won't double-insert.
|
||||
*/
|
||||
export const notificationsFanoutJob: JobDefinition = {
|
||||
export const notificationsFanoutJob = defineJournalJob({
|
||||
name: "notifications-fanout",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
|
|
@ -24,11 +20,10 @@ export const notificationsFanoutJob: JobDefinition = {
|
|||
// process whichever shape we get.
|
||||
const batch = Array.isArray(job) ? job : [job];
|
||||
for (const item of batch) {
|
||||
const data = item.data as FanoutData;
|
||||
await fanout(data.activityId);
|
||||
await fanout(item.data.activityId);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export async function fanout(activityId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { purgeReadOlderThan } from "../lib/notifications.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* than 90 days; unread rows are kept indefinitely so users never miss
|
||||
* an event.
|
||||
*/
|
||||
export const notificationsPurgeJob: JobDefinition = {
|
||||
export const notificationsPurgeJob = defineJournalJob({
|
||||
name: "notifications-purge",
|
||||
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
|
||||
retryLimit: 1,
|
||||
|
|
@ -18,4 +18,4 @@ export const notificationsPurgeJob: JobDefinition = {
|
|||
logger.info({ days, purged }, "notifications-purge");
|
||||
return { days, purged };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
47
apps/journal/app/jobs/payloads.test.ts
Normal file
47
apps/journal/app/jobs/payloads.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Importing every job module pulls in their (transitively heavy)
|
||||
// server deps; mock the leaf modules with side effects so this stays a
|
||||
// unit test of the registry wiring.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() }));
|
||||
vi.mock("../lib/logger.server.ts", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("job registry", () => {
|
||||
it("every job's queue name is unique and a key of JobPayloads", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("./backfill-user-keypairs.ts"),
|
||||
import("./consumed-jti-sweep.ts"),
|
||||
import("./deliver-activity.ts"),
|
||||
import("./demo-bot-generate.ts"),
|
||||
import("./demo-bot-prune.ts"),
|
||||
import("./federation-kv-sweep.ts"),
|
||||
import("./garmin-import-activity.ts"),
|
||||
import("./import-batches-sweep.ts"),
|
||||
import("./komoot-bulk-import.ts"),
|
||||
import("./notifications-fanout.ts"),
|
||||
import("./notifications-purge.ts"),
|
||||
import("./poll-remote-actor.ts"),
|
||||
import("./poll-remote-outboxes.ts"),
|
||||
import("./send-welcome-email.ts"),
|
||||
]);
|
||||
|
||||
const names = modules.flatMap((m) =>
|
||||
Object.values(m as Record<string, unknown>)
|
||||
.filter(
|
||||
(v): v is { name: string; handler: unknown } =>
|
||||
typeof v === "object" && v !== null && "handler" in v && "name" in v,
|
||||
)
|
||||
.map((def) => def.name),
|
||||
);
|
||||
|
||||
expect(names).toHaveLength(14);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
// pg-boss v11+ queue-name constraint, mirrored from packages/jobs
|
||||
for (const name of names) {
|
||||
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
41
apps/journal/app/jobs/payloads.ts
Normal file
41
apps/journal/app/jobs/payloads.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { defineJob, type JobDefinition, type TypedJobDefinition } from "@trails-cool/jobs";
|
||||
import type { DeliveryPayload } from "../lib/federation-delivery.server.ts";
|
||||
import type { GarminImportData } from "../lib/connected-services/providers/garmin/import.server.ts";
|
||||
|
||||
/**
|
||||
* Every journal job queue and its payload shape, in one place. The
|
||||
* typed `enqueue` / `enqueueOptional` in boss.server.ts and the
|
||||
* `defineJournalJob` helper below both key off this map, so an enqueue
|
||||
* site and its handler cannot drift apart, and a queue-name typo is a
|
||||
* compile error instead of an orphaned queue.
|
||||
*
|
||||
* `void` marks cron-only jobs that are never enqueued with data.
|
||||
*/
|
||||
export interface JobPayloads {
|
||||
"backfill-user-keypairs": Record<string, never>;
|
||||
"consumed-jti-sweep": void;
|
||||
"deliver-activity": DeliveryPayload;
|
||||
"demo-bot-generate": void;
|
||||
"demo-bot-prune": void;
|
||||
"federation-kv-sweep": void;
|
||||
"garmin-import-activity": GarminImportData;
|
||||
"import-batches-sweep": void;
|
||||
"komoot-bulk-import": { batchId: string; userId: string; serviceId: string };
|
||||
"notifications-fanout": { activityId: string };
|
||||
"notifications-purge": void;
|
||||
"poll-remote-actor": { actorIri: string };
|
||||
"poll-remote-outboxes": void;
|
||||
"send-welcome-email": { email: string; username: string };
|
||||
}
|
||||
|
||||
export type JobName = keyof JobPayloads;
|
||||
|
||||
/**
|
||||
* defineJob, constrained to the journal's queue map: the name must be
|
||||
* a known queue and the handler's payload type follows from it.
|
||||
*/
|
||||
export function defineJournalJob<K extends JobName>(
|
||||
definition: TypedJobDefinition<JobPayloads[K]> & { name: K },
|
||||
): JobDefinition {
|
||||
return defineJob<JobPayloads[K]>(definition);
|
||||
}
|
||||
|
|
@ -1,26 +1,23 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { pollRemoteActor } from "../lib/federation-ingest.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
interface PollPayload {
|
||||
actorIri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one remote trails actor's outbox (spec §7). Enqueued by the
|
||||
* inbox Accept(Follow) listener (first poll, 7.5) and fanned out by
|
||||
* the poll-remote-outboxes cron sweep (7.1).
|
||||
*/
|
||||
export const pollRemoteActorJob: JobDefinition = {
|
||||
export const pollRemoteActorJob = defineJournalJob({
|
||||
name: "poll-remote-actor",
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 120,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
const { actorIri } = (job.data ?? {}) as PollPayload;
|
||||
// Defensive: jobs enqueued before the typed seam may carry no data.
|
||||
const actorIri = job.data?.actorIri;
|
||||
if (!actorIri) continue;
|
||||
const result = await pollRemoteActor(actorIri);
|
||||
logger.info({ actorIri, result }, "poll-remote-actor");
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { listActorsDuePolling } from "../lib/federation-ingest.server.ts";
|
||||
import { enqueueOptional } from "../lib/boss.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
|
@ -9,7 +9,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* been polled within the last hour, and fan out one poll-remote-actor
|
||||
* job each. Per-host pacing lives in the poll itself.
|
||||
*/
|
||||
export const pollRemoteOutboxesJob: JobDefinition = {
|
||||
export const pollRemoteOutboxesJob = defineJournalJob({
|
||||
name: "poll-remote-outboxes",
|
||||
cron: "*/5 * * * *",
|
||||
retryLimit: 1,
|
||||
|
|
@ -22,4 +22,4 @@ export const pollRemoteOutboxesJob: JobDefinition = {
|
|||
if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep");
|
||||
return { due: due.length };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,21 @@
|
|||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { sendWelcome } from "../lib/email.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
interface WelcomeEmailData {
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue-backed welcome email send. The old code did
|
||||
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
|
||||
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
|
||||
* failures via the dead-letter queue / logs.
|
||||
*/
|
||||
export const sendWelcomeEmailJob: JobDefinition = {
|
||||
export const sendWelcomeEmailJob = defineJournalJob({
|
||||
name: "send-welcome-email",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 120,
|
||||
async handler(job) {
|
||||
const batch = Array.isArray(job) ? job : [job];
|
||||
for (const item of batch) {
|
||||
const { email, username } = item.data as WelcomeEmailData;
|
||||
const { email, username } = item.data;
|
||||
try {
|
||||
await sendWelcome(email, username);
|
||||
} catch (err) {
|
||||
|
|
@ -29,4 +24,4 @@ export const sendWelcomeEmailJob: JobDefinition = {
|
|||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
// global registry key.
|
||||
|
||||
import { logger } from "./logger.server.ts";
|
||||
import type { JobName, JobPayloads } from "../jobs/payloads.ts";
|
||||
|
||||
// Structurally typed (we only need `send`) so we don't have to pull
|
||||
// pg-boss into the journal app's dep graph just for the typedef.
|
||||
|
|
@ -51,20 +52,33 @@ export function getBoss(): BossLike {
|
|||
return boss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a job. The queue name must be a key of JobPayloads and the
|
||||
* payload must match its declared shape. Throws if the queue is down —
|
||||
* use this when the caller's correctness depends on the job existing
|
||||
* (e.g. a batch row that would otherwise wait forever).
|
||||
*/
|
||||
export async function enqueue<K extends JobName>(
|
||||
queue: K,
|
||||
data: JobPayloads[K],
|
||||
options?: BossSendOptions,
|
||||
): Promise<void> {
|
||||
await getBoss().send(queue, data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort enqueue: log + swallow errors so a downstream queue
|
||||
* outage doesn't fail the user-visible request that triggered the
|
||||
* fan-out. Use this for "fire and forget" notifications work.
|
||||
*/
|
||||
export async function enqueueOptional(
|
||||
queue: string,
|
||||
data: unknown,
|
||||
export async function enqueueOptional<K extends JobName>(
|
||||
queue: K,
|
||||
data: JobPayloads[K],
|
||||
ctx: Record<string, unknown> = {},
|
||||
options?: BossSendOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const boss = getBoss();
|
||||
await boss.send(queue, data, options);
|
||||
await enqueue(queue, data, options);
|
||||
} catch (err) {
|
||||
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import { importBatches } from "@trails-cool/db/schema/journal";
|
||||
import { fetchKomootTours, fetchKomootTourGpx } from "./komoot.server.ts";
|
||||
|
|
@ -7,10 +7,29 @@ import { createActivity } from "./activities.server.ts";
|
|||
import { decrypt } from "./crypto.server.ts";
|
||||
import { logger } from "./logger.server.ts";
|
||||
|
||||
type KomootCreds =
|
||||
export type KomootCreds =
|
||||
| { mode: "public"; komootUserId: string }
|
||||
| { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string };
|
||||
|
||||
/**
|
||||
* Marks a batch failed unless it already reached a terminal state.
|
||||
* Used by the job handler when credential resolution fails before
|
||||
* runKomootBulkImport (which owns failure-marking for its own errors)
|
||||
* ever runs — otherwise the batch would sit "pending" forever.
|
||||
*/
|
||||
export async function markBatchFailed(batchId: string, message: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(importBatches)
|
||||
.set({ status: "failed", errorMessage: message, completedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(importBatches.id, batchId),
|
||||
notInArray(importBatches.status, ["completed", "failed"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getBasicAuthToken(creds: KomootCreds): string | undefined {
|
||||
if (creds.mode !== "authenticated") return undefined;
|
||||
const password = decrypt(creds.encryptedPassword);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { data } from "react-router";
|
|||
import type { Route } from "./+types/api.sync.komoot.import";
|
||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
||||
import { getService } from "~/lib/connected-services/manager";
|
||||
import { getBoss } from "~/lib/boss.server";
|
||||
import { enqueue } from "~/lib/boss.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { importBatches } from "@trails-cool/db/schema/journal";
|
||||
|
||||
|
|
@ -27,11 +27,12 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
status: "pending",
|
||||
});
|
||||
|
||||
const boss = getBoss();
|
||||
await boss.send("komoot-bulk-import", {
|
||||
// Only the serviceId crosses the queue — the job resolves fresh
|
||||
// credentials through the ConnectedServiceManager at execution time.
|
||||
await enqueue("komoot-bulk-import", {
|
||||
batchId,
|
||||
userId: user.id,
|
||||
creds: service.credentials,
|
||||
serviceId: service.id,
|
||||
});
|
||||
|
||||
return data({ batchId });
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ server.listen(port, async () => {
|
|||
await startWorker(boss, jobs);
|
||||
// Register the started boss so feature code can enqueue jobs against
|
||||
// the same instance via getBoss() / enqueueOptional().
|
||||
const { setBoss } = await import("./app/lib/boss.server.ts");
|
||||
const { setBoss, enqueue } = await import("./app/lib/boss.server.ts");
|
||||
setBoss(boss);
|
||||
logger.info("Background job worker started");
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ server.listen(port, async () => {
|
|||
// users get keys before any federation traffic). Each run only
|
||||
// touches users whose public_key IS NULL, so repeats are no-ops.
|
||||
if (process.env.FEDERATION_ENABLED === "true") {
|
||||
await boss.send("backfill-user-keypairs", {});
|
||||
await enqueue("backfill-user-keypairs", {});
|
||||
logger.info("federation keypair backfill enqueued");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:25-slim AS base
|
||||
FROM node:26-slim AS base
|
||||
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
|
||||
# trixie-slim and ships neither curl nor wget by default).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import {
|
|||
elevationColor, routeGradeColor, maxspeedColor,
|
||||
} from "@trails-cool/map-core";
|
||||
|
||||
export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute";
|
||||
import type { ColorMode } from "~/lib/route-data";
|
||||
|
||||
// ColorMode lives in the routeData schema module; re-exported here for
|
||||
// existing importers.
|
||||
export type { ColorMode };
|
||||
|
||||
interface ColoredRouteProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@trails-cool/map-core";
|
||||
import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw";
|
||||
import { useElevationData } from "~/lib/use-elevation-data";
|
||||
import { setColorMode, type ColorMode } from "~/lib/route-data";
|
||||
|
||||
interface ElevationChartProps {
|
||||
yjs: YjsState;
|
||||
|
|
@ -371,7 +372,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
);
|
||||
|
||||
const setMode = useCallback((mode: string) => {
|
||||
yjs.routeData.set("colorMode", mode);
|
||||
setColorMode(yjs.routeData, mode as ColorMode);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
|
|
|||
|
|
@ -1,33 +1,12 @@
|
|||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { generateGpx, computeDays } from "@trails-cool/gpx";
|
||||
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
|
||||
import { waypointFromYMap } from "~/lib/waypoint-ymap";
|
||||
|
||||
function getTracks(yjs: YjsState): TrackPoint[][] {
|
||||
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (!geojsonStr) return [];
|
||||
try {
|
||||
const geojson = JSON.parse(geojsonStr);
|
||||
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
|
||||
if (coords.length > 0) {
|
||||
return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
|
||||
}
|
||||
} catch { /* invalid geojson */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function getWaypoints(yjs: YjsState) {
|
||||
return yjs.waypoints.toArray().map(waypointFromYMap);
|
||||
}
|
||||
|
||||
function getNoGoAreas(yjs: YjsState): NoGoArea[] {
|
||||
return yjs.noGoAreas.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
|
||||
})).filter((a) => a.points.length >= 3);
|
||||
}
|
||||
import {
|
||||
buildDayGpxFiles,
|
||||
buildPlanGpx,
|
||||
buildRouteGpx,
|
||||
hasDayBreaks,
|
||||
} from "~/lib/gpx-export";
|
||||
|
||||
function download(gpx: string, filename: string) {
|
||||
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
||||
|
|
@ -55,68 +34,23 @@ export function ExportButton({ yjs }: { yjs: YjsState }) {
|
|||
}, [open]);
|
||||
|
||||
const handleExportRoute = useCallback(() => {
|
||||
const tracks = getTracks(yjs);
|
||||
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
|
||||
download(gpx, "route.gpx");
|
||||
download(buildRouteGpx(yjs), "route.gpx");
|
||||
setOpen(false);
|
||||
}, [yjs]);
|
||||
|
||||
const handleExportPlan = useCallback(() => {
|
||||
const tracks = getTracks(yjs);
|
||||
const waypoints = getWaypoints(yjs);
|
||||
const noGoAreas = getNoGoAreas(yjs);
|
||||
const notes = yjs.notes.toString() || undefined;
|
||||
const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas });
|
||||
download(gpx, "route-plan.gpx");
|
||||
download(buildPlanGpx(yjs), "route-plan.gpx");
|
||||
setOpen(false);
|
||||
}, [yjs]);
|
||||
|
||||
const handleExportDays = useCallback(() => {
|
||||
const tracks = getTracks(yjs);
|
||||
const waypoints = getWaypoints(yjs);
|
||||
const allPoints = tracks.flat();
|
||||
if (allPoints.length === 0 || waypoints.length === 0) return;
|
||||
|
||||
const days = computeDays(waypoints, tracks);
|
||||
if (days.length <= 1) {
|
||||
// Single day — just export the full route
|
||||
const gpx = generateGpx({ name: "trails.cool route", tracks });
|
||||
download(gpx, "route.gpx");
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find closest track index for each waypoint
|
||||
const wpTrackIndices = waypoints.map((wp) => {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < allPoints.length; i++) {
|
||||
const dx = allPoints[i]!.lat - wp.lat;
|
||||
const dy = allPoints[i]!.lon - wp.lon;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
}
|
||||
return bestIdx;
|
||||
});
|
||||
|
||||
for (const day of days) {
|
||||
const startIdx = wpTrackIndices[day.startWaypointIndex]!;
|
||||
const endIdx = wpTrackIndices[day.endWaypointIndex]!;
|
||||
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
|
||||
const dayName = day.startName && day.endName
|
||||
? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}`
|
||||
: `Day ${day.dayNumber}`;
|
||||
const gpx = generateGpx({ name: dayName, tracks: [dayPoints] });
|
||||
const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`;
|
||||
download(gpx, filename);
|
||||
for (const file of buildDayGpxFiles(yjs)) {
|
||||
download(file.gpx, file.filename);
|
||||
}
|
||||
setOpen(false);
|
||||
}, [yjs]);
|
||||
|
||||
const hasMultipleDays = (() => {
|
||||
const waypoints = getWaypoints(yjs);
|
||||
return waypoints.some((w) => w.isDayBreak);
|
||||
})();
|
||||
const hasMultipleDays = hasDayBreaks(yjs);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative z-[1001]">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { YjsState } from "~/lib/use-yjs";
|
|||
import { overlayLayers } from "@trails-cool/map";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { Z_CURSOR } from "@trails-cool/map-core";
|
||||
import { getBaseLayer, getOverlays, setBaseLayer, setOverlays } from "~/lib/route-data";
|
||||
|
||||
// Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations.
|
||||
export function MapExposer() {
|
||||
|
|
@ -188,11 +189,10 @@ export function OverlaySync({
|
|||
if (suppressRef.current) return;
|
||||
const layer = overlayLayers.find((l) => l.name === e.name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
const current = getOverlays(yjs.routeData) ?? [];
|
||||
if (!current.includes(layer.id)) {
|
||||
const updated = [...current, layer.id];
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
setOverlays(yjs.routeData, updated);
|
||||
onOverlayChange(updated);
|
||||
}
|
||||
};
|
||||
|
|
@ -201,16 +201,14 @@ export function OverlaySync({
|
|||
if (suppressRef.current) return;
|
||||
const layer = overlayLayers.find((l) => l.name === e.name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
const updated = current.filter((id) => id !== layer.id);
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
const updated = (getOverlays(yjs.routeData) ?? []).filter((id) => id !== layer.id);
|
||||
setOverlays(yjs.routeData, updated);
|
||||
onOverlayChange(updated);
|
||||
};
|
||||
|
||||
const handleBaseChange = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
yjs.routeData.set("baseLayer", e.name);
|
||||
setBaseLayer(yjs.routeData, e.name);
|
||||
};
|
||||
|
||||
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
|
||||
|
|
@ -225,11 +223,9 @@ export function OverlaySync({
|
|||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
if (raw) {
|
||||
try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ }
|
||||
}
|
||||
const base = yjs.routeData.get("baseLayer") as string | undefined;
|
||||
const overlays = getOverlays(yjs.routeData);
|
||||
if (overlays) onOverlayChange(overlays);
|
||||
const base = getBaseLayer(yjs.routeData);
|
||||
if (base) onBaseLayerChange(base);
|
||||
};
|
||||
yjs.routeData.observe(handleChange);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { DEFAULT_PROFILE, getProfile, setProfile as setYjsProfile } from "~/lib/route-data";
|
||||
|
||||
const PROFILE_IDS = ["fastbike", "safety", "shortest", "car", "trekking"] as const;
|
||||
|
||||
|
|
@ -10,11 +11,11 @@ interface ProfileSelectorProps {
|
|||
|
||||
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [profile, setProfile] = useState("fastbike");
|
||||
const [profile, setProfile] = useState(DEFAULT_PROFILE);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const p = yjs.routeData.get("profile") as string | undefined;
|
||||
const p = getProfile(yjs.routeData);
|
||||
if (p) setProfile(p);
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
|
|
@ -26,7 +27,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
|
|||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setProfile(value);
|
||||
yjs.routeData.set("profile", value);
|
||||
setYjsProfile(yjs.routeData, value);
|
||||
},
|
||||
[yjs.routeData],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { generateGpx } from "@trails-cool/gpx";
|
||||
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
|
||||
import { waypointFromYMap } from "~/lib/waypoint-ymap";
|
||||
import { buildPlanGpx } from "~/lib/gpx-export";
|
||||
|
||||
interface SaveToJournalButtonProps {
|
||||
yjs: YjsState;
|
||||
|
|
@ -23,28 +20,9 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Build GPX from computed track with planning data (no-go areas)
|
||||
// so the route round-trips correctly through the journal.
|
||||
let tracks: TrackPoint[][] = [];
|
||||
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojsonStr) {
|
||||
try {
|
||||
const geojson = JSON.parse(geojsonStr);
|
||||
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
|
||||
if (coords.length > 0) {
|
||||
tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
|
||||
}
|
||||
} catch { /* invalid geojson */ }
|
||||
}
|
||||
|
||||
const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
|
||||
})).filter((a) => a.points.length >= 3);
|
||||
|
||||
const waypoints = yjs.waypoints.toArray().map(waypointFromYMap);
|
||||
|
||||
const notes = yjs.notes.toString() || undefined;
|
||||
const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas });
|
||||
// Full plan GPX (track + waypoints + no-go areas + notes) so the
|
||||
// route round-trips correctly through the journal.
|
||||
const gpx = buildPlanGpx(yjs);
|
||||
|
||||
// POST to the planner's server-side proxy. The proxy attaches the
|
||||
// journal Bearer token (stored on the session row) and forwards
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { TFunction } from "i18next";
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { useYjs, type YjsState } from "~/lib/use-yjs";
|
||||
import { useRouting, type RouteError } from "~/lib/use-routing";
|
||||
import { getCoordinates } from "~/lib/route-data";
|
||||
import { useDays } from "~/lib/use-days";
|
||||
import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
|
|
@ -208,14 +209,10 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
|
|||
const handleResetZoom = useCallback(() => {
|
||||
const map = window.__leafletMap;
|
||||
if (!map || !yjs) return;
|
||||
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
|
||||
if (!coordsJson) return;
|
||||
try {
|
||||
const coords: [number, number, number][] = JSON.parse(coordsJson);
|
||||
if (coords.length < 2) return;
|
||||
const latLngs = coords.map((c) => [c[1]!, c[0]!] as [number, number]);
|
||||
map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] });
|
||||
} catch { /* ignore */ }
|
||||
const coords = getCoordinates(yjs.routeData);
|
||||
if (!coords || coords.length < 2) return;
|
||||
const latLngs = coords.map((c) => [c[1], c[0]] as [number, number]);
|
||||
map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] });
|
||||
setIsZoomedByChart(false);
|
||||
}, [yjs]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import { setOvernight, isOvernight } from "~/lib/overnight";
|
||||
import { setOvernight } from "~/lib/overnight";
|
||||
import { DayBreakdown } from "./DayBreakdown";
|
||||
import { useNearbyPois } from "~/lib/use-nearby-pois";
|
||||
import { poiCategories } from "@trails-cool/map-core";
|
||||
import type { Poi } from "~/lib/overpass";
|
||||
import { waypointFromYMap } from "~/lib/waypoint-ymap";
|
||||
import {
|
||||
extractWaypointData,
|
||||
waypointFromYMap,
|
||||
waypointToYMap,
|
||||
type WaypointData,
|
||||
} from "~/lib/waypoint-ymap";
|
||||
|
||||
const NOTE_MAX = 500;
|
||||
|
||||
interface WaypointData {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
note?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return waypoints.toArray().map((yMap) => {
|
||||
const wp = waypointFromYMap(yMap);
|
||||
return { lat: wp.lat, lon: wp.lon, name: wp.name, note: wp.note, overnight: isOvernight(yMap) };
|
||||
});
|
||||
}
|
||||
|
||||
interface WaypointSidebarProps {
|
||||
yjs: YjsState;
|
||||
routeStats?: {
|
||||
|
|
@ -48,7 +37,7 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp
|
|||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
|
||||
const update = () => setWaypoints(extractWaypointData(yjs.waypoints));
|
||||
yjs.waypoints.observeDeep(update);
|
||||
update();
|
||||
return () => yjs.waypoints.unobserveDeep(update);
|
||||
|
|
@ -66,26 +55,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp
|
|||
if (from === to || from < 0 || to < 0) return;
|
||||
const item = yjs.waypoints.get(from);
|
||||
if (!item) return;
|
||||
const data = {
|
||||
lat: item.get("lat") as number,
|
||||
lon: item.get("lon") as number,
|
||||
name: item.get("name") as string | undefined,
|
||||
note: item.get("note") as string | undefined,
|
||||
overnight: isOvernight(item),
|
||||
osmId: item.get("osmId") as number | undefined,
|
||||
poiTags: item.get("poiTags") as Record<string, string> | undefined,
|
||||
};
|
||||
const wp = waypointFromYMap(item);
|
||||
yjs.doc.transact(() => {
|
||||
yjs.waypoints.delete(from, 1);
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", data.lat);
|
||||
yMap.set("lon", data.lon);
|
||||
if (data.name) yMap.set("name", data.name);
|
||||
if (data.note) yMap.set("note", data.note);
|
||||
if (data.overnight) yMap.set("overnight", true);
|
||||
if (data.osmId !== undefined) yMap.set("osmId", data.osmId);
|
||||
if (data.poiTags) yMap.set("poiTags", data.poiTags);
|
||||
yjs.waypoints.insert(to, [yMap]);
|
||||
yjs.waypoints.insert(to, [waypointToYMap(wp)]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.waypoints, yjs.doc],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react";
|
|||
import * as Y from "yjs";
|
||||
import * as awarenessProtocol from "y-protocols/awareness";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { DEFAULT_PROFILE, clearRouteData, getProfile, setGeojson } from "~/lib/route-data";
|
||||
|
||||
// --- localStorage helpers ---
|
||||
|
||||
|
|
@ -125,8 +126,8 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
|
|||
while (yjs.waypoints.length > 0) {
|
||||
yjs.waypoints.delete(0, 1);
|
||||
}
|
||||
yjs.routeData.delete("geojson");
|
||||
yjs.routeData.delete("profile");
|
||||
// Nested transact joins this transaction
|
||||
clearRouteData(yjs.doc, yjs.routeData);
|
||||
});
|
||||
}, [yjs]);
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
|
|||
|
||||
if (waypoints.length < 2) return;
|
||||
|
||||
const profile = (yjs.routeData.get("profile") as string) ?? "trekking";
|
||||
const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE;
|
||||
const response = await fetch("/api/route", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -147,7 +148,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
|
|||
|
||||
if (response.ok) {
|
||||
const geojson = await response.json();
|
||||
yjs.routeData.set("geojson", JSON.stringify(geojson));
|
||||
setGeojson(yjs.routeData, geojson);
|
||||
}
|
||||
}, [yjs, sessionId]);
|
||||
|
||||
|
|
|
|||
144
apps/planner/app/lib/gpx-export.test.ts
Normal file
144
apps/planner/app/lib/gpx-export.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import { waypointToYMap } from "./waypoint-ymap.ts";
|
||||
import {
|
||||
buildDayGpxFiles,
|
||||
buildPlanGpx,
|
||||
buildRouteGpx,
|
||||
hasDayBreaks,
|
||||
type PlanDoc,
|
||||
} from "./gpx-export.ts";
|
||||
|
||||
function createDoc(): PlanDoc & { doc: Y.Doc } {
|
||||
const doc = new Y.Doc();
|
||||
return {
|
||||
doc,
|
||||
routeData: doc.getMap("routeData"),
|
||||
waypoints: doc.getArray<Y.Map<unknown>>("waypoints"),
|
||||
noGoAreas: doc.getArray<Y.Map<unknown>>("noGoAreas"),
|
||||
notes: doc.getText("notes"),
|
||||
};
|
||||
}
|
||||
|
||||
function addWaypoints(doc: PlanDoc, waypoints: Waypoint[]): void {
|
||||
doc.waypoints.push(waypoints.map(waypointToYMap));
|
||||
}
|
||||
|
||||
function setTrack(doc: PlanDoc, coords: [number, number, number][]): void {
|
||||
doc.routeData.set("coordinates", JSON.stringify(coords));
|
||||
}
|
||||
|
||||
const TRACK: [number, number, number][] = [
|
||||
[13.4, 52.5, 35],
|
||||
[13.41, 52.51, 40],
|
||||
[13.42, 52.52, 38],
|
||||
[13.43, 52.53, 42],
|
||||
];
|
||||
|
||||
describe("buildRouteGpx", () => {
|
||||
it("contains the track but no waypoints", () => {
|
||||
const doc = createDoc();
|
||||
setTrack(doc, TRACK);
|
||||
addWaypoints(doc, [
|
||||
{ lat: 52.5, lon: 13.4, name: "Start" },
|
||||
{ lat: 52.53, lon: 13.43, name: "End" },
|
||||
]);
|
||||
|
||||
const gpx = buildRouteGpx(doc);
|
||||
expect(gpx).toContain("<trk>");
|
||||
expect(gpx).toContain('lat="52.5"');
|
||||
expect(gpx).not.toContain("<wpt");
|
||||
});
|
||||
|
||||
it("produces a track-less GPX for an empty document", () => {
|
||||
const gpx = buildRouteGpx(createDoc());
|
||||
expect(gpx).toContain("<gpx");
|
||||
expect(gpx).not.toContain("<trkpt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPlanGpx", () => {
|
||||
it("includes waypoints, no-go areas, and notes", () => {
|
||||
const doc = createDoc();
|
||||
setTrack(doc, TRACK);
|
||||
addWaypoints(doc, [
|
||||
{ lat: 52.5, lon: 13.4, name: "Start" },
|
||||
{ lat: 52.53, lon: 13.43, name: "End" },
|
||||
]);
|
||||
const area = new Y.Map<unknown>();
|
||||
area.set("points", [
|
||||
{ lat: 52.5, lon: 13.4 },
|
||||
{ lat: 52.51, lon: 13.41 },
|
||||
{ lat: 52.52, lon: 13.4 },
|
||||
]);
|
||||
doc.noGoAreas.push([area]);
|
||||
doc.notes.insert(0, "Pack sunscreen");
|
||||
|
||||
const gpx = buildPlanGpx(doc);
|
||||
expect(gpx).toContain("<wpt");
|
||||
expect(gpx).toContain("Start");
|
||||
expect(gpx).toContain("Pack sunscreen");
|
||||
expect(gpx).toContain("trails:"); // no-go areas ride in the trails extension namespace
|
||||
});
|
||||
|
||||
it("matches buildRouteGpx track content so save and export cannot diverge", () => {
|
||||
const doc = createDoc();
|
||||
setTrack(doc, TRACK);
|
||||
const routeTrkpts = (buildRouteGpx(doc).match(/<trkpt/g) ?? []).length;
|
||||
const planTrkpts = (buildPlanGpx(doc).match(/<trkpt/g) ?? []).length;
|
||||
expect(planTrkpts).toBe(routeTrkpts);
|
||||
expect(planTrkpts).toBe(TRACK.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("day exports", () => {
|
||||
it("hasDayBreaks reflects overnight waypoints", () => {
|
||||
const doc = createDoc();
|
||||
addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]);
|
||||
expect(hasDayBreaks(doc)).toBe(false);
|
||||
|
||||
const withBreak = createDoc();
|
||||
addWaypoints(withBreak, [
|
||||
{ lat: 52.5, lon: 13.4 },
|
||||
{ lat: 52.51, lon: 13.41, isDayBreak: true },
|
||||
{ lat: 52.53, lon: 13.43 },
|
||||
]);
|
||||
expect(hasDayBreaks(withBreak)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns [] without a track or waypoints", () => {
|
||||
expect(buildDayGpxFiles(createDoc())).toEqual([]);
|
||||
});
|
||||
|
||||
it("exports a single route file when there are no day breaks", () => {
|
||||
const doc = createDoc();
|
||||
setTrack(doc, TRACK);
|
||||
addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]);
|
||||
|
||||
const files = buildDayGpxFiles(doc);
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]!.filename).toBe("route.gpx");
|
||||
});
|
||||
|
||||
it("splits at day-break waypoints into per-day files", () => {
|
||||
const doc = createDoc();
|
||||
setTrack(doc, TRACK);
|
||||
addWaypoints(doc, [
|
||||
{ lat: 52.5, lon: 13.4, name: "Alpha" },
|
||||
{ lat: 52.51, lon: 13.41, name: "Hut", isDayBreak: true },
|
||||
{ lat: 52.53, lon: 13.43, name: "Omega" },
|
||||
]);
|
||||
|
||||
const files = buildDayGpxFiles(doc);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0]!.filename).toBe("day-1-alpha.gpx");
|
||||
expect(files[0]!.gpx).toContain("Day 1: Alpha - Hut");
|
||||
expect(files[1]!.filename).toBe("day-2-hut.gpx");
|
||||
expect(files[1]!.gpx).toContain("Day 2: Hut - Omega");
|
||||
|
||||
// Day 1 covers track points up to the hut; day 2 the rest.
|
||||
expect((files[0]!.gpx.match(/<trkpt/g) ?? []).length).toBe(2);
|
||||
expect((files[1]!.gpx.match(/<trkpt/g) ?? []).length).toBe(3);
|
||||
});
|
||||
});
|
||||
101
apps/planner/app/lib/gpx-export.ts
Normal file
101
apps/planner/app/lib/gpx-export.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import * as Y from "yjs";
|
||||
import { generateGpx, computeDays } from "@trails-cool/gpx";
|
||||
import type { TrackPoint } from "@trails-cool/gpx";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import { getCoordinates, extractNoGoAreas } from "./route-data.ts";
|
||||
import { extractWaypoints } from "./waypoint-ymap.ts";
|
||||
|
||||
// Assembles GPX from the collaborative document. Both "save to journal"
|
||||
// and the export menu go through here so the persisted plan and the
|
||||
// downloaded file can never diverge.
|
||||
|
||||
/** The document parts GPX assembly needs (structural subset of YjsState). */
|
||||
export interface PlanDoc {
|
||||
routeData: Y.Map<unknown>;
|
||||
waypoints: Y.Array<Y.Map<unknown>>;
|
||||
noGoAreas: Y.Array<Y.Map<unknown>>;
|
||||
notes: Y.Text;
|
||||
}
|
||||
|
||||
export const ROUTE_NAME = "trails.cool route";
|
||||
|
||||
function getTracks(doc: PlanDoc): TrackPoint[][] {
|
||||
const coords = getCoordinates(doc.routeData);
|
||||
if (!coords || coords.length === 0) return [];
|
||||
return [coords.map((c) => ({ lat: c[1], lon: c[0], ele: c[2] }))];
|
||||
}
|
||||
|
||||
/** The computed track only — no waypoints, no planning data. */
|
||||
export function buildRouteGpx(doc: PlanDoc): string {
|
||||
return generateGpx({ name: ROUTE_NAME, waypoints: [], tracks: getTracks(doc) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The full plan: track, waypoints, no-go areas, and session notes —
|
||||
* everything needed to round-trip the route through the Journal.
|
||||
*/
|
||||
export function buildPlanGpx(doc: PlanDoc): string {
|
||||
return generateGpx({
|
||||
name: ROUTE_NAME,
|
||||
description: doc.notes.toString() || undefined,
|
||||
waypoints: extractWaypoints(doc.waypoints),
|
||||
tracks: getTracks(doc),
|
||||
noGoAreas: extractNoGoAreas(doc.noGoAreas),
|
||||
});
|
||||
}
|
||||
|
||||
export function hasDayBreaks(doc: PlanDoc): boolean {
|
||||
return extractWaypoints(doc.waypoints).some((w) => w.isDayBreak);
|
||||
}
|
||||
|
||||
export interface GpxFile {
|
||||
filename: string;
|
||||
gpx: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One GPX file per day stage, split at day-break waypoints. A route
|
||||
* without day breaks (or without a computed track) yields a single
|
||||
* full-route file.
|
||||
*/
|
||||
export function buildDayGpxFiles(doc: PlanDoc): GpxFile[] {
|
||||
const tracks = getTracks(doc);
|
||||
const waypoints = extractWaypoints(doc.waypoints);
|
||||
const allPoints = tracks.flat();
|
||||
if (allPoints.length === 0 || waypoints.length === 0) return [];
|
||||
|
||||
const days = computeDays(waypoints, tracks);
|
||||
if (days.length <= 1) {
|
||||
return [{ filename: "route.gpx", gpx: generateGpx({ name: ROUTE_NAME, tracks }) }];
|
||||
}
|
||||
|
||||
// Find closest track index for each waypoint
|
||||
const wpTrackIndices = waypoints.map((wp: Waypoint) => {
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < allPoints.length; i++) {
|
||||
const dx = allPoints[i]!.lat - wp.lat;
|
||||
const dy = allPoints[i]!.lon - wp.lon;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
return bestIdx;
|
||||
});
|
||||
|
||||
return days.map((day) => {
|
||||
const startIdx = wpTrackIndices[day.startWaypointIndex]!;
|
||||
const endIdx = wpTrackIndices[day.endWaypointIndex]!;
|
||||
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
|
||||
const dayName =
|
||||
day.startName && day.endName
|
||||
? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}`
|
||||
: `Day ${day.dayNumber}`;
|
||||
const filename = `day-${day.dayNumber}${
|
||||
day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""
|
||||
}.gpx`;
|
||||
return { filename, gpx: generateGpx({ name: dayName, tracks: [dayPoints] }) };
|
||||
});
|
||||
}
|
||||
243
apps/planner/app/lib/route-data.test.ts
Normal file
243
apps/planner/app/lib/route-data.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import type { EnrichedRoute } from "./route-merge.ts";
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
PROFILE_KEY,
|
||||
ROAD_METADATA_KEYS,
|
||||
clearRouteData,
|
||||
extractNoGoAreas,
|
||||
getBaseLayer,
|
||||
getColorMode,
|
||||
getCoordinates,
|
||||
getGeojson,
|
||||
getOverlays,
|
||||
getPoiCategories,
|
||||
getProfile,
|
||||
parseJsonArray,
|
||||
readComputedRoute,
|
||||
readRoadMetadata,
|
||||
setBaseLayer,
|
||||
setColorMode,
|
||||
setGeojson,
|
||||
setOverlays,
|
||||
setPoiCategories,
|
||||
setProfile,
|
||||
writeComputedRoute,
|
||||
} from "./route-data.ts";
|
||||
|
||||
function createDoc(): { doc: Y.Doc; routeData: Y.Map<unknown> } {
|
||||
const doc = new Y.Doc();
|
||||
return { doc, routeData: doc.getMap("routeData") };
|
||||
}
|
||||
|
||||
function enrichedFixture(overrides: Partial<EnrichedRoute> = {}): EnrichedRoute {
|
||||
return {
|
||||
coordinates: [
|
||||
[13.4, 52.5, 35],
|
||||
[13.41, 52.51, 40],
|
||||
[13.42, 52.52, 38],
|
||||
],
|
||||
segmentBoundaries: [0],
|
||||
surfaces: ["asphalt", "asphalt", "gravel"],
|
||||
highways: ["cycleway", "cycleway", "track"],
|
||||
maxspeeds: ["30", "30", ""],
|
||||
smoothnesses: ["good", "good", "bad"],
|
||||
tracktypes: ["", "", "grade2"],
|
||||
cycleways: ["lane", "lane", ""],
|
||||
bikeroutes: ["", "rcn", "rcn"],
|
||||
totalLength: 2500,
|
||||
totalAscend: 12,
|
||||
totalTime: 600,
|
||||
geojson: {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[13.4, 52.5, 35],
|
||||
[13.41, 52.51, 40],
|
||||
[13.42, 52.52, 38],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseJsonArray", () => {
|
||||
it("returns [] for undefined and corrupt JSON", () => {
|
||||
expect(parseJsonArray(undefined)).toEqual([]);
|
||||
expect(parseJsonArray("{not json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a JSON array", () => {
|
||||
expect(parseJsonArray<number>("[1,2,3]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computed route round-trip", () => {
|
||||
it("writes an enriched route and reads it back", () => {
|
||||
const { doc, routeData } = createDoc();
|
||||
const enriched = enrichedFixture();
|
||||
|
||||
writeComputedRoute(doc, routeData, enriched);
|
||||
const computed = readComputedRoute(routeData);
|
||||
|
||||
expect(computed.coordinates).toEqual(enriched.coordinates);
|
||||
expect(computed.segmentBoundaries).toEqual(enriched.segmentBoundaries);
|
||||
for (const key of ROAD_METADATA_KEYS) {
|
||||
expect(computed[key]).toEqual(enriched[key]);
|
||||
}
|
||||
expect(getGeojson(routeData)).toBe(JSON.stringify(enriched.geojson));
|
||||
});
|
||||
|
||||
it("keeps previous metadata when a recompute yields empty arrays", () => {
|
||||
const { doc, routeData } = createDoc();
|
||||
writeComputedRoute(doc, routeData, enrichedFixture());
|
||||
writeComputedRoute(doc, routeData, enrichedFixture({ surfaces: [] }));
|
||||
|
||||
// empty surfaces are not written; the previous value remains
|
||||
expect(readComputedRoute(routeData).surfaces).toEqual(["asphalt", "asphalt", "gravel"]);
|
||||
});
|
||||
|
||||
it("writes in a single transaction", () => {
|
||||
const { doc, routeData } = createDoc();
|
||||
let events = 0;
|
||||
routeData.observe(() => events++);
|
||||
writeComputedRoute(doc, routeData, enrichedFixture());
|
||||
expect(events).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null coordinates and empty arrays for an empty document", () => {
|
||||
const { routeData } = createDoc();
|
||||
const computed = readComputedRoute(routeData);
|
||||
expect(computed.coordinates).toBeNull();
|
||||
expect(computed.segmentBoundaries).toEqual([]);
|
||||
expect(computed.surfaces).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCoordinates", () => {
|
||||
it("falls back to geojson for documents without a coordinates key", () => {
|
||||
const { routeData } = createDoc();
|
||||
setGeojson(routeData, enrichedFixture().geojson);
|
||||
|
||||
expect(getCoordinates(routeData)).toEqual([
|
||||
[13.4, 52.5, 35],
|
||||
[13.41, 52.51, 40],
|
||||
[13.42, 52.52, 38],
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults missing elevation to 0 in the geojson fallback", () => {
|
||||
const { routeData } = createDoc();
|
||||
setGeojson(routeData, {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: { type: "LineString", coordinates: [[13.4, 52.5], [13.41, 52.51]] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(getCoordinates(routeData)).toEqual([
|
||||
[13.4, 52.5, 0],
|
||||
[13.41, 52.51, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null when neither key is set", () => {
|
||||
const { routeData } = createDoc();
|
||||
expect(getCoordinates(routeData)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile and view preferences", () => {
|
||||
it("round-trips the profile", () => {
|
||||
const { routeData } = createDoc();
|
||||
expect(getProfile(routeData)).toBeUndefined();
|
||||
setProfile(routeData, DEFAULT_PROFILE);
|
||||
expect(getProfile(routeData)).toBe(DEFAULT_PROFILE);
|
||||
expect(routeData.has(PROFILE_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults colorMode to plain and round-trips it", () => {
|
||||
const { routeData } = createDoc();
|
||||
expect(getColorMode(routeData)).toBe("plain");
|
||||
setColorMode(routeData, "surface");
|
||||
expect(getColorMode(routeData)).toBe("surface");
|
||||
});
|
||||
|
||||
it("round-trips baseLayer and overlays", () => {
|
||||
const { routeData } = createDoc();
|
||||
expect(getBaseLayer(routeData)).toBeUndefined();
|
||||
expect(getOverlays(routeData)).toBeUndefined();
|
||||
setBaseLayer(routeData, "OpenTopoMap");
|
||||
setOverlays(routeData, ["hiking", "cycling"]);
|
||||
expect(getBaseLayer(routeData)).toBe("OpenTopoMap");
|
||||
expect(getOverlays(routeData)).toEqual(["hiking", "cycling"]);
|
||||
});
|
||||
|
||||
it("treats corrupt overlays/poiCategories as unset", () => {
|
||||
const { routeData } = createDoc();
|
||||
routeData.set("overlays", "{corrupt");
|
||||
routeData.set("poiCategories", "{corrupt");
|
||||
expect(getOverlays(routeData)).toBeUndefined();
|
||||
expect(getPoiCategories(routeData)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips poiCategories", () => {
|
||||
const { routeData } = createDoc();
|
||||
setPoiCategories(routeData, ["water", "camping"]);
|
||||
expect(getPoiCategories(routeData)).toEqual(["water", "camping"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearRouteData", () => {
|
||||
it("removes the computed route and profile but keeps view preferences", () => {
|
||||
const { doc, routeData } = createDoc();
|
||||
writeComputedRoute(doc, routeData, enrichedFixture());
|
||||
setProfile(routeData, "trekking");
|
||||
setColorMode(routeData, "surface");
|
||||
setOverlays(routeData, ["hiking"]);
|
||||
|
||||
clearRouteData(doc, routeData);
|
||||
|
||||
expect(getCoordinates(routeData)).toBeNull();
|
||||
expect(getGeojson(routeData)).toBeUndefined();
|
||||
expect(getProfile(routeData)).toBeUndefined();
|
||||
expect(readRoadMetadata(routeData).surfaces).toEqual([]);
|
||||
expect(getColorMode(routeData)).toBe("surface");
|
||||
expect(getOverlays(routeData)).toEqual(["hiking"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractNoGoAreas", () => {
|
||||
it("reads areas and drops degenerate ones", () => {
|
||||
const doc = new Y.Doc();
|
||||
const noGoAreas = doc.getArray<Y.Map<unknown>>("noGoAreas");
|
||||
|
||||
const valid = new Y.Map<unknown>();
|
||||
valid.set("points", [
|
||||
{ lat: 52.5, lon: 13.4 },
|
||||
{ lat: 52.51, lon: 13.41 },
|
||||
{ lat: 52.52, lon: 13.4 },
|
||||
]);
|
||||
const degenerate = new Y.Map<unknown>();
|
||||
degenerate.set("points", [{ lat: 52.5, lon: 13.4 }]);
|
||||
const empty = new Y.Map<unknown>();
|
||||
noGoAreas.push([valid, degenerate, empty]);
|
||||
|
||||
const areas = extractNoGoAreas(noGoAreas);
|
||||
expect(areas).toHaveLength(1);
|
||||
expect(areas[0]!.points).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
221
apps/planner/app/lib/route-data.ts
Normal file
221
apps/planner/app/lib/route-data.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import * as Y from "yjs";
|
||||
import type { EnrichedRoute } from "./route-merge.ts";
|
||||
|
||||
// The schema of the shared `routeData` Y.Map (and the `noGoAreas` Y.Array).
|
||||
// This module is the only place that knows the key strings and JSON
|
||||
// encoding of the collaborative document's route state — consumers read
|
||||
// and write through the typed functions below. Waypoints have their own
|
||||
// schema module: waypoint-ymap.ts.
|
||||
|
||||
export type ColorMode =
|
||||
| "plain"
|
||||
| "elevation"
|
||||
| "surface"
|
||||
| "grade"
|
||||
| "highway"
|
||||
| "maxspeed"
|
||||
| "smoothness"
|
||||
| "tracktype"
|
||||
| "cycleway"
|
||||
| "bikeroute";
|
||||
|
||||
export const DEFAULT_PROFILE = "fastbike";
|
||||
|
||||
/** routeData key observed externally (use-routing recomputes on change). */
|
||||
export const PROFILE_KEY = "profile";
|
||||
|
||||
/** Per-coordinate road attributes BRouter enriches the route with. */
|
||||
export const ROAD_METADATA_KEYS = [
|
||||
"surfaces",
|
||||
"highways",
|
||||
"maxspeeds",
|
||||
"smoothnesses",
|
||||
"tracktypes",
|
||||
"cycleways",
|
||||
"bikeroutes",
|
||||
] as const;
|
||||
|
||||
export type RoadMetadataKey = (typeof ROAD_METADATA_KEYS)[number];
|
||||
export type RoadMetadata = Record<RoadMetadataKey, string[]>;
|
||||
|
||||
/** The computed-route portion of routeData, decoded. */
|
||||
export interface ComputedRoute extends RoadMetadata {
|
||||
/** [lon, lat, ele] triples, GeoJSON axis order. */
|
||||
coordinates: [number, number, number][] | null;
|
||||
segmentBoundaries: number[];
|
||||
}
|
||||
|
||||
export interface NoGoAreaData {
|
||||
points: Array<{ lat: number; lon: number }>;
|
||||
}
|
||||
|
||||
export function parseJsonArray<T>(json: string | undefined): T[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Like parseJsonArray, but distinguishes "absent or unparseable" from "empty". */
|
||||
function parseJsonArrayOrUndefined<T>(json: string | undefined): T[] | undefined {
|
||||
if (!json) return undefined;
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Computed route (written by the routing host, read by everyone) ---
|
||||
|
||||
export function getGeojson(routeData: Y.Map<unknown>): string | undefined {
|
||||
return routeData.get("geojson") as string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route coordinates as [lon, lat, ele] triples. Reads the "coordinates"
|
||||
* key; falls back to extracting them from "geojson" for documents written
|
||||
* before the coordinates key existed.
|
||||
*/
|
||||
export function getCoordinates(routeData: Y.Map<unknown>): [number, number, number][] | null {
|
||||
const coordsJson = routeData.get("coordinates") as string | undefined;
|
||||
if (coordsJson) {
|
||||
try {
|
||||
return JSON.parse(coordsJson);
|
||||
} catch {
|
||||
/* fall through to geojson */
|
||||
}
|
||||
}
|
||||
const geojson = getGeojson(routeData);
|
||||
if (geojson) {
|
||||
try {
|
||||
const parsed = JSON.parse(geojson);
|
||||
const coords: number[][] | undefined = parsed.features?.[0]?.geometry?.coordinates;
|
||||
if (coords) {
|
||||
return coords.map((c) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]);
|
||||
}
|
||||
} catch {
|
||||
/* invalid geojson */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readRoadMetadata(routeData: Y.Map<unknown>): RoadMetadata {
|
||||
const metadata = {} as RoadMetadata;
|
||||
for (const key of ROAD_METADATA_KEYS) {
|
||||
metadata[key] = parseJsonArray<string>(routeData.get(key) as string | undefined);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function readComputedRoute(routeData: Y.Map<unknown>): ComputedRoute {
|
||||
return {
|
||||
coordinates: getCoordinates(routeData),
|
||||
segmentBoundaries: parseJsonArray<number>(
|
||||
routeData.get("segmentBoundaries") as string | undefined,
|
||||
),
|
||||
...readRoadMetadata(routeData),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an enriched route for all participants in one transaction.
|
||||
* Metadata keys are only written when non-empty, mirroring what the
|
||||
* routing host has always done — a recompute that yields no metadata
|
||||
* leaves the previous arrays in place.
|
||||
*/
|
||||
export function writeComputedRoute(
|
||||
doc: Y.Doc,
|
||||
routeData: Y.Map<unknown>,
|
||||
enriched: EnrichedRoute,
|
||||
): void {
|
||||
doc.transact(() => {
|
||||
routeData.set("geojson", JSON.stringify(enriched.geojson));
|
||||
routeData.set("coordinates", JSON.stringify(enriched.coordinates));
|
||||
routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries));
|
||||
for (const key of ROAD_METADATA_KEYS) {
|
||||
if (enriched[key]?.length) {
|
||||
routeData.set(key, JSON.stringify(enriched[key]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Debug/recovery escape hatch: store a raw GeoJSON route without enrichment. */
|
||||
export function setGeojson(routeData: Y.Map<unknown>, geojson: unknown): void {
|
||||
routeData.set("geojson", JSON.stringify(geojson));
|
||||
}
|
||||
|
||||
/** Removes the computed route and profile (view preferences are kept). */
|
||||
export function clearRouteData(doc: Y.Doc, routeData: Y.Map<unknown>): void {
|
||||
doc.transact(() => {
|
||||
routeData.delete("geojson");
|
||||
routeData.delete("coordinates");
|
||||
routeData.delete("segmentBoundaries");
|
||||
for (const key of ROAD_METADATA_KEYS) {
|
||||
routeData.delete(key);
|
||||
}
|
||||
routeData.delete(PROFILE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Routing profile ---
|
||||
|
||||
export function getProfile(routeData: Y.Map<unknown>): string | undefined {
|
||||
return routeData.get(PROFILE_KEY) as string | undefined;
|
||||
}
|
||||
|
||||
export function setProfile(routeData: Y.Map<unknown>, profile: string): void {
|
||||
routeData.set(PROFILE_KEY, profile);
|
||||
}
|
||||
|
||||
// --- View preferences (shared across participants) ---
|
||||
|
||||
export function getColorMode(routeData: Y.Map<unknown>): ColorMode {
|
||||
return (routeData.get("colorMode") as ColorMode | undefined) ?? "plain";
|
||||
}
|
||||
|
||||
export function setColorMode(routeData: Y.Map<unknown>, mode: ColorMode): void {
|
||||
routeData.set("colorMode", mode);
|
||||
}
|
||||
|
||||
export function getBaseLayer(routeData: Y.Map<unknown>): string | undefined {
|
||||
return routeData.get("baseLayer") as string | undefined;
|
||||
}
|
||||
|
||||
export function setBaseLayer(routeData: Y.Map<unknown>, name: string): void {
|
||||
routeData.set("baseLayer", name);
|
||||
}
|
||||
|
||||
export function getOverlays(routeData: Y.Map<unknown>): string[] | undefined {
|
||||
return parseJsonArrayOrUndefined<string>(routeData.get("overlays") as string | undefined);
|
||||
}
|
||||
|
||||
export function setOverlays(routeData: Y.Map<unknown>, ids: string[]): void {
|
||||
routeData.set("overlays", JSON.stringify(ids));
|
||||
}
|
||||
|
||||
export function getPoiCategories(routeData: Y.Map<unknown>): string[] | undefined {
|
||||
return parseJsonArrayOrUndefined<string>(
|
||||
routeData.get("poiCategories") as string | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function setPoiCategories(routeData: Y.Map<unknown>, categories: string[]): void {
|
||||
routeData.set("poiCategories", JSON.stringify(categories));
|
||||
}
|
||||
|
||||
// --- No-go areas (their own Y.Array on the document) ---
|
||||
|
||||
/** Reads all no-go areas, dropping degenerate ones (fewer than 3 points). */
|
||||
export function extractNoGoAreas(noGoAreas: Y.Array<Y.Map<unknown>>): NoGoAreaData[] {
|
||||
return noGoAreas
|
||||
.toArray()
|
||||
.map((yMap) => ({
|
||||
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
|
||||
}))
|
||||
.filter((a) => a.points.length >= 3);
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { computeDays, type DayStage } from "@trails-cool/gpx";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import type { TrackPoint } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
import { isOvernight } from "./overnight.ts";
|
||||
import { extractWaypoints } from "./waypoint-ymap.ts";
|
||||
import { getCoordinates } from "./route-data.ts";
|
||||
|
||||
/**
|
||||
* Reactive hook that computes day stages from Yjs waypoints and route data.
|
||||
|
|
@ -17,13 +16,7 @@ export function useDays(yjs: YjsState | null): DayStage[] {
|
|||
if (!yjs) return;
|
||||
|
||||
const recompute = () => {
|
||||
// Extract waypoints with isDayBreak from Yjs
|
||||
const waypoints: Waypoint[] = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
isDayBreak: isOvernight(yMap) || undefined,
|
||||
}));
|
||||
const waypoints = extractWaypoints(yjs.waypoints);
|
||||
|
||||
// Check if any waypoint has isDayBreak
|
||||
const hasBreaks = waypoints.some((w) => w.isDayBreak);
|
||||
|
|
@ -32,26 +25,18 @@ export function useDays(yjs: YjsState | null): DayStage[] {
|
|||
return;
|
||||
}
|
||||
|
||||
// Extract track points from route coordinates stored in Yjs
|
||||
const coordsStr = yjs.routeData.get("coordinates") as string | undefined;
|
||||
if (!coordsStr) {
|
||||
const coords = getCoordinates(yjs.routeData);
|
||||
if (!coords) {
|
||||
setDays([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// coordinates are stored as [[lon, lat, ele], ...] (GeoJSON format)
|
||||
const coords: number[][] = JSON.parse(coordsStr);
|
||||
const trackPoints: TrackPoint[] = coords.map((c) => ({
|
||||
lat: c[1]!,
|
||||
lon: c[0]!,
|
||||
ele: c[2],
|
||||
}));
|
||||
|
||||
setDays(computeDays(waypoints, [trackPoints]));
|
||||
} catch {
|
||||
setDays([]);
|
||||
}
|
||||
const trackPoints: TrackPoint[] = coords.map((c) => ({
|
||||
lat: c[1],
|
||||
lon: c[0],
|
||||
ele: c[2],
|
||||
}));
|
||||
setDays(computeDays(waypoints, [trackPoints]));
|
||||
};
|
||||
|
||||
yjs.waypoints.observeDeep(recompute);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { ColorMode } from "~/components/ColoredRoute";
|
||||
import type { ElevationPoint } from "~/lib/elevation-chart-draw";
|
||||
import {
|
||||
getColorMode,
|
||||
getGeojson,
|
||||
readRoadMetadata,
|
||||
type ColorMode,
|
||||
type RoadMetadata,
|
||||
} from "~/lib/route-data";
|
||||
|
||||
function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
|
|
@ -44,21 +50,9 @@ function extractElevation(geojsonStr: string): ElevationPoint[] {
|
|||
}
|
||||
}
|
||||
|
||||
function parseJsonArray(json: string | undefined): string[] {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json); } catch { return []; }
|
||||
}
|
||||
|
||||
export interface ElevationData {
|
||||
export interface ElevationData extends RoadMetadata {
|
||||
points: ElevationPoint[];
|
||||
colorMode: ColorMode;
|
||||
surfaces: string[];
|
||||
highways: string[];
|
||||
maxspeeds: string[];
|
||||
smoothnesses: string[];
|
||||
tracktypes: string[];
|
||||
cycleways: string[];
|
||||
bikeroutes: string[];
|
||||
}
|
||||
|
||||
export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
|
||||
|
|
@ -76,17 +70,11 @@ export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
|
|||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const geojson = routeData.get("geojson") as string | undefined;
|
||||
const geojson = getGeojson(routeData);
|
||||
setData({
|
||||
points: geojson ? extractElevation(geojson) : [],
|
||||
colorMode: (routeData.get("colorMode") as ColorMode | undefined) ?? "plain",
|
||||
surfaces: parseJsonArray(routeData.get("surfaces") as string | undefined),
|
||||
highways: parseJsonArray(routeData.get("highways") as string | undefined),
|
||||
maxspeeds: parseJsonArray(routeData.get("maxspeeds") as string | undefined),
|
||||
smoothnesses: parseJsonArray(routeData.get("smoothnesses") as string | undefined),
|
||||
tracktypes: parseJsonArray(routeData.get("tracktypes") as string | undefined),
|
||||
cycleways: parseJsonArray(routeData.get("cycleways") as string | undefined),
|
||||
bikeroutes: parseJsonArray(routeData.get("bikeroutes") as string | undefined),
|
||||
colorMode: getColorMode(routeData),
|
||||
...readRoadMetadata(routeData),
|
||||
});
|
||||
};
|
||||
routeData.observe(update);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||
import type { YjsState } from "./use-yjs.ts";
|
||||
import type { PoiState } from "./use-pois.ts";
|
||||
import { getCategoriesForProfile } from "@trails-cool/map-core";
|
||||
import { getProfile } from "./route-data.ts";
|
||||
|
||||
/**
|
||||
* Auto-enable relevant POI categories when the routing profile changes.
|
||||
|
|
@ -15,7 +16,7 @@ export function useProfileDefaults(yjs: YjsState | null, poiState: PoiState): vo
|
|||
if (!yjs) return;
|
||||
|
||||
const handleChange = () => {
|
||||
const profile = yjs.routeData.get("profile") as string | undefined;
|
||||
const profile = getProfile(yjs.routeData);
|
||||
if (!profile) return;
|
||||
|
||||
// Skip initial load — respect existing state
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import {
|
|||
type NoGoArea,
|
||||
} from "./route-merge.ts";
|
||||
import { SegmentCache } from "./segment-cache.ts";
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
PROFILE_KEY,
|
||||
extractNoGoAreas,
|
||||
getProfile,
|
||||
writeComputedRoute,
|
||||
} from "./route-data.ts";
|
||||
import { extractWaypoints } from "./waypoint-ymap.ts";
|
||||
|
||||
interface RouteStats {
|
||||
distance?: number;
|
||||
|
|
@ -22,10 +30,7 @@ interface WaypointData {
|
|||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return waypoints.toArray().map((yMap) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
}));
|
||||
return extractWaypoints(waypoints).map((wp) => ({ lat: wp.lat, lon: wp.lon }));
|
||||
}
|
||||
|
||||
function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject<boolean>) {
|
||||
|
|
@ -72,9 +77,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
|
|||
if (!yjs || !isHost || waypoints.length < 2) return;
|
||||
|
||||
// Collect no-go areas from Yjs
|
||||
const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap) => ({
|
||||
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
|
||||
})).filter((a) => a.points.length >= 3);
|
||||
const noGoAreas: NoGoArea[] = extractNoGoAreas(yjs.noGoAreas);
|
||||
|
||||
// Save current waypoints so we can restore on failure
|
||||
const snapshotBeforeCompute = getWaypointsFromYjs(yjs.waypoints);
|
||||
|
|
@ -84,7 +87,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
|
|||
const controller = new AbortController();
|
||||
inflightAbortRef.current = controller;
|
||||
|
||||
const profile = (yjs.routeData.get("profile") as string) ?? "fastbike";
|
||||
const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE;
|
||||
const noGoHash = hashNoGoAreas(noGoAreas);
|
||||
const cache = segmentCacheRef.current;
|
||||
|
||||
|
|
@ -158,32 +161,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
|
|||
});
|
||||
|
||||
// Store enriched route data in Yjs for all participants
|
||||
yjs.doc.transact(() => {
|
||||
yjs.routeData.set("geojson", JSON.stringify(enriched.geojson));
|
||||
yjs.routeData.set("coordinates", JSON.stringify(enriched.coordinates));
|
||||
yjs.routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries));
|
||||
if (enriched.surfaces?.length) {
|
||||
yjs.routeData.set("surfaces", JSON.stringify(enriched.surfaces));
|
||||
}
|
||||
if (enriched.highways?.length) {
|
||||
yjs.routeData.set("highways", JSON.stringify(enriched.highways));
|
||||
}
|
||||
if (enriched.maxspeeds?.length) {
|
||||
yjs.routeData.set("maxspeeds", JSON.stringify(enriched.maxspeeds));
|
||||
}
|
||||
if (enriched.smoothnesses?.length) {
|
||||
yjs.routeData.set("smoothnesses", JSON.stringify(enriched.smoothnesses));
|
||||
}
|
||||
if (enriched.tracktypes?.length) {
|
||||
yjs.routeData.set("tracktypes", JSON.stringify(enriched.tracktypes));
|
||||
}
|
||||
if (enriched.cycleways?.length) {
|
||||
yjs.routeData.set("cycleways", JSON.stringify(enriched.cycleways));
|
||||
}
|
||||
if (enriched.bikeroutes?.length) {
|
||||
yjs.routeData.set("bikeroutes", JSON.stringify(enriched.bikeroutes));
|
||||
}
|
||||
});
|
||||
writeComputedRoute(yjs.doc, yjs.routeData, enriched);
|
||||
} catch (err) {
|
||||
// A superseding request aborted this one — leave state alone so
|
||||
// the newer call's result becomes authoritative.
|
||||
|
|
@ -222,7 +200,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
|
|||
|
||||
// Observe routeData for profile changes
|
||||
const profileObserver = (event: Y.YMapEvent<unknown>) => {
|
||||
if (event.keysChanged.has("profile")) {
|
||||
if (event.keysChanged.has(PROFILE_KEY)) {
|
||||
triggerRecompute();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,27 +1,17 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import type { ColorMode } from "~/components/ColoredRoute";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { snapToPoi } from "~/lib/poi-snap";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { findSegmentForPoint } from "~/components/ColoredRoute";
|
||||
import { waypointFromYMap } from "~/lib/waypoint-ymap";
|
||||
import { extractWaypointData, type WaypointData } from "~/lib/waypoint-ymap";
|
||||
import {
|
||||
getColorMode,
|
||||
readComputedRoute,
|
||||
type ColorMode,
|
||||
} from "~/lib/route-data";
|
||||
|
||||
export interface WaypointData {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
note?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return waypoints.toArray().map((yMap) => {
|
||||
const wp = waypointFromYMap(yMap);
|
||||
return { lat: wp.lat, lon: wp.lon, name: wp.name, note: wp.note, overnight: isOvernight(yMap) };
|
||||
});
|
||||
}
|
||||
export type { WaypointData };
|
||||
|
||||
function pointToSegmentDist(
|
||||
pLat: number, pLon: number,
|
||||
|
|
@ -38,11 +28,6 @@ function pointToSegmentDist(
|
|||
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
|
||||
}
|
||||
|
||||
function parseJsonArray<T>(json: string | undefined): T[] {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json); } catch { return []; }
|
||||
}
|
||||
|
||||
export interface RouteState {
|
||||
waypoints: WaypointData[];
|
||||
routeCoordinates: [number, number, number][] | null;
|
||||
|
|
@ -80,7 +65,7 @@ export function useWaypointManager(
|
|||
// Sync waypoints from Yjs
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const wps = getWaypointsFromYjs(yjs.waypoints);
|
||||
const wps = extractWaypointData(yjs.waypoints);
|
||||
setState((prev) => ({ ...prev, waypoints: wps }));
|
||||
if (wps.length >= 2 && onRouteRequest) {
|
||||
onRouteRequest(wps);
|
||||
|
|
@ -94,39 +79,12 @@ export function useWaypointManager(
|
|||
// Sync route data from Yjs
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
|
||||
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
|
||||
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||
|
||||
let routeCoordinates: [number, number, number][] | null = null;
|
||||
if (coordsJson) {
|
||||
try { routeCoordinates = JSON.parse(coordsJson); } catch { /* ignore */ }
|
||||
} else {
|
||||
// Fallback: parse from geojson for backwards compat
|
||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojson) {
|
||||
try {
|
||||
const parsed = JSON.parse(geojson);
|
||||
const coords = parsed.features?.[0]?.geometry?.coordinates;
|
||||
if (coords) {
|
||||
routeCoordinates = coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
const { coordinates, ...computed } = readComputedRoute(yjs.routeData);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
routeCoordinates,
|
||||
segmentBoundaries: parseJsonArray<number>(boundsJson),
|
||||
surfaces: parseJsonArray<string>(yjs.routeData.get("surfaces") as string | undefined),
|
||||
highways: parseJsonArray<string>(yjs.routeData.get("highways") as string | undefined),
|
||||
maxspeeds: parseJsonArray<string>(yjs.routeData.get("maxspeeds") as string | undefined),
|
||||
smoothnesses: parseJsonArray<string>(yjs.routeData.get("smoothnesses") as string | undefined),
|
||||
tracktypes: parseJsonArray<string>(yjs.routeData.get("tracktypes") as string | undefined),
|
||||
cycleways: parseJsonArray<string>(yjs.routeData.get("cycleways") as string | undefined),
|
||||
bikeroutes: parseJsonArray<string>(yjs.routeData.get("bikeroutes") as string | undefined),
|
||||
colorMode: modeVal ?? "plain",
|
||||
...computed,
|
||||
routeCoordinates: coordinates,
|
||||
colorMode: getColorMode(yjs.routeData),
|
||||
}));
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { YjsState } from "./use-yjs.ts";
|
||||
import type { PoiState } from "./use-pois.ts";
|
||||
|
||||
const YJS_KEY_POI_CATEGORIES = "poiCategories";
|
||||
import { getPoiCategories, setPoiCategories } from "./route-data.ts";
|
||||
|
||||
/**
|
||||
* Bidirectional sync between POI/overlay state and Yjs routeData.
|
||||
|
|
@ -21,7 +20,7 @@ export function useYjsPoiSync(yjs: YjsState | null, poiState: PoiState): void {
|
|||
prevCategories.current = current;
|
||||
|
||||
suppressYjsUpdate.current = true;
|
||||
yjs.routeData.set(YJS_KEY_POI_CATEGORIES, JSON.stringify(current));
|
||||
setPoiCategories(yjs.routeData, current);
|
||||
// Allow Yjs observer to fire but suppress our handler
|
||||
queueMicrotask(() => { suppressYjsUpdate.current = false; });
|
||||
}, [yjs, poiState.enabledCategories]);
|
||||
|
|
@ -33,17 +32,12 @@ export function useYjsPoiSync(yjs: YjsState | null, poiState: PoiState): void {
|
|||
const handleChange = () => {
|
||||
if (suppressYjsUpdate.current) return;
|
||||
|
||||
const raw = yjs.routeData.get(YJS_KEY_POI_CATEGORIES) as string | undefined;
|
||||
if (!raw) return;
|
||||
const categories = getPoiCategories(yjs.routeData);
|
||||
if (!categories) return;
|
||||
|
||||
try {
|
||||
const categories = JSON.parse(raw) as string[];
|
||||
if (!arraysEqual(categories, prevCategories.current)) {
|
||||
prevCategories.current = categories;
|
||||
poiState.setEnabledCategories(categories);
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON in Yjs — ignore
|
||||
if (!arraysEqual(categories, prevCategories.current)) {
|
||||
prevCategories.current = categories;
|
||||
poiState.setEnabledCategories(categories);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
77
apps/planner/app/lib/waypoint-ymap.test.ts
Normal file
77
apps/planner/app/lib/waypoint-ymap.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import type { Waypoint } from "@trails-cool/types";
|
||||
import {
|
||||
extractWaypointData,
|
||||
extractWaypoints,
|
||||
waypointFromYMap,
|
||||
waypointToYMap,
|
||||
} from "./waypoint-ymap.ts";
|
||||
|
||||
function createArray(): Y.Array<Y.Map<unknown>> {
|
||||
return new Y.Doc().getArray<Y.Map<unknown>>("waypoints");
|
||||
}
|
||||
|
||||
/** Y.Maps only expose values once integrated into a document. */
|
||||
function roundTrip(wp: Waypoint): Waypoint {
|
||||
const arr = createArray();
|
||||
arr.push([waypointToYMap(wp)]);
|
||||
return waypointFromYMap(arr.get(0)!);
|
||||
}
|
||||
|
||||
describe("waypoint round-trip", () => {
|
||||
it("preserves all fields through toYMap → fromYMap", () => {
|
||||
const wp: Waypoint = {
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
name: "Hut",
|
||||
note: "Book ahead",
|
||||
isDayBreak: true,
|
||||
osmId: 12345,
|
||||
poiTags: { tourism: "alpine_hut" },
|
||||
};
|
||||
expect(roundTrip(wp)).toEqual(wp);
|
||||
});
|
||||
|
||||
it("leaves optional fields undefined", () => {
|
||||
const wp = roundTrip({ lat: 1, lon: 2 });
|
||||
expect(wp).toEqual({
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
name: undefined,
|
||||
note: undefined,
|
||||
isDayBreak: undefined,
|
||||
osmId: undefined,
|
||||
poiTags: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWaypoints", () => {
|
||||
it("reads the whole list in order", () => {
|
||||
const arr = createArray();
|
||||
arr.push([
|
||||
waypointToYMap({ lat: 1, lon: 2, name: "A" }),
|
||||
waypointToYMap({ lat: 3, lon: 4, isDayBreak: true }),
|
||||
]);
|
||||
|
||||
const wps = extractWaypoints(arr);
|
||||
expect(wps).toHaveLength(2);
|
||||
expect(wps[0]!.name).toBe("A");
|
||||
expect(wps[1]!.isDayBreak).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWaypointData", () => {
|
||||
it("flattens isDayBreak to a plain overnight boolean", () => {
|
||||
const arr = createArray();
|
||||
arr.push([
|
||||
waypointToYMap({ lat: 1, lon: 2 }),
|
||||
waypointToYMap({ lat: 3, lon: 4, isDayBreak: true, note: "n" }),
|
||||
]);
|
||||
|
||||
const data = extractWaypointData(arr);
|
||||
expect(data[0]!.overnight).toBe(false);
|
||||
expect(data[1]!).toEqual({ lat: 3, lon: 4, name: undefined, note: "n", overnight: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -39,3 +39,27 @@ export function waypointToYMap(wp: Waypoint): Y.Map<unknown> {
|
|||
applyWaypointToYMap(yMap, wp);
|
||||
return yMap;
|
||||
}
|
||||
|
||||
/** Reads the whole shared waypoint list as plain Waypoints. */
|
||||
export function extractWaypoints(waypoints: Y.Array<Y.Map<unknown>>): Waypoint[] {
|
||||
return waypoints.toArray().map(waypointFromYMap);
|
||||
}
|
||||
|
||||
/** Waypoint flattened for UI state (isDayBreak as a plain boolean). */
|
||||
export interface WaypointData {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
note?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
export function extractWaypointData(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return extractWaypoints(waypoints).map((wp) => ({
|
||||
lat: wp.lat,
|
||||
lon: wp.lon,
|
||||
name: wp.name,
|
||||
note: wp.note,
|
||||
overnight: wp.isDayBreak === true,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { createBoss } from "./boss.ts";
|
||||
export { startWorker } from "./worker.ts";
|
||||
export type { JobDefinition } from "./types.ts";
|
||||
export { defineJob } from "./types.ts";
|
||||
export type { JobDefinition, TypedJobDefinition } from "./types.ts";
|
||||
|
|
|
|||
28
packages/jobs/src/types.test.ts
Normal file
28
packages/jobs/src/types.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { Job } from "pg-boss";
|
||||
import { defineJob } from "./types.ts";
|
||||
|
||||
describe("defineJob", () => {
|
||||
it("returns the definition unchanged (the cast is type-level only)", async () => {
|
||||
const seen: string[] = [];
|
||||
const def = defineJob<{ widgetId: string }>({
|
||||
name: "widget-job",
|
||||
retryLimit: 2,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
// job.data is typed { widgetId: string } — no cast needed
|
||||
seen.push(job.data.widgetId);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
expect(def.name).toBe("widget-job");
|
||||
expect(def.retryLimit).toBe(2);
|
||||
|
||||
// The returned JobDefinition's handler is the same function and
|
||||
// receives whatever pg-boss hands it.
|
||||
await def.handler([{ data: { widgetId: "w1" } } as Job<unknown>]);
|
||||
expect(seen).toEqual(["w1"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
import type { Job } from "pg-boss";
|
||||
|
||||
/**
|
||||
* A pg-boss job definition. The payload type is intentionally
|
||||
* `unknown` at this boundary: a heterogeneous `JobDefinition[]` array
|
||||
* (e.g. the one in `apps/journal/server.ts`) was previously forced to
|
||||
* cast each typed job to `any` because of contravariance — a handler
|
||||
* taking `Job<SpecificPayload>` is not assignable to one taking
|
||||
* `Job<object>`. Handlers narrow internally instead.
|
||||
* A pg-boss job definition. The payload type is `unknown` at this
|
||||
* boundary: a heterogeneous `JobDefinition[]` array (e.g. the one in
|
||||
* `apps/journal/server.ts`) would otherwise be impossible because of
|
||||
* contravariance — a handler taking `Job<SpecificPayload>` is not
|
||||
* assignable to one taking `Job<object>`.
|
||||
*
|
||||
* Don't write handlers against this type directly; author them with
|
||||
* `defineJob`, which keeps the payload typed inside the handler and
|
||||
* performs the contravariance cast once, here in the package.
|
||||
*/
|
||||
export interface JobDefinition {
|
||||
name: string;
|
||||
|
|
@ -15,3 +18,23 @@ export interface JobDefinition {
|
|||
retryLimit?: number;
|
||||
expireInSeconds?: number;
|
||||
}
|
||||
|
||||
/** A JobDefinition whose handler sees the payload type it was enqueued with. */
|
||||
export interface TypedJobDefinition<TPayload> {
|
||||
name: string;
|
||||
handler: (jobs: Job<TPayload>[]) => Promise<unknown>;
|
||||
cron?: string;
|
||||
retryLimit?: number;
|
||||
expireInSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Author a job with a typed payload. The handler receives
|
||||
* `Job<TPayload>[]` — no `job.data as ...` casts at the call sites.
|
||||
* This is the single place the contravariance cast happens.
|
||||
*/
|
||||
export function defineJob<TPayload = void>(
|
||||
definition: TypedJobDefinition<TPayload>,
|
||||
): JobDefinition {
|
||||
return definition as unknown as JobDefinition;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue