mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 05:28:15 +02:00
Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork. MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go) Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la session n'est plus perdu au compactage suivant. Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne demande au lieu de continuer la tâche en cours. PROJETS (projects.go) Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md → memory/generale/, discussions et tâches orphelines rattachées). Une tâche planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive pas dans la mémoire du chantier affiché à l'écran. TRACKERS (tracker.go) 3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais en entier — consultation par niveaux (vue d'ensemble → année → mois → événements), et la dernière valeur de chaque tracker est donnée d'emblée au modèle, qui répond sans appeler l'outil. Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation avec la description du projet (le modèle ne peut plus le désynchroniser) ; prompt système rattaché au PRESET et non plus global — stocké en base, pas dans le .env, qui ne saurait pas porter un texte multiligne. Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les fichiers : msgText signale la présence d'une image au compactage, et le garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges). Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c45ba615fa
commit
17f8fa8f34
31 files changed
+3859
-62
No files matched your search
@@ -149,9 +149,18 @@ function renderMemList(){
|
||||
const span=document.createElement('span');
|
||||
const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b);
|
||||
if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); }
|
||||
const acts=document.createElement('span'); acts.style.cssText='display:flex;gap:4px;flex:none';
|
||||
const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||||
btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); };
|
||||
row.appendChild(span); row.appendChild(btn); list.appendChild(row);
|
||||
acts.appendChild(btn);
|
||||
// Déplacer vers un autre projet : proposé seulement s'il y en a un autre, et
|
||||
// jamais sur l'index MEMORY.md, qui appartient au projet et ne se déplace pas.
|
||||
if(typeof PROJECTS!=='undefined' && PROJECTS.length>1 && x.name.toUpperCase()!=='MEMORY.MD'){
|
||||
const mv=document.createElement('button'); mv.textContent='déplacer'; mv.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||||
mv.onclick=e=>{ e.stopPropagation(); moveMemPageUI(x.name); };
|
||||
acts.appendChild(mv);
|
||||
}
|
||||
row.appendChild(span); row.appendChild(acts); list.appendChild(row);
|
||||
});
|
||||
if(matches.length > shown.length){
|
||||
const more=document.createElement('div'); more.className='mem-more';
|
||||
@@ -451,7 +460,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -269,6 +269,10 @@ function renderToolMsg(el, tu){
|
||||
git_status: {lbl:'git', head:'git status'},
|
||||
git_diff: {lbl:'git', head:'git diff'},
|
||||
git_clone: {lbl:'git', head:'git clone'},
|
||||
// Mémoire longue de la conversation : blocs archivés au compactage. Étiquette
|
||||
// distincte de « mémoire » (les pages MEMORY), ce n'est pas la même chose.
|
||||
recall: {lbl:'archive', head:'rappel d’un bloc archivé'},
|
||||
recall_search: {lbl:'archive', head:'recherche dans l’archive'},
|
||||
};
|
||||
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
|
||||
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
|
||||
|
||||
@@ -11,6 +11,7 @@ let tasksList = [];
|
||||
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
|
||||
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
|
||||
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
|
||||
let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé »)
|
||||
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
|
||||
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
|
||||
let tasksPollTimer = null;
|
||||
@@ -33,6 +34,7 @@ function renderTasks(r){
|
||||
const paused = !!(r && r.paused);
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_PROJECTS = (r && r.projects) || [];
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
@@ -193,6 +195,9 @@ function openTask(id){
|
||||
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
|
||||
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
// Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une
|
||||
// tâche pour le chantier qu'on a sous les yeux.
|
||||
fillTaskProjectSelect(t ? (t.project||'') : (typeof PROJ_ACTIVE!=='undefined' ? PROJ_ACTIVE : ''));
|
||||
|
||||
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
|
||||
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
|
||||
@@ -265,6 +270,20 @@ function fillPresetSelect(selected){
|
||||
sel.value = selected || '';
|
||||
}
|
||||
|
||||
// fillTaskProjectSelect peuple le sélecteur « projet visé ». Pas d'option vide :
|
||||
// une tâche appartient toujours à un projet — le serveur retombe sur le projet
|
||||
// actif si rien n'est envoyé, autant le montrer explicitement.
|
||||
function fillTaskProjectSelect(selected){
|
||||
const sel = document.getElementById('task-project');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_PROJECTS.forEach(p=>{
|
||||
const o = document.createElement('option'); o.value = p.slug; o.textContent = p.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
@@ -304,13 +323,14 @@ async function saveTask(){
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
const preset = document.getElementById('task-preset').value;
|
||||
const project = (document.getElementById('task-project')||{}).value || '';
|
||||
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
||||
const no_mem = !document.getElementById('task-mem').checked;
|
||||
const no_web = !document.getElementById('task-web').checked;
|
||||
const st = document.getElementById('task-modal-status');
|
||||
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
|
||||
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
|
||||
@@ -15,6 +15,14 @@ let SET_LAST = 'config';
|
||||
const SET_HOOKS = {
|
||||
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||
'dictee': () => { loadDictateSettings(); },
|
||||
// Les compteurs d'un projet (discussions, pages, trackers) bougent en
|
||||
// permanence : on les relit à l'ouverture plutôt que d'afficher ceux du
|
||||
// chargement de la page.
|
||||
'projets': () => { loadProjects(); },
|
||||
// Le prompt système appartient au PRESET actif : changer de modèle change le
|
||||
// texte à afficher. On le relit à l'ouverture du panneau plutôt que de garder
|
||||
// celui du chargement de la page, qui serait alors celui d'un autre preset.
|
||||
'sysprompt': () => { loadSys(); },
|
||||
};
|
||||
function openSettings(pane){
|
||||
showModal('settings-modal');
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Projets — un chantier = une mémoire + ses discussions + ses trackers.
|
||||
//
|
||||
// Deux surfaces, volontairement asymétriques :
|
||||
// - le SÉLECTEUR de la barre latérale, qu'on utilise dix fois par jour : il ne
|
||||
// fait qu'une chose, changer de projet ;
|
||||
// - le PANNEAU de réglages, qu'on ouvre rarement : créer, renommer, décrire,
|
||||
// supprimer.
|
||||
//
|
||||
// Changer de projet change ce que voit l'IA (mémoire, trackers) ET la liste des
|
||||
// discussions : le serveur ouvre une discussion du projet d'arrivée, et le flux
|
||||
// SSE redessine le chat tout seul — comme une bascule de discussion.
|
||||
|
||||
let PROJECTS = [], PROJ_ACTIVE = '';
|
||||
|
||||
async function loadProjects(){
|
||||
let r;
|
||||
try{ r = await jget('/api/projects'); }catch(_){ return; }
|
||||
if(!r || !r.ok) return;
|
||||
PROJECTS = r.projects || [];
|
||||
PROJ_ACTIVE = r.active || '';
|
||||
renderProjectPicker();
|
||||
renderProjectList();
|
||||
}
|
||||
|
||||
// Le sélecteur de la barre latérale. Reconstruit à chaque chargement : la liste
|
||||
// est courte, et un rendu complet évite de synchroniser des options une par une.
|
||||
function renderProjectPicker(){
|
||||
const sel = document.getElementById('proj-switch');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
PROJECTS.forEach(p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p.slug;
|
||||
o.textContent = p.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.value = PROJ_ACTIVE;
|
||||
}
|
||||
|
||||
async function onProjectSwitch(sel){
|
||||
const slug = sel.value;
|
||||
if(!slug || slug === PROJ_ACTIVE) return;
|
||||
const r = await jpost('/api/projects', {action:'switch', slug});
|
||||
if(!r.ok){
|
||||
// Refusé (génération en cours) : on remet le sélecteur sur le projet réel,
|
||||
// sinon il affiche un projet où l'on n'est pas.
|
||||
sel.value = PROJ_ACTIVE;
|
||||
toast('erreur : ' + (r.error||''));
|
||||
return;
|
||||
}
|
||||
PROJECTS = r.projects || PROJECTS;
|
||||
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
|
||||
renderProjectPicker();
|
||||
renderProjectList();
|
||||
// La mémoire, les trackers et les discussions appartiennent au projet : tout
|
||||
// ce qui est à l'écran parle du précédent.
|
||||
if(typeof loadConversations === 'function') loadConversations();
|
||||
if(typeof loadAgent === 'function') loadAgent();
|
||||
if(typeof loadTrackers === 'function') loadTrackers();
|
||||
}
|
||||
|
||||
// Le panneau de gestion. Une ligne par projet, avec ce qu'il contient — c'est ce
|
||||
// qui permet de décider quoi supprimer sans ouvrir chaque projet.
|
||||
function renderProjectList(){
|
||||
const list = document.getElementById('proj-list');
|
||||
if(!list) return;
|
||||
list.textContent = '';
|
||||
PROJECTS.forEach(p => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset';
|
||||
row.style.fontSize = '12px';
|
||||
|
||||
const span = document.createElement('span');
|
||||
const b = document.createElement('b');
|
||||
b.style.color = 'var(--text)';
|
||||
b.textContent = p.name;
|
||||
span.appendChild(b);
|
||||
if(p.active){
|
||||
const a = document.createElement('span');
|
||||
a.className = 'sbadge on';
|
||||
a.textContent = 'actif';
|
||||
a.style.marginLeft = '6px';
|
||||
span.appendChild(a);
|
||||
}
|
||||
const d = document.createElement('span');
|
||||
d.className = 'muted';
|
||||
d.textContent = ' — ' + p.convs + ' discussion' + (p.convs > 1 ? 's' : '')
|
||||
+ ' · ' + p.pages + ' page' + (p.pages > 1 ? 's' : '')
|
||||
+ ' · ' + p.trackers + ' tracker' + (p.trackers > 1 ? 's' : '');
|
||||
span.appendChild(d);
|
||||
if(p.desc){
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'muted';
|
||||
desc.style.cssText = 'font-size:11px;margin-top:2px';
|
||||
desc.textContent = p.desc;
|
||||
span.appendChild(desc);
|
||||
}
|
||||
|
||||
const acts = document.createElement('span');
|
||||
acts.style.cssText = 'display:flex;gap:4px;flex:none';
|
||||
acts.appendChild(projBtn('décrire', () => editProjectDesc(p)));
|
||||
acts.appendChild(projBtn('renommer', () => renameProjectUI(p)));
|
||||
// Le dernier projet n'est pas supprimable côté serveur : ne pas proposer le
|
||||
// bouton évite une erreur qu'on ne peut pas résoudre.
|
||||
if(PROJECTS.length > 1) acts.appendChild(projBtn('supprimer', () => deleteProjectUI(p)));
|
||||
|
||||
row.appendChild(span);
|
||||
row.appendChild(acts);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function projBtn(label, fn){
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
|
||||
b.onclick = e => { e.stopPropagation(); fn(); };
|
||||
return b;
|
||||
}
|
||||
|
||||
// Applique une action et rafraîchit tout ce qui dépend des projets.
|
||||
async function projectAction(payload, okMsg){
|
||||
const r = await jpost('/api/projects', payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
|
||||
PROJECTS = r.projects || PROJECTS;
|
||||
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
|
||||
renderProjectPicker();
|
||||
renderProjectList();
|
||||
if(okMsg) toast(okMsg);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function newProject(){
|
||||
const name = await askPrompt('Nom du projet (ex. « Serveur NAS », « Recettes ») :', {title:'Nouveau projet', okText:'Créer'});
|
||||
if(!name || !name.trim()) return;
|
||||
await projectAction({action:'create', name:name.trim()}, 'projet créé');
|
||||
}
|
||||
|
||||
async function renameProjectUI(p){
|
||||
const name = await askPrompt('Nouveau nom :', {title:'Renommer le projet', default:p.name, okText:'Renommer'});
|
||||
if(!name || !name.trim() || name.trim() === p.name) return;
|
||||
// Seul le libellé change : les chemins disque, les discussions et les trackers
|
||||
// restent accrochés au slug d'origine.
|
||||
await projectAction({action:'rename', slug:p.slug, name:name.trim()}, 'renommé');
|
||||
}
|
||||
|
||||
async function editProjectDesc(p){
|
||||
const desc = await askPrompt(
|
||||
'À quoi sert ce projet ? Ce texte est donné à l\'IA au début de chaque discussion du projet.',
|
||||
{title:'Description du projet', default:p.desc||'', placeholder:'ex. administration du NAS Unraid, docker, sauvegardes', okText:'Enregistrer'});
|
||||
if(desc === null) return;
|
||||
await projectAction({action:'desc', slug:p.slug, desc}, 'description enregistrée');
|
||||
}
|
||||
|
||||
async function deleteProjectUI(p){
|
||||
const ok = await askConfirm(
|
||||
'Supprimer « ' + p.name + ' » ?\n\nSes ' + p.pages + ' page(s) mémoire, ses '
|
||||
+ p.convs + ' discussion(s) et ses ' + p.trackers + ' tracker(s) seront effacés. Irréversible.',
|
||||
{title:'Supprimer le projet', okText:'Supprimer', danger:true});
|
||||
if(!ok) return;
|
||||
await projectAction({action:'delete', slug:p.slug}, 'projet supprimé');
|
||||
if(typeof loadConversations === 'function') loadConversations();
|
||||
if(typeof loadAgent === 'function') loadAgent();
|
||||
if(typeof loadTrackers === 'function') loadTrackers();
|
||||
}
|
||||
|
||||
// Déplacer une page mémoire vers un autre projet — appelé depuis la liste des
|
||||
// pages (06-settings.js). Une page rangée dans le mauvais chantier n'a pas à
|
||||
// être recopiée à la main pour en changer.
|
||||
async function moveMemPageUI(name){
|
||||
const others = PROJECTS.filter(p => p.slug !== PROJ_ACTIVE);
|
||||
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
|
||||
const choice = await askPrompt(
|
||||
'Déplacer « ' + name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
|
||||
{title:'Déplacer la page', default:others[0].name, okText:'Déplacer'});
|
||||
if(!choice) return;
|
||||
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
|
||||
if(!target){ toast('projet inconnu : ' + choice); return; }
|
||||
const r = await jpost('/api/mem/move', {name, to:target.slug});
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('déplacée vers ' + target.name);
|
||||
if(typeof loadAgent === 'function') loadAgent();
|
||||
loadProjects();
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Trackers — le 3ᵉ type de mémoire : les données DATÉES qui s'accumulent
|
||||
// (compteurs, relevés, journaux d'événements).
|
||||
//
|
||||
// Une page mémoire répond à « qu'est-ce que je sais » ; un tracker répond à
|
||||
// « comment ça évolue ». D'où deux affichages distincts : la liste montre la
|
||||
// DERNIÈRE valeur de chaque tracker (c'est presque toujours la question posée),
|
||||
// le détail montre les points, du plus récent au plus ancien.
|
||||
//
|
||||
// La liste ne charge jamais les points : un tracker peut compter des milliers
|
||||
// d'entrées, et le panneau doit s'ouvrir instantanément.
|
||||
|
||||
let TRACKERS = [], TRACK_OPEN = null;
|
||||
|
||||
async function loadTrackers(){
|
||||
let r;
|
||||
try{ r = await jget('/api/tracker'); }catch(_){ return; }
|
||||
if(!r || !r.ok) return;
|
||||
TRACKERS = r.trackers || [];
|
||||
renderTrackers();
|
||||
}
|
||||
|
||||
function toggleTrackers(){
|
||||
const body = document.getElementById('track-body');
|
||||
if(!body) return;
|
||||
body.hidden = !body.hidden;
|
||||
if(!body.hidden) loadTrackers();
|
||||
}
|
||||
|
||||
// Date lisible : on n'affiche l'heure que si le point en a une côté serveur —
|
||||
// un relevé saisi « le 12 mars » ne doit pas s'afficher « 00:00 ».
|
||||
function trackWhen(ev){
|
||||
const d = new Date(ev.ts);
|
||||
const day = d.toLocaleDateString();
|
||||
return ev.date_only ? day : day + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function renderTrackers(){
|
||||
const list = document.getElementById('track-list');
|
||||
const count = document.getElementById('track-count');
|
||||
if(!list) return;
|
||||
if(count) count.textContent = TRACKERS.length ? '(' + TRACKERS.length + ')' : '';
|
||||
list.textContent = '';
|
||||
if(!TRACKERS.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'muted';
|
||||
e.style.fontSize = '12px';
|
||||
e.textContent = '(aucun tracker — ex. « poids », « abonnés », « relevés compteur »)';
|
||||
list.appendChild(e);
|
||||
return;
|
||||
}
|
||||
TRACKERS.forEach(t => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset';
|
||||
row.style.fontSize = '12px';
|
||||
row.onclick = () => openTracker(t.slug);
|
||||
|
||||
const span = document.createElement('span');
|
||||
const b = document.createElement('b');
|
||||
b.style.color = 'var(--text)';
|
||||
b.textContent = t.name;
|
||||
span.appendChild(b);
|
||||
const d = document.createElement('span');
|
||||
d.className = 'muted';
|
||||
// La dernière valeur EST l'information : elle passe avant le compte.
|
||||
d.textContent = t.last_text ? ' — ' + t.last_text : ' — (vide)';
|
||||
span.appendChild(d);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'muted';
|
||||
sub.style.cssText = 'font-size:11px;margin-top:2px';
|
||||
sub.textContent = t.count + ' point' + (t.count > 1 ? 's' : '')
|
||||
+ (t.last_ts ? ' · dernier le ' + new Date(t.last_ts).toLocaleDateString() : '');
|
||||
span.appendChild(sub);
|
||||
|
||||
row.appendChild(span);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// --- détail d'un tracker -----------------------------------------------------
|
||||
|
||||
async function openTracker(slug){
|
||||
const r = await jget('/api/tracker?slug=' + encodeURIComponent(slug));
|
||||
if(!r || !r.ok){ toast('erreur : ' + ((r&&r.error)||'')); return; }
|
||||
TRACK_OPEN = r.tracker;
|
||||
document.getElementById('track-modal-title').textContent = TRACK_OPEN.name;
|
||||
renderTrackerEvents();
|
||||
showModal('track-modal');
|
||||
}
|
||||
|
||||
function closeTracker(){ hideModal('track-modal'); TRACK_OPEN = null; }
|
||||
|
||||
function renderTrackerEvents(){
|
||||
const body = document.getElementById('track-events');
|
||||
if(!body || !TRACK_OPEN) return;
|
||||
body.textContent = '';
|
||||
const evs = (TRACK_OPEN.events || []).slice().reverse(); // plus récent d'abord
|
||||
if(!evs.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'muted';
|
||||
e.style.fontSize = '12px';
|
||||
e.textContent = '(aucun point)';
|
||||
body.appendChild(e);
|
||||
return;
|
||||
}
|
||||
evs.forEach(ev => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset';
|
||||
row.style.fontSize = '12px';
|
||||
const span = document.createElement('span');
|
||||
const w = document.createElement('b');
|
||||
w.style.color = 'var(--text)';
|
||||
w.textContent = trackWhen(ev);
|
||||
span.appendChild(w);
|
||||
const t = document.createElement('span');
|
||||
t.className = 'muted';
|
||||
t.textContent = ' — ' + ev.text;
|
||||
span.appendChild(t);
|
||||
|
||||
const acts = document.createElement('span');
|
||||
acts.style.cssText = 'display:flex;gap:4px;flex:none';
|
||||
acts.appendChild(trackBtn('modifier', () => editTrackerPoint(ev)));
|
||||
acts.appendChild(trackBtn('supprimer', () => deleteTrackerPoint(ev)));
|
||||
|
||||
row.appendChild(span);
|
||||
row.appendChild(acts);
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function trackBtn(label, fn){
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
|
||||
b.onclick = e => { e.stopPropagation(); fn(); };
|
||||
return b;
|
||||
}
|
||||
|
||||
// Applique une action sur le tracker ouvert, puis recharge son détail ET la
|
||||
// liste : les deux affichent la même donnée, ils ne doivent pas diverger.
|
||||
async function trackerAction(payload, okMsg){
|
||||
const r = await jpost('/api/tracker', payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
|
||||
TRACKERS = r.trackers || TRACKERS;
|
||||
renderTrackers();
|
||||
if(okMsg) toast(okMsg);
|
||||
return true;
|
||||
}
|
||||
|
||||
// La date est saisie en texte libre à précision variable (« 2026-07-15 »,
|
||||
// « 2026-07-15 14:30 », vide = maintenant) : c'est le serveur qui la comprend
|
||||
// (parseWhen), pour que l'outil du modèle et l'interface acceptent exactement
|
||||
// la même chose.
|
||||
const TRACK_WHEN_HINT = 'Date (vide = maintenant) — AAAA-MM-JJ ou AAAA-MM-JJ HH:MM';
|
||||
|
||||
async function addTrackerPoint(){
|
||||
if(!TRACK_OPEN) return;
|
||||
const text = await askPrompt('Valeur ou note du point :', {title:'Ajouter un point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
|
||||
if(!text || !text.trim()) return;
|
||||
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Ajouter'});
|
||||
if(when === null) return;
|
||||
if(!await trackerAction({action:'add', name:TRACK_OPEN.name, when, text}, 'point ajouté')) return;
|
||||
openTracker(TRACK_OPEN.slug);
|
||||
}
|
||||
|
||||
async function newTracker(){
|
||||
const name = await askPrompt('Nom du tracker (ex. « poids », « abonnés ») :', {title:'Nouveau tracker', okText:'Suivant'});
|
||||
if(!name || !name.trim()) return;
|
||||
const text = await askPrompt('Premier point — valeur ou note :', {title:'Premier point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
|
||||
if(!text || !text.trim()) return;
|
||||
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Créer'});
|
||||
if(when === null) return;
|
||||
// Un tracker naît de son premier point : pas de coquille vide à remplir plus tard.
|
||||
await trackerAction({action:'add', name:name.trim(), when, text}, 'tracker créé');
|
||||
}
|
||||
|
||||
async function editTrackerPoint(ev){
|
||||
if(!TRACK_OPEN) return;
|
||||
const text = await askPrompt('Valeur ou note :', {title:'Modifier le point', default:ev.text, okText:'Suivant'});
|
||||
if(text === null) return;
|
||||
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', default:'', placeholder:'vide = date inchangée', okText:'Enregistrer'});
|
||||
if(when === null) return;
|
||||
if(!await trackerAction({action:'edit', slug:TRACK_OPEN.slug, id:ev.id, text, when}, 'point modifié')) return;
|
||||
openTracker(TRACK_OPEN.slug);
|
||||
}
|
||||
|
||||
async function deleteTrackerPoint(ev){
|
||||
if(!TRACK_OPEN) return;
|
||||
if(!await askConfirm('Supprimer le point du ' + trackWhen(ev) + ' ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
|
||||
if(!await trackerAction({action:'delete', slug:TRACK_OPEN.slug, id:ev.id}, 'point supprimé')) return;
|
||||
openTracker(TRACK_OPEN.slug);
|
||||
}
|
||||
|
||||
async function renameTracker(){
|
||||
if(!TRACK_OPEN) return;
|
||||
const name = await askPrompt('Nouveau nom :', {title:'Renommer le tracker', default:TRACK_OPEN.name, okText:'Renommer'});
|
||||
if(!name || !name.trim() || name.trim() === TRACK_OPEN.name) return;
|
||||
// Le slug dérive du nom : le serveur re-clé le tracker si nécessaire, sinon un
|
||||
// point ajouté ensuite créerait un tracker parallèle sous l'ancien nom.
|
||||
if(!await trackerAction({action:'rename', slug:TRACK_OPEN.slug, name:name.trim()}, 'renommé')) return;
|
||||
closeTracker();
|
||||
}
|
||||
|
||||
async function moveTracker(){
|
||||
if(!TRACK_OPEN) return;
|
||||
const others = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).filter(p => p.slug !== PROJ_ACTIVE);
|
||||
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
|
||||
const choice = await askPrompt(
|
||||
'Déplacer « ' + TRACK_OPEN.name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
|
||||
{title:'Déplacer le tracker', default:others[0].name, okText:'Déplacer'});
|
||||
if(!choice) return;
|
||||
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
|
||||
if(!target){ toast('projet inconnu : ' + choice); return; }
|
||||
if(!await trackerAction({action:'move', slug:TRACK_OPEN.slug, to:target.slug}, 'déplacé vers ' + target.name)) return;
|
||||
closeTracker();
|
||||
if(typeof loadProjects === 'function') loadProjects();
|
||||
}
|
||||
|
||||
async function deleteTracker(){
|
||||
if(!TRACK_OPEN) return;
|
||||
const ok = await askConfirm(
|
||||
'Supprimer le tracker « ' + TRACK_OPEN.name + ' » et ses ' + TRACK_OPEN.count + ' point(s) ? Irréversible.',
|
||||
{title:'Supprimer le tracker', okText:'Supprimer', danger:true});
|
||||
if(!ok) return;
|
||||
if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return;
|
||||
closeTracker();
|
||||
}
|
||||
Reference in new issue
Block a user