/* ── 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 += ``;
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 ?
\ud83d\udce7
Gmail
Google
\ud83d\udcec
Outlook
Microsoft 365
\ud83c\udf10
OVH
Pro
\u2709\ufe0f
Autre
IMAP
`;
}
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.
📅
Google Calendar
📆
Outlook
🔗
Lien iCal
`;
}
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 += `
`;
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 += `
`;
hiddenEvents.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();
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 timeLabel = `${evDate.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', timeZone: _timezone })} ${timeRange}`;
}
const title = ev.title || ev.summary || 'Événement';
const location = ev.location ? ` — ${ev.location}` : '';
html += `
${timeLabel} ${escHtml(title)} ${location ? `${escHtml(location)} ` : ''}
`;
});
html += `
`;
html += `
+ ${hiddenEvents.length} autre${hiddenEvents.length > 1 ? 's' : ''} `;
}
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 ? `Voir ` : ''}
`;
});
}
// ── Tasks today
if (!b.tasks) b.tasks = b.urgent_tasks || b.overdueTasks || [];
if (b.tasks && b.tasks.length > 0) {
html += ``;
html += `
`;
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 += `
`;
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 += `
`;
staleContacts.forEach(c => {
const name = escHtml(c.display_name || c.email);
const days = c.days_silent || '?';
html += `
${name} — pas de nouvelles depuis ${days} jours
Relancer
`;
});
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 += `
`;
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 += `
`;
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 += `
`;
unfinishedToday.forEach(t => {
html += `
${escHtml(t.title || t.description)}
Demain
Cette semaine
Annuler
`;
});
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 += `
`;
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 += `
✉ Brouillons
🔔 Relances
📅 Ma semaine
`;
// ── 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)}
OK, fais-le
`;
});
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 += `
Voir le bilan de la semaine dernière
`;
}
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)}
Répondre
📄 Modèle
Archiver
Plus tard
`;
}
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
html = html.replace(/<\/ul>\s*/g, '');
// Line breaks
html = html.replace(/\n/g, ' ');
// Clean up extra around lists
html = html.replace(//g, '');
html = html.replace(/<\/ul> /g, ' ');
return html;
}
// ── Action detection in Claire's replies ───────────────
function detectAndRenderActions(msgEl, reply, rawRes) {
const actions = [];
// Detect draft created (keywords in reply)
// Only detect draft creation, not listing of existing drafts
const draftMatch = reply.match(/brouillon.{0,20}(sauvegardé|créé|prêt|enregistré)|draft.{0,10}(sauv|cr[ée])/i);
if (draftMatch && !reply.match(/tu as \d+ brouillon|voici.*brouillon/i)) {
actions.push({
type: 'draft',
icon: '✉',
label: 'Brouillon prêt',
detail: 'Vérifie dans tes brouillons',
buttons: [
{ text: 'Voir les brouillons', action: 'view-drafts' },
]
});
}
// Detect event created
const eventMatch = reply.match(/[ée]v[ée]nement.*ajout|ajout.*agenda|calendrier.*cr[ée]/i);
if (eventMatch) {
actions.push({
type: 'event',
icon: '📅',
label: 'Ajouté à ton agenda',
buttons: []
});
}
// Detect task created
const taskMatch = reply.match(/t[âa]che.*cr[ée]|tâche.*ajout|noté.*todo|ajouté.*tâche/i);
if (taskMatch) {
actions.push({
type: 'task',
icon: '✅',
label: 'Tâche créée',
buttons: []
});
}
// Detect email archived
const archiveMatch = reply.match(/archiv[ée]/i);
if (archiveMatch && !draftMatch) {
actions.push({
type: 'archive',
icon: '📦',
label: 'Archivé',
buttons: []
});
}
// Render action bubbles
actions.forEach(act => {
const bubble = document.createElement('div');
bubble.className = `chat-action-bubble ${act.type}`;
let html = `${act.icon} ${act.label} `;
if (act.detail) html += `${escHtml(act.detail)} `;
if (act.buttons.length > 0) {
html += ``;
act.buttons.forEach(btn => {
html += `${btn.text} `;
});
html += `
`;
}
bubble.innerHTML = html;
msgEl.appendChild(bubble);
// Bind button actions
bubble.querySelectorAll('.cab-btn').forEach(b => {
b.addEventListener('click', (e) => { e.stopPropagation(); handleChatBubbleAction(b.dataset.chatAction); });
});
});
}
function replyHintsStateChange(reply) {
return /brouillon|archiv[ée]|supprim[ée]|ajout.*agenda|t[âa]che.*cr[ée]|déplac[ée]|classé/i.test(reply);
}
async function handleChatBubbleAction(action) {
switch (action) {
case 'view-drafts':
chatInput.value = 'Montre-moi mes brouillons';
sendChat();
break;
default:
showToast('Action en cours...');
}
}
function scrollChat() {
requestAnimationFrame(() => {
chatConv.scrollTop = chatConv.scrollHeight;
});
}
// ── Voice (hold to talk, release to send) ──────────────
let _voiceStream = null;
let _voiceChunks = [];
let _voiceTimer = null;
function initVoice() {
// Desktop: mousedown/mouseup
btnVoice.addEventListener('mousedown', voiceStart);
btnVoice.addEventListener('mouseup', voiceStop);
btnVoice.addEventListener('mouseleave', voiceStop);
// Mobile: touchstart/touchend
btnVoice.addEventListener('touchstart', e => { e.preventDefault(); voiceStart(); }, { passive: false });
btnVoice.addEventListener('touchend', e => { e.preventDefault(); voiceStop(); });
btnVoice.addEventListener('touchcancel', voiceStop);
}
async function voiceStart() {
if (_recording) return;
try {
_voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus'
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm'
: MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4'
: '';
const opts = mimeType ? { mimeType } : {};
_mediaRec = new MediaRecorder(_voiceStream, opts);
_voiceChunks = [];
_mediaRec.ondataavailable = e => _voiceChunks.push(e.data);
_mediaRec.onstop = async () => {
_voiceStream.getTracks().forEach(t => t.stop());
_voiceStream = null;
const actualType = _mediaRec.mimeType || mimeType || 'audio/webm';
const ext = actualType.includes('mp4') ? 'mp4' : 'webm';
const blob = new Blob(_voiceChunks, { type: actualType });
// Ignore very short recordings (accidental taps)
if (blob.size < 1000) return;
await transcribeAndSend(blob, ext);
};
_mediaRec.start();
_recording = true;
btnVoice.classList.add('recording');
if (_carMode) updateCarModeStatus('recording');
// Safety: max 30s recording
_voiceTimer = setTimeout(() => voiceStop(), 30000);
} catch (err) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
showToast('Autorise le micro dans ton navigateur');
} else if (err.name === 'NotFoundError') {
showToast('Aucun micro détecté');
} else {
showToast('Erreur micro : ' + (err.message || 'inconnu'));
}
console.warn('[voice]', err);
}
}
function voiceStop() {
if (!_recording) return;
clearTimeout(_voiceTimer);
_recording = false;
btnVoice.classList.remove('recording');
if (_carMode) updateCarModeStatus('processing');
if (_mediaRec && _mediaRec.state !== 'inactive') {
_mediaRec.stop();
}
}
async function transcribeAndSend(blob, ext) {
if (_carMode) {
updateCarModeStatus('processing');
}
// Show "listening" state in chat
if (!_carMode) openChat();
const indicator = document.createElement('div');
indicator.className = 'chat-msg user voice-pending';
indicator.innerHTML = 'Transcription... ';
chatConv.appendChild(indicator);
scrollChat();
const fd = new FormData();
fd.append('audio', blob, `voice.${ext}`);
try {
const res = await api('/api/stt', { method: 'POST', body: fd });
const text = (res.text || res.transcription || '').trim();
indicator.remove();
if (text) {
if (_carMode) {
// In car mode: show transcription, auto-send with voice_mode
$('car-mode-response').textContent = text;
}
// Auto-send — flag voice mode
chatInput.value = text;
btnSend.disabled = false;
sendChat(true); // true = voice-initiated
} else {
showToast("J'ai pas compris -- réessaie");
if (_carMode) {
$('car-mode-response').textContent = "Je n'ai pas compris, réessaie.";
updateCarModeStatus('idle');
}
}
} catch (err) {
indicator.remove();
const errMsg = err.message || 'Transcription échouée';
if (errMsg.includes('trop court')) {
showToast('Enregistrement trop court');
} else if (errMsg.includes('Groq') || errMsg.includes('502')) {
showToast('Erreur transcription — réessaie');
} else {
showToast('Transcription échouée');
}
if (_carMode) {
$('car-mode-response').textContent = 'Erreur. Réessaie.';
updateCarModeStatus('idle');
}
console.warn('[stt]', err);
}
}
// ── TTS (Text-to-Speech) ────────────────────────────────
async function speakText(text) {
if (!text || !text.trim()) return;
_carSpeaking = true;
updateCarModeStatus('speaking');
// Strip markdown for speech
const clean = text.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/^[-•]\s+/gm, '')
.replace(/^\d+\.\s+/gm, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.trim();
try {
// Try server TTS (Gemini) first
const res = await fetch('/api/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_token}`,
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ text: clean })
});
if (res.ok) {
const audioBlob = await res.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
return new Promise((resolve) => {
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
_carSpeaking = false;
updateCarModeStatus('idle');
resolve();
};
audio.onerror = () => {
URL.revokeObjectURL(audioUrl);
_carSpeaking = false;
updateCarModeStatus('idle');
// Fallback to browser TTS
speakBrowser(clean).then(resolve);
};
audio.play().catch(() => {
URL.revokeObjectURL(audioUrl);
_carSpeaking = false;
updateCarModeStatus('idle');
speakBrowser(clean).then(resolve);
});
});
} else {
// Server TTS failed — use browser fallback
return speakBrowser(clean);
}
} catch (err) {
console.warn('[tts] server error, using browser fallback:', err.message);
return speakBrowser(clean);
}
}
// Select the best French voice available (prefer natural/premium voices)
let _bestFrVoice = null;
function findBestFrenchVoice() {
if (_bestFrVoice) return _bestFrVoice;
const voices = speechSynthesis.getVoices();
const frVoices = voices.filter(v => v.lang.startsWith('fr'));
if (!frVoices.length) return null;
// Prefer: Google > Microsoft > Apple natural voices, then any female voice
const ranked = frVoices.sort((a, b) => {
const score = v => {
const n = v.name.toLowerCase();
if (n.includes('natural') || n.includes('neural')) return 100;
if (n.includes('google')) return 80;
if (n.includes('microsoft') && n.includes('denise')) return 75;
if (n.includes('microsoft')) return 70;
if (n.includes('amelie') || n.includes('thomas')) return 60;
if (v.localService === false) return 50; // remote = usually better
return 10;
};
return score(b) - score(a);
});
_bestFrVoice = ranked[0];
return _bestFrVoice;
}
if ('speechSynthesis' in window) {
speechSynthesis.onvoiceschanged = () => { _bestFrVoice = null; findBestFrenchVoice(); };
findBestFrenchVoice();
}
function speakBrowser(text) {
return new Promise((resolve) => {
if (!('speechSynthesis' in window)) {
_carSpeaking = false;
updateCarModeStatus('idle');
resolve();
return;
}
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'fr-FR';
const voice = findBestFrenchVoice();
if (voice) utterance.voice = voice;
utterance.rate = 0.95; // slightly slower = more natural
utterance.pitch = 1.05; // slightly higher = warmer
utterance.onend = () => {
_carSpeaking = false;
updateCarModeStatus('idle');
resolve();
};
utterance.onerror = () => {
_carSpeaking = false;
updateCarModeStatus('idle');
resolve();
};
speechSynthesis.speak(utterance);
});
}
function stopSpeaking() {
_carSpeaking = false;
if ('speechSynthesis' in window) speechSynthesis.cancel();
// Stop any playing audio elements
document.querySelectorAll('audio').forEach(a => { a.pause(); a.currentTime = 0; });
}
// ── Car Mode ────────────────────────────────────────────
function initCarMode() {
// Create overlay
const overlay = document.createElement('div');
overlay.id = 'car-mode-overlay';
overlay.className = 'car-mode-overlay';
overlay.innerHTML = `
Clai re
Appuie sur le micro pour parler
`;
document.body.appendChild(overlay);
// Bind car mode mic — tap to toggle recording
const carMic = $('car-mode-mic');
let _carMicTouched = false;
carMic.addEventListener('touchstart', e => {
e.preventDefault();
_carMicTouched = true;
carMicToggle();
}, { passive: false });
carMic.addEventListener('touchend', e => { e.preventDefault(); }, { passive: false });
carMic.addEventListener('click', (e) => {
// Avoid double-fire on touch devices
if (_carMicTouched) { _carMicTouched = false; return; }
carMicToggle();
});
$('car-mode-close').addEventListener('click', exitCarMode);
// Re-acquire wake lock when tab becomes visible again
document.addEventListener('visibilitychange', async () => {
if (_carMode && document.visibilityState === 'visible' && !_carWakeLock) {
try {
if ('wakeLock' in navigator) {
_carWakeLock = await navigator.wakeLock.request('screen');
_carWakeLock.addEventListener('release', () => { _carWakeLock = null; });
}
} catch (e) { /* ignore */ }
}
});
}
function carMicToggle() {
if (_carSpeaking) {
stopSpeaking();
return;
}
if (_recording) {
voiceStop();
} else {
voiceStart();
}
}
async function enterCarMode() {
_carMode = true;
const overlay = $('car-mode-overlay');
if (overlay) overlay.classList.add('active');
updateCarModeStatus('idle');
$('car-mode-response').textContent = 'Appuie sur le micro pour parler';
// Wake Lock — keep screen on
try {
if ('wakeLock' in navigator) {
_carWakeLock = await navigator.wakeLock.request('screen');
_carWakeLock.addEventListener('release', () => { _carWakeLock = null; });
}
} catch (err) {
console.warn('[car-mode] Wake Lock failed:', err.message);
}
}
function exitCarMode() {
_carMode = false;
const overlay = $('car-mode-overlay');
if (overlay) overlay.classList.remove('active');
// Stop any ongoing recording/speech
if (_recording) voiceStop();
stopSpeaking();
// Release wake lock
if (_carWakeLock) {
_carWakeLock.release().catch(() => {});
_carWakeLock = null;
}
}
function updateCarModeStatus(state) {
if (!_carMode) return;
const status = $('car-mode-status');
const mic = $('car-mode-mic');
if (!status || !mic) return;
mic.classList.remove('recording', 'processing', 'speaking');
switch (state) {
case 'recording':
status.textContent = 'Enregistrement...';
mic.classList.add('recording');
break;
case 'processing':
status.textContent = 'Claire réfléchit...';
mic.classList.add('processing');
break;
case 'speaking':
status.textContent = 'Claire parle...';
mic.classList.add('speaking');
break;
default:
status.textContent = '';
}
}
// ── Toast ──────────────────────────────────────────────
let _toastTimeout;
function showToast(msg) {
if (!toast) return;
toast.textContent = msg;
toast.classList.add('show');
clearTimeout(_toastTimeout);
_toastTimeout = setTimeout(() => toast.classList.remove('show'), 2800);
}
// ── Privacy modal ──────────────────────────────────────
function openPrivacy() {
$('privacy-modal').classList.add('open');
}
function closePrivacy() {
$('privacy-modal').classList.remove('open');
}
// ── Utilities ──────────────────────────────────────────
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Auto-resize textarea ───────────────────────────────
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
// ── Event bindings ─────────────────────────────────────
function bindEvents() {
// Auth
loginForm.addEventListener('submit', handleLogin);
// Chat input
chatInput.addEventListener('input', () => {
btnSend.disabled = !chatInput.value.trim();
autoResize(chatInput);
});
chatInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChat();
}
});
chatInput.addEventListener('focus', () => {
// N'ouvrir le chat au focus QUE si l'utilisateur a un historique récent (pas au chargement)
// Et pas juste après une fermeture manuelle (cooldown 1s)
if (_chatHistory.length > 0 && !_chatOpen && Date.now() - _chatClosedAt > 1000) {
const lastTime = cache.get('last_chat_time');
if (lastTime && Date.now() - lastTime < 30 * 60 * 1000) openChat();
}
});
// Template picker button (injected next to chat input)
const tplBtn = document.createElement('button');
tplBtn.className = 'chat-voice';
tplBtn.title = 'Modèles de réponse';
tplBtn.style.cssText = 'font-size:1.1rem;width:36px;height:36px';
tplBtn.textContent = '\uD83D\uDCC4';
tplBtn.addEventListener('click', () => showTemplatePicker(chatInput));
const chatBar = chatInput.closest('.chat-bar');
if (chatBar) chatBar.insertBefore(tplBtn, chatBar.querySelector('.chat-send'));
// Car mode toggle button (injected into topbar)
const carBtn = document.createElement('button');
carBtn.id = 'btn-car-mode';
carBtn.className = 'topbar-btn';
carBtn.title = 'Mode voiture';
carBtn.innerHTML = ' ';
carBtn.addEventListener('click', enterCarMode);
const topbarActions = document.querySelector('.topbar-actions');
if (topbarActions) topbarActions.insertBefore(carBtn, topbarActions.firstChild);
initCarMode();
// Buttons
btnSend.addEventListener('click', () => sendChat());
initVoice();
$('btn-refresh').addEventListener('click', loadJournal);
$('btn-privacy').addEventListener('click', openPrivacy);
$('btn-close-privacy').addEventListener('click', closePrivacy);
$('btn-account').addEventListener('click', openAccount);
// Account modal
$('account-modal').addEventListener('click', e => { if (e.target === $('account-modal')) closeAccount(); });
$('acct-close').addEventListener('click', closeAccount);
$('acct-save').addEventListener('click', saveAccount);
$('acct-logout').addEventListener('click', () => { closeAccount(); logout(); });
$('acct-change-pw').addEventListener('click', () => { closeAccount(); openPasswordModal(); });
$('acct-export').addEventListener('click', async () => {
try {
const data = await api('/api/account/export');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'claire-export.json'; a.click();
URL.revokeObjectURL(url);
showToast('Export téléchargé');
} catch { showToast('Erreur export'); }
});
$('acct-delete').addEventListener('click', async () => {
if (!confirm('Supprimer définitivement ton compte et toutes tes données ? Cette action est irréversible.')) return;
const confirmEmail = prompt('Pour confirmer, entre ton adresse email ou ton identifiant :');
if (!confirmEmail) return;
try {
await api('/api/account', { method: 'DELETE', body: { confirm_email: confirmEmail } });
logout();
} catch (err) { showToast(err.message || 'Erreur suppression'); }
});
$('acct-add-mail').addEventListener('click', () => { closeAccount(); openAddMail(); });
$('btn-open-ai-logs-acct').addEventListener('click', () => { closeAccount(); openAiLogs(); });
// Tone & lang chip selection
$('acct-tone').addEventListener('click', e => {
const chip = e.target.closest('.tone-chip');
if (!chip) return;
_selectedTone = chip.dataset.tone;
updateChips('acct-tone', 'tone', _selectedTone);
});
$('acct-lang').addEventListener('click', e => {
const chip = e.target.closest('.tone-chip');
if (!chip) return;
_selectedLang = chip.dataset.lang;
updateChips('acct-lang', 'lang', _selectedLang);
});
// Password modal
$('password-modal').addEventListener('click', e => { if (e.target === $('password-modal')) closePasswordModal(); });
$('pw-save').addEventListener('click', savePassword);
$('pw-cancel').addEventListener('click', closePasswordModal);
// Add mail modal
$('addmail-modal').addEventListener('click', e => { if (e.target === $('addmail-modal')) closeAddMail(); });
$('mail-provider').addEventListener('click', e => {
const chip = e.target.closest('.tone-chip');
if (!chip) return;
_selectedProvider = chip.dataset.provider;
updateChips('mail-provider', 'provider', _selectedProvider);
updateMailHelp(_selectedProvider);
});
$('mail-save').addEventListener('click', saveMailAccount);
$('mail-cancel').addEventListener('click', closeAddMail);
// Privacy modal
$('privacy-modal').addEventListener('click', e => {
if (e.target === $('privacy-modal')) closePrivacy();
});
$('btn-open-ai-logs').addEventListener('click', () => { closePrivacy(); openAiLogs(); });
// AI logs modal
$('ai-logs-modal').addEventListener('click', e => { if (e.target === $('ai-logs-modal')) closeAiLogs(); });
$('btn-close-ai-logs').addEventListener('click', closeAiLogs);
// Close chat on outside tap (journal area) or close button
$('journal').addEventListener('click', (e) => {
if (_chatOpen && e.target === $('journal')) closeChat();
});
const btnCloseChat = $('btn-close-chat');
if (btnCloseChat) btnCloseChat.addEventListener('click', (e) => { e.stopPropagation(); closeChat(); });
// Backdrop tap to close
if (chatBackdrop) chatBackdrop.addEventListener('click', closeChat);
// Swipe down on drag handle to close
const dragHandle = $('chat-drag-handle');
if (dragHandle) {
let startY = 0;
dragHandle.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; }, { passive: true });
dragHandle.addEventListener('touchend', (e) => {
const dy = e.changedTouches[0].clientY - startY;
if (dy > 40) closeChat();
});
}
}
// ── Reply Templates ─────────────────────────────────────
async function loadTemplates() {
try { return (await api('/api/templates')) || []; } catch { return []; }
}
async function saveTemplate() {
const name = $('tpl-name')?.value?.trim();
const body = $('tpl-body')?.value?.trim();
const category = $('tpl-category')?.value || 'general';
if (!name || !body) { showToast('Nom et corps requis'); return; }
try {
await api('/api/templates', { method: 'POST', body: { name, body, category } });
showToast('Modèle enregistré');
$('tpl-name').value = '';
$('tpl-body').value = '';
refreshTemplatesList();
} catch (e) { showToast('Erreur: ' + e.message); }
}
async function deleteTemplate(id) {
try {
await api(`/api/templates/${id}`, { method: 'DELETE' });
showToast('Supprimé');
refreshTemplatesList();
} catch (e) { showToast('Erreur: ' + e.message); }
}
async function refreshTemplatesList() {
const list = $('templates-list');
if (!list) return;
const templates = await loadTemplates();
if (!templates.length) { list.innerHTML = 'Aucun modèle pour l\'instant
'; return; }
const catLabels = { general: 'Général', confirmation: 'Confirmation', relance: 'Relance', remerciement: 'Remerciement', refus: 'Refus', info: 'Info' };
list.innerHTML = templates.map(t => `
${escHtml(t.name)}
${escHtml(catLabels[t.category] || t.category || '')}
${t.usage_count ? `
(${t.usage_count}x) ` : ''}
${escHtml((t.body||'').substring(0,100))}${(t.body||'').length>100?'\u2026':''}
✎
🗑
`).join('');
}
window._deleteTemplate = deleteTemplate;
window._editTemplate = async function(id) {
const templates = await loadTemplates();
const tpl = templates.find(t => t.id === id);
if (!tpl) return;
const nameEl = $('tpl-name'), bodyEl = $('tpl-body'), catEl = $('tpl-category');
if (!nameEl || !bodyEl) return;
nameEl.value = tpl.name;
bodyEl.value = tpl.body;
if (catEl) catEl.value = tpl.category || 'general';
// Open the details
const details = nameEl.closest('details');
if (details) details.open = true;
// Switch save button to update mode
nameEl.dataset.editId = id;
nameEl.focus();
};
// Override saveTemplate to handle edit mode
const _origSaveTemplate = saveTemplate;
window.saveTemplate = saveTemplate = async function() {
const nameEl = $('tpl-name');
const editId = nameEl?.dataset?.editId;
const name = nameEl?.value?.trim();
const body = $('tpl-body')?.value?.trim();
const category = $('tpl-category')?.value || 'general';
if (!name || !body) { showToast('Nom et corps requis'); return; }
try {
if (editId) {
await api(`/api/templates/${editId}`, { method: 'PUT', body: { name, body, category } });
showToast('Modèle mis à jour');
delete nameEl.dataset.editId;
} else {
await api('/api/templates', { method: 'POST', body: { name, body, category } });
showToast('Modèle enregistré');
}
nameEl.value = '';
$('tpl-body').value = '';
const details = nameEl.closest('details');
if (details) details.open = false;
refreshTemplatesList();
} catch (e) { showToast('Erreur: ' + e.message); }
};
function showTemplatePicker(targetTextarea, { onSelect, context } = {}) {
loadTemplates().then(templates => {
if (!templates.length) { showToast('Aucun modèle — crée-en un dans Mon compte'); return; }
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
const cats = [...new Set(templates.map(t => t.category || 'general'))];
const catLabels = { general: 'Général', confirmation: 'Confirmation', relance: 'Relance', remerciement: 'Remerciement', refus: 'Refus', info: 'Info' };
let html = '';
html += '
Choisir un modèle ';
for (const cat of cats) {
const catTpls = templates.filter(t => (t.category || 'general') === cat);
if (cats.length > 1) html += `
${escHtml(catLabels[cat] || cat)}
`;
for (const t of catTpls) {
html += `
${escHtml(t.name)} ${t.usage_count ? `
(${t.usage_count}x) ` : ''}
${escHtml((t.body || '').substring(0, 120))}${(t.body || '').length > 120 ? '\u2026' : ''}
`;
}
}
html += '
';
overlay.innerHTML = html;
overlay.addEventListener('click', e => {
const pick = e.target.closest('.tpl-pick');
if (pick) {
const tpl = templates.find(t => t.id === pick.dataset.id);
if (tpl) {
if (onSelect) { onSelect(tpl); }
else if (targetTextarea) { targetTextarea.value = tpl.body; targetTextarea.focus(); }
api(`/api/templates/${tpl.id}/use`, { method: 'POST' }).catch(() => {});
}
overlay.remove();
} else if (e.target === overlay) overlay.remove();
});
document.body.appendChild(overlay);
});
}
// ── Account management ─────────────────────────────────
let _accountData = null;
let _selectedTone = 'chaleureux';
let _selectedLang = 'fr';
let _selectedProvider = 'gmail';
async function openAccount() {
const modal = $('account-modal');
modal.classList.add('open');
refreshTemplatesList();
try {
_accountData = await cachedApi('/api/account', {}, 120000);
const acctName = $('acct-name');
const acctEmail = $('acct-email');
if (!acctName || !acctEmail) return;
acctName.value = _accountData.display_name || '';
acctEmail.value = _accountData.email || _accountData.username || '';
_selectedTone = _accountData.tone_preference || 'chaleureux';
_selectedLang = _accountData.language || 'fr';
updateChips('acct-tone', 'tone', _selectedTone);
updateChips('acct-lang', 'lang', _selectedLang);
$('acct-tz').textContent = `${_timezone} (détecté automatiquement)`;
// Load mail accounts
const accounts = await api('/api/accounts');
renderMailAccounts(accounts || []);
// Load account summary (comptes connectés)
renderAccountSummary();
// Load connectors status
renderConnectors();
// Re-fetch connector status when user returns from OAuth tab
document.addEventListener('visibilitychange', () => {
if (!document.hidden && $('account-modal')?.classList.contains('open')) {
renderAccountSummary();
renderConnectors();
}
});
// Inject templates section if not already present
if (!$('templates-section')) {
const tplSection = document.createElement('div');
tplSection.id = 'templates-section';
tplSection.innerHTML = `
`;
// Insert before the "Mot de passe" section separator
const acctContent = $('account-content');
if (!acctContent) return;
const pwSep = acctContent.querySelectorAll('hr.journal-sep');
if (pwSep.length >= 3) {
acctContent.insertBefore(tplSection, pwSep[2]);
} else {
acctContent.appendChild(tplSection);
}
window._saveTemplate = saveTemplate;
}
refreshTemplatesList();
} catch (err) {
showToast('Erreur chargement compte');
}
}
function closeAccount() {
$('account-modal').classList.remove('open');
}
async function sendFeedback() {
const type = document.getElementById('feedback-type')?.value || 'general';
const message = document.getElementById('feedback-message')?.value;
if (!message || message.trim().length < 5) { showToast('Message trop court'); return; }
try {
await api('/api/feedback', { method: 'POST', body: { type, message } });
showToast('Merci pour ton retour !');
document.getElementById('feedback-message').value = '';
} catch(e) {
showToast('Erreur, réessaie');
}
}
function updateChips(containerId, dataAttr, activeValue) {
const container = $(containerId);
container.querySelectorAll('.tone-chip').forEach(chip => {
chip.classList.toggle('active', chip.dataset[dataAttr] === activeValue);
});
}
function renderMailAccounts(accounts) {
const list = $('acct-mail-list');
if (!accounts.length) {
list.innerHTML = 'Aucun compte connecté
';
return;
}
list.innerHTML = accounts.map(a => `
${escHtml(a.label || a.provider)}
${escHtml(a.email)}
Retirer
`).join('');
list.querySelectorAll('.acct-mail-remove').forEach(btn => {
let confirmPending = false;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirmPending) {
// First tap — ask confirmation
confirmPending = true;
btn.textContent = 'Confirmer ?';
btn.style.background = 'var(--urgent-bg)';
setTimeout(() => { confirmPending = false; btn.textContent = 'Retirer'; btn.style.background = ''; }, 3000);
return;
}
// Second tap — delete
try {
await api(`/api/accounts/${btn.dataset.id}`, { method: 'DELETE' });
btn.closest('.acct-mail-item').remove();
showToast('Compte retiré');
} catch { showToast('Erreur lors de la suppression'); }
});
});
}
async function saveAccount() {
try {
await api('/api/account', {
method: 'PUT',
body: {
display_name: $('acct-name').value.trim() || null,
tone_preference: _selectedTone,
language: _selectedLang
}
});
// Update user display name in state
if (_user) _user.display_name = $('acct-name').value.trim();
showToast('Enregistré !');
closeAccount();
loadJournal(); // Refresh with new name
} catch (err) {
showToast('Erreur : ' + err.message);
}
}
// ── Password change ────────────────────────────────────
function openPasswordModal() {
$('password-modal').classList.add('open');
$('pw-current').value = '';
$('pw-new').value = '';
$('pw-confirm').value = '';
$('pw-error').textContent = '';
}
function closePasswordModal() {
$('password-modal').classList.remove('open');
}
async function savePassword() {
const current = $('pw-current').value;
const newPw = $('pw-new').value;
const confirm = $('pw-confirm').value;
const errEl = $('pw-error');
if (!current || !newPw) { errEl.textContent = 'Remplis tous les champs'; return; }
if (newPw.length < 8) { errEl.textContent = '8 caractères minimum'; return; }
if (newPw !== confirm) { errEl.textContent = 'Les mots de passe ne correspondent pas'; return; }
try {
await api('/api/account/password', {
method: 'PUT',
body: { current_password: current, new_password: newPw }
});
showToast('Mot de passe changé');
closePasswordModal();
} catch (err) {
errEl.textContent = 'Mot de passe actuel incorrect';
}
}
// ── Add mail account ───────────────────────────────────
const PROVIDERS = {
gmail: { imap_host: 'imap.gmail.com', imap_port: 993, smtp_host: 'smtp.gmail.com', smtp_port: 465 },
outlook: { imap_host: 'outlook.office365.com', imap_port: 993, smtp_host: 'smtp.office365.com', smtp_port: 587 },
ovh: { imap_host: 'ssl0.ovh.net', imap_port: 993, smtp_host: 'ssl0.ovh.net', smtp_port: 465 },
other: { imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 465 }
};
const MAIL_HELP = {
gmail: {
html: `Gmail nécessite un "mot de passe d'application" (pas ton mot de passe Google habituel).
Étapes :
1. Va sur myaccount.google.com/apppasswords
2. Connecte-toi avec ton compte Google
3. Crée un mot de passe pour "Mail" → "Autre" → nomme-le "Claire"
4. Copie le mot de passe généré (16 caractères) et colle-le ci-dessous
Si le lien ne marche pas, active d'abord la validation en 2 étapes dans les paramètres de sécurité Google. `
},
outlook: {
html: `Outlook / Hotmail / Live
Utilise ton mot de passe habituel. Si tu as la double authentification :
1. Va sur account.microsoft.com/security
2. Crée un "mot de passe d'application"
3. Colle-le ci-dessous`
},
ovh: {
html: `OVH / Webmail pro
Utilise ton adresse email complète et le mot de passe de ta messagerie OVH (le même que pour te connecter au webmail).`
},
other: {
html: `Autre fournisseur
Entre ton adresse email et ton mot de passe. Claire essaiera de détecter les paramètres automatiquement.
Si la connexion échoue, contacte ton fournisseur pour les paramètres IMAP (serveur, port). `
}
};
function updateMailHelp(provider) {
const help = $('mail-help');
if (!help) return;
const info = MAIL_HELP[provider];
if (info) {
help.innerHTML = info.html;
help.style.display = '';
} else {
help.style.display = 'none';
}
}
function openAddMail() {
$('addmail-modal').classList.add('open');
$('mail-label').value = '';
$('mail-email').value = '';
$('mail-password').value = '';
$('mail-error').textContent = '';
_selectedProvider = 'gmail';
updateChips('mail-provider', 'provider', 'gmail');
updateMailHelp('gmail');
}
function closeAddMail() {
$('addmail-modal').classList.remove('open');
}
async function saveMailAccount() {
const label = $('mail-label').value.trim();
const email = $('mail-email').value.trim();
const password = $('mail-password').value;
const errEl = $('mail-error');
if (!email || !password) { errEl.textContent = 'Email et mot de passe requis'; return; }
const prov = PROVIDERS[_selectedProvider] || PROVIDERS.gmail;
try {
await api('/api/accounts', {
method: 'POST',
body: {
label: label || _selectedProvider,
provider: _selectedProvider,
email,
password,
imap_host: prov.imap_host,
imap_port: prov.imap_port,
imap_secure: true,
smtp_host: prov.smtp_host,
smtp_port: prov.smtp_port,
smtp_secure: true
}
});
showToast('Compte connecté !');
closeAddMail();
openAccount(); // Refresh account list
} catch (err) {
if (err.message && err.message.includes('déjà connecté')) {
errEl.textContent = 'Ce compte est déjà connecté. Tu peux en ajouter un autre.';
$('mail-email').value = '';
$('mail-password').value = '';
} else {
errEl.textContent = err.message || 'Connexion échouée — vérifie tes identifiants';
}
}
}
// ── Connectors ─────────────────────────────────────────
const CONNECTORS = {
google: [
{ id: 'google-all', name: 'Google', desc: 'Mail + Agenda + Contacts — un seul clic', icon: '🔵', iconClass: 'google', type: 'oauth', authUrl: '/api/google/auth', statusUrl: '/api/google/status', disconnectUrl: '/api/google/disconnect', needsKeys: 'google' },
],
microsoft: [
{ id: 'microsoft-all', name: 'Microsoft 365', desc: 'Outlook + Agenda + OneDrive — un seul clic', icon: '🔷', iconClass: 'apple', type: 'oauth', authUrl: '/api/microsoft/auth', statusUrl: '/api/microsoft/status', disconnectUrl: '/api/microsoft/disconnect', needsKeys: 'microsoft' },
],
calendar: [
{ id: 'apple-calendar', name: 'Apple Calendar', desc: 'Via lien iCal (.ics)', icon: '🍎', iconClass: 'apple', type: 'ical' },
{ id: 'ical-feed', name: 'Flux iCal', desc: 'N\'importe quel calendrier .ics', icon: '🔗', iconClass: 'ical', type: 'ical' },
{ id: 'calendly', name: 'Calendly', desc: 'Prises de RDV', icon: '📋', iconClass: 'calendly', type: 'soon' },
],
productivity: [
{ id: 'obsidian', name: 'Obsidian', desc: 'Notes et vault local', icon: '💎', iconClass: 'notion', type: 'vault' },
{ id: 'notion', name: 'Notion', desc: 'Notes, bases de données, wiki', icon: '📝', iconClass: 'notion', type: 'soon' },
{ id: 'todoist', name: 'Todoist', desc: 'Tâches et projets', icon: '✅', iconClass: 'todoist', type: 'soon' },
{ id: 'trello', name: 'Trello', desc: 'Tableaux et cartes', icon: '📋', iconClass: 'trello', type: 'soon' },
{ id: 'ms-todo', name: 'Microsoft To Do', desc: 'Tâches Microsoft 365', icon: '☑️', iconClass: 'apple', type: 'soon' },
{ id: 'ms-onenote', name: 'OneNote', desc: 'Notes Microsoft', icon: '📓', iconClass: 'apple', type: 'soon' },
],
messaging: [
{ id: 'whatsapp', name: 'WhatsApp', desc: 'Messages et notifications', icon: '💬', iconClass: 'whatsapp', type: 'soon' },
{ id: 'telegram', name: 'Telegram', desc: 'Parle à Claire via Telegram', icon: '✈️', iconClass: 'telegram', type: 'oauth', statusUrl: '/api/telegram/status', authUrl: null, disconnectUrl: '/api/telegram/disconnect', customConnect: 'telegram' },
{ id: 'slack', name: 'Slack', desc: 'Canaux et messages', icon: '💼', iconClass: 'slack', type: 'soon' },
{ id: 'ms-teams', name: 'Microsoft Teams', desc: 'Chat et réunions', icon: '👥', iconClass: 'apple', type: 'soon' },
],
storage: [
{ id: 'google-drive', name: 'Google Drive', desc: 'Documents et fichiers', icon: '📁', iconClass: 'drive', type: 'soon' },
{ id: 'ms-onedrive', name: 'OneDrive', desc: 'Stockage Microsoft 365', icon: '☁️', iconClass: 'apple', type: 'soon' },
{ id: 'dropbox', name: 'Dropbox', desc: 'Fichiers et dossiers', icon: '📦', iconClass: 'dropbox', type: 'soon' },
{ id: 'ms-sharepoint', name: 'SharePoint', desc: 'Documents partagés Microsoft', icon: '🏢', iconClass: 'apple', type: 'soon' },
],
finance: [
{ id: 'stripe', name: 'Stripe', desc: 'Paiements et factures', icon: '💳', iconClass: 'stripe', type: 'soon' },
{ id: 'qonto', name: 'Qonto', desc: 'Compte pro', icon: '🏦', iconClass: 'qonto', type: 'soon' },
{ id: 'pennylane', name: 'Pennylane', desc: 'Comptabilité', icon: '📊', iconClass: 'pennylane', type: 'soon' },
]
};
async function renderConnectors() {
// Fetch all statuses in parallel first
const statusCache = {};
const statusPromises = [];
for (const connectors of Object.values(CONNECTORS)) {
for (const c of connectors) {
if (c.statusUrl && c.type === 'oauth') {
statusPromises.push(
api(c.statusUrl).then(s => { statusCache[c.id] = s; }).catch(() => { statusCache[c.id] = null; })
);
}
}
}
await Promise.all(statusPromises);
for (const [category, connectors] of Object.entries(CONNECTORS)) {
const container = $(`connector-${category}`);
if (!container) continue;
let html = '';
for (const c of connectors) {
let statusHtml = '';
let connected = false;
// Use cached status
let needsSetup = false;
if (c.statusUrl && c.type === 'oauth') {
const status = statusCache[c.id];
connected = status?.connected || status?.google_connected || false;
needsSetup = !connected && !status?.configured;
}
// Check calendar sources for iCal
if (c.type === 'ical') {
// Will show "Connecter" button
}
if (connected) {
statusHtml = `
Connecté
Retirer `;
} else if (c.type === 'soon') {
statusHtml = `Bientôt `;
} else if (c.type === 'oauth' && c.customConnect === 'telegram') {
statusHtml = `Connecter `;
} else if (c.type === 'oauth' && c.needsKeys) {
const status = statusCache[c.id];
const isConfigured = status?.configured || status?.has_own_keys;
if (isConfigured) {
// Credentials available (global or user) — direct 1-click OAuth
statusHtml = `Connecter `;
} else {
// No credentials at all — show setup guide
statusHtml = `Connecter `;
}
} else if (c.type === 'oauth') {
statusHtml = `Connecter `;
} else if (c.type === 'vault') {
// Check if vault path already configured
const vaultConf = cache.get(`vault_${c.id}`);
if (vaultConf) {
statusHtml = `Connecté
Retirer `;
} else {
statusHtml = `Connecter `;
}
} else if (c.type === 'ical') {
statusHtml = `Ajouter `;
}
html += `
`;
}
container.innerHTML = html;
}
// Bind connector actions
document.querySelectorAll('.connector-status.available[data-auth]').forEach(btn => {
btn.addEventListener('click', () => {
// Pass token via URL so new tab is authenticated
const sep = btn.dataset.auth.includes('?') ? '&' : '?';
window.open(`${btn.dataset.auth}${sep}token=${_token}`, '_blank');
});
});
document.querySelectorAll('.connector-status.available[data-setup-keys]').forEach(btn => {
btn.addEventListener('click', () => openKeysSetup(btn.dataset.setupKeys));
});
document.querySelectorAll('.connector-status.available[data-telegram]').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const res = await api('/api/telegram/link', { method: 'POST' });
window.open(res.link, '_blank');
showToast('Ouvre Telegram et appuie sur Start');
} catch (err) {
showToast(err.message || 'Telegram non configuré');
}
});
});
document.querySelectorAll('.connector-status.available[data-vault]').forEach(btn => {
btn.addEventListener('click', () => openVaultSetup(btn.dataset.vault));
});
document.querySelectorAll('.connector-status.disconnect[data-vault-remove]').forEach(btn => {
btn.addEventListener('click', () => {
cache.remove(`vault_${btn.dataset.vaultRemove}`);
showToast('Vault déconnecté');
renderConnectors();
});
});
document.querySelectorAll('.connector-status.available[data-ical]').forEach(btn => {
btn.addEventListener('click', () => {
const url = prompt('Colle l\'URL du flux iCal (.ics) :');
if (url && url.trim()) addIcalSource(url.trim());
});
});
document.querySelectorAll('.connector-status.disconnect').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Déconnecter ce service ?')) return;
try {
await api(btn.dataset.disconnect, { method: 'DELETE' });
showToast('Déconnecté');
renderConnectors();
} catch { showToast('Erreur'); }
});
});
}
// ── Account Summary (Comptes connectés) ────────────────
async function renderAccountSummary() {
// Remove existing summary if re-rendering
const existing = document.getElementById('account-summary');
if (existing) existing.remove();
// Fetch statuses in parallel
const [accounts, googleStatus, microsoftStatus, telegramStatus] = await Promise.all([
api('/api/accounts').catch(() => []),
api('/api/google/status').catch(() => null),
api('/api/microsoft/status').catch(() => null),
api('/api/telegram/status').catch(() => null),
]);
const googleConnected = googleStatus?.connected || googleStatus?.google_connected || false;
const msConnected = microsoftStatus?.connected || false;
const telegramConnected = telegramStatus?.connected || false;
// Build service list
const services = [];
// Email accounts
if (accounts && accounts.length > 0) {
for (const a of accounts) {
const email = a.email || a.label || 'Email';
const short = email.length > 28 ? email.slice(0, 26) + '...' : email;
services.push({ icon: '\uD83D\uDCE7', label: short, connected: true, key: 'email-' + a.id });
}
} else {
services.push({ icon: '\uD83D\uDCE7', label: 'Email', connected: false, key: 'email' });
}
// Google (Calendar + Mail)
if (googleConnected) {
services.push({ icon: '\uD83D\uDCC5', label: 'Google Calendar', connected: true, key: 'gcal' });
} else {
services.push({ icon: '\uD83D\uDCC5', label: 'Google Calendar', connected: false, key: 'gcal', authUrl: '/api/google/auth', needsKeys: 'google', configured: googleStatus?.configured || googleStatus?.has_own_keys });
}
// Microsoft 365 (OneDrive/Outlook)
if (msConnected) {
services.push({ icon: '\uD83D\uDCC1', label: 'Microsoft 365', connected: true, key: 'ms365' });
} else {
services.push({ icon: '\uD83D\uDCC1', label: 'Microsoft 365', connected: false, key: 'ms365', authUrl: '/api/microsoft/auth', needsKeys: 'microsoft', configured: microsoftStatus?.configured || microsoftStatus?.has_own_keys });
}
// Telegram
if (telegramConnected) {
services.push({ icon: '\uD83D\uDCAC', label: 'Telegram', connected: true, key: 'telegram' });
} else {
services.push({ icon: '\uD83D\uDCAC', label: 'Telegram', connected: false, key: 'telegram', customConnect: 'telegram' });
}
// Build HTML
const rows = services.map(s => {
const dot = s.connected
? ' '
: ' ';
const action = s.connected
? '\u2713 '
: `Connecter `;
return `${dot}${s.icon} ${escHtml(s.label)} ${action}
`;
}).join('');
const summary = document.createElement('div');
summary.id = 'account-summary';
summary.className = 'account-summary-card';
summary.innerHTML = `Comptes connect\u00e9s
${rows}`;
// Insert at top of account-content
const acctContent = $('account-content');
if (acctContent) {
acctContent.insertBefore(summary, acctContent.firstChild);
}
// Bind connect buttons
summary.querySelectorAll('.summary-connect').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const svc = btn.dataset.svc;
if (svc === 'telegram' || btn.dataset.custom === 'telegram') {
// Trigger Telegram link
api('/api/telegram/link', { method: 'POST' }).then(res => {
window.open(res.link, '_blank');
showToast('Ouvre Telegram et appuie sur Start');
}).catch(err => showToast(err.message || 'Telegram non configur\u00e9'));
} else if (btn.dataset.auth) {
if (btn.dataset.needsKeys && btn.dataset.configured !== 'true') {
openKeysSetup(btn.dataset.needsKeys);
} else {
const sep = btn.dataset.auth.includes('?') ? '&' : '?';
window.open(`${btn.dataset.auth}${sep}token=${_token}`, '_blank');
}
} else {
// Scroll to connectors section
const connSection = document.querySelector('[id^="connector-"]');
if (connSection) connSection.scrollIntoView({ behavior: 'smooth' });
}
});
});
// Also update the status bar in journal view
updateServiceStatusBar(services);
}
// ── Service Status Bar (under topbar) ──────────────────
function updateServiceStatusBar(services) {
let bar = document.getElementById('service-status-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'service-status-bar';
bar.className = 'service-status-bar';
const topbar = document.querySelector('.topbar');
if (topbar && topbar.parentNode) {
topbar.parentNode.insertBefore(bar, topbar.nextSibling);
}
}
// Deduplicate: show one icon per category
const categories = {};
for (const s of services) {
const cat = s.key.startsWith('email') ? 'email' : s.key;
if (!categories[cat] || s.connected) {
categories[cat] = s;
}
}
const iconMap = { email: '\uD83D\uDCE7', gcal: '\uD83D\uDCC5', ms365: '\uD83D\uDCC1', telegram: '\uD83D\uDCAC' };
const items = Object.entries(categories).map(([cat, s]) => {
const icon = iconMap[cat] || s.icon;
const check = s.connected ? '\u2713' : '\u2717';
const cls = s.connected ? 'sbar-ok' : 'sbar-off';
return `${icon}\u00A0${check} `;
}).join('');
bar.innerHTML = items;
}
// Initialize status bar on journal load
async function initServiceStatusBar() {
try {
const [accounts, googleStatus, microsoftStatus, telegramStatus] = await Promise.all([
api('/api/accounts').catch(() => []),
api('/api/google/status').catch(() => null),
api('/api/microsoft/status').catch(() => null),
api('/api/telegram/status').catch(() => null),
]);
const services = [];
const googleConnected = googleStatus?.connected || googleStatus?.google_connected || false;
const msConnected = microsoftStatus?.connected || false;
const telegramConnected = telegramStatus?.connected || false;
if (accounts && accounts.length > 0) {
services.push({ icon: '\uD83D\uDCE7', label: 'Email', connected: true, key: 'email' });
} else {
services.push({ icon: '\uD83D\uDCE7', label: 'Email', connected: false, key: 'email' });
}
services.push({ icon: '\uD83D\uDCC5', label: 'Google Calendar', connected: googleConnected, key: 'gcal' });
services.push({ icon: '\uD83D\uDCC1', label: 'Microsoft 365', connected: msConnected, key: 'ms365' });
services.push({ icon: '\uD83D\uDCAC', label: 'Telegram', connected: telegramConnected, key: 'telegram' });
updateServiceStatusBar(services);
} catch (e) {
// silently fail
}
}
async function addIcalSource(url) {
try {
await api('/api/calendar-sources', {
method: 'POST',
body: { url, label: 'Calendrier iCal' }
});
showToast('Calendrier ajouté ! Sync en cours...');
} catch (err) {
showToast('Erreur : ' + err.message);
}
}
// ── Obsidian Vault Setup ───────────────────────────────
function openVaultSetup(vaultId) {
const modal = $('privacy-modal');
const sheet = modal.querySelector('.modal-sheet');
sheet.innerHTML = `
Connecter Obsidian
Comment tu synchronises ton vault Obsidian ?
☁️
iCloud
📁
Google Drive
📦
Dropbox
☁️
OneDrive
💻
Dossier local
🔄
Obsidian Sync
Annuler
`;
modal.classList.add('open');
const hints = {
icloud: { hint: "Sur Mac, ton vault iCloud est dans :", example: "~/Library/Mobile Documents/iCloud~md~obsidian/Documents/MonVault", auto: "" },
gdrive: { hint: "Indique le chemin de ton Google Drive où se trouve le vault :", example: "~/Google Drive/MonVault", auto: "" },
dropbox: { hint: "Indique le chemin Dropbox :", example: "~/Dropbox/MonVault", auto: "" },
onedrive: { hint: "Indique le chemin OneDrive :", example: "~/OneDrive/MonVault", auto: "" },
local: { hint: "Indique le chemin complet de ton vault :", example: "/home/user/Documents/MonVault", auto: "" },
'obsidian-sync': { hint: "Obsidian Sync ne propose pas d'accès API. Mais ton vault a quand même un dossier local. Indique son chemin :", example: "~/Documents/MonVault", auto: "" }
};
sheet.querySelectorAll('.setup-card[data-sync]').forEach(card => {
card.addEventListener('click', () => {
sheet.querySelectorAll('.setup-card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
const sync = card.dataset.sync;
const info = hints[sync];
$('vault-hint').textContent = info.hint;
$('vault-path').placeholder = info.example;
$('vault-path-form').classList.remove('hidden');
$('vault-path').focus();
});
});
$('vault-save').addEventListener('click', async () => {
const path = $('vault-path').value.trim();
if (!path) { $('vault-error').textContent = 'Indique le chemin de ton vault'; return; }
try {
await api('/api/gardien/config', { method: 'POST', body: { root_path: path, vault_type: 'obsidian' } });
cache.set(`vault_${vaultId}`, path);
modal.classList.remove('open');
showToast('Vault Obsidian connecté !');
renderConnectors();
location.reload();
} catch (err) {
$('vault-error').textContent = err.message || 'Chemin inaccessible';
}
});
$('vault-cancel').addEventListener('click', () => {
modal.classList.remove('open');
location.reload();
});
}
// ── OAuth Keys Setup (Google / Microsoft) ──────────────
function openKeysSetup(provider) {
const guides = {
google: {
title: '📅 Connecter Google Calendar',
intro: 'Pour lire et créer des événements dans ton agenda Google, Claire a besoin d\'une autorisation. C\'est gratuit et sécurisé — tu peux la retirer à tout moment.',
steps: [
'🔑 Va sur console.cloud.google.com et connecte-toi avec le même compte Google que ton agenda ',
'📁 Crée un nouveau projet : clique sur le sélecteur en haut → Nouveau projet → Nomme-le "Claire " → Créer ',
'⚡ Active l\'API Calendar : menu API et services → Bibliothèque → cherche "Google Calendar API " → clique Activer ',
'🛡️ Configure l\'écran de connexion : API et services → Écran de consentement OAuth → choisis "Externe " → remplis juste le nom ("Claire") et ton email → Enregistrer ',
'🔐 Crée les identifiants : API et services → Identifiants → Créer des identifiants → ID client OAuth → Type "Application Web " → Dans "URI de redirection autorisés" ajoute :https://claire-ia.fr/api/google/callback',
'📋 Copie le Client ID et le Client Secret qui s\'affichent et colle-les ci-dessous',
],
footer: '⏱️ ~5 minutes · Une seule fois · Plus simple depuis un ordinateur ',
saveUrl: '/api/google/credentials',
authUrl: '/api/google/auth'
},
microsoft: {
title: 'Connecter Outlook Calendar',
steps: [
'Va sur portal.azure.com → Inscriptions d\'applications',
'Clique "Nouvelle inscription" — nom : "Claire", comptes : "Tous les types"',
'Note l\'ID d\'application (client) = ton Client ID',
'Va dans "Certificats & secrets" → "Nouveau secret client" → copie la valeur',
'Va dans "Authentification" → Ajoute https://claire-ia.fr/api/microsoft/callback ',
'Copie le Client ID et le Secret ci-dessous'
],
saveUrl: '/api/microsoft/credentials',
authUrl: '/api/microsoft/auth'
}
};
const guide = guides[provider];
if (!guide) return;
// Use the privacy modal as a generic sheet
const modal = $('privacy-modal');
const sheet = modal.querySelector('.modal-sheet');
sheet.innerHTML = `
${guide.title}
${guide.intro || 'Pour connecter ce service, suis ces étapes :'}
${guide.steps.map(s => `${s} `).join('')}
${guide.footer ? `
${guide.footer}
` : ''}
Annuler
`;
modal.classList.add('open');
$('keys-cancel').addEventListener('click', () => {
modal.classList.remove('open');
// Restore privacy content on next open
location.reload(); // simplest way to restore the modal
});
$('keys-save').addEventListener('click', async () => {
const clientId = $('keys-client-id').value.trim();
const clientSecret = $('keys-client-secret').value.trim();
if (!clientId || !clientSecret) {
$('keys-error').textContent = 'Les deux champs sont requis';
return;
}
try {
await api(guide.saveUrl, { method: 'POST', body: { client_id: clientId, client_secret: clientSecret } });
showToast('Clés enregistrées ! Redirection vers la connexion...');
modal.classList.remove('open');
const sep = guide.authUrl.includes('?') ? '&' : '?';
setTimeout(() => window.open(`${guide.authUrl}${sep}token=${_token}`, '_blank'), 1000);
} catch (err) {
$('keys-error').textContent = err.message || 'Erreur';
}
});
}
// ── AI Transparency logs ───────────────────────────────
async function openAiLogs() {
$('ai-logs-modal').classList.add('open');
$('ai-logs-list').innerHTML = 'Chargement...
';
$('ai-logs-stats').innerHTML = '';
try {
const data = await api('/api/ai-logs?limit=50');
const stats = data.stats || {};
$('ai-logs-stats').innerHTML = `
${stats.total_calls || 0}
Appels total
${formatTokens(stats.total_tokens_sent || 0)}
Tokens envoyés
${formatTokens(stats.total_tokens_received || 0)}
Tokens reçus
`;
if (!data.logs || data.logs.length === 0) {
$('ai-logs-list').innerHTML = 'Aucun appel IA enregistré pour le moment.
';
return;
}
$('ai-logs-list').innerHTML = data.logs.map(log => {
const time = new Date(log.created_at).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: _timezone
});
const purposeLabels = {
chat: 'Conversation',
briefing: 'Briefing',
classify: 'Classification',
draft: 'Brouillon',
summarize: 'Résumé',
};
const purposeLabel = purposeLabels[log.purpose] || log.purpose || 'Autre';
return `
${escHtml(log.provider)} · ${escHtml(log.model)}
${log.prompt_preview ? `
${escHtml(log.prompt_preview)}
Voir plus
` : ''}
`;
}).join('');
} catch (err) {
$('ai-logs-list').innerHTML = `Erreur : ${escHtml(err.message)}
`;
}
}
function closeAiLogs() {
$('ai-logs-modal').classList.remove('open');
}
function formatTokens(n) {
if (!n || n === 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
// ── Socket.IO real-time notifications ──────────────────
let _socket = null;
function initSocket() {
if (_socket) return;
if (typeof io === 'undefined') return; // socket.io not loaded
try {
_socket = io({ transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 10000, reconnectionAttempts: Infinity });
_socket.on('draft-created', (data) => {
showToast(`Brouillon prêt : ${data.subject || 'sans objet'}`);
});
_socket.on('new-emails', (data) => {
const emails = data?.emails || [];
if (emails.length === 1) {
const e = emails[0];
const from = e.from_name || e.from_email || 'Quelqu\'un';
const subj = e.subject ? ` — ${e.subject.substring(0, 50)}` : '';
showToast(`📩 ${from}${subj}`);
appendNotification('email', `Nouveau mail de ${from}${subj}`, { uid: e.uid, account_id: data.accountId });
} else if (emails.length > 1) {
showToast(`📩 ${emails.length} nouveaux mails`);
} else {
showToast('📩 Nouveaux mails reçus');
}
setTimeout(() => loadJournal(), 2000);
});
_socket.on('event-detected', (data) => {
const title = data.title || 'un événement';
appendNotification('event', `J'ai repéré ${title} dans un mail. Tu veux l'ajouter à ton agenda ?`, data);
});
_socket.on('invoice-available', (data) => {
appendNotification('invoice', `Facture détectée : ${data.from || 'expéditeur inconnu'}`, data);
});
_socket.on('relance-ready', (data) => {
appendNotification('relance', `Relance prête pour ${data.contact || 'un contact'}`, data);
});
_socket.on('deadline-detected', (data) => {
appendNotification('deadline', `Deadline repérée : ${data.title || data.description || ''}`, data);
});
_socket.on('meeting-prep', (data) => {
appendNotification('prep',
`Réunion « ${data.title || '?'} » dans 15 min. ${data.prep_summary || ''}`,
data
);
});
_socket.on('meeting-ended', (data) => {
const title = data.title || 'ta réunion';
appendMeetingDebrief(title, data);
});
_socket.on('day-ending', () => {
appendDayEndingPrompt();
});
_socket.on('weekly-report-ready', () => {
showToast('Ton bilan de la semaine est prêt !');
});
_socket.on('sync-retrying', (data) => {
showToast(`Sync mail : tentative ${data.attempt}...`);
});
_socket.on('sync-error', (data) => {
if (data.persistent) {
showToast('Sync mail échouée. Vérifie ta connexion.');
} else {
showToast('Erreur sync mail, nouvelle tentative...');
}
});
// Sync progress indicator (first-sync / cold-start)
_socket.on('sync-progress', (data) => {
const existing = document.getElementById('sync-progress-bar');
if (!existing) {
const bar = document.createElement('div');
bar.id = 'sync-progress-bar';
bar.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:var(--bg2,#f0ebe3);padding:8px 16px;text-align:center;font-size:.82rem;color:var(--ink3,#6b6055);border-bottom:1px solid var(--bg3,#e0dbd4);';
document.body.prepend(bar);
}
const bar = document.getElementById('sync-progress-bar');
if (bar) {
bar.textContent = data.message || `${data.processed} emails traités...`;
}
});
// Remove progress bar when sync completes
_socket.on('sync-ok', () => {
const bar = document.getElementById('sync-progress-bar');
if (bar) bar.remove();
});
// Connection status
_socket.on('connect', () => { console.log('[socket] connected'); updateConnectionStatus(true); });
_socket.on('disconnect', (reason) => { console.warn('[socket] disconnected:', reason); updateConnectionStatus(false); });
_socket.on('reconnect', () => { console.log('[socket] reconnected'); updateConnectionStatus(true); if (typeof loadJournal === 'function') loadJournal(); });
} catch { /* socket unavailable — silent */ }
}
function updateConnectionStatus(connected) {
let indicator = document.getElementById('connection-status');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'connection-status';
document.body.appendChild(indicator);
}
indicator.className = connected ? 'conn-status conn-online' : 'conn-status conn-offline';
indicator.textContent = connected ? '' : '\u26A1 Reconnexion...';
indicator.style.display = connected ? 'none' : 'block';
}
function appendMeetingDebrief(title, data) {
openChat();
const safeTitle = escHtml(title);
appendChatMsg('claire', `Ta réunion « ${safeTitle} » est terminée. Des notes à retenir ou des actions à planifier ?`);
const lastMsg = chatConv.querySelector('.chat-msg.claire:last-child');
if (lastMsg) {
const bubble = document.createElement('div');
bubble.className = 'chat-action-bubble meeting-debrief';
bubble.innerHTML = `
📝
${safeTitle}
Oui, noter
Rien à noter
`;
lastMsg.appendChild(bubble);
bubble.querySelectorAll('.cab-btn').forEach(b => {
b.addEventListener('click', (e) => {
e.stopPropagation();
if (b.dataset.chatAction === 'debrief-yes') {
chatInput.value = `Suite à la réunion ${title}, `;
chatInput.focus();
// Place cursor at end
chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
} else {
showToast('OK, rien à noter.');
}
bubble.remove();
});
});
}
}
function appendDayEndingPrompt() {
openChat();
appendChatMsg('claire', 'La journée touche à sa fin. Des choses à noter avant de déconnecter ?');
const lastMsg = chatConv.querySelector('.chat-msg.claire:last-child');
if (lastMsg) {
const bubble = document.createElement('div');
bubble.className = 'chat-action-bubble day-ending';
bubble.innerHTML = `
🌙
Fin de journée
Oui, noter
Non, bonne soirée !
`;
lastMsg.appendChild(bubble);
bubble.querySelectorAll('.cab-btn').forEach(b => {
b.addEventListener('click', (e) => {
e.stopPropagation();
if (b.dataset.chatAction === 'note-yes') {
chatInput.value = 'Avant de partir, je voulais noter : ';
chatInput.focus();
chatInput.setSelectionRange(chatInput.value.length, chatInput.value.length);
} else {
appendChatMsg('claire', 'Bonne soirée ! À demain.');
showToast('Bonne soirée !');
}
bubble.remove();
});
});
}
}
function appendNotification(type, text, data) {
openChat();
appendChatMsg('claire', text);
const lastMsg = chatConv.querySelector('.chat-msg.claire:last-child');
if (lastMsg && type === 'event' && data) {
const bubble = document.createElement('div');
bubble.className = 'chat-action-bubble event';
bubble.innerHTML = `
📅
${escHtml(data.title || 'Événement')}
${escHtml(data.start_dt || '')}
Ajouter
Ignorer
`;
lastMsg.appendChild(bubble);
bubble.querySelectorAll('.cab-btn').forEach(b => {
b.addEventListener('click', async (e) => {
e.stopPropagation();
const action = b.dataset.chatAction;
if (action === 'add-event') {
chatInput.value = `Ajoute cet événement à mon agenda : ${data.title || ''} le ${data.start_dt || ''}`;
sendChat();
} else {
bubble.remove();
showToast('OK, ignoré');
}
});
});
}
}
// ── Swipe-down to close modals (mobile) ────────────────
function initSwipeDismiss() {
document.querySelectorAll('.modal-sheet').forEach(sheet => {
let startY = 0;
sheet.addEventListener('touchstart', e => {
if (sheet.scrollTop <= 0) startY = e.touches[0].clientY;
else startY = 0;
}, { passive: true });
sheet.addEventListener('touchend', e => {
if (startY && e.changedTouches[0].clientY - startY > 80) {
// Swiped down > 80px — close the modal
const overlay = sheet.closest('.modal-overlay');
if (overlay) overlay.classList.remove('open');
}
startY = 0;
}, { passive: true });
});
}
// ── Expose functions needed by inline onclick in HTML ──
window.sendFeedback = sendFeedback;
window.toggleTheme = toggleTheme;
// ── Boot ───────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
})();