From d1d126d2d3d3e5f98fa2254094dff365a2e7f516 Mon Sep 17 00:00:00 2001 From: Jarkko Lietolahti Date: Wed, 1 Jul 2026 13:21:15 +0300 Subject: [PATCH 1/4] feat: Add auto-reload for kanban.md changes and visual highlights --- package-lock.json | 4 +- src/index.html | 4 + src/scripts/01-state.js | 1 + src/scripts/02-i18n.js | 9 + src/scripts/03-storage.js | 39 + src/scripts/04-io.js | 21 + src/scripts/06-filters.js | 5 + src/scripts/08-board.js | 7 + src/styles/02-board.css | 31 + task-manager.html | 4458 ++++++++++++++++++++++++++++++++++++- 10 files changed, 4576 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index eaf1daba..e79a17eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markdown-task-manager", - "version": "1.3.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-task-manager", - "version": "1.3.1", + "version": "2.0.0", "devDependencies": { "html-minifier-terser": "^7.2.0" } diff --git a/src/index.html b/src/index.html index e524935f..cd7ae539 100644 --- a/src/index.html +++ b/src/index.html @@ -19,6 +19,10 @@

📋 Task Manager

+ diff --git a/src/scripts/01-state.js b/src/scripts/01-state.js index 66525bd6..d4defba0 100644 --- a/src/scripts/01-state.js +++ b/src/scripts/01-state.js @@ -2,6 +2,7 @@ let directoryHandle = null; let kanbanFileHandle = null; let currentKanbanContent = ''; + let lastKanbanModified = 0; let tasks = []; let config = {}; let activeFilters = []; // Array of {type: 'tag'|'category'|'user', value: string} diff --git a/src/scripts/02-i18n.js b/src/scripts/02-i18n.js index 24e0d00d..2bfc4546 100644 --- a/src/scripts/02-i18n.js +++ b/src/scripts/02-i18n.js @@ -14,6 +14,7 @@ 'header.newTask': '➕ Task', 'header.archives': '📦 Archives', 'header.columns': '⚙️ Columns', + 'header.autoReload': 'Auto-Reload', // Filters 'filters.tags': 'Tags:', @@ -137,6 +138,7 @@ 'notif.projectDeleted': 'Project removed from list', 'notif.renameError': 'Error renaming', 'notif.projectRestored': 'Project restored automatically', + 'notif.fileChangedReloaded': 'kanban.md changed on disk. Board reloaded.', 'notif.taskMoved': 'Task moved!', 'notif.taskEdited': 'Task {id} updated!', 'notif.taskCreated': 'Task {id} created!', @@ -193,6 +195,7 @@ 'header.newTask': '➕ Tâche', 'header.archives': '📦 Archives', 'header.columns': '⚙️ Colonnes', + 'header.autoReload': 'Rechargement auto', // Filters 'filters.tags': 'Tags:', @@ -316,6 +319,7 @@ 'notif.projectDeleted': 'Projet retiré de la liste', 'notif.renameError': 'Erreur lors du renommage', 'notif.projectRestored': 'Projet restauré automatiquement', + 'notif.fileChangedReloaded': 'kanban.md a été modifié sur le disque. Tableau rechargé.', 'notif.taskMoved': 'Tâche déplacée !', 'notif.taskEdited': 'Tâche {id} modifiée !', 'notif.taskCreated': 'Tâche {id} créée !', @@ -372,6 +376,7 @@ 'header.newTask': '➕ Aufgabe', 'header.archives': '📦 Archiv', 'header.columns': '⚙️ Spalten', + 'header.autoReload': 'Auto-Reload', // Filters 'filters.tags': 'Tags:', @@ -495,6 +500,7 @@ 'notif.projectDeleted': 'Projekt aus Liste gelöscht', 'notif.renameError': 'Problem beim Umbenennen', 'notif.projectRestored': 'Projekt erfolgreich wiederhergestellt', + 'notif.fileChangedReloaded': 'kanban.md auf dem Datenträger geändert. Board neu geladen.', 'notif.taskMoved': 'Aufgabe verschoben!', 'notif.taskEdited': 'Aufgabe {id} aktualisiert!', 'notif.taskCreated': 'Aufgabe {id} erstellt!', @@ -550,6 +556,7 @@ 'header.newTask': '➕ 任务', 'header.archives': '📦 归档', 'header.columns': '⚙️ 列设置', + 'header.autoReload': '自动重新加载', // Filters 'filters.tags': '标签:', @@ -673,6 +680,7 @@ 'notif.projectDeleted': '项目已从列表中移除', 'notif.renameError': '重命名时出错', 'notif.projectRestored': '项目已自动恢复', + 'notif.fileChangedReloaded': 'kanban.md 已在磁盘上更改。看板已重新加载。', 'notif.taskMoved': '任务已移动!', 'notif.taskEdited': '任务 {id} 已更新!', 'notif.taskCreated': '任务 {id} 已创建!', @@ -840,6 +848,7 @@ document.getElementById('newTaskBtn').innerHTML = t('header.newTask'); document.getElementById('archiveBtn').innerHTML = t('header.archives'); document.getElementById('manageColsBtn').innerHTML = t('header.columns'); + document.getElementById('autoReloadLabel').textContent = t('header.autoReload'); // Update language selector to match current language document.getElementById('languageSelector').value = currentLanguage; diff --git a/src/scripts/03-storage.js b/src/scripts/03-storage.js index bde2939b..02431391 100644 --- a/src/scripts/03-storage.js +++ b/src/scripts/03-storage.js @@ -313,6 +313,45 @@ await switchProject(projectIndex); } }); + + // Start background poller to auto-reload kanban.md when it changes externally + setInterval(async () => { + if (!kanbanFileHandle) return; + + // Only poll if auto-reload is enabled + const autoReloadCheckbox = document.getElementById('autoReloadCheckbox'); + if (autoReloadCheckbox && !autoReloadCheckbox.checked) return; + + try { + const file = await kanbanFileHandle.getFile(); + if (file.lastModified > lastKanbanModified) { + const newText = await file.text(); + if (newText !== currentKanbanContent) { + console.log('kanban.md changed on disk. Reloading...'); + await loadKanbanFile(); + + // If the details modal is open, refresh it with the updated task data + const modal = document.getElementById('taskModal'); + if (modal && modal.classList.contains('active') && currentDetailTask) { + const updatedTask = tasks.find(t => t.id === currentDetailTask.id); + if (updatedTask) { + showTaskDetail(updatedTask); + } else { + closeModal(); + } + } + + showNotification(t('notif.fileChangedReloaded'), 'info'); + } else { + // Only lastModified changed (e.g. metadata touched), content is same. + lastKanbanModified = file.lastModified; + } + } + } catch (error) { + // Suppress check errors since they might be temporary lock/permission prompts + console.debug('Error checking kanban.md changes:', error); + } + }, 1000); }); // Select folder diff --git a/src/scripts/04-io.js b/src/scripts/04-io.js index e922c174..279c1cfc 100644 --- a/src/scripts/04-io.js +++ b/src/scripts/04-io.js @@ -3,11 +3,32 @@ kanbanFileHandle = await directoryHandle.getFileHandle('kanban.md'); const file = await kanbanFileHandle.getFile(); currentKanbanContent = await file.text(); + lastKanbanModified = file.lastModified; console.log('File loaded, size:', currentKanbanContent.length); + + // Capture previous task list mapping task IDs to statuses to identify new/moved tasks + const oldTaskStatusMap = new Map(); + const wasLoadedBefore = tasks && tasks.length > 0; + if (wasLoadedBefore) { + tasks.forEach(t => oldTaskStatusMap.set(t.id, t.status)); + } + parseMarkdown(currentKanbanContent); await loadArchive(); // Load archive for historical autocomplete data updateAutocomplete(); + + // Compare tasks to identify new or moved items, only if there were tasks loaded before + if (wasLoadedBefore) { + tasks.forEach(task => { + if (!oldTaskStatusMap.has(task.id)) { + task.isNew = true; + } else if (oldTaskStatusMap.get(task.id) !== task.status) { + task.isMoved = true; + } + }); + } + renderKanban(); // Auto-migrate a legacy (V1) kanban.md to V2 on disk: nothing is lost and future // AI reads/edits stay cheap. One-time — V2 files carry the format marker. diff --git a/src/scripts/06-filters.js b/src/scripts/06-filters.js index ff855487..d3016435 100644 --- a/src/scripts/06-filters.js +++ b/src/scripts/06-filters.js @@ -6,6 +6,11 @@ await writable.write(newContent); await writable.close(); currentKanbanContent = newContent; + + // Get the updated file metadata to avoid reloading what we just saved + const file = await kanbanFileHandle.getFile(); + lastKanbanModified = file.lastModified; + console.log('Auto-saved'); return true; } catch (error) { diff --git a/src/scripts/08-board.js b/src/scripts/08-board.js index 37abc3c0..c749ffd2 100644 --- a/src/scripts/08-board.js +++ b/src/scripts/08-board.js @@ -86,6 +86,13 @@ const due = dueStatus(task); if (due) taskEl.classList.add(`task-card--${due}`); + // Visual highlight for new or moved tasks from external changes + if (task.isNew) { + taskEl.classList.add('task-card--new'); + } else if (task.isMoved) { + taskEl.classList.add('task-card--moved'); + } + var priorityBadgeClass = "Default"; if (config.priorities) { const priority = config.priorities.find(p => clean(p) === task.priority); diff --git a/src/styles/02-board.css b/src/styles/02-board.css index ef62f080..991c4730 100644 --- a/src/styles/02-board.css +++ b/src/styles/02-board.css @@ -251,3 +251,34 @@ background: none; padding: 0; } + + /* Flash highlights for new/moved tasks */ + @keyframes highlightNew { + 0% { + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.6); /* green outline */ + background-color: rgba(34, 197, 94, 0.15); /* light green bg */ + } + 100% { + box-shadow: none; + background-color: var(--bg-secondary); + } + } + + @keyframes highlightMoved { + 0% { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.6); /* blue outline */ + background-color: rgba(59, 130, 246, 0.15); /* light blue bg */ + } + 100% { + box-shadow: none; + background-color: var(--bg-secondary); + } + } + + .task-card--new { + animation: highlightNew 5s ease-out forwards; + } + + .task-card--moved { + animation: highlightMoved 5s ease-out forwards; + } diff --git a/task-manager.html b/task-manager.html index 293f19a5..6bb9bc89 100644 --- a/task-manager.html +++ b/task-manager.html @@ -1 +1,4457 @@ -Markdown Task Manager

