diff --git a/.a2a/agent.dsl.json b/.a2a/agent.dsl.json
index b3059cf..0901457 100644
--- a/.a2a/agent.dsl.json
+++ b/.a2a/agent.dsl.json
@@ -93,7 +93,23 @@
"wants_cp_jwt": false,
"platform_resources": {
"memory": null,
- "databases": []
+ "databases": [
+ {
+ "name": "app",
+ "engine": "postgres",
+ "provider": "neon",
+ "scope": "user",
+ "branch": "main",
+ "access_mode": "read_write",
+ "env": {
+ "url": "DATABASE_URL"
+ },
+ "migrations": {
+ "path": "db/migrations"
+ },
+ "scale_to_zero": true
+ }
+ ]
},
"apt_packages": []
},
diff --git a/a2a.yaml b/a2a.yaml
index 71514b4..8eca598 100644
--- a/a2a.yaml
+++ b/a2a.yaml
@@ -8,17 +8,19 @@ expose:
public: true
runtime:
lifecycle: warm
-# Optional platform resources:
-# resources:
-# memory:
-# tiers: [kv, vector]
-# namespace: robertseares
-# databases:
-# - name: app
-# provider: neon
-# engine: postgres
-# env:
-# url: DATABASE_URL
+resources:
+ databases:
+ - name: app
+ provider: neon
+ engine: postgres
+ scope: user
+ branch: main
+ access_mode: read_write
+ env:
+ url: DATABASE_URL
+ migrations:
+ path: db/migrations
+ scale_to_zero: true
frontend:
type: server-rendered
diff --git a/db/migrations/001_init.sql b/db/migrations/001_init.sql
new file mode 100644
index 0000000..c25c472
--- /dev/null
+++ b/db/migrations/001_init.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS robertseares_page_loads (
+ id BIGSERIAL PRIMARY KEY,
+ path TEXT NOT NULL,
+ locale TEXT,
+ referrer TEXT,
+ user_agent TEXT,
+ viewport JSONB NOT NULL DEFAULT '{}'::jsonb,
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS robertseares_page_loads_created_at_idx
+ ON robertseares_page_loads (created_at DESC);
+
+CREATE INDEX IF NOT EXISTS robertseares_page_loads_path_created_at_idx
+ ON robertseares_page_loads (path, created_at DESC);
diff --git a/frontend/app/(en)/layout.jsx b/frontend/app/(en)/layout.jsx
index 570c282..bca0d31 100644
--- a/frontend/app/(en)/layout.jsx
+++ b/frontend/app/(en)/layout.jsx
@@ -1,4 +1,5 @@
import "../globals.css";
+import { PageLoadTracker } from "../PageLoadTracker.jsx";
import { baseMetadata } from "../seo.js";
export const metadata = baseMetadata;
@@ -6,7 +7,10 @@ export const metadata = baseMetadata;
export default function EnglishLayout({ children }) {
return (
-
{children}
+
+
+ {children}
+
);
}
diff --git a/frontend/app/PageLoadTracker.jsx b/frontend/app/PageLoadTracker.jsx
new file mode 100644
index 0000000..bd64fb9
--- /dev/null
+++ b/frontend/app/PageLoadTracker.jsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function PageLoadTracker({ locale }) {
+ useEffect(() => {
+ const payload = {
+ path: window.location.pathname,
+ locale,
+ referrer: document.referrer || null,
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ devicePixelRatio: window.devicePixelRatio || 1,
+ },
+ metadata: {
+ language: navigator.language || null,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
+ },
+ };
+ const body = JSON.stringify(payload);
+
+ if (navigator.sendBeacon) {
+ const blob = new Blob([body], { type: "application/json" });
+ navigator.sendBeacon("/api/page-load", blob);
+ return;
+ }
+
+ fetch("/api/page-load", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body,
+ keepalive: true,
+ }).catch(() => {});
+ }, [locale]);
+
+ return null;
+}
diff --git a/frontend/app/api/page-load/route.js b/frontend/app/api/page-load/route.js
new file mode 100644
index 0000000..7575730
--- /dev/null
+++ b/frontend/app/api/page-load/route.js
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server";
+import pg from "pg";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+const { Pool } = pg;
+
+function pool() {
+ if (!process.env.DATABASE_URL) {
+ return null;
+ }
+ globalThis.__robertsearesPageLoadPool ||= new Pool({
+ connectionString: process.env.DATABASE_URL,
+ max: 2,
+ idleTimeoutMillis: 30_000,
+ connectionTimeoutMillis: 10_000,
+ });
+ return globalThis.__robertsearesPageLoadPool;
+}
+
+function text(value, maxLength) {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const trimmed = value.trim();
+ return trimmed ? trimmed.slice(0, maxLength) : null;
+}
+
+function object(value) {
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
+}
+
+export async function POST(request) {
+ const database = pool();
+ if (!database) {
+ return new NextResponse(null, { status: 202 });
+ }
+
+ try {
+ const body = await request.json();
+ const path = text(body.path, 512) || "/";
+ const locale = text(body.locale, 16);
+ const referrer = text(body.referrer, 2048);
+ const userAgent = text(request.headers.get("user-agent"), 2048);
+ const viewport = object(body.viewport);
+ const metadata = {
+ ...object(body.metadata),
+ forwardedFor: text(request.headers.get("x-forwarded-for"), 2048),
+ country:
+ text(request.headers.get("cf-ipcountry"), 16)
+ || text(request.headers.get("x-vercel-ip-country"), 16)
+ || text(request.headers.get("x-country-code"), 16),
+ };
+
+ await database.query(
+ `INSERT INTO robertseares_page_loads
+ (path, locale, referrer, user_agent, viewport, metadata)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`,
+ [path, locale, referrer, userAgent, JSON.stringify(viewport), JSON.stringify(metadata)],
+ );
+ } catch (error) {
+ console.warn("page load tracking failed", error);
+ }
+
+ return new NextResponse(null, { status: 204 });
+}
+
+export function GET() {
+ return NextResponse.json({ ok: true });
+}
diff --git a/frontend/app/pt/layout.jsx b/frontend/app/pt/layout.jsx
index 653c544..3200a92 100644
--- a/frontend/app/pt/layout.jsx
+++ b/frontend/app/pt/layout.jsx
@@ -1,4 +1,5 @@
import "../globals.css";
+import { PageLoadTracker } from "../PageLoadTracker.jsx";
import { baseMetadata } from "../seo.js";
export const metadata = {
@@ -16,7 +17,10 @@ export const metadata = {
export default function PortugueseLayout({ children }) {
return (
- {children}
+
+
+ {children}
+
);
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a29a89c..15c80ba 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"next": "^15.3.0",
+ "pg": "^8.13.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -923,6 +924,72 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
@@ -1407,6 +1474,95 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pg": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
+ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.13.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.14.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
+ "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
+ "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1442,6 +1598,45 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1536,6 +1731,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -1585,6 +1789,15 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index b169e3a..57708dd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,7 @@
},
"dependencies": {
"next": "^15.3.0",
+ "pg": "^8.13.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},