Files
flowdeck/static/js/my_tasks.js
T
bruno 3706689eca
FlowDeck CI / lint (push) Successful in 2m15s
FlowDeck CI / test (push) Failing after 13m47s
FlowDeck CI / docker (push) Skipped
fix: side peek = document seul en edition + bouton Open calendrier/Kanban (v7.50.0)
- Les 4 peeks (library.js, local_workspace.js, my_tasks.js, database_table.js)
  chargent /pages/{id}?embed=1 : le panneau ne montre plus que le document en
  mode edition, sans sidebar ni barre (regression v7.49.0). Le cadre du
  panneau (close/full/resize partages fdWirePeekResize) est inchange.
- Calendrier My Tasks : le handler testait e.target.dataset.openRow mais les
  boutons .mt-cal-open ne contiennent que leur <svg> -> clic sur l'icone =
  e.target = <svg>, rien ne partait. cause racine corrigee par
  t.closest('[data-open-row]') : un seul chemin tableau/calendrier/Kanban.
- Kanban My Tasks : meme bouton Open ajoute sur les tuiles (openButton(t),
  overlay absolu revele au survol de la carte .mt-card).
- Menu contextuel global « Open in side peek » : etait un toast d'erreur ;
  route maintenant vers le peek de la page courante (_libData.openPeekById /
  _wsData.openSidePeek), repli nouvel onglet ailleurs.
- /pages/{id}?embed=1 sur une page de base (content_format=collection)
  rendait un editeur de blocs vide : le template collection est garde en
  embed (body.embed-mode retire sidebar+barre, le tableau reste). Bouton
  pleine page des peeks de bases : navigue via dataset.page (URL propre)
  au lieu du src ?embed=1.
- Tests : garde de coherence inversee (?embed=1 exige sur les 4 fichiers),
  +3 nouveaux (closest, tuiles Kanban, collection embed). Fix d'un test
  pre-existant casse (assertion panel.style.display, reecriture vanilla JS
  v7.49.0). Suite 1264 passed / 0 failed (-n auto) + ruff 0.
- Bump VERSION/main.py 7.50.0, CHANGELOG, WORKLOAD, OpenAPI regenere.
2026-10-06 12:48:48 -04:00