📋 Task Manager

Bienvenue ! 👋

Sélectionnez le dossier contenant vos fichiers Markdown (kanban.md et archive.md)

💡 Comment ça marche ?

  1. Cliquez sur "Commencer" ci-dessus
  2. Sélectionnez le dossier contenant vos fichiers Markdown
  3. L'application charge automatiquement kanban.md
  4. Gérez vos tâches visuellement avec le Kanban
  5. Les modifications sont sauvegardées dans les fichiers Markdown

⚠️ Navigateurs supportés : Chrome 86+, Edge 86+, Opera 72+

\ No newline at end of file + + + + + + Markdown Task Manager + + + +
+
+

📋 Task Manager

+
+ + + + + + + + + + +
+
+
+ + +
+
+

Bienvenue ! 👋

+

Sélectionnez le dossier contenant vos fichiers Markdown (kanban.md et archive.md)

+ +
+

💡 Comment ça marche ?

+
    +
  1. Cliquez sur "Commencer" ci-dessus
  2. +
  3. Sélectionnez le dossier contenant vos fichiers Markdown
  4. +
  5. L'application charge automatiquement kanban.md
  6. +
  7. Gérez vos tâches visuellement avec le Kanban
  8. +
  9. Les modifications sont sauvegardées dans les fichiers Markdown
  10. +
