|
| 1 | +"use strict"; |
1 | 2 |
|
| 3 | +/** |
| 4 | + * Relay dependency checklist |
| 5 | + * The task catalogue is immutable; only completed task IDs are persisted. |
| 6 | + */ |
| 7 | +const PHASES = [ |
| 8 | + { |
| 9 | + id: "foundation", number: "01", title: "Foundation", subtitle: "Define the scope and establish the project baseline", color: "#c8f056", |
| 10 | + tasks: [ |
| 11 | + { id: "brief", title: "Create project brief", description: "Define the project goals, audience and key success metrics.", requires: [] }, |
| 12 | + { id: "stakeholders", title: "Confirm stakeholders", description: "Identify decision-makers and establish the approval workflow.", requires: ["brief"] }, |
| 13 | + { id: "requirements", title: "Gather requirements", description: "Document functional, content and technical requirements.", requires: ["brief", "stakeholders"] }, |
| 14 | + { id: "scope", title: "Approve project scope", description: "Lock deliverables, constraints and the delivery timeline.", requires: ["requirements"] } |
| 15 | + ] |
| 16 | + }, |
| 17 | + { |
| 18 | + id: "build", number: "02", title: "Design & build", subtitle: "Turn the approved direction into a working experience", color: "#6d8dff", |
| 19 | + tasks: [ |
| 20 | + { id: "wireframes", title: "Create wireframes", description: "Map core screens, hierarchy and user journeys.", requires: ["scope"] }, |
| 21 | + { id: "content", title: "Prepare final content", description: "Write, review and approve production-ready content.", requires: ["scope"] }, |
| 22 | + { id: "ui", title: "Design user interface", description: "Build the visual system and high-fidelity screens.", requires: ["wireframes"] }, |
| 23 | + { id: "development", title: "Develop website", description: "Implement responsive components and interactions.", requires: ["ui", "content"] }, |
| 24 | + { id: "qa", title: "Quality assurance", description: "Test accessibility, performance and browser compatibility.", requires: ["development"] } |
| 25 | + ] |
| 26 | + }, |
| 27 | + { |
| 28 | + id: "launch", number: "03", title: "Launch", subtitle: "Validate the release and bring the project live", color: "#aa82ff", |
| 29 | + tasks: [ |
| 30 | + { id: "approval", title: "Final stakeholder approval", description: "Collect sign-off on the production-ready experience.", requires: ["qa"] }, |
| 31 | + { id: "deploy", title: "Deploy to production", description: "Publish the approved release and verify the environment.", requires: ["approval"] }, |
| 32 | + { id: "handoff", title: "Complete project handoff", description: "Deliver documentation, assets and ownership details.", requires: ["deploy"] } |
| 33 | + ] |
| 34 | + } |
| 35 | +]; |
| 36 | + |
| 37 | +const STORAGE_KEY = "relay-checklist-v1"; |
| 38 | +const THEME_KEY = "relay-theme-v1"; |
| 39 | +const allTasks = PHASES.flatMap((phase) => phase.tasks); |
| 40 | +const taskById = new Map(allTasks.map((task) => [task.id, task])); |
| 41 | +let completed = loadCompleted(); |
| 42 | +let activeFilter = "all"; |
| 43 | +let toastTimer; |
| 44 | + |
| 45 | +const elements = { |
| 46 | + taskList: document.querySelector("#task-list"), progressCount: document.querySelector("#progress-count"), |
| 47 | + progressPercent: document.querySelector("#progress-percent"), mainProgressBar: document.querySelector("#main-progress-bar"), |
| 48 | + progressbar: document.querySelector("[role='progressbar']"), sidebarProgress: document.querySelector("#sidebar-progress"), |
| 49 | + sidebarProgressBar: document.querySelector("#sidebar-progress-bar"), nextTask: document.querySelector("#next-task"), |
| 50 | + nextUp: document.querySelector("#next-up"), savedState: document.querySelector("#saved-state"), |
| 51 | + dependencyDialog: document.querySelector("#dependency-dialog"), dependencyMap: document.querySelector("#dependency-map"), |
| 52 | + confirmDialog: document.querySelector("#confirm-dialog"), sidebar: document.querySelector(".sidebar"), |
| 53 | + menuButton: document.querySelector("#mobile-menu"), toast: document.querySelector("#toast"), |
| 54 | + toastMessage: document.querySelector("#toast-message") |
| 55 | +}; |
| 56 | + |
| 57 | +function loadCompleted() { |
| 58 | + try { |
| 59 | + const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); |
| 60 | + return new Set(Array.isArray(data) ? data.filter((id) => taskById.has(id)) : []); |
| 61 | + } catch (error) { |
| 62 | + console.warn("Stored checklist data could not be read.", error); |
| 63 | + return new Set(); |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +function isAvailable(task) { return task.requires.every((id) => completed.has(id)); } |
| 68 | +function statusOf(task) { return completed.has(task.id) ? "completed" : isAvailable(task) ? "available" : "locked"; } |
| 69 | + |
| 70 | +function persist() { |
| 71 | + elements.savedState.classList.add("saving"); |
| 72 | + elements.savedState.lastChild.textContent = " Saving…"; |
| 73 | + try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...completed])); } |
| 74 | + catch (error) { console.warn("Progress could not be saved.", error); } |
| 75 | + window.setTimeout(() => { |
| 76 | + elements.savedState.classList.remove("saving"); |
| 77 | + elements.savedState.lastChild.textContent = " Saved locally"; |
| 78 | + }, 320); |
| 79 | +} |
| 80 | + |
| 81 | +function makeIcon() { |
| 82 | + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| 83 | + svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("aria-hidden", "true"); |
| 84 | + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| 85 | + path.setAttribute("d", "m5 12 4 4L19 6"); svg.append(path); return svg; |
| 86 | +} |
| 87 | + |
| 88 | +function buildTask(task) { |
| 89 | + const status = statusOf(task); |
| 90 | + const article = document.createElement("article"); |
| 91 | + article.className = `task ${status}`; article.dataset.status = status; |
| 92 | + |
| 93 | + const check = document.createElement("button"); |
| 94 | + check.className = "task-check"; check.type = "button"; check.dataset.taskId = task.id; |
| 95 | + check.disabled = status === "locked"; |
| 96 | + check.setAttribute("aria-label", status === "completed" ? `Mark ${task.title} incomplete` : status === "locked" ? `${task.title} is locked` : `Complete ${task.title}`); |
| 97 | + check.setAttribute("aria-pressed", String(status === "completed")); check.append(makeIcon()); |
| 98 | + |
| 99 | + const copy = document.createElement("div"); copy.className = "task-copy"; |
| 100 | + const title = document.createElement("h3"); title.textContent = task.title; |
| 101 | + const description = document.createElement("p"); description.textContent = task.description; |
| 102 | + copy.append(title, description); |
| 103 | + |
| 104 | + if (task.requires.length) { |
| 105 | + const requirements = document.createElement("div"); requirements.className = "requirements"; |
| 106 | + const label = document.createElement("span"); label.className = "requires-label"; label.textContent = "Requires"; requirements.append(label); |
| 107 | + task.requires.forEach((id) => { |
| 108 | + const pill = document.createElement("span"); pill.className = `requirement-pill${completed.has(id) ? " met" : ""}`; |
| 109 | + pill.textContent = taskById.get(id).title; requirements.append(pill); |
| 110 | + }); |
| 111 | + copy.append(requirements); |
| 112 | + } |
| 113 | + |
| 114 | + const badge = document.createElement("span"); badge.className = "task-status"; |
| 115 | + badge.textContent = status === "completed" ? "Done" : status === "available" ? "Ready" : "Locked"; |
| 116 | + article.append(check, copy, badge); |
| 117 | + return article; |
| 118 | +} |
| 119 | + |
| 120 | +function buildPhaseHeader(phase, done) { |
| 121 | + const header = document.createElement("header"); header.className = "phase-header"; |
| 122 | + const titleWrap = document.createElement("div"); titleWrap.className = "phase-title"; |
| 123 | + const number = document.createElement("span"); number.className = "phase-number"; number.textContent = phase.number; |
| 124 | + const copy = document.createElement("div"); |
| 125 | + const title = document.createElement("h2"); title.textContent = phase.title; |
| 126 | + const subtitle = document.createElement("p"); subtitle.textContent = phase.subtitle; |
| 127 | + copy.append(title, subtitle); titleWrap.append(number, copy); |
| 128 | + |
| 129 | + const meta = document.createElement("div"); meta.className = "phase-meta"; |
| 130 | + const count = document.createElement("span"); count.textContent = `${done}/${phase.tasks.length} complete`; |
| 131 | + const track = document.createElement("span"); track.className = "phase-mini-track"; |
| 132 | + const fill = document.createElement("i"); fill.style.width = `${(done / phase.tasks.length) * 100}%`; |
| 133 | + track.append(fill); meta.append(count, track); header.append(titleWrap, meta); |
| 134 | + return header; |
| 135 | +} |
| 136 | + |
| 137 | +function render() { |
| 138 | + elements.taskList.replaceChildren(); |
| 139 | + let visibleTasks = 0; |
| 140 | + PHASES.forEach((phase) => { |
| 141 | + const matching = phase.tasks.filter((task) => activeFilter === "all" || statusOf(task) === activeFilter); |
| 142 | + if (!matching.length) return; |
| 143 | + visibleTasks += matching.length; |
| 144 | + const section = document.createElement("section"); section.className = "phase"; section.style.setProperty("--phase-color", phase.color); |
| 145 | + const done = phase.tasks.filter((task) => completed.has(task.id)).length; |
| 146 | + section.append(buildPhaseHeader(phase, done)); |
| 147 | + const list = document.createElement("div"); list.className = "tasks"; |
| 148 | + matching.forEach((task) => list.append(buildTask(task))); section.append(list); elements.taskList.append(section); |
| 149 | + }); |
| 150 | + if (!visibleTasks) { |
| 151 | + const empty = document.createElement("div"); empty.className = "empty-state"; |
| 152 | + const strong = document.createElement("strong"); strong.textContent = "Nothing here yet"; |
| 153 | + const span = document.createElement("span"); span.textContent = `No tasks match the ${activeFilter} filter.`; |
| 154 | + empty.append(strong, span); elements.taskList.append(empty); |
| 155 | + } |
| 156 | + updateSummary(); renderMap(); |
| 157 | +} |
| 158 | + |
| 159 | +function updateSummary() { |
| 160 | + const done = completed.size; const percent = Math.round((done / allTasks.length) * 100); |
| 161 | + elements.progressCount.textContent = `${done} of ${allTasks.length} tasks`; |
| 162 | + elements.progressPercent.textContent = `${percent}%`; elements.sidebarProgress.textContent = `${percent}%`; |
| 163 | + elements.mainProgressBar.style.width = `${percent}%`; elements.sidebarProgressBar.style.width = `${percent}%`; |
| 164 | + elements.progressbar.setAttribute("aria-valuenow", String(percent)); |
| 165 | + |
| 166 | + const next = allTasks.find((task) => !completed.has(task.id) && isAvailable(task)); |
| 167 | + elements.nextTask.textContent = next ? next.title : done === allTasks.length ? "Project complete" : "Finish prerequisites"; |
| 168 | + elements.nextUp.classList.toggle("complete", done === allTasks.length); |
| 169 | + |
| 170 | + const counts = { all: allTasks.length, available: 0, locked: 0, completed: done }; |
| 171 | + allTasks.forEach((task) => { if (!completed.has(task.id)) counts[statusOf(task)] += 1; }); |
| 172 | + Object.entries(counts).forEach(([key, value]) => { document.querySelector(`#${key}-count`).textContent = value; }); |
| 173 | +} |
| 174 | + |
| 175 | +function toggleTask(taskId) { |
| 176 | + const task = taskById.get(taskId); if (!task || (!completed.has(taskId) && !isAvailable(task))) return; |
| 177 | + if (completed.has(taskId)) { |
| 178 | + const dependents = getCompletedDependents(taskId); |
| 179 | + dependents.forEach((id) => completed.delete(id)); completed.delete(taskId); |
| 180 | + showToast(dependents.length ? `Task reopened; ${dependents.length} dependent task${dependents.length > 1 ? "s" : ""} reset` : "Task reopened"); |
| 181 | + } else { |
| 182 | + completed.add(taskId); showToast(`${task.title} completed`); |
| 183 | + } |
| 184 | + persist(); render(); |
| 185 | +} |
| 186 | + |
| 187 | +function getCompletedDependents(rootId) { |
| 188 | + const affected = new Set(); let changed = true; |
| 189 | + while (changed) { |
| 190 | + changed = false; |
| 191 | + allTasks.forEach((task) => { |
| 192 | + if (completed.has(task.id) && !affected.has(task.id) && task.requires.some((id) => id === rootId || affected.has(id))) { |
| 193 | + affected.add(task.id); changed = true; |
| 194 | + } |
| 195 | + }); |
| 196 | + } |
| 197 | + return affected; |
| 198 | +} |
| 199 | + |
| 200 | +function renderMap() { |
| 201 | + elements.dependencyMap.replaceChildren(); |
| 202 | + PHASES.forEach((phase) => { |
| 203 | + const column = document.createElement("section"); column.className = "map-phase"; column.style.setProperty("--phase-color", phase.color); |
| 204 | + const heading = document.createElement("h3"); const dot = document.createElement("i"); heading.append(dot, document.createTextNode(phase.title)); column.append(heading); |
| 205 | + phase.tasks.forEach((task) => { |
| 206 | + const node = document.createElement("div"); node.className = `map-node ${completed.has(task.id) ? "done" : statusOf(task)}`; |
| 207 | + node.append(document.createTextNode(task.title)); |
| 208 | + if (task.requires.length) { const small = document.createElement("small"); small.textContent = `After: ${task.requires.map((id) => taskById.get(id).title).join(" + ")}`; node.append(small); } |
| 209 | + column.append(node); |
| 210 | + }); |
| 211 | + elements.dependencyMap.append(column); |
| 212 | + }); |
| 213 | +} |
| 214 | + |
| 215 | +function showToast(message) { |
| 216 | + window.clearTimeout(toastTimer); elements.toastMessage.textContent = message; elements.toast.classList.add("show"); |
| 217 | + toastTimer = window.setTimeout(() => elements.toast.classList.remove("show"), 2600); |
| 218 | +} |
| 219 | + |
| 220 | +elements.taskList.addEventListener("click", (event) => { |
| 221 | + const button = event.target.closest("[data-task-id]"); if (button) toggleTask(button.dataset.taskId); |
| 222 | +}); |
| 223 | +document.querySelectorAll(".filter").forEach((button) => button.addEventListener("click", () => { |
| 224 | + activeFilter = button.dataset.filter; |
| 225 | + document.querySelectorAll(".filter").forEach((item) => { const active = item === button; item.classList.toggle("active", active); item.setAttribute("aria-pressed", String(active)); }); |
| 226 | + render(); |
| 227 | +})); |
| 228 | + |
| 229 | +function openMap() { elements.dependencyDialog.showModal(); } |
| 230 | +document.querySelector("#map-button").addEventListener("click", openMap); |
| 231 | +document.querySelector("#open-dependency-map").addEventListener("click", openMap); |
| 232 | +document.querySelector("#dialog-close").addEventListener("click", () => elements.dependencyDialog.close()); |
| 233 | +document.querySelector("#reset-button").addEventListener("click", () => elements.confirmDialog.showModal()); |
| 234 | +document.querySelector("#cancel-reset").addEventListener("click", () => elements.confirmDialog.close()); |
| 235 | +document.querySelector("#confirm-reset").addEventListener("click", () => { completed.clear(); persist(); render(); elements.confirmDialog.close(); showToast("All progress has been reset"); }); |
| 236 | + |
| 237 | +elements.menuButton.addEventListener("click", () => { |
| 238 | + const open = elements.sidebar.classList.toggle("open"); elements.menuButton.setAttribute("aria-expanded", String(open)); |
| 239 | +}); |
| 240 | +document.addEventListener("click", (event) => { |
| 241 | + if (window.innerWidth <= 740 && elements.sidebar.classList.contains("open") && !elements.sidebar.contains(event.target) && !elements.menuButton.contains(event.target)) { |
| 242 | + elements.sidebar.classList.remove("open"); elements.menuButton.setAttribute("aria-expanded", "false"); |
| 243 | + } |
| 244 | +}); |
| 245 | + |
| 246 | +const preferredTheme = localStorage.getItem(THEME_KEY) || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); |
| 247 | +document.documentElement.dataset.theme = preferredTheme; |
| 248 | +document.querySelector("#theme-toggle").addEventListener("click", () => { |
| 249 | + const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; |
| 250 | + document.documentElement.dataset.theme = theme; localStorage.setItem(THEME_KEY, theme); |
| 251 | +}); |
| 252 | + |
| 253 | +[elements.dependencyDialog, elements.confirmDialog].forEach((dialog) => dialog.addEventListener("click", (event) => { |
| 254 | + if (event.target === dialog) dialog.close(); |
| 255 | +})); |
| 256 | + |
| 257 | +render(); |
0 commit comments