Apply demo-activity-bot: Bruno, the synthetic public-walk generator

Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:14:58 +02:00
parent 7304a9e904
commit ba4c4e1b4f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 1003 additions and 43 deletions

View file

@ -0,0 +1,60 @@
import type { JobDefinition } from "@trails-cool/jobs";
import {
DEMO_BACKFILL_TARGET,
DEMO_DAILY_CAP,
countSyntheticRoutesRecent,
countSyntheticRoutesTotal,
ensureDemoUser,
generateOneWalk,
isDemoBotEnabled,
refreshDemoBotGauges,
shouldWalkNow,
} from "../lib/demo-bot.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Generate job. Fires every 90 minutes via pg-boss cron. Handler steps:
*
* 1. Bail if `DEMO_BOT_ENABLED` is not "true".
* 2. On first run (no synthetic rows yet) produce a small backfill so the
* demo user's profile has content immediately.
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.12) and
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
*/
export const demoBotGenerateJob: JobDefinition = {
name: "demo-bot:generate",
cron: "*/90 * * * *",
retryLimit: 1,
expireInSeconds: 120,
async handler() {
if (!isDemoBotEnabled()) return { skipped: "disabled" };
const ownerId = await ensureDemoUser();
const total = await countSyntheticRoutesTotal();
if (total === 0) {
let inserted = 0;
for (let i = 0; i < DEMO_BACKFILL_TARGET; i++) {
const id = await generateOneWalk(ownerId);
if (id) inserted++;
}
logger.info({ inserted }, "demo-bot backfill complete");
await refreshDemoBotGauges();
return { mode: "backfill", inserted };
}
const recent = await countSyntheticRoutesRecent(14);
if (recent >= DEMO_DAILY_CAP) {
logger.info({ recent, cap: DEMO_DAILY_CAP }, "demo-bot cap hit, skipping");
return { skipped: "cap", recent };
}
const now = new Date();
if (!shouldWalkNow(now)) return { skipped: "gate" };
const id = await generateOneWalk(ownerId, { now });
logger.info({ inserted: id ? 1 : 0, routeId: id }, "demo-bot walk");
await refreshDemoBotGauges();
return { mode: "single", routeId: id };
},
};

View file

@ -0,0 +1,27 @@
import type { JobDefinition } from "@trails-cool/jobs";
import {
demoRetentionDays,
isDemoBotEnabled,
pruneSynthetic,
refreshDemoBotGauges,
} from "../lib/demo-bot.server.ts";
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 = {
name: "demo-bot:prune",
cron: "15 3 * * *",
retryLimit: 1,
expireInSeconds: 60,
async handler() {
if (!isDemoBotEnabled()) return { skipped: "disabled" };
const days = demoRetentionDays();
const counts = await pruneSynthetic(days);
logger.info({ days, ...counts }, "demo-bot prune");
await refreshDemoBotGauges();
return { days, ...counts };
},
};

View file

