/* ── Claire v2 — Journal + Conversation ─────────────── */ /* Mobile-first · Single page · No dashboard ············ */ (function() { 'use strict'; // ── State ────────────────────────────────────────────── let _token = localStorage.getItem('claire_token') || ''; let _user = null; let _chatOpen = false; let _chatHistory = []; let _recording = false; let _mediaRec = null; let _carMode = false; let _carSpeaking = false; let _carWakeLock = null; let _onboarding = false; let _onboardingStep = 0; let _onboardingAnswers = {}; let _timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Paris'; let _utcOffset = new Date().getTimezoneOffset(); // minutes // ── CSRF helper ───────────────────────────────────────── function getCsrfToken() { const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/); return match ? decodeURIComponent(match[1]) : ''; } // ── API helper ───────────────────────────────────────── async function api(path, opts = {}) { const headers = { ...(opts.headers || {}) }; if (_token) headers['Authorization'] = `Bearer ${_token}`; const csrf = getCsrfToken(); if (csrf) headers['x-csrf-token'] = csrf; if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } const res = await fetch(path, { ...opts, headers, credentials: 'include' }); if (res.status === 401) { logout(); throw new Error('Non authentifié'); } if (!res.ok) { const txt = await res.text().catch(() => ''); throw new Error(txt || `Erreur ${res.status}`); } const ct = res.headers.get('content-type') || ''; return ct.includes('json') ? res.json() : res.text(); } // ── Local persistence layer ──────────────────────────── // Tout est en localStorage + sync serveur quand dispo const cache = { set(key, value, ttlMs = 0) { const entry = { v: value, t: Date.now() }; if (ttlMs > 0) entry.e = Date.now() + ttlMs; localStorage.setItem(`claire_${key}`, JSON.stringify(entry)); }, get(key, defaultVal = null) { const raw = localStorage.getItem(`claire_${key}`); if (!raw) return defaultVal; try { const entry = JSON.parse(raw); if (entry.e && Date.now() > entry.e) { localStorage.removeItem(`claire_${key}`); return defaultVal; } return entry.v; } catch { return defaultVal; } }, remove(key) { localStorage.removeItem(`claire_${key}`); }, clear() { const keys = Object.keys(localStorage).filter(k => k.startsWith('claire_')); keys.forEach(k => localStorage.removeItem(k)); } }; // Cache API responses with TTL const _apiCache = {}; async function cachedApi(path, opts = {}, ttlMs = 60000) { const cacheKey = `${opts.method || 'GET'}_${path}`; // POST/PUT/DELETE = no cache if (opts.method && opts.method !== 'GET') return api(path, opts); // Check memory cache first (fastest) if (_apiCache[cacheKey] && _apiCache[cacheKey].exp > Date.now()) { return _apiCache[cacheKey].data; } // Check localStorage (survives refresh) const stored = cache.get(`api_${cacheKey}`); if (stored) { _apiCache[cacheKey] = { data: stored, exp: Date.now() + ttlMs }; return stored; } // Fetch from server const data = await api(path, opts); // Never cache sensitive endpoints in localStorage const sensitivePatterns = ['/api/login', '/api/accounts', '/api/ai-logs', '/api/account/export']; const isSensitive = sensitivePatterns.some(p => path.includes(p)); // Store in memory cache always, localStorage only if not sensitive _apiCache[cacheKey] = { data, exp: Date.now() + ttlMs }; if (!isSensitive) { cache.set(`api_${cacheKey}`, data, ttlMs); } return data; } // ── DOM refs ─────────────────────────────────────────── const $ = id => document.getElementById(id); const authScreen = $('auth-screen'); const appScreen = $('app-screen'); const loginForm = $('login-form'); const authError = $('auth-error'); const journal = $('journal-content'); const journalLoad = $('journal-loading'); const chatConv = $('chat-conversation'); const chatInput = $('chat-input'); const btnSend = $('btn-send'); const btnVoice = $('btn-voice'); const toast = $('toast'); // ── Theme ────────────────────────────────────────────── function initTheme() { const saved = localStorage.getItem('claire_theme') || 'light'; document.documentElement.setAttribute('data-theme', saved); ensureThemeToggle(); } function toggleTheme() { const current = document.documentElement.getAttribute('data-theme') || 'light'; const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('claire_theme', next); const btn = document.getElementById('theme-toggle-btn'); if (btn) btn.textContent = next === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'; } function ensureThemeToggle() { if (document.getElementById('theme-toggle-btn')) return; const btn = document.createElement('button'); btn.id = 'theme-toggle-btn'; btn.className = 'theme-toggle'; btn.onclick = toggleTheme; const theme = document.documentElement.getAttribute('data-theme') || 'light'; btn.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'; document.body.appendChild(btn); } // ── Mobile keyboard handler (visualViewport) ─────────── function initMobileKeyboard() { if (!window.visualViewport) return; const chatBar = document.querySelector('.chat-bar'); const chatConv = document.querySelector('.chat-conversation'); if (!chatBar) return; function onViewportResize() { const vv = window.visualViewport; const bottomOffset = window.innerHeight - vv.height - vv.offsetTop; if (bottomOffset > 50) { // Keyboard is open chatBar.style.bottom = bottomOffset + 'px'; if (chatConv) chatConv.style.setProperty('--chat-bar-h', (bottomOffset + 72) + 'px'); } else { chatBar.style.bottom = ''; if (chatConv) chatConv.style.removeProperty('--chat-bar-h'); } } window.visualViewport.addEventListener('resize', onViewportResize); window.visualViewport.addEventListener('scroll', onViewportResize); } // ── Init ─────────────────────────────────────────────── async function init() { initTheme(); bindEvents(); initSwipeDismiss(); initMobileKeyboard(); // Esc key closes modals document.addEventListener('keydown', (e) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === 'Escape') { // Fermer le chat en priorité if (_chatOpen) { closeChat(); e.preventDefault(); return; } const modal = document.querySelector('.modal-overlay, .privacy-modal-overlay'); if (modal) { modal.remove(); e.preventDefault(); return; } } }); if (_token) { try { _user = await api('/api/me'); showApp(); } catch { showAuth(); } } else { showAuth(); } } // ── Auth ─────────────────────────────────────────────── function showAuth() { authScreen.style.display = ''; appScreen.classList.remove('active'); } async function showApp() { authScreen.style.display = 'none'; appScreen.classList.add('active'); initSocket(); // Restore chat history from localStorage (survives page refresh) const savedChat = cache.get('chat_history'); if (savedChat && savedChat.length > 0) { _chatHistory = savedChat; chatConv.innerHTML = ''; for (const msg of savedChat.slice(-15)) { const div = document.createElement('div'); div.className = `chat-msg ${msg.role}`; div.innerHTML = formatChatText(msg.text); chatConv.appendChild(div); } } else { // No local chat — fresh login. Load last 20 messages from server DB. try { const histRes = await api('/api/chat/history?limit=20'); const msgs = histRes && histRes.messages ? histRes.messages : []; if (msgs.length > 0) { chatConv.innerHTML = ''; for (const msg of msgs) { const role = msg.role === 'user' ? 'user' : 'claire'; appendChatMsg(role, msg.content); } } } catch(e) { /* chat history load failed — not critical */ } } // Restore page state — where the user left off const lastPage = cache.get('current_page'); if (lastPage === 'chat' && _chatHistory.length > 0) { // Re-open chat where user left off openChat(); scrollChat(); // Add a "welcome back" if last message was > 5 min ago const lastMsgTime = cache.get('last_chat_time'); if (!lastMsgTime || Date.now() - lastMsgTime > 5 * 60 * 1000) { const div = document.createElement('div'); div.className = 'chat-msg claire'; div.innerHTML = formatChatText("Je suis toujours là. On en était où ?"); chatConv.appendChild(div); scrollChat(); } } // Check if user needs onboarding (no memories = first time) try { const memories = await api('/api/memories'); const hasOnboarding = (memories || []).some(m => m.source === 'onboarding'); if (!hasOnboarding && (memories || []).length < 3) { startOnboarding(); return; } } catch { /* memories endpoint may fail — skip onboarding */ } loadJournal(); } function logout() { _token = ''; _user = null; localStorage.removeItem('claire_token'); cache.clear(); // wipe all cached data on logout showAuth(); } async function handleLogin(e) { e.preventDefault(); authError.textContent = ''; const user = $('login-user').value.trim(); const pass = $('login-pass').value; if (!user || !pass) return; try { const res = await api('/api/login', { method: 'POST', body: { username: user, password: pass, timezone: _timezone } }); _token = res.token || ''; if (_token) localStorage.setItem('claire_token', _token); _user = await api('/api/me'); showApp(); } catch (err) { authError.textContent = 'Identifiant ou mot de passe incorrect'; } } // ── Onboarding interview ─────────────────────────────── const ONBOARDING_QUESTIONS = [ { key: 'name', claire: "Salut ! Moi c'est Claire, ta nouvelle assistante. Je vais te poser quelques questions pour mieux te connaître. Plus tu m'en dis, plus je serai efficace — un peu comme une prise de poste, sauf que je retiens tout. Comment tu t'appelles ?", memory_type: 'identity', memory_key: 'name', saveTo: 'display_name' }, { key: 'tea', claire: null, // dynamic — uses name memory_type: 'preference', memory_key: 'drink', quick: true }, { key: 'who', claire: null, // dynamic memory_type: 'identity', memory_key: 'role' }, { key: 'biz', claire: "Et au quotidien, ça ressemble à quoi ? Le type de journée, les gens avec qui tu échanges, ce qui prend le plus de temps.", memory_type: 'identity', memory_key: 'activity' }, { key: 'family', claire: "Et côté perso ? Tu as de la famille, des enfants ? Ça m'aide à comprendre ton rythme.", memory_type: 'identity', memory_key: 'family' }, { key: 'hobbies', claire: "En dehors du travail, tu fais quoi ? Sport, lecture, voyages, autre chose ?", memory_type: 'identity', memory_key: 'hobbies' }, { key: 'superpower', claire: "Si tu pouvais me déléguer une seule chose, ce serait quoi ?", memory_type: 'preference', memory_key: 'superpower', quick: true }, { key: 'hours', claire: "Tu es plutôt du matin ou du soir ? Pour savoir quand te solliciter et quand te laisser tranquille.", memory_type: 'preference', memory_key: 'schedule' }, { key: 'style', claire: "Pour les mails, tu préfères un ton pro ou plutôt détendu ? Ou ça dépend de la personne ?", memory_type: 'preference', memory_key: 'tone', saveTo: 'tone_preference', toneMap: { 'formel': 'professionnel', 'pro': 'professionnel', 'sérieuse': 'professionnel', 'sérieux': 'professionnel', 'détendu': 'chaleureux', 'naturel': 'chaleureux', 'cool': 'chaleureux', 'dépend': 'direct', 'les deux': 'direct', 'entre': 'direct', 'mix': 'direct' } }, { key: 'routines', claire: "Tu as des activités récurrentes qui ne sont pas dans ton agenda ? Sport le mardi, école des enfants à 16h30, dîner du dimanche... Ces créneaux qu'on ne note pas mais qui comptent.", memory_type: 'preference', memory_key: 'routines' }, { key: 'pet_peeve', claire: "Dernière question — qu'est-ce qui te prend le plus de temps ou d'énergie dans ta journée ? La tâche que tu aimerais ne plus gérer.", memory_type: 'preference', memory_key: 'pet_peeve', quick: true }, { key: 'setup-email', claire: null, // dynamic type: 'setup' }, { key: 'setup-calendar', claire: null, type: 'setup' }, { key: 'done', claire: null } ]; async function startOnboarding() { _onboarding = true; // Try to restore from localStorage first (same device) const saved = localStorage.getItem('claire_onboarding'); if (saved) { try { const data = JSON.parse(saved); _onboardingStep = data.step || 0; _onboardingAnswers = data.answers || {}; } catch { _onboardingStep = 0; _onboardingAnswers = {}; } } else { // Fallback: check server memories to resume from another device _onboardingStep = 0; _onboardingAnswers = {}; try { const memories = await api('/api/memories'); const onboardingMems = (memories || []).filter(m => m.source === 'onboarding'); const keyToStep = {}; ONBOARDING_QUESTIONS.forEach((q, i) => { if (q.memory_key) keyToStep[q.memory_key] = i; }); let maxStep = 0; for (const m of onboardingMems) { // Match via subject_ref (onboarding_role, onboarding_activity, etc.) const memKey = (m.subject_ref || '').replace('onboarding_', ''); const stepIdx = keyToStep[memKey]; if (stepIdx !== undefined) { _onboardingAnswers[ONBOARDING_QUESTIONS[stepIdx].key] = m.content; if (stepIdx + 1 > maxStep) maxStep = stepIdx + 1; } } if (maxStep > 0) { _onboardingStep = maxStep; saveOnboardingProgress(); } } catch { /* no memories — start fresh */ } } journalLoad.style.display = 'none'; journal.classList.remove('hidden'); renderOnboardingStep(); } function saveOnboardingProgress() { localStorage.setItem('claire_onboarding', JSON.stringify({ step: _onboardingStep, answers: _onboardingAnswers })); } function renderOnboardingStep() { const step = ONBOARDING_QUESTIONS[_onboardingStep]; if (!step) return finishOnboarding(); if (step.key === 'done') return finishOnboarding(); const total = ONBOARDING_QUESTIONS.length - 1; // exclude 'done' const progress = `${_onboardingStep + 1}/${total}`; const userName = _onboardingAnswers.name || _user?.display_name || _user?.username || ''; // Setup steps — render with buttons instead of text input if (step.type === 'setup') { renderSetupStep(step, userName, progress, total); return; } // Generate dynamic question text let questionText = step.claire; if (step.key === 'tea' && !questionText) { questionText = userName ? `${userName}, petite question pour commencer — plutôt thé ou café ?` : "Petite question pour commencer — plutôt thé ou café ?"; } if (step.key === 'who' && !questionText) { const drink = _onboardingAnswers.tea || ''; const drinkReact = drink.toLowerCase().includes('thé') ? "Thé, très bien." : drink.toLowerCase().includes('café') ? "Café, bonne réponse." : drink.toLowerCase().includes('les deux') || drink.toLowerCase().includes('deux') ? "Les deux, je respecte." : drink ? `Noté !` : ''; questionText = userName ? `${drinkReact} ${userName}, tu fais quoi dans la vie ? Indépendant, salarié, dirigeant, étudiant... ?` : `${drinkReact} Tu fais quoi dans la vie ?`; } // Progress bar let html = `
${Array.from({length:total}, (_, i) => `
` ).join('')}
${progress}
`; if (_onboardingStep === 0) { html += `
Hello !
`; } // Show previous Q&A (only last 2 to keep it clean on mobile) const showFrom = Math.max(0, _onboardingStep - 2); for (let i = showFrom; i < _onboardingStep; i++) { const q = ONBOARDING_QUESTIONS[i]; if (q.type === 'setup') continue; // don't replay setup steps const a = _onboardingAnswers[q.key] || ''; const qText = q.claire || ''; html += `
${escHtml(qText)}
${escHtml(a)}
`; } // Current question with animation html += `
${escHtml(questionText)}
`; html += ``; journal.innerHTML = html; // Bind skip button $('onboarding-skip').addEventListener('click', () => { _onboardingStep++; saveOnboardingProgress(); if (_onboardingStep >= total) finishOnboarding(); else renderOnboardingStep(); }); // Focus chat input chatInput.placeholder = 'Ta réponse...'; chatInput.focus(); // Scroll to bottom on mobile journal.scrollTop = journal.scrollHeight; } // ── Setup steps (connect accounts) ───────────────────── function renderSetupStep(step, userName, progress, total) { let html = `
${Array.from({length:total}, (_, i) => `
` ).join('')}
${progress}
`; if (step.key === 'setup-email') { html += `
Top ${escHtml(userName || '')} ! Pour que je puisse t'aider, il me faut accès à tes mails. Quel service tu utilises ?
`; } if (step.key === 'setup-calendar') { html += `
Et ton agenda ? Si tu connectes ton calendrier, je pourrai te briefer sur tes RDV et éviter les conflits quand on planifie.
`; } html += ``; journal.innerHTML = html; // Bind email provider cards journal.querySelectorAll('.setup-card[data-provider]').forEach(card => { card.addEventListener('click', () => { const provider = card.dataset.provider; const providerConfigs = { gmail: { host: 'imap.gmail.com', port: 993, smtp: 'smtp.gmail.com', smtpPort: 465, instruction: "Pour Gmail, il te faut un \"mot de passe d'application\" \u2014 pas ton mot de passe Google normal. Va dans myaccount.google.com \u2192 S\u00e9curit\u00e9 \u2192 Mots de passe des applications. Je t'attends !" }, outlook: { host: 'outlook.office365.com', port: 993, smtp: 'smtp.office365.com', smtpPort: 587, instruction: "Entre ton adresse Outlook et ton mot de passe. Si tu as la double authentification, il te faut un mot de passe d'application (account.microsoft.com \u2192 S\u00e9curit\u00e9)." }, ovh: { host: 'ssl0.ovh.net', port: 993, smtp: 'ssl0.ovh.net', smtpPort: 465, instruction: "Entre ton adresse OVH et ton mot de passe mail. C'est le m\u00eame que celui de ton webmail." }, other: { host: '', port: 993, smtp: '', smtpPort: 465, instruction: "Entre ton adresse email et ton mot de passe. J'essaierai de d\u00e9tecter les param\u00e8tres automatiquement." } }; const cfg = providerConfigs[provider]; _onboardingAnswers._emailProvider = provider; _onboardingAnswers._emailConfig = cfg; journal.querySelectorAll('.setup-card[data-provider]').forEach(c => c.classList.remove('active')); card.classList.add('active'); const form = $('setup-email-form'); form.classList.remove('hidden'); $('setup-email-instruction').textContent = cfg.instruction; $('setup-email-addr').focus(); $('setup-email-connect').onclick = () => connectEmailOnboarding(provider, cfg); }); }); // Bind calendar cards journal.querySelectorAll('.setup-card[data-cal]').forEach(card => { card.addEventListener('click', async () => { const cal = card.dataset.cal; if (cal === 'google') { window.open(`/api/google/auth?token=${_token}`, '_blank'); showToast('Connecte ton compte Google dans le nouvel onglet'); } else if (cal === 'outlook') { window.open(`/api/microsoft/auth?token=${_token}`, '_blank'); showToast('Connecte ton compte Microsoft dans le nouvel onglet'); } else if (cal === 'ical') { showToast('Fonctionnalité iCal bientôt disponible'); } }); }); // Bind skip — "Plus tard" const skipBtn = $('onboarding-skip'); if (skipBtn) { skipBtn.addEventListener('click', () => { _onboardingStep++; saveOnboardingProgress(); if (_onboardingStep >= total) finishOnboarding(); else renderOnboardingStep(); }); } journal.scrollTop = journal.scrollHeight; } async function connectEmailOnboarding(provider, cfg) { const email = $('setup-email-addr').value.trim(); const password = $('setup-email-pass').value; const errEl = $('setup-email-error'); errEl.textContent = ''; if (!email || !password) { errEl.textContent = 'Remplis les deux champs'; return; } $('setup-email-connect').disabled = true; $('setup-email-connect').innerHTML = 'Connexion en cours...'; try { await api('/api/accounts', { method: 'POST', body: { label: provider === 'other' ? 'Email' : provider.charAt(0).toUpperCase() + provider.slice(1), provider, email, password, imap_host: cfg.host, imap_port: cfg.port, imap_secure: true, smtp_host: cfg.smtp, smtp_port: cfg.smtpPort, smtp_secure: true } }); showToast('Compte connecté ! 🎉'); // Move to next step _onboardingStep++; setTimeout(() => { if (_onboardingStep >= ONBOARDING_QUESTIONS.length - 1) finishOnboarding(); else renderOnboardingStep(); }, 1000); } catch (err) { if (err.message && err.message.includes('déjà connecté')) { errEl.textContent = ''; // Already connected — skip to next step showToast('Ce compte est déjà connecté ! Tu veux en ajouter un autre ?'); $('setup-email-connect').disabled = false; $('setup-email-connect').innerHTML = 'Connecter'; $('setup-email-addr').value = ''; $('setup-email-pass').value = ''; // Deselect provider cards to let user pick another journal.querySelectorAll('.setup-card[data-provider]').forEach(c => c.classList.remove('active')); $('setup-email-form').classList.add('hidden'); } else { errEl.textContent = 'Connexion échouée — vérifie tes identifiants et réessaie'; $('setup-email-connect').disabled = false; $('setup-email-connect').innerHTML = 'Réessayer'; } } } async function handleOnboardingAnswer(text) { const step = ONBOARDING_QUESTIONS[_onboardingStep]; if (!step || step.key === 'done') return; // Save answer _onboardingAnswers[step.key] = text; // Save as memory if (step.memory_key) { try { await api('/api/memories', { method: 'POST', body: { type: 'user', subject_ref: `onboarding_${step.memory_key}`, content: _onboardingAnswers[step.key], source: 'onboarding' } }); } catch { /* non-blocking */ } } // Save display_name if this is the name question if (step.saveTo === 'display_name') { try { await api('/api/account', { method: 'PUT', body: { display_name: text.trim() } }); if (_user) _user.display_name = text.trim(); } catch {} } // Save tone_preference if (step.saveTo === 'tone_preference' && step.toneMap) { const lower = text.toLowerCase(); let tone = 'chaleureux'; for (const [keyword, value] of Object.entries(step.toneMap)) { if (lower.includes(keyword)) { tone = value; break; } } try { await api('/api/account', { method: 'PUT', body: { tone_preference: tone } }); } catch {} } _onboardingStep++; saveOnboardingProgress(); if (_onboardingStep >= ONBOARDING_QUESTIONS.length - 1) { finishOnboarding(); } else { renderOnboardingStep(); } } function finishOnboarding() { _onboarding = false; localStorage.removeItem('claire_onboarding'); const name = _onboardingAnswers.name || _user?.display_name || _user?.username || 'toi'; const closing = `Merci ${name}, c'est tout ce dont j'ai besoin pour commencer !\n\nJe lis tes mails et je te prépare ton premier briefing...`; journal.innerHTML = `
${escHtml(closing)}
`; chatInput.placeholder = 'Parle-moi...'; // Load journal (briefing) immediately setTimeout(() => loadJournal(), 1500); } // ── Journal (the heart of v2) ────────────────────────── async function loadJournal() { const skeletonHtml = '
'; // Show cached briefing instantly if available (while fresh one loads) const cachedBriefing = cache.get('last_briefing'); if (cachedBriefing) { renderJournal(cachedBriefing); journal.classList.remove('hidden'); journalLoad.style.display = 'none'; } else { // Show skeleton loading instead of dots journal.classList.remove('hidden'); journal.innerHTML = skeletonHtml; journalLoad.style.display = 'none'; } try { // Sync sent emails first — so we know what's already replied await api('/api/emails/sync-replied', { method: 'POST' }).catch(() => {}); // Fetch all data in parallel const [briefing, overdueTasks, followups, financialAlerts, dashboard] = await Promise.all([ api('/api/briefing'), api('/api/tasks/overdue').catch(() => []), api('/api/tasks/followups').catch(() => []), api('/api/financial-documents/alerts').catch(() => ({})), api('/api/dashboard').catch(() => ({})) ]); // Enrich briefing with extra data briefing._overdue = Array.isArray(overdueTasks) ? overdueTasks : overdueTasks?.tasks || []; briefing._followups = Array.isArray(followups) ? followups : followups?.tasks || []; briefing._financial = financialAlerts; briefing._dashboard = dashboard; cache.set('last_briefing', briefing, 10 * 60 * 1000); // If briefing is completely empty (first sync), show a friendly message const hasEmails = (briefing.action_emails || briefing.actionEmails || []).length > 0 || (briefing.waiting_emails || []).length > 0 || (briefing.info_emails || []).length > 0; const hasEvents = (briefing.events || briefing.today_events || briefing.todayEvents || []).length > 0; if (!hasEmails && !hasEvents) { journal.innerHTML = `
Claire analyse tes mails...
Première synchronisation en cours. Le briefing apparaîtra dès que tes mails seront lus.
`; } else { renderJournal(briefing); } } catch (err) { if (!cachedBriefing) { journal.innerHTML = `

Hmm, je n'arrive pas à charger ton briefing. ${err.message}

`; } } journalLoad.style.display = 'none'; journal.classList.remove('hidden'); // Init service status bar (non-blocking) initServiceStatusBar(); } function renderJournal(b) { const name = _user?.display_name || _user?.username || 'toi'; const hour = parseInt(new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', timeZone: _timezone })); const greeting = hour < 12 ? `Bonjour ${name},` : hour < 18 ? `${name},` : `Bonsoir ${name},`; const morningQuips = [ "j'ai fait le tour pendant que tu dormais.", "j'ai lu tes mails pour que tu n'aies pas à le faire.", "voici ce qui mérite ton attention.", "j'ai trié, tu valides.", "installe-toi, je t'ai préparé le résumé.", ]; const afternoonQuips = [ "petit point de mi-journée.", "voici ce qui a bougé depuis ce matin.", "un résumé rapide de l'après-midi.", ]; const eveningQuips = [ "voici le bilan de ta journée et ce qui t'attend demain.", "avant de décrocher, un petit résumé.", "la journée touche à sa fin — voici où on en est.", "dernier point avant de souffler.", ]; const quips = hour < 12 ? morningQuips : hour < 18 ? afternoonQuips : eveningQuips; const quip = quips[Math.floor(Math.random() * quips.length)]; const isEvening = hour >= 18; let html = `
${greeting}
${quip}
`; // ── Charge mentale du jour ── if (b.charge_mentale && b.charge_mentale.cognitive_items > 0) { const cm = b.charge_mentale; html += `
`; html += `
Charge mentale du jour
`; // Cognitive load indicator const load = cm.cognitive_items; const loadLabel = load <= 3 ? 'Légère' : load <= 6 ? 'Normale' : load <= 10 ? 'Chargée' : 'Lourde'; const loadColor = load <= 3 ? '#27864a' : load <= 6 ? '#c67a2e' : '#c0392b'; html += `
${loadLabel} — ${load} éléments à gérer
`; // Top priorities (enriched with urgency labels, estimated time, draft badge) if (cm.top_priorities && cm.top_priorities.length > 0) { html += `
Actions prioritaires :
`; const urgencyColors = { 'URGENT': '#c0392b', "AUJOURD'HUI": '#d4760a', 'NOUVEAU': '#27864a', 'PRÉPARER': '#2c6fbb' }; cm.top_priorities.forEach(p => { const icon = p.type === 'task' ? '☐' : p.type === 'event' ? '📅' : '✉'; const color = urgencyColors[p.urgency_label] || '#6b6055'; const numberEmoji = ['1️⃣', '2️⃣', '3️⃣'][p.rank - 1] || '▪'; const draftBadge = p.has_draft ? ' brouillon' : ''; const timeLabel = p.estimated_minutes ? ` ⏱ ${p.estimated_minutes}min` : ''; const timeTag = p.time ? ` ${p.time}` : ''; html += `
${numberEmoji} ${p.urgency_label || ''} · ${p.who ? escHtml(p.who) : ''}${draftBadge}${timeLabel}${timeTag}
${escHtml(p.what)}
${p.ai_summary ? `
→ ${escHtml(p.ai_summary)}
` : ''}
`; }); } // Blockers if (cm.blockers_count > 0) { html += `
${cm.blockers_count} blocage${cm.blockers_count > 1 ? 's' : ''} détecté${cm.blockers_count > 1 ? 's' : ''}
`; } // Can wait if (cm.can_wait_count > 0) { html += `
${cm.can_wait_count} élément${cm.can_wait_count > 1 ? 's' : ''} peu${cm.can_wait_count > 1 ? 'vent' : 't'} attendre
`; } // Next action if (cm.next_action) { html += `
→ Prochain pas : ${escHtml(cm.next_action)}
`; } // Compact agenda preview inside charge mentale const cmEvents = (b.events || b.today_events || b.todayEvents || []) .filter(ev => { const t = ev.time || ev.start_time || ev.start_dt; return t && new Date(t) > new Date(); }).slice(0, 3); if (cmEvents.length > 0) { html += `
`; html += `📅 Agenda : `; html += cmEvents.map(ev => { const t = ev.time || ev.start_time || ev.start_dt; const et = ev.end_time || ev.end_dt || ''; const hhmm = new Date(t).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }); const endHhmm = et ? new Date(et).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }) : ''; const timeStr = endHhmm && endHhmm !== hhmm ? `${hhmm}–${endHhmm}` : hhmm; return `${timeStr} ${escHtml((ev.title || ev.summary || '').substring(0, 30))}`; }).join(' · '); html += `
`; } // Day load (agenda-based) with charge mentale /10 and slot sequencing const dl = cm.day_load; if (dl) { const scoreDisplay = dl.score10 != null ? dl.score10 : Math.round(dl.score / 10); const stateColors = { light: '#27864a', balanced: '#c67a2e', dense: '#d4760a', overloaded: '#c0392b' }; const color = stateColors[dl.state] || '#6b6055'; html += `
`; html += `
Charge mentale : ${scoreDisplay}/10 (${dl.label.toLowerCase()})
`; // Factors explaining the score if (dl.factors && dl.factors.length > 0) { dl.factors.forEach(f => { html += `
\u00b7 ${escHtml(f)}
`; }); } // Remaining capacity html += `
Capacité restante : ${dl.remaining_label}
`; // Suggestion if (dl.suggestion) { html += `
\ud83d\udca1 ${escHtml(dl.suggestion)}
`; } // Smart slot sequencing — free gaps with task suggestions if (dl.gaps && dl.gaps.length > 0) { dl.gaps.forEach(gap => { const startH = new Date(gap.start).getHours(); const startM = new Date(gap.start).getMinutes(); const startLabel = `${String(startH).padStart(2,'0')}h${startM > 0 ? String(startM).padStart(2,'0') : ''}`; html += `
`; html += `
\ud83d\udca1 ${gap.minutes}min libres \u00e0 ${startLabel}
`; if (gap.sequence && gap.sequence.length > 0) { let cursor = new Date(gap.start); gap.sequence.forEach(s => { const h = cursor.getHours(); const m = cursor.getMinutes(); const timeLabel = `${String(h).padStart(2,'0')}h${String(m).padStart(2,'0')}`; const icon = s.type === 'email' ? '\u2709' : s.type === 'task' ? '\u2610' : '\u2615'; html += `
${timeLabel} \u00b7 ${icon} ${escHtml(s.label)} (${s.minutes}min)
`; cursor = new Date(cursor.getTime() + s.minutes * 60000); }); } html += `
`; }); } html += `
`; } html += `
`; } // ── Classify emails, filtering out already-handled ones // Server sends separate arrays (action_emails, waiting_emails, info_emails) // Reconstruct a unified list with ai_category + action_status for classification below const allEmails = [ ...(b.action_emails || b.actionEmails || []).map(e => ({ ...e, ai_category: e.ai_category || 'action', action_status: e.action_status || 'pending' })), ...(b.waiting_emails || b.waitingReplies || []).map(e => ({ ...e, ai_category: e.ai_category || 'action', action_status: e.action_status || 'waiting_reply' })), ...(b.info_emails || b.infoItems || []).map(e => ({ ...e, ai_category: e.ai_category || 'info', action_status: e.action_status || 'pending' })), ]; const replied = allEmails.filter(e => e.action_status === 'replied'); const archived = allEmails.filter(e => e.action_status === 'archived'); const handled = new Set([...replied, ...archived].map(e => e.uid || e.id)); // Deduplicate: emails already shown in charge_mentale top_priorities const chargeMentalUids = new Set((b.charge_mentale?.priority_uids || []).map(String)); const actions = allEmails.filter(e => (e.triage_label === 'action' || e.ai_category === 'action') && e.action_status !== 'replied' && e.action_status !== 'archived' && e.action_status !== 'waiting_reply' && !chargeMentalUids.has(String(e.uid || e.id)) ); const waiting = allEmails.filter(e => e.action_status === 'waiting_reply' && !handled.has(e.uid || e.id) ); const infos = allEmails.filter(e => (e.triage_label === 'info' || e.ai_category === 'info') && !handled.has(e.uid || e.id) ); const noise = allEmails.filter(e => (e.triage_label === 'noise' || e.ai_category === 'noise') && !handled.has(e.uid || e.id) ); if (actions.length > 0) { html += `
`; html += `
${actions.length === 1 ? 'Un truc qui attend ta réponse' : `${actions.length} trucs qui attendent ta réponse`}
`; actions.forEach(email => { html += renderActionCard(email, 'urgent'); }); html += `
`; } // ── En attente if (waiting.length > 0) { html += `
`; html += `
En attente de réponse
`; waiting.forEach(email => { html += renderActionCard(email, 'waiting'); }); html += `
`; } // ── Calendar today (enriched with location + travel time) if (!b.events) b.events = b.today_events || b.todayEvents || []; if (b.events && b.events.length > 0) { html += `
`; html += `
À venir
`; // Max 3 événements affichés, le reste masqué derrière "Voir plus" const MAX_EVENTS = 3; const visibleEvents = b.events.slice(0, MAX_EVENTS); const hiddenEvents = b.events.slice(MAX_EVENTS); visibleEvents.forEach(ev => { const time = ev.time || ev.start_time || ev.start_dt || ''; const endTime = ev.end_time || ev.end_dt || ''; const evDate = time ? new Date(time) : null; const evEndDate = endTime ? new Date(endTime) : null; const now = new Date(); // Format relatif : "14h30–15h30", "Demain 10h–11h", "Jeu. 9h–10h" let timeLabel = '—'; if (evDate) { const hhmm = evDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }); const endHhmm = evEndDate ? evEndDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }) : ''; const timeRange = endHhmm && endHhmm !== hhmm ? `${hhmm}–${endHhmm}` : hhmm; const evDay = evDate.toLocaleDateString('fr-CA', { timeZone: _timezone }); const todayStr = now.toLocaleDateString('fr-CA', { timeZone: _timezone }); const tomorrowDate = new Date(now); tomorrowDate.setDate(tomorrowDate.getDate() + 1); const tomorrowStr = tomorrowDate.toLocaleDateString('fr-CA', { timeZone: _timezone }); if (evDay === todayStr) { timeLabel = timeRange; } else if (evDay === tomorrowStr) { timeLabel = `Demain ${timeRange}`; } else { const jour = evDate.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', timeZone: _timezone }); timeLabel = `${jour} ${timeRange}`; } } const title = ev.title || ev.summary || 'Événement'; const location = ev.location ? ` — ${ev.location}` : ''; const isPast = evDate && evDate < now; const prepHint = ev._prep_hint || ''; html += `
${timeLabel}
${escHtml(title)} ${location ? `${escHtml(location)}` : ''} ${prepHint ? `${escHtml(prepHint)}` : ''}
`; }); if (hiddenEvents.length > 0) { html += ``; html += ``; } html += `
`; } // ── Smart time suggestions (gaps between events) const timeSuggestions = b.time_suggestions || b.timeSuggestions || []; if (timeSuggestions.length > 0) { timeSuggestions.forEach(s => { html += `
💡 ${escHtml(s.message)} ${s.suggested_task ? `` : ''}
`; }); } // ── Tasks today if (!b.tasks) b.tasks = b.urgent_tasks || b.overdueTasks || []; if (b.tasks && b.tasks.length > 0) { html += `
`; html += `
Tes tâches du jour
`; html += `
`; b.tasks.forEach(t => { html += `

• ${escHtml(t.description || t.title || t.text)}

`; }); html += `
`; } // ── Overdue tasks const overdue = b._overdue || []; if (overdue.length > 0) { html += `
`; html += `
En retard
`; overdue.forEach(t => { const daysLate = t.due_date ? Math.floor((Date.now() - new Date(t.due_date).getTime()) / 86400000) : 0; html += `
${escHtml(t.title || t.description)} ${daysLate > 0 ? `${daysLate}j de retard` : ''}
`; }); html += `
`; } // ── Followups / Relances const followups = b._followups || []; if (followups.length > 0) { html += `
`; html += `
Relances prévues
`; html += `
`; followups.forEach(t => { const contact = t.contact_email ? ` → ${t.contact_email}` : ''; html += `

• ${escHtml(t.title || t.description)}${contact}

`; }); html += `
`; } // ── Stale priority contacts (no news in 14+ days) const staleContacts = b.stale_contacts || b.staleContacts || []; if (staleContacts.length > 0) { html += `
`; html += `
Contacts silencieux
`; staleContacts.forEach(c => { const name = escHtml(c.display_name || c.email); const days = c.days_silent || '?'; html += `
${name} — pas de nouvelles depuis ${days} jours
`; }); html += `
`; } // ── Financial alerts (invoices due) const fin = b._financial || {}; const finAlerts = [...(fin.overdue || []), ...(fin.due_today || []), ...(fin.due_soon || [])]; if (finAlerts.length > 0) { html += `
`; html += `
Factures & paiements
`; html += `
`; finAlerts.forEach(d => { const status = d.due_date && new Date(d.due_date) < new Date() ? '⚠️ en retard' : 'à venir'; html += `

• ${escHtml(d.title || d.description || d.from || 'Document')} — ${status}

`; }); html += `
`; } // ── Gardien (pending documents) const gardienCount = b._dashboard?.gardien_pending || 0; if (gardienCount > 0) { html += `
`; html += `
${gardienCount} document${gardienCount > 1 ? 's' : ''} en attente de classement
`; } // ── Already handled (replied) if (replied.length > 0) { html += `
`; html += `
Déjà traité — bien joué
`; html += `
`; replied.forEach(e => { const from = e.from_name || e.from_address || e.from || ''; html += `

✓ ${escHtml(from)} — ${escHtml(e.subject || '')}

`; }); html += `
`; } // ── Pending drafts (enriched with completion %) const draftsCount = b.drafts_count || 0; if (draftsCount > 0) { html += `
`; html += `
${draftsCount} brouillon${draftsCount > 1 ? 's' : ''} en attente d'envoi
`; if (b.drafts_detail && b.drafts_detail.length > 0) { b.drafts_detail.forEach(d => { const completionColor = d.completion >= 80 ? '#27864a' : d.completion >= 50 ? '#d4760a' : '#c0392b'; html += `
${escHtml(d.to || '—')} — ${escHtml(d.subject || '(sans objet)')}
État : ${d.completion}% · ⏱ ${d.completion >= 80 ? '5' : '10'}min pour finaliser
`; }); } html += `
`; } // ── Weekly radar if (b.radar && (b.radar.deadline_proche?.length > 0 || b.radar.cette_semaine?.length > 0)) { html += `
`; html += `
En radar cette semaine
`; if (b.radar.deadline_proche?.length > 0) { b.radar.deadline_proche.forEach(d => { const color = d.days_left <= 1 ? '#c0392b' : d.days_left <= 3 ? '#d4760a' : '#6b6055'; html += `
J-${d.days_left} · ${escHtml(d.title)}
`; }); } if (b.radar.cette_semaine?.length > 0) { html += `
Prochains jours :
`; b.radar.cette_semaine.slice(0, 3).forEach(e => { const d = new Date(e.date); const dayLabel = d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', timeZone: _timezone }); html += `
· ${dayLabel} — ${escHtml(e.title)}
`; }); } html += `
`; } // ── Emotional context if (b.emotional_contexts && b.emotional_contexts.length > 0) { b.emotional_contexts.forEach(ctx => { if (!ctx.note) return; html += `
${escHtml(ctx.note)}
`; }); } // ── Separator html += `
`; // ── Le reste const restCount = infos.length + noise.length; if (restCount > 0) { html += `

`; const parts = []; if (infos.length > 0) parts.push(`${infos.length} mail${infos.length > 1 ? 's' : ''} informatif${infos.length > 1 ? 's' : ''}`); if (noise.length > 0) parts.push(`${noise.length} notification${noise.length > 1 ? 's' : ''}`); html += `Le reste ? ${parts.join(' et ')} — rien qui nécessite ton intervention. J'ai archivé le bruit.`; html += `

`; } else if (actions.length === 0 && waiting.length === 0) { html += `

Rien d'urgent dans tes mails. Profite du calme.

`; } // ── Sign-off const signoffs = [ "Bonne journée. N'hésite pas à me donner des précisions sur tes contacts ou tes habitudes dans le chat — je mémorise tout pour être plus précise la prochaine fois.", "Voilà le topo. Si quelque chose n'est pas exact ou s'il me manque du contexte, dis-le-moi — j'apprends à chaque échange.", "C'est tout pour l'instant. Pense à me dire quand tu traites un truc sans moi — comme ça je reste à jour.", "Fin du briefing. Si tu veux que je retienne quelque chose (un contact, une habitude, un projet), il suffit de me le dire dans le chat.", "Je veille au grain. Plus tu m'en dis, plus je suis efficace — n'hésite pas à enrichir mes connaissances via le chat.", ]; // ── Unfinished tasks — ask when to reschedule const todayTasks = b.tasks || []; const unfinishedToday = todayTasks.filter(t => t.status !== 'done' && t.status !== 'cancelled'); if (isEvening && unfinishedToday.length > 0) { html += `
`; html += `
Pas terminé aujourd'hui
`; unfinishedToday.forEach(t => { html += `
${escHtml(t.title || t.description)}
`; }); html += `
`; } // ── Tomorrow preview (evening only, AFTER today's content) if (isEvening) { const tomorrowEvents = b.tomorrow_events || []; const tomorrowTasks = b.tomorrow_tasks || []; if (tomorrowEvents.length > 0 || tomorrowTasks.length > 0) { html += `
`; html += `
`; html += `
Demain
`; if (tomorrowEvents.length > 0) { tomorrowEvents.forEach(ev => { const time = ev.time || ev.start_time || ev.start_dt || ''; const endTime = ev.end_time || ev.end_dt || ''; const shortTime = time ? new Date(time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }) : ''; const shortEnd = endTime ? new Date(endTime).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: _timezone }) : ''; const timeDisplay = shortTime && shortEnd && shortEnd !== shortTime ? `${shortTime}–${shortEnd}` : shortTime; const location = ev.location ? ` — ${ev.location}` : ''; html += `
${timeDisplay || '—'}
${escHtml(ev.title || ev.summary || '')} ${location ? `${escHtml(location)}` : ''}
`; }); } if (tomorrowTasks.length > 0) { html += `
`; tomorrowTasks.forEach(t => { html += `

• ${escHtml(t.title || t.description)}

`; }); html += `
`; } html += `
`; } } // ── Quick actions bar html += `
`; // ── Suggestions proactives const suggestions = []; if (waiting.length > 0) { const oldest = waiting.reduce((a, b) => (new Date(a.date) < new Date(b.date) ? a : b)); const daysWaiting = Math.floor((Date.now() - new Date(oldest.date).getTime()) / 86400000); if (daysWaiting >= 3) { const fromName = oldest.from_name || oldest.from_address || oldest.from || 'un contact'; suggestions.push({ text: `${fromName} n'a pas répondu depuis ${daysWaiting} jours. Tu veux que je relance ?`, action: `Relance ${fromName} pour le mail "${oldest.subject || ''}"`, icon: '🔔' }); } } if (b.tasks) { const overdue = (b.tasks || []).filter(t => t.due_date && new Date(t.due_date) < new Date()); if (overdue.length > 0) { suggestions.push({ text: `Tu as ${overdue.length} tâche${overdue.length > 1 ? 's' : ''} en retard. On s'en occupe ?`, action: 'Montre-moi mes tâches en retard', icon: '⚠' }); } } if (suggestions.length > 0) { html += `
Suggestion
`; suggestions.slice(0, 1).forEach(s => { html += `
${s.icon} ${escHtml(s.text)}
`; }); html += `
`; } // ── Streak const streakDays = cache.get('streak_days') || 0; const lastVisit = cache.get('last_visit_date'); const todayDate = new Date().toLocaleDateString('fr-CA'); let newStreak = streakDays; if (lastVisit === todayDate) { // Already visited today } else if (lastVisit === new Date(Date.now() - 86400000).toLocaleDateString('fr-CA')) { newStreak = streakDays + 1; } else if (!lastVisit) { newStreak = 1; } else { newStreak = 1; // streak broken } cache.set('streak_days', newStreak, 365 * 24 * 60 * 60 * 1000); cache.set('last_visit_date', todayDate, 365 * 24 * 60 * 60 * 1000); if (newStreak >= 3) { html += `
${newStreak} jours avec Claire
`; } // ── Sign-off html += `
${signoffs[Math.floor(Math.random() * signoffs.length)]}
`; // ── Weekly report (if Monday) const dayOfWeek = new Date().getDay(); if (dayOfWeek === 1) { html += `
`; } journal.innerHTML = html; bindCardActions(); bindQuickActions(); } function bindQuickActions() { // Reschedule buttons (unfinished tasks) journal.querySelectorAll('[data-reschedule]').forEach(btn => { btn.addEventListener('click', () => { const taskId = btn.dataset.reschedule; const when = btn.dataset.when; if (when === 'annuler') { chatInput.value = `Annule la tâche ${taskId}`; } else { chatInput.value = `Reporte la tâche ${taskId} à ${when}`; } openChat(); sendChat(); }); }); // Quick action buttons journal.querySelectorAll('.quick-btn').forEach(btn => { btn.addEventListener('click', () => { const cmds = { drafts: 'Montre-moi mes brouillons', relances: "Quelles sont mes relances en cours ?", semaine: "C'est quoi mon planning de la semaine ?", bilan: "Fais-moi le bilan de la semaine dernière" }; chatInput.value = cmds[btn.dataset.quick] || ''; openChat(); sendChat(); }); }); // Suggestion buttons journal.querySelectorAll('.suggestion-btn').forEach(btn => { btn.addEventListener('click', () => { chatInput.value = btn.dataset.suggest; openChat(); sendChat(); }); }); // Weekly report button journal.querySelectorAll('.weekly-btn').forEach(btn => { btn.addEventListener('click', () => { chatInput.value = 'Fais-moi le bilan de la semaine dernière'; openChat(); sendChat(); }); }); } function renderActionCard(email, type) { const from = email.from_name || email.from_address || email.from || 'Inconnu'; const subject = email.subject || '(sans objet)'; const summary = email.ai_summary || email.summary || subject; const age = emailAge(email.date || email.received_at); const uid = email.uid || email.id; const accountId = email.account_id || ''; return `
${escHtml(from)}
${escHtml(subject)} · ${age}
${escHtml(summary)}
`; } function emailAge(dateStr) { if (!dateStr) return ''; const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `il y a ${mins} min`; const hours = Math.floor(mins / 60); if (hours < 24) return `il y a ${hours}h`; const days = Math.floor(hours / 24); if (days === 1) return 'hier'; return `il y a ${days} jours`; } function bindCardActions() { journal.querySelectorAll('.action-btn').forEach(btn => { btn.addEventListener('click', handleCardAction); }); } async function handleCardAction(e) { const btn = e.currentTarget; const action = btn.dataset.action; const uid = btn.dataset.uid; const accountId = btn.dataset.account; const card = btn.closest('.action-card'); try { switch (action) { case 'draft': // Open chat with draft context chatInput.value = `Rédige une réponse pour le mail de ${card.querySelector('.action-card-from')?.textContent || 'ce contact'}`; chatInput.focus(); openChat(); break; case 'template': showTemplatePicker(null, { onSelect: (tpl) => { const from = btn.dataset.from || card.querySelector('.action-card-from')?.textContent || 'ce contact'; chatInput.value = `Rédige une réponse pour le mail de ${from} en utilisant ce modèle :\n\n${tpl.body}`; chatInput.focus(); openChat(); } }); break; case 'archive': await api(`/api/emails/${uid}/archive`, { method: 'POST', body: { account_id: accountId } }); card.classList.add('done'); showToast('Archivé !'); break; case 'later': await api(`/api/emails/${uid}/status`, { method: 'POST', body: { status: 'pending', account_id: accountId } }); card.classList.add('done'); showToast('OK, on verra ça plus tard'); break; } } catch (err) { showToast(`Oups : ${err.message}`); } } // ── Chat ─────────────────────────────────────────────── const chatBackdrop = $('chat-backdrop'); function openChat() { _chatOpen = true; chatConv.classList.add('open'); if (chatBackdrop) chatBackdrop.classList.add('open'); cache.set('current_page', 'chat', 24 * 60 * 60 * 1000); } let _chatClosedAt = 0; function closeChat() { _chatOpen = false; _chatClosedAt = Date.now(); chatConv.classList.remove('open'); if (chatBackdrop) chatBackdrop.classList.remove('open'); chatInput.blur(); cache.set('current_page', 'journal', 24 * 60 * 60 * 1000); } function toggleChat() { _chatOpen ? closeChat() : openChat(); } async function sendChat(voiceInitiated) { const text = chatInput.value.trim(); if (!text) return; const isVoice = voiceInitiated === true || _carMode; chatInput.value = ''; chatInput.style.height = 'auto'; btnSend.disabled = true; // Intercept during onboarding if (_onboarding) { handleOnboardingAnswer(text); return; } if (!_carMode) openChat(); // Add user message appendChatMsg('user', text); // Show typing const typing = document.createElement('div'); typing.className = 'chat-typing visible'; typing.innerHTML = '
...
'; chatConv.appendChild(typing); scrollChat(); if (_carMode) { updateCarModeStatus('processing'); } try { const res = await api('/api/chat', { method: 'POST', body: { message: text, voice_mode: isVoice, timezone: _timezone } }); typing.remove(); const reply = res.reply || res.message || res.text || JSON.stringify(res); appendChatMsg('claire', reply); // Detect action confirmations in the reply (drafts, events, tasks) const lastMsg = chatConv.querySelector('.chat-msg.claire:last-child'); if (lastMsg) { detectAndRenderActions(lastMsg, reply, res); } // Refresh journal if Claire did something that changes state if (replyHintsStateChange(reply)) { setTimeout(() => loadJournal(), 1000); } // Voice / Car mode: read response aloud if (isVoice) { if (_carMode) { $('car-mode-response').textContent = reply; } await speakText(reply); } } catch (err) { typing.remove(); const errReply = `Désolée, j'ai eu un souci : ${err.message}`; appendChatMsg('claire', errReply); if (_carMode) { $('car-mode-response').textContent = errReply; updateCarModeStatus('idle'); } } } function appendChatMsg(role, text) { const div = document.createElement('div'); div.className = `chat-msg ${role}`; div.innerHTML = formatChatText(text); chatConv.appendChild(div); _chatHistory.push({ role, text }); // Persist last 30 messages + timestamp cache.set('chat_history', _chatHistory.slice(-30), 24 * 60 * 60 * 1000); cache.set('last_chat_time', Date.now(), 24 * 60 * 60 * 1000); scrollChat(); } function formatChatText(text) { // Rich markdown-like formatting let html = escHtml(text); // Bold & italic html = html.replace(/\*\*(.*?)\*\*/g, '$1'); html = html.replace(/\*(.*?)\*/g, '$1'); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bullet lists (lines starting with - or •) html = html.replace(/^[-•]\s+(.+)/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>)/gs, ''); // Numbered lists html = html.replace(/^\d+\.\s+(.+)/gm, '
  • $1
  • '); // Clean up double