Merge pull request #257 from trails-cool/feat/demo-activity-bot

Apply demo-activity-bot: Bruno the synthetic walker
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:18:17 +02:00 committed by GitHub
commit f447852d70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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>
<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");
}
}
});

116
e2e/demo-bot.test.ts Normal file
View 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();
}
}
});
});

View file

@ -6,3 +6,11 @@ POSTGRES_PASSWORD=change-me
JWT_SECRET=change-me
S3_ACCESS_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]}

View file

@ -34,6 +34,13 @@ services:
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
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:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"]
interval: 15s

View file

@ -1,72 +1,73 @@
## 1. Schema
- [ ] 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`
- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
- [x] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
- [x] 1.2 Add the same column to `journal.activities`
- [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.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.
- [ ] 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.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.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.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
- [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.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, 212 km crow distance apart (dog-walk scale)
- [ ] 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 🐕"); 1015 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
- [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]`
- [x] 3.2 Add `pickEndpoints(bbox)` — random start + end, 212 km crow distance apart (dog-walk scale)
- [x] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
- [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 🐕"); 1015 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
## 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
- [ ] 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.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.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.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"`
- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:0021:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~23 walks/day
- [ ] 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
- [ ] 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.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
- [x] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
- [x] 5.3 Decide-to-walk gate: compute local hour; if outside 07:0021:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~23 walks/day
- [x] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
- [x] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
- [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.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.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.2 After the backfill path, fall through to the normal single-item generation
## 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
- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
- [ ] 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
- [ ] 7.5 Log the count removed for observability
- [x] 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.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
- [x] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
- [x] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
- [x] 7.5 Log the count removed for observability
## 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)
- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod
- [ ] 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.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)
- [x] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod (runtime flag — not set anywhere in-repo)
- [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.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.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
- [x] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
## 10. Tests
- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate
- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
- [ ] 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
- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
- [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`)
- [x] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
- [x] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
- [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
- [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.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.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 35 items, and `/users/demo` renders them publicly
- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `bruno` user, the backfill produced 35 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

View file

@ -5,6 +5,7 @@ import {
integer,
real,
jsonb,
boolean,
customType,
} from "drizzle-orm/pg-core";
@ -84,6 +85,12 @@ export const routes = journalSchema.table("routes", {
tags: jsonb("tags").$type<string[]>(),
plannerState: bytea("planner_state"),
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(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
@ -118,6 +125,7 @@ export const activities = journalSchema.table("activities", {
photos: jsonb("photos").$type<string[]>(),
participants: jsonb("participants").$type<string[]>(),
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
synthetic: boolean("synthetic").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -202,6 +202,9 @@ export default {
noPublicRoutes: "Noch keine öffentlichen Routen.",
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
},
demo: {
badge: "🐕 Demo-Konto",
},
activities: {
title: "Aktivitäten",
new: "Neue Aktivität",

View file

@ -202,6 +202,9 @@ export default {
noPublicRoutes: "No public routes yet.",
noPublicActivities: "No public activities yet.",
},
demo: {
badge: "🐕 Demo account",
},
activities: {
title: "Activities",
new: "New Activity",

View file

@ -47,6 +47,14 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
},
{
name: "demo-bot",
testMatch: "demo-bot.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "integration",
testMatch: "integration.test.ts",