@ -0,0 +1,125 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import { sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
import {
DEMO_USERNAME,
ensureDemoUser,
generateOneWalk,
pruneSynthetic,
} from "./demo-bot.server.ts";
// Opt-in: these tests talk to a real Postgres and are skipped unless
// `DEMO_BOT_INTEGRATION=1` is set. CI gates them behind the same flag
// so we don't break laptop runs that have no Postgres up.
const runIntegration = process.env.DEMO_BOT_INTEGRATION === "1";
// A tiny valid GPX that parseGpxAsync can chew on. Two trkpts in Berlin,
// ~2 km apart — enough to pass the 500 m min-distance gate in
// generateOneWalk.
const STUB_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="demo-bot-test" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34</ele></trkpt>
<trkpt lat="52.5300" lon="13.4200"><ele>38</ele></trkpt>
<trkpt lat="52.5400" lon="13.4350"><ele>40</ele></trkpt>
</trkseg></trk>
</gpx>`;
async function wipeSynthetic() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.activities WHERE synthetic = true`);
await db.execute(sql`DELETE FROM journal.routes WHERE synthetic = true`);
}
describe.skipIf(!runIntegration)("demo-bot integration", () => {
beforeAll(async () => {
// Sanity: make sure we can reach the DB and synthetic column exists.
const db = getDb();
await db.execute(sql`SELECT 1 FROM journal.routes WHERE synthetic IS NOT NULL LIMIT 0`);
});
afterEach(async () => {
await wipeSynthetic();
vi.restoreAllMocks();
});
it("ensureDemoUser is idempotent", async () => {
const id1 = await ensureDemoUser();
const id2 = await ensureDemoUser();
expect(id1).toBe(id2);
const db = getDb();
const rows = await db
.select()
.from(users)
.where(sql`${users.username} = ${DEMO_USERNAME}`);
expect(rows.length).toBe(1);
});
it("generateOneWalk inserts a public synthetic route + activity", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => STUB_GPX }),
);
const ownerId = await ensureDemoUser();
const routeId = await generateOneWalk(ownerId);
expect(routeId).toBeTruthy();
const db = getDb();
const [route] = await db
.select()
.from(routes)
.where(sql`${routes.id} = ${routeId!}`);
expect(route?.synthetic).toBe(true);
expect(route?.visibility).toBe("public");
expect(route?.routingProfile).toBe("trekking");
expect(route?.ownerId).toBe(ownerId);
const acts = await db
.select()
.from(activities)
.where(sql`${activities.routeId} = ${routeId!}`);
expect(acts.length).toBe(1);
expect(acts[0]?.synthetic).toBe(true);
expect(acts[0]?.visibility).toBe("public");
expect(acts[0]?.duration).toBeGreaterThan(0);
});
it("pruneSynthetic only removes synthetic rows past the window", async () => {
const ownerId = await ensureDemoUser();
const db = getDb();
// One synthetic route that is 30 days old, one that is fresh.
await db.execute(sql`
INSERT INTO journal.routes
(id, owner_id, name, synthetic, visibility, created_at, updated_at)
VALUES
(gen_random_uuid()::text, ${ownerId}, 'old-synth', true, 'public', now() - interval '30 days', now() - interval '30 days'),
(gen_random_uuid()::text, ${ownerId}, 'fresh-synth', true, 'public', now(), now())
`);
// A non-synthetic route that is also 30 days old — must be kept.
await db.execute(sql`
INSERT INTO journal.routes
(id, owner_id, name, synthetic, visibility, created_at, updated_at)
VALUES
(gen_random_uuid()::text, ${ownerId}, 'old-real', false, 'private', now() - interval '30 days', now() - interval '30 days')
`);
const counts = await pruneSynthetic(14);
expect(counts.routes).toBe(1);
const remaining = await db
.select({ name: routes.name, synthetic: routes.synthetic })
.from(routes)
.where(sql`${routes.ownerId} = ${ownerId} AND ${routes.name} IN ('old-synth','fresh-synth','old-real')`);
const names = remaining.map((r) => r.name).sort();
expect(names).toEqual(["fresh-synth", "old-real"]);
// Clean up the non-synthetic test row
await db.execute(sql`DELETE FROM journal.routes WHERE owner_id = ${ownerId} AND name = 'old-real'`);
});
});

View file

@ -0,0 +1,438 @@
import { randomUUID } from "node:crypto";
import { and, eq, lt, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
import { setGeomFromGpx } from "./routes.server.ts";
import { TERMS_VERSION } from "./legal.ts";
import { parseGpxAsync } from "@trails-cool/gpx";
import { logger } from "./logger.server.ts";
import {
demoBotSyntheticActivitiesTotal,
demoBotSyntheticRoutesTotal,
} from "./metrics.server.ts";
export const DEMO_USERNAME = "bruno";
export const DEMO_DISPLAY_NAME = "Bruno";
export const DEMO_BIO =
"Professional park inspector. Currently accepting tennis balls.";
/**
* Inner Berlin (Tiergarten Grunewald corridor). Keeps Bruno's walks
* somewhere plausible for an urban dog and makes templated copy work.
* Override with `DEMO_BOT_REGION='{"bbox":[w,s,e,n]}'`.
*/
const DEFAULT_BBOX: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60];
export function isDemoBotEnabled(): boolean {
return process.env.DEMO_BOT_ENABLED === "true";
}
export function demoRetentionDays(): number {
const raw = process.env.DEMO_BOT_RETENTION_DAYS;
if (!raw) return 14;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : 14;
}
/**
* Idempotent insert of the Bruno demo user. Safe to call every worker
* boot; relies on `users.username` uniqueness to short-circuit duplicates.
*/
export async function ensureDemoUser(): Promise<string> {
const db = getDb();
const domain = process.env.DOMAIN ?? "localhost";
const [existing] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
if (existing) return existing.id;
const id = randomUUID();
await db
.insert(users)
.values({
id,
username: DEMO_USERNAME,
email: `${DEMO_USERNAME}@${domain}`,
displayName: DEMO_DISPLAY_NAME,
bio: DEMO_BIO,
domain,
termsAcceptedAt: new Date(),
termsVersion: TERMS_VERSION,
})
.onConflictDoNothing({ target: users.username });
// Re-read in case a concurrent insert won the race
const [row] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found");
return row.id;
}
export interface Region {
bbox: [number, number, number, number];
}
export function loadRegion(): Region {
const raw = process.env.DEMO_BOT_REGION;
if (!raw) return { bbox: DEFAULT_BBOX };
try {
const parsed = JSON.parse(raw) as { bbox?: unknown };
if (
Array.isArray(parsed.bbox) &&
parsed.bbox.length === 4 &&
parsed.bbox.every((n) => typeof n === "number")
) {
return { bbox: parsed.bbox as [number, number, number, number] };
}
} catch {
// fall through
}
logger.warn({ raw }, "DEMO_BOT_REGION invalid, using default");
return { bbox: DEFAULT_BBOX };
}
/**
* Great-circle distance in metres between two (lon, lat) points.
*/
function haversineMeters(a: [number, number], b: [number, number]): number {
const R = 6_371_000;
const [lon1, lat1] = a;
const [lon2, lat2] = b;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function randInBbox(bbox: [number, number, number, number]): [number, number] {
const [w, s, e, n] = bbox;
return [w + Math.random() * (e - w), s + Math.random() * (n - s)];
}
export interface Endpoints {
start: [number, number];
end: [number, number];
}
/**
* Random start + end inside the bbox such that the crow-distance is in
* the dog-walk band (212 km). Gives up after N tries; on exhaustion
* returns whatever it has so the caller can decide (BRouter will likely
* succeed anyway, just with shorter/longer routes).
*/
export function pickEndpoints(bbox: [number, number, number, number]): Endpoints {
const MIN = 2_000;
const MAX = 12_000;
for (let i = 0; i < 50; i++) {
const start = randInBbox(bbox);
const end = randInBbox(bbox);
const d = haversineMeters(start, end);
if (d >= MIN && d <= MAX) return { start, end };
}
const start = randInBbox(bbox);
const end = randInBbox(bbox);
return { start, end };
}
// --- Bruno-voiced copy --------------------------------------------------
const NAME_POOL_EN = [
"Grunewald north-loop patrol",
"Tiergarten perimeter audit",
"Tempelhof runway inspection",
"Spree embankment survey",
"Bruno's morning rounds",
"Sniff-check: Kreuzberg edition",
"Bruno found three sticks today",
"Dog-audit report: all squirrels present",
"Volkspark compliance walk",
"Tennis ball reconnaissance",
"Pretzel-crumb cleanup detail",
"Bruno vs. the pigeons",
];
const NAME_POOL_DE = [
"Grunewald-Nordschleife-Patrouille",
"Tiergarten-Umfangsprüfung",
"Tempelhof-Startbahn-Inspektion",
"Spreeufer-Rundgang",
"Brunos Morgenrunde",
"Schnüffelkontrolle: Kreuzberg",
"Bruno hat heute drei Stöcke gefunden",
"Hundeprüfbericht: alle Eichhörnchen anwesend",
"Volkspark-Konformitätsgang",
"Tennisball-Aufklärung",
"Brezel-Krümel-Aufräumdienst",
"Bruno gegen die Tauben",
];
const DESCRIPTION_POOL_EN = [
"Inspected several bushes. All present and accounted for.",
"Three squirrels successfully monitored. Two got away.",
"Good sticks: 2. Great sticks: 1. Excellent sticks: 0.",
"Pace was brisk. Sniffs were thorough.",
"Encountered another dog. Diplomacy established.",
"Weather: windy. Ears: flopping.",
"Pigeons stood their ground. A follow-up visit is required.",
"Finished the route ahead of schedule. Extra treats expected.",
"Investigated one suspicious paper bag. False alarm.",
"Logged one (1) successful puddle inspection.",
];
const DESCRIPTION_POOL_DE = [
"Mehrere Büsche inspiziert. Alle vorhanden.",
"Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.",
"Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.",
"Tempo zügig. Schnüffeln gründlich.",
"Einen anderen Hund getroffen. Diplomatie hergestellt.",
"Wetter: windig. Ohren: flatternd.",
"Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.",
"Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.",
"Eine verdächtige Papiertüte untersucht. Fehlalarm.",
"Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.",
];
function pickFrom<T>(pool: readonly T[], seed: number): T {
const idx = Math.abs(Math.floor(seed)) % pool.length;
return pool[idx]!;
}
/**
* Deterministic-ish per day + per started-at minute, so same-day walks
* don't land on the same name. Locale is chosen from the journal's
* default Bruno doesn't know German, but his walks come with German
* subtitles when the reader expects them.
*/
export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string {
const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN;
const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes();
return pickFrom(pool, seed);
}
export function templateDescription(
startedAt: Date,
locale: "en" | "de" = "en",
): string {
const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN;
const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7;
return pickFrom(pool, seed);
}
// --- BRouter --------------------------------------------------------------
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
export interface BrouterResult {
gpx: string;
}
export type BrouterError =
| { kind: "no-route" }
| { kind: "timeout" }
| { kind: "upstream-error"; status?: number };
export async function requestBrouterGpx(
endpoints: Endpoints,
{ signal }: { signal?: AbortSignal } = {},
): Promise<BrouterResult | BrouterError> {
const lonlats = `${endpoints.start[0]},${endpoints.start[1]}|${endpoints.end[0]},${endpoints.end[1]}`;
const url = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=trekking&alternativeidx=0&format=gpx`;
try {
const resp = await fetch(url, { signal });
if (!resp.ok) {
if (resp.status === 500) return { kind: "no-route" };
return { kind: "upstream-error", status: resp.status };
}
const gpx = await resp.text();
if (!gpx.includes("<trkpt")) return { kind: "no-route" };
return { gpx };
} catch (err) {
if ((err as Error).name === "AbortError") return { kind: "timeout" };
return { kind: "upstream-error" };
}
}
// --- Generation + retention ----------------------------------------------
export interface GenerateOptions {
now?: Date;
/** Localise the walk name; defaults to random-ish. */
locale?: "en" | "de";
}
/**
* Core generate step: insert one Bruno route + activity pair. Callers
* (the recurring job and the backfill loop) decide whether to invoke it.
*
* Returns the inserted route id on success, or `null` on BRouter failure
* or DB failure. Never throws the worker logs and moves on.
*/
export async function generateOneWalk(
ownerId: string,
{ now = new Date(), locale = Math.random() < 0.5 ? "en" : "de" }: GenerateOptions = {},
): Promise<string | null> {
const region = loadRegion();
const endpoints = pickEndpoints(region.bbox);
const result = await requestBrouterGpx(endpoints);
if ("kind" in result) {
logger.info({ endpoints, err: result.kind }, "demo-bot brouter miss");
return null;
}
const name = templateName(now, locale);
const description = templateDescription(now, locale);
let distance: number;
let elevationGain: number;
let elevationLoss: number;
try {
const parsed = await parseGpxAsync(result.gpx);
distance = parsed.distance;
elevationGain = parsed.elevation.gain;
elevationLoss = parsed.elevation.loss;
} catch {
return null;
}
if (!distance || distance < 500) return null;
const db = getDb();
const routeId = randomUUID();
const activityId = randomUUID();
await db.insert(routes).values({
id: routeId,
ownerId,
name,
description,
gpx: result.gpx,
routingProfile: "trekking",
distance,
elevationGain,
elevationLoss,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(routeId, "routes", result.gpx);
const walkingMetersPerSecond = 4.5 * 1000 / 3600; // ~4.5 km/h
const jitter = 0.85 + Math.random() * 0.3; // ±15 %
const durationSeconds = Math.round((distance / walkingMetersPerSecond) * jitter);
await db.insert(activities).values({
id: activityId,
ownerId,
routeId,
name,
description,
gpx: result.gpx,
startedAt: now,
duration: durationSeconds,
distance,
elevationGain,
elevationLoss,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(activityId, "activities", result.gpx);
return routeId;
}
export async function countSyntheticRoutesRecent(days: number): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.routes
WHERE synthetic = true AND created_at > now() - make_interval(days => ${days})`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
export async function countSyntheticRoutesTotal(): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.routes WHERE synthetic = true`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
async function countSyntheticActivitiesTotal(): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.activities WHERE synthetic = true`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
/**
* Refresh the prometheus gauges. Called by the job handlers so the
* worker process exposes fresh counts at scrape time without needing a
* periodic poller.
*/
export async function refreshDemoBotGauges(): Promise<void> {
const [routesCount, activitiesCount] = await Promise.all([
countSyntheticRoutesTotal(),
countSyntheticActivitiesTotal(),
]);
demoBotSyntheticRoutesTotal.set(routesCount);
demoBotSyntheticActivitiesTotal.set(activitiesCount);
}
/**
* Delete synthetic routes + activities older than `days`. Never touches
* non-synthetic rows.
*/
export async function pruneSynthetic(days: number): Promise<{ routes: number; activities: number }> {
const db = getDb();
const cutoff = sql`now() - make_interval(days => ${days})`;
const actDeleted = await db
.delete(activities)
.where(and(eq(activities.synthetic, true), lt(activities.createdAt, cutoff)))
.returning({ id: activities.id });
const routeDeleted = await db
.delete(routes)
.where(and(eq(routes.synthetic, true), lt(routes.createdAt, cutoff)))
.returning({ id: routes.id });
return { routes: routeDeleted.length, activities: actDeleted.length };
}
/**
* Compute the local hour in Europe/Berlin without pulling in a tz library
* we only need to gate the decide-to-walk window, which is quite tolerant
* to DST drift.
*/
export function berlinHour(now: Date): number {
const s = now.toLocaleString("en-US", {
timeZone: "Europe/Berlin",
hour12: false,
hour: "2-digit",
});
return Number.parseInt(s, 10);
}
/**
* The decide-to-walk gate: only within 0721 local, Bernoulli p=0.12.
* Factored out so tests can replace `Math.random` / `now`.
*/
export function shouldWalkNow(now: Date, rand: () => number = Math.random): boolean {
const hour = berlinHour(now);
if (hour < 7 || hour >= 21) return false;
return rand() < 0.12;
}
export const DEMO_DAILY_CAP = 40;
export const DEMO_BACKFILL_TARGET = 4;

View file

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
berlinHour,
loadRegion,
pickEndpoints,
shouldWalkNow,
templateDescription,
templateName,
} from "./demo-bot.server.ts";
describe("pickEndpoints", () => {
const bbox: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60];
it("returns points inside the bbox", () => {
for (let i = 0; i < 20; i++) {
const { start, end } = pickEndpoints(bbox);
expect(start[0]).toBeGreaterThanOrEqual(bbox[0]);
expect(start[0]).toBeLessThanOrEqual(bbox[2]);
expect(start[1]).toBeGreaterThanOrEqual(bbox[1]);
expect(start[1]).toBeLessThanOrEqual(bbox[3]);
expect(end[0]).toBeGreaterThanOrEqual(bbox[0]);
expect(end[0]).toBeLessThanOrEqual(bbox[2]);
expect(end[1]).toBeGreaterThanOrEqual(bbox[1]);
expect(end[1]).toBeLessThanOrEqual(bbox[3]);
}
});
it("produces distinct endpoints", () => {
for (let i = 0; i < 10; i++) {
const { start, end } = pickEndpoints(bbox);
expect(start).not.toEqual(end);
}
});
});
describe("templateName / templateDescription", () => {
it("returns non-empty strings in both locales", () => {
const d = new Date("2026-04-17T09:00:00Z");
expect(templateName(d, "en").length).toBeGreaterThan(0);
expect(templateName(d, "de").length).toBeGreaterThan(0);
expect(templateDescription(d, "en").length).toBeGreaterThan(0);
expect(templateDescription(d, "de").length).toBeGreaterThan(0);
});
it("returns different entries for different seeds", () => {
const a = templateName(new Date("2026-04-17T09:00:00Z"), "en");
const b = templateName(new Date("2026-04-18T10:01:00Z"), "en");
// Not strictly required, but with 12 entries and different seeds it
// would be surprising if every pair collided — this is a smoke check.
expect(typeof a).toBe("string");
expect(typeof b).toBe("string");
});
});
describe("loadRegion", () => {
const envKey = "DEMO_BOT_REGION";
it("falls back to the default Berlin bbox when unset", () => {
const saved = process.env[envKey];
delete process.env[envKey];
try {
const r = loadRegion();
expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]);
} finally {
if (saved !== undefined) process.env[envKey] = saved;
}
});
it("parses a valid JSON override", () => {
const saved = process.env[envKey];
process.env[envKey] = JSON.stringify({ bbox: [0, 0, 1, 1] });
try {
const r = loadRegion();
expect(r.bbox).toEqual([0, 0, 1, 1]);
} finally {
if (saved === undefined) delete process.env[envKey];
else process.env[envKey] = saved;
}
});
it("falls back on invalid JSON", () => {
const saved = process.env[envKey];
process.env[envKey] = "{not json";
try {
const r = loadRegion();
expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]);
} finally {
if (saved === undefined) delete process.env[envKey];
else process.env[envKey] = saved;
}
});
});
describe("shouldWalkNow", () => {
// 10:00 Berlin ≈ 08:00 UTC in winter / 09:00 summer. Both are inside 07-21.
const berlinMidday = new Date("2026-06-17T10:00:00Z");
// 03:00 Berlin ≈ 01:00 UTC summer — outside the window.
const berlinNight = new Date("2026-06-17T01:00:00Z");
it("rejects times outside the 07-21 window regardless of the random roll", () => {
expect(shouldWalkNow(berlinNight, () => 0)).toBe(false);
});
it("accepts inside the window when the random roll succeeds", () => {
expect(shouldWalkNow(berlinMidday, () => 0)).toBe(true);
});
it("rejects inside the window when the random roll fails", () => {
expect(shouldWalkNow(berlinMidday, () => 0.99)).toBe(false);
});
});
describe("berlinHour", () => {
it("reports a plausible hour", () => {
const h = berlinHour(new Date("2026-06-17T10:00:00Z"));
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThanOrEqual(23);
});
});