+

+ ⚠️ Navigateurs supportés : Chrome 86+, Edge 86+, Opera 72+ +

+
+
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + + \ No newline at end of file From 97153c7421b7cddac2fd1c281d1cabd6fb2714c2 Mon Sep 17 00:00:00 2001 From: Jarkko Lietolahti Date: Wed, 1 Jul 2026 13:24:10 +0300 Subject: [PATCH 2/4] chore: Update version to 1.3.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e79a17eb..74150e7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markdown-task-manager", - "version": "2.0.0", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-task-manager", - "version": "2.0.0", + "version": "1.3.2", "devDependencies": { "html-minifier-terser": "^7.2.0" } diff --git a/package.json b/package.json index 394ee232..a0285e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown-task-manager", - "version": "2.0.0", + "version": "1.3.2", "private": true, "type": "module", "description": "Single-file Markdown Kanban task manager — built from src/ into one self-contained, dependency-free task-manager.html", From 5a17b2554666fadea24bb426b9945b8ffa069959 Mon Sep 17 00:00:00 2001 From: Jarkko Lietolahti Date: Wed, 1 Jul 2026 13:32:54 +0300 Subject: [PATCH 3/4] Revert "chore: Update version to 1.3.2" This reverts commit 97153c7421b7cddac2fd1c281d1cabd6fb2714c2. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 74150e7d..e79a17eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markdown-task-manager", - "version": "1.3.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markdown-task-manager", - "version": "1.3.2", + "version": "2.0.0", "devDependencies": { "html-minifier-terser": "^7.2.0" } diff --git a/package.json b/package.json index a0285e00..394ee232 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown-task-manager", - "version": "1.3.2", + "version": "2.0.0", "private": true, "type": "module", "description": "Single-file Markdown Kanban task manager — built from src/ into one self-contained, dependency-free task-manager.html", From 8a625c54adb3475a95fae5a38f4886084ee362d8 Mon Sep 17 00:00:00 2001 From: Jarkko Lietolahti Date: Wed, 1 Jul 2026 13:40:50 +0300 Subject: [PATCH 4/4] feat: Add Recent Activity log section for tracking local & reload changes --- src/index.html | 11 ++ src/scripts/01-state.js | 1 + src/scripts/02-i18n.js | 66 +++++++++ src/scripts/03-storage.js | 39 ++++++ src/scripts/04-io.js | 40 +++++- src/scripts/08-board.js | 10 ++ src/scripts/09-task-detail.js | 2 + src/scripts/10-archive.js | 5 + src/styles/02-board.css | 71 ++++++++++ task-manager.html | 245 +++++++++++++++++++++++++++++++++- 10 files changed, 484 insertions(+), 6 deletions(-) diff --git a/src/index.html b/src/index.html index cd7ae539..54525c52 100644 --- a/src/index.html +++ b/src/index.html @@ -116,6 +116,17 @@

💡 Comment ça marche ?

