Okoge-keys commited on
Commit
769932b
·
verified ·
1 Parent(s): f3c968c

Upload 3 files

Browse files
v6_軽量KanbanBoard/app.js CHANGED
@@ -1,1413 +1,1545 @@
1
- 
2
- (() => {
3
- "use strict";
4
-
5
- const STORAGE_KEY = "toyota-kanban-tasks-v1";
6
- const SETTINGS_KEY = "toyota-kanban-settings-v1";
7
- const DEFAULT_TITLE = "トヨタ式カンバンボード";
8
- const PANEL_KEYS = ["analytics", "filters", "form"];
9
-
10
- const columnSettings = {
11
- backlog: { wip: Number.POSITIVE_INFINITY, label: "バックログ" },
12
- ready: { wip: 5, label: "準備中" },
13
- inProgress: { wip: 3, label: "仕掛中" },
14
- waiting: { wip: 2, label: "停止中" },
15
- done: { wip: Number.POSITIVE_INFINITY, label: "完了" }
16
- };
17
-
18
- const form = document.getElementById("task-form");
19
- const fields = {
20
- title: document.getElementById("task-title"),
21
- owner: document.getElementById("task-owner"),
22
- due: document.getElementById("task-due"),
23
- status: document.getElementById("task-status"),
24
- notes: document.getElementById("task-notes"),
25
- tags: document.getElementById("task-tags"),
26
- link: document.getElementById("task-link")
27
- };
28
- const messageArea = document.getElementById("message-area");
29
- const seedButton = document.getElementById("seed-demo");
30
- const template = document.getElementById("task-template");
31
- const dropzones = Array.from(document.querySelectorAll("[data-dropzone]"));
32
- const counters = Array.from(document.querySelectorAll("[data-counter]"));
33
- const metricElements = {
34
- total: document.querySelector("[data-metric=\"total\"]"),
35
- active: document.querySelector("[data-metric=\"active\"]"),
36
- throughput7: document.querySelector("[data-metric=\"throughput7\"]"),
37
- leadtime: document.querySelector("[data-metric=\"leadtime\"]")
38
- };
39
- const editDialog = document.getElementById("edit-dialog");
40
- const editForm = document.getElementById("edit-form");
41
- const editFields = editForm
42
- ? {
43
- title: document.getElementById("edit-title"),
44
- owner: document.getElementById("edit-owner"),
45
- due: document.getElementById("edit-due"),
46
- notes: document.getElementById("edit-notes"),
47
- tags: document.getElementById("edit-tags"),
48
- link: document.getElementById("edit-link")
49
- }
50
- : null;
51
-
52
- const boardTitleHeading = document.getElementById("board-title-heading");
53
- const titleEditButton = document.getElementById("title-edit");
54
- const panelSections = Array.from(document.querySelectorAll("[data-panel]"));
55
- const panels = new Map(panelSections.map((section) => [section.dataset.panel, section]));
56
- const panelToggleButtons = Array.from(document.querySelectorAll("[data-action=\"toggle-panel\"]"));
57
-
58
- const filterElements = {
59
- keyword: document.getElementById("filter-keyword"),
60
- dueStart: document.getElementById("filter-due-start"),
61
- dueEnd: document.getElementById("filter-due-end"),
62
- clear: document.getElementById("filter-clear"),
63
- tagList: document.getElementById("filter-tag-list")
64
- };
65
- const selectionElements = {
66
- counter: document.getElementById("selection-counter"),
67
- hint: document.getElementById("selection-hint"),
68
- selectAll: document.getElementById("selection-selectall"),
69
- clear: document.getElementById("selection-clear"),
70
- delete: document.getElementById("selection-delete")
71
- };
72
- const uiElements = {
73
- viewToggle: document.getElementById("view-toggle")
74
- };
75
-
76
- const filterState = {
77
- keyword: "",
78
- dueStart: "",
79
- dueEnd: "",
80
- tags: new Set()
81
- };
82
-
83
- const selectionState = {
84
- ids: new Set(),
85
- lastVisibleIds: []
86
- };
87
-
88
- const uiState = {
89
- compact: false
90
- };
91
-
92
- let settings = createDefaultSettings();
93
- let tasks = [];
94
- let messageTimer = null;
95
- let draggingTaskId = null;
96
- let editingTaskId = null;
97
-
98
- init();
99
-
100
- function createDefaultSettings() {
101
- return {
102
- title: DEFAULT_TITLE,
103
- panels: PANEL_KEYS.reduce((acc, key) => {
104
- acc[key] = false;
105
- return acc;
106
- }, {})
107
- };
108
- }
109
-
110
- function init() {
111
- loadSettings();
112
- applyTitle();
113
- applyPanelStates({ initial: true });
114
- loadFromStorage();
115
- attachFormEvents();
116
- attachDropzoneEvents();
117
- attachSeedButton();
118
- attachEditForm();
119
- attachFilterHandlers();
120
- attachSelectionHandlers();
121
- attachViewHandlers();
122
- attachPanelHandlers();
123
- attachTitleEditor();
124
- form?.addEventListener("reset", () => hideMessage());
125
- updateViewToggleLabel();
126
- updateSelectionUI();
127
- render();
128
- }
129
-
130
- function loadSettings() {
131
- try {
132
- const raw = window.localStorage.getItem(SETTINGS_KEY);
133
- if (raw) {
134
- const parsed = JSON.parse(raw);
135
- if (parsed && typeof parsed === "object") {
136
- settings = {
137
- title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim() : DEFAULT_TITLE,
138
- panels: { ...createDefaultSettings().panels, ...(parsed.panels || {}) }
139
- };
140
- return;
141
- }
142
- }
143
- } catch (error) {
144
- console.warn("Failed to load settings:", error);
145
- }
146
- settings = createDefaultSettings();
147
- }
148
-
149
- function saveSettings() {
150
- try {
151
- window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
152
- } catch (error) {
153
- console.warn("Failed to save settings:", error);
154
- }
155
- }
156
-
157
- function applyTitle() {
158
- if (boardTitleHeading) {
159
- boardTitleHeading.textContent = settings.title;
160
- }
161
- document.title = settings.title;
162
- }
163
-
164
- function attachTitleEditor() {
165
- titleEditButton?.addEventListener("click", handleTitleEdit);
166
- }
167
-
168
- function handleTitleEdit() {
169
- const current = settings.title || DEFAULT_TITLE;
170
- const next = window.prompt("ボードタイトルを入力してください。", current);
171
- if (next === null) return;
172
- const trimmed = next.trim();
173
- settings.title = trimmed || DEFAULT_TITLE;
174
- applyTitle();
175
- saveSettings();
176
- showMessage("タイトルを更新しました。", "info");
177
- }
178
-
179
- function attachPanelHandlers() {
180
- panelToggleButtons.forEach((button) => {
181
- button.addEventListener("click", () => {
182
- const key = button.dataset.target;
183
- const section = panels.get(key);
184
- if (!key || !section) return;
185
- const willCollapse = !section.classList.contains("panel-collapsed");
186
- setPanelCollapsed(key, willCollapse);
187
- });
188
- });
189
- }
190
-
191
- function applyPanelStates({ initial = false } = {}) {
192
- PANEL_KEYS.forEach((key) => {
193
- const collapsed = Boolean(settings.panels[key]);
194
- setPanelCollapsed(key, collapsed, { save: !initial });
195
- });
196
- }
197
- function setPanelCollapsed(panelKey, collapsed, { save = true } = {}) {
198
- const section = panels.get(panelKey);
199
- if (!section) return;
200
- section.classList.toggle("panel-collapsed", collapsed);
201
- const body = section.querySelector(`[data-panel-body="${panelKey}"]`);
202
- if (body) {
203
- body.hidden = collapsed;
204
- }
205
- updatePanelButton(panelKey, collapsed);
206
- settings.panels[panelKey] = collapsed;
207
- if (save) {
208
- saveSettings();
209
- }
210
- }
211
-
212
- function updatePanelButton(panelKey, collapsed) {
213
- const button = panelToggleButtons.find((item) => item.dataset.target === panelKey);
214
- if (!button) return;
215
- button.textContent = collapsed ? "展開する" : "折りたたむ";
216
- button.setAttribute("aria-expanded", collapsed ? "false" : "true");
217
- }
218
-
219
- function attachFormEvents() {
220
- form?.addEventListener("submit", handleSubmit);
221
- }
222
-
223
- function handleSubmit(event) {
224
- event.preventDefault();
225
- const rawTitle = fields.title.value.trim();
226
- const rawOwner = fields.owner.value.trim();
227
- const rawNotes = fields.notes.value.trim();
228
- const status = fields.status.value;
229
- const due = fields.due.value;
230
- const tags = parseTags(fields.tags?.value ?? "");
231
- const link = normalizeLink(fields.link?.value ?? "");
232
-
233
- if (!rawTitle) {
234
- showMessage("タイトルを入力してください。", "warn");
235
- fields.title.focus();
236
- return;
237
- }
238
-
239
- if (!canEnterColumn(status)) {
240
- const limit = columnSettings[status]?.wip;
241
- const label = columnSettings[status]?.label ?? status;
242
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
243
- return;
244
- }
245
-
246
- const now = new Date();
247
- const newTask = {
248
- id: createId(),
249
- title: rawTitle,
250
- owner: rawOwner,
251
- due,
252
- notes: rawNotes,
253
- status,
254
- link,
255
- createdAt: now.toISOString(),
256
- tags
257
- };
258
-
259
- if (status === "done") {
260
- newTask.completedAt = now.toISOString();
261
- }
262
-
263
- tasks.push(newTask);
264
- persist();
265
- render();
266
- form.reset();
267
- fields.title.focus();
268
- showMessage("タスクを登録しました。", "info");
269
- }
270
-
271
- function attachDropzoneEvents() {
272
- dropzones.forEach((zone) => {
273
- zone.addEventListener("dragover", handleDragOver);
274
- zone.addEventListener("dragleave", handleDragLeave);
275
- zone.addEventListener("drop", handleDrop);
276
- });
277
- }
278
-
279
- function attachSeedButton() {
280
- seedButton?.addEventListener("click", seedDemoData);
281
- }
282
-
283
- function attachEditForm() {
284
- if (!editForm || !editDialog || !editFields) return;
285
-
286
- editForm.addEventListener("submit", handleEditSubmit);
287
- const cancelButton = editForm.querySelector("[data-action=\"cancel-edit\"]");
288
- cancelButton?.addEventListener("click", () => closeEditDialog());
289
-
290
- editDialog.addEventListener("cancel", () => closeEditDialog(true));
291
- editDialog.addEventListener("close", () => resetEditingState());
292
- }
293
-
294
- function attachFilterHandlers() {
295
- filterElements.keyword?.addEventListener("input", handleKeywordFilter);
296
- filterElements.dueStart?.addEventListener("change", handleDueFilter);
297
- filterElements.dueEnd?.addEventListener("change", handleDueFilter);
298
- filterElements.clear?.addEventListener("click", clearFilters);
299
- filterElements.tagList?.addEventListener("click", handleTagFilterClick);
300
- }
301
-
302
- function attachSelectionHandlers() {
303
- selectionElements.selectAll?.addEventListener("click", selectAllVisible);
304
- selectionElements.clear?.addEventListener("click", () => {
305
- clearSelection();
306
- syncSelectionCheckboxes();
307
- });
308
- selectionElements.delete?.addEventListener("click", deleteSelectedTasks);
309
- }
310
-
311
- function attachViewHandlers() {
312
- uiElements.viewToggle?.addEventListener("click", toggleViewMode);
313
- }
314
-
315
- function handleKeywordFilter(event) {
316
- filterState.keyword = event.target.value.trim();
317
- render();
318
- }
319
-
320
- function handleDueFilter() {
321
- filterState.dueStart = filterElements.dueStart?.value ?? "";
322
- filterState.dueEnd = filterElements.dueEnd?.value ?? "";
323
- render();
324
- }
325
-
326
- function handleTagFilterClick(event) {
327
- const button = event.target.closest("[data-tag-value]");
328
- if (!button) return;
329
- const tag = button.dataset.tagValue;
330
- if (!tag) return;
331
-
332
- if (filterState.tags.has(tag)) {
333
- filterState.tags.delete(tag);
334
- } else {
335
- filterState.tags.add(tag);
336
- }
337
- render();
338
- }
339
-
340
- function clearFilters() {
341
- filterState.keyword = "";
342
- filterState.dueStart = "";
343
- filterState.dueEnd = "";
344
- filterState.tags.clear();
345
- if (filterElements.keyword) {
346
- filterElements.keyword.value = "";
347
- }
348
- if (filterElements.dueStart) {
349
- filterElements.dueStart.value = "";
350
- }
351
- if (filterElements.dueEnd) {
352
- filterElements.dueEnd.value = "";
353
- }
354
- render();
355
- }
356
-
357
- function handleDragStart(event) {
358
- const card = event.currentTarget;
359
- const taskId = card.dataset.taskId;
360
- if (!taskId) return;
361
- draggingTaskId = taskId;
362
- event.dataTransfer.effectAllowed = "move";
363
- event.dataTransfer.setData("text/plain", taskId);
364
- card.classList.add("opacity-40");
365
- }
366
-
367
- function handleDragEnd(event) {
368
- event.currentTarget.classList.remove("opacity-40");
369
- draggingTaskId = null;
370
- dropzones.forEach((zone) => setDropzoneHighlight(zone, "none"));
371
- }
372
-
373
- function handleDragOver(event) {
374
- event.preventDefault();
375
- const zone = event.currentTarget;
376
- const targetColumn = zone.dataset.dropzone;
377
- const allowed = canEnterColumn(targetColumn, draggingTaskId);
378
- event.dataTransfer.dropEffect = allowed ? "move" : "none";
379
- setDropzoneHighlight(zone, allowed ? "allowed" : "denied");
380
- }
381
-
382
- function handleDragLeave(event) {
383
- setDropzoneHighlight(event.currentTarget, "none");
384
- }
385
-
386
- function handleDrop(event) {
387
- event.preventDefault();
388
- const zone = event.currentTarget;
389
- const targetColumn = zone.dataset.dropzone;
390
- const taskId = draggingTaskId || event.dataTransfer.getData("text/plain");
391
-
392
- setDropzoneHighlight(zone, "none");
393
-
394
- if (!taskId) return;
395
-
396
- if (!canEnterColumn(targetColumn, taskId)) {
397
- const limit = columnSettings[targetColumn]?.wip;
398
- const label = columnSettings[targetColumn]?.label ?? targetColumn;
399
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
400
- return;
401
- }
402
-
403
- moveTask(taskId, targetColumn);
404
- }
405
-
406
- function moveTask(taskId, targetColumn) {
407
- const task = tasks.find((item) => item.id === taskId);
408
- if (!task || task.status === targetColumn) return;
409
-
410
- task.status = targetColumn;
411
- if (targetColumn === "done") {
412
- task.completedAt = new Date().toISOString();
413
- }
414
-
415
- persist();
416
- render();
417
- showMessage(`タスクを「${columnSettings[targetColumn]?.label ?? targetColumn}」へ移動しました。`, "info");
418
- }
419
-
420
- function render() {
421
- cleanupSelection();
422
- renderTagFilters();
423
-
424
- const filterActive = isFilterActive();
425
- const filteredTasks = getFilteredTasks();
426
- const tasksToRender = filterActive ? filteredTasks : tasks;
427
-
428
- selectionState.lastVisibleIds = tasksToRender.map((task) => task.id);
429
-
430
- dropzones.forEach((zone) => zone.replaceChildren());
431
-
432
- const byColumn = new Map();
433
- tasksToRender
434
- .slice()
435
- .sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime())
436
- .forEach((task) => {
437
- const list = byColumn.get(task.status) ?? [];
438
- list.push(task);
439
- byColumn.set(task.status, list);
440
- });
441
-
442
- dropzones.forEach((zone) => {
443
- const columnKey = zone.dataset.dropzone;
444
- const columnTasks = byColumn.get(columnKey) ?? [];
445
- columnTasks.forEach((task) => zone.appendChild(buildTaskCard(task)));
446
- if (!zone.childElementCount) {
447
- zone.appendChild(createEmptyState(filterActive));
448
- }
449
- });
450
-
451
- updateCounters(filterActive, filteredTasks);
452
- renderMetrics(filteredTasks, filterActive);
453
- syncSelectionCheckboxes();
454
- updateSelectionUI();
455
- }
456
- function buildTaskCard(task) {
457
- const fragment = template.content.cloneNode(true);
458
- const card = fragment.querySelector("[data-task-id]");
459
- card.dataset.taskId = task.id;
460
-
461
- const isSelected = selectionState.ids.has(task.id);
462
- card.classList.toggle("kanban-card-selected", isSelected);
463
-
464
-
465
-
466
- const titleEl = card.querySelector("[data-field=\"title\"]");
467
- const ownerEl = card.querySelector("[data-field=\"owner\"]");
468
- const dueEl = card.querySelector("[data-field=\"due\"]");
469
- const notesEl = card.querySelector("[data-field=\"notes\"]");
470
- const tagsEl = card.querySelector("[data-field=\"tags\"]");
471
- const tagsSection = card.querySelector("[data-section=\"tags\"]");
472
- const linkSection = card.querySelector("[data-section=\"link\"]");
473
- const linkEl = card.querySelector("[data-field=\"link\"]");
474
- const doneButton = card.querySelector("[data-action=\"mark-done\"]");
475
- const editButton = card.querySelector("[data-action=\"edit-task\"]");
476
- const deleteButton = card.querySelector("[data-action=\"delete-task\"]");
477
- const checkbox = card.querySelector("[data-action=\"select-task\"]");
478
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
479
-
480
- titleEl.textContent = task.title;
481
- ownerEl.textContent = task.owner || "未設定";
482
- dueEl.textContent = formatDue(task.due);
483
- notesEl.textContent = task.notes || "メモなし";
484
- notesEl.classList.toggle("text-slate-500", !task.notes);
485
-
486
- if (tagsEl) {
487
- tagsEl.replaceChildren();
488
- const hasTags = Array.isArray(task.tags) && task.tags.length > 0;
489
- tagsSection?.classList.toggle("hidden", !hasTags);
490
- tagsSection?.setAttribute("aria-hidden", hasTags ? "false" : "true");
491
- tagsSection?.setAttribute("data-has-tags", hasTags ? "true" : "false");
492
- if (hasTags) {
493
- task.tags.forEach((tag) => {
494
- const pill = document.createElement("span");
495
- pill.textContent = `#${tag}`;
496
- pill.className = buildTagPillClass(filterState.tags.has(tag));
497
- tagsEl.appendChild(pill);
498
- });
499
- }
500
- }
501
-
502
- if (linkSection && linkEl) {
503
- if (task.link) {
504
- linkSection.classList.remove("hidden");
505
- linkEl.href = task.link;
506
- linkEl.textContent = formatLinkLabel(task.link);
507
- linkEl.title = task.link;
508
- } else {
509
- linkSection.classList.add("hidden");
510
- linkEl.removeAttribute("href");
511
- linkEl.removeAttribute("title");
512
- linkEl.textContent = "";
513
- }
514
- }
515
-
516
- if (checkbox) {
517
- checkbox.checked = isSelected;
518
- checkbox.addEventListener("click", (event) => event.stopPropagation());
519
- checkbox.addEventListener("mousedown", (event) => event.stopPropagation());
520
- checkbox.addEventListener("change", (event) => toggleSelection(task.id, event.target.checked));
521
- }
522
-
523
- if (doneButton) {
524
- const isDone = task.status === "done";
525
- doneButton.classList.toggle("hidden", isDone);
526
- doneButton.addEventListener("click", (event) => {
527
- event.preventDefault();
528
- event.stopPropagation();
529
- moveTask(task.id, "done");
530
- });
531
- }
532
-
533
- editButton?.addEventListener("click", (event) => {
534
- event.preventDefault();
535
- event.stopPropagation();
536
- openEditDialog(task.id);
537
- });
538
- deleteButton?.addEventListener("click", (event) => {
539
- event.preventDefault();
540
- event.stopPropagation();
541
- deleteTask(task.id);
542
- });
543
-
544
- if (toggleDetailsButton) {
545
- toggleDetailsButton.addEventListener("click", (event) => {
546
- event.preventDefault();
547
- event.stopPropagation();
548
- card.classList.toggle("kanban-card-expanded");
549
- updateCardDetailVisibility(card);
550
- });
551
- }
552
-
553
- card.addEventListener("dragstart", handleDragStart);
554
- card.addEventListener("dragend", handleDragEnd);
555
-
556
- applyCardLayout(card);
557
-
558
- return card;
559
- }
560
-
561
- function applyCardLayout(card) {
562
- if (!card) return;
563
- card.classList.toggle("kanban-card-compact", uiState.compact);
564
- if (!uiState.compact) {
565
- card.classList.remove("kanban-card-expanded");
566
- }
567
- updateCardDetailVisibility(card);
568
- }
569
-
570
- function updateCardDetailVisibility(card) {
571
- const isExpanded = card.classList.contains("kanban-card-expanded");
572
- const showDetails = !uiState.compact || isExpanded;
573
-
574
- const meta = card.querySelector("[data-section=\"meta\"]");
575
- const notes = card.querySelector("[data-section=\"notes\"]");
576
- const tags = card.querySelector("[data-section=\"tags\"]");
577
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
578
-
579
- if (meta) {
580
- meta.classList.toggle("hidden", !showDetails);
581
- }
582
- if (notes) {
583
- notes.classList.toggle("hidden", !showDetails);
584
- notes.classList.toggle("kanban-note-clamped", uiState.compact && !isExpanded);
585
- }
586
-
587
- if (tags) {
588
- const hasTags = tags && tags.getAttribute("data-has-tags") === "true";
589
- if (hasTags) {
590
- tags.classList.toggle("hidden", !showDetails);
591
- }
592
- }
593
- if (toggleDetailsButton) {
594
- const hasDetails = Boolean((meta && meta.textContent.trim()) || (notes && notes.textContent.trim() && notes.textContent.trim() !== "メモなし"));
595
- const shouldShowToggle = uiState.compact && hasDetails;
596
- toggleDetailsButton.classList.toggle("invisible", !shouldShowToggle);
597
- toggleDetailsButton.classList.toggle("pointer-events-none", !shouldShowToggle);
598
- toggleDetailsButton.textContent = isExpanded ? "折りたた��" : "詳細";
599
- toggleDetailsButton.setAttribute("aria-expanded", isExpanded ? "true" : "false");
600
- }
601
- }
602
-
603
- function createEmptyState(filterActive) {
604
- const empty = document.createElement("p");
605
- empty.className = "rounded-lg border border-dashed border-slate-600/60 px-3 py-6 text-center text-xs text-slate-500";
606
- empty.textContent = filterActive ? "該当するカードがありません" : "カードなし";
607
- empty.setAttribute("aria-hidden", "true");
608
- return empty;
609
- }
610
-
611
- function updateCounters(filterActive, filteredTasks) {
612
- counters.forEach((counter) => {
613
- const columnKey = counter.dataset.counter;
614
- const totalCount = tasks.filter((task) => task.status === columnKey).length;
615
- const visibleCount = filterActive
616
- ? filteredTasks.filter((task) => task.status === columnKey).length
617
- : totalCount;
618
-
619
- counter.textContent = filterActive ? `${visibleCount}/${totalCount}` : String(totalCount);
620
-
621
- const parentArticle = counter.closest("article");
622
- if (!parentArticle) return;
623
-
624
- const limit = columnSettings[columnKey]?.wip;
625
- const isLimited = Number.isFinite(limit);
626
- parentArticle.classList.remove("border", "border-kanban-caution");
627
- if (isLimited && totalCount >= limit) {
628
- parentArticle.classList.add("border", "border-kanban-caution");
629
- }
630
- });
631
- }
632
-
633
- function renderMetrics(filteredTasks, filterActive) {
634
- const activeStatuses = new Set(["ready", "inProgress", "waiting"]);
635
-
636
- const visibleTasks = filterActive ? filteredTasks : tasks;
637
- const totalTasks = tasks.length;
638
- const visibleTotal = visibleTasks.length;
639
-
640
- const totalDisplay = filterActive
641
- ? formatFilteredCount(visibleTotal, totalTasks)
642
- : formatCount(totalTasks);
643
-
644
- const totalActive = tasks.filter((task) => activeStatuses.has(task.status)).length;
645
- const visibleActive = visibleTasks.filter((task) => activeStatuses.has(task.status)).length;
646
- const activeDisplay = filterActive
647
- ? formatFilteredCount(visibleActive, totalActive)
648
- : formatCount(totalActive);
649
-
650
- const now = new Date();
651
- const sevenDaysAgo = new Date(now);
652
- sevenDaysAgo.setDate(now.getDate() - 7);
653
-
654
- const totalDone = tasks.filter((task) => task.status === "done");
655
- const visibleDone = visibleTasks.filter((task) => task.status === "done");
656
-
657
- const totalThroughput7 = totalDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
658
- const visibleThroughput7 = visibleDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
659
- const throughputDisplay = filterActive
660
- ? formatFilteredCount(visibleThroughput7, totalThroughput7)
661
- : formatCount(totalThroughput7);
662
-
663
- const totalLeadAvg = computeAverageLead(totalDone);
664
- const visibleLeadAvg = computeAverageLead(visibleDone);
665
- const leadDisplay = filterActive
666
- ? formatLeadFiltered(visibleLeadAvg, totalLeadAvg)
667
- : formatLeadFiltered(totalLeadAvg, null);
668
-
669
- setMetric("total", totalDisplay);
670
- setMetric("active", activeDisplay);
671
- setMetric("throughput7", throughputDisplay);
672
- setMetric("leadtime", leadDisplay);
673
- }
674
-
675
- function computeAverageLead(doneTasks) {
676
- const leadTimes = doneTasks
677
- .map((task) => computeLeadTimeDays(task))
678
- .filter((value) => Number.isFinite(value));
679
- if (!leadTimes.length) return null;
680
- const total = leadTimes.reduce((sum, value) => sum + value, 0);
681
- return total / leadTimes.length;
682
- }
683
-
684
- function formatLeadFiltered(primary, overall) {
685
- const base = Number.isFinite(primary) ? `${primary.toFixed(1)}日` : "--";
686
- if (overall === null || overall === undefined) {
687
- return base;
688
- }
689
- const overallText = Number.isFinite(overall) ? `${overall.toFixed(1)}日` : "--";
690
- return `${base} / 全${overallText}`;
691
- }
692
-
693
- function setMetric(key, text) {
694
- const target = metricElements[key];
695
- if (!target) return;
696
- target.textContent = text;
697
- }
698
-
699
- function formatCount(count) {
700
- return `${count}枚`;
701
- }
702
-
703
- function formatFilteredCount(visible, total) {
704
- return `${formatCount(visible)} / 全${formatCount(total)}`;
705
- }
706
-
707
- function isCompletedSince(task, threshold) {
708
- if (!task.completedAt) return false;
709
- const completed = new Date(task.completedAt);
710
- return !Number.isNaN(completed.getTime()) && completed >= threshold;
711
- }
712
-
713
- function computeLeadTimeDays(task) {
714
- if (!task?.createdAt || !task?.completedAt) return Number.NaN;
715
- const start = new Date(task.createdAt);
716
- const end = new Date(task.completedAt);
717
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return Number.NaN;
718
- const diffMs = end.getTime() - start.getTime();
719
- if (diffMs < 0) return Number.NaN;
720
- return diffMs / (1000 * 60 * 60 * 24);
721
- }
722
-
723
- function setDropzoneHighlight(zone, state) {
724
- zone.classList.remove("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "ring-kanban-caution", "bg-slate-800/40", "bg-slate-900/40");
725
- if (state === "allowed") {
726
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "bg-slate-800/40");
727
- } else if (state === "denied") {
728
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-caution", "bg-slate-900/40");
729
- }
730
- }
731
-
732
- function canEnterColumn(columnKey, movingTaskId) {
733
- const settingsColumn = columnSettings[columnKey];
734
- if (!settingsColumn) return true;
735
- if (!Number.isFinite(settingsColumn.wip)) return true;
736
- const current = tasks.filter((task) => task.status === columnKey && task.id !== movingTaskId).length;
737
- return current < settingsColumn.wip;
738
- }
739
-
740
- function isFilterActive() {
741
- return (
742
- Boolean(filterState.keyword) ||
743
- Boolean(filterState.dueStart) ||
744
- Boolean(filterState.dueEnd) ||
745
- filterState.tags.size > 0
746
- );
747
- }
748
-
749
- function getFilteredTasks() {
750
- if (!isFilterActive()) return tasks;
751
- const keyword = filterState.keyword.toLowerCase();
752
- const startDate = parseISODate(filterState.dueStart);
753
- const endDate = parseISODate(filterState.dueEnd);
754
-
755
- return tasks.filter((task) => {
756
- const keywordMatch = keyword
757
- ? [task.title, task.owner, task.notes, task.due]
758
- .filter(Boolean)
759
- .some((value) => String(value).toLowerCase().includes(keyword))
760
- : true;
761
-
762
- const tagsMatch = filterState.tags.size
763
- ? [...filterState.tags].every((tag) => Array.isArray(task.tags) && task.tags.includes(tag))
764
- : true;
765
-
766
- const dueMatch = (() => {
767
- if (!startDate && !endDate) return true;
768
- if (!task.due) return false;
769
- const dueDate = parseISODate(task.due);
770
- if (!dueDate) return false;
771
- if (startDate && dueDate < startDate) return false;
772
- if (endDate && dueDate > endDate) return false;
773
- return true;
774
- })();
775
-
776
- return keywordMatch && tagsMatch && dueMatch;
777
- });
778
- }
779
-
780
- function renderTagFilters() {
781
- if (!filterElements.tagList) return;
782
-
783
- const keyword = filterState.keyword.trim().toLowerCase();
784
- const startDate = parseISODate(filterState.dueStart);
785
- const endDate = parseISODate(filterState.dueEnd);
786
-
787
- const sourceTasks = tasks.filter((task) => {
788
- const keywordMatch = keyword
789
- ? [task.title, task.owner, task.notes, task.due]
790
- .filter(Boolean)
791
- .some((value) => String(value).toLowerCase().includes(keyword))
792
- : true;
793
-
794
- const dueMatch = (() => {
795
- if (!startDate && !endDate) return true;
796
- if (!task.due) return false;
797
- const dueDate = parseISODate(task.due);
798
- if (!dueDate) return false;
799
- if (startDate && dueDate < startDate) return false;
800
- if (endDate && dueDate > endDate) return false;
801
- return true;
802
- })();
803
-
804
- return keywordMatch && dueMatch;
805
- });
806
-
807
- const allTags = Array.from(
808
- new Set(
809
- sourceTasks
810
- .flatMap((task) => (Array.isArray(task.tags) ? task.tags : []))
811
- .map((tag) => tag.trim())
812
- .filter(Boolean)
813
- )
814
- ).sort((a, b) => a.localeCompare(b, 'ja'));
815
-
816
- const validTags = new Set(allTags);
817
- let removed = false;
818
- [...filterState.tags].forEach((tag) => {
819
- if (!validTags.has(tag)) {
820
- filterState.tags.delete(tag);
821
- removed = true;
822
- }
823
- });
824
- if (removed && filterElements.keyword && !isFilterActive()) {
825
- filterElements.keyword.value = filterState.keyword;
826
- }
827
-
828
- filterElements.tagList.replaceChildren();
829
-
830
- if (!allTags.length) {
831
- const empty = document.createElement('p');
832
- empty.className = 'text-xs text-slate-500';
833
- empty.textContent = 'タグはまだ登録されていません。';
834
- filterElements.tagList.appendChild(empty);
835
- return;
836
- }
837
-
838
- allTags.forEach((tag) => {
839
- const button = document.createElement('button');
840
- button.type = 'button';
841
- button.dataset.tagValue = tag;
842
- button.textContent = `#${tag}`;
843
- button.className = buildTagFilterClass(filterState.tags.has(tag));
844
- filterElements.tagList.appendChild(button);
845
- });
846
- }
847
-
848
- function buildTagFilterClass(isActive) {
849
- const base = "rounded-full border px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring focus-visible:ring-kanban-accent focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900";
850
- return isActive
851
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
852
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300 hover:border-slate-400 hover:text-white`;
853
- }
854
-
855
- function buildTagPillClass(isActive) {
856
- const base = "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium";
857
- return isActive
858
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
859
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300`;
860
- }
861
-
862
- function cleanupSelection() {
863
- const validIds = new Set(tasks.map((task) => task.id));
864
- let changed = false;
865
- selectionState.ids.forEach((id) => {
866
- if (!validIds.has(id)) {
867
- selectionState.ids.delete(id);
868
- changed = true;
869
- }
870
- });
871
- if (changed) {
872
- updateSelectionUI();
873
- syncSelectionCheckboxes();
874
- }
875
- }
876
-
877
- function toggleSelection(taskId, isSelected) {
878
- if (isSelected) {
879
- selectionState.ids.add(taskId);
880
- } else {
881
- selectionState.ids.delete(taskId);
882
- }
883
- updateSelectionUI();
884
- syncSelectionCheckboxes();
885
- }
886
-
887
- function selectAllVisible() {
888
- if (!selectionState.lastVisibleIds.length) return;
889
- selectionState.lastVisibleIds.forEach((id) => selectionState.ids.add(id));
890
- updateSelectionUI();
891
- syncSelectionCheckboxes();
892
- }
893
-
894
- function clearSelection() {
895
- selectionState.ids.clear();
896
- updateSelectionUI();
897
- syncSelectionCheckboxes();
898
- }
899
-
900
- function syncSelectionCheckboxes() {
901
- const checkboxes = document.querySelectorAll("[data-action=\"select-task\"]");
902
- checkboxes.forEach((checkbox) => {
903
- const card = checkbox.closest("[data-task-id]");
904
- if (!card) return;
905
- const isSelected = selectionState.ids.has(card.dataset.taskId);
906
- checkbox.checked = isSelected;
907
- card.classList.toggle("kanban-card-selected", isSelected);
908
- });
909
- }
910
-
911
- function updateSelectionUI() {
912
- const count = selectionState.ids.size;
913
- if (selectionElements.counter) {
914
- selectionElements.counter.textContent = `選択中: ${count}枚`;
915
- }
916
- if (selectionElements.delete) {
917
- selectionElements.delete.disabled = count === 0;
918
- selectionElements.delete.setAttribute("aria-disabled", count === 0 ? "true" : "false");
919
- }
920
- if (selectionElements.clear) {
921
- selectionElements.clear.disabled = count === 0;
922
- }
923
- if (selectionElements.selectAll) {
924
- selectionElements.selectAll.disabled = selectionState.lastVisibleIds.length === 0;
925
- }
926
- }
927
-
928
- function deleteTask(taskId) {
929
- const task = tasks.find((item) => item.id === taskId);
930
- if (!task) return;
931
- if (!window.confirm("このタスクを削除しますか?")) return;
932
- selectionState.ids.delete(taskId);
933
- tasks = tasks.filter((item) => item.id !== taskId);
934
- persist();
935
- render();
936
- showMessage("タスクを削除しました。", "info");
937
- }
938
-
939
- function deleteSelectedTasks() {
940
- const ids = [...selectionState.ids];
941
- if (!ids.length) return;
942
- if (!window.confirm(`選択中の${ids.length}件を削除しますか?`)) return;
943
- const idSet = new Set(ids);
944
- tasks = tasks.filter((task) => !idSet.has(task.id));
945
- selectionState.ids.clear();
946
- persist();
947
- render();
948
- showMessage("選択したタスクを削除しました。", "info");
949
- }
950
- function loadFromStorage() {
951
- try {
952
- const raw = window.localStorage.getItem(STORAGE_KEY);
953
- if (!raw) return;
954
- const parsed = JSON.parse(raw);
955
- if (!Array.isArray(parsed)) return;
956
-
957
- tasks = parsed
958
- .filter((item) => typeof item === "object" && item !== null && item.id && item.title)
959
- .map((item) => normalizeTask(item));
960
- } catch (error) {
961
- console.warn("Failed to load tasks from localStorage:", error);
962
- tasks = [];
963
- }
964
- }
965
-
966
- function normalizeTask(item) {
967
- const status = columnSettings[item.status] ? item.status : "backlog";
968
- const createdAt = item.createdAt ?? new Date().toISOString();
969
- const normalized = {
970
- id: item.id,
971
- title: item.title,
972
- owner: item.owner ?? "",
973
- notes: item.notes ?? "",
974
- status,
975
- due: item.due ?? "",
976
- createdAt,
977
- tags: normalizeTags(item.tags),
978
- link: normalizeLink(item.link ?? item.url ?? "")
979
- };
980
-
981
- if (status === "done") {
982
- normalized.completedAt = item.completedAt ?? item.updatedAt ?? createdAt;
983
- } else if (item.completedAt) {
984
- normalized.completedAt = item.completedAt;
985
- }
986
-
987
- return normalized;
988
- }
989
-
990
- function normalizeTags(value) {
991
- if (Array.isArray(value)) {
992
- return parseTags(value.join(","));
993
- }
994
- if (typeof value === "string") {
995
- return parseTags(value);
996
- }
997
- return [];
998
- }
999
-
1000
- function persist() {
1001
- try {
1002
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
1003
- } catch (error) {
1004
- console.warn("Failed to write tasks to localStorage:", error);
1005
- showMessage("ローカルストレージに保存できませんでした。", "warn");
1006
- }
1007
- }
1008
-
1009
- function showMessage(message, tone = "info") {
1010
- if (!messageArea) return;
1011
- clearTimeout(messageTimer);
1012
- messageArea.textContent = message;
1013
- messageArea.classList.remove("opacity-0", "text-kanban-caution", "text-sky-300");
1014
- messageArea.classList.add(tone === "warn" ? "text-kanban-caution" : "text-sky-300", "opacity-100");
1015
- messageTimer = window.setTimeout(() => hideMessage(), 4200);
1016
- }
1017
-
1018
- function hideMessage() {
1019
- if (!messageArea) return;
1020
- messageArea.classList.add("opacity-0");
1021
- }
1022
-
1023
- function seedDemoData() {
1024
- const now = new Date();
1025
- const demoCards = [
1026
- {
1027
- title: "前日の不具合整理",
1028
- owner: "田中",
1029
- notes: "Eラインの停止要因を5Whyで再整理",
1030
- status: "backlog",
1031
- due: isoDateOffset(now, 1),
1032
- tags: ["品質", "改善"],
1033
- link: "https://example.com/quality-review"
1034
- },
1035
- {
1036
- title: "朝会ファシリテーション",
1037
- owner: "佐藤",
1038
- notes: "指標ボード更新と本日の重点テーマ共有",
1039
- status: "ready",
1040
- due: isoDateOffset(now, 0),
1041
- tags: ["コミュニケーション"],
1042
- link: "https://example.com/daily-meeting"
1043
- },
1044
- {
1045
- title: "標準作業票の更新",
1046
- owner: "鈴木",
1047
- notes: "工程C-12の新治具対応版。現場レビュー待ち。",
1048
- status: "waiting",
1049
- due: isoDateOffset(now, 3),
1050
- tags: ["標準化", "改善"],
1051
- link: "https://example.com/standard-work"
1052
- },
1053
- {
1054
- title: "トレーニング計画ドラフト",
1055
- owner: "山本",
1056
- notes: "新入社員向け。ヒアリング結果を反映中。",
1057
- status: "inProgress",
1058
- due: isoDateOffset(now, 5),
1059
- tags: ["人材育成"],
1060
- link: "https://example.com/training-plan"
1061
- },
1062
- {
1063
- title: "品質ロス分析",
1064
- owner: "石井",
1065
- notes: "先週分のデータを取り込み済み。残りは原因記入。",
1066
- status: "ready",
1067
- due: isoDateOffset(now, 2),
1068
- tags: ["品質", "分析"],
1069
- link: "https://example.com/quality-loss"
1070
- },
1071
- {
1072
- title: "安全KY実施",
1073
- owner: "加藤",
1074
- notes: "夜勤チームへ展開済み。記録は箱に保管。",
1075
- status: "done",
1076
- due: isoDateOffset(now, -1),
1077
- tags: ["安全", "教育"],
1078
- link: "https://example.com/safety-ky"
1079
- }
1080
- ];
1081
-
1082
- demoCards.forEach((card, index) => {
1083
- const desiredStatus = card.status;
1084
- const status = canEnterColumn(desiredStatus) ? desiredStatus : "backlog";
1085
- const createdAt = new Date(now.getTime() + index * 1000);
1086
- const entry = {
1087
- id: createId(),
1088
- title: card.title,
1089
- owner: card.owner,
1090
- notes: card.notes,
1091
- status,
1092
- due: card.due,
1093
- createdAt: createdAt.toISOString(),
1094
- tags: parseTags(card.tags?.join(",") ?? ""),
1095
- link: normalizeLink(card.link ?? "")
1096
- };
1097
-
1098
- if (status === "done") {
1099
- entry.completedAt = new Date(createdAt.getTime() + 60 * 60 * 1000).toISOString();
1100
- }
1101
-
1102
- tasks.push(entry);
1103
- });
1104
-
1105
- persist();
1106
- render();
1107
- showMessage("デモデータを追加しました。", "info");
1108
- }
1109
-
1110
- function isoDateOffset(date, offsetDays) {
1111
- const clone = new Date(date);
1112
- clone.setDate(clone.getDate() + offsetDays);
1113
- return clone.toISOString().slice(0, 10);
1114
- }
1115
-
1116
- function formatDue(value) {
1117
- if (!value) return "未設定";
1118
- try {
1119
- const date = new Date(value);
1120
- if (Number.isNaN(date.getTime())) return "未設定";
1121
- return `${date.getFullYear()}年${padZero(date.getMonth() + 1)}月${padZero(date.getDate())}日`;
1122
- } catch (_error) {
1123
- return "未設定";
1124
- }
1125
- }
1126
-
1127
- function padZero(num) {
1128
- return String(num).padStart(2, "0");
1129
- }
1130
-
1131
- function createId() {
1132
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1133
- return crypto.randomUUID();
1134
- }
1135
- return `task-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
1136
- }
1137
-
1138
- function openEditDialog(taskId) {
1139
- const task = tasks.find((item) => item.id === taskId);
1140
- if (!task) return;
1141
-
1142
- if (!editDialog || !editForm || !editFields) {
1143
- fallbackEdit(task);
1144
- return;
1145
- }
1146
-
1147
- editingTaskId = taskId;
1148
- editFields.title.value = task.title;
1149
- editFields.owner.value = task.owner ?? "";
1150
- editFields.due.value = task.due ?? "";
1151
- editFields.notes.value = task.notes ?? "";
1152
- if (editFields.tags) {
1153
- editFields.tags.value = Array.isArray(task.tags) ? task.tags.join(", ") : "";
1154
- }
1155
- editFields.link.value = task.link ?? "";
1156
-
1157
- try {
1158
- if (typeof editDialog.showModal === "function") {
1159
- editDialog.showModal();
1160
- } else {
1161
- editDialog.setAttribute("open", "true");
1162
- }
1163
- window.setTimeout(() => editFields.title.focus(), 0);
1164
- } catch (error) {
1165
- console.warn("Failed to open dialog, using fallback.", error);
1166
- fallbackEdit(task);
1167
- }
1168
- }
1169
-
1170
- function handleEditSubmit(event) {
1171
- event.preventDefault();
1172
- if (!editFields || !editingTaskId) return;
1173
-
1174
- const title = editFields.title.value.trim();
1175
- if (!title) {
1176
- showMessage("タイトルを入力してください。", "warn");
1177
- editFields.title.focus();
1178
- return;
1179
- }
1180
-
1181
- const updates = {
1182
- title,
1183
- owner: editFields.owner.value.trim(),
1184
- due: editFields.due.value,
1185
- notes: editFields.notes.value.trim(),
1186
- tags: parseTags(editFields.tags ? editFields.tags.value : ""),
1187
- link: normalizeLink(editFields.link?.value ?? "")
1188
- };
1189
-
1190
- applyTaskUpdates(editingTaskId, updates);
1191
- closeEditDialog();
1192
- }
1193
-
1194
- function applyTaskUpdates(taskId, updates) {
1195
- const task = tasks.find((item) => item.id === taskId);
1196
- if (!task) return false;
1197
-
1198
- task.title = updates.title;
1199
- task.owner = updates.owner;
1200
- task.due = updates.due;
1201
- task.notes = updates.notes;
1202
- task.tags = updates.tags;
1203
- task.link = updates.link;
1204
-
1205
- persist();
1206
- render();
1207
- showMessage("タスクを更新しました。", "info");
1208
- return true;
1209
- }
1210
-
1211
- function closeEditDialog(fromEvent = false) {
1212
- if (!editDialog) return;
1213
-
1214
- if (!fromEvent) {
1215
- if (typeof editDialog.close === "function" && editDialog.open) {
1216
- editDialog.close();
1217
- } else {
1218
- editDialog.removeAttribute("open");
1219
- }
1220
- } else {
1221
- editDialog.removeAttribute("open");
1222
- }
1223
-
1224
- resetEditingState();
1225
- }
1226
-
1227
- function resetEditingState() {
1228
- editingTaskId = null;
1229
- editForm?.reset();
1230
- }
1231
-
1232
- function fallbackEdit(task) {
1233
- const title = window.prompt("タイトルを編集", task.title);
1234
- if (title === null) return;
1235
- const trimmedTitle = title.trim();
1236
- if (!trimmedTitle) {
1237
- showMessage("タイトルを入力してください。", "warn");
1238
- return;
1239
- }
1240
-
1241
- const owner = window.prompt("担当を編集", task.owner ?? "") ?? task.owner ?? "";
1242
- const due = window.prompt("期限 (YYYY-MM-DD)", task.due ?? "") ?? task.due ?? "";
1243
- const notes = window.prompt("メモ", task.notes ?? "") ?? task.notes ?? "";
1244
- const tagsInput = window.prompt("タグ(カンマ区切り)", Array.isArray(task.tags) ? task.tags.join(", ") : "") ?? (Array.isArray(task.tags) ? task.tags.join(", ") : "");
1245
- const linkInput = window.prompt("参考リンク(URL)", task.link ?? "") ?? (task.link ?? "");
1246
-
1247
- applyTaskUpdates(task.id, {
1248
- title: trimmedTitle,
1249
- owner: owner.trim(),
1250
- due,
1251
- notes: notes.trim(),
1252
- tags: parseTags(tagsInput),
1253
- link: normalizeLink(linkInput)
1254
- });
1255
- }
1256
-
1257
- function toggleViewMode() {
1258
- uiState.compact = !uiState.compact;
1259
- updateViewToggleLabel();
1260
- render();
1261
- showMessage(uiState.compact ? "コンパクト表示に切り替えました。" : "標準表示に戻しました。", "info");
1262
- }
1263
-
1264
- function updateViewToggleLabel() {
1265
- if (!uiElements.viewToggle) return;
1266
- uiElements.viewToggle.textContent = uiState.compact ? "標準表示" : "コンパクト表示";
1267
- }
1268
-
1269
- function parseISODate(value) {
1270
- if (!value) return null;
1271
- try {
1272
- const date = new Date(value);
1273
- return Number.isNaN(date.getTime()) ? null : new Date(date.getFullYear(), date.getMonth(), date.getDate());
1274
- } catch (_error) {
1275
- return null;
1276
- }
1277
- }
1278
-
1279
- function formatLinkLabel(url) {
1280
- try {
1281
- const parsed = new URL(url);
1282
- const path = parsed.pathname === "/" ? "" : parsed.pathname;
1283
- return `${parsed.hostname}${path}`;
1284
- } catch (_error) {
1285
- return url;
1286
- }
1287
- }
1288
-
1289
- function normalizeLink(input) {
1290
- if (!input) return "";
1291
- let raw0 = String(input).trim();
1292
- if (!raw0) return "";
1293
-
1294
- // ✅ 追加: 先頭・末尾のダブルクォーテーションを削除
1295
- raw0 = raw0.replace(/^"(.*)"$/, "$1");
1296
-
1297
- // 全角の「¥」をバックスラッシュに誤入力しているケースを補正
1298
- const raw = raw0.replace(/¥/g, "\\");
1299
-
1300
- const ensureDirSlash = (u) => {
1301
- try {
1302
- const url = new URL(u);
1303
- if (url.protocol !== "file:") return u;
1304
- if (url.pathname.endsWith("/")) return u;
1305
-
1306
- const last = decodeURI(url.pathname).split("/").pop() || "";
1307
- const looksLikeFile = /\.[A-Za-z0-9]{1,6}$/.test(last);
1308
- return looksLikeFile ? u : (u + "/");
1309
- } catch {
1310
- return u;
1311
- }
1312
- };
1313
-
1314
- // 1) Windows ドライブパス: C:\... → file:///C:/...
1315
- if (/^[a-zA-Z]:\\/.test(raw)) {
1316
- const path = raw.replace(/\\/g, "/");
1317
- return ensureDirSlash(`file:///${encodeURI(path)}`);
1318
- }
1319
-
1320
- // 2) UNC: \\server\share\path → file://server/share/path
1321
- if (/^\\\\[^\\]/.test(raw)) {
1322
- const path = raw.replace(/^\\\\/, "").replace(/\\/g, "/");
1323
- return ensureDirSlash(`file://${encodeURI(path)}`);
1324
- }
1325
-
1326
- // 3) 明示スキームあり(http/https/file)
1327
- const hasScheme = /^[a-zA-Z][\\w+.-]*:/.test(raw);
1328
- if (hasScheme) {
1329
- try {
1330
- const url = new URL(raw);
1331
- if (/^https?:$/i.test(url.protocol)) return url.href;
1332
- if (/^file:$/i.test(url.protocol)) return ensureDirSlash(url.href);
1333
- return "";
1334
- } catch {
1335
- return "";
1336
- }
1337
- }
1338
-
1339
- // 4) スキーム無し → Webドメインらしければ https:// を補完
1340
- try {
1341
- const candidate = `https://${raw}`;
1342
- const url = new URL(candidate);
1343
- return url.href;
1344
- } catch {
1345
- return "";
1346
- }
1347
- }
1348
-
1349
-
1350
- function parseTags(input) {
1351
- if (!input) return [];
1352
- const parts = Array.isArray(input) ? input : String(input).split(/[,、\s]+/);
1353
- const seen = new Set();
1354
- const result = [];
1355
- parts
1356
- .map((item) => String(item).trim())
1357
- .filter(Boolean)
1358
- .forEach((tag) => {
1359
- const cleaned = tag.replace(/^#/, "").trim();
1360
- if (!cleaned) return;
1361
- const key = cleaned.toLowerCase();
1362
- if (seen.has(key)) return;
1363
- seen.add(key);
1364
- result.push(cleaned);
1365
- });
1366
- return result;
1367
- }
1368
- })();
1369
-
1370
-
1371
-
1372
-
1373
-
1374
-
1375
-
1376
-
1377
-
1378
-
1379
-
1380
-
1381
-
1382
-
1383
-
1384
-
1385
-
1386
-
1387
-
1388
-
1389
-
1390
-
1391
-
1392
-
1393
-
1394
-
1395
-
1396
-
1397
-
1398
-
1399
-
1400
-
1401
-
1402
-
1403
-
1404
-
1405
-
1406
-
1407
-
1408
-
1409
-
1410
-
1411
-
1412
-
1413
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 
2
+ (() => {
3
+ "use strict";
4
+
5
+ const STORAGE_KEY = "toyota-kanban-tasks-v1";
6
+ const SETTINGS_KEY = "toyota-kanban-settings-v1";
7
+ const DEFAULT_TITLE = "トヨタ式カンバンボード";
8
+ const PANEL_KEYS = ["analytics", "filters", "form"];
9
+
10
+ const columnSettings = {
11
+ backlog: { wip: Number.POSITIVE_INFINITY, label: "バックログ" },
12
+ ready: { wip: 5, label: "準備中" },
13
+ inProgress: { wip: 3, label: "仕掛中" },
14
+ waiting: { wip: 2, label: "停止中" },
15
+ done: { wip: Number.POSITIVE_INFINITY, label: "完了" }
16
+ };
17
+
18
+ const form = document.getElementById("task-form");
19
+ const fields = {
20
+ title: document.getElementById("task-title"),
21
+ owner: document.getElementById("task-owner"),
22
+ due: document.getElementById("task-due"),
23
+ status: document.getElementById("task-status"),
24
+ notes: document.getElementById("task-notes"),
25
+ tags: document.getElementById("task-tags"),
26
+ link: document.getElementById("task-link")
27
+ };
28
+ const messageArea = document.getElementById("message-area");
29
+ const seedButton = document.getElementById("seed-demo");
30
+ const template = document.getElementById("task-template");
31
+ const dropzones = Array.from(document.querySelectorAll("[data-dropzone]"));
32
+ const counters = Array.from(document.querySelectorAll("[data-counter]"));
33
+ const metricElements = {
34
+ total: document.querySelector("[data-metric=\"total\"]"),
35
+ active: document.querySelector("[data-metric=\"active\"]"),
36
+ throughput7: document.querySelector("[data-metric=\"throughput7\"]"),
37
+ leadtime: document.querySelector("[data-metric=\"leadtime\"]")
38
+ };
39
+ const editDialog = document.getElementById("edit-dialog");
40
+ const editForm = document.getElementById("edit-form");
41
+ const editFields = editForm
42
+ ? {
43
+ title: document.getElementById("edit-title"),
44
+ owner: document.getElementById("edit-owner"),
45
+ due: document.getElementById("edit-due"),
46
+ notes: document.getElementById("edit-notes"),
47
+ tags: document.getElementById("edit-tags"),
48
+ link: document.getElementById("edit-link")
49
+ }
50
+ : null;
51
+
52
+ const boardTitleHeading = document.getElementById("board-title-heading");
53
+ const titleEditButton = document.getElementById("title-edit");
54
+ const panelSections = Array.from(document.querySelectorAll("[data-panel]"));
55
+ const panels = new Map(panelSections.map((section) => [section.dataset.panel, section]));
56
+ const panelToggleButtons = Array.from(document.querySelectorAll("[data-action=\"toggle-panel\"]"));
57
+
58
+ const filterElements = {
59
+ keyword: document.getElementById("filter-keyword"),
60
+ dueStart: document.getElementById("filter-due-start"),
61
+ dueEnd: document.getElementById("filter-due-end"),
62
+ clear: document.getElementById("filter-clear"),
63
+ tagList: document.getElementById("filter-tag-list")
64
+ };
65
+ const selectionElements = {
66
+ counter: document.getElementById("selection-counter"),
67
+ hint: document.getElementById("selection-hint"),
68
+ selectAll: document.getElementById("selection-selectall"),
69
+ clear: document.getElementById("selection-clear"),
70
+ delete: document.getElementById("selection-delete")
71
+ };
72
+ const dataTransferElements = {
73
+ exportButton: document.getElementById("data-export"),
74
+ importButton: document.getElementById("data-import"),
75
+ importInput: document.getElementById("data-import-input")
76
+ };
77
+
78
+ const uiElements = {
79
+ viewToggle: document.getElementById("view-toggle")
80
+ };
81
+
82
+ const filterState = {
83
+ keyword: "",
84
+ dueStart: "",
85
+ dueEnd: "",
86
+ tags: new Set()
87
+ };
88
+
89
+ const selectionState = {
90
+ ids: new Set(),
91
+ lastVisibleIds: []
92
+ };
93
+
94
+ const uiState = {
95
+ compact: false
96
+ };
97
+
98
+ let settings = createDefaultSettings();
99
+ let tasks = [];
100
+ let messageTimer = null;
101
+ let draggingTaskId = null;
102
+ let editingTaskId = null;
103
+
104
+ init();
105
+
106
+ function createDefaultSettings() {
107
+ return {
108
+ title: DEFAULT_TITLE,
109
+ panels: PANEL_KEYS.reduce((acc, key) => {
110
+ acc[key] = false;
111
+ return acc;
112
+ }, {})
113
+ };
114
+ }
115
+
116
+ function init() {
117
+ loadSettings();
118
+ applyTitle();
119
+ applyPanelStates({ initial: true });
120
+ loadFromStorage();
121
+ attachFormEvents();
122
+ attachDropzoneEvents();
123
+ attachSeedButton();
124
+ attachEditForm();
125
+ attachFilterHandlers();
126
+ attachSelectionHandlers();
127
+ attachDataTransferHandlers();
128
+ attachViewHandlers();
129
+ attachPanelHandlers();
130
+ attachTitleEditor();
131
+ form?.addEventListener("reset", () => hideMessage());
132
+ updateViewToggleLabel();
133
+ updateSelectionUI();
134
+ render();
135
+ }
136
+
137
+ function loadSettings() {
138
+ try {
139
+ const raw = window.localStorage.getItem(SETTINGS_KEY);
140
+ if (raw) {
141
+ const parsed = JSON.parse(raw);
142
+ if (parsed && typeof parsed === "object") {
143
+ settings = {
144
+ title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim() : DEFAULT_TITLE,
145
+ panels: { ...createDefaultSettings().panels, ...(parsed.panels || {}) }
146
+ };
147
+ return;
148
+ }
149
+ }
150
+ } catch (error) {
151
+ console.warn("Failed to load settings:", error);
152
+ }
153
+ settings = createDefaultSettings();
154
+ }
155
+
156
+ function saveSettings() {
157
+ try {
158
+ window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
159
+ } catch (error) {
160
+ console.warn("Failed to save settings:", error);
161
+ }
162
+ }
163
+
164
+ function applyTitle() {
165
+ if (boardTitleHeading) {
166
+ boardTitleHeading.textContent = settings.title;
167
+ }
168
+ document.title = settings.title;
169
+ }
170
+
171
+ function attachTitleEditor() {
172
+ titleEditButton?.addEventListener("click", handleTitleEdit);
173
+ }
174
+
175
+ function handleTitleEdit() {
176
+ const current = settings.title || DEFAULT_TITLE;
177
+ const next = window.prompt("ボードタイトルを入力してください。", current);
178
+ if (next === null) return;
179
+ const trimmed = next.trim();
180
+ settings.title = trimmed || DEFAULT_TITLE;
181
+ applyTitle();
182
+ saveSettings();
183
+ showMessage("タイトルを更新しました。", "info");
184
+ }
185
+
186
+ function attachPanelHandlers() {
187
+ panelToggleButtons.forEach((button) => {
188
+ button.addEventListener("click", () => {
189
+ const key = button.dataset.target;
190
+ const section = panels.get(key);
191
+ if (!key || !section) return;
192
+ const willCollapse = !section.classList.contains("panel-collapsed");
193
+ setPanelCollapsed(key, willCollapse);
194
+ });
195
+ });
196
+ }
197
+
198
+ function applyPanelStates({ initial = false } = {}) {
199
+ PANEL_KEYS.forEach((key) => {
200
+ const collapsed = Boolean(settings.panels[key]);
201
+ setPanelCollapsed(key, collapsed, { save: !initial });
202
+ });
203
+ }
204
+ function setPanelCollapsed(panelKey, collapsed, { save = true } = {}) {
205
+ const section = panels.get(panelKey);
206
+ if (!section) return;
207
+ section.classList.toggle("panel-collapsed", collapsed);
208
+ const body = section.querySelector(`[data-panel-body="${panelKey}"]`);
209
+ if (body) {
210
+ body.hidden = collapsed;
211
+ }
212
+ updatePanelButton(panelKey, collapsed);
213
+ settings.panels[panelKey] = collapsed;
214
+ if (save) {
215
+ saveSettings();
216
+ }
217
+ }
218
+
219
+ function updatePanelButton(panelKey, collapsed) {
220
+ const button = panelToggleButtons.find((item) => item.dataset.target === panelKey);
221
+ if (!button) return;
222
+ button.textContent = collapsed ? "展開する" : "折りたたむ";
223
+ button.setAttribute("aria-expanded", collapsed ? "false" : "true");
224
+ }
225
+
226
+ function attachFormEvents() {
227
+ form?.addEventListener("submit", handleSubmit);
228
+ }
229
+
230
+ function handleSubmit(event) {
231
+ event.preventDefault();
232
+ const rawTitle = fields.title.value.trim();
233
+ const rawOwner = fields.owner.value.trim();
234
+ const rawNotes = fields.notes.value.trim();
235
+ const status = fields.status.value;
236
+ const due = fields.due.value;
237
+ const tags = parseTags(fields.tags?.value ?? "");
238
+ const link = normalizeLink(fields.link?.value ?? "");
239
+
240
+ if (!rawTitle) {
241
+ showMessage("タイトルを入力してください。", "warn");
242
+ fields.title.focus();
243
+ return;
244
+ }
245
+
246
+ if (!canEnterColumn(status)) {
247
+ const limit = columnSettings[status]?.wip;
248
+ const label = columnSettings[status]?.label ?? status;
249
+ showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
250
+ return;
251
+ }
252
+
253
+ const now = new Date();
254
+ const newTask = {
255
+ id: createId(),
256
+ title: rawTitle,
257
+ owner: rawOwner,
258
+ due,
259
+ notes: rawNotes,
260
+ status,
261
+ link,
262
+ createdAt: now.toISOString(),
263
+ tags
264
+ };
265
+
266
+ if (status === "done") {
267
+ newTask.completedAt = now.toISOString();
268
+ }
269
+
270
+ tasks.push(newTask);
271
+ persist();
272
+ render();
273
+ form.reset();
274
+ fields.title.focus();
275
+ showMessage("タスクを登録しました。", "info");
276
+ }
277
+
278
+ function attachDropzoneEvents() {
279
+ dropzones.forEach((zone) => {
280
+ zone.addEventListener("dragover", handleDragOver);
281
+ zone.addEventListener("dragleave", handleDragLeave);
282
+ zone.addEventListener("drop", handleDrop);
283
+ });
284
+ }
285
+
286
+ function attachSeedButton() {
287
+ seedButton?.addEventListener("click", seedDemoData);
288
+ }
289
+
290
+ function attachEditForm() {
291
+ if (!editForm || !editDialog || !editFields) return;
292
+
293
+ editForm.addEventListener("submit", handleEditSubmit);
294
+ const cancelButton = editForm.querySelector("[data-action=\"cancel-edit\"]");
295
+ cancelButton?.addEventListener("click", () => closeEditDialog());
296
+
297
+ editDialog.addEventListener("cancel", () => closeEditDialog(true));
298
+ editDialog.addEventListener("close", () => resetEditingState());
299
+ }
300
+
301
+ function attachFilterHandlers() {
302
+ filterElements.keyword?.addEventListener("input", handleKeywordFilter);
303
+ filterElements.dueStart?.addEventListener("change", handleDueFilter);
304
+ filterElements.dueEnd?.addEventListener("change", handleDueFilter);
305
+ filterElements.clear?.addEventListener("click", clearFilters);
306
+ filterElements.tagList?.addEventListener("click", handleTagFilterClick);
307
+ }
308
+
309
+ function attachSelectionHandlers() {
310
+ selectionElements.selectAll?.addEventListener("click", selectAllVisible);
311
+ selectionElements.clear?.addEventListener("click", () => {
312
+ clearSelection();
313
+ syncSelectionCheckboxes();
314
+ });
315
+ selectionElements.delete?.addEventListener("click", deleteSelectedTasks);
316
+ }
317
+
318
+ function attachDataTransferHandlers() {
319
+ const { exportButton, importButton, importInput } = dataTransferElements;
320
+ exportButton?.addEventListener("click", handleExportTasks);
321
+ importButton?.addEventListener("click", () => importInput?.click());
322
+ importInput?.addEventListener("change", handleImportFileSelection);
323
+ }
324
+
325
+ function handleExportTasks() {
326
+ if (!tasks.length) {
327
+ showMessage("エクスポートできるカードがありません。", "warn");
328
+ return;
329
+ }
330
+
331
+ try {
332
+ const now = new Date();
333
+ const payload = {
334
+ version: 1,
335
+ exportedAt: now.toISOString(),
336
+ boardTitle: settings.title,
337
+ tasks: tasks.map((task) => ({ ...task }))
338
+ };
339
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
340
+ const filename = `kanban-export-${now.toISOString().replace(/[:.]/g, "-")}.json`;
341
+ const url = URL.createObjectURL(blob);
342
+ const link = document.createElement("a");
343
+ link.href = url;
344
+ link.download = filename;
345
+ document.body.appendChild(link);
346
+ link.click();
347
+ window.setTimeout(() => {
348
+ document.body.removeChild(link);
349
+ URL.revokeObjectURL(url);
350
+ }, 0);
351
+ showMessage("カードデータをエクスポートしました。", "info");
352
+ } catch (error) {
353
+ console.warn("Failed to export tasks:", error);
354
+ showMessage("エクスポートに失敗しました。", "warn");
355
+ }
356
+ }
357
+
358
+ function handleImportFileSelection(event) {
359
+ const input = event.target;
360
+ if (!input?.files?.length) return;
361
+
362
+ const [file] = input.files;
363
+ const reader = new FileReader();
364
+
365
+ reader.addEventListener("load", () => {
366
+ try {
367
+ const imported = parseImportedTasks(reader.result);
368
+ if (!imported.length) {
369
+ showMessage("取り込めるカードが見つかりませんでした。", "warn");
370
+ } else {
371
+ tasks = imported;
372
+ selectionState.ids.clear();
373
+ persist();
374
+ render();
375
+ showMessage(`${imported.length}件のカードをインポートしました。`, "info");
376
+ }
377
+ } catch (error) {
378
+ console.warn("Failed to import tasks:", error);
379
+ showMessage("インポートに失敗しました。", "warn");
380
+ } finally {
381
+ input.value = "";
382
+ }
383
+ });
384
+
385
+ reader.addEventListener("error", () => {
386
+ console.warn("Failed to read import file:", reader.error);
387
+ showMessage("ファイルを読み取れませんでした。", "warn");
388
+ input.value = "";
389
+ });
390
+
391
+ reader.readAsText(file, "utf-8");
392
+ }
393
+
394
+ function parseImportedTasks(raw) {
395
+ if (raw == null) return [];
396
+
397
+ let data;
398
+ try {
399
+ data = JSON.parse(String(raw));
400
+ } catch (error) {
401
+ throw new Error("Invalid JSON");
402
+ }
403
+
404
+ const entries = Array.isArray(data) ? data : Array.isArray(data?.tasks) ? data.tasks : [];
405
+ if (!entries.length) {
406
+ return [];
407
+ }
408
+
409
+ const seenIds = new Set();
410
+ const imported = [];
411
+
412
+ entries.forEach((item) => {
413
+ if (!item || typeof item !== "object") return;
414
+ const candidate = { ...item };
415
+ let id = normalizeImportedId(candidate.id);
416
+ if (!id) {
417
+ id = createId();
418
+ }
419
+ while (seenIds.has(id)) {
420
+ id = createId();
421
+ }
422
+ candidate.id = id;
423
+ const normalized = normalizeTask(candidate);
424
+ if (!normalized.title) return;
425
+ seenIds.add(normalized.id);
426
+ imported.push(normalized);
427
+ });
428
+
429
+ return imported;
430
+ }
431
+
432
+ function normalizeImportedId(value) {
433
+ if (typeof value === "string") {
434
+ const trimmed = value.trim();
435
+ return trimmed ? trimmed : "";
436
+ }
437
+ if (typeof value === "number" && Number.isFinite(value)) {
438
+ return String(value);
439
+ }
440
+ return "";
441
+ }
442
+
443
+ function attachViewHandlers() {
444
+ uiElements.viewToggle?.addEventListener("click", toggleViewMode);
445
+ }
446
+
447
+ function handleKeywordFilter(event) {
448
+ filterState.keyword = event.target.value.trim();
449
+ render();
450
+ }
451
+
452
+ function handleDueFilter() {
453
+ filterState.dueStart = filterElements.dueStart?.value ?? "";
454
+ filterState.dueEnd = filterElements.dueEnd?.value ?? "";
455
+ render();
456
+ }
457
+
458
+ function handleTagFilterClick(event) {
459
+ const button = event.target.closest("[data-tag-value]");
460
+ if (!button) return;
461
+ const tag = button.dataset.tagValue;
462
+ if (!tag) return;
463
+
464
+ if (filterState.tags.has(tag)) {
465
+ filterState.tags.delete(tag);
466
+ } else {
467
+ filterState.tags.add(tag);
468
+ }
469
+ render();
470
+ }
471
+
472
+ function clearFilters() {
473
+ filterState.keyword = "";
474
+ filterState.dueStart = "";
475
+ filterState.dueEnd = "";
476
+ filterState.tags.clear();
477
+ if (filterElements.keyword) {
478
+ filterElements.keyword.value = "";
479
+ }
480
+ if (filterElements.dueStart) {
481
+ filterElements.dueStart.value = "";
482
+ }
483
+ if (filterElements.dueEnd) {
484
+ filterElements.dueEnd.value = "";
485
+ }
486
+ render();
487
+ }
488
+
489
+ function handleDragStart(event) {
490
+ const card = event.currentTarget;
491
+ const taskId = card.dataset.taskId;
492
+ if (!taskId) return;
493
+ draggingTaskId = taskId;
494
+ event.dataTransfer.effectAllowed = "move";
495
+ event.dataTransfer.setData("text/plain", taskId);
496
+ card.classList.add("opacity-40");
497
+ }
498
+
499
+ function handleDragEnd(event) {
500
+ event.currentTarget.classList.remove("opacity-40");
501
+ draggingTaskId = null;
502
+ dropzones.forEach((zone) => setDropzoneHighlight(zone, "none"));
503
+ }
504
+
505
+ function handleDragOver(event) {
506
+ event.preventDefault();
507
+ const zone = event.currentTarget;
508
+ const targetColumn = zone.dataset.dropzone;
509
+ const allowed = canEnterColumn(targetColumn, draggingTaskId);
510
+ event.dataTransfer.dropEffect = allowed ? "move" : "none";
511
+ setDropzoneHighlight(zone, allowed ? "allowed" : "denied");
512
+ }
513
+
514
+ function handleDragLeave(event) {
515
+ setDropzoneHighlight(event.currentTarget, "none");
516
+ }
517
+
518
+ function handleDrop(event) {
519
+ event.preventDefault();
520
+ const zone = event.currentTarget;
521
+ const targetColumn = zone.dataset.dropzone;
522
+ const taskId = draggingTaskId || event.dataTransfer.getData("text/plain");
523
+
524
+ setDropzoneHighlight(zone, "none");
525
+
526
+ if (!taskId) return;
527
+
528
+ if (!canEnterColumn(targetColumn, taskId)) {
529
+ const limit = columnSettings[targetColumn]?.wip;
530
+ const label = columnSettings[targetColumn]?.label ?? targetColumn;
531
+ showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
532
+ return;
533
+ }
534
+
535
+ moveTask(taskId, targetColumn);
536
+ }
537
+
538
+ function moveTask(taskId, targetColumn) {
539
+ const task = tasks.find((item) => item.id === taskId);
540
+ if (!task || task.status === targetColumn) return;
541
+
542
+ task.status = targetColumn;
543
+ if (targetColumn === "done") {
544
+ task.completedAt = new Date().toISOString();
545
+ }
546
+
547
+ persist();
548
+ render();
549
+ showMessage(`タスクを「${columnSettings[targetColumn]?.label ?? targetColumn}」へ移動しました。`, "info");
550
+ }
551
+
552
+ function render() {
553
+ cleanupSelection();
554
+ renderTagFilters();
555
+
556
+ const filterActive = isFilterActive();
557
+ const filteredTasks = getFilteredTasks();
558
+ const tasksToRender = filterActive ? filteredTasks : tasks;
559
+
560
+ selectionState.lastVisibleIds = tasksToRender.map((task) => task.id);
561
+
562
+ dropzones.forEach((zone) => zone.replaceChildren());
563
+
564
+ const byColumn = new Map();
565
+ tasksToRender
566
+ .slice()
567
+ .sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime())
568
+ .forEach((task) => {
569
+ const list = byColumn.get(task.status) ?? [];
570
+ list.push(task);
571
+ byColumn.set(task.status, list);
572
+ });
573
+
574
+ dropzones.forEach((zone) => {
575
+ const columnKey = zone.dataset.dropzone;
576
+ const columnTasks = byColumn.get(columnKey) ?? [];
577
+ columnTasks.forEach((task) => zone.appendChild(buildTaskCard(task)));
578
+ if (!zone.childElementCount) {
579
+ zone.appendChild(createEmptyState(filterActive));
580
+ }
581
+ });
582
+
583
+ updateCounters(filterActive, filteredTasks);
584
+ renderMetrics(filteredTasks, filterActive);
585
+ syncSelectionCheckboxes();
586
+ updateSelectionUI();
587
+ }
588
+ function buildTaskCard(task) {
589
+ const fragment = template.content.cloneNode(true);
590
+ const card = fragment.querySelector("[data-task-id]");
591
+ card.dataset.taskId = task.id;
592
+
593
+ const isSelected = selectionState.ids.has(task.id);
594
+ card.classList.toggle("kanban-card-selected", isSelected);
595
+
596
+
597
+
598
+ const titleEl = card.querySelector("[data-field=\"title\"]");
599
+ const ownerEl = card.querySelector("[data-field=\"owner\"]");
600
+ const dueEl = card.querySelector("[data-field=\"due\"]");
601
+ const notesEl = card.querySelector("[data-field=\"notes\"]");
602
+ const tagsEl = card.querySelector("[data-field=\"tags\"]");
603
+ const tagsSection = card.querySelector("[data-section=\"tags\"]");
604
+ const linkSection = card.querySelector("[data-section=\"link\"]");
605
+ const linkEl = card.querySelector("[data-field=\"link\"]");
606
+ const doneButton = card.querySelector("[data-action=\"mark-done\"]");
607
+ const editButton = card.querySelector("[data-action=\"edit-task\"]");
608
+ const deleteButton = card.querySelector("[data-action=\"delete-task\"]");
609
+ const checkbox = card.querySelector("[data-action=\"select-task\"]");
610
+ const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
611
+
612
+ titleEl.textContent = task.title;
613
+ ownerEl.textContent = task.owner || "未設定";
614
+ dueEl.textContent = formatDue(task.due);
615
+ notesEl.textContent = task.notes || "メモなし";
616
+ notesEl.classList.toggle("text-slate-500", !task.notes);
617
+
618
+ if (tagsEl) {
619
+ tagsEl.replaceChildren();
620
+ const hasTags = Array.isArray(task.tags) && task.tags.length > 0;
621
+ tagsSection?.classList.toggle("hidden", !hasTags);
622
+ tagsSection?.setAttribute("aria-hidden", hasTags ? "false" : "true");
623
+ tagsSection?.setAttribute("data-has-tags", hasTags ? "true" : "false");
624
+ if (hasTags) {
625
+ task.tags.forEach((tag) => {
626
+ const pill = document.createElement("span");
627
+ pill.textContent = `#${tag}`;
628
+ pill.className = buildTagPillClass(filterState.tags.has(tag));
629
+ tagsEl.appendChild(pill);
630
+ });
631
+ }
632
+ }
633
+
634
+ if (linkSection && linkEl) {
635
+ if (task.link) {
636
+ linkSection.classList.remove("hidden");
637
+ linkEl.href = task.link;
638
+ linkEl.textContent = formatLinkLabel(task.link);
639
+ linkEl.title = task.link;
640
+ } else {
641
+ linkSection.classList.add("hidden");
642
+ linkEl.removeAttribute("href");
643
+ linkEl.removeAttribute("title");
644
+ linkEl.textContent = "";
645
+ }
646
+ }
647
+
648
+ if (checkbox) {
649
+ checkbox.checked = isSelected;
650
+ checkbox.addEventListener("click", (event) => event.stopPropagation());
651
+ checkbox.addEventListener("mousedown", (event) => event.stopPropagation());
652
+ checkbox.addEventListener("change", (event) => toggleSelection(task.id, event.target.checked));
653
+ }
654
+
655
+ if (doneButton) {
656
+ const isDone = task.status === "done";
657
+ doneButton.classList.toggle("hidden", isDone);
658
+ doneButton.addEventListener("click", (event) => {
659
+ event.preventDefault();
660
+ event.stopPropagation();
661
+ moveTask(task.id, "done");
662
+ });
663
+ }
664
+
665
+ editButton?.addEventListener("click", (event) => {
666
+ event.preventDefault();
667
+ event.stopPropagation();
668
+ openEditDialog(task.id);
669
+ });
670
+ deleteButton?.addEventListener("click", (event) => {
671
+ event.preventDefault();
672
+ event.stopPropagation();
673
+ deleteTask(task.id);
674
+ });
675
+
676
+ if (toggleDetailsButton) {
677
+ toggleDetailsButton.addEventListener("click", (event) => {
678
+ event.preventDefault();
679
+ event.stopPropagation();
680
+ card.classList.toggle("kanban-card-expanded");
681
+ updateCardDetailVisibility(card);
682
+ });
683
+ }
684
+
685
+ card.addEventListener("dragstart", handleDragStart);
686
+ card.addEventListener("dragend", handleDragEnd);
687
+
688
+ applyCardLayout(card);
689
+
690
+ return card;
691
+ }
692
+
693
+ function applyCardLayout(card) {
694
+ if (!card) return;
695
+ card.classList.toggle("kanban-card-compact", uiState.compact);
696
+ if (!uiState.compact) {
697
+ card.classList.remove("kanban-card-expanded");
698
+ }
699
+ updateCardDetailVisibility(card);
700
+ }
701
+
702
+ function updateCardDetailVisibility(card) {
703
+ const isExpanded = card.classList.contains("kanban-card-expanded");
704
+ const showDetails = !uiState.compact || isExpanded;
705
+
706
+ const meta = card.querySelector("[data-section=\"meta\"]");
707
+ const notes = card.querySelector("[data-section=\"notes\"]");
708
+ const tags = card.querySelector("[data-section=\"tags\"]");
709
+ const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
710
+
711
+ if (meta) {
712
+ meta.classList.toggle("hidden", !showDetails);
713
+ }
714
+ if (notes) {
715
+ notes.classList.toggle("hidden", !showDetails);
716
+ notes.classList.toggle("kanban-note-clamped", uiState.compact && !isExpanded);
717
+ }
718
+
719
+ if (tags) {
720
+ const hasTags = tags && tags.getAttribute("data-has-tags") === "true";
721
+ if (hasTags) {
722
+ tags.classList.toggle("hidden", !showDetails);
723
+ }
724
+ }
725
+ if (toggleDetailsButton) {
726
+ const hasDetails = Boolean((meta && meta.textContent.trim()) || (notes && notes.textContent.trim() && notes.textContent.trim() !== "メモなし"));
727
+ const shouldShowToggle = uiState.compact && hasDetails;
728
+ toggleDetailsButton.classList.toggle("invisible", !shouldShowToggle);
729
+ toggleDetailsButton.classList.toggle("pointer-events-none", !shouldShowToggle);
730
+ toggleDetailsButton.textContent = isExpanded ? "折りたたむ" : "詳細";
731
+ toggleDetailsButton.setAttribute("aria-expanded", isExpanded ? "true" : "false");
732
+ }
733
+ }
734
+
735
+ function createEmptyState(filterActive) {
736
+ const empty = document.createElement("p");
737
+ empty.className = "rounded-lg border border-dashed border-slate-600/60 px-3 py-6 text-center text-xs text-slate-500";
738
+ empty.textContent = filterActive ? "該当するカードがありません" : "カードなし";
739
+ empty.setAttribute("aria-hidden", "true");
740
+ return empty;
741
+ }
742
+
743
+ function updateCounters(filterActive, filteredTasks) {
744
+ counters.forEach((counter) => {
745
+ const columnKey = counter.dataset.counter;
746
+ const totalCount = tasks.filter((task) => task.status === columnKey).length;
747
+ const visibleCount = filterActive
748
+ ? filteredTasks.filter((task) => task.status === columnKey).length
749
+ : totalCount;
750
+
751
+ counter.textContent = filterActive ? `${visibleCount}/${totalCount}` : String(totalCount);
752
+
753
+ const parentArticle = counter.closest("article");
754
+ if (!parentArticle) return;
755
+
756
+ const limit = columnSettings[columnKey]?.wip;
757
+ const isLimited = Number.isFinite(limit);
758
+ parentArticle.classList.remove("border", "border-kanban-caution");
759
+ if (isLimited && totalCount >= limit) {
760
+ parentArticle.classList.add("border", "border-kanban-caution");
761
+ }
762
+ });
763
+ }
764
+
765
+ function renderMetrics(filteredTasks, filterActive) {
766
+ const activeStatuses = new Set(["ready", "inProgress", "waiting"]);
767
+
768
+ const visibleTasks = filterActive ? filteredTasks : tasks;
769
+ const totalTasks = tasks.length;
770
+ const visibleTotal = visibleTasks.length;
771
+
772
+ const totalDisplay = filterActive
773
+ ? formatFilteredCount(visibleTotal, totalTasks)
774
+ : formatCount(totalTasks);
775
+
776
+ const totalActive = tasks.filter((task) => activeStatuses.has(task.status)).length;
777
+ const visibleActive = visibleTasks.filter((task) => activeStatuses.has(task.status)).length;
778
+ const activeDisplay = filterActive
779
+ ? formatFilteredCount(visibleActive, totalActive)
780
+ : formatCount(totalActive);
781
+
782
+ const now = new Date();
783
+ const sevenDaysAgo = new Date(now);
784
+ sevenDaysAgo.setDate(now.getDate() - 7);
785
+
786
+ const totalDone = tasks.filter((task) => task.status === "done");
787
+ const visibleDone = visibleTasks.filter((task) => task.status === "done");
788
+
789
+ const totalThroughput7 = totalDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
790
+ const visibleThroughput7 = visibleDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
791
+ const throughputDisplay = filterActive
792
+ ? formatFilteredCount(visibleThroughput7, totalThroughput7)
793
+ : formatCount(totalThroughput7);
794
+
795
+ const totalLeadAvg = computeAverageLead(totalDone);
796
+ const visibleLeadAvg = computeAverageLead(visibleDone);
797
+ const leadDisplay = filterActive
798
+ ? formatLeadFiltered(visibleLeadAvg, totalLeadAvg)
799
+ : formatLeadFiltered(totalLeadAvg, null);
800
+
801
+ setMetric("total", totalDisplay);
802
+ setMetric("active", activeDisplay);
803
+ setMetric("throughput7", throughputDisplay);
804
+ setMetric("leadtime", leadDisplay);
805
+ }
806
+
807
+ function computeAverageLead(doneTasks) {
808
+ const leadTimes = doneTasks
809
+ .map((task) => computeLeadTimeDays(task))
810
+ .filter((value) => Number.isFinite(value));
811
+ if (!leadTimes.length) return null;
812
+ const total = leadTimes.reduce((sum, value) => sum + value, 0);
813
+ return total / leadTimes.length;
814
+ }
815
+
816
+ function formatLeadFiltered(primary, overall) {
817
+ const base = Number.isFinite(primary) ? `${primary.toFixed(1)}日` : "--";
818
+ if (overall === null || overall === undefined) {
819
+ return base;
820
+ }
821
+ const overallText = Number.isFinite(overall) ? `${overall.toFixed(1)}日` : "--";
822
+ return `${base} / 全${overallText}`;
823
+ }
824
+
825
+ function setMetric(key, text) {
826
+ const target = metricElements[key];
827
+ if (!target) return;
828
+ target.textContent = text;
829
+ }
830
+
831
+ function formatCount(count) {
832
+ return `${count}枚`;
833
+ }
834
+
835
+ function formatFilteredCount(visible, total) {
836
+ return `${formatCount(visible)} / 全${formatCount(total)}`;
837
+ }
838
+
839
+ function isCompletedSince(task, threshold) {
840
+ if (!task.completedAt) return false;
841
+ const completed = new Date(task.completedAt);
842
+ return !Number.isNaN(completed.getTime()) && completed >= threshold;
843
+ }
844
+
845
+ function computeLeadTimeDays(task) {
846
+ if (!task?.createdAt || !task?.completedAt) return Number.NaN;
847
+ const start = new Date(task.createdAt);
848
+ const end = new Date(task.completedAt);
849
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return Number.NaN;
850
+ const diffMs = end.getTime() - start.getTime();
851
+ if (diffMs < 0) return Number.NaN;
852
+ return diffMs / (1000 * 60 * 60 * 24);
853
+ }
854
+
855
+ function setDropzoneHighlight(zone, state) {
856
+ zone.classList.remove("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "ring-kanban-caution", "bg-slate-800/40", "bg-slate-900/40");
857
+ if (state === "allowed") {
858
+ zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "bg-slate-800/40");
859
+ } else if (state === "denied") {
860
+ zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-caution", "bg-slate-900/40");
861
+ }
862
+ }
863
+
864
+ function canEnterColumn(columnKey, movingTaskId) {
865
+ const settingsColumn = columnSettings[columnKey];
866
+ if (!settingsColumn) return true;
867
+ if (!Number.isFinite(settingsColumn.wip)) return true;
868
+ const current = tasks.filter((task) => task.status === columnKey && task.id !== movingTaskId).length;
869
+ return current < settingsColumn.wip;
870
+ }
871
+
872
+ function isFilterActive() {
873
+ return (
874
+ Boolean(filterState.keyword) ||
875
+ Boolean(filterState.dueStart) ||
876
+ Boolean(filterState.dueEnd) ||
877
+ filterState.tags.size > 0
878
+ );
879
+ }
880
+
881
+ function getFilteredTasks() {
882
+ if (!isFilterActive()) return tasks;
883
+ const keyword = filterState.keyword.toLowerCase();
884
+ const startDate = parseISODate(filterState.dueStart);
885
+ const endDate = parseISODate(filterState.dueEnd);
886
+
887
+ return tasks.filter((task) => {
888
+ const keywordMatch = keyword
889
+ ? [task.title, task.owner, task.notes, task.due]
890
+ .filter(Boolean)
891
+ .some((value) => String(value).toLowerCase().includes(keyword))
892
+ : true;
893
+
894
+ const tagsMatch = filterState.tags.size
895
+ ? [...filterState.tags].every((tag) => Array.isArray(task.tags) && task.tags.includes(tag))
896
+ : true;
897
+
898
+ const dueMatch = (() => {
899
+ if (!startDate && !endDate) return true;
900
+ if (!task.due) return false;
901
+ const dueDate = parseISODate(task.due);
902
+ if (!dueDate) return false;
903
+ if (startDate && dueDate < startDate) return false;
904
+ if (endDate && dueDate > endDate) return false;
905
+ return true;
906
+ })();
907
+
908
+ return keywordMatch && tagsMatch && dueMatch;
909
+ });
910
+ }
911
+
912
+ function renderTagFilters() {
913
+ if (!filterElements.tagList) return;
914
+
915
+ const keyword = filterState.keyword.trim().toLowerCase();
916
+ const startDate = parseISODate(filterState.dueStart);
917
+ const endDate = parseISODate(filterState.dueEnd);
918
+
919
+ const sourceTasks = tasks.filter((task) => {
920
+ const keywordMatch = keyword
921
+ ? [task.title, task.owner, task.notes, task.due]
922
+ .filter(Boolean)
923
+ .some((value) => String(value).toLowerCase().includes(keyword))
924
+ : true;
925
+
926
+ const dueMatch = (() => {
927
+ if (!startDate && !endDate) return true;
928
+ if (!task.due) return false;
929
+ const dueDate = parseISODate(task.due);
930
+ if (!dueDate) return false;
931
+ if (startDate && dueDate < startDate) return false;
932
+ if (endDate && dueDate > endDate) return false;
933
+ return true;
934
+ })();
935
+
936
+ return keywordMatch && dueMatch;
937
+ });
938
+
939
+ const allTags = Array.from(
940
+ new Set(
941
+ sourceTasks
942
+ .flatMap((task) => (Array.isArray(task.tags) ? task.tags : []))
943
+ .map((tag) => tag.trim())
944
+ .filter(Boolean)
945
+ )
946
+ ).sort((a, b) => a.localeCompare(b, 'ja'));
947
+
948
+ const validTags = new Set(allTags);
949
+ let removed = false;
950
+ [...filterState.tags].forEach((tag) => {
951
+ if (!validTags.has(tag)) {
952
+ filterState.tags.delete(tag);
953
+ removed = true;
954
+ }
955
+ });
956
+ if (removed && filterElements.keyword && !isFilterActive()) {
957
+ filterElements.keyword.value = filterState.keyword;
958
+ }
959
+
960
+ filterElements.tagList.replaceChildren();
961
+
962
+ if (!allTags.length) {
963
+ const empty = document.createElement('p');
964
+ empty.className = 'text-xs text-slate-500';
965
+ empty.textContent = 'タグはまだ登録されていません。';
966
+ filterElements.tagList.appendChild(empty);
967
+ return;
968
+ }
969
+
970
+ allTags.forEach((tag) => {
971
+ const button = document.createElement('button');
972
+ button.type = 'button';
973
+ button.dataset.tagValue = tag;
974
+ button.textContent = `#${tag}`;
975
+ button.className = buildTagFilterClass(filterState.tags.has(tag));
976
+ filterElements.tagList.appendChild(button);
977
+ });
978
+ }
979
+
980
+ function buildTagFilterClass(isActive) {
981
+ const base = "rounded-full border px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring focus-visible:ring-kanban-accent focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900";
982
+ return isActive
983
+ ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
984
+ : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300 hover:border-slate-400 hover:text-white`;
985
+ }
986
+
987
+ function buildTagPillClass(isActive) {
988
+ const base = "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium";
989
+ return isActive
990
+ ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
991
+ : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300`;
992
+ }
993
+
994
+ function cleanupSelection() {
995
+ const validIds = new Set(tasks.map((task) => task.id));
996
+ let changed = false;
997
+ selectionState.ids.forEach((id) => {
998
+ if (!validIds.has(id)) {
999
+ selectionState.ids.delete(id);
1000
+ changed = true;
1001
+ }
1002
+ });
1003
+ if (changed) {
1004
+ updateSelectionUI();
1005
+ syncSelectionCheckboxes();
1006
+ }
1007
+ }
1008
+
1009
+ function toggleSelection(taskId, isSelected) {
1010
+ if (isSelected) {
1011
+ selectionState.ids.add(taskId);
1012
+ } else {
1013
+ selectionState.ids.delete(taskId);
1014
+ }
1015
+ updateSelectionUI();
1016
+ syncSelectionCheckboxes();
1017
+ }
1018
+
1019
+ function selectAllVisible() {
1020
+ if (!selectionState.lastVisibleIds.length) return;
1021
+ selectionState.lastVisibleIds.forEach((id) => selectionState.ids.add(id));
1022
+ updateSelectionUI();
1023
+ syncSelectionCheckboxes();
1024
+ }
1025
+
1026
+ function clearSelection() {
1027
+ selectionState.ids.clear();
1028
+ updateSelectionUI();
1029
+ syncSelectionCheckboxes();
1030
+ }
1031
+
1032
+ function syncSelectionCheckboxes() {
1033
+ const checkboxes = document.querySelectorAll("[data-action=\"select-task\"]");
1034
+ checkboxes.forEach((checkbox) => {
1035
+ const card = checkbox.closest("[data-task-id]");
1036
+ if (!card) return;
1037
+ const isSelected = selectionState.ids.has(card.dataset.taskId);
1038
+ checkbox.checked = isSelected;
1039
+ card.classList.toggle("kanban-card-selected", isSelected);
1040
+ });
1041
+ }
1042
+
1043
+ function updateSelectionUI() {
1044
+ const count = selectionState.ids.size;
1045
+ if (selectionElements.counter) {
1046
+ selectionElements.counter.textContent = `選択中: ${count}枚`;
1047
+ }
1048
+ if (selectionElements.delete) {
1049
+ selectionElements.delete.disabled = count === 0;
1050
+ selectionElements.delete.setAttribute("aria-disabled", count === 0 ? "true" : "false");
1051
+ }
1052
+ if (selectionElements.clear) {
1053
+ selectionElements.clear.disabled = count === 0;
1054
+ }
1055
+ if (selectionElements.selectAll) {
1056
+ selectionElements.selectAll.disabled = selectionState.lastVisibleIds.length === 0;
1057
+ }
1058
+ }
1059
+
1060
+ function deleteTask(taskId) {
1061
+ const task = tasks.find((item) => item.id === taskId);
1062
+ if (!task) return;
1063
+ if (!window.confirm("このタスクを削除しますか?")) return;
1064
+ selectionState.ids.delete(taskId);
1065
+ tasks = tasks.filter((item) => item.id !== taskId);
1066
+ persist();
1067
+ render();
1068
+ showMessage("タスクを削除しました。", "info");
1069
+ }
1070
+
1071
+ function deleteSelectedTasks() {
1072
+ const ids = [...selectionState.ids];
1073
+ if (!ids.length) return;
1074
+ if (!window.confirm(`選択中の${ids.length}件を削除しますか?`)) return;
1075
+ const idSet = new Set(ids);
1076
+ tasks = tasks.filter((task) => !idSet.has(task.id));
1077
+ selectionState.ids.clear();
1078
+ persist();
1079
+ render();
1080
+ showMessage("選択したタスクを削除しました。", "info");
1081
+ }
1082
+ function loadFromStorage() {
1083
+ try {
1084
+ const raw = window.localStorage.getItem(STORAGE_KEY);
1085
+ if (!raw) return;
1086
+ const parsed = JSON.parse(raw);
1087
+ if (!Array.isArray(parsed)) return;
1088
+
1089
+ tasks = parsed
1090
+ .filter((item) => typeof item === "object" && item !== null && item.id && item.title)
1091
+ .map((item) => normalizeTask(item));
1092
+ } catch (error) {
1093
+ console.warn("Failed to load tasks from localStorage:", error);
1094
+ tasks = [];
1095
+ }
1096
+ }
1097
+
1098
+ function normalizeTask(item) {
1099
+ const status = columnSettings[item.status] ? item.status : "backlog";
1100
+ const createdAt = item.createdAt ?? new Date().toISOString();
1101
+ const normalized = {
1102
+ id: item.id,
1103
+ title: item.title,
1104
+ owner: item.owner ?? "",
1105
+ notes: item.notes ?? "",
1106
+ status,
1107
+ due: item.due ?? "",
1108
+ createdAt,
1109
+ tags: normalizeTags(item.tags),
1110
+ link: normalizeLink(item.link ?? item.url ?? "")
1111
+ };
1112
+
1113
+ if (status === "done") {
1114
+ normalized.completedAt = item.completedAt ?? item.updatedAt ?? createdAt;
1115
+ } else if (item.completedAt) {
1116
+ normalized.completedAt = item.completedAt;
1117
+ }
1118
+
1119
+ return normalized;
1120
+ }
1121
+
1122
+ function normalizeTags(value) {
1123
+ if (Array.isArray(value)) {
1124
+ return parseTags(value.join(","));
1125
+ }
1126
+ if (typeof value === "string") {
1127
+ return parseTags(value);
1128
+ }
1129
+ return [];
1130
+ }
1131
+
1132
+ function persist() {
1133
+ try {
1134
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
1135
+ } catch (error) {
1136
+ console.warn("Failed to write tasks to localStorage:", error);
1137
+ showMessage("ローカルストレージに保存できませんでした���", "warn");
1138
+ }
1139
+ }
1140
+
1141
+ function showMessage(message, tone = "info") {
1142
+ if (!messageArea) return;
1143
+ clearTimeout(messageTimer);
1144
+ messageArea.textContent = message;
1145
+ messageArea.classList.remove("opacity-0", "text-kanban-caution", "text-sky-300");
1146
+ messageArea.classList.add(tone === "warn" ? "text-kanban-caution" : "text-sky-300", "opacity-100");
1147
+ messageTimer = window.setTimeout(() => hideMessage(), 4200);
1148
+ }
1149
+
1150
+ function hideMessage() {
1151
+ if (!messageArea) return;
1152
+ messageArea.classList.add("opacity-0");
1153
+ }
1154
+
1155
+ function seedDemoData() {
1156
+ const now = new Date();
1157
+ const demoCards = [
1158
+ {
1159
+ title: "前日の不具合整理",
1160
+ owner: "田中",
1161
+ notes: "Eラインの停止要因を5Whyで再整理",
1162
+ status: "backlog",
1163
+ due: isoDateOffset(now, 1),
1164
+ tags: ["品質", "改善"],
1165
+ link: "https://example.com/quality-review"
1166
+ },
1167
+ {
1168
+ title: "朝会ファシリテーション",
1169
+ owner: "佐藤",
1170
+ notes: "指標ボード更新と本日の重点テーマ共有",
1171
+ status: "ready",
1172
+ due: isoDateOffset(now, 0),
1173
+ tags: ["コミュニケーション"],
1174
+ link: "https://example.com/daily-meeting"
1175
+ },
1176
+ {
1177
+ title: "標準作業票の更新",
1178
+ owner: "鈴木",
1179
+ notes: "工程C-12の新治具対応版。現場レビュー待ち。",
1180
+ status: "waiting",
1181
+ due: isoDateOffset(now, 3),
1182
+ tags: ["標準化", "改善"],
1183
+ link: "https://example.com/standard-work"
1184
+ },
1185
+ {
1186
+ title: "トレーニング計画ドラフト",
1187
+ owner: "山本",
1188
+ notes: "新入社員向け。ヒアリング結果を反映中。",
1189
+ status: "inProgress",
1190
+ due: isoDateOffset(now, 5),
1191
+ tags: ["人材育成"],
1192
+ link: "https://example.com/training-plan"
1193
+ },
1194
+ {
1195
+ title: "品質ロス分析",
1196
+ owner: "石井",
1197
+ notes: "先週分のデータを取り込み済み。残りは原因記入。",
1198
+ status: "ready",
1199
+ due: isoDateOffset(now, 2),
1200
+ tags: ["品質", "分析"],
1201
+ link: "https://example.com/quality-loss"
1202
+ },
1203
+ {
1204
+ title: "安全KY実施",
1205
+ owner: "加藤",
1206
+ notes: "夜勤チームへ展開済み。記録は箱に保管。",
1207
+ status: "done",
1208
+ due: isoDateOffset(now, -1),
1209
+ tags: ["安全", "教育"],
1210
+ link: "https://example.com/safety-ky"
1211
+ }
1212
+ ];
1213
+
1214
+ demoCards.forEach((card, index) => {
1215
+ const desiredStatus = card.status;
1216
+ const status = canEnterColumn(desiredStatus) ? desiredStatus : "backlog";
1217
+ const createdAt = new Date(now.getTime() + index * 1000);
1218
+ const entry = {
1219
+ id: createId(),
1220
+ title: card.title,
1221
+ owner: card.owner,
1222
+ notes: card.notes,
1223
+ status,
1224
+ due: card.due,
1225
+ createdAt: createdAt.toISOString(),
1226
+ tags: parseTags(card.tags?.join(",") ?? ""),
1227
+ link: normalizeLink(card.link ?? "")
1228
+ };
1229
+
1230
+ if (status === "done") {
1231
+ entry.completedAt = new Date(createdAt.getTime() + 60 * 60 * 1000).toISOString();
1232
+ }
1233
+
1234
+ tasks.push(entry);
1235
+ });
1236
+
1237
+ persist();
1238
+ render();
1239
+ showMessage("デモデータを追加しました。", "info");
1240
+ }
1241
+
1242
+ function isoDateOffset(date, offsetDays) {
1243
+ const clone = new Date(date);
1244
+ clone.setDate(clone.getDate() + offsetDays);
1245
+ return clone.toISOString().slice(0, 10);
1246
+ }
1247
+
1248
+ function formatDue(value) {
1249
+ if (!value) return "未設定";
1250
+ try {
1251
+ const date = new Date(value);
1252
+ if (Number.isNaN(date.getTime())) return "未設定";
1253
+ return `${date.getFullYear()}年${padZero(date.getMonth() + 1)}月${padZero(date.getDate())}日`;
1254
+ } catch (_error) {
1255
+ return "未設定";
1256
+ }
1257
+ }
1258
+
1259
+ function padZero(num) {
1260
+ return String(num).padStart(2, "0");
1261
+ }
1262
+
1263
+ function createId() {
1264
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1265
+ return crypto.randomUUID();
1266
+ }
1267
+ return `task-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
1268
+ }
1269
+
1270
+ function openEditDialog(taskId) {
1271
+ const task = tasks.find((item) => item.id === taskId);
1272
+ if (!task) return;
1273
+
1274
+ if (!editDialog || !editForm || !editFields) {
1275
+ fallbackEdit(task);
1276
+ return;
1277
+ }
1278
+
1279
+ editingTaskId = taskId;
1280
+ editFields.title.value = task.title;
1281
+ editFields.owner.value = task.owner ?? "";
1282
+ editFields.due.value = task.due ?? "";
1283
+ editFields.notes.value = task.notes ?? "";
1284
+ if (editFields.tags) {
1285
+ editFields.tags.value = Array.isArray(task.tags) ? task.tags.join(", ") : "";
1286
+ }
1287
+ editFields.link.value = task.link ?? "";
1288
+
1289
+ try {
1290
+ if (typeof editDialog.showModal === "function") {
1291
+ editDialog.showModal();
1292
+ } else {
1293
+ editDialog.setAttribute("open", "true");
1294
+ }
1295
+ window.setTimeout(() => editFields.title.focus(), 0);
1296
+ } catch (error) {
1297
+ console.warn("Failed to open dialog, using fallback.", error);
1298
+ fallbackEdit(task);
1299
+ }
1300
+ }
1301
+
1302
+ function handleEditSubmit(event) {
1303
+ event.preventDefault();
1304
+ if (!editFields || !editingTaskId) return;
1305
+
1306
+ const title = editFields.title.value.trim();
1307
+ if (!title) {
1308
+ showMessage("タイトルを入力してください。", "warn");
1309
+ editFields.title.focus();
1310
+ return;
1311
+ }
1312
+
1313
+ const updates = {
1314
+ title,
1315
+ owner: editFields.owner.value.trim(),
1316
+ due: editFields.due.value,
1317
+ notes: editFields.notes.value.trim(),
1318
+ tags: parseTags(editFields.tags ? editFields.tags.value : ""),
1319
+ link: normalizeLink(editFields.link?.value ?? "")
1320
+ };
1321
+
1322
+ applyTaskUpdates(editingTaskId, updates);
1323
+ closeEditDialog();
1324
+ }
1325
+
1326
+ function applyTaskUpdates(taskId, updates) {
1327
+ const task = tasks.find((item) => item.id === taskId);
1328
+ if (!task) return false;
1329
+
1330
+ task.title = updates.title;
1331
+ task.owner = updates.owner;
1332
+ task.due = updates.due;
1333
+ task.notes = updates.notes;
1334
+ task.tags = updates.tags;
1335
+ task.link = updates.link;
1336
+
1337
+ persist();
1338
+ render();
1339
+ showMessage("タスクを更新しました。", "info");
1340
+ return true;
1341
+ }
1342
+
1343
+ function closeEditDialog(fromEvent = false) {
1344
+ if (!editDialog) return;
1345
+
1346
+ if (!fromEvent) {
1347
+ if (typeof editDialog.close === "function" && editDialog.open) {
1348
+ editDialog.close();
1349
+ } else {
1350
+ editDialog.removeAttribute("open");
1351
+ }
1352
+ } else {
1353
+ editDialog.removeAttribute("open");
1354
+ }
1355
+
1356
+ resetEditingState();
1357
+ }
1358
+
1359
+ function resetEditingState() {
1360
+ editingTaskId = null;
1361
+ editForm?.reset();
1362
+ }
1363
+
1364
+ function fallbackEdit(task) {
1365
+ const title = window.prompt("タイトルを編集", task.title);
1366
+ if (title === null) return;
1367
+ const trimmedTitle = title.trim();
1368
+ if (!trimmedTitle) {
1369
+ showMessage("タイトルを入力してください。", "warn");
1370
+ return;
1371
+ }
1372
+
1373
+ const owner = window.prompt("担当を編集", task.owner ?? "") ?? task.owner ?? "";
1374
+ const due = window.prompt("期限 (YYYY-MM-DD)", task.due ?? "") ?? task.due ?? "";
1375
+ const notes = window.prompt("メモ", task.notes ?? "") ?? task.notes ?? "";
1376
+ const tagsInput = window.prompt("タグ(カンマ区切り)", Array.isArray(task.tags) ? task.tags.join(", ") : "") ?? (Array.isArray(task.tags) ? task.tags.join(", ") : "");
1377
+ const linkInput = window.prompt("参考リンク(URL)", task.link ?? "") ?? (task.link ?? "");
1378
+
1379
+ applyTaskUpdates(task.id, {
1380
+ title: trimmedTitle,
1381
+ owner: owner.trim(),
1382
+ due,
1383
+ notes: notes.trim(),
1384
+ tags: parseTags(tagsInput),
1385
+ link: normalizeLink(linkInput)
1386
+ });
1387
+ }
1388
+
1389
+ function toggleViewMode() {
1390
+ uiState.compact = !uiState.compact;
1391
+ updateViewToggleLabel();
1392
+ render();
1393
+ showMessage(uiState.compact ? "コンパクト表示に切り替えました。" : "標準表示に戻しました。", "info");
1394
+ }
1395
+
1396
+ function updateViewToggleLabel() {
1397
+ if (!uiElements.viewToggle) return;
1398
+ uiElements.viewToggle.textContent = uiState.compact ? "標準表示" : "コンパクト表示";
1399
+ }
1400
+
1401
+ function parseISODate(value) {
1402
+ if (!value) return null;
1403
+ try {
1404
+ const date = new Date(value);
1405
+ return Number.isNaN(date.getTime()) ? null : new Date(date.getFullYear(), date.getMonth(), date.getDate());
1406
+ } catch (_error) {
1407
+ return null;
1408
+ }
1409
+ }
1410
+
1411
+ function formatLinkLabel(url) {
1412
+ try {
1413
+ const parsed = new URL(url);
1414
+ const path = parsed.pathname === "/" ? "" : parsed.pathname;
1415
+ return `${parsed.hostname}${path}`;
1416
+ } catch (_error) {
1417
+ return url;
1418
+ }
1419
+ }
1420
+
1421
+ function normalizeLink(input) {
1422
+ if (!input) return "";
1423
+ let raw0 = String(input).trim();
1424
+ if (!raw0) return "";
1425
+
1426
+ // ✅ 追加: 先頭・末尾のダブルクォーテーションを削除
1427
+ raw0 = raw0.replace(/^"(.*)"$/, "$1");
1428
+
1429
+ // 全角の「¥」をバックスラッシュに誤入力しているケースを補正
1430
+ const raw = raw0.replace(/¥/g, "\\");
1431
+
1432
+ const ensureDirSlash = (u) => {
1433
+ try {
1434
+ const url = new URL(u);
1435
+ if (url.protocol !== "file:") return u;
1436
+ if (url.pathname.endsWith("/")) return u;
1437
+
1438
+ const last = decodeURI(url.pathname).split("/").pop() || "";
1439
+ const looksLikeFile = /\.[A-Za-z0-9]{1,6}$/.test(last);
1440
+ return looksLikeFile ? u : (u + "/");
1441
+ } catch {
1442
+ return u;
1443
+ }
1444
+ };
1445
+
1446
+ // 1) Windows ドライブパス: C:\... → file:///C:/...
1447
+ if (/^[a-zA-Z]:\\/.test(raw)) {
1448
+ const path = raw.replace(/\\/g, "/");
1449
+ return ensureDirSlash(`file:///${encodeURI(path)}`);
1450
+ }
1451
+
1452
+ // 2) UNC: \\server\share\path → file://server/share/path
1453
+ if (/^\\\\[^\\]/.test(raw)) {
1454
+ const path = raw.replace(/^\\\\/, "").replace(/\\/g, "/");
1455
+ return ensureDirSlash(`file://${encodeURI(path)}`);
1456
+ }
1457
+
1458
+ // 3) 明示スキームあり(http/https/file)
1459
+ const hasScheme = /^[a-zA-Z][\\w+.-]*:/.test(raw);
1460
+ if (hasScheme) {
1461
+ try {
1462
+ const url = new URL(raw);
1463
+ if (/^https?:$/i.test(url.protocol)) return url.href;
1464
+ if (/^file:$/i.test(url.protocol)) return ensureDirSlash(url.href);
1465
+ return "";
1466
+ } catch {
1467
+ return "";
1468
+ }
1469
+ }
1470
+
1471
+ // 4) スキーム無し → Webドメインらしければ https:// を補完
1472
+ try {
1473
+ const candidate = `https://${raw}`;
1474
+ const url = new URL(candidate);
1475
+ return url.href;
1476
+ } catch {
1477
+ return "";
1478
+ }
1479
+ }
1480
+
1481
+
1482
+ function parseTags(input) {
1483
+ if (!input) return [];
1484
+ const parts = Array.isArray(input) ? input : String(input).split(/[,、\s]+/);
1485
+ const seen = new Set();
1486
+ const result = [];
1487
+ parts
1488
+ .map((item) => String(item).trim())
1489
+ .filter(Boolean)
1490
+ .forEach((tag) => {
1491
+ const cleaned = tag.replace(/^#/, "").trim();
1492
+ if (!cleaned) return;
1493
+ const key = cleaned.toLowerCase();
1494
+ if (seen.has(key)) return;
1495
+ seen.add(key);
1496
+ result.push(cleaned);
1497
+ });
1498
+ return result;
1499
+ }
1500
+ })();
1501
+
1502
+
1503
+
1504
+
1505
+
1506
+
1507
+
1508
+
1509
+
1510
+
1511
+
1512
+
1513
+
1514
+
1515
+
1516
+
1517
+
1518
+
1519
+
1520
+
1521
+
1522
+
1523
+
1524
+
1525
+
1526
+
1527
+
1528
+
1529
+
1530
+
1531
+
1532
+
1533
+
1534
+
1535
+
1536
+
1537
+
1538
+
1539
+
1540
+
1541
+
1542
+
1543
+
1544
+
1545
+
v6_軽量KanbanBoard/index.html CHANGED
@@ -352,6 +352,14 @@
352
  <button id="selection-delete" type="button" class="rounded-lg bg-rose-500/80 px-3 py-1 text-xs font-semibold text-rose-50 transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:bg-rose-900/40" disabled>
353
  選択削除
354
  </button>
 
 
 
 
 
 
 
 
355
  </div>
356
  </div>
357
 
 
352
  <button id="selection-delete" type="button" class="rounded-lg bg-rose-500/80 px-3 py-1 text-xs font-semibold text-rose-50 transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:bg-rose-900/40" disabled>
353
  選択削除
354
  </button>
355
+
356
+ <button id="data-export" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
357
+ エクスポート
358
+ </button>
359
+ <button id="data-import" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
360
+ インポート
361
+ </button>
362
+ <input id="data-import-input" type="file" accept="application/json" class="hidden" />
363
  </div>
364
  </div>
365