View file

@ -10,4 +10,19 @@ export const httpRequestDuration = new client.Histogram({
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
/**
* Count of synthetic demo-bot routes currently in the database. Scraped
* by the background worker after each generation run so the Grafana
* board stays live without a /metrics handler on the worker process.
*/
export const demoBotSyntheticRoutesTotal = new client.Gauge({
name: "demo_bot_synthetic_routes_total",
help: "Total synthetic demo-bot routes currently stored",
});
export const demoBotSyntheticActivitiesTotal = new client.Gauge({
name: "demo_bot_synthetic_activities_total",
help: "Total synthetic demo-bot activities currently stored",
});
export const registry = client.register;

View file

@ -85,6 +85,8 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, routes, activities, isOwn } = loaderData;
const { t } = useTranslation("journal");
const isDemo = user.username === "bruno";
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start gap-4">
@ -92,9 +94,16 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
{user.username[0]?.toUpperCase()}
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{user.displayName ?? user.username}
</h1>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-900">
{user.displayName ?? user.username}
</h1>
{isDemo && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
{t("demo.badge")}
</span>
)}
</div>
<p className="text-sm text-gray-500">
@{user.username}@{user.domain}
</p>

View file

@ -108,8 +108,21 @@ server.listen(port, async () => {
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
// Start background job worker (no jobs yet — placeholder for Komoot import and federation)
// Start background job worker
const { demoBotGenerateJob } = await import("./app/jobs/demo-bot-generate.ts");
const { demoBotPruneJob } = await import("./app/jobs/demo-bot-prune.ts");
const boss = createBoss(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails");
await startWorker(boss, []);
await startWorker(boss, [demoBotGenerateJob, demoBotPruneJob]);
logger.info("Background job worker started");
// Bootstrap the demo user when the bot is enabled; cheap idempotent insert.
if (process.env.DEMO_BOT_ENABLED === "true") {
const { ensureDemoUser } = await import("./app/lib/demo-bot.server.ts");
try {
const id = await ensureDemoUser();
logger.info({ id }, "demo-bot user ensured");
} catch (err) {
logger.error({ err }, "demo-bot ensureDemoUser failed");
}
}
});