Merge pull request #257 from trails-cool/feat/demo-activity-bot
Apply demo-activity-bot: Bruno the synthetic walker
This commit is contained in:
commit
f447852d70
16 changed files with 1003 additions and 43 deletions
60
apps/journal/app/jobs/demo-bot-generate.ts
Normal file
60
apps/journal/app/jobs/demo-bot-generate.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
27
apps/journal/app/jobs/demo-bot-prune.ts
Normal file
27
apps/journal/app/jobs/demo-bot-prune.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
125
apps/journal/app/lib/demo-bot.integration.test.ts
Normal file
125
apps/journal/app/lib/demo-bot.integration.test.ts
Normal 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'`);
|
||||||
|
});
|
||||||
|
});
|
||||||
438
apps/journal/app/lib/demo-bot.server.ts
Normal file
438
apps/journal/app/lib/demo-bot.server.ts
Normal 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 (2–12 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 07–21 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;
|
||||||
119
apps/journal/app/lib/demo-bot.test.ts
Normal file
119
apps/journal/app/lib/demo-bot.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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],
|
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;
|
export const registry = client.register;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, routes, activities, isOwn } = loaderData;
|
const { user, routes, activities, isOwn } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
const { t } = useTranslation("journal");
|
||||||
|
|
||||||
|
const isDemo = user.username === "bruno";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
|
|
@ -92,9 +94,16 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||||
{user.username[0]?.toUpperCase()}
|
{user.username[0]?.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<div className="flex items-center gap-2">
|
||||||
{user.displayName ?? user.username}
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
</h1>
|
{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">
|
<p className="text-sm text-gray-500">
|
||||||
@{user.username}@{user.domain}
|
@{user.username}@{user.domain}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,21 @@ server.listen(port, async () => {
|
||||||
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
const { seedOAuthClient } = await import("./app/lib/oauth.server.ts");
|
||||||
await seedOAuthClient("trails-cool-mobile", "trailscool://auth/callback", true);
|
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");
|
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");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
116
e2e/demo-bot.test.ts
Normal file
116
e2e/demo-bot.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
// Demo-bot UX: a browser-side verification that a pre-seeded `bruno`
|
||||||
|
// profile renders the demo badge and surfaces his public content to an
|
||||||
|
// anonymous visitor. We seed directly via SQL rather than running the
|
||||||
|
// pg-boss handler, because the handler depends on BRouter and on the
|
||||||
|
// demo flag being live in the server process. The point of this test
|
||||||
|
// is the *public surface*, not the generation path (that's covered in
|
||||||
|
// app/lib/demo-bot.integration.test.ts).
|
||||||
|
|
||||||
|
const CONN = process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails";
|
||||||
|
|
||||||
|
async function seedBrunoWithSyntheticRoute(): Promise<{ username: string; routeName: string }> {
|
||||||
|
const sql = postgres(CONN, { max: 1 });
|
||||||
|
const username = `bruno-e2e-${Date.now()}`;
|
||||||
|
const routeName = `Bruno's ${Date.now()} walk`;
|
||||||
|
const userId = randomUUID();
|
||||||
|
const routeId = randomUUID();
|
||||||
|
try {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
|
||||||
|
VALUES (${userId}, ${`${username}@example.com`}, ${username}, 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
|
||||||
|
VALUES (${routeId}, ${userId}, ${routeName}, 'Sniffed three bushes.', 'public', true, 3200, now(), now())
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
return { username, routeName };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupBruno(username: string) {
|
||||||
|
const sql = postgres(CONN, { max: 1 });
|
||||||
|
try {
|
||||||
|
const [row] = await sql<{ id: string }[]>`SELECT id FROM journal.users WHERE username = ${username}`;
|
||||||
|
if (!row) return;
|
||||||
|
await sql`DELETE FROM journal.activities WHERE owner_id = ${row.id}`;
|
||||||
|
await sql`DELETE FROM journal.routes WHERE owner_id = ${row.id}`;
|
||||||
|
await sql`DELETE FROM journal.users WHERE id = ${row.id}`;
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Demo-bot UX", () => {
|
||||||
|
test("seeded bruno profile shows demo badge + public route to anon visitor", async ({ browser }) => {
|
||||||
|
const { username, routeName } = await seedBrunoWithSyntheticRoute();
|
||||||
|
try {
|
||||||
|
const anonCtx = await browser.newContext();
|
||||||
|
const anon = await anonCtx.newPage();
|
||||||
|
const resp = await anon.goto(`/users/${username}`);
|
||||||
|
expect(resp?.status()).toBe(200);
|
||||||
|
|
||||||
|
await expect(anon.getByRole("heading", { name: /Bruno/ })).toBeVisible();
|
||||||
|
// The demo badge is only rendered when username === 'bruno' — our
|
||||||
|
// seeded username includes a timestamp suffix, so the badge
|
||||||
|
// shouldn't appear. This is an intentional guard: only the *real*
|
||||||
|
// bruno gets the badge. The rest of the page still works.
|
||||||
|
await expect(anon.getByText("🐕 Demo account")).toHaveCount(0);
|
||||||
|
|
||||||
|
// Public synthetic route is listed on the profile.
|
||||||
|
await expect(anon.getByRole("link", { name: new RegExp(routeName) })).toBeVisible();
|
||||||
|
await anonCtx.close();
|
||||||
|
} finally {
|
||||||
|
await cleanupBruno(username);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("profile rendered at /users/bruno shows the demo badge when username is exactly 'bruno'", async ({ browser }) => {
|
||||||
|
const sql = postgres(CONN, { max: 1 });
|
||||||
|
const userId = randomUUID();
|
||||||
|
const routeId = randomUUID();
|
||||||
|
// Use ON CONFLICT so a pre-existing prod `bruno` user doesn't block
|
||||||
|
// the test; we don't clean up a row we didn't create.
|
||||||
|
const created = await sql`
|
||||||
|
INSERT INTO journal.users (id, email, username, display_name, bio, domain, terms_accepted_at, terms_version)
|
||||||
|
VALUES (${userId}, 'bruno@localhost', 'bruno', 'Bruno', 'Professional park inspector.', 'localhost', now(), '2026-04-19')
|
||||||
|
ON CONFLICT (username) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const weCreatedIt = created.length > 0;
|
||||||
|
const effectiveUserId = weCreatedIt ? userId : (await sql`SELECT id FROM journal.users WHERE username='bruno'`)[0]!.id as string;
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO journal.routes (id, owner_id, name, description, visibility, synthetic, distance, created_at, updated_at)
|
||||||
|
VALUES (${routeId}, ${effectiveUserId}, 'Grunewald patrol', 'Sniffed three bushes.', 'public', true, 3200, now(), now())
|
||||||
|
`;
|
||||||
|
await sql.end();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anonCtx = await browser.newContext();
|
||||||
|
const anon = await anonCtx.newPage();
|
||||||
|
const resp = await anon.goto(`/users/bruno`);
|
||||||
|
expect(resp?.status()).toBe(200);
|
||||||
|
await expect(anon.getByText(/Demo account|Demo-Konto/)).toBeVisible();
|
||||||
|
await expect(anon.getByRole("link", { name: /Grunewald patrol/ })).toBeVisible();
|
||||||
|
await anonCtx.close();
|
||||||
|
} finally {
|
||||||
|
const cleanup = postgres(CONN, { max: 1 });
|
||||||
|
try {
|
||||||
|
await cleanup`DELETE FROM journal.routes WHERE id = ${routeId}`;
|
||||||
|
if (weCreatedIt) {
|
||||||
|
await cleanup`DELETE FROM journal.users WHERE id = ${userId}`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await cleanup.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,3 +6,11 @@ POSTGRES_PASSWORD=change-me
|
||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
S3_ACCESS_KEY=
|
S3_ACCESS_KEY=
|
||||||
S3_SECRET_KEY=
|
S3_SECRET_KEY=
|
||||||
|
|
||||||
|
# Demo-activity-bot (Bruno). Only enable in prod. When true, the journal
|
||||||
|
# worker bootstraps a `bruno` user and generates public demo routes +
|
||||||
|
# activities every 90 min. Set DEMO_BOT_REGION to override the default
|
||||||
|
# Berlin bbox — JSON {"bbox":[w,s,e,n]}. Retention is in days (default 14).
|
||||||
|
# DEMO_BOT_ENABLED=true
|
||||||
|
# DEMO_BOT_RETENTION_DAYS=14
|
||||||
|
# DEMO_BOT_REGION={"bbox":[13.25,52.45,13.55,52.60]}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,13 @@ services:
|
||||||
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
||||||
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
||||||
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
||||||
|
# Demo-activity-bot (Bruno). Disabled by default everywhere; flip to
|
||||||
|
# "true" only in prod to let the background worker generate public
|
||||||
|
# demo routes + activities and prune old ones. Region is a JSON blob
|
||||||
|
# `{"bbox":[w,s,e,n]}`; retention is in days (default 14).
|
||||||
|
DEMO_BOT_ENABLED: ${DEMO_BOT_ENABLED:-}
|
||||||
|
DEMO_BOT_RETENTION_DAYS: ${DEMO_BOT_RETENTION_DAYS:-}
|
||||||
|
DEMO_BOT_REGION: ${DEMO_BOT_REGION:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,73 @@
|
||||||
## 1. Schema
|
## 1. Schema
|
||||||
|
|
||||||
- [ ] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
|
- [x] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
|
||||||
- [ ] 1.2 Add the same column to `journal.activities`
|
- [x] 1.2 Add the same column to `journal.activities`
|
||||||
- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
|
- [x] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
|
||||||
|
|
||||||
## 2. Bruno bootstrap
|
## 2. Bruno bootstrap
|
||||||
|
|
||||||
- [ ] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@<DOMAIN>`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls.").
|
- [x] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@<DOMAIN>`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls.").
|
||||||
- [ ] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated.
|
- [x] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated.
|
||||||
- [ ] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
|
- [x] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
|
||||||
- [ ] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@<DOMAIN>`).
|
- [x] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@<DOMAIN>`).
|
||||||
|
|
||||||
## 3. Region + generation helpers
|
## 3. Region + generation helpers
|
||||||
|
|
||||||
- [ ] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]`
|
- [x] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]`
|
||||||
- [ ] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale)
|
- [x] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale)
|
||||||
- [ ] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
|
- [x] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
|
||||||
- [ ] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
|
- [x] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
|
||||||
|
|
||||||
## 4. BRouter client for the bot
|
## 4. BRouter client for the bot
|
||||||
|
|
||||||
- [ ] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats
|
- [x] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats
|
||||||
- [ ] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker
|
- [x] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker
|
||||||
|
|
||||||
## 5. Generation job
|
## 5. Generation job
|
||||||
|
|
||||||
- [ ] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
|
- [x] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
|
||||||
- [ ] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
|
- [x] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
|
||||||
- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day
|
- [x] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day
|
||||||
- [ ] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
|
- [x] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
|
||||||
- [ ] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
|
- [x] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
|
||||||
- [ ] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX
|
- [x] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX
|
||||||
|
|
||||||
## 6. Initial backfill
|
## 6. Initial backfill
|
||||||
|
|
||||||
- [ ] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going)
|
- [x] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going)
|
||||||
- [ ] 6.2 After the backfill path, fall through to the normal single-item generation
|
- [x] 6.2 After the backfill path, fall through to the normal single-item generation
|
||||||
|
|
||||||
## 7. Retention job
|
## 7. Retention job
|
||||||
|
|
||||||
- [ ] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton
|
- [x] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton
|
||||||
- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
|
- [x] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
|
||||||
- [ ] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
|
- [x] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
|
||||||
- [ ] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
|
- [x] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
|
||||||
- [ ] 7.5 Log the count removed for observability
|
- [x] 7.5 Log the count removed for observability
|
||||||
|
|
||||||
## 8. Ops + env surface
|
## 8. Ops + env surface
|
||||||
|
|
||||||
- [ ] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/secrets.app.env`'s commented template (do not enable in any environment file except prod)
|
- [x] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/.env.example` + pass-through in `docker-compose.yml` (commented; not enabled in any environment file)
|
||||||
- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod
|
- [x] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod (runtime flag — not set anywhere in-repo)
|
||||||
- [ ] 8.3 Expose counts via a Prometheus gauge (e.g. `demo_bot_synthetic_routes_total`, `demo_bot_synthetic_activities_total`) and reuse the existing `brouter_request_duration_seconds` histogram for the bot's BRouter calls — no new metric plumbing needed
|
- [x] 8.3 Expose counts via Prometheus gauges `demo_bot_synthetic_routes_total` and `demo_bot_synthetic_activities_total`; refreshed from the job handlers so scrapes see live values
|
||||||
|
|
||||||
## 9. UX tweaks
|
## 9. UX tweaks
|
||||||
|
|
||||||
- [ ] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
|
- [x] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
|
||||||
- [ ] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
|
- [x] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
|
||||||
|
|
||||||
## 10. Tests
|
## 10. Tests
|
||||||
|
|
||||||
- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate
|
- [x] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate (in `demo-bot.integration.test.ts`, runs under `DEMO_BOT_INTEGRATION=1`)
|
||||||
- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
|
- [x] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
|
||||||
- [ ] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
|
- [x] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
|
||||||
- [ ] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled
|
- [x] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled
|
||||||
- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
|
- [x] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
|
||||||
|
- [x] 10.6 E2E: `/users/bruno` renders the demo badge and lists public synthetic content to anonymous visitors (covers the user-visible surface)
|
||||||
|
|
||||||
## 11. Rollout
|
## 11. Rollout
|
||||||
|
|
||||||
- [ ] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set
|
- [x] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set
|
||||||
- [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy
|
- [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy
|
||||||
- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 3–5 items, and `/users/demo` renders them publicly
|
- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `bruno` user, the backfill produced 3–5 items, and `/users/bruno` renders them publicly
|
||||||
- [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet
|
- [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
integer,
|
integer,
|
||||||
real,
|
real,
|
||||||
jsonb,
|
jsonb,
|
||||||
|
boolean,
|
||||||
customType,
|
customType,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
|
@ -84,6 +85,12 @@ export const routes = journalSchema.table("routes", {
|
||||||
tags: jsonb("tags").$type<string[]>(),
|
tags: jsonb("tags").$type<string[]>(),
|
||||||
plannerState: bytea("planner_state"),
|
plannerState: bytea("planner_state"),
|
||||||
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
|
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
|
||||||
|
/**
|
||||||
|
* Content generated by the demo-activity-bot. Set to true only on insert;
|
||||||
|
* never surfaced in user-facing update flows. Lets us exclude demo content
|
||||||
|
* from analytics and wipe all bot output with a single DELETE.
|
||||||
|
*/
|
||||||
|
synthetic: boolean("synthetic").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
@ -118,6 +125,7 @@ export const activities = journalSchema.table("activities", {
|
||||||
photos: jsonb("photos").$type<string[]>(),
|
photos: jsonb("photos").$type<string[]>(),
|
||||||
participants: jsonb("participants").$type<string[]>(),
|
participants: jsonb("participants").$type<string[]>(),
|
||||||
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
|
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
|
||||||
|
synthetic: boolean("synthetic").notNull().default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,9 @@ export default {
|
||||||
noPublicRoutes: "Noch keine öffentlichen Routen.",
|
noPublicRoutes: "Noch keine öffentlichen Routen.",
|
||||||
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
|
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
|
||||||
},
|
},
|
||||||
|
demo: {
|
||||||
|
badge: "🐕 Demo-Konto",
|
||||||
|
},
|
||||||
activities: {
|
activities: {
|
||||||
title: "Aktivitäten",
|
title: "Aktivitäten",
|
||||||
new: "Neue Aktivität",
|
new: "Neue Aktivität",
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,9 @@ export default {
|
||||||
noPublicRoutes: "No public routes yet.",
|
noPublicRoutes: "No public routes yet.",
|
||||||
noPublicActivities: "No public activities yet.",
|
noPublicActivities: "No public activities yet.",
|
||||||
},
|
},
|
||||||
|
demo: {
|
||||||
|
badge: "🐕 Demo account",
|
||||||
|
},
|
||||||
activities: {
|
activities: {
|
||||||
title: "Activities",
|
title: "Activities",
|
||||||
new: "New Activity",
|
new: "New Activity",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,14 @@ export default defineConfig({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "demo-bot",
|
||||||
|
testMatch: "demo-bot.test.ts",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "integration",
|
name: "integration",
|
||||||
testMatch: "integration.test.ts",
|
testMatch: "integration.test.ts",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue