diff --git a/scripts/verify-task-board.sh b/scripts/verify-task-board.sh new file mode 100755 index 0000000..d13c908 --- /dev/null +++ b/scripts/verify-task-board.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +required_files=( + "task-board/index.html" + "task-board/styles.css" + "task-board/app.js" + "task-board/README.md" +) + +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "FAIL: Missing required file: $file" + exit 1 + fi +done + +required_tokens=( + "addTask" + "toggleTaskComplete" + "deleteTask" + "setFilter" + "localStorage" +) + +for token in "${required_tokens[@]}"; do + if ! rg -q "$token" task-board/app.js; then + echo "FAIL: task-board/app.js missing token: $token" + exit 1 + fi +done + +echo "PASS: task-board verification succeeded" diff --git a/task-board/README.md b/task-board/README.md new file mode 100644 index 0000000..93b45e2 --- /dev/null +++ b/task-board/README.md @@ -0,0 +1,25 @@ +# Task Board + +A small vanilla JavaScript task board app. + +## Features + +- Add tasks with a single input and Add button +- Render tasks in a list +- Toggle task complete/incomplete +- Delete tasks +- Filter tasks by All, Active, and Completed +- Summary counts for total, active, and completed tasks +- Persist tasks in `localStorage` under the key `taskBoardItems` + +## Run locally + +1. Open `task-board/index.html` directly in a browser. +2. Or serve the directory with any static server, for example: + +```bash +cd task-board +python3 -m http.server 8080 +``` + +Then open `http://localhost:8080`. diff --git a/task-board/app.js b/task-board/app.js new file mode 100644 index 0000000..255f367 --- /dev/null +++ b/task-board/app.js @@ -0,0 +1,179 @@ +const STORAGE_KEY = "taskBoardItems"; + +const state = { + tasks: [], + filter: "all", +}; + +const elements = { + form: document.getElementById("task-form"), + input: document.getElementById("task-input"), + list: document.getElementById("task-list"), + summary: document.getElementById("summary"), + filters: document.getElementById("filters"), +}; + +function loadTasks() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveTasks() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.tasks)); +} + +function addTask(title) { + const cleanTitle = title.trim(); + if (!cleanTitle) { + return; + } + + state.tasks.push({ + id: crypto.randomUUID(), + title: cleanTitle, + completed: false, + }); + + saveTasks(); + render(); +} + +function toggleTaskComplete(taskId) { + const task = state.tasks.find((item) => item.id === taskId); + if (!task) { + return; + } + + task.completed = !task.completed; + saveTasks(); + render(); +} + +function deleteTask(taskId) { + state.tasks = state.tasks.filter((task) => task.id !== taskId); + saveTasks(); + render(); +} + +function setFilter(filterName) { + state.filter = filterName; + + Array.from(elements.filters.querySelectorAll("button")).forEach((button) => { + const isActive = button.dataset.filter === filterName; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); + + render(); +} + +function getFilteredTasks() { + if (state.filter === "active") { + return state.tasks.filter((task) => !task.completed); + } + + if (state.filter === "completed") { + return state.tasks.filter((task) => task.completed); + } + + return state.tasks; +} + +function renderSummary() { + const total = state.tasks.length; + const completed = state.tasks.filter((task) => task.completed).length; + const active = total - completed; + + elements.summary.textContent = `Total: ${total} | Active: ${active} | Completed: ${completed}`; +} + +function createTaskListItem(task) { + const item = document.createElement("li"); + item.className = "task-item"; + + const main = document.createElement("div"); + main.className = "task-main"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = task.completed; + checkbox.setAttribute("aria-label", `Toggle completion for ${task.title}`); + checkbox.addEventListener("change", () => toggleTaskComplete(task.id)); + + const title = document.createElement("span"); + title.className = "task-title"; + if (task.completed) { + title.classList.add("is-completed"); + } + title.textContent = task.title; + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "delete-btn"; + deleteButton.textContent = "Delete"; + deleteButton.addEventListener("click", () => deleteTask(task.id)); + + main.appendChild(checkbox); + main.appendChild(title); + item.appendChild(main); + item.appendChild(deleteButton); + + return item; +} + +function renderTaskList() { + const visibleTasks = getFilteredTasks(); + elements.list.innerHTML = ""; + + if (visibleTasks.length === 0) { + const empty = document.createElement("li"); + empty.className = "empty-state"; + empty.textContent = "No tasks to show for this filter."; + elements.list.appendChild(empty); + return; + } + + visibleTasks.forEach((task) => { + elements.list.appendChild(createTaskListItem(task)); + }); +} + +function render() { + renderTaskList(); + renderSummary(); +} + +function attachEventHandlers() { + elements.form.addEventListener("submit", (event) => { + event.preventDefault(); + addTask(elements.input.value); + elements.input.value = ""; + elements.input.focus(); + }); + + elements.filters.addEventListener("click", (event) => { + const button = event.target.closest("button[data-filter]"); + if (!button) { + return; + } + + setFilter(button.dataset.filter); + }); +} + +function initializeApp() { + state.tasks = loadTasks(); + attachEventHandlers(); + render(); +} + +initializeApp(); diff --git a/task-board/index.html b/task-board/index.html new file mode 100644 index 0000000..c477e8e --- /dev/null +++ b/task-board/index.html @@ -0,0 +1,50 @@ + + + + + + Task Board + + + +
+
+

Task Board

+

Track tasks with filters and completion status.

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

Total: 0 | Active: 0 | Completed: 0

+
+ +
+ +
+
+ + + + diff --git a/task-board/styles.css b/task-board/styles.css new file mode 100644 index 0000000..c9b1b72 --- /dev/null +++ b/task-board/styles.css @@ -0,0 +1,149 @@ +:root { + --bg: #f5f7fb; + --panel: #ffffff; + --border: #d9e0ee; + --text: #1f2937; + --muted: #6b7280; + --primary: #1d4ed8; + --primary-hover: #1e40af; + --danger: #b91c1c; + --danger-hover: #991b1b; + --complete: #0f766e; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + color: var(--text); + background: linear-gradient(180deg, #eff6ff 0%, var(--bg) 60%); +} + +.app { + max-width: 760px; + margin: 2rem auto; + padding: 0 1rem; +} + +.app__header { + margin-bottom: 1rem; +} + +.subtitle { + color: var(--muted); + margin-top: 0.25rem; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem; + margin-bottom: 0.75rem; +} + +.task-form { + display: flex; + gap: 0.5rem; +} + +.task-form input { + flex: 1; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 1rem; +} + +button { + border: 0; + border-radius: 8px; + padding: 0.6rem 0.85rem; + font-size: 0.95rem; + cursor: pointer; + color: #fff; + background: var(--primary); +} + +button:hover { + background: var(--primary-hover); +} + +.filters { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.filters button { + background: #e2e8f0; + color: #0f172a; +} + +.filters button.is-active { + background: var(--primary); + color: #fff; +} + +.task-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.5rem; +} + +.task-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem; + border: 1px solid var(--border); + border-radius: 8px; +} + +.task-main { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.task-title { + overflow-wrap: anywhere; +} + +.task-title.is-completed { + text-decoration: line-through; + color: var(--complete); +} + +.delete-btn { + background: var(--danger); +} + +.delete-btn:hover { + background: var(--danger-hover); +} + +.empty-state { + color: var(--muted); + text-align: center; + padding: 0.5rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +}