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
+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 />