| const STORAGE_KEY = "toyota-kanban-tasks-v1"; | |
| const STATUSES = [ | |
| { id: "backlog", label: "\u30d0\u30c3\u30af\u30ed\u30b0", hint: "\u69cb\u60f3\u4e2d" }, | |
| { id: "ready", label: "\u6e96\u5099", hint: "\u6e96\u5099\u4e2d" }, | |
| { id: "inprogress", label: "\u4f5c\u696d", hint: "\u9032\u884c\u4e2d" }, | |
| { id: "waiting", label: "\u5f85\u6a5f", hint: "\u505c\u6b62\u4e2d" }, | |
| { id: "done", label: "\u5b8c\u4e86", hint: "\u5b8c\u4e86" } | |
| ]; | |
| const state = { | |
| tasks: [], | |
| filters: { | |
| search: "", | |
| searchRaw: "", | |
| selectedTags: [], | |
| dateFrom: "", | |
| dateTo: "", | |
| parentId: "", | |
| parentSearch: "" | |
| }, | |
| selectionMode: false, | |
| selectedIds: new Set(), | |
| showParentView: false, | |
| highlightParentId: "", | |
| activeStatus: STATUSES[0].id, | |
| parentViewSearch: "" | |
| }; | |
| const dom = {}; | |
| let initialized = false; | |
| function init() { | |
| if (initialized) return; | |
| initialized = true; | |
| cacheDom(); | |
| bindGlobalEvents(); | |
| hydrateStaticUi(); | |
| loadTasks(); | |
| renderAll(); | |
| } | |
| function cacheDom() { | |
| dom.board = document.getElementById("board"); | |
| dom.statusDock = document.getElementById("status-dock"); | |
| dom.parentView = document.getElementById("parent-view"); | |
| dom.parentClusters = document.getElementById("parent-clusters"); | |
| dom.selectionToolbar = document.getElementById("selection-toolbar"); | |
| dom.selectionCount = document.getElementById("selection-count"); | |
| dom.bulkStatusButtons = document.getElementById("bulk-status-buttons"); | |
| dom.filterReset = document.getElementById("filter-reset-button"); | |
| dom.parentFilter = document.getElementById("parent-filter-select"); | |
| dom.parentFilterSearch = document.getElementById("parent-filter-search"); | |
| dom.tagToggleContainer = document.getElementById("tag-toggle-container"); | |
| dom.parentViewSearch = document.getElementById("parent-view-search"); | |
| dom.parentViewClear = document.getElementById("parent-view-clear"); | |
| dom.searchInput = document.getElementById("search-input"); | |
| dom.dateFrom = document.getElementById("date-from"); | |
| dom.dateTo = document.getElementById("date-to"); | |
| dom.toggleGroupBtn = document.getElementById("btn-toggle-group"); | |
| dom.toggleSelectionBtn = document.getElementById("btn-toggle-selection"); | |
| dom.newTaskBtn = document.getElementById("btn-new-task"); | |
| dom.bulkDeleteBtn = document.getElementById("btn-bulk-delete"); | |
| dom.selectAllBtn = document.getElementById("btn-select-all"); | |
| dom.clearSelectionBtn = document.getElementById("btn-clear-selection"); | |
| dom.dialog = document.getElementById("task-dialog"); | |
| dom.dialogTitle = document.getElementById("dialog-title"); | |
| dom.dialogClose = document.getElementById("dialog-close"); | |
| dom.taskForm = document.getElementById("task-form"); | |
| dom.taskDeleteBtn = document.getElementById("task-delete-button"); | |
| dom.taskStatusSelect = document.getElementById("task-status"); | |
| dom.taskParentSelect = document.getElementById("task-parent"); | |
| dom.taskCardTemplate = document.getElementById("task-card-template"); | |
| } | |
| function bindGlobalEvents() { | |
| dom.newTaskBtn.addEventListener("click", () => openTaskDialog()); | |
| dom.dialogClose.addEventListener("click", closeDialog); | |
| dom.taskForm.addEventListener("submit", handleTaskSubmit); | |
| dom.taskDeleteBtn.addEventListener("click", handleDialogDelete); | |
| dom.toggleGroupBtn.addEventListener("click", toggleParentView); | |
| dom.toggleSelectionBtn.addEventListener("click", toggleSelectionMode); | |
| dom.bulkDeleteBtn.addEventListener("click", handleBulkDelete); | |
| dom.selectAllBtn.addEventListener("click", handleSelectAll); | |
| dom.clearSelectionBtn.addEventListener("click", () => { | |
| state.selectedIds.clear(); | |
| updateBulkActions(); | |
| renderAll(); | |
| }); | |
| dom.searchInput.addEventListener("input", (event) => { | |
| const raw = event.target.value.trim(); | |
| state.filters.searchRaw = raw; | |
| state.filters.search = raw.toLowerCase(); | |
| renderAll(); | |
| }); | |
| dom.dateFrom.addEventListener("change", (event) => { | |
| state.filters.dateFrom = event.target.value; | |
| renderAll(); | |
| }); | |
| dom.dateTo.addEventListener("change", (event) => { | |
| state.filters.dateTo = event.target.value; | |
| renderAll(); | |
| }); | |
| dom.parentFilter.addEventListener("change", (event) => { | |
| state.filters.parentId = event.target.value; | |
| renderAll(); | |
| }); | |
| if (dom.filterReset) { | |
| dom.filterReset.addEventListener("click", resetFilters); | |
| } | |
| if (dom.parentFilterSearch) { | |
| dom.parentFilterSearch.addEventListener("input", (event) => { | |
| state.filters.parentSearch = event.target.value.trim(); | |
| renderParentFilterOptions(); | |
| }); | |
| } | |
| if (dom.parentViewSearch) { | |
| dom.parentViewSearch.addEventListener("input", (event) => { | |
| state.parentViewSearch = event.target.value.trim(); | |
| renderParentClusters(); | |
| }); | |
| } | |
| if (dom.parentViewClear) { | |
| dom.parentViewClear.addEventListener("click", () => { | |
| state.parentViewSearch = ""; | |
| state.highlightParentId = ""; | |
| state.filters.parentId = ""; | |
| if (dom.parentViewSearch) dom.parentViewSearch.value = ""; | |
| renderAll(); | |
| }); | |
| } | |
| dom.dialog.addEventListener("close", () => { | |
| dom.taskForm.reset(); | |
| dom.taskDeleteBtn.hidden = true; | |
| dom.taskForm.dataset.editingId = ""; | |
| }); | |
| } | |
| function hydrateStaticUi() { | |
| STATUSES.forEach((status) => { | |
| const option = document.createElement("option"); | |
| option.value = status.id; | |
| option.textContent = status.label; | |
| dom.taskStatusSelect.appendChild(option); | |
| }); | |
| renderBulkStatusButtons(); | |
| } | |
| function loadTasks() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (raw) { | |
| const parsed = JSON.parse(raw); | |
| if (Array.isArray(parsed)) { | |
| state.tasks = parsed; | |
| return; | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Failed to parse storage", error); | |
| } | |
| state.tasks = createSampleTasks(); | |
| saveTasks(); | |
| } | |
| function saveTasks() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(state.tasks)); | |
| } | |
| function renderAll() { | |
| document.body.classList.toggle("selection-mode", state.selectionMode); | |
| document.body.classList.toggle("parent-view-mode", state.showParentView); | |
| dom.parentView.classList.toggle("active", state.showParentView); | |
| dom.toggleGroupBtn.classList.toggle("active", state.showParentView); | |
| dom.toggleSelectionBtn.classList.toggle("active", state.selectionMode); | |
| if (dom.searchInput) dom.searchInput.value = state.filters.searchRaw; | |
| if (dom.dateFrom) dom.dateFrom.value = state.filters.dateFrom; | |
| if (dom.dateTo) dom.dateTo.value = state.filters.dateTo; | |
| if (dom.parentViewSearch) dom.parentViewSearch.value = state.parentViewSearch; | |
| const tagScopedTasks = getFilteredTasks({ includeTagFilter: false }); | |
| renderTagToggles(tagScopedTasks); | |
| const filteredTasks = getFilteredTasks(); | |
| const groupedByStatus = groupByStatus(filteredTasks); | |
| renderStatusDock(groupedByStatus); | |
| renderBoard(groupedByStatus); | |
| renderParentOptions(); | |
| renderParentFilterOptions(); | |
| renderParentClusters(); | |
| updateBulkActions(); | |
| } | |
| function renderBoard(groupedTasks) { | |
| dom.board.innerHTML = ""; | |
| const status = STATUSES.find((item) => item.id === state.activeStatus) ?? STATUSES[0]; | |
| const tasks = groupedTasks[status.id] ?? []; | |
| const column = document.createElement("section"); | |
| column.className = "column"; | |
| column.dataset.status = status.id; | |
| const header = document.createElement("header"); | |
| header.className = "column-header"; | |
| header.innerHTML = `<span>${status.label}</span><span class="column-count">${tasks.length}</span>`; | |
| const body = document.createElement("div"); | |
| body.className = "column-body"; | |
| if (tasks.length === 0) { | |
| body.classList.add("empty"); | |
| } else { | |
| tasks.forEach((task) => body.appendChild(createTaskCard(task))); | |
| } | |
| column.append(header, body); | |
| dom.board.appendChild(column); | |
| } | |
| function renderParentOptions(currentId = "") { | |
| dom.taskParentSelect.innerHTML = ""; | |
| const noneOption = document.createElement("option"); | |
| noneOption.value = ""; | |
| noneOption.textContent = "\u306a\u3057"; | |
| dom.taskParentSelect.appendChild(noneOption); | |
| state.tasks | |
| .filter((task) => task.id !== currentId && !isDescendant(task.id, currentId)) | |
| .sort((a, b) => a.title.localeCompare(b.title, "ja")) | |
| .forEach((task) => { | |
| const option = document.createElement("option"); | |
| option.value = task.id; | |
| option.textContent = task.title; | |
| dom.taskParentSelect.appendChild(option); | |
| }); | |
| } | |
| function renderParentFilterOptions() { | |
| const previous = state.filters.parentId || ""; | |
| const searchTerm = state.filters.parentSearch.trim().toLowerCase(); | |
| dom.parentFilter.innerHTML = ""; | |
| const allOption = document.createElement("option"); | |
| allOption.value = ""; | |
| allOption.textContent = "\u3059\u3079\u3066"; | |
| dom.parentFilter.appendChild(allOption); | |
| const allParents = state.tasks | |
| .filter((task) => !task.parentId) | |
| .sort((a, b) => a.title.localeCompare(b.title, "ja")); | |
| const filtered = []; | |
| allParents.forEach((task) => { | |
| if (!searchTerm || task.title.toLowerCase().includes(searchTerm)) { | |
| filtered.push(task); | |
| } | |
| }); | |
| if (previous && !filtered.some((task) => task.id === previous)) { | |
| const selected = allParents.find((task) => task.id === previous); | |
| if (selected) { | |
| filtered.unshift(selected); | |
| } | |
| } | |
| filtered.forEach((task) => { | |
| const option = document.createElement("option"); | |
| option.value = task.id; | |
| option.textContent = task.title; | |
| dom.parentFilter.appendChild(option); | |
| }); | |
| const hasSelection = previous && allParents.some((task) => task.id === previous); | |
| dom.parentFilter.value = hasSelection ? previous : ""; | |
| if (!hasSelection) { | |
| state.filters.parentId = ""; | |
| } | |
| if (dom.parentFilterSearch) { | |
| dom.parentFilterSearch.value = state.filters.parentSearch; | |
| } | |
| } | |
| function renderStatusDock(groupedTasks) { | |
| dom.statusDock.innerHTML = ""; | |
| STATUSES.forEach((status) => { | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.className = "status-chip"; | |
| button.dataset.status = status.id; | |
| if (status.id === state.activeStatus) { | |
| button.classList.add("active"); | |
| } | |
| const count = groupedTasks[status.id]?.length ?? 0; | |
| button.innerHTML = ` | |
| <div class="status-chip-text"> | |
| <strong>${status.label}</strong> | |
| <span>${status.hint}</span> | |
| </div> | |
| <span class="status-chip-count">${count}</span> | |
| `; | |
| button.addEventListener("click", () => { | |
| if (state.activeStatus !== status.id) { | |
| state.activeStatus = status.id; | |
| renderAll(); | |
| } | |
| }); | |
| dom.statusDock.appendChild(button); | |
| }); | |
| } | |
| function renderBulkStatusButtons() { | |
| dom.bulkStatusButtons.innerHTML = ""; | |
| STATUSES.forEach((status) => { | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.className = "btn subtle"; | |
| button.textContent = status.label; | |
| button.addEventListener("click", () => handleBulkMove(status.id)); | |
| dom.bulkStatusButtons.appendChild(button); | |
| }); | |
| } | |
| function renderTagToggles(tasks = []) { | |
| const container = dom.tagToggleContainer; | |
| if (!container) return; | |
| container.innerHTML = ""; | |
| const tagSet = new Set(); | |
| tasks.forEach((task) => (task.tags ?? []).forEach((tag) => tagSet.add(tag))); | |
| const sortedTags = [...tagSet].sort((a, b) => a.localeCompare(b, "ja")); | |
| const available = new Set(sortedTags); | |
| state.filters.selectedTags = state.filters.selectedTags.filter((tag) => available.has(tag)); | |
| if (!sortedTags.length) { | |
| const placeholder = document.createElement("span"); | |
| placeholder.className = "tag-toggle-empty"; | |
| placeholder.textContent = "\u30bf\u30b0\u306f\u672a\u767b\u9332\u3067\u3059"; | |
| container.appendChild(placeholder); | |
| return; | |
| } | |
| sortedTags.forEach((tag) => { | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.className = "tag-toggle"; | |
| button.textContent = tag; | |
| const isActive = state.filters.selectedTags.includes(tag); | |
| button.setAttribute("aria-pressed", isActive ? "true" : "false"); | |
| button.classList.toggle("active", isActive); | |
| button.addEventListener("click", () => toggleTagFilter(tag)); | |
| container.appendChild(button); | |
| }); | |
| } | |
| function toggleTagFilter(tag) { | |
| const list = state.filters.selectedTags; | |
| const index = list.indexOf(tag); | |
| if (index >= 0) { | |
| list.splice(index, 1); | |
| } else { | |
| list.push(tag); | |
| } | |
| renderAll(); | |
| } | |
| function createTaskCard(task) { | |
| const card = dom.taskCardTemplate.content.firstElementChild.cloneNode(true); | |
| card.dataset.id = task.id; | |
| card.dataset.status = task.status; | |
| if (state.highlightParentId && (state.highlightParentId === task.id || state.highlightParentId === task.parentId)) { | |
| card.classList.add("highlighted"); | |
| } | |
| const checkbox = card.querySelector(".task-select"); | |
| checkbox.checked = state.selectedIds.has(task.id); | |
| checkbox.style.display = state.selectionMode ? "block" : "none"; | |
| checkbox.addEventListener("change", (event) => { | |
| if (event.target.checked) { | |
| state.selectedIds.add(task.id); | |
| } else { | |
| state.selectedIds.delete(task.id); | |
| } | |
| updateBulkActions(); | |
| }); | |
| card.querySelector(".task-title").textContent = task.title; | |
| card.querySelector(".assignee").textContent = task.assignee ? `\u62c5\u5f53: ${task.assignee}` : "\u62c5\u5f53: \u672a\u5b9a"; | |
| card.querySelector(".due-date").textContent = task.dueDate ? `\u671f\u9650: ${formatDate(task.dueDate)}` : "\u671f\u9650: \u672a\u8a2d\u5b9a"; | |
| card.querySelector(".progress").textContent = task.progress || "\u9032\u6357\u30e1\u30e2\u306a\u3057"; | |
| card.querySelector(".notes").textContent = task.notes || "\u5099\u8003\u306a\u3057"; | |
| const linkEl = card.querySelector(".resource-link"); | |
| if (task.link) { | |
| linkEl.href = task.link; | |
| linkEl.textContent = task.link; | |
| linkEl.style.display = "block"; | |
| } else { | |
| linkEl.style.display = "none"; | |
| } | |
| const tagContainer = card.querySelector(".task-tags"); | |
| tagContainer.innerHTML = ""; | |
| (task.tags || []).forEach((tag) => { | |
| const tagEl = document.createElement("span"); | |
| tagEl.className = "task-tag"; | |
| tagEl.textContent = tag; | |
| tagContainer.appendChild(tagEl); | |
| }); | |
| const childrenContainer = card.querySelector(".task-children"); | |
| const children = state.tasks.filter((item) => item.parentId === task.id); | |
| if (children.length) { | |
| children.forEach((child) => { | |
| const row = document.createElement("div"); | |
| row.className = "task-child"; | |
| const statusLabel = STATUSES.find((status) => status.id === child.status)?.label ?? ""; | |
| row.innerHTML = `<span>${child.title}</span><span>${statusLabel}</span>`; | |
| childrenContainer.appendChild(row); | |
| }); | |
| } else { | |
| childrenContainer.style.display = "none"; | |
| } | |
| const quickButtons = card.querySelector(".status-quick-buttons"); | |
| quickButtons.innerHTML = ""; | |
| STATUSES.forEach((status) => { | |
| if (status.id === task.status) return; | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.textContent = status.label; | |
| button.addEventListener("click", () => { | |
| updateTask(task.id, { status: status.id }, { skipRender: true }); | |
| renderAll(); | |
| }); | |
| quickButtons.appendChild(button); | |
| }); | |
| card.querySelector('[data-action="edit"]').addEventListener("click", () => openTaskDialog(task.id)); | |
| card.querySelector('[data-action="delete"]').addEventListener("click", () => { | |
| if (confirm("\u3053\u306e\u30bf\u30b9\u30af\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f\u5b50\u30bf\u30b9\u30af\u3082\u524a\u9664\u3055\u308c\u307e\u3059\u3002")) { | |
| deleteTask(task.id); | |
| renderAll(); | |
| } | |
| }); | |
| return card; | |
| } | |
| function openTaskDialog(taskId = "") { | |
| const editing = Boolean(taskId); | |
| dom.dialogTitle.textContent = editing ? "\u30bf\u30b9\u30af\u3092\u7de8\u96c6" : "\u30bf\u30b9\u30af\u3092\u8ffd\u52a0"; | |
| dom.taskDeleteBtn.hidden = !editing; | |
| dom.taskForm.dataset.editingId = editing ? taskId : ""; | |
| populateTaskForm(editing ? getTask(taskId) : null); | |
| dom.dialog.showModal(); | |
| } | |
| function closeDialog() { | |
| dom.dialog.close(); | |
| } | |
| function populateTaskForm(task) { | |
| dom.taskForm.reset(); | |
| renderParentOptions(task?.id ?? ""); | |
| dom.taskForm.elements.title.value = task?.title ?? ""; | |
| dom.taskForm.elements.assignee.value = task?.assignee ?? ""; | |
| dom.taskForm.elements.dueDate.value = task?.dueDate ?? ""; | |
| dom.taskForm.elements.link.value = task?.link ?? ""; | |
| dom.taskForm.elements.tags.value = (task?.tags ?? []).join(", "); | |
| dom.taskForm.elements.progress.value = task?.progress ?? ""; | |
| dom.taskForm.elements.notes.value = task?.notes ?? ""; | |
| dom.taskForm.elements.parentId.value = task?.parentId ?? ""; | |
| dom.taskForm.elements.status.value = task?.status ?? STATUSES[0].id; | |
| } | |
| function handleTaskSubmit(event) { | |
| event.preventDefault(); | |
| const formData = new FormData(dom.taskForm); | |
| const payload = { | |
| title: formData.get("title").trim(), | |
| assignee: formData.get("assignee").trim(), | |
| dueDate: formData.get("dueDate") || "", | |
| link: formData.get("link").trim(), | |
| tags: formData | |
| .get("tags") | |
| .split(",") | |
| .map((tag) => tag.trim()) | |
| .filter(Boolean), | |
| progress: formData.get("progress").trim(), | |
| notes: formData.get("notes").trim(), | |
| parentId: formData.get("parentId") || "", | |
| status: formData.get("status") | |
| }; | |
| const editingId = dom.taskForm.dataset.editingId; | |
| if (editingId) { | |
| updateTask(editingId, payload, { skipRender: true, skipPersist: true }); | |
| saveTasks(); | |
| } else { | |
| const newTask = { | |
| id: generateId(), | |
| createdAt: Date.now(), | |
| updatedAt: Date.now(), | |
| ...payload | |
| }; | |
| state.tasks.push(newTask); | |
| saveTasks(); | |
| } | |
| renderAll(); | |
| closeDialog(); | |
| } | |
| function handleDialogDelete() { | |
| const editingId = dom.taskForm.dataset.editingId; | |
| if (!editingId) return; | |
| if (!confirm("\u3053\u306e\u30bf\u30b9\u30af\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f\u5b50\u30bf\u30b9\u30af\u3082\u524a\u9664\u3055\u308c\u307e\u3059\u3002")) return; | |
| deleteTask(editingId); | |
| renderAll(); | |
| closeDialog(); | |
| } | |
| function resetFilters() { | |
| state.filters.search = ""; | |
| state.filters.searchRaw = ""; | |
| state.filters.selectedTags = []; | |
| state.filters.dateFrom = ""; | |
| state.filters.dateTo = ""; | |
| state.filters.parentId = ""; | |
| state.filters.parentSearch = ""; | |
| state.parentViewSearch = ""; | |
| state.parentViewSearch = ""; | |
| state.highlightParentId = ""; | |
| state.selectedIds.clear(); | |
| state.selectionMode = false; | |
| state.showParentView = false; | |
| state.activeStatus = STATUSES[0].id; | |
| if (dom.searchInput) dom.searchInput.value = ""; | |
| if (dom.dateFrom) dom.dateFrom.value = ""; | |
| if (dom.dateTo) dom.dateTo.value = ""; | |
| if (dom.parentFilterSearch) dom.parentFilterSearch.value = ""; | |
| if (dom.parentViewSearch) dom.parentViewSearch.value = ""; | |
| if (dom.parentViewClear) dom.parentViewClear.disabled = true; | |
| renderAll(); | |
| } | |
| function handleBulkDelete() { | |
| if (!state.selectedIds.size) return; | |
| if (!confirm("\u9078\u629e\u3057\u305f\u30bf\u30b9\u30af\u3092\u307e\u3068\u3081\u3066\u524a\u9664\u3057\u307e\u3059\u304b\uff1f\u5b50\u30bf\u30b9\u30af\u3082\u524a\u9664\u3055\u308c\u307e\u3059\u3002")) { | |
| return; | |
| } | |
| [...state.selectedIds].forEach((taskId) => deleteTask(taskId)); | |
| state.selectedIds.clear(); | |
| updateBulkActions(); | |
| renderAll(); | |
| } | |
| function handleBulkMove(statusId) { | |
| if (!state.selectedIds.size) return; | |
| state.selectedIds.forEach((taskId) => updateTask(taskId, { status: statusId }, { skipRender: true, skipPersist: true })); | |
| saveTasks(); | |
| renderAll(); | |
| } | |
| function handleSelectAll() { | |
| const filtered = getFilteredTasks(); | |
| filtered.forEach((task) => state.selectedIds.add(task.id)); | |
| updateBulkActions(); | |
| renderAll(); | |
| } | |
| function focusParentTasks(parentId) { | |
| const parentTask = getTask(parentId); | |
| state.filters.search = ""; | |
| state.filters.searchRaw = ""; | |
| state.filters.selectedTags = []; | |
| state.filters.dateFrom = ""; | |
| state.filters.dateTo = ""; | |
| state.filters.parentId = parentId; | |
| state.highlightParentId = parentId; | |
| state.showParentView = false; | |
| state.selectionMode = false; | |
| state.selectedIds.clear(); | |
| state.filters.parentSearch = ""; | |
| state.parentViewSearch = ""; | |
| if (parentTask) { | |
| state.activeStatus = parentTask.status; | |
| } | |
| renderAll(); | |
| } | |
| function toggleParentView() { | |
| state.showParentView = !state.showParentView; | |
| if (state.showParentView) { | |
| state.selectionMode = false; | |
| state.selectedIds.clear(); | |
| state.parentViewSearch = ""; | |
| } else { | |
| state.highlightParentId = ""; | |
| } | |
| renderAll(); | |
| } | |
| function toggleSelectionMode() { | |
| state.selectionMode = !state.selectionMode; | |
| if (!state.selectionMode) { | |
| state.selectedIds.clear(); | |
| } | |
| renderAll(); | |
| } | |
| function renderParentClusters() { | |
| dom.parentClusters.innerHTML = ""; | |
| if (!state.showParentView) return; | |
| if (dom.parentViewSearch) { | |
| dom.parentViewSearch.value = state.parentViewSearch; | |
| } | |
| if (dom.parentViewClear) { | |
| dom.parentViewClear.disabled = !state.highlightParentId; | |
| } | |
| const searchTerm = state.parentViewSearch.trim().toLowerCase(); | |
| const parents = state.tasks | |
| .filter((task) => !task.parentId) | |
| .filter((parent) => { | |
| const matchesParent = matchesTaskFilters(parent); | |
| const childMatches = state.tasks.some((child) => child.parentId === parent.id && matchesTaskFilters(child)); | |
| if (!(matchesParent || childMatches)) { | |
| return false; | |
| } | |
| if (!searchTerm) { | |
| return true; | |
| } | |
| const parentHaystack = [ | |
| parent.title, | |
| parent.assignee, | |
| parent.progress, | |
| parent.notes, | |
| parent.link, | |
| ...(parent.tags ?? []) | |
| ] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| if (parentHaystack.includes(searchTerm)) { | |
| return true; | |
| } | |
| return state.tasks.some((child) => { | |
| if (child.parentId !== parent.id) return false; | |
| const childHaystack = [ | |
| child.title, | |
| child.assignee, | |
| child.progress, | |
| child.notes, | |
| child.link, | |
| ...(child.tags ?? []) | |
| ] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| return childHaystack.includes(searchTerm); | |
| }); | |
| }) | |
| .sort((a, b) => a.title.localeCompare(b.title, "ja")); | |
| if (!parents.length) { | |
| const empty = document.createElement("div"); | |
| empty.className = "parent-empty"; | |
| empty.textContent = "条件に一致する親タスクがありません。"; | |
| dom.parentClusters.appendChild(empty); | |
| return; | |
| } | |
| parents.forEach((parent) => { | |
| const children = state.tasks.filter((task) => task.parentId === parent.id); | |
| const isHighlighted = state.highlightParentId === parent.id; | |
| let expanded = isHighlighted; | |
| const cluster = document.createElement("div"); | |
| cluster.className = "parent-cluster"; | |
| if (isHighlighted) { | |
| cluster.classList.add("highlighted"); | |
| cluster.classList.add("expanded"); | |
| } | |
| const header = document.createElement("div"); | |
| header.className = "cluster-header"; | |
| const titleBox = document.createElement("div"); | |
| titleBox.className = "cluster-title"; | |
| const title = document.createElement("h4"); | |
| title.textContent = parent.title; | |
| const meta = document.createElement("div"); | |
| meta.className = "cluster-meta"; | |
| const metaParts = []; | |
| if (parent.assignee) metaParts.push(`担当: ${parent.assignee}`); | |
| if (parent.dueDate) metaParts.push(`期限: ${formatDate(parent.dueDate)}`); | |
| metaParts.push(`子タスク ${children.length} 件`); | |
| meta.textContent = metaParts.join(" / "); | |
| titleBox.append(title, meta); | |
| const statusStrip = document.createElement("div"); | |
| statusStrip.className = "cluster-status"; | |
| STATUSES.forEach((status) => { | |
| const count = children.filter((child) => child.status === status.id).length; | |
| const pill = document.createElement("span"); | |
| pill.className = "status-pill"; | |
| pill.textContent = `${status.label} ${count}`; | |
| statusStrip.appendChild(pill); | |
| }); | |
| const actions = document.createElement("div"); | |
| actions.className = "cluster-actions"; | |
| const toggleButton = document.createElement("button"); | |
| toggleButton.type = "button"; | |
| toggleButton.className = "btn tiny ghost"; | |
| toggleButton.textContent = expanded ? "折りたたむ" : "展開"; | |
| const focusButton = document.createElement("button"); | |
| focusButton.type = "button"; | |
| focusButton.className = "btn tiny subtle"; | |
| focusButton.textContent = "ボードで表示"; | |
| actions.append(toggleButton, focusButton); | |
| header.append(titleBox, statusStrip, actions); | |
| const body = document.createElement("div"); | |
| body.className = "cluster-body"; | |
| body.hidden = !expanded; | |
| if (children.length) { | |
| children | |
| .sort((a, b) => a.title.localeCompare(b.title, "ja")) | |
| .forEach((child) => { | |
| const row = document.createElement("div"); | |
| row.className = "cluster-child-row"; | |
| const statusLabel = STATUSES.find((status) => status.id === child.status)?.label ?? ""; | |
| row.innerHTML = `<span class="child-title">${child.title}</span><span class="child-status">${statusLabel}</span>`; | |
| row.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| focusParentTasks(parent.id); | |
| }); | |
| body.appendChild(row); | |
| }); | |
| } else { | |
| const emptyChild = document.createElement("div"); | |
| emptyChild.className = "cluster-child-empty"; | |
| emptyChild.textContent = "子タスクはまだありません。"; | |
| body.appendChild(emptyChild); | |
| } | |
| toggleButton.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| expanded = !expanded; | |
| body.hidden = !expanded; | |
| cluster.classList.toggle("expanded", expanded); | |
| toggleButton.textContent = expanded ? "折りたたむ" : "展開"; | |
| }); | |
| focusButton.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| focusParentTasks(parent.id); | |
| }); | |
| header.addEventListener("click", (event) => { | |
| if (event.target.closest("button")) return; | |
| focusParentTasks(parent.id); | |
| }); | |
| cluster.append(header, body); | |
| dom.parentClusters.appendChild(cluster); | |
| }); | |
| } | |
| function matchesTaskFilters(task, options = {}) { | |
| const { includeTags = true } = options; | |
| const search = state.filters.search; | |
| const selectedTags = state.filters.selectedTags; | |
| const from = state.filters.dateFrom; | |
| const to = state.filters.dateTo; | |
| if (search) { | |
| const haystack = [ | |
| task.title, | |
| task.assignee, | |
| task.progress, | |
| task.notes, | |
| task.link, | |
| ...(task.tags ?? []) | |
| ] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| if (!haystack.includes(search)) { | |
| return false; | |
| } | |
| } | |
| if (includeTags && selectedTags.length) { | |
| const lowerTags = (task.tags ?? []).map((tag) => tag.toLowerCase()); | |
| const matchesAll = selectedTags.every((tag) => lowerTags.includes(tag.toLowerCase())); | |
| if (!matchesAll) return false; | |
| } | |
| if (from && task.dueDate && task.dueDate < from) return false; | |
| if (to && task.dueDate && task.dueDate > to) return false; | |
| return true; | |
| } | |
| function getFilteredTasks(options = {}) { | |
| const { includeTagFilter = true } = options; | |
| const parentId = state.filters.parentId; | |
| const highlight = state.highlightParentId; | |
| return state.tasks.filter((task) => { | |
| if (!matchesTaskFilters(task, { includeTags: includeTagFilter })) { | |
| return false; | |
| } | |
| if (parentId && !(task.id === parentId || task.parentId === parentId)) { | |
| return false; | |
| } | |
| if (highlight && !(task.id === highlight || task.parentId === highlight)) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| } | |
| function groupByStatus(tasks) { | |
| return tasks.reduce((acc, task) => { | |
| if (!acc[task.status]) acc[task.status] = []; | |
| acc[task.status].push(task); | |
| return acc; | |
| }, {}); | |
| } | |
| function updateTask(taskId, patch, options = {}) { | |
| const task = getTask(taskId); | |
| if (!task) return; | |
| const { skipRender = false, skipPersist = false } = options; | |
| Object.assign(task, patch, { updatedAt: Date.now() }); | |
| if (!skipPersist) { | |
| saveTasks(); | |
| } | |
| if (!skipRender) { | |
| renderAll(); | |
| } | |
| } | |
| function deleteTask(taskId) { | |
| const childIds = state.tasks.filter((task) => task.parentId === taskId).map((task) => task.id); | |
| state.tasks = state.tasks.filter((task) => task.id !== taskId); | |
| childIds.forEach((childId) => deleteTask(childId)); | |
| state.selectedIds.delete(taskId); | |
| saveTasks(); | |
| } | |
| function getTask(taskId) { | |
| return state.tasks.find((task) => task.id === taskId); | |
| } | |
| function isDescendant(candidateId, targetId) { | |
| if (!candidateId || !targetId) return false; | |
| let cursor = state.tasks.find((task) => task.id === candidateId)?.parentId; | |
| while (cursor) { | |
| if (cursor === targetId) return true; | |
| cursor = state.tasks.find((task) => task.id === cursor)?.parentId; | |
| } | |
| return false; | |
| } | |
| function updateBulkActions() { | |
| const visible = state.selectionMode; | |
| const count = state.selectedIds.size; | |
| if (dom.selectionToolbar) { | |
| dom.selectionToolbar.classList.toggle("active", visible); | |
| } | |
| if (dom.selectionCount) { | |
| dom.selectionCount.textContent = `${count}\u4EF6\u9078\u629E\u4E2D`; | |
| } | |
| dom.bulkDeleteBtn.disabled = count === 0; | |
| dom.clearSelectionBtn.disabled = count === 0; | |
| dom.bulkStatusButtons.querySelectorAll("button").forEach((button) => { | |
| button.disabled = count === 0; | |
| }); | |
| } | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", init, { once: true }); | |
| } else { | |
| init(); | |
| } | |
| function formatDate(isoDate) { | |
| if (!isoDate) return ""; | |
| const [year, month, day] = isoDate.split("-"); | |
| return `${year}/${month}/${day}`; | |
| } | |
| function generateId() { | |
| if (crypto?.randomUUID) return crypto.randomUUID(); | |
| return `task-${Date.now()}-${Math.random().toString(16).slice(2)}`; | |
| } | |
| function createSampleTasks() { | |
| const now = new Date(); | |
| const iso = (offset) => { | |
| const base = new Date(now); | |
| base.setDate(base.getDate() + offset); | |
| return base.toISOString().slice(0, 10); | |
| }; | |
| const projectId = generateId(); | |
| return [ | |
| { | |
| id: projectId, | |
| title: "\u30e9\u30a4\u30f3\u6539\u5584\u306e\u5168\u4f53\u8a2d\u8a08", | |
| assignee: "\u7530\u4e2d", | |
| dueDate: iso(7), | |
| link: "https://www.toyota.co.jp/", | |
| tags: ["\u5de5\u7a0b\u7ba1\u7406", "\u512a\u5148"], | |
| progress: "\u73fe\u72b6\u5206\u6790\u3092\u5b8c\u4e86\u3002\u6539\u5584\u6848\u306e\u521d\u7a3f\u3092\u4f5c\u6210\u6e08\u307f\u3002", | |
| notes: "\u30b3\u30b9\u30c8\u8a66\u7b97\u3092\u8ffd\u52a0\u3059\u308b\u5fc5\u8981\u3042\u308a\u3002", | |
| parentId: "", | |
| status: "ready", | |
| createdAt: Date.now(), | |
| updatedAt: Date.now() | |
| }, | |
| { | |
| id: generateId(), | |
| title: "\u6f5c\u5728\u30dc\u30c8\u30eb\u30cd\u30c3\u30af\u306e\u8abf\u67fb", | |
| assignee: "\u4f50\u85e4", | |
| dueDate: iso(3), | |
| link: "", | |
| tags: ["\u5de5\u7a0b\u7ba1\u7406", "\u8abf\u67fb"], | |
| progress: "\u30e9\u30a4\u30f3\u505c\u6b62\u30ed\u30b0\u304b\u30895\u4ef6\u306e\u5019\u88dc\u3092\u62bd\u51fa\u3002", | |
| notes: "\u73fe\u5834\u30d2\u30a2\u30ea\u30f3\u30b0\u3092\u4e88\u5b9a\u3002", | |
| parentId: projectId, | |
| status: "inprogress", | |
| createdAt: Date.now(), | |
| updatedAt: Date.now() | |
| }, | |
| { | |
| id: generateId(), | |
| title: "\u5b89\u5168\u6559\u80b2\u30b3\u30f3\u30c6\u30f3\u30c4\u306e\u898b\u76f4\u3057", | |
| assignee: "\u4e2d\u6751", | |
| dueDate: iso(12), | |
| link: "file:///\\\\server\\docs\\safety", | |
| tags: ["\u6559\u80b2", "\u5b89\u5168"], | |
| progress: "\u65e2\u5b58\u6559\u6750\u306e\u68da\u5378\u3057\u5b8c\u4e86\u3002", | |
| notes: "\u65b0\u5165\u793e\u54e1\u5411\u3051\u306e\u8ffd\u52a0\u30d1\u30fc\u30c8\u304c\u5fc5\u8981\u3002", | |
| parentId: "", | |
| status: "backlog", | |
| createdAt: Date.now(), | |
| updatedAt: Date.now() | |
| }, | |
| { | |
| id: generateId(), | |
| title: "AI\u5224\u5b9a\u30e2\u30c7\u30eb\u306e\u7cbe\u5ea6\u691c\u8a3c", | |
| assignee: "\u9234\u6728", | |
| dueDate: iso(-2), | |
| link: "", | |
| tags: ["AI", "\u54c1\u8cea"], | |
| progress: "\u30c6\u30b9\u30c8\u30c7\u30fc\u30bf\u306795%\u7cbe\u5ea6\u3002", | |
| notes: "\u73fe\u5834\u5c0e\u5165\u524d\u306b\u6700\u7d42\u78ba\u8a8d\u304c\u5fc5\u9808\u3002", | |
| parentId: "", | |
| status: "waiting", | |
| createdAt: Date.now(), | |
| updatedAt: Date.now() | |
| }, | |
| { | |
| id: generateId(), | |
| title: "\u6539\u5584\u6848\u30ec\u30d3\u30e5\u30fc\u4f1a\u8b70", | |
| assignee: "\u7530\u4e2d", | |
| dueDate: iso(1), | |
| link: "https://teams.microsoft.com/", | |
| tags: ["\u4f1a\u8b70", "\u512a\u5148"], | |
| progress: "\u30a2\u30b8\u30a7\u30f3\u30c0\u4f5c\u6210\u6e08\u307f\u3002", | |
| notes: "\u53c2\u52a0\u8005\u3078\u306e\u8cc7\u6599\u914d\u5e03\u3092\u5b8c\u4e86\u3002", | |
| parentId: projectId, | |
| status: "ready", | |
| createdAt: Date.now(), | |
| updatedAt: Date.now() | |
| } | |
| ]; | |
| } | |