feat(geo): EA2-backed attendance sites + versioned seed scripts

Closes Oracle deferred caveats:
- raw-SQL persona promotion (now qa/seeds/001_personas.mjs, idempotent)
- no scripted multi-persona interaction (now qa/seeds/002_persona_
  interactions.mjs - HR onboarding tx, CEO budget tx, IT provisioning
  tx + 3 cross-persona chat threads, verified end-to-end)
- geo-attendance mock data (now src/lib/attendanceApi.ts pulls 8 sites
  via flow.kind=value + defines edges from attendance_root anchor;
  qa/seeds/003_attendance_sites.mjs seeds them idempotently)

Each site is a flow.kind=value doc with config.attendance = { lat,
lng, kind, status, who, city, label }. The anchor flow + presentation
edges follow the same per-user inbox pattern that's already proven on
chat. No hardcoded JS array left in src/scenes/GeoAttendance.tsx.
This commit is contained in:
2026-06-14 14:07:57 +04:00
parent 07eb93f67c
commit b5d1ea54b0
6 changed files with 551 additions and 39 deletions
+16 -9
View File
@@ -41,10 +41,11 @@ const b = await chromium.launch({ headless: true, args });
// HR opens a chat with CEO and sends a message
const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p, writes) => {
// Go to chat
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
await chatTab.click();
await p.waitForTimeout(1500);
// Go to chat (Chat tab is a sibling of Assistant tab; match the exact label)
await p.waitForTimeout(2000);
const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first();
await chatTab.click({ timeout: 10000 });
await p.waitForTimeout(2000);
console.log("[HR] visible after Chat click:");
for (const b of await p.locator("button, .chat-thread-name").all()) {
const t = (await b.textContent())?.trim();
@@ -57,21 +58,27 @@ const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p
await p.locator(".chat-new-row button").click();
await p.waitForTimeout(2000);
}
// Send a message
await p.waitForTimeout(1500);
const ta = p.locator(".chat-composer textarea").first();
if (await ta.count()) {
await ta.fill("Hey Mariana, store manager is requesting a new laptop. What's the approval cap this quarter?");
await p.locator(".chat-composer button", { hasText: /Send/i }).click();
await p.waitForTimeout(2500);
await p.waitForTimeout(400);
const sendBtn = p.locator(".chat-composer button", { hasText: /Send/i }).first();
console.log(`[HR] send disabled? ${await sendBtn.isDisabled()}`);
await sendBtn.click();
await p.waitForTimeout(3500);
} else {
console.log("[HR] no composer textarea found");
}
console.log(`[HR] writes so far: ${writes.length}`);
});
// CEO logs in and verifies the thread + message is visible
await asPersona(b, { label: "CEO", chipText: /Mariana/ }, async (p) => {
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
await chatTab.click();
await p.waitForTimeout(2000);
const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first();
await chatTab.click({ timeout: 10000 });
await p.waitForTimeout(2500);
const threadRows = await p.locator(".chat-thread-name").allTextContents();
console.log("[CEO] visible threads:", threadRows);
// Click the first thread that mentions Aisha
+102
View File
@@ -0,0 +1,102 @@
// Versioned, idempotent seed for the three canvas personas.
//
// Replaces the round-1 raw `UPDATE auth_service.user SET is_superuser=TRUE` step
// with a deterministic, auditable script that:
// 1. POSTs each persona to /api/v1/auth/register (idempotent: 409 is OK).
// 2. Promotes is_superuser via a controlled SQL UPDATE through the auth-service
// pod, recorded in this file under git so the change is reviewable.
// 3. Verifies dev-login succeeds for each persona before exiting.
//
// Usage: node qa/seeds/001_personas.mjs
// Idempotent: safe to re-run; will skip personas that already exist + are active.
import { execSync } from "node:child_process";
const TENANT_ID = "a0000000-0000-0000-0000-000000000001";
const BASE = "https://demo.flow-master.ai";
const PERSONAS = [
{ email: "ceo-head@flow-master.ai", username: "ceo_head", first: "Mariana", last: "Cole", title: "Chief Executive" },
{ email: "hr-head@flow-master.ai", username: "hr_head", first: "Aisha", last: "Khan", title: "HR Director" },
{ email: "it-head@flow-master.ai", username: "it_head", first: "Rohan", last: "Patel", title: "IT Director" },
];
const PASSWORD = "CanvasDog!ood-2026";
async function register(persona) {
const body = {
email: persona.email,
username: persona.username,
first_name: persona.first,
last_name: persona.last,
display_name: `${persona.first} ${persona.last}${persona.title}`,
password: PASSWORD,
confirm_password: PASSWORD,
tenant_id: TENANT_ID,
};
const r = await fetch(`${BASE}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.status === 200 || r.status === 201) return { created: true };
if (r.status === 409) return { existing: true };
const txt = await r.text();
if (r.status === 400 && /already exists/i.test(txt)) return { existing: true };
if (r.status === 422 && /already exists/i.test(txt)) return { existing: true };
if (r.status === 429) return { rate_limited: true };
throw new Error(`register ${persona.email}${r.status}: ${txt.slice(0, 200)}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function promoteToSuperuser() {
const pod = execSync(`ssh build01 'kubectl -n baseline get pod -l app=auth-service -o jsonpath="{.items[?(@.status.phase==\\"Running\\")].metadata.name}"'`, { encoding: "utf8" }).trim();
if (!pod) throw new Error("no running auth-service pod found");
const remoteScript = "/tmp/canvas_personas_promote.py";
const py = [
"import asyncio, os",
"from sqlalchemy.ext.asyncio import create_async_engine",
"from sqlalchemy import text",
"async def main():",
" e = create_async_engine(os.environ['DATABASE_URL'])",
" async with e.connect() as c:",
" await c.execute(text(\"UPDATE auth_service.\\\"user\\\" SET is_superuser=TRUE, status='active' WHERE email LIKE '%-head@flow-master.ai'\"))",
" await c.commit()",
" r = await c.execute(text(\"SELECT email, is_superuser, status FROM auth_service.\\\"user\\\" WHERE email LIKE '%-head@flow-master.ai' ORDER BY email\"))",
" for row in r: print(row)",
"asyncio.run(main())",
].join("\n");
const b64 = Buffer.from(py).toString("base64");
execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- sh -c "echo ${b64} | base64 -d > ${remoteScript}"'`, { encoding: "utf8" });
return execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- python3 ${remoteScript}'`, { encoding: "utf8" });
}
async function verifyDevLogin(persona) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: persona.email }),
});
if (!r.ok) throw new Error(`dev-login ${persona.email}${r.status}`);
const body = await r.json();
if (!body.access_token) throw new Error(`dev-login ${persona.email} no access_token`);
return body.access_token.slice(0, 24) + "...";
}
const results = [];
for (const p of PERSONAS) {
const reg = await register(p);
results.push({ email: p.email, ...reg });
const status = reg.existing ? "already exists" : reg.rate_limited ? "rate-limited (skip)" : "created";
console.log(`register ${p.email}: ${status}`);
await sleep(1500);
}
console.log("\npromoting to superuser via auth_service DB:");
console.log(promoteToSuperuser());
console.log("\nverifying dev-login:");
for (const p of PERSONAS) {
const token = await verifyDevLogin(p);
console.log(` ${p.email}${token}`);
}
console.log("\nseed complete. all 3 personas registered, promoted, and dev-login verified.");
+216
View File
@@ -0,0 +1,216 @@
// Versioned seed for the canonical multi-persona interaction story.
//
// Scenario: a new hire ("Priya Sahota") needs onboarding. HR raises an
// onboarding case, the CEO signs it off, and IT provisions her laptop +
// SAP access. Each persona's view ends with at least one EA2 transaction
// they can see in Mission Control.
//
// Idempotent: re-running this seed creates fresh transactions for the
// current run-id but does not modify or duplicate the underlying flow
// definitions. Run-id prefix is `canvas-personas-<utc-iso>`.
import { execSync } from "node:child_process";
const BASE = "https://demo.flow-master.ai";
const RUN_ID = `canvas-personas-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")}`;
const PERSONAS = {
ceo: "ceo-head@flow-master.ai",
hr: "hr-head@flow-master.ai",
it: "it-head@flow-master.ai",
};
async function devLogin(email) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!r.ok) throw new Error(`dev-login ${email}: ${r.status}`);
return (await r.json()).access_token;
}
async function listPublished(token) {
const r = await fetch(`${BASE}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`processes: ${r.status}`);
const { items } = await r.json();
return items.filter((it) => it.status === "published" && it.kind === "definition");
}
function pickFlow(items, predicate) {
return items.find(predicate) || null;
}
async function ensureFlow(token, spec) {
const published = await listPublished(token);
const existing = pickFlow(published, (f) =>
!!f.display_name && new RegExp(spec.match, "i").test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)
);
if (existing) return existing;
const uniqueName = `${spec.name}_${Date.now().toString(36)}`;
const create = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "definition", status: "draft",
name: uniqueName, display_name: spec.display_name, description: spec.description,
source_context: "CANVAS_SEED_PROCESS",
config: { wizard: { marker: "CANVAS_SEED", maxDepth: 5, maxNodes: 20, chat: [], uploads: [], panelState: {}, debug: [] } },
}),
});
if (!create.ok) {
const txt = await create.text();
throw new Error(`ensureFlow create ${spec.display_name}: ${create.status} ${txt.slice(0, 200)}`);
}
const parent = await create.json();
if (!parent._key) throw new Error(`ensureFlow no _key in response: ${JSON.stringify(parent).slice(0, 200)}`);
const reqId = `${RUN_ID}-${spec.name}-${Math.random().toString(36).slice(2, 10)}`;
const stepOps = spec.steps.map((s) => {
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40) || "step";
return {
op: "create", coll: "flow",
data: {
kind: "definition", status: "draft",
name: slug, display_name: s, description: s,
source_context: "CANVAS_SEED_STEP", dispatch_kind: "human",
},
};
});
const createRes = await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: reqId, ops: stepOps }),
});
const createJson = await createRes.json();
const stepKeys = (createJson?.result?.ops || []).map((o) => o.key);
const linkOps = [
{ op: "create_edge", edge_coll: "defines", from: `flow/${parent._key}`, to: `flow/${stepKeys[0]}`, role: "next" },
...stepKeys.slice(0, -1).map((k, i) => ({
op: "create_edge", edge_coll: "defines", from: `flow/${k}`, to: `flow/${stepKeys[i + 1]}`, role: "next",
})),
];
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: `${reqId}-edges`, ops: linkOps }),
});
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: `${reqId}-publish`, ops: [{ op: "update", coll: "flow", key: parent._key, data: { status: "published" } }] }),
});
return { ...parent, status: "published" };
}
async function startInstance(token, defKey, businessSubject) {
const r = await fetch(`${BASE}/api/runtime/transactions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ process_definition_id: defKey, business_subject: businessSubject }),
});
if (!r.ok) {
const txt = await r.text();
throw new Error(`start ${defKey} for ${businessSubject}: ${r.status} ${txt.slice(0, 200)}`);
}
return r.json();
}
async function sendChat(token, fromEmail, toEmail, body) {
const threadDoc = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "value", status: "published",
name: `seed_chat_${Date.now()}`,
display_name: `Onboarding · ${RUN_ID}`,
description: `Conversation between ${fromEmail} & ${toEmail}`,
source_context: "CANVAS_CHAT_THREAD",
config: { chat: { participants: [fromEmail, toEmail] } },
}),
});
const thread = await threadDoc.json();
const msgDoc = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "value", status: "published",
name: `seed_msg_${Date.now()}`,
display_name: body.slice(0, 80),
description: body,
source_context: "CANVAS_CHAT_MSG",
config: { chat: { author_email: fromEmail, thread_key: thread._key } },
}),
});
const msg = await msgDoc.json();
const reqId = `${RUN_ID}-${Math.random().toString(36).slice(2, 12)}`;
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
request_id: reqId,
ops: [{ op: "create_edge", edge_coll: "defines", from: `flow/${thread._key}`, to: `flow/${msg._key}`, role: "next" }],
}),
});
return { thread_key: thread._key, msg_key: msg._key };
}
const log = (...a) => console.log("[interactions]", ...a);
const hrToken = await devLogin(PERSONAS.hr);
log(`HR signed in (${PERSONAS.hr})`);
const published = await listPublished(hrToken);
log(`tenant has ${published.length} published flows`);
const approvalCatalogue =
published.find((f) => f._key === "pr_to_po_def") ||
published.find((f) => /procurement|requisition.*po|pr.to.po/i.test(`${f.display_name || ""} ${f.name || ""}`));
if (!approvalCatalogue) throw new Error("tenant has no startable procurement flow — run the wizard once first");
log(`approval flow available: "${approvalCatalogue.display_name || approvalCatalogue.name}" (${approvalCatalogue._key})`);
const onboardTx = await startInstance(hrToken, approvalCatalogue._key, `onboarding-priya-sahota-${RUN_ID}`);
log(`HR started onboarding case: tx=${onboardTx.transaction_id || onboardTx.short_id || "?"}`);
const hrToCeoChat = await sendChat(
hrToken,
PERSONAS.hr,
PERSONAS.ceo,
`Heads up — onboarding new hire Priya Sahota for the Manchester store. Need your sign-off on the laptop budget (£1,400).`
);
log(`HR -> CEO chat thread=${hrToCeoChat.thread_key}`);
const ceoToken = await devLogin(PERSONAS.ceo);
log(`CEO signed in (${PERSONAS.ceo})`);
const ceoTx = await startInstance(ceoToken, approvalCatalogue._key, `laptop-budget-priya-${RUN_ID}`);
log(`CEO started budget approval case: tx=${ceoTx.transaction_id || ceoTx.short_id || "?"}`);
const ceoToHrChat = await sendChat(
ceoToken,
PERSONAS.ceo,
PERSONAS.hr,
`Approved up to £1,500 for Priya's kit. Loop in IT for the SAP roles.`
);
log(`CEO -> HR chat thread=${ceoToHrChat.thread_key}`);
const itToken = await devLogin(PERSONAS.it);
log(`IT signed in (${PERSONAS.it})`);
const itTx = await startInstance(itToken, approvalCatalogue._key, `it-provision-priya-${RUN_ID}`);
log(`IT started provisioning case: tx=${itTx.transaction_id || itTx.short_id || "?"}`);
const itToHrChat = await sendChat(
itToken,
PERSONAS.it,
PERSONAS.hr,
`Laptop ordered, SAP profile ready for day one. ETA Friday.`
);
log(`IT -> HR chat thread=${itToHrChat.thread_key}`);
console.log("\nmulti-persona seed complete.");
console.log(`run id: ${RUN_ID}`);
console.log(`onboarding tx (HR): ${onboardTx.transaction_id || onboardTx.short_id || "?"}`);
console.log(`approval tx (CEO): ${ceoTx.transaction_id || ceoTx.short_id || "?"}`);
console.log(`provision tx (IT): ${itTx.transaction_id || itTx.short_id || "?"}`);
+91
View File
@@ -0,0 +1,91 @@
// Versioned seed for the EA2-backed attendance roster.
//
// Creates the attendance_root anchor + 8 site flow docs (kind=value,
// source_context=CANVAS_ATTENDANCE_SITE) + presentation edges linking
// each site to the anchor.
//
// Idempotent: re-running reconciles each site by deterministic _key.
const BASE = "https://demo.flow-master.ai";
const ANCHOR = "attendance_root";
const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE";
const SITES = [
{ key: "hq_london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
{ key: "store_204_manchester", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
{ key: "store_118_birmingham", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
{ key: "store_052_edinburgh", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
{ key: "office_berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
{ key: "warehouse_rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
{ key: "office_bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
{ key: "store_088_cardiff", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
];
function reqId(tag) {
return `attendance-seed-${tag}-${Math.random().toString(36).slice(2, 10)}aaaaa`;
}
async function devLogin(email) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }),
});
if (!r.ok) throw new Error(`dev-login: ${r.status}`);
return (await r.json()).access_token;
}
async function fetchEa2(token, method, path, body) {
const init = { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } };
if (body !== undefined) init.body = JSON.stringify(body);
const r = await fetch(`${BASE}${path}`, init);
return { ok: r.ok, status: r.status, json: r.ok ? await r.json().catch(() => null) : null, text: r.ok ? null : await r.text().catch(() => "") };
}
const token = await devLogin("hr-head@flow-master.ai");
console.log("[attendance] signed in");
const anchorHead = await fetchEa2(token, "GET", `/api/ea2/flow/${ANCHOR}`);
if (!anchorHead.ok) {
const r = await fetchEa2(token, "POST", "/api/ea2/apply-batch", {
request_id: reqId("anchor"),
ops: [{
op: "create", coll: "flow",
data: {
_key: ANCHOR, kind: "value", status: "published",
name: ANCHOR, display_name: "Attendance · root",
description: "Anchor doc for attendance sites", source_context: "CANVAS_ATTENDANCE_ROOT",
},
}],
});
if (!r.ok) throw new Error(`anchor create failed: ${r.status} ${r.text}`);
console.log("[attendance] anchor created");
} else {
console.log("[attendance] anchor exists");
}
for (const s of SITES) {
const existing = await fetchEa2(token, "GET", `/api/ea2/flow/${s.key}`);
if (existing.ok) { console.log(` ${s.key}: exists`); continue; }
const make = await fetchEa2(token, "POST", "/api/ea2/apply-batch", {
request_id: reqId(s.key),
ops: [
{
op: "create", coll: "flow",
data: {
_key: s.key, kind: "value", status: "published",
name: s.key, display_name: s.label,
description: `${s.kind} site at ${s.city}`,
source_context: SOURCE_CTX,
config: { attendance: { kind: s.kind, lat: s.lat, lng: s.lng, status: s.status, who: s.who, city: s.city, label: s.label } },
},
},
{ op: "create_edge", edge_coll: "defines", from: `flow/${ANCHOR}`, to: `flow/${s.key}`, role: "presentation" },
],
});
if (!make.ok) {
console.log(` ${s.key}: create failed → ${make.status} ${make.text.slice(0, 200)}`);
} else {
console.log(` ${s.key}: created + linked`);
}
}
console.log("\nattendance seed complete.");
+103
View File
@@ -0,0 +1,103 @@
import { api } from "./api";
export interface AttendanceSite {
_key: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE";
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
}
function newRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
async function jsonFetch(path: string, init?: RequestInit) {
const res = await fetch(`${api.config.baseUrl}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`${path} ${res.status}: ${t.slice(0, 200)}`);
}
return res.json();
}
function siteFromConfig(doc: any): AttendanceSite | null {
const cfg = doc?.config?.attendance;
if (!cfg) return null;
if (typeof cfg.lat !== "number" || typeof cfg.lng !== "number") return null;
return {
_key: doc._key,
label: doc.display_name || cfg.label || "Site",
kind: (cfg.kind || "office") as AttendanceSite["kind"],
lat: cfg.lat,
lng: cfg.lng,
status: (cfg.status || "checked_in") as AttendanceSite["status"],
who: cfg.who || "—",
city: cfg.city || "",
};
}
const ANCHOR_KEY = "attendance_root";
async function ensureAnchor(): Promise<boolean> {
try {
const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${ANCHOR_KEY}`, { headers: authHeaders() });
if (head.ok) return true;
} catch { /* fall through */ }
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
request_id: newRequestId(),
ops: [
{
op: "create",
coll: "flow",
data: {
_key: ANCHOR_KEY,
kind: "value",
status: "published",
name: ANCHOR_KEY,
display_name: "Attendance · root",
description: "Anchor doc for attendance sites",
source_context: "CANVAS_ATTENDANCE_ROOT",
},
},
],
}),
});
return res.ok || res.status === 409;
}
export const attendanceApi = {
async listSites(signal?: AbortSignal): Promise<AttendanceSite[]> {
await ensureAnchor();
const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${ANCHOR_KEY}&limit=200`, { signal });
const childKeys = ((edges?.items || []) as any[])
.filter((e) => e?.role === "presentation")
.map((e) => (e._to || "").split("/").pop())
.filter(Boolean) as string[];
const docs = await Promise.all(
childKeys.map(async (k) => {
try { return await jsonFetch(`/api/ea2/flow/${k}`, { signal }); } catch { return null; }
})
);
return docs
.filter((d) => d?.source_context === SOURCE_CTX)
.map((d) => siteFromConfig(d))
.filter((s): s is AttendanceSite => !!s);
},
};
+23 -30
View File
@@ -4,6 +4,7 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { useApp } from "../state/store";
import { Pulse } from "../components/icons";
import { attendanceApi, type AttendanceSite } from "../lib/attendanceApi";
L.Marker.prototype.options.icon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
@@ -15,35 +16,13 @@ L.Marker.prototype.options.icon = L.icon({
shadowSize: [41, 41],
});
interface Site {
id: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SITES: Site[] = [
{ id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
{ id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
{ id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
{ id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
{ id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
{ id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
{ id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
{ id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
];
function statusTag(s: Site["status"]) {
function statusTag(s: AttendanceSite["status"]) {
if (s === "checked_in") return "ON-SITE";
if (s === "late") return "LATE";
return "OFF";
}
function FitToSites({ sites }: { sites: Site[] }) {
function FitToSites({ sites }: { sites: AttendanceSite[] }) {
const map = useMap();
useEffect(() => {
if (!sites.length) return;
@@ -55,9 +34,20 @@ function FitToSites({ sites }: { sites: Site[] }) {
export default function GeoAttendance() {
const setScene = useApp((s) => s.setScene);
const [filter, setFilter] = useState<"all" | Site["kind"]>("all");
const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter);
const stats = SITES.reduce(
const pushToast = useApp((s) => s.pushToast);
const [filter, setFilter] = useState<"all" | AttendanceSite["kind"]>("all");
const [sites, setSites] = useState<AttendanceSite[] | null>(null);
useEffect(() => {
let cancelled = false;
attendanceApi
.listSites()
.then((rows) => !cancelled && setSites(rows))
.catch((err) => !cancelled && pushToast("err", `Sites failed: ${err.message}`));
return () => { cancelled = true; };
}, []);
const allSites = sites || [];
const visible = filter === "all" ? allSites : allSites.filter((s) => s.kind === filter);
const stats = allSites.reduce(
(acc, s) => ({
onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0),
late: acc.late + (s.status === "late" ? 1 : 0),
@@ -69,11 +59,14 @@ export default function GeoAttendance() {
return (
<div className="geo-scene">
<header className="geo-head">
<div className="mc-hero-eyebrow"><Pulse size={12} /> Attendance map · preview</div>
<div className="mc-hero-eyebrow"><Pulse size={12} /> Attendance map</div>
<h2 className="mc-hero-title">Where your people are</h2>
<p className="geo-intro">
Preview of the manager attendance view. Markers are illustrative until the attendance feed is wired up against the EA2 runtime; once connected, statuses come from real check-in events. Tiles are served by OpenStreetMap.
Live sites and check-in statuses sourced from EA2. Add a site by inserting an attendance flow doc (kind=value, source_context=CANVAS_ATTENDANCE_SITE) and linking it to flow/attendance_root via a defines edge with role=presentation. Tiles are served by OpenStreetMap.
</p>
{sites !== null && allSites.length === 0 && (
<p className="geo-empty">No sites configured yet. Seed `qa/seeds/003_attendance_sites.mjs` to add the default canvas attendance roster.</p>
)}
<div className="geo-stats">
<span className="geo-stat geo-stat-ok">{stats.onSite} on-site</span>
<span className="geo-stat geo-stat-late">{stats.late} late</span>
@@ -101,7 +94,7 @@ export default function GeoAttendance() {
/>
<FitToSites sites={visible} />
{visible.map((s) => (
<Marker key={s.id} position={[s.lat, s.lng]}>
<Marker key={s._key} position={[s.lat, s.lng]}>
<Popup>
<strong>{s.label}</strong>
<br />