diff --git a/src/scripts/01-state.js b/src/scripts/01-state.js index d4defba0..1488f660 100644 --- a/src/scripts/01-state.js +++ b/src/scripts/01-state.js @@ -15,3 +15,4 @@ let wasArchiveModalActive = false; let loadedFormatV2 = false; // true once the loaded kanban.md is V2 (status carried by a **Status** field) let loadedArchiveV2 = false; // true once the loaded archive.md carries the v2 format marker + let activityLogs = []; // Array of {time: Date, type: string, message: string} diff --git a/src/scripts/02-i18n.js b/src/scripts/02-i18n.js index 2bfc4546..e1612725 100644 --- a/src/scripts/02-i18n.js +++ b/src/scripts/02-i18n.js @@ -146,6 +146,21 @@ 'notif.taskDeleted': 'Task permanently deleted', 'notif.taskRestored': 'Task restored to its original column!', + // Activity logs + 'activity.title': 'Recent Activity', + 'activity.clear': 'Clear', + 'activity.externalReload': 'Board reloaded automatically from disk.', + 'activity.externalCreated': 'Task {id} ("{title}") was created externally.', + 'activity.externalMoved': 'Task {id} ("{title}") was moved from "{from}" to "{to}" externally.', + 'activity.externalEdited': 'Task {id} ("{title}") was modified externally.', + 'activity.externalDeleted': 'Task {id} was deleted externally.', + 'activity.localCreated': 'Task {id} ("{title}") created.', + 'activity.localMoved': 'Task {id} ("{title}") moved from "{from}" to "{to}".', + 'activity.localEdited': 'Task {id} ("{title}") updated.', + 'activity.localArchived': 'Task {id} ("{title}") archived.', + 'activity.localDeleted': 'Task {id} permanently deleted.', + 'activity.localRestored': 'Task {id} ("{title}") restored from archive.', + // Prompts and confirmations 'prompt.projectName': 'Project name (leave empty to use "{name}"):', 'prompt.renameProject': 'New project name:', @@ -327,6 +342,21 @@ 'notif.taskDeleted': 'Tâche supprimée définitivement', 'notif.taskRestored': 'Tâche restaurée dans sa colonne d\'origine !', + // Activity logs + 'activity.title': 'Activité Récente', + 'activity.clear': 'Effacer', + 'activity.externalReload': 'Tableau rechargé automatiquement depuis le disque.', + 'activity.externalCreated': 'La tâche {id} ("{title}") a été créée de l\'extérieur.', + 'activity.externalMoved': 'La tâche {id} ("{title}") a été déplacée de "{from}" à "{to}" de l\'extérieur.', + 'activity.externalEdited': 'La tâche {id} ("{title}") a été modifiée de l\'extérieur.', + 'activity.externalDeleted': 'La tâche {id} a été supprimée de l\'extérieur.', + 'activity.localCreated': 'Tâche {id} ("{title}") créée.', + 'activity.localMoved': 'Tâche {id} ("{title}") déplacée de "{from}" à "{to}".', + 'activity.localEdited': 'Tâche {id} ("{title}") mise à jour.', + 'activity.localArchived': 'Tâche {id} ("{title}") archivée.', + 'activity.localDeleted': 'Tâche {id} supprimée définitivement.', + 'activity.localRestored': 'Tâche {id} ("{title}") restaurée depuis l\'archive.', + // Prompts and confirmations 'prompt.projectName': 'Nom du projet (laisser vide pour utiliser "{name}") :', 'prompt.renameProject': 'Nouveau nom du projet :', @@ -508,6 +538,21 @@ 'notif.taskDeleted': 'Aufgabe endgültig gelöscht', 'notif.taskRestored': 'Aufgabe in ursprünglicher Spalte zurückgesetzt!', + // Activity logs + 'activity.title': 'Letzte Aktivitäten', + 'activity.clear': 'Leeren', + 'activity.externalReload': 'Board automatisch vom Datenträger neu geladen.', + 'activity.externalCreated': 'Aufgabe {id} ("{title}") wurde extern erstellt.', + 'activity.externalMoved': 'Aufgabe {id} ("{title}") wurde extern von "{from}" nach "{to}" verschoben.', + 'activity.externalEdited': 'Aufgabe {id} ("{title}") wurde extern geändert.', + 'activity.externalDeleted': 'Aufgabe {id} wurde extern gelöscht.', + 'activity.localCreated': 'Aufgabe {id} ("{title}") erstellt.', + 'activity.localMoved': 'Aufgabe {id} ("{title}") von "{from}" nach "{to}" verschoben.', + 'activity.localEdited': 'Aufgabe {id} ("{title}") aktualisiert.', + 'activity.localArchived': 'Aufgabe {id} ("{title}") archiviert.', + 'activity.localDeleted': 'Aufgabe {id} endgültig gelöscht.', + 'activity.localRestored': 'Aufgabe {id} ("{title}") aus dem Archiv wiederhergestellt.', + // Prompts and confirmations 'prompt.projectName': 'Projektname (leer lassen, um "{name}" zu verwenden):', 'prompt.renameProject': 'Neuer Projektname:', @@ -688,6 +733,21 @@ 'notif.taskDeleted': '任务已永久删除', 'notif.taskRestored': '任务已恢复至原列!', + // Activity logs + 'activity.title': '最近动态', + 'activity.clear': '清空', + 'activity.externalReload': '看板已自动从磁盘重新加载。', + 'activity.externalCreated': '任务 {id} ("{title}") 已在外部创建。', + 'activity.externalMoved': '任务 {id} ("{title}") 已在外部从 "{from}" 移动到 "{to}"。', + 'activity.externalEdited': '任务 {id} ("{title}") 已在外部修改。', + 'activity.externalDeleted': '任务 {id} 已在外部删除。', + 'activity.localCreated': '任务 {id} ("{title}") 已创建。', + 'activity.localMoved': '任务 {id} ("{title}") 已从 "{from}" 移动到 "{to}"。', + 'activity.localEdited': '任务 {id} ("{title}") 已更新。', + 'activity.localArchived': '任务 {id} ("{title}") 已归档。', + 'activity.localDeleted': '任务 {id} 已永久删除。', + 'activity.localRestored': '任务 {id} ("{title}") 已从归档中恢复。', + // Prompts and confirmations 'prompt.projectName': '项目名称 (留空则使用 "{name}"):', 'prompt.renameProject': '新项目名称:', @@ -850,6 +910,12 @@ document.getElementById('manageColsBtn').innerHTML = t('header.columns'); document.getElementById('autoReloadLabel').textContent = t('header.autoReload'); + // Update activity log static texts + const activityLogTitle = document.getElementById('activityLogTitle'); + if (activityLogTitle) activityLogTitle.textContent = t('activity.title'); + const clearActivityLogBtn = document.getElementById('clearActivityLogBtn'); + if (clearActivityLogBtn) clearActivityLogBtn.textContent = t('activity.clear'); + // Update language selector to match current language document.getElementById('languageSelector').value = currentLanguage; diff --git a/src/scripts/03-storage.js b/src/scripts/03-storage.js index 02431391..239aedba 100644 --- a/src/scripts/03-storage.js +++ b/src/scripts/03-storage.js @@ -386,4 +386,43 @@ } }); + function logActivity(type, message) { + const entry = { + time: new Date(), + type: type, + message: message + }; + activityLogs.unshift(entry); + if (activityLogs.length > 50) { + activityLogs.pop(); + } + renderActivityLog(); + } + + function renderActivityLog() { + const listEl = document.getElementById('activityLogList'); + if (!listEl) return; + + listEl.innerHTML = activityLogs.map(log => { + const timeStr = log.time.toLocaleTimeString(); + let borderClass = ''; + if (log.type === 'reload') borderClass = 'reload'; + else if (log.type === 'create') borderClass = 'create'; + else if (log.type === 'delete' || log.type === 'archive') borderClass = 'delete'; + else if (log.type === 'edit' || log.type === 'move') borderClass = 'edit'; + + return ` +
+ [${timeStr}] + ${escapeHtml(log.message)} +
+ `; + }).join(''); + } + + function clearActivityLog() { + activityLogs = []; + renderActivityLog(); + } + // Load kanban.md file diff --git a/src/scripts/04-io.js b/src/scripts/04-io.js index 279c1cfc..300e55b2 100644 --- a/src/scripts/04-io.js +++ b/src/scripts/04-io.js @@ -9,9 +9,13 @@ // Capture previous task list mapping task IDs to statuses to identify new/moved tasks const oldTaskStatusMap = new Map(); + const oldTaskTitleMap = new Map(); const wasLoadedBefore = tasks && tasks.length > 0; if (wasLoadedBefore) { - tasks.forEach(t => oldTaskStatusMap.set(t.id, t.status)); + tasks.forEach(t => { + oldTaskStatusMap.set(t.id, t.status); + oldTaskTitleMap.set(t.id, t.title); + }); } parseMarkdown(currentKanbanContent); @@ -20,13 +24,43 @@ // Compare tasks to identify new or moved items, only if there were tasks loaded before if (wasLoadedBefore) { + let externalChangesCount = 0; + const newTaskIds = new Set(tasks.map(t => t.id)); + tasks.forEach(task => { if (!oldTaskStatusMap.has(task.id)) { task.isNew = true; - } else if (oldTaskStatusMap.get(task.id) !== task.status) { - task.isMoved = true; + logActivity('reload', t('activity.externalCreated', {id: task.id, title: task.title})); + externalChangesCount++; + } else { + if (oldTaskStatusMap.get(task.id) !== task.status) { + task.isMoved = true; + const oldStatus = oldTaskStatusMap.get(task.id); + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === task.status); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : task.status; + logActivity('reload', t('activity.externalMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + externalChangesCount++; + } + if (oldTaskTitleMap.get(task.id) !== task.title) { + logActivity('reload', t('activity.externalEdited', {id: task.id, title: task.title})); + externalChangesCount++; + } + } + }); + + // Track deleted tasks + oldTaskStatusMap.forEach((status, id) => { + if (!newTaskIds.has(id)) { + logActivity('reload', t('activity.externalDeleted', {id: id})); + externalChangesCount++; } }); + + if (externalChangesCount > 0) { + logActivity('reload', t('activity.externalReload')); + } } renderKanban(); diff --git a/src/scripts/08-board.js b/src/scripts/08-board.js index c749ffd2..3e8dc109 100644 --- a/src/scripts/08-board.js +++ b/src/scripts/08-board.js @@ -177,6 +177,7 @@ const oldIdx = tasks.findIndex(t => t.id === taskId); if (oldIdx < 0) return false; const task = tasks[oldIdx]; + const oldStatus = task.status; const statusChanged = newStatus && task.status !== newStatus; const targetStatus = newStatus || task.status; tasks.splice(oldIdx, 1); @@ -195,6 +196,15 @@ tasks.splice(insertIdx, 0, task); const posChanged = tasks.indexOf(task) !== oldIdx; if (newStatus) task.status = newStatus; + + if (statusChanged) { + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === newStatus); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : newStatus; + logActivity('move', t('activity.localMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + } + return statusChanged || posChanged; } diff --git a/src/scripts/09-task-detail.js b/src/scripts/09-task-detail.js index f7cc14ef..6d2baea3 100644 --- a/src/scripts/09-task-detail.js +++ b/src/scripts/09-task-detail.js @@ -269,6 +269,7 @@ task.subtasks = formSubtasks; task.notes = notes; showNotification(t('notif.taskEdited', {id: taskId}), 'success'); + logActivity('edit', t('activity.localEdited', {id: taskId, title: title})); } } else { // Create new task @@ -291,6 +292,7 @@ notes }); showNotification(t('notif.taskCreated', {id: newTaskId}), 'success'); + logActivity('create', t('activity.localCreated', {id: newTaskId, title: title})); } closeTaskModal(); diff --git a/src/scripts/10-archive.js b/src/scripts/10-archive.js index 18d8e7a7..7ed72a26 100644 --- a/src/scripts/10-archive.js +++ b/src/scripts/10-archive.js @@ -104,6 +104,7 @@ renderKanban(); closeModal(); showNotification(t('notif.taskArchived'), 'success'); + logActivity('archive', t('activity.localArchived', {id: task.id, title: task.title})); } } } @@ -144,6 +145,9 @@ } showNotification(t('notif.taskDeleted'), 'success'); + if (!fromArchive) { + logActivity('delete', t('activity.localDeleted', {id: taskId})); + } } } } @@ -228,6 +232,7 @@ renderKanban(); renderArchiveList(document.getElementById('archiveSearch').value); showNotification(t('notif.taskRestored'), 'success'); + logActivity('create', t('activity.localRestored', {id: task.id, title: task.title})); } } diff --git a/src/styles/02-board.css b/src/styles/02-board.css index 991c4730..1cfbc750 100644 --- a/src/styles/02-board.css +++ b/src/styles/02-board.css @@ -282,3 +282,74 @@ .task-card--moved { animation: highlightMoved 5s ease-out forwards; } + + /* Activity Log / History Section */ + .activity-log-section { + margin: 2rem; + padding: 1.5rem; + background: var(--bg-secondary); + border-radius: 8px; + box-shadow: var(--shadow); + border: 1px solid var(--border-color); + } + + .activity-log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); + } + + .activity-log-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + } + + .activity-log-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-family: monospace; + font-size: 0.9rem; + } + + .activity-log-item { + display: flex; + gap: 1rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + background: var(--bg-primary); + border-left: 4px solid var(--accent); + } + + .activity-log-item.reload { + border-left-color: var(--priority-green); + } + + .activity-log-item.create { + border-left-color: var(--priority-blue); + } + + .activity-log-item.edit { + border-left-color: var(--priority-yellow); + } + + .activity-log-item.delete { + border-left-color: var(--priority-red); + } + + .activity-log-time { + color: var(--text-secondary); + flex-shrink: 0; + } + + .activity-log-msg { + color: var(--text-primary); + word-break: break-all; + } diff --git a/task-manager.html b/task-manager.html index 6bb9bc89..4950e2a6 100644 --- a/task-manager.html +++ b/task-manager.html @@ -462,6 +462,77 @@ animation: highlightMoved 5s ease-out forwards; } + /* Activity Log / History Section */ + .activity-log-section { + margin: 2rem; + padding: 1.5rem; + background: var(--bg-secondary); + border-radius: 8px; + box-shadow: var(--shadow); + border: 1px solid var(--border-color); + } + + .activity-log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--border-color); + } + + .activity-log-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + } + + .activity-log-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-family: monospace; + font-size: 0.9rem; + } + + .activity-log-item { + display: flex; + gap: 1rem; + padding: 0.4rem 0.6rem; + border-radius: 4px; + background: var(--bg-primary); + border-left: 4px solid var(--accent); + } + + .activity-log-item.reload { + border-left-color: var(--priority-green); + } + + .activity-log-item.create { + border-left-color: var(--priority-blue); + } + + .activity-log-item.edit { + border-left-color: var(--priority-yellow); + } + + .activity-log-item.delete { + border-left-color: var(--priority-red); + } + + .activity-log-time { + color: var(--text-secondary); + flex-shrink: 0; + } + + .activity-log-msg { + color: var(--text-primary); + word-break: break-all; + } + /* ===== 03-modals.css ===== */ .modal { display: none; @@ -808,6 +879,17 @@

💡 Comment ça marche ?

@@ -988,6 +1070,7 @@

📦 Archives

let wasArchiveModalActive = false; let loadedFormatV2 = false; // true once the loaded kanban.md is V2 (status carried by a **Status** field) let loadedArchiveV2 = false; // true once the loaded archive.md carries the v2 format marker + let activityLogs = []; // Array of {time: Date, type: string, message: string} /* ===== 02-i18n.js ===== */ // ===== TRANSLATION SYSTEM ===== @@ -1138,6 +1221,21 @@

📦 Archives

'notif.taskDeleted': 'Task permanently deleted', 'notif.taskRestored': 'Task restored to its original column!', + // Activity logs + 'activity.title': 'Recent Activity', + 'activity.clear': 'Clear', + 'activity.externalReload': 'Board reloaded automatically from disk.', + 'activity.externalCreated': 'Task {id} ("{title}") was created externally.', + 'activity.externalMoved': 'Task {id} ("{title}") was moved from "{from}" to "{to}" externally.', + 'activity.externalEdited': 'Task {id} ("{title}") was modified externally.', + 'activity.externalDeleted': 'Task {id} was deleted externally.', + 'activity.localCreated': 'Task {id} ("{title}") created.', + 'activity.localMoved': 'Task {id} ("{title}") moved from "{from}" to "{to}".', + 'activity.localEdited': 'Task {id} ("{title}") updated.', + 'activity.localArchived': 'Task {id} ("{title}") archived.', + 'activity.localDeleted': 'Task {id} permanently deleted.', + 'activity.localRestored': 'Task {id} ("{title}") restored from archive.', + // Prompts and confirmations 'prompt.projectName': 'Project name (leave empty to use "{name}"):', 'prompt.renameProject': 'New project name:', @@ -1319,6 +1417,21 @@

📦 Archives

'notif.taskDeleted': 'Tâche supprimée définitivement', 'notif.taskRestored': 'Tâche restaurée dans sa colonne d\'origine !', + // Activity logs + 'activity.title': 'Activité Récente', + 'activity.clear': 'Effacer', + 'activity.externalReload': 'Tableau rechargé automatiquement depuis le disque.', + 'activity.externalCreated': 'La tâche {id} ("{title}") a été créée de l\'extérieur.', + 'activity.externalMoved': 'La tâche {id} ("{title}") a été déplacée de "{from}" à "{to}" de l\'extérieur.', + 'activity.externalEdited': 'La tâche {id} ("{title}") a été modifiée de l\'extérieur.', + 'activity.externalDeleted': 'La tâche {id} a été supprimée de l\'extérieur.', + 'activity.localCreated': 'Tâche {id} ("{title}") créée.', + 'activity.localMoved': 'Tâche {id} ("{title}") déplacée de "{from}" à "{to}".', + 'activity.localEdited': 'Tâche {id} ("{title}") mise à jour.', + 'activity.localArchived': 'Tâche {id} ("{title}") archivée.', + 'activity.localDeleted': 'Tâche {id} supprimée définitivement.', + 'activity.localRestored': 'Tâche {id} ("{title}") restaurée depuis l\'archive.', + // Prompts and confirmations 'prompt.projectName': 'Nom du projet (laisser vide pour utiliser "{name}") :', 'prompt.renameProject': 'Nouveau nom du projet :', @@ -1500,6 +1613,21 @@

📦 Archives

'notif.taskDeleted': 'Aufgabe endgültig gelöscht', 'notif.taskRestored': 'Aufgabe in ursprünglicher Spalte zurückgesetzt!', + // Activity logs + 'activity.title': 'Letzte Aktivitäten', + 'activity.clear': 'Leeren', + 'activity.externalReload': 'Board automatisch vom Datenträger neu geladen.', + 'activity.externalCreated': 'Aufgabe {id} ("{title}") wurde extern erstellt.', + 'activity.externalMoved': 'Aufgabe {id} ("{title}") wurde extern von "{from}" nach "{to}" verschoben.', + 'activity.externalEdited': 'Aufgabe {id} ("{title}") wurde extern geändert.', + 'activity.externalDeleted': 'Aufgabe {id} wurde extern gelöscht.', + 'activity.localCreated': 'Aufgabe {id} ("{title}") erstellt.', + 'activity.localMoved': 'Aufgabe {id} ("{title}") von "{from}" nach "{to}" verschoben.', + 'activity.localEdited': 'Aufgabe {id} ("{title}") aktualisiert.', + 'activity.localArchived': 'Aufgabe {id} ("{title}") archiviert.', + 'activity.localDeleted': 'Aufgabe {id} endgültig gelöscht.', + 'activity.localRestored': 'Aufgabe {id} ("{title}") aus dem Archiv wiederhergestellt.', + // Prompts and confirmations 'prompt.projectName': 'Projektname (leer lassen, um "{name}" zu verwenden):', 'prompt.renameProject': 'Neuer Projektname:', @@ -1680,6 +1808,21 @@

📦 Archives

'notif.taskDeleted': '任务已永久删除', 'notif.taskRestored': '任务已恢复至原列!', + // Activity logs + 'activity.title': '最近动态', + 'activity.clear': '清空', + 'activity.externalReload': '看板已自动从磁盘重新加载。', + 'activity.externalCreated': '任务 {id} ("{title}") 已在外部创建。', + 'activity.externalMoved': '任务 {id} ("{title}") 已在外部从 "{from}" 移动到 "{to}"。', + 'activity.externalEdited': '任务 {id} ("{title}") 已在外部修改。', + 'activity.externalDeleted': '任务 {id} 已在外部删除。', + 'activity.localCreated': '任务 {id} ("{title}") 已创建。', + 'activity.localMoved': '任务 {id} ("{title}") 已从 "{from}" 移动到 "{to}"。', + 'activity.localEdited': '任务 {id} ("{title}") 已更新。', + 'activity.localArchived': '任务 {id} ("{title}") 已归档。', + 'activity.localDeleted': '任务 {id} 已永久删除。', + 'activity.localRestored': '任务 {id} ("{title}") 已从归档中恢复。', + // Prompts and confirmations 'prompt.projectName': '项目名称 (留空则使用 "{name}"):', 'prompt.renameProject': '新项目名称:', @@ -1842,6 +1985,12 @@

📦 Archives

document.getElementById('manageColsBtn').innerHTML = t('header.columns'); document.getElementById('autoReloadLabel').textContent = t('header.autoReload'); + // Update activity log static texts + const activityLogTitle = document.getElementById('activityLogTitle'); + if (activityLogTitle) activityLogTitle.textContent = t('activity.title'); + const clearActivityLogBtn = document.getElementById('clearActivityLogBtn'); + if (clearActivityLogBtn) clearActivityLogBtn.textContent = t('activity.clear'); + // Update language selector to match current language document.getElementById('languageSelector').value = currentLanguage; @@ -2481,6 +2630,45 @@

${t('welcome.howItWorks')}

} }); + function logActivity(type, message) { + const entry = { + time: new Date(), + type: type, + message: message + }; + activityLogs.unshift(entry); + if (activityLogs.length > 50) { + activityLogs.pop(); + } + renderActivityLog(); + } + + function renderActivityLog() { + const listEl = document.getElementById('activityLogList'); + if (!listEl) return; + + listEl.innerHTML = activityLogs.map(log => { + const timeStr = log.time.toLocaleTimeString(); + let borderClass = ''; + if (log.type === 'reload') borderClass = 'reload'; + else if (log.type === 'create') borderClass = 'create'; + else if (log.type === 'delete' || log.type === 'archive') borderClass = 'delete'; + else if (log.type === 'edit' || log.type === 'move') borderClass = 'edit'; + + return ` +
+ [${timeStr}] + ${escapeHtml(log.message)} +
+ `; + }).join(''); + } + + function clearActivityLog() { + activityLogs = []; + renderActivityLog(); + } + // Load kanban.md file /* ===== 04-io.js ===== */ @@ -2495,9 +2683,13 @@

${t('welcome.howItWorks')}

// Capture previous task list mapping task IDs to statuses to identify new/moved tasks const oldTaskStatusMap = new Map(); + const oldTaskTitleMap = new Map(); const wasLoadedBefore = tasks && tasks.length > 0; if (wasLoadedBefore) { - tasks.forEach(t => oldTaskStatusMap.set(t.id, t.status)); + tasks.forEach(t => { + oldTaskStatusMap.set(t.id, t.status); + oldTaskTitleMap.set(t.id, t.title); + }); } parseMarkdown(currentKanbanContent); @@ -2506,13 +2698,43 @@

${t('welcome.howItWorks')}

// Compare tasks to identify new or moved items, only if there were tasks loaded before if (wasLoadedBefore) { + let externalChangesCount = 0; + const newTaskIds = new Set(tasks.map(t => t.id)); + tasks.forEach(task => { if (!oldTaskStatusMap.has(task.id)) { task.isNew = true; - } else if (oldTaskStatusMap.get(task.id) !== task.status) { - task.isMoved = true; + logActivity('reload', t('activity.externalCreated', {id: task.id, title: task.title})); + externalChangesCount++; + } else { + if (oldTaskStatusMap.get(task.id) !== task.status) { + task.isMoved = true; + const oldStatus = oldTaskStatusMap.get(task.id); + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === task.status); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : task.status; + logActivity('reload', t('activity.externalMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + externalChangesCount++; + } + if (oldTaskTitleMap.get(task.id) !== task.title) { + logActivity('reload', t('activity.externalEdited', {id: task.id, title: task.title})); + externalChangesCount++; + } } }); + + // Track deleted tasks + oldTaskStatusMap.forEach((status, id) => { + if (!newTaskIds.has(id)) { + logActivity('reload', t('activity.externalDeleted', {id: id})); + externalChangesCount++; + } + }); + + if (externalChangesCount > 0) { + logActivity('reload', t('activity.externalReload')); + } } renderKanban(); @@ -3680,6 +3902,7 @@

${t('welcome.howItWorks')}

const oldIdx = tasks.findIndex(t => t.id === taskId); if (oldIdx < 0) return false; const task = tasks[oldIdx]; + const oldStatus = task.status; const statusChanged = newStatus && task.status !== newStatus; const targetStatus = newStatus || task.status; tasks.splice(oldIdx, 1); @@ -3698,6 +3921,15 @@

${t('welcome.howItWorks')}

tasks.splice(insertIdx, 0, task); const posChanged = tasks.indexOf(task) !== oldIdx; if (newStatus) task.status = newStatus; + + if (statusChanged) { + const oldCol = config.columns.find(c => c.id === oldStatus); + const newCol = config.columns.find(c => c.id === newStatus); + const oldColName = oldCol ? oldCol.name : oldStatus; + const newColName = newCol ? newCol.name : newStatus; + logActivity('move', t('activity.localMoved', {id: task.id, title: task.title, from: oldColName, to: newColName})); + } + return statusChanged || posChanged; } @@ -4068,6 +4300,7 @@

${escap task.subtasks = formSubtasks; task.notes = notes; showNotification(t('notif.taskEdited', {id: taskId}), 'success'); + logActivity('edit', t('activity.localEdited', {id: taskId, title: title})); } } else { // Create new task @@ -4090,6 +4323,7 @@

${escap notes }); showNotification(t('notif.taskCreated', {id: newTaskId}), 'success'); + logActivity('create', t('activity.localCreated', {id: newTaskId, title: title})); } closeTaskModal(); @@ -4206,6 +4440,7 @@

${escap renderKanban(); closeModal(); showNotification(t('notif.taskArchived'), 'success'); + logActivity('archive', t('activity.localArchived', {id: task.id, title: task.title})); } } } @@ -4246,6 +4481,9 @@

${escap } showNotification(t('notif.taskDeleted'), 'success'); + if (!fromArchive) { + logActivity('delete', t('activity.localDeleted', {id: taskId})); + } } } } @@ -4330,6 +4568,7 @@

${escap renderKanban(); renderArchiveList(document.getElementById('archiveSearch').value); showNotification(t('notif.taskRestored'), 'success'); + logActivity('create', t('activity.localRestored', {id: task.id, title: task.title})); } }