/* ========================================================= CONNIE — Hint engine (S3c) Client-side "proactive hints" derived purely from the /web/jarvis snapshot (see panels-jarvis.jsx) plus the client clock. No sound, no TTS, no orb changes — a single dedicated banner, top-right, that shows at most one hint at a time. window.ConnieHints = { deriveHints, // ({jarvis, now}) -> [{key, prio, level, text, action?}] pickHint, // ({hints, state, appState, loadedAt, now}) -> hint|null loadHintState, // () -> {lastShownAt, dedupe} (localStorage, keys/timestamps only) saveHintState, // (state) -> void HintBanner, // React component } Rules (design §4, connie-jarvis-s3-design.md): H1 meeting-in-Kuerze (prio1/warn): next event 0 < t <= 30 Min. H2 overdue tasks (prio2/info): N tasks due < today, max 1x/Tag. H3 deal follow-up (prio3/info): deal next_activity_date >= 5 Tage in der Vergangenheit, max 1x/Tag/Deal. H4 (approvals) is Backlog — not built (Entscheidung §8.1). Anti-Nerv state lives in localStorage under "connie.hints" and holds ONLY rule-keys + epoch-ms timestamps — never hint text, never the meeting title / deal name / task title that produced the hint (SR-JV-6: those strings are attacker-influenced and must stay JSX text nodes only, never persisted or used to build hrefs). Everything except loadHintState/saveHintState is a pure function. ========================================================= */ // No hooks needed here: HintBanner is a pure presentational component and // the derivation re-runs on every jarvisData poll (app.jsx), so there is no // local ticking state to manage in this file. // --------------------------------------------------------------------------- // Anti-Nerv tuning (design §4) // --------------------------------------------------------------------------- const _STORAGE_KEY = "connie.hints"; const _DEDUPE_MS_DEFAULT = 4 * 60 * 60 * 1000; // 4h general dedupe (H1 + any future rule) const _DEDUPE_MS_DAILY = 24 * 60 * 60 * 1000; // "max 1x/Tag" for H2 and H3 (per deal) const _COOLDOWN_MS = 15 * 60 * 1000; // global cooldown between hints const _QUIET_AFTER_LOAD_MS = 10 * 1000; // no hints in the first 10s after app load const _STALE_BLACKOUT_MIN = 60; // age_min > 60 -> no hints at all const _H1_THRESHOLD_MIN = 30; const _H3_THRESHOLD_DAYS = 5; function _dedupeWindowMs(key) { // H2 ("h2") and H3 ("h3:") are explicitly "max 1x/Tag" in the design; // everything else (H1, future rules) falls back to the general 4h dedupe. if (key === "h2" || key.indexOf("h3:") === 0) return _DEDUPE_MS_DAILY; return _DEDUPE_MS_DEFAULT; } // --------------------------------------------------------------------------- // localStorage (only impure functions in this file) // --------------------------------------------------------------------------- function loadHintState() { try { const raw = window.localStorage.getItem(_STORAGE_KEY); if (!raw) return { lastShownAt: 0, dedupe: {} }; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return { lastShownAt: 0, dedupe: {} }; const dedupe = (parsed.dedupe && typeof parsed.dedupe === "object" && !Array.isArray(parsed.dedupe)) ? parsed.dedupe : {}; const lastShownAt = typeof parsed.lastShownAt === "number" ? parsed.lastShownAt : 0; return { lastShownAt, dedupe }; } catch (_) { // Corrupt JSON, storage disabled (private mode), quota errors, ... -> silent default. return { lastShownAt: 0, dedupe: {} }; } } function saveHintState(state) { try { const lastShownAt = (state && typeof state.lastShownAt === "number") ? state.lastShownAt : 0; const dedupe = (state && state.dedupe && typeof state.dedupe === "object") ? state.dedupe : {}; window.localStorage.setItem(_STORAGE_KEY, JSON.stringify({ lastShownAt, dedupe })); } catch (_) { // Storage unavailable — hints just won't be deduped across reloads. No crash. } } // --------------------------------------------------------------------------- // deriveHints — pure. Turns a /web/jarvis snapshot into candidate hints. // Staleness-Gate lives here because it is entirely data-driven (age_min / // stale_fields come from the snapshot itself, no persisted state needed). // --------------------------------------------------------------------------- function deriveHints({ jarvis, now }) { const _now = now instanceof Date ? now : new Date(); if (!jarvis || jarvis.available === false) return []; // Total blackout when the pre-cache snapshot itself is too old to trust. if (typeof jarvis.age_min === "number" && jarvis.age_min > _STALE_BLACKOUT_MIN) return []; const staleFields = Array.isArray(jarvis.stale_fields) ? jarvis.stale_fields : []; const fields = jarvis.fields || {}; const logic = (typeof window !== "undefined" && window.JarvisCardLogic) || {}; const out = []; // --- H1: next meeting in 0 < t <= 30 Min ----------------------------- const calendarStale = staleFields.indexOf("calendar_today") !== -1 || staleFields.indexOf("calendar_tomorrow") !== -1; if (!calendarStale && typeof logic.nextEvent === "function") { const calToday = fields.calendar_today; const calTom = fields.calendar_tomorrow; const todayItems = (calToday && calToday.items) || []; const tomItems = (calTom && calTom.items) || []; const next = logic.nextEvent(todayItems, tomItems, _now); if (next && next.isToday) { const diffMin = (next.startsAt.getTime() - _now.getTime()) / 60000; if (diffMin > 0 && diffMin <= _H1_THRESHOLD_MIN) { const roundedMin = Math.max(1, Math.round(diffMin)); const title = (next.item && typeof next.item.title === "string" && next.item.title) || "Termin"; out.push({ key: "h1", prio: 1, level: "warn", text: `Meeting "${title}" in ${roundedMin} Min - Prep bereit?`, action: "prep_meeting", }); } } } // --- H2: overdue tasks, max 1x/Tag ----------------------------------- const tasksStale = staleFields.indexOf("tasks_open") !== -1; if (!tasksStale && typeof logic.overdueTasks === "function") { const tasksField = fields.tasks_open; const items = (tasksField && tasksField.items) || []; const todayMidnight = new Date(_now); todayMidnight.setHours(0, 0, 0, 0); const overdue = logic.overdueTasks(items, todayMidnight); if (overdue.length > 0) { out.push({ key: "h2", prio: 2, level: "info", text: `${overdue.length} überfällige Aufgabe${overdue.length === 1 ? "" : "n"}`, }); } } // --- H3: deal follow-up >= 5 Tage in der Vergangenheit, max 1x/Tag/Deal const dealsStale = staleFields.indexOf("hubspot_active_deals_top5") !== -1; if (!dealsStale && typeof logic.parseDue === "function") { const dealsField = fields.hubspot_active_deals_top5; const items = (dealsField && dealsField.items) || []; const todayMidnight = new Date(_now); todayMidnight.setHours(0, 0, 0, 0); let oldest = null; // pick the single most-overdue deal (one hint slot anyway) for (const item of items) { if (!item || typeof item !== "object") continue; // CRM era (#40): the date slot is "closedate" (Zieltermin); HubSpot-era // snapshots may still carry "next_activity_date" - accept both. const due = logic.parseDue(item.closedate || item.next_activity_date || ""); if (!due) continue; const diffDays = Math.round((todayMidnight.getTime() - due.getTime()) / 86400000); if (diffDays >= _H3_THRESHOLD_DAYS) { if (!oldest || diffDays > oldest.diffDays) oldest = { item, diffDays }; } } if (oldest) { // QS-JV-4: the key is persisted to localStorage on dismiss - it must // never carry content (deal NAME). Id-less deals share the fallback key. const dealId = oldest.item.id || "unknown"; const dealName = (typeof oldest.item.name === "string" && oldest.item.name) || "Deal"; out.push({ key: `h3:${dealId}`, prio: 3, level: "info", text: `Deal "${dealName}": Zieltermin seit ${oldest.diffDays} Tagen überfällig`, }); } } return out; } // --------------------------------------------------------------------------- // pickHint — pure. Applies the Anti-Nerv gates and returns at most one hint. // --------------------------------------------------------------------------- function pickHint({ hints, state, appState, loadedAt, now }) { const _now = now instanceof Date ? now.getTime() : (typeof now === "number" ? now : Date.now()); const _loadedAt = loadedAt instanceof Date ? loadedAt.getTime() : loadedAt; // Ruhe-Gate: only while the app is idle/ready, and never in the first 10s. if (appState !== "ready") return null; if (typeof _loadedAt === "number" && (_now - _loadedAt) < _QUIET_AFTER_LOAD_MS) return null; const st = state || { lastShownAt: 0, dedupe: {} }; const dedupe = st.dedupe || {}; const lastShownAt = typeof st.lastShownAt === "number" ? st.lastShownAt : 0; const candidates = (hints || []).filter((h) => { if (!h || !h.key || typeof h.prio !== "number") return false; const lastForKey = dedupe[h.key]; if (typeof lastForKey === "number" && (_now - lastForKey) < _dedupeWindowMs(h.key)) return false; // Global cooldown — H1 is exempt (design §4: time-critical). if (h.prio !== 1 && (_now - lastShownAt) < _COOLDOWN_MS) return false; return true; }); if (candidates.length === 0) return null; candidates.sort((a, b) => a.prio - b.prio); return candidates[0]; } // --------------------------------------------------------------------------- // HintBanner — dedicated banner, top-right under the clock. NOT the system // toast. Colors come from styles.css (.hint-banner--info / --warn) so this // file needs no color constants of its own. Click = dismiss; the optional // action button fires onAction(hint.action) then also dismisses. // // SR-JV-6 guardrail: hint.text is attacker-influenced (mail subject / deal // name / meeting title flow through deriveHints). It is rendered as a plain // JSX text child only — no dangerouslySetInnerHTML, no href/src built from // hint data. // --------------------------------------------------------------------------- const _ACTION_LABELS = { prep_meeting: "Prep" }; function HintBanner({ hint, onDismiss, onAction }) { if (!hint) return null; const levelClass = hint.level === "warn" ? "hint-banner--warn" : "hint-banner--info"; const actionLabel = (hint.action && _ACTION_LABELS[hint.action]) || "Los"; return (
{ if (typeof onDismiss === "function") onDismiss(hint); }} > {hint.text} {hint.action && ( )}
); } window.ConnieHints = { deriveHints, pickHint, loadHintState, saveHintState, HintBanner };