/* ========================================================= CONNIE — Jarvis cards (S3a + S3b) Four new Jarvis dashboard cards backed by GET /web/jarvis: - NextMeetingCard (next upcoming calendar event) - TasksCard (open tasks + overdue highlight) - MailsCard (unread mails top 5 — From + Subject, no snippet) - InsightsCard (KG insights/decisions/reflections, max 5) Pure logic helpers (testable without React): window.JarvisCardLogic = { parseEventStart, // "HH:MM" / "HH:MM-HH:MM" / "ganztaegig" / "" -> {h,m}|null parseDue, // "2026-07-04" -> Date(midnight) | null nextEvent, // (todayItems, tomItems, now) -> {item, startsAt, isToday}|null overdueTasks, // (taskItems, today) -> [{item, due}] relTime, // (dateStr, now) -> "vor N Min/Std/Tagen" | "" shortFrom, // ("Name " | "a@b.de") -> "Name" | "a" } Each card receives `data` (the full /web/jarvis response payload) and fail-softs gracefully: null=loading, available:false=unavailable. New components are in window scope so app.jsx can reference them without a build step. No collision with panels.jsx — different window-global names. CSS uses existing class names from styles.css where possible; new layout uses inline styles to avoid touching styles.css (S3b scope, design §5). ========================================================= */ /* React hooks — alias with j* prefix to avoid any potential name collision if this file is ever concatenated with panels.jsx in a future build. */ const { useState: jState, useEffect: jEffect } = React; // --------------------------------------------------------------------------- // PURE HELPERS (window.JarvisCardLogic) // Tested by jarvis-cards.test.mjs — must stay dependency-free. // --------------------------------------------------------------------------- /** * parseEventStart — defensively extract {h, m} from a time string. * * Handles four shapes: * "HH:MM" -> {h: 10, m: 30} * "HH:MM-HH:MM" -> {h: 10, m: 30} (start time only) * "ganztaegig" -> null (all-day, no specific start) * "" / any other -> null */ function parseEventStart(timeStr) { if (!timeStr || typeof timeStr !== "string") return null; const t = timeStr.trim(); if (!t || t === "ganztaegig") return null; // Accept "HH:MM" or "HH:MM-HH:MM" (take the start part) const m = t.match(/^(\d{1,2}):(\d{2})/); if (!m) return null; const h = parseInt(m[1], 10); const min = parseInt(m[2], 10); if (h < 0 || h > 23 || min < 0 || min > 59) return null; return { h, min }; } /** * parseDue — parse a due-date string ("2026-07-04") to a Date at midnight * local time. Returns null for empty / unparseable strings. */ function parseDue(str) { if (!str || typeof str !== "string") return null; const t = str.trim(); if (!t) return null; // Accept ISO date "YYYY-MM-DD" (calendar task format) const dm = t.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!dm) return null; const y = parseInt(dm[1], 10); const mo = parseInt(dm[2], 10) - 1; // 0-indexed month const d = parseInt(dm[3], 10); const date = new Date(y, mo, d, 0, 0, 0, 0); if (isNaN(date.getTime())) return null; return date; } /** * _parseEventEnd — extract the END time {h, min} from a "HH:MM-HH:MM" range. * Returns null for single-time / all-day / unparseable strings. */ function _parseEventEnd(timeStr) { if (!timeStr || typeof timeStr !== "string") return null; const m = timeStr.trim().match(/^\d{1,2}:\d{2}\s*-\s*(\d{1,2}):(\d{2})/); if (!m) return null; const h = parseInt(m[1], 10); const min = parseInt(m[2], 10); if (h < 0 || h > 23 || min < 0 || min > 59) return null; return { h, min }; } /** * nextEvent — find the next upcoming OR currently running calendar event. * * Searches todayItems first (calendar_today), then tomItems (calendar_tomorrow). * All-day events (no parseable time) are skipped for countdown purposes. * A running event (start <= now < end) is returned before any future one * (QS-JV-3; NextMeetingCard shows it as "läuft seit HH:MM"). The end comes * from a "HH:MM-HH:MM" range when present, otherwise a default 60-minute * window is assumed. * Returns { item, startsAt: Date, isToday: boolean } or null when neither a * running nor a future timed event is found. * * Note: timezone assumption is Europe/Berlin (client-local). This is documented * as a known limitation (design §7): breaks on travel, accepted V1. */ function nextEvent(todayItems, tomItems, now) { const _now = now instanceof Date ? now : new Date(); const todayBase = new Date(_now); todayBase.setHours(0, 0, 0, 0); const tomBase = new Date(todayBase); tomBase.setDate(tomBase.getDate() + 1); const sources = [ { items: todayItems || [], base: todayBase, isToday: true }, { items: tomItems || [], base: tomBase, isToday: false }, ]; for (const { items, base, isToday } of sources) { if (!Array.isArray(items)) continue; // Items from the sidecar come in start-time order; find the first future one. for (const item of items) { if (!item || typeof item !== "object") continue; const parsed = parseEventStart(item.time || ""); if (!parsed) continue; // skip all-day / no-time events const startsAt = new Date(base); startsAt.setHours(parsed.h, parsed.min, 0, 0); if (startsAt > _now) { return { item, startsAt, isToday }; } // QS-JV-3: started but not yet over -> the event is running and is the // one to show. Cross-midnight or missing ends fall back to +60 minutes. const endParsed = _parseEventEnd(item.time || ""); let endsAt = new Date(startsAt); if (endParsed) endsAt.setHours(endParsed.h, endParsed.min, 0, 0); if (!endParsed || endsAt <= startsAt) { endsAt = new Date(startsAt.getTime() + 60 * 60000); } if (_now < endsAt) { return { item, startsAt, isToday }; } } } return null; } /** * overdueTasks — return task items whose due date is strictly before `today`. * * `today` should be a Date set to midnight (start of today) in local time. * Tasks with no `due` field or an unparseable due date are excluded from the * overdue list (they show normally in the task list without a red highlight). * * Returns an array of { item, due: Date } pairs sorted earliest-first. */ function overdueTasks(taskItems, today) { const _today = today instanceof Date ? today : (() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; })(); const out = []; for (const item of (taskItems || [])) { if (!item || typeof item !== "object") continue; const due = parseDue(item.due || ""); if (!due) continue; if (due.getTime() < _today.getTime()) { out.push({ item, due }); } } out.sort((a, b) => a.due - b.due); return out; } /** * relTime — format a past timestamp as a relative German label. * * Accepts any string `Date` can parse: ISO 8601 ("2026-05-31T16:00:00Z", KG * insights) as well as RFC 2822 mail-header dates ("Mon, 1 Jun 2026 08:12:00 * +0200", gmail_unread_top5). Returns "" for empty/unparseable input so * callers can simply omit the label rather than show "Invalid Date". * * Buckets: <1 Min "gerade eben", <60 Min "vor N Min", <24 Std "vor N Std", * else "vor N Tag(en)". Future timestamps (clock skew) also fall back to * "gerade eben" rather than a negative duration. */ function relTime(dateStr, now) { if (!dateStr || typeof dateStr !== "string") return ""; const t = dateStr.trim(); if (!t) return ""; const parsed = new Date(t); if (isNaN(parsed.getTime())) return ""; const _now = now instanceof Date ? now : new Date(); const diffMs = _now.getTime() - parsed.getTime(); if (diffMs < 60_000) return "gerade eben"; const diffMin = Math.floor(diffMs / 60_000); if (diffMin < 60) return `vor ${diffMin} Min`; const diffH = Math.floor(diffMin / 60); if (diffH < 24) return `vor ${diffH} Std`; const diffD = Math.floor(diffH / 24); return `vor ${diffD} Tag${diffD === 1 ? "" : "en"}`; } /** * shortFrom — shorten a Gmail "From" header to a display-friendly name. * * Handles `"Display Name" ` and `Display Name ` by * returning the display name; a bare `` (no name) or a plain * `addr@x.de` falls back to the local-part before "@". Anything else * (already-plain text, empty, non-string) is returned trimmed/as-is. */ function shortFrom(fromStr) { if (!fromStr || typeof fromStr !== "string") return ""; const t = fromStr.trim(); if (!t) return ""; const angled = t.match(/^"?([^"<]*)"?\s*<([^>]+)>$/); if (angled) { const name = angled[1].trim(); if (name) return name; const email = angled[2].trim(); const at = email.indexOf("@"); return at > 0 ? email.slice(0, at) : email; } const at = t.indexOf("@"); if (at > 0 && !t.includes(" ")) return t.slice(0, at); return t; } // Expose pure helpers globally for testing + use by app.jsx if needed. window.JarvisCardLogic = { parseEventStart, parseDue, nextEvent, overdueTasks, relTime, shortFrom }; // --------------------------------------------------------------------------- // DISPLAY HELPERS (browser-only, not exported for pure-function testing) // --------------------------------------------------------------------------- /** Format a countdown from now to `target` Date as "in N Min" / "in X Std". */ function _countdown(target, now) { const diffMs = target.getTime() - (now instanceof Date ? now : new Date()).getTime(); if (diffMs <= 0) return "jetzt"; const diffMin = Math.round(diffMs / 60000); if (diffMin < 60) return `in ${diffMin} Min`; const diffH = Math.round(diffMin / 60); return `in ${diffH} Std`; } /** Format "HH:MM" from a Date object. */ function _toHHMM(date) { if (!(date instanceof Date) || isNaN(date.getTime())) return ""; const h = String(date.getHours()).padStart(2, "0"); const m = String(date.getMinutes()).padStart(2, "0"); return `${h}:${m}`; } /** "Stand vor X Min" label from age_min or fresh_since fallback. */ function _standLabel(ageMins) { if (ageMins === null || ageMins === undefined) return ""; return `Stand vor ${ageMins} Min`; } // Shared inline-style tokens (avoid touching styles.css in S3a scope). const _TEAL = "#05E6A5"; const _RED = "#FF6464"; const _AMBER = "#FFB020"; const _PURPLE = "#B388FF"; const _FG2 = "var(--fg-2)"; const _FG3 = "var(--fg-3)"; const _MONO = "var(--font-mono)"; function _metaStyle() { return { fontSize: 11, fontFamily: _MONO, letterSpacing: "0.08em", color: _FG3, marginTop: 2, }; } /** * _kindMeta — chip label/colors for a kg_recent_insights "kind" value. * Unknown/missing kind falls back to the "Insight" look (most common kind, * design §3). Backend sends lowercase ("insight"/"decision"/"reflection"). */ function _kindMeta(kind) { const k = typeof kind === "string" ? kind.trim().toLowerCase() : ""; if (k === "decision") { return { label: "Decision", color: _AMBER, bg: "rgba(255,176,32,0.12)" }; } if (k === "reflection") { return { label: "Reflection", color: _PURPLE, bg: "rgba(179,136,255,0.12)" }; } return { label: "Insight", color: _TEAL, bg: "rgba(5,230,165,0.10)" }; } // --------------------------------------------------------------------------- // NEXT MEETING CARD // --------------------------------------------------------------------------- /** * NextMeetingCard — shows the next upcoming calendar event. * * Props: * data - the full /web/jarvis response (or null while loading) * onPrepMeeting - callback fired when the "Prep" button is clicked */ function NextMeetingCard({ data, onPrepMeeting }) { const [now, setNow] = jState(new Date()); // Refresh countdown every 30s so the label stays current. jEffect(() => { const id = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(id); }, []); if (data === null) { return (
Nächster Termin
lade ...
); } if (!data.available) { return (
Nächster Termin
nicht verfügbar
); } const fields = data.fields || {}; const calToday = fields.calendar_today; const calTom = fields.calendar_tomorrow; const todayItems = (calToday && calToday.items) || []; const tomItems = (calTom && calTom.items) || []; const next = nextEvent(todayItems, tomItems, now); const standLabel = _standLabel(data.age_min); const todayMidnight = new Date(now); todayMidnight.setHours(0, 0, 0, 0); const noEventsStyle = { padding: "12px 8px", textAlign: "center", color: _FG3, fontSize: 12, fontFamily: _MONO, }; return (
Nächster Termin
{standLabel &&
{standLabel}
}
{!next ? (
Keine weiteren Termine heute.
) : (() => { const { item, startsAt } = next; const isRunning = startsAt <= now; const countdownLabel = isRunning ? `läuft seit ${_toHHMM(startsAt)} Uhr` : _countdown(startsAt, now); const peers = Array.isArray(item.peers) && item.peers.length > 0 ? item.peers.join(", ") : null; return (
{item.title || "(kein Titel)"}
{peers && (
mit {peers}
)}
{_toHHMM(startsAt)} · {countdownLabel} {typeof onPrepMeeting === "function" && ( )}
); })()}
); } window.NextMeetingCard = NextMeetingCard; // --------------------------------------------------------------------------- // TASKS CARD // --------------------------------------------------------------------------- /** * TasksCard — shows open tasks with overdue highlight. * * Props: * data - the full /web/jarvis response (or null while loading) */ function TasksCard({ data }) { if (data === null) { return (
Aufgaben
lade ...
); } if (!data.available) { return (
Aufgaben
nicht verfügbar
); } const fields = data.fields || {}; const tasksField = fields.tasks_open; const items = (tasksField && tasksField.items) || []; const standLabel = _standLabel(data.age_min); const today = new Date(); today.setHours(0, 0, 0, 0); const overdueSet = new Set( overdueTasks(items, today).map((x) => x.item.id || x.item.title) ); const overdueCount = overdueSet.size; const meta = items.length === 0 ? "keine offenen Aufgaben" : `${items.length} offen${overdueCount > 0 ? ` · ${overdueCount} überfällig` : ""}`; return (
Aufgaben
{standLabel || meta}
{standLabel && (
{meta}
)} {items.length === 0 ? (
Keine offenen Aufgaben.
) : (
{items.map((item, idx) => { const key = item.id || item.title || idx; const isOverdue = overdueSet.has(item.id || item.title); const due = parseDue(item.due || ""); const dueLabel = due ? due.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }) : (item.due || ""); return (
{item.title || "(kein Titel)"}
{dueLabel && (
{dueLabel}
)}
); })}
)}
); } window.TasksCard = TasksCard; // --------------------------------------------------------------------------- // MAILS CARD // --------------------------------------------------------------------------- /** * MailsCard — unread mails top 5 (From + Subject, no snippet — SR-JV-1/§2 * Hard-Projection already drops it server-side; nothing to hide here). * * From/Subject/event titles are attacker-influenced strings (incoming mail, * SR-JV-6) — rendered ONLY as JSX text nodes below, never via * dangerouslySetInnerHTML, never used to build an href/src. * * Props: * data - the full /web/jarvis response (or null while loading) */ function MailsCard({ data }) { const [now, setNow] = jState(new Date()); jEffect(() => { const id = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(id); }, []); if (data === null) { return (
Ungelesen · Top 5
lade ...
); } if (!data.available) { return (
Ungelesen · Top 5
nicht verfügbar
); } const fields = data.fields || {}; const mailField = fields.gmail_unread_top5; const rawItems = (mailField && Array.isArray(mailField.items)) ? mailField.items : []; const items = rawItems.slice(0, 5); const count = mailField && typeof mailField.count === "number" ? mailField.count : items.length; const standLabel = _standLabel(data.age_min); const countLabel = count === 0 ? "keine ungelesenen Mails" : `${count} ungelesen`; return (
Ungelesen · Top 5
{standLabel || countLabel}
{standLabel && (
{countLabel}
)} {items.length === 0 ? (
Keine ungelesenen Mails.
) : (
{items.map((item, idx) => { const key = (item && item.thread_id) || idx; const from = shortFrom((item && item.from) || ""); const subject = (item && item.subject) || "(kein Betreff)"; const rel = relTime((item && item.date) || "", now); return (
{from || "(unbekannt)"}
{subject}
{rel && (
{rel}
)}
); })}
)}
); } window.MailsCard = MailsCard; // --------------------------------------------------------------------------- // INSIGHTS CARD // --------------------------------------------------------------------------- /** * InsightsCard — recent KG insights/decisions/reflections, capped at 5 even * if the backend ever sends more (design §3 "max 3-5 Eintraege"). * * KG summaries are attacker-influenced strings (derived from conversations, * SR-JV-6) — rendered ONLY as JSX text nodes, never dangerouslySetInnerHTML, * never used to build an href/src. * * Props: * data - the full /web/jarvis response (or null while loading) */ function InsightsCard({ data }) { const [now, setNow] = jState(new Date()); jEffect(() => { const id = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(id); }, []); if (data === null) { return (
Erkenntnisse · KG
lade ...
); } if (!data.available) { return (
Erkenntnisse · KG
nicht verfügbar
); } const fields = data.fields || {}; const insightsField = fields.kg_recent_insights; const rawItems = (insightsField && Array.isArray(insightsField.items)) ? insightsField.items : []; const items = rawItems.slice(0, 5); const standLabel = _standLabel(data.age_min); const countLabel = items.length === 0 ? "keine Erkenntnisse" : `${items.length} Erkenntnisse`; return (
Erkenntnisse · KG
{standLabel || countLabel}
{standLabel && (
{countLabel}
)} {items.length === 0 ? (
Keine aktuellen Erkenntnisse.
) : (
{items.map((item, idx) => { const key = (item && item.id) || idx; const km = _kindMeta(item && item.kind); const summary = (item && item.summary) || ""; const rel = relTime((item && item.created_at) || "", now); return (
{km.label} {rel && ( {rel} )}
{summary}
); })}
)}
); } window.InsightsCard = InsightsCard;