Skip to content

Commit 75f28a9

Browse files
authored
Add files via upload
1 parent 6ae214b commit 75f28a9

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

  • 049-dependency-checklist/js

‎049-dependency-checklist/js/app.js‎

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,257 @@
1+
"use strict";
12

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

Comments
 (0)