977 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════════════════════════════════════════════
* FlowDeck — My Tasks (tableau de bord façon Notion)
*
* Trois vues sur les mêmes tâches normalisées par `/my-tasks/api` :
* • tableau — liste dense, statut / échéance éditables au clic ;
* • kanban — colonnes = options de statut de la base d'origine, glisser
* d'une colonne à l'autre écrit le statut ;
* • calendrier — mois courant, tâches placées sur leur date d'échéance.
*
* Tout passe par le mapping enregistré côté serveur
* (`collections.task_*_prop`) : le front envoie un champ (`status`, `due`,
* `title`), jamais un identifiant de colonne.
* ══════════════════════════════════════════════════════════════════════ */
/* Le tableau de bord est monté par `flowDeckMyTasks()` pour **chaque** noeud
* `#my-tasks-app` : la navigation interne de `app.js` (htmx) remplace
* `.main-wrapper` sans recharger le document — le <script> est bien rejoué,
* mais son garde global faisait sortir le IIFE et la nouvelle coquille restait
* vide jusqu'au hard refresh. Le drapeau est désormais porté par l'élément
* (`__fdMounted`), pas par `window`.
*
* Filet supplémentaire : une coquille montée mais **restée vide** est le
* symptôme d'un swap interrompu. Plutôt que d'afficher du vide, on remonte
* (les delegations d'événements, elles, ne sont posées qu'une fois par
* noeud grâce à `__fdWired`). */
function flowDeckMyTasks() {
'use strict';
var app = document.getElementById('my-tasks-app');
if (!app) return;
if (app.__fdMounted && app.innerHTML.trim()) return;
app.__fdMounted = true;
var wire = !app.__fdWired; // écoutes posées une seule fois par noeud
app.__fdWired = true;
var cfg = {};
try { cfg = JSON.parse(app.getAttribute('data-config') || '{}'); } catch (e) { cfg = {}; }
var state = {
view: cfg.view || 'table',
due: (cfg.filters && cfg.filters.due) || 'all',
sort: (cfg.filters && cfg.filters.sort) || 'due',
hide_done: !!(cfg.filters && cfg.filters.hide_done),
sources: cfg.sources || [],
maxSources: cfg.maxSources || 10,
tasks: [],
month: null,
calView: 'month',
calDay: null
};
var MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet',
'août', 'septembre', 'octobre', 'novembre', 'décembre'];
var DAYS = ['lu', 'ma', 'me', 'je', 've', 'sa', 'di'];
function csrf() {
return (typeof window.getCsrf === 'function') ? window.getCsrf() : '';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// `collection_pages.icon` vaut la chaine sentinelle "file" pour une ligne
// sans icône (héritage de l'import, cf. `database_table.js`). L'afficher
// telle quel collait le mot "file" devant chaque titre.
function rowIcon(icon) {
return (!icon || icon === 'file') ? '📄' : icon;
}
// Un identifiant de ligne de base n'est PAS un identifiant de page :
// /db/pages/{id}/open/api renvoie la page de contenu (créée à la volée)
// qui porte les blocs de la tâche. On ne navigue JAMAIS vers
// /pages/{rowId} : cet id désigne une page sans rapport (cf.
// database_table.js) — d'où le panneau latéral, comme dans Library.
var PEEK_SVG_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" ' +
'x2="18" y2="18"/></svg>';
var PEEK_SVG_FULL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/>' +
'<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
// Le panneau vit HORS de `#my-tasks-app` : `render()` remplace le contenu
// de l'app à chaque filtre, le panneau doit survivre.
function ensurePeek() {
var p = document.getElementById('mt-peek');
if (p) return p;
p = document.createElement('div');
p.className = 'mt-peek';
p.id = 'mt-peek';
p.setAttribute('aria-hidden', 'true');
p.innerHTML = '<div class="mt-peek-handle" id="mt-peek-handle"></div>' +
'<div class="mt-peek-head">' +
'<button class="mt-peek-btn" id="mt-peek-close" title="Close" ' +
'aria-label="Fermer">' + PEEK_SVG_CLOSE + '</button>' +
'<div class="mt-peek-title" id="mt-peek-title"></div>' +
'<button class="mt-peek-btn" id="mt-peek-full" title="Open full page" ' +
'aria-label="Ouvrir dans une page entière">' + PEEK_SVG_FULL + '</button>' +
'</div>' +
'<div class="mt-peek-body" id="mt-peek-body">' +
'<div class="mt-peek-loading" id="mt-peek-loading">Chargement…</div>' +
'<iframe id="mt-peek-iframe" title="Contenu de la tâche" ' +
'style="display:none"></iframe></div>';
document.body.appendChild(p);
document.getElementById('mt-peek-close').addEventListener('click', closePeek);
document.getElementById('mt-peek-full').addEventListener('click', function () {
var f = document.getElementById('mt-peek-iframe');
if (f && f.dataset.page) window.location.href = '/pages/' + f.dataset.page;
});
wirePeekResize(p);
return p;
}
function closePeek() {
var p = document.getElementById('mt-peek');
if (!p) return;
p.classList.remove('open');
p.setAttribute('aria-hidden', 'true');
var f = document.getElementById('mt-peek-iframe');
// On décharge l'iframe : elle garde sinon en mémoire tout l'éditeur
// d'un document que l'on vient de quitter.
if (f) { f.src = ''; delete f.dataset.page; f.style.display = 'none'; }
var l = document.getElementById('mt-peek-loading');
if (l) l.style.display = '';
}
function wirePeekResize(panel) {
// Helper partagé (app.js) : pointer capture, borne 300–90vw, clic =
// fermer, largeur persistée (localStorage fd_peek_width) — comme Library.
window.fdWirePeekResize(panel, document.getElementById('mt-peek-handle'), closePeek);
}
function openRowContent(rowId, title) {
var panel = ensurePeek();
document.getElementById('mt-peek-title').textContent = title || '';
var loading = document.getElementById('mt-peek-loading');
var frame = document.getElementById('mt-peek-iframe');
if (loading) loading.style.display = '';
if (frame) { frame.style.display = 'none'; frame.src = ''; delete frame.dataset.page; }
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
api('/db/pages/' + rowId + '/open/api').then(function (d) {
var pid = d && d.page_id;
if (!pid) throw new Error('page de contenu introuvable');
var f = document.getElementById('mt-peek-iframe');
if (!f || !document.getElementById('mt-peek').classList.contains('open')) return;
if (loading) loading.style.display = 'none';
f.dataset.page = pid;
f.style.display = '';
// `about:blank` d'abord : ouvrir une autre tâche doit REcharger
// l'éditeur, sinon l'URL identique laisse le document précédent
// affiché. `?embed=1` = le document en édition seule, sans
// sidebar ni barre de navigation (le cadre du panneau suffit).
f.src = 'about:blank';
f.src = '/pages/' + pid + '?embed=1';
}).catch(function (e) {
if (loading) {
loading.textContent = 'Impossible d’ouvrir le contenu : ' + e.message;
loading.style.display = '';
}
});
}
function syncUrl() {
// `replaceState` peut être refusé (iframe sandbox, `file://`,
// navigation cross-origin) : l'URL n'est qu'un confort, jamais
// l'interface — on ne laisse jamais une exception casser l'action.
try {
history.replaceState(null, '', '/my-tasks?' + qs());
} catch (e) { /* ignoré */ }
}
function qs() {
var p = new URLSearchParams();
p.set('view', state.view);
p.set('due', state.due);
p.set('sort', state.sort);
if (state.hide_done) p.set('hide_done', 'true');
return p.toString();
}
function api(path, opts) {
opts = opts || {};
var headers = { 'X-CSRF-Token': csrf() };
if (opts.body) headers['Content-Type'] = 'application/json';
return fetch(path, {
method: opts.method || 'GET',
credentials: 'same-origin',
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (d) {
if (!r.ok) throw new Error(d.detail || ('Erreur ' + r.status));
return d;
});
});
}
/* ── Données ───────────────────────────────────────────────────── */
function load() {
return api('/my-tasks/api?' + qs()).then(function (d) {
state.tasks = d.tasks || [];
state.sources = d.sources || [];
render();
}).catch(function (e) {
app.innerHTML = '<div class="my-tasks-error">' + esc(e.message) + '</div>';
});
}
function configuredSources() {
return state.sources.filter(function (s) { return s.configured; });
}
/* ── Barre d'outils ────────────────────────────────────────────── */
function toolbar() {
var h = '<div class="mt-toolbar">';
h += '<div class="mt-toolbar-left">';
h += viewTabs();
h += '</div><div class="mt-toolbar-right">';
h += select('mt-due-filter', 'due', [
['all', 'Toutes les échéances'],
['today', 'Échéance aujourd\'hui'],
['overdue', 'En retard + aujourd\'hui'],
['week', 'Cette semaine']
]);
h += select('mt-sort', 'sort', [
['due', 'Tri : échéance'],
['status', 'Tri : statut'],
['source', 'Tri : source'],
['created', 'Tri : création']
]);
h += '<label class="mt-toggle"><input type="checkbox" id="mt-hide-done"' +
(state.hide_done ? ' checked' : '') + '> Masquer terminées</label>';
h += '<button class="mt-new-btn" id="mt-new-btn">+ Nouvelle tâche</button>';
h += '</div></div>';
h += sourcesBar();
return h;
}
function viewTabs() {
var labels = { table: 'Tableau', board: 'Kanban', calendar: 'Calendrier' };
var h = '<div class="mt-view-tabs">';
Object.keys(labels).forEach(function (k) {
// `data-no-nav` : le changement de vue est traité ici, en JS.
// Sans lui, l'intercepteur de liens de `app.js` rejoue un
// chargement complet de la page et remplace le DOM rendu.
// Le `href` reste présent : le lien reste partageable et
// ouvrable dans un onglet.
h += '<a class="mt-view-tab' + (state.view === k ? ' active' : '') +
'" data-no-nav href="/my-tasks?' + withView(k) + '">' +
labels[k] + '</a>';
});
return h + '</div>';
}
function withView(v) {
var p = new URLSearchParams();
p.set('view', v);
p.set('due', state.due);
p.set('sort', state.sort);
if (state.hide_done) p.set('hide_done', 'true');
return p.toString();
}
function select(cls, key, opts) {
var s = '<select class="mt-select ' + cls + '" data-key="' + key + '">';
opts.forEach(function (o) {
s += '<option value="' + esc(o[0]) + '"' +
(state[key] === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>';
});
return s + '</select>';
}
function sourcesBar() {
var n = state.sources.length;
var ok = configuredSources().length;
var pend = state.sources.filter(function (s) { return !s.configured; });
var h = '<div class="mt-sources">';
h += '<span class="mt-sources-label">Sources : <b>' + ok + '/' + state.maxSources +
'</b> base(s) connectée(s)</span>';
state.sources.forEach(function (s) {
h += '<a class="mt-source-chip' + (s.configured ? '' : ' pending') +
'" href="/db/' + s.id + '" title="' + esc(s.workspace_name) + '">' +
esc(s.icon || '📋') + ' ' + esc(s.name) + '</a>';
// Une base connectée au mapping incomplet se corrige d'ici.
if (!s.configured) {
h += '<button class="mt-source-fix" data-fix-source="' + s.id +
'" title="Relier les colonnes manquantes : ' +
esc((s.missing_roles || []).join(', ')) + '">Configurer</button>';
}
});
if (pend.length) {
h += '<span class="mt-sources-warn" title="Ces bases n’émettent pas de tâche ' +
'tant que les 3 colonnes ne sont pas liées">⚠ ' + pend.length +
' base(s) à configurer</span>';
}
if (n >= state.maxSources) {
h += '<span class="mt-sources-warn">Limite de ' + state.maxSources +
' bases atteinte</span>';
}
return h + '</div>';
}
/* ── Vue tableau ──────────────────────────────────────────────────────
* Lignes en `div`, comme Library : c'est ce qui permet de n'afficher le
* bouton « Open » qu'au survol de la ligne (`.mt-hover-only`).
*/
// Bouton « Open » : SVG et libellé identiques à `.btn-open` de Library,
// pour que les deux listes se ressemblent exactement.
function openButton(t) {
return '<button class="mt-open-btn mt-hover-only" data-open-row="' + t.id +
'" data-open-title="' + esc(t.title) + '" ' +
'title="Open in side peek" aria-label="Ouvrir dans le panneau latéral">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<rect x="3" y="3" width="18" height="18" rx="2"/>' +
'<line x1="15" y1="3" x2="15" y2="21"/></svg> Open</button>';
}
function viewTable() {
if (!state.tasks.length) return emptyState();
var h = '<div class="mt-table">' +
'<div class="mt-thead">' +
'<div class="mt-th mt-cell-name">Tâche</div>' +
'<div class="mt-th mt-cell-status">Statut</div>' +
'<div class="mt-th mt-cell-due">Échéance</div>' +
'<div class="mt-th mt-cell-src">Source</div>' +
'</div>';
state.tasks.forEach(function (t) {
h += '<div class="mt-row" data-id="' + t.id + '">';
h += '<div class="mt-cell mt-cell-name">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-title-text" data-edit="title" title="Cliquer pour renommer">' +
esc(t.title) + '</span>' + openButton(t) + '</div>';
h += '<div class="mt-cell mt-cell-status">' + statusCell(t) + '</div>';
h += '<div class="mt-cell mt-cell-due">' + dueCell(t) + '</div>';
h += '<div class="mt-cell mt-cell-src"><a class="mt-chip" href="/db/' +
t.collection_id + '" title="' + esc(t.workspace_name) +
' · ouvrir la base">' + esc(t.collection_icon) + ' ' +
esc(t.collection_name) + '</a></div>';
h += '</div>';
});
return h + '</div>';
}
function statusCell(t) {
if (!t.status_options || !t.status_options.length) {
return '<span class="mt-static">' + esc(t.status || '—') + '</span>';
}
return '<select class="mt-status-select" data-id="' + t.id +
'" data-field="status">' + t.status_options.map(function (o) {
return '<option value="' + esc(o) + '"' +
(o === t.status ? ' selected' : '') + '>' + esc(o) + '</option>';
}).join('') + '</select>';
}
function dueCell(t) {
var cls = t.due_state ? ' mt-due-' + t.due_state : '';
return '<input type="date" class="mt-due-input' + cls + '" data-id="' + t.id +
'" data-field="due" value="' + esc(t.due || '') + '">';
}
/* ── Vue kanban ────────────────────────────────────────────────── */
function viewBoard() {
var cols = [], seen = {};
state.tasks.forEach(function (t) {
(t.status_options || []).forEach(function (o) {
if (!seen[o]) { seen[o] = 1; cols.push(o); }
});
if (!t.status || seen[t.status] === undefined) {
if (t.status && !seen[t.status]) { seen[t.status] = 1; cols.push(t.status); }
}
});
if (!cols.length) cols = ['À faire'];
var h = '<div class="mt-board">';
cols.forEach(function (col) {
var items = state.tasks.filter(function (t) { return (t.status || '') === col; });
h += '<div class="mt-col" data-status="' + esc(col) + '">';
h += '<div class="mt-col-head">' + esc(col) + ' <span class="mt-count">' +
items.length + '</span></div>';
h += '<div class="mt-col-body">';
items.forEach(function (t) {
h += '<div class="mt-card" draggable="true" data-id="' + t.id + '">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-card-title">' + esc(t.title) + '</span>' +
openButton(t) +
(t.due ? '<span class="mt-card-due mt-due-' + esc(t.due_state) + '">📅 ' +
esc(t.due) + '</span>' : '') +
'<span class="mt-card-src">' + esc(t.collection_icon) + ' ' +
esc(t.collection_name) + '</span></div>';
});
h += '</div></div>';
});
return h + '</div>';
}
/* ── Vue calendrier (5 vues) ─────────────────────────────────────── */
var CAL_VIEWS = [
['month', 'Mois'], ['year', 'Année'], ['week', 'Semaine'],
['day', 'Jour'], ['planning', 'Planning']
];
var OPEN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/>' +
'<line x1="15" y1="3" x2="15" y2="21"/></svg>';
function calEnsure() {
if (!state.month) {
var n = new Date();
state.month = { y: n.getFullYear(), m: n.getMonth() };
}
if (!state.calDay) {
var t = new Date();
state.calDay = t.getFullYear() + '-' +
('0' + (t.getMonth() + 1)).slice(-2) + '-' +
('0' + t.getDate()).slice(-2);
}
}
function isoDate(d) {
return d.getFullYear() + '-' +
('0' + (d.getMonth() + 1)).slice(-2) + '-' +
('0' + d.getDate()).slice(-2);
}
function calDate(iso) {
var p = String(iso || '').split('-');
return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
}
// Tâches groupées par jour d'échéance (celles sans échéance sont ignorées).
function tasksByDay() {
var byDay = {};
state.tasks.forEach(function (t) {
if (!t.due) return;
(byDay[t.due] = byDay[t.due] || []).push(t);
});
return byDay;
}
// Décalage du calendrier, la granularité dépendant de la vue courante.
function calShift(delta) {
calEnsure();
var v = state.calView;
if (v === 'year') {
state.month = { y: state.month.y + delta, m: state.month.m };
} else if (v === 'week') {
var w = calDate(state.calDay);
w.setDate(w.getDate() + delta * 7);
state.calDay = isoDate(w);
} else if (v === 'day') {
var d = calDate(state.calDay);
d.setDate(d.getDate() + delta);
state.calDay = isoDate(d);
state.month = { y: d.getFullYear(), m: d.getMonth() };
} else {
var m = state.month.m + delta;
state.month = {
m: ((m % 12) + 12) % 12,
y: state.month.y + Math.floor(m / 12)
};
}
render();
}
function calTitle() {
calEnsure();
var y = state.month.y, m = state.month.m;
if (state.calView === 'year') return String(y);
if (state.calView === 'planning') return 'Planning — ' + MONTHS[m] + ' ' + y;
if (state.calView === 'day') {
var d = calDate(state.calDay);
return DAYS[(d.getDay() + 6) % 7] + ' ' + d.getDate() + ' ' +
MONTHS[d.getMonth()] + ' ' + d.getFullYear();
}
if (state.calView === 'week') {
var s = calDate(state.calDay);
var lead = (s.getDay() + 6) % 7; // lundi = 0
s.setDate(s.getDate() - lead);
var e = new Date(s.getFullYear(), s.getMonth(), s.getDate() + 6);
return s.getDate() + ' – ' + e.getDate() + ' ' + MONTHS[e.getMonth()] +
' ' + e.getFullYear();
}
return MONTHS[m] + ' ' + y;
}
function calTabs() {
var h = '<div class="mt-cal-tabs">';
CAL_VIEWS.forEach(function (v) {
h += '<button class="mt-cal-tab' + (state.calView === v[0] ? ' active' : '') +
'" data-cal-view="' + v[0] + '">' + v[1] + '</button>';
});
return h + '</div>';
}
// Événement de calendrier : titre + bouton d'ouverture au survol.
function calEvHtml(t, draggable) {
return '<div class="mt-cal-ev' + (draggable ? ' drag' : '') + '"' +
(draggable ? ' draggable="true" data-id="' + t.id + '"' : '') +
' title="' + esc(t.collection_name) + ' · ' + esc(t.status || '') +
' · ' + esc(t.due || '') + '">' +
'<span class="mt-cal-ev-text">' + esc(t.title) + '</span>' +
'<button class="mt-cal-open mt-hover-only" data-open-row="' + t.id +
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + '</button>' +
'</div>';
}
function viewCalendar() {
calEnsure();
var h = '<div class="mt-cal">' +
'<div class="mt-cal-head">' +
'<button class="mt-cal-nav" data-nav="-1" aria-label="Précédent">‹</button>' +
'<span class="mt-cal-title">' + esc(calTitle()) + '</span>' +
'<button class="mt-cal-nav" data-nav="1" aria-label="Suivant">›</button>' +
'</div>' + calTabs() + '<div class="mt-cal-body">';
if (state.calView === 'year') h += calYear();
else if (state.calView === 'week') h += calWeek();
else if (state.calView === 'day') h += calDay();
else if (state.calView === 'planning') h += calPlanning();
else h += calMonth();
return h + '</div></div>';
}
/* Mois : grille 7 × 6, glisser-déposer pour changer l'échéance. */
function calMonth() {
calEnsure();
var y = state.month.y, m = state.month.m;
var byDay = tasksByDay();
var first = new Date(y, m, 1);
var lead = (first.getDay() + 6) % 7; // lundi = 0
var start = new Date(y, m, 1 - lead);
var todayIso = isoDate(new Date());
var h = '<div class="mt-cal-grid">';
DAYS.forEach(function (d) { h += '<div class="mt-cal-dow">' + d + '</div>'; });
for (var i = 0; i < 42; i++) {
var d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
var key = isoDate(d);
var evs = byDay[key] || [];
h += '<div class="mt-cal-cell' + (d.getMonth() !== m ? ' out' : '') +
(key === todayIso ? ' today' : '') + '" data-day="' + key + '">';
h += '<div class="mt-cal-day">' + d.getDate() + '</div>';
evs.forEach(function (t) { h += calEvHtml(t, true); });
h += '</div>';
}
return h + '</div>';
}
/* Année : 12 mini-mois ; un point signale un jour chargé. */
function calYear() {
calEnsure();
var byDay = tasksByDay();
var todayIso = isoDate(new Date());
var h = '<div class="mt-cal-year">';
for (var mo = 0; mo < 12; mo++) {
var y = state.month.y;
var first = new Date(y, mo, 1);
var lead = (first.getDay() + 6) % 7;
var start = new Date(y, mo, 1 - lead);
var dim = new Date(y, mo + 1, 0).getDate();
h += '<div class="mt-cal-mini">';
h += '<div class="mt-cal-mini-title">' + MONTHS[mo] + '</div>';
h += '<div class="mt-cal-mini-grid">';
DAYS.forEach(function (d) {
h += '<span class="mt-cal-mini-dow">' + d.charAt(0) + '</span>';
});
for (var i = 0; i < lead; i++) h += '<span class="mt-cal-mini-day out"></span>';
for (var dd = 1; dd <= dim; dd++) {
var key = y + '-' + ('0' + (mo + 1)).slice(-2) + '-' +
('0' + dd).slice(-2);
var n = (byDay[key] || []).length;
h += '<button class="mt-cal-mini-day' + (key === todayIso ? ' today' : '') +
(n ? ' busy' : '') + '" data-cal-day="' + key + '" ' +
'title="' + dd + ' ' + MONTHS[mo] + (n ? ' — ' + n + ' tâche(s)' : '') + '">' +
dd + '</button>';
}
h += '</div></div>';
}
return h + '</div>';
}
/* Semaine : 7 colonnes, une par jour. */
function calWeek() {
calEnsure();
var byDay = tasksByDay();
var s = calDate(state.calDay);
var lead = (s.getDay() + 6) % 7; // lundi = 0
s.setDate(s.getDate() - lead);
var todayIso = isoDate(new Date());
var h = '<div class="mt-cal-week">';
for (var i = 0; i < 7; i++) {
var d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i);
var key = isoDate(d);
var evs = byDay[key] || [];
h += '<div class="mt-cal-wcol' + (key === todayIso ? ' today' : '') + '">';
h += '<div class="mt-cal-wcol-head">' + DAYS[i] + ' ' + d.getDate() + '</div>';
h += '<div class="mt-cal-wcol-body">';
if (!evs.length) h += '<div class="mt-cal-wempty">—</div>';
evs.forEach(function (t) { h += calEvHtml(t, false); });
h += '</div></div>';
}
return h + '</div>';
}
/* Jour : la liste complète des tâches du jour sélectionné. */
function calDay() {
calEnsure();
var byDay = tasksByDay();
var key = state.calDay;
var evs = byDay[key] || [];
var h = '<div class="mt-cal-dayview">';
h += '<div class="mt-cal-dayview-head">' + esc(calTitle()) +
' · <b>' + evs.length + '</b> tâche(s)</div>';
if (!evs.length) {
h += '<div class="mt-cal-dayview-empty">Aucune tâche pour ce jour.</div>';
return h + '</div>';
}
h += '<div class="mt-cal-dayview-list">';
evs.forEach(function (t) {
h += '<div class="mt-cal-task" data-id="' + t.id + '">' +
'<span class="mt-icon">' + esc(rowIcon(t.icon)) + '</span>' +
'<span class="mt-cal-task-title">' + esc(t.title) + '</span>' +
'<span class="mt-cal-task-src">' + esc(t.collection_icon) + ' ' +
esc(t.collection_name) + '</span>' +
'<span class="mt-cal-task-status">' + esc(t.status || '—') + '</span>' +
'<button class="mt-open-btn" data-open-row="' + t.id +
'" data-open-title="' + esc(t.title) + '" title="Open in side peek" ' +
'aria-label="Ouvrir dans le panneau latéral">' + OPEN_SVG + ' Open</button>' +
'</div>';
});
return h + '</div></div>';
}
/* Planning : colonnes = statuts, lignes = jours du mois (tâches placées
à leur date d'échéance). C'est la vue « planning » de Notion. */
function calPlanning() {
calEnsure();
var byDay = tasksByDay();
var y = state.month.y, m = state.month.m;
var statuses = [], seen = {};
state.tasks.forEach(function (t) {
if (!t.due) return;
var s = t.status || 'Sans statut';
if (!seen[s]) { seen[s] = 1; statuses.push(s); }
});
if (!statuses.length) statuses = ['Sans statut'];
var dim = new Date(y, m + 1, 0).getDate();
var todayIso = isoDate(new Date());
var h = '<div class="mt-cal-plan">';
h += '<div class="mt-cal-plan-row head"><div class="mt-cal-plan-gutter"></div>';
statuses.forEach(function (s) {
h += '<div class="mt-cal-plan-col-title">' + esc(s) + '</div>';
});
h += '</div>';
for (var d = 1; d <= dim; d++) {
var key = y + '-' + ('0' + (m + 1)).slice(-2) + '-' + ('0' + d).slice(-2);
var evs = byDay[key] || [];
h += '<div class="mt-cal-plan-row' + (key === todayIso ? ' today' : '') + '">' +
'<div class="mt-cal-plan-gutter">' + d + '</div>';
statuses.forEach(function (s) {
h += '<div class="mt-cal-plan-cell">';
evs.filter(function (t) {
return (t.status || 'Sans statut') === s;
}).forEach(function (t) { h += calEvHtml(t, false); });
h += '</div>';
});
h += '</div>';
}
return h + '</div>';
}
/* ── États vides ───────────────────────────────────────────────── */
function emptyState() {
var title, msg, hint, act;
var connected = configuredSources().length;
if (!state.sources.length) {
title = 'Aucune base connectée';
msg = "Aucune base de tâches n'alimente ce tableau de bord.";
hint = 'Ouvrez une base, puis cliquez sur « Convertir en base de tâches » ' +
'dans sa barre (à gauche du bouton New) et reliez les colonnes ' +
'Assigné à, Statut et Échéance.';
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
} else if (!connected) {
title = 'Mapping incomplet';
msg = 'Bases connectées mais aucune configurée.';
hint = 'Il manque le mapping Assigné à / Statut / Échéance. Ouvrez la ' +
'base et cliquez sur « Configurer » dans sa barre.';
act = '<a class="mt-empty-btn" href="/db">Ouvrir mes bases</a>';
} else if (state.due !== 'all' || state.hide_done || state.sort !== 'due') {
title = 'Aucun résultat';
msg = 'Aucune tâche ne correspond aux filtres actifs.';
hint = '';
act = '<a class="mt-empty-btn" href="/my-tasks">Réinitialiser les filtres</a>';
} else {
title = 'Rien à faire';
msg = "Aucune tâche ne vous est assignée pour le moment.";
hint = 'Ajoutez-en une, ou branchesz une autre base pour voir plus large.';
act = '<button class="mt-empty-btn" id="mt-new-btn">Créer une tâche</button>';
}
// Même gabarit centré que l'état vide rendu par le serveur
// (`_render_empty`), pour que les deux se ressemblent.
return '<div class="mt-empty"><div class="mt-empty-icon">📋</div>' +
'<h2>' + esc(title) + '</h2><p>' + esc(msg) + '</p>' +
(hint ? '<p class="mt-empty-hint">' + esc(hint) + '</p>' : '') +
act + '</div>';
}
/* ── Modale « nouvelle tâche » (multi-destinations) ────────────── */
// Ouvre la modale de conversion pour une base donnée (les bases
// connectées mais mal configurées ne peuvent pas recevoir de tâche).
function openConvertModal(collectionId) {
if (collectionId && window.FlowDeckTaskDb) {
window.FlowDeckTaskDb.open(Number(collectionId));
}
}
function openNewModal() {
var srcs = configuredSources();
if (!srcs.length) {
alert("Aucune base de tâches connectée.\n\nConvertissez d'abord une base : " +
"ouvrez-la, puis cliquez sur « Convertir en base de tâches » " +
"dans la barre de la base, à gauche du bouton New.");
return;
}
var groups = {};
srcs.forEach(function (s) {
(groups[s.workspace_name || 'Autres'] = groups[s.workspace_name || 'Autres'] || [])
.push(s);
});
var h = '<div class="mt-modal-overlay" id="mt-modal-overlay"><div class="mt-modal">';
h += '<div class="mt-modal-head"><span>Nouvelle tâche</span>' +
'<button class="mt-modal-close" id="mt-modal-close">✕</button></div>';
h += '<div class="mt-modal-body">';
h += '<label class="mt-field">Titre<input id="mt-new-title" type="text" ' +
'placeholder="Ex. Envoyer la maquette"></label>';
h += '<label class="mt-field">Échéance<input id="mt-new-due" type="date"></label>';
Object.keys(groups).forEach(function (ws) {
h += '<div class="mt-modal-group">' + esc(ws) + '</div>';
groups[ws].forEach(function (s) {
h += '<label class="mt-dest"><input type="radio" name="mt-dest" value="' +
s.id + '"><span class="mt-icon">' + esc(s.icon || '📋') + '</span>' +
esc(s.name) + '</label>';
});
});
h += '</div><div class="mt-modal-foot">' +
'<button class="mt-btn-cancel" id="mt-new-cancel">Annuler</button>' +
'<button class="mt-btn-primary" id="mt-new-create">Créer</button></div>';
h += '</div></div>';
document.body.insertAdjacentHTML('beforeend', h);
var first = document.querySelector('input[name="mt-dest"]');
if (first) first.checked = true;
document.getElementById('mt-new-title').focus();
var close = function () {
var ov = document.getElementById('mt-modal-overlay');
if (ov) ov.remove();
};
// `Escape` ferme aussi la modale.
document.addEventListener('keydown', function onEsc(e) {
if (e.key !== 'Escape') return;
document.removeEventListener('keydown', onEsc);
close();
});
document.getElementById('mt-modal-close').onclick = close;
document.getElementById('mt-new-cancel').onclick = close;
document.getElementById('mt-modal-overlay').onclick = function (e) {
if (e.target === this) close();
};
document.getElementById('mt-new-create').onclick = function () {
var title = document.getElementById('mt-new-title').value.trim();
var due = document.getElementById('mt-new-due').value;
var dest = document.querySelector('input[name="mt-dest"]:checked');
if (!title) { alert('Donnez un titre à la tâche.'); return; }
if (!dest) { alert('Choisissez une base de destination.'); return; }
api('/my-tasks/api/task', {
method: 'POST',
body: { collection_id: Number(dest.value), title: title, due: due || null }
}).then(function () { close(); load(); })
.catch(function (e) { close(); alert(e.message); });
};
}
/* ── Écriture en direct ────────────────────────────────────────── */
function patch(id, field, value) {
return api('/my-tasks/api/task/' + id, { method: 'PUT', body: { field: field, value: value } })
.then(function (d) {
if (d.left_mytasks) {
state.tasks = state.tasks.filter(function (t) { return t.id !== id; });
render();
} else if (d.task) {
state.tasks = state.tasks.map(function (t) {
return t.id === d.task.id ? d.task : t;
});
render();
}
})
.catch(function (e) { alert(e.message); load(); });
}
function startTitleEdit(span) {
if (span.querySelector('input')) return;
var tr = span.closest('.mt-row');
var id = Number(tr.dataset.id);
var old = span.textContent;
var input = document.createElement('input');
input.type = 'text';
input.className = 'mt-title-input';
input.value = old;
span.textContent = '';
span.appendChild(input);
input.focus();
input.select();
var done = false;
var commit = function () {
if (done) return;
done = true;
var v = input.value.trim();
if (v && v !== old) patch(id, 'title', v); else render();
};
input.onblur = commit;
input.onkeydown = function (e) {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { done = true; render(); }
};
}
/* ── Rendu + délégation d'événements ───────────────────────────── */
function render() {
var body = state.view === 'board' ? viewBoard()
: state.view === 'calendar' ? viewCalendar()
: viewTable();
app.innerHTML = '<div class="mt-head"><h1>📋 My Tasks</h1>' +
'<span class="mt-count-total">' + state.tasks.length + ' tâche(s)</span></div>' +
toolbar() + body;
wireDnD();
}
function wireDnD() {
var cards = app.querySelectorAll('[draggable="true"]');
cards.forEach(function (c) {
c.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', c.dataset.id);
e.dataTransfer.effectAllowed = 'move';
});
});
app.querySelectorAll('.mt-col').forEach(function (col) {
col.addEventListener('dragover', function (e) { e.preventDefault(); });
col.addEventListener('drop', function (e) {
e.preventDefault();
var id = Number(e.dataTransfer.getData('text/plain'));
if (id && col.dataset.status) patch(id, 'status', col.dataset.status);
});
});
// Calendrier : glisser une tâche sur un jour change son échéance.
app.querySelectorAll('.mt-cal-cell').forEach(function (cell) {
cell.addEventListener('dragover', function (e) { e.preventDefault(); });
cell.addEventListener('drop', function (e) {
e.preventDefault();
var id = Number(e.dataTransfer.getData('text/plain'));
if (id && cell.dataset.day) patch(id, 'due', cell.dataset.day);
});
});
}
if (wire) app.addEventListener('click', function (e) {
var t = e.target;
if (t.id === 'mt-new-btn') { openNewModal(); return; }
var opener = t.closest ? t.closest('[data-open-row]') : null;
if (opener) {
// `closest` : dans le calendrier le bouton ne contient que son
// SVG — un clic sur l'icone donnait `e.target` = <svg> sans
// dataset, et le bouton ne faisait rien.
e.preventDefault();
// Le titre vient de l'attribut : il est disponible aussi bien dans
// le tableau que dans le calendrier ou la vue Jour.
var label = opener.dataset.openTitle;
if (!label) {
var row = opener.closest('.mt-row');
var cell = row && row.querySelector('.mt-title-text');
label = cell ? cell.textContent : '';
}
openRowContent(Number(opener.dataset.openRow), label);
return;
}
if (t.dataset && t.dataset.fixSource) {
e.preventDefault();
openConvertModal(t.dataset.fixSource);
return;
}
if (t.classList.contains('mt-cal-nav')) {
calShift(Number(t.dataset.nav));
return;
}
if (t.dataset && t.dataset.calView) {
state.calView = t.dataset.calView;
render();
return;
}
if (t.dataset && t.dataset.calDay) {
// Un jour de la vue Année ouvre la vue Jour dessus.
state.calDay = t.dataset.calDay;
var cd = calDate(state.calDay);
state.month = { y: cd.getFullYear(), m: cd.getMonth() };
state.calView = 'day';
render();
return;
}
if (t.dataset && t.dataset.edit === 'title') { startTitleEdit(t); return; }
var link = t.closest ? t.closest('a.mt-view-tab') : null;
if (link) {
e.preventDefault();
var p = new URLSearchParams(link.getAttribute('href').split('?')[1] || '');
state.view = p.get('view') || 'table';
syncUrl();
render();
}
});
if (wire) app.addEventListener('change', function (e) {
var t = e.target;
if (t.classList.contains('mt-select')) {
state[t.dataset.key] = t.value;
syncUrl();
load();
} else if (t.id === 'mt-hide-done') {
state.hide_done = t.checked;
syncUrl();
load();
} else if (t.classList.contains('mt-status-select')) {
patch(Number(t.dataset.id), 'status', t.value);
} else if (t.classList.contains('mt-due-input')) {
patch(Number(t.dataset.id), 'due', t.value);
}
});
load();
/* Une modale ou un panneau restés ouverts pendant une navigation
* masqueraient la page suivante : on referme au remontage. */
if (wire) {
var staleModal = document.getElementById('mt-modal-overlay');
if (staleModal) staleModal.remove();
closePeek();
document.addEventListener('keydown', function (e) {
// `Escape` ferme le panneau latéral, comme dans Library.
if (e.key === 'Escape') closePeek();
});
}
}
// htmx ré-exécute les <script> de la zone swappée : on remonte à chaque
// navigation interne. `popstate` couvre le bouton « retour », où le <script>
// n'est pas forcément rejoué.
document.addEventListener('htmx:afterSwap', flowDeckMyTasks);
window.addEventListener('popstate', flowDeckMyTasks);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', flowDeckMyTasks);
} else {
flowDeckMyTasks();
}