From 12e766d24e7b408a15af5477fab83d14bd2d0442 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 14:19:24 +0000 Subject: [PATCH 1/5] Add board design team task management dashboard A modern, single-page task management dashboard for the board design team leader. Features include: - Overview with KPI stats, today's tasks strip, team workload cards, and leader task summary - Today view with priority columns (Critical / High / Medium) and animated completion progress ring - Kanban board with per-member filtering across four status columns - Team view with per-member task cards showing active tasks, progress, and overdue indicators - Leader (My Tasks) view with full-detail task cards - New Task modal with full form (assignee, priority, status, due date, project, description) - Global search and status/priority filters wired to all views - Collapsible sidebar navigation - 32 seeded tasks across 6 team members and 4 board design projects - Dark theme with Linear-inspired indigo accent design system https://claude.ai/code/session_01BddfitpYfdVvu7oAjt5i14 --- dashboard/app.js | 739 +++++++++++++++++++++++++++++++++++++ dashboard/index.html | 424 ++++++++++++++++++++++ dashboard/styles.css | 842 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 2005 insertions(+) create mode 100644 dashboard/app.js create mode 100644 dashboard/index.html create mode 100644 dashboard/styles.css diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 00000000000..4338e2c9189 --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,739 @@ +/* ═══════════════════════════════════════════════════ + BOARDFLOW — App Logic & Data +════════════════════════════════════════════════════ */ + +// ─── DATE HELPERS ───────────────────────────────── +const today = new Date(); +const fmt = (d) => d.toISOString().slice(0, 10); +const todayStr = fmt(today); + +function relDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + const diff = Math.round((d - today) / 86400000); + if (diff < -1) return `${Math.abs(diff)}d overdue`; + if (diff === -1) return 'Yesterday'; + if (diff === 0) return 'Today'; + if (diff === 1) return 'Tomorrow'; + if (diff < 7) return `In ${diff}d`; + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function isOverdue(dateStr) { + return dateStr && new Date(dateStr) < today && fmt(new Date(dateStr)) !== todayStr; +} +function isToday(dateStr) { + return dateStr && fmt(new Date(dateStr)) === todayStr; +} +function offsetDay(n) { + const d = new Date(today); + d.setDate(d.getDate() + n); + return fmt(d); +} + +// ─── AVATAR COLORS ──────────────────────────────── +const COLORS = { + AL: '#6366f1', SM: '#22c55e', JD: '#f97316', + MP: '#ec4899', RK: '#0ea5e9', TW: '#a855f7' +}; + +// ─── TEAM DATA ───────────────────────────────────── +const TEAM = [ + { id: 'AL', name: 'Alex Laurent', role: 'Team Leader', initials: 'AL' }, + { id: 'SM', name: 'Sara Mitchell', role: 'Senior PCB Designer', initials: 'SM' }, + { id: 'JD', name: 'James Dupont', role: 'Schematic Engineer', initials: 'JD' }, + { id: 'MP', name: 'Mia Park', role: 'Signal Integrity Eng', initials: 'MP' }, + { id: 'RK', name: 'Ryan Kim', role: 'BOM & Procurement', initials: 'RK' }, + { id: 'TW', name: 'Tara Wilson', role: 'Layout Engineer', initials: 'TW' }, +]; + +// ─── TASKS DATA ──────────────────────────────────── +let tasks = [ + // ── ALEX (Leader) ────────────────────────────── + { + id: 1, assignee: 'AL', title: 'Review PCB Layout v4 final submission', + status: 'in-progress', priority: 'critical', + project: 'PCB Layout v4', due: offsetDay(0), + desc: 'Final sign-off review before handoff to manufacturing.', + tags: ['review', 'sign-off'], + }, + { + id: 2, assignee: 'AL', title: 'Approve BOM for new supplier quotes', + status: 'todo', priority: 'critical', + project: 'BOM Validation', due: offsetDay(0), + desc: 'Compare new supplier quotes and approve updated BOM.', + tags: ['approval', 'procurement'], + }, + { + id: 3, assignee: 'AL', title: 'Sprint planning meeting — Q2 board projects', + status: 'done', priority: 'high', + project: 'PCB Layout v4', due: offsetDay(-1), + desc: 'Quarterly sprint planning for all active board design projects.', + tags: ['meeting', 'planning'], + }, + { + id: 4, assignee: 'AL', title: 'Resolve EMI issue flagged by Mia on power plane', + status: 'in-progress', priority: 'high', + project: 'Signal Integrity', due: offsetDay(1), + desc: 'Coordinate with Mia to address EMI findings on the power plane layer.', + tags: ['EMI', 'signal-integrity'], + }, + { + id: 5, assignee: 'AL', title: 'Update design guidelines document v3', + status: 'todo', priority: 'medium', + project: 'Schematic Review', due: offsetDay(3), + desc: 'Incorporate feedback from last retrospective into team design guidelines.', + tags: ['docs'], + }, + { + id: 6, assignee: 'AL', title: 'Kickoff call with new contractor', + status: 'todo', priority: 'medium', + project: 'PCB Layout v4', due: offsetDay(2), + desc: 'Onboarding call for new layout contractor joining the project.', + tags: ['meeting'], + }, + { + id: 7, assignee: 'AL', title: 'Review signal integrity report from Mia', + status: 'review', priority: 'high', + project: 'Signal Integrity', due: offsetDay(0), + desc: 'Full SI simulation report review before client presentation.', + tags: ['review'], + }, + { + id: 8, assignee: 'AL', title: 'Archive old component libraries', + status: 'todo', priority: 'low', + project: 'Schematic Review', due: offsetDay(7), + desc: 'Remove deprecated component libraries from shared drives.', + tags: ['maintenance'], + }, + + // ── SARA ─────────────────────────────────────── + { + id: 9, assignee: 'SM', title: 'Finalize copper pour on power layers 3 & 4', + status: 'in-progress', priority: 'critical', + project: 'PCB Layout v4', due: offsetDay(0), + desc: 'Complete copper pour optimization on layers 3 and 4.', + tags: ['layout', 'power'], + }, + { + id: 10, assignee: 'SM', title: 'DRC clean-up — 47 remaining errors', + status: 'in-progress', priority: 'high', + project: 'PCB Layout v4', due: offsetDay(1), + desc: 'Address all DRC violations before submission.', + tags: ['DRC', 'clean-up'], + }, + { + id: 11, assignee: 'SM', title: 'Place decoupling capacitors near ICs', + status: 'done', priority: 'high', + project: 'PCB Layout v4', due: offsetDay(-1), + desc: 'Place all decoupling caps per schematic netlist.', + tags: ['placement'], + }, + { + id: 12, assignee: 'SM', title: 'Export Gerber files for fab review', + status: 'todo', priority: 'medium', + project: 'PCB Layout v4', due: offsetDay(2), + desc: 'Generate and verify Gerber output files for fabrication.', + tags: ['export', 'gerber'], + }, + { + id: 13, assignee: 'SM', title: 'Update board outline per mechanical drawing', + status: 'todo', priority: 'medium', + project: 'PCB Layout v4', due: offsetDay(3), + desc: 'Adjust PCB outline to match latest mechanical CAD file.', + tags: ['mechanical'], + }, + + // ── JAMES ────────────────────────────────────── + { + id: 14, assignee: 'JD', title: 'Schematic annotation & cross-reference update', + status: 'in-progress', priority: 'high', + project: 'Schematic Review', due: offsetDay(0), + desc: 'Re-annotate all schematics and update cross-references.', + tags: ['schematic', 'annotation'], + }, + { + id: 15, assignee: 'JD', title: 'Add missing power symbols on sheet 7', + status: 'todo', priority: 'critical', + project: 'Schematic Review', due: offsetDay(0), + desc: 'Sheet 7 is missing VCC and GND symbols flagged in last review.', + tags: ['schematic', 'error'], + }, + { + id: 16, assignee: 'JD', title: 'Review connector pinout for USB-C block', + status: 'review', priority: 'high', + project: 'Schematic Review', due: offsetDay(1), + desc: 'Verify USB-C connector pinout against datasheet.', + tags: ['connector', 'USB-C'], + }, + { + id: 17, assignee: 'JD', title: 'ERC report — resolve 12 warnings', + status: 'todo', priority: 'medium', + project: 'Schematic Review', due: offsetDay(2), + desc: 'Address ERC warnings from last tool run.', + tags: ['ERC'], + }, + { + id: 18, assignee: 'JD', title: 'Document power sequencing requirements', + status: 'done', priority: 'medium', + project: 'Schematic Review', due: offsetDay(-2), + desc: 'Write power sequencing document for hardware team.', + tags: ['docs'], + }, + + // ── MIA ──────────────────────────────────────── + { + id: 19, assignee: 'MP', title: 'Run IBIS simulation on DDR5 data bus', + status: 'in-progress', priority: 'critical', + project: 'Signal Integrity', due: offsetDay(0), + desc: 'Full IBIS simulation for DDR5 channel compliance.', + tags: ['simulation', 'DDR5'], + }, + { + id: 20, assignee: 'MP', title: 'Eye diagram analysis — high-speed pairs', + status: 'todo', priority: 'high', + project: 'Signal Integrity', due: offsetDay(1), + desc: 'Analyze eye diagrams for all differential pairs > 1 Gbps.', + tags: ['eye-diagram', 'SI'], + }, + { + id: 21, assignee: 'MP', title: 'EMI pre-compliance check on power plane', + status: 'review', priority: 'critical', + project: 'Signal Integrity', due: offsetDay(0), + desc: 'Flag potential EMI issues for team leader review.', + tags: ['EMI', 'compliance'], + }, + { + id: 22, assignee: 'MP', title: 'Prepare SI summary report for client', + status: 'todo', priority: 'medium', + project: 'Signal Integrity', due: offsetDay(3), + desc: 'Compile simulation results into client-facing report.', + tags: ['report'], + }, + + // ── RYAN ─────────────────────────────────────── + { + id: 23, assignee: 'RK', title: 'Validate BOM against approved vendor list', + status: 'in-progress', priority: 'high', + project: 'BOM Validation', due: offsetDay(0), + desc: 'Check all components against AVL and flag non-compliant parts.', + tags: ['BOM', 'AVL'], + }, + { + id: 24, assignee: 'RK', title: 'Source alternative for EOL capacitor C47', + status: 'todo', priority: 'critical', + project: 'BOM Validation', due: offsetDay(0), + desc: 'C47 is end-of-life; find approved alternative ASAP.', + tags: ['EOL', 'BOM'], + }, + { + id: 25, assignee: 'RK', title: 'Update component prices in cost tracker', + status: 'done', priority: 'medium', + project: 'BOM Validation', due: offsetDay(-1), + desc: 'Update Q2 component pricing from distributor portal.', + tags: ['cost', 'BOM'], + }, + { + id: 26, assignee: 'RK', title: 'Request lead time quotes from 3 suppliers', + status: 'todo', priority: 'high', + project: 'BOM Validation', due: offsetDay(2), + desc: 'Get lead time estimates for long-lead items.', + tags: ['procurement'], + }, + { + id: 27, assignee: 'RK', title: 'Reconcile BOM rev B vs rev C changes', + status: 'todo', priority: 'medium', + project: 'BOM Validation', due: offsetDay(4), + desc: 'Diff the two BOM revisions and document changes.', + tags: ['BOM'], + }, + + // ── TARA ─────────────────────────────────────── + { + id: 28, assignee: 'TW', title: 'Trace routing — high-speed clock nets', + status: 'in-progress', priority: 'critical', + project: 'PCB Layout v4', due: offsetDay(0), + desc: 'Route all clock differential pairs with proper length matching.', + tags: ['routing', 'clocks'], + }, + { + id: 29, assignee: 'TW', title: 'Implement length matching on DDR5 byte lanes', + status: 'todo', priority: 'high', + project: 'PCB Layout v4', due: offsetDay(1), + desc: 'Match trace lengths on all DDR5 byte lanes per spec.', + tags: ['length-matching', 'DDR5'], + }, + { + id: 30, assignee: 'TW', title: 'Fix via stitching around RF area', + status: 'review', priority: 'high', + project: 'PCB Layout v4', due: offsetDay(0), + desc: 'Add sufficient ground via stitching around RF antenna area.', + tags: ['via', 'RF'], + }, + { + id: 31, assignee: 'TW', title: 'Board outline layer 3D export for enclosure check', + status: 'todo', priority: 'medium', + project: 'PCB Layout v4', due: offsetDay(3), + desc: 'Export 3D model for mechanical team enclosure validation.', + tags: ['3D', 'export'], + }, + { + id: 32, assignee: 'TW', title: 'Update layer stackup documentation', + status: 'done', priority: 'medium', + project: 'PCB Layout v4', due: offsetDay(-2), + desc: 'Update stackup doc with final impedance target values.', + tags: ['docs', 'stackup'], + }, +]; + +// ─── STATE ──────────────────────────────────────── +let currentView = 'overview'; +let kanbanFilter = 'all'; +let searchQuery = ''; +let statusFilter = 'all'; +let priorityFilter = 'all'; + +// ─── PRIORITY CONFIG ────────────────────────────── +const PRIORITY = { + critical: { label: 'Critical', color: '#ef4444', icon: '▲▲' }, + high: { label: 'High', color: '#f97316', icon: '▲' }, + medium: { label: 'Medium', color: '#eab308', icon: '●' }, + low: { label: 'Low', color: '#94a3b8', icon: '▼' }, +}; +const STATUS_LABEL = { + 'todo': 'To Do', 'in-progress': 'In Progress', 'review': 'In Review', 'done': 'Done', +}; + +// ─── HELPERS ───────────────────────────────────── +function memberById(id) { return TEAM.find(m => m.id === id); } + +function avatar(id, size = '') { + const m = memberById(id); + return `
${m?.initials || id}
`; +} + +function priorityBadge(p) { + return `${PRIORITY[p]?.icon || ''} ${PRIORITY[p]?.label || p}`; +} + +function statusBadge(s) { + return `${STATUS_LABEL[s] || s}`; +} + +function dueBadge(due) { + if (!due) return ''; + const cls = isOverdue(due) ? 'overdue' : isToday(due) ? 'today' : ''; + const label = relDate(due); + return ` + + + + ${label}`; +} + +function priorityDot(p) { + return ``; +} + +function filteredTasks() { + return tasks.filter(t => { + if (statusFilter !== 'all' && t.status !== statusFilter) return false; + if (priorityFilter !== 'all' && t.priority !== priorityFilter) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + if (!t.title.toLowerCase().includes(q) && !t.project.toLowerCase().includes(q)) return false; + } + return true; + }); +} + +// ─── RENDER: TASK ROW (compact) ─────────────────── +function renderTaskRow(task) { + const done = task.status === 'done'; + return ` +
+
+ ${task.title} +
+ ${task.project} + ${dueBadge(task.due)} + ${priorityBadge(task.priority)} + ${statusBadge(task.status)} + ${avatar(task.assignee)} +
+
`; +} + +// ─── RENDER: TASK CARD (full) ───────────────────── +function renderTaskCard(task) { + const done = task.status === 'done'; + const tagsHtml = task.tags.map(t => `${t}`).join(''); + return ` +
+
+ ${task.title} +
+ ${priorityBadge(task.priority)} + ${statusBadge(task.status)} +
+
+ ${task.desc ? `

${task.desc}

` : ''} +
+ ${avatar(task.assignee)} + ${memberById(task.assignee)?.name || task.assignee} + ${task.project} + ${dueBadge(task.due)} +
+ ${tagsHtml ? `
${tagsHtml}
` : ''} +
`; +} + +// ─── RENDER: TODAY TASK ROW ─────────────────────── +function renderTodayCard(task) { + const done = task.status === 'done'; + return ` +
+
+
+
${task.title}
+
${task.project} • ${memberById(task.assignee)?.name}
+
+
+ ${statusBadge(task.status)} + ${avatar(task.assignee)} +
+
`; +} + +// ─── TOGGLE DONE ────────────────────────────────── +function toggleDone(e, id) { + e.stopPropagation(); + const task = tasks.find(t => t.id === id); + if (!task) return; + task.status = task.status === 'done' ? 'todo' : 'done'; + renderAll(); +} + +// ─── VIEW: OVERVIEW ─────────────────────────────── +function renderOverview() { + const ft = filteredTasks(); + + // Today's tasks (strip — max 5) + const todayTs = ft.filter(t => isToday(t.due) || (isOverdue(t.due) && t.status !== 'done')); + const todayHtml = todayTs.slice(0, 5).map(renderTaskRow).join('') || + '
No tasks due today.
'; + document.getElementById('todayTasksOverview').innerHTML = todayHtml; + + // Team workload + renderTeamWorkload('teamWorkloadGrid', ft, 3); + + // Leader tasks + const leaderTs = ft.filter(t => t.assignee === 'AL').slice(0, 5); + document.getElementById('leaderTasksOverview').innerHTML = + leaderTs.map(renderTaskRow).join('') || '
No tasks found.
'; +} + +// ─── VIEW: TODAY ───────────────────────────────── +function renderToday() { + const ft = filteredTasks(); + const due = ft.filter(t => isToday(t.due) || isOverdue(t.due)); + const critical = due.filter(t => t.priority === 'critical'); + const high = due.filter(t => t.priority === 'high'); + const medium = due.filter(t => t.priority === 'medium' || t.priority === 'low'); + + document.getElementById('criticalCount').textContent = critical.length; + document.getElementById('highCount').textContent = high.length; + document.getElementById('mediumCount').textContent = medium.length; + + document.getElementById('criticalTasks').innerHTML = + critical.map(renderTodayCard).join('') || '
None
'; + document.getElementById('highTasks').innerHTML = + high.map(renderTodayCard).join('') || '
None
'; + document.getElementById('mediumTasks').innerHTML = + medium.map(renderTodayCard).join('') || '
None
'; + + // Progress ring + const total = due.length || 1; + const done = due.filter(t => t.status === 'done').length; + const pct = Math.round((done / total) * 100); + const circ = 2 * Math.PI * 32; + document.getElementById('ringFill').style.strokeDasharray = circ; + document.getElementById('ringFill').style.strokeDashoffset = circ - (circ * pct / 100); + document.getElementById('ringPct').textContent = pct + '%'; + + // Greeting + const h = today.getHours(); + const greeting = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'; + document.getElementById('todayGreeting').textContent = `${greeting}, Alex`; +} + +// ─── VIEW: KANBAN ───────────────────────────────── +function renderKanban() { + const COLUMNS = [ + { key: 'todo', label: 'To Do', dotColor: '#5a5f72' }, + { key: 'in-progress', label: 'In Progress', dotColor: '#6366f1' }, + { key: 'review', label: 'In Review', dotColor: '#a855f7' }, + { key: 'done', label: 'Done', dotColor: '#22c55e' }, + ]; + + let ft = filteredTasks(); + if (kanbanFilter !== 'all') ft = ft.filter(t => t.assignee === kanbanFilter); + + const board = document.getElementById('kanbanBoard'); + board.innerHTML = COLUMNS.map(col => { + const colTasks = ft.filter(t => t.status === col.key); + const cards = colTasks.map(t => { + const overdueClass = isOverdue(t.due) ? 'overdue' : isToday(t.due) ? 'today-due' : ''; + const dueLabel = t.due ? relDate(t.due) : ''; + return ` +
+
+ ${t.title} + ${priorityDot(t.priority)} +
+
+ ${t.project} + ${priorityBadge(t.priority)} +
+ +
`; + }).join('') || '
No tasks
'; + + return ` +
+
+ + + ${col.label} + + ${colTasks.length} +
+ ${cards} +
`; + }).join(''); +} + +// ─── VIEW: TEAM WORKLOAD ────────────────────────── +function renderTeamWorkload(containerId, ft, maxTasks = 4) { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = TEAM.filter(m => m.id !== 'AL').map(member => { + const memberTasks = ft.filter(t => t.assignee === member.id); + const inProg = memberTasks.filter(t => t.status === 'in-progress' || t.status === 'review').length; + const total = memberTasks.length; + const pct = total === 0 ? 0 : Math.round((inProg / total) * 100); + const barColor = pct > 80 ? '#ef4444' : pct > 50 ? '#f97316' : '#6366f1'; + + const topTasks = memberTasks + .filter(t => t.status !== 'done') + .sort((a, b) => { + const po = ['critical','high','medium','low']; + return po.indexOf(a.priority) - po.indexOf(b.priority); + }) + .slice(0, maxTasks); + + const miniTasks = topTasks.map(t => { + const c = PRIORITY[t.priority]?.color || '#94a3b8'; + return `
+ + ${t.title} + ${statusBadge(t.status)} +
`; + }).join('') || '
No active tasks
'; + + return ` +
+
+ ${avatar(member.id)} +
+
${member.name}
+
${member.role}
+
+ ${total} tasks +
+
+
+ Workload${inProg}/${total} active +
+
+
+
+
+
${miniTasks}
+
`; + }).join(''); +} + +// ─── VIEW: TEAM MEMBERS ─────────────────────────── +function renderTeamView() { + const ft = filteredTasks(); + const grid = document.getElementById('teamMembersGrid'); + + grid.innerHTML = TEAM.map(member => { + const memberTasks = ft.filter(t => t.assignee === member.id); + const active = memberTasks.filter(t => t.status !== 'done'); + const done = memberTasks.filter(t => t.status === 'done'); + const overdue = memberTasks.filter(t => isOverdue(t.due) && t.status !== 'done'); + const today_t = memberTasks.filter(t => isToday(t.due) && t.status !== 'done'); + const isLeader = member.id === 'AL'; + + const taskItems = active + .sort((a, b) => ['critical','high','medium','low'].indexOf(a.priority) - ['critical','high','medium','low'].indexOf(b.priority)) + .map(t => { + const overCls = isOverdue(t.due) ? 'overdue' : ''; + const dueStr = t.due ? relDate(t.due) : ''; + return ` +
+ ${priorityDot(t.priority)} + ${t.title} + ${statusBadge(t.status)} + ${dueStr ? `${dueStr}` : ''} +
`; + }).join('') || '
All tasks complete!
'; + + return ` +
+
+ ${avatar(member.id, 'lg')} +
+
${member.name} ${isLeader ? 'Leader' : ''}
+
${member.role}
+
+
+
+ ${active.length} active + ${done.length} done + ${today_t.length} due today + ${overdue.length ? `${overdue.length} overdue` : ''} +
+
${taskItems}
+
`; + }).join(''); +} + +// ─── VIEW: MY TASKS (LEADER) ───────────────────── +function renderMyTasks() { + const ft = filteredTasks().filter(t => t.assignee === 'AL'); + document.getElementById('leaderTasksFull').innerHTML = + ft.map(renderTaskCard).join('') || '
No tasks found.
'; +} + +// ─── RENDER ALL ─────────────────────────────────── +function renderAll() { + renderOverview(); + renderToday(); + renderKanban(); + renderTeamView(); + renderMyTasks(); +} + +// ─── SWITCH VIEW ────────────────────────────────── +function switchView(name) { + currentView = name; + document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); + const el = document.getElementById(`view-${name}`); + if (el) el.classList.remove('hidden'); + + document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); + const nav = document.querySelector(`.nav-item[data-view="${name}"]`); + if (nav) nav.classList.add('active'); + + const titles = { + overview: 'Overview', today: "Today's Tasks", kanban: 'Board', + team: 'Team', 'my-tasks': 'My Tasks', + }; + document.getElementById('pageTitle').textContent = titles[name] || 'Dashboard'; +} + +// ─── DATE DISPLAY ───────────────────────────────── +document.getElementById('currentDate').textContent = today.toLocaleDateString('en-US', { + weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', +}); + +// ─── SIDEBAR TOGGLE ──────────────────────────────── +document.getElementById('sidebarToggle').addEventListener('click', () => { + document.getElementById('sidebar').classList.toggle('collapsed'); + document.body.classList.toggle('sidebar-collapsed'); +}); + +// ─── NAV LINKS ───────────────────────────────────── +document.querySelectorAll('.nav-item[data-view]').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + switchView(link.dataset.view); + }); +}); + +// ─── KANBAN FILTERS ──────────────────────────────── +document.querySelectorAll('.kb-filter').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.kb-filter').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + kanbanFilter = btn.dataset.filter; + renderKanban(); + }); +}); + +// ─── SEARCH & FILTERS ───────────────────────────── +let searchTimeout; +document.getElementById('searchInput').addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + searchQuery = e.target.value.trim(); + renderAll(); + }, 200); +}); +document.getElementById('filterStatus').addEventListener('change', (e) => { + statusFilter = e.target.value; + renderAll(); +}); +document.getElementById('filterPriority').addEventListener('change', (e) => { + priorityFilter = e.target.value; + renderAll(); +}); + +// ─── ADD TASK MODAL ──────────────────────────────── +const modalOverlay = document.getElementById('modalOverlay'); +const taskForm = document.getElementById('taskForm'); + +document.getElementById('addTaskBtn').addEventListener('click', () => { + document.getElementById('taskDue').value = todayStr; + modalOverlay.classList.remove('hidden'); +}); +document.getElementById('modalClose').addEventListener('click', () => modalOverlay.classList.add('hidden')); +document.getElementById('cancelBtn').addEventListener('click', () => modalOverlay.classList.add('hidden')); +modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) modalOverlay.classList.add('hidden'); +}); + +taskForm.addEventListener('submit', (e) => { + e.preventDefault(); + const newTask = { + id: Date.now(), + title: document.getElementById('taskTitle').value.trim(), + assignee: document.getElementById('taskAssignee').value, + priority: document.getElementById('taskPriority').value, + status: document.getElementById('taskStatus').value, + due: document.getElementById('taskDue').value, + project: document.getElementById('taskProject').value, + desc: document.getElementById('taskDesc').value.trim(), + tags: [], + }; + tasks.unshift(newTask); + modalOverlay.classList.add('hidden'); + taskForm.reset(); + renderAll(); +}); + +// ─── INIT ────────────────────────────────────────── +renderAll(); +switchView('overview'); diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000000..d24bc62b1e1 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,424 @@ + + + + + + Board Design Team — Task Dashboard + + + + + + + + + + + +
+ + +
+
+

Overview

+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+ + +
+
+
+ +
+
+
34
+
Total Tasks
+
+
+4 this week
+
+
+
+ +
+
+
12
+
In Progress
+
+
same as yesterday
+
+
+
+ +
+
+
3
+
Overdue
+
+
-1 resolved
+
+
+
+ +
+
+
19
+
Completed
+
+
+6 this week
+
+
+
+ +
+
+
5
+
Team Members
+
+
all active
+
+
+ + +
+
+
+

+ + Today's Tasks +

+ 7 tasks +
+
+ +
+
+
+
+ + +
+
+
+

+ + Team Workload +

+ 5 members +
+ +
+
+
+ + +
+
+
+

+ + Leader Tasks +

+ 8 tasks +
+ +
+
+
+ +
+ + + + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/dashboard/styles.css b/dashboard/styles.css new file mode 100644 index 00000000000..9357e206136 --- /dev/null +++ b/dashboard/styles.css @@ -0,0 +1,842 @@ +/* ═══════════════════════════════════════════════════ + BOARDFLOW — TASK MANAGEMENT DASHBOARD + Design System: Linear-inspired with indigo accent +════════════════════════════════════════════════════ */ + +/* ─── TOKENS ───────────────────────────────────── */ +:root { + --bg: #0d0f14; + --bg-2: #13161e; + --bg-3: #1a1d27; + --bg-4: #22263a; + --border: rgba(255,255,255,.07); + --border-strong:rgba(255,255,255,.13); + + --text-1: #e8eaf2; + --text-2: #9195a8; + --text-3: #5a5f72; + + --indigo: #6366f1; + --indigo-light: #818cf8; + --indigo-dim: rgba(99,102,241,.15); + + --green: #22c55e; + --green-dim: rgba(34,197,94,.12); + --orange: #f97316; + --orange-dim: rgba(249,115,22,.12); + --red: #ef4444; + --red-dim: rgba(239,68,68,.12); + --yellow: #eab308; + --yellow-dim: rgba(234,179,8,.12); + --purple: #a855f7; + --purple-dim: rgba(168,85,247,.12); + --pink: #ec4899; + --pink-dim: rgba(236,72,153,.12); + --sky: #0ea5e9; + --sky-dim: rgba(14,165,233,.12); + + --sidebar-w: 240px; + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 4px 24px rgba(0,0,0,.4); + + --transition: all .18s ease; +} + +/* ─── RESET / BASE ─────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html { scroll-behavior: smooth; } + +body { + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg); + color: var(--text-1); + display: flex; + min-height: 100vh; + font-size: 13.5px; + line-height: 1.5; + overflow-x: hidden; +} + +a { text-decoration: none; color: inherit; } +button { cursor: pointer; font-family: inherit; border: none; background: none; } +input, select, textarea { font-family: inherit; } +::placeholder { color: var(--text-3); } + +/* ─── SCROLLBAR ─────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 99px; } + +/* ════════════════════════════════════════════════ + SIDEBAR +════════════════════════════════════════════════ */ +.sidebar { + width: var(--sidebar-w); + min-height: 100vh; + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 100; + transition: width .22s ease; + overflow: hidden; +} +.sidebar.collapsed { width: 58px; } +.sidebar.collapsed .brand-name, +.sidebar.collapsed .nav-label, +.sidebar.collapsed .nav-item span:not(.project-dot), +.sidebar.collapsed .nav-badge, +.sidebar.collapsed .user-name, +.sidebar.collapsed .user-role { display: none; } +.sidebar.collapsed .sidebar-toggle { + transform: rotate(180deg); +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px 16px; + border-bottom: 1px solid var(--border); +} +.brand-icon { + width: 32px; height: 32px; + background: var(--indigo-dim); + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: var(--indigo); + flex-shrink: 0; +} +.brand-name { + font-size: 15px; + font-weight: 700; + color: var(--text-1); + letter-spacing: -.3px; +} +.sidebar-toggle { + margin-left: auto; + color: var(--text-3); + padding: 4px; + border-radius: 4px; + transition: var(--transition); +} +.sidebar-toggle:hover { color: var(--text-1); background: var(--bg-4); } + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; +} +.nav-section { display: flex; flex-direction: column; gap: 1px; } +.nav-label { + font-size: 10.5px; + font-weight: 600; + color: var(--text-3); + letter-spacing: .8px; + padding: 2px 8px 8px; +} +.nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 8px; + border-radius: var(--radius-sm); + color: var(--text-2); + transition: var(--transition); + position: relative; + white-space: nowrap; +} +.nav-item:hover { background: var(--bg-3); color: var(--text-1); } +.nav-item.active { background: var(--indigo-dim); color: var(--indigo-light); } +.nav-item svg { flex-shrink: 0; } +.nav-badge { + margin-left: auto; + font-size: 10px; + font-weight: 600; + background: var(--bg-4); + color: var(--text-2); + border-radius: 99px; + padding: 1px 6px; +} +.nav-badge.urgent { background: var(--red-dim); color: var(--red); } +.project-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.add-project { color: var(--text-3); font-style: italic; } +.add-project:hover { color: var(--text-2); } + +.sidebar-footer { + padding: 14px; + border-top: 1px solid var(--border); +} +.user-avatar-wrap { + display: flex; + align-items: center; + gap: 10px; +} +.user-name { font-size: 13px; font-weight: 600; } +.user-role { font-size: 11px; color: var(--text-3); margin-top: 1px; } + +/* ════════════════════════════════════════════════ + MAIN +════════════════════════════════════════════════ */ +.main { + margin-left: var(--sidebar-w); + flex: 1; + display: flex; + flex-direction: column; + transition: margin-left .22s ease; +} +body.sidebar-collapsed .main { margin-left: 58px; } + +/* ─── TOPBAR ───────────────────────────────────── */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; z-index: 50; + gap: 16px; + flex-wrap: wrap; +} +.topbar-left { display: flex; align-items: center; gap: 14px; } +.page-title { font-size: 18px; font-weight: 700; letter-spacing: -.4px; } +.date-chip { + display: flex; align-items: center; gap: 5px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 99px; + padding: 4px 10px; + font-size: 11.5px; + color: var(--text-2); +} +.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + +.search-wrap { + display: flex; + align-items: center; + gap: 7px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 11px; + color: var(--text-3); +} +.search-wrap:focus-within { border-color: var(--indigo); } +.search-input { + background: none; + border: none; + outline: none; + color: var(--text-1); + font-size: 13px; + width: 180px; +} + +.filter-group { display: flex; gap: 6px; } +.filter-select { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-2); + padding: 6px 10px; + font-size: 12.5px; + outline: none; + cursor: pointer; +} +.filter-select:focus { border-color: var(--indigo); } + +/* ─── BUTTONS ─────────────────────────────────── */ +.btn-primary { + background: var(--indigo); + color: #fff; + border-radius: var(--radius-sm); + padding: 7px 14px; + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + transition: var(--transition); + white-space: nowrap; +} +.btn-primary:hover { background: #5254cc; } +.btn-ghost { + background: none; + color: var(--text-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 5px 11px; + font-size: 12px; + transition: var(--transition); +} +.btn-ghost:hover { border-color: var(--border-strong); color: var(--text-1); background: var(--bg-3); } + +/* ─── VIEWS ───────────────────────────────────── */ +.view-container { flex: 1; overflow-y: auto; } +.view { padding: 24px; display: flex; flex-direction: column; gap: 28px; } +.view.hidden { display: none; } + +/* ─── STAT CARDS ─────────────────────────────── */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; +} +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + display: flex; + align-items: flex-start; + gap: 13px; + position: relative; + transition: var(--transition); +} +.stat-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.stat-icon { + width: 38px; height: 38px; + border-radius: 8px; + display: grid; + place-items: center; + flex-shrink: 0; +} +.stat-value { font-size: 26px; font-weight: 700; letter-spacing: -.5px; line-height: 1; } +.stat-label { font-size: 11.5px; color: var(--text-2); margin-top: 3px; } +.stat-trend { + position: absolute; + bottom: 12px; right: 14px; + font-size: 10.5px; + font-weight: 500; +} +.stat-trend.up { color: var(--green); } +.stat-trend.down { color: var(--red); } +.stat-trend.neutral { color: var(--text-3); } + +/* ─── SECTION ─────────────────────────────────── */ +.section { display: flex; flex-direction: column; gap: 14px; } +.section-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.section-title-group { display: flex; align-items: center; gap: 10px; } +.section-title { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 7px; + color: var(--text-1); +} +.section-title svg { color: var(--text-3); } +.section-count { + font-size: 11px; + color: var(--text-3); + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 99px; + padding: 1px 8px; +} + +/* ─── TODAY TASKS (overview strip) ────────────── */ +.today-tasks { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* ─── TASK ROW ────────────────────────────────── */ +.task-row { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + transition: var(--transition); + cursor: pointer; +} +.task-row:hover { border-color: var(--border-strong); background: var(--bg-3); } +.task-row.done { opacity: .5; } +.task-check { + width: 18px; height: 18px; + border: 1.5px solid var(--border-strong); + border-radius: 50%; + flex-shrink: 0; + display: grid; + place-items: center; + transition: var(--transition); + cursor: pointer; +} +.task-check:hover { border-color: var(--green); } +.task-check.checked { background: var(--green); border-color: var(--green); } +.task-check.checked::after { + content: ''; + display: block; + width: 5px; height: 9px; + border: 2px solid #fff; + border-top: none; border-left: none; + transform: rotate(45deg) translate(-1px, -1px); +} +.task-title-text { + flex: 1; + font-size: 13.5px; + font-weight: 500; +} +.task-row.done .task-title-text { text-decoration: line-through; color: var(--text-3); } +.task-meta { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } +.task-project-tag { + font-size: 11px; + color: var(--text-3); + background: var(--bg-4); + border-radius: 4px; + padding: 2px 7px; +} +.task-due { + font-size: 11px; + color: var(--text-3); + display: flex; + align-items: center; + gap: 4px; +} +.task-due.overdue { color: var(--red); } +.task-due.today { color: var(--orange); } + +/* ─── PRIORITY BADGE ──────────────────────────── */ +.priority-badge { + font-size: 10.5px; + font-weight: 600; + border-radius: 4px; + padding: 2px 7px; + display: flex; + align-items: center; + gap: 4px; + letter-spacing: .2px; + white-space: nowrap; +} +.priority-badge.critical { background: rgba(239,68,68,.15); color: #f87171; } +.priority-badge.high { background: rgba(249,115,22,.15); color: #fb923c; } +.priority-badge.medium { background: rgba(234,179,8,.15); color: #facc15; } +.priority-badge.low { background: rgba(148,163,184,.1); color: #94a3b8; } + +/* ─── STATUS BADGE ────────────────────────────── */ +.status-badge { + font-size: 10.5px; + font-weight: 500; + border-radius: 99px; + padding: 2px 9px; + white-space: nowrap; +} +.status-badge.todo { background: var(--bg-4); color: var(--text-2); } +.status-badge.in-progress { background: var(--indigo-dim); color: var(--indigo-light); } +.status-badge.review { background: var(--purple-dim); color: var(--purple); } +.status-badge.done { background: var(--green-dim); color: var(--green); } +.status-badge.overdue { background: var(--red-dim); color: var(--red); } + +/* ─── AVATAR ──────────────────────────────────── */ +.avatar { + width: 30px; height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} +.avatar.lg { width: 52px; height: 52px; font-size: 17px; } + +/* ─── TEAM WORKLOAD CARDS ─────────────────────── */ +.team-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 14px; +} +.team-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + transition: var(--transition); + cursor: pointer; +} +.team-card:hover { border-color: var(--border-strong); transform: translateY(-1px); } +.team-card-header { display: flex; align-items: center; gap: 10px; } +.team-info { flex: 1; } +.team-name { font-size: 13.5px; font-weight: 600; } +.team-role { font-size: 11px; color: var(--text-3); margin-top: 1px; } +.team-task-count { font-size: 12px; color: var(--text-2); font-weight: 500; } + +.workload-bar-wrap { display: flex; flex-direction: column; gap: 4px; } +.workload-bar-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); } +.workload-bar { + height: 5px; + background: var(--bg-4); + border-radius: 99px; + overflow: hidden; +} +.workload-bar-fill { + height: 100%; + border-radius: 99px; + transition: width .5s ease; +} + +.mini-tasks { display: flex; flex-direction: column; gap: 4px; } +.mini-task { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--text-2); + padding: 3px 0; +} +.mini-task-dot { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ─── TASK LIST ───────────────────────────────── */ +.task-list { display: flex; flex-direction: column; gap: 6px; } +.task-list.full { gap: 8px; } + +/* ─── TODAY VIEW ──────────────────────────────── */ +.today-header { + background: linear-gradient(135deg, var(--bg-2), var(--bg-3)); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} +.today-greeting { font-size: 20px; font-weight: 700; letter-spacing: -.4px; } +.today-sub { font-size: 13px; color: var(--text-2); margin-top: 5px; } + +.progress-ring-wrap { position: relative; flex-shrink: 0; } +.progress-ring { transform: rotate(-90deg); } +.ring-bg { fill: none; stroke: var(--bg-4); stroke-width: 6; } +.ring-fill { fill: none; stroke: var(--indigo); stroke-width: 6; stroke-linecap: round; transition: stroke-dashoffset .6s ease; } +.ring-label { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} +.ring-pct { font-size: 14px; font-weight: 700; } +.ring-sub { font-size: 9px; color: var(--text-3); } + +.today-columns { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} +.today-col { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.today-col-header { display: flex; align-items: center; justify-content: space-between; } +.col-count { font-size: 12px; color: var(--text-3); font-weight: 500; } +.col-badge { + font-size: 11px; + font-weight: 600; + border-radius: 4px; + padding: 2px 8px; +} +.col-badge.critical { background: var(--red-dim); color: var(--red); } +.col-badge.high { background: var(--orange-dim); color: var(--orange); } +.col-badge.medium { background: var(--yellow-dim); color: var(--yellow); } + +.today-col-tasks { display: flex; flex-direction: column; gap: 6px; } + +/* ─── KANBAN ──────────────────────────────────── */ +.kanban-toolbar { + margin-bottom: 16px; +} +.kanban-filters { display: flex; gap: 6px; flex-wrap: wrap; } +.kb-filter { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 99px; + color: var(--text-2); + padding: 5px 13px; + font-size: 12px; + transition: var(--transition); +} +.kb-filter:hover { border-color: var(--border-strong); color: var(--text-1); } +.kb-filter.active { background: var(--indigo-dim); border-color: var(--indigo); color: var(--indigo-light); } + +.kanban-board { + display: grid; + grid-template-columns: repeat(4, minmax(250px, 1fr)); + gap: 14px; + overflow-x: auto; + padding-bottom: 8px; +} +.kb-col { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 300px; +} +.kb-col-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} +.kb-col-title { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 7px; } +.kb-col-dot { width: 8px; height: 8px; border-radius: 50%; } +.kb-col-count { font-size: 11px; color: var(--text-3); background: var(--bg-4); border-radius: 4px; padding: 1px 6px; } + +.kb-card { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 9px; + cursor: pointer; + transition: var(--transition); + position: relative; +} +.kb-card:hover { border-color: var(--border-strong); transform: translateY(-1px); box-shadow: var(--shadow); } +.kb-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; } +.kb-card-title { font-size: 13px; font-weight: 500; line-height: 1.4; } +.kb-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; } +.kb-card-project { font-size: 10.5px; color: var(--text-3); } +.kb-card-footer { display: flex; align-items: center; justify-content: space-between; } +.kb-card-due { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 4px; } +.kb-card-due.overdue { color: var(--red); } +.kb-card-due.today-due { color: var(--orange); } + +.priority-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ─── TEAM MEMBERS VIEW ───────────────────────── */ +.team-members-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 18px; +} +.member-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: var(--transition); +} +.member-card:hover { border-color: var(--border-strong); } +.member-card-header { + padding: 18px; + background: var(--bg-3); + display: flex; + align-items: center; + gap: 13px; + border-bottom: 1px solid var(--border); +} +.member-header-info { flex: 1; } +.member-name { font-size: 15px; font-weight: 600; } +.member-role { font-size: 11.5px; color: var(--text-3); margin-top: 2px; } +.member-card-stats { + display: flex; + gap: 14px; + padding: 12px 18px; + border-bottom: 1px solid var(--border); +} +.mstat { font-size: 11.5px; color: var(--text-2); } +.mstat strong { color: var(--text-1); font-weight: 600; } +.mstat.danger strong { color: var(--red); } + +.member-tasks { padding: 12px 18px 16px; display: flex; flex-direction: column; gap: 6px; } +.member-task-item { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 11px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12.5px; + transition: var(--transition); +} +.member-task-item:hover { border-color: var(--border-strong); } +.member-task-title { flex: 1; font-weight: 500; } +.member-task-due { font-size: 11px; color: var(--text-3); white-space: nowrap; } +.member-task-due.overdue { color: var(--red); font-weight: 600; } + +/* ─── LEADER (MY TASKS) ───────────────────────── */ +.my-tasks-wrap { display: flex; flex-direction: column; gap: 20px; } +.leader-profile { + background: linear-gradient(135deg, var(--bg-2), var(--bg-3)); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 22px; + display: flex; + align-items: center; + gap: 16px; +} +.leader-name { font-size: 18px; font-weight: 700; } +.leader-role { font-size: 12px; color: var(--text-3); margin-top: 2px; } +.leader-stats { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; } +.lstat { + font-size: 12px; + color: var(--text-2); + background: var(--bg-4); + border-radius: 6px; + padding: 3px 10px; +} +.lstat .lstat-val { color: var(--text-1); font-weight: 600; } +.lstat.urgent { background: var(--red-dim); } +.lstat.urgent .lstat-val { color: var(--red); } + +/* ─── TASK CARD (full detail) ─────────────────── */ +.task-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; + transition: var(--transition); + cursor: pointer; +} +.task-card:hover { border-color: var(--border-strong); background: var(--bg-3); } +.task-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.task-card-title { font-size: 13.5px; font-weight: 500; line-height: 1.4; } +.task-card-desc { font-size: 12px; color: var(--text-3); line-height: 1.5; } +.task-card-bottom { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.tag { + font-size: 10.5px; + background: var(--bg-4); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 7px; + color: var(--text-3); +} + +/* ─── MODAL ───────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.6); + backdrop-filter: blur(4px); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} +.modal-overlay.hidden { display: none; } +.modal { + background: var(--bg-2); + border: 1px solid var(--border-strong); + border-radius: 14px; + width: 100%; + max-width: 520px; + box-shadow: 0 24px 60px rgba(0,0,0,.6); + animation: slideUp .2s ease; +} +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} +.modal-header { + padding: 18px 22px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-title { font-size: 16px; font-weight: 600; } +.modal-close { + color: var(--text-3); + padding: 4px; + border-radius: 4px; + transition: var(--transition); +} +.modal-close:hover { color: var(--text-1); background: var(--bg-4); } +.modal-form { padding: 20px 22px; display: flex; flex-direction: column; gap: 14px; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.form-group { display: flex; flex-direction: column; gap: 5px; } +.form-label { font-size: 12px; font-weight: 500; color: var(--text-2); } +.form-input { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-1); + padding: 8px 11px; + font-size: 13px; + outline: none; + transition: var(--transition); + resize: vertical; +} +.form-input:focus { border-color: var(--indigo); background: var(--bg-4); } +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +/* ─── EMPTY STATE ─────────────────────────────── */ +.empty-state { + text-align: center; + padding: 32px; + color: var(--text-3); + font-size: 13px; +} + +/* ─── RESPONSIVE ──────────────────────────────── */ +@media (max-width: 900px) { + .sidebar { width: 58px; } + .sidebar .brand-name, + .sidebar .nav-label, + .sidebar .nav-item span:not(.project-dot), + .sidebar .nav-badge, + .sidebar .user-name, + .sidebar .user-role { display: none; } + .main { margin-left: 58px; } + .kanban-board { grid-template-columns: repeat(2, minmax(220px, 1fr)); } + .form-row { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .topbar { padding: 10px 14px; } + .view { padding: 14px; gap: 20px; } + .kanban-board { grid-template-columns: 1fr; } + .search-input { width: 120px; } +} From 13d1d161bc133e2623fac485fe35b3f8277bd026 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 14:25:52 +0000 Subject: [PATCH 2/5] Add Excel import/export to task management dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import button: opens file picker (.xlsx, .xls, .csv), parses with SheetJS, shows a confirmation modal with Append / Replace All mode selection. Flexible column-name mapping (title, assignee, priority, status, project, due date, description, tags) with value normalisation (e.g. "In Progress" / "wip" → in-progress, partial name matching for assignees, Excel serial date numbers handled). - Export button: instantly downloads board-tasks-YYYYMMDD.xlsx with three sheets — All Tasks (full detail), Today's Tasks (filtered), and Team Summary (per-member stats). - Toast notification confirms import/export result (count, filename, duplicates skipped). - Added btn-secondary style, import modal styles, and toast styles. https://claude.ai/code/session_01BddfitpYfdVvu7oAjt5i14 --- dashboard/app.js | 288 +++++++++++++++++++++++++++++++++++++++++++ dashboard/index.html | 83 +++++++++++++ dashboard/styles.css | 79 ++++++++++++ 3 files changed, 450 insertions(+) diff --git a/dashboard/app.js b/dashboard/app.js index 4338e2c9189..07c1aee85a2 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -734,6 +734,294 @@ taskForm.addEventListener('submit', (e) => { renderAll(); }); +// ═══════════════════════════════════════════════════ +// EXCEL IMPORT / EXPORT (requires SheetJS / xlsx.js) +// ═══════════════════════════════════════════════════ + +// ─── TOAST HELPER ───────────────────────────────── +let toastTimer; +function showToast(msg, type = 'success') { + const toast = document.getElementById('toast'); + const icon = document.getElementById('toastIcon'); + document.getElementById('toastMsg').textContent = msg; + icon.textContent = type === 'success' ? '✓' : type === 'error' ? '✕' : 'ℹ'; + toast.className = `toast ${type}`; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.add('hidden'), 4000); +} + +// ─── COLUMN NAME NORMALISATION ───────────────────── +// Maps flexible Excel headers → our internal field names +const HEADER_MAP = { + 'title': 'title', 'task': 'title', 'task title': 'title', 'name': 'title', + 'assignee': 'assignee', 'assigned to': 'assignee', 'assignee id': 'assignee', + 'member': 'assignee', 'owner': 'assignee', + 'priority': 'priority', + 'status': 'status', + 'project': 'project', + 'due': 'due', 'due date': 'due', 'deadline': 'due', 'date': 'due', + 'description': 'desc', 'desc': 'desc', 'details': 'desc', 'notes': 'desc', + 'tags': 'tags', 'labels': 'tags', 'tag': 'tags', +}; + +function normaliseHeader(h) { + return HEADER_MAP[(h || '').toString().trim().toLowerCase()] || null; +} + +// ─── VALUE NORMALISATION ────────────────────────── +function normalisePriority(v) { + const s = (v || '').toString().trim().toLowerCase(); + if (s.includes('critical') || s === 'p0') return 'critical'; + if (s.includes('high') || s === 'p1') return 'high'; + if (s.includes('medium') || s === 'p2' || s === 'med') return 'medium'; + if (s.includes('low') || s === 'p3') return 'low'; + return 'medium'; // default +} + +function normaliseStatus(v) { + const s = (v || '').toString().trim().toLowerCase().replace(/\s+/g, '-'); + if (s === 'todo' || s === 'to-do' || s === 'not-started' || s === 'open') return 'todo'; + if (s === 'in-progress' || s === 'in-work' || s === 'wip' || s === 'active') return 'in-progress'; + if (s === 'review' || s === 'in-review' || s === 'pending-review') return 'review'; + if (s === 'done' || s === 'complete' || s === 'completed' || s === 'closed') return 'done'; + return 'todo'; // default +} + +function normaliseAssignee(v) { + const s = (v || '').toString().trim(); + // First try exact ID match (AL, SM, JD, …) + const byId = TEAM.find(m => m.id.toLowerCase() === s.toLowerCase()); + if (byId) return byId.id; + // Then try name match (partial, case-insensitive) + const byName = TEAM.find(m => m.name.toLowerCase().includes(s.toLowerCase()) || + s.toLowerCase().includes(m.name.split(' ')[0].toLowerCase())); + if (byName) return byName.id; + return 'AL'; // default to leader +} + +function normaliseDate(v) { + if (!v) return ''; + // SheetJS may give a JS serial number for dates + if (typeof v === 'number') { + const d = XLSX.SSF.parse_date_code(v); + if (d) { + const month = String(d.m).padStart(2, '0'); + const day = String(d.d).padStart(2, '0'); + return `${d.y}-${month}-${day}`; + } + } + const s = v.toString().trim(); + // Already ISO + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + // Try parsing + const parsed = new Date(s); + if (!isNaN(parsed)) return fmt(parsed); + return ''; +} + +// ─── PARSE ROWS → TASKS ─────────────────────────── +function rowsToTasks(rows) { + if (!rows || rows.length < 2) return []; + const headers = rows[0].map(h => normaliseHeader(h)); + const result = []; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const raw = {}; + headers.forEach((field, idx) => { + if (field) raw[field] = row[idx]; + }); + if (!raw.title) continue; // skip rows with no title + result.push({ + id: Date.now() + i, + title: raw.title.toString().trim(), + assignee: normaliseAssignee(raw.assignee), + priority: normalisePriority(raw.priority), + status: normaliseStatus(raw.status), + project: (raw.project || 'PCB Layout v4').toString().trim(), + due: normaliseDate(raw.due), + desc: (raw.desc || '').toString().trim(), + tags: raw.tags + ? raw.tags.toString().split(',').map(t => t.trim()).filter(Boolean) + : [], + }); + } + return result; +} + +// ─── IMPORT STATE ───────────────────────────────── +let pendingImportRows = null; +let pendingFileName = ''; + +// ─── IMPORT: FILE SELECTED ──────────────────────── +document.getElementById('xlsxInput').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + e.target.value = ''; // reset so same file can be re-selected + + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = new Uint8Array(ev.target.result); + const wb = XLSX.read(data, { type: 'array', cellDates: false }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); + + const parsed = rowsToTasks(rows); + if (parsed.length === 0) { + showToast('No valid tasks found in file — check column headers.', 'error'); + return; + } + + // Store for confirm step + pendingImportRows = parsed; + pendingFileName = file.name; + + // Fill info panel in modal + document.getElementById('importFileInfo').innerHTML = ` + + + + + + +
+
${file.name}
+
${parsed.length} task${parsed.length !== 1 ? 's' : ''} found across ${rows.length - 1} row${rows.length - 1 !== 1 ? 's' : ''}
+
`; + + document.getElementById('importOverlay').classList.remove('hidden'); + } catch (err) { + showToast('Could not read the file. Make sure it is a valid .xlsx or .csv.', 'error'); + console.error(err); + } + }; + reader.readAsArrayBuffer(file); +}); + +// ─── IMPORT: CONFIRM ────────────────────────────── +document.getElementById('importConfirmBtn').addEventListener('click', () => { + if (!pendingImportRows) return; + const mode = document.querySelector('input[name="importMode"]:checked').value; + + if (mode === 'replace') { + tasks = pendingImportRows; + } else { + // Append — avoid duplicate IDs + const existing = new Set(tasks.map(t => t.title.toLowerCase())); + const newOnes = pendingImportRows.filter(t => !existing.has(t.title.toLowerCase())); + tasks = [...tasks, ...newOnes]; + if (newOnes.length < pendingImportRows.length) { + const skipped = pendingImportRows.length - newOnes.length; + showToast(`Imported ${newOnes.length} tasks (${skipped} duplicate title${skipped !== 1 ? 's' : ''} skipped).`, 'success'); + document.getElementById('importOverlay').classList.add('hidden'); + pendingImportRows = null; + renderAll(); + return; + } + } + + document.getElementById('importOverlay').classList.add('hidden'); + pendingImportRows = null; + renderAll(); + + const count = mode === 'replace' ? tasks.length : pendingImportRows?.length ?? 0; + showToast(`${mode === 'replace' ? 'Replaced all tasks with' : 'Imported'} ${tasks.length} task${tasks.length !== 1 ? 's' : ''} from "${pendingFileName}".`); +}); + +// ─── IMPORT: CANCEL / CLOSE ─────────────────────── +function closeImportModal() { + document.getElementById('importOverlay').classList.add('hidden'); + pendingImportRows = null; +} +document.getElementById('importModalClose').addEventListener('click', closeImportModal); +document.getElementById('importCancelBtn').addEventListener('click', closeImportModal); +document.getElementById('importOverlay').addEventListener('click', (e) => { + if (e.target === document.getElementById('importOverlay')) closeImportModal(); +}); + +// ─── IMPORT BUTTON ──────────────────────────────── +document.getElementById('importBtn').addEventListener('click', () => { + document.getElementById('xlsxInput').click(); +}); + +// ─── EXPORT TO EXCEL ────────────────────────────── +document.getElementById('exportBtn').addEventListener('click', exportToExcel); + +function exportToExcel() { + const wb = XLSX.utils.book_new(); + + // ── Sheet 1: All Tasks ────────────────────────── + const allHeaders = ['ID','Title','Assignee ID','Assignee Name','Priority','Status', + 'Project','Due Date','Description','Tags']; + const allRows = tasks.map(t => [ + t.id, + t.title, + t.assignee, + memberById(t.assignee)?.name || t.assignee, + PRIORITY[t.priority]?.label || t.priority, + STATUS_LABEL[t.status] || t.status, + t.project, + t.due || '', + t.desc || '', + (t.tags || []).join(', '), + ]); + const allSheet = XLSX.utils.aoa_to_sheet([allHeaders, ...allRows]); + applySheetStyle(allSheet, allHeaders.length, allRows.length); + XLSX.utils.book_append_sheet(wb, allSheet, 'All Tasks'); + + // ── Sheet 2: Today's Tasks ────────────────────── + const todayTasks = tasks.filter(t => isToday(t.due) || (isOverdue(t.due) && t.status !== 'done')); + const todayRows = todayTasks.map(t => [ + t.title, + memberById(t.assignee)?.name || t.assignee, + PRIORITY[t.priority]?.label || t.priority, + STATUS_LABEL[t.status] || t.status, + t.project, + t.due || '', + t.desc || '', + ]); + const todaySheet = XLSX.utils.aoa_to_sheet( + [['Title','Assignee','Priority','Status','Project','Due Date','Description'], ...todayRows] + ); + applySheetStyle(todaySheet, 7, todayRows.length); + XLSX.utils.book_append_sheet(wb, todaySheet, "Today's Tasks"); + + // ── Sheet 3: Per-Member Summary ───────────────── + const summaryHeaders = ['Member','Role','Total Tasks','Active','Done','Overdue']; + const summaryRows = TEAM.map(m => { + const mt = tasks.filter(t => t.assignee === m.id); + const active = mt.filter(t => t.status !== 'done').length; + const done = mt.filter(t => t.status === 'done').length; + const overdue = mt.filter(t => isOverdue(t.due) && t.status !== 'done').length; + return [m.name, m.role, mt.length, active, done, overdue]; + }); + const summarySheet = XLSX.utils.aoa_to_sheet([summaryHeaders, ...summaryRows]); + applySheetStyle(summarySheet, summaryHeaders.length, summaryRows.length); + XLSX.utils.book_append_sheet(wb, summarySheet, 'Team Summary'); + + // ── Download ──────────────────────────────────── + const dateStamp = todayStr.replace(/-/g, ''); + XLSX.writeFile(wb, `board-tasks-${dateStamp}.xlsx`); + showToast(`Exported ${tasks.length} tasks to board-tasks-${dateStamp}.xlsx`); +} + +// Set column widths for readability +function applySheetStyle(ws, numCols, numRows) { + const colWidths = [ + { wch: 6 }, // ID + { wch: 42 }, // Title + { wch: 12 }, // Assignee ID + { wch: 18 }, // Assignee Name + { wch: 10 }, // Priority + { wch: 14 }, // Status + { wch: 20 }, // Project + { wch: 12 }, // Due Date + { wch: 40 }, // Description + { wch: 20 }, // Tags + ].slice(0, numCols); + ws['!cols'] = colWidths; +} + // ─── INIT ────────────────────────────────────────── renderAll(); switchView('overview'); diff --git a/dashboard/index.html b/dashboard/index.html index d24bc62b1e1..1f8a1185329 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -8,6 +8,8 @@ + + @@ -130,6 +132,17 @@

Overview

+ + + + + + + + + + + + + diff --git a/dashboard/styles.css b/dashboard/styles.css index 9357e206136..0951cdc4ff6 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -284,6 +284,22 @@ body.sidebar-collapsed .main { margin-left: 58px; } } .btn-ghost:hover { border-color: var(--border-strong); color: var(--text-1); background: var(--bg-3); } +.btn-secondary { + background: var(--bg-3); + color: var(--text-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 13px; + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + transition: var(--transition); + white-space: nowrap; +} +.btn-secondary:hover { border-color: var(--border-strong); color: var(--text-1); background: var(--bg-4); } + /* ─── VIEWS ───────────────────────────────────── */ .view-container { flex: 1; overflow-y: auto; } .view { padding: 24px; display: flex; flex-direction: column; gap: 28px; } @@ -821,6 +837,69 @@ body.sidebar-collapsed .main { margin-left: 58px; } font-size: 13px; } +/* ─── IMPORT MODAL EXTRAS ─────────────────────── */ +.import-file-info { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 12.5px; + display: flex; + align-items: center; + gap: 10px; +} +.import-file-info .file-icon { color: var(--green); flex-shrink: 0; } +.import-file-name { font-weight: 600; color: var(--text-1); } +.import-file-meta { font-size: 11.5px; color: var(--text-3); margin-top: 2px; } + +.import-section-label { font-size: 11px; font-weight: 500; color: var(--text-3); margin-bottom: 7px; letter-spacing: .3px; } +.import-columns { display: flex; flex-wrap: wrap; gap: 5px; } +.import-col { + font-size: 11px; + background: var(--bg-4); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 8px; + color: var(--text-3); +} +.import-col.required { border-color: var(--indigo); color: var(--indigo-light); } + +.import-mode-group { display: flex; flex-direction: column; gap: 7px; } +.import-mode-opt { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; } +.import-mode-opt input[type="radio"] { margin-top: 3px; accent-color: var(--indigo); } +.import-mode-box { display: flex; flex-direction: column; gap: 2px; } +.import-mode-box strong { font-size: 13px; color: var(--text-1); } +.import-mode-box small { font-size: 11.5px; color: var(--text-3); } + +/* ─── TOAST ───────────────────────────────────── */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--bg-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 12px 18px; + font-size: 13px; + color: var(--text-1); + display: flex; + align-items: center; + gap: 9px; + box-shadow: 0 8px 32px rgba(0,0,0,.5); + z-index: 999; + animation: slideRight .22s ease; + max-width: 340px; +} +.toast.hidden { display: none; } +.toast.success { border-color: var(--green); } +.toast.error { border-color: var(--red); } +.toast-icon { font-size: 16px; flex-shrink: 0; } + +@keyframes slideRight { + from { opacity: 0; transform: translateX(16px); } + to { opacity: 1; transform: translateX(0); } +} + /* ─── RESPONSIVE ──────────────────────────────── */ @media (max-width: 900px) { .sidebar { width: 58px; } From 7d0d27805d8331920f006836815742aba0f9a167 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 18:49:49 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Make=20dashboard=20fully=20offline=20?= =?UTF-8?q?=E2=80=94=20remove=20all=20CDN=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop Google Fonts CDN links; use Inter → system-ui font stack instead (Inter is built-in on macOS 10.15+ / Windows 11; falls back to Segoe UI / Helvetica Neue on older systems) - Drop SheetJS CDN script; replace with two zero-dependency solutions: * Import: built-in RFC-4180 CSV parser (FileReader.readAsText + pure JS) handles quoted fields, escaped quotes, mixed line endings * Export: SpreadsheetML XML writer — produces a multi-sheet .xls that Excel / LibreOffice open natively (no ZIP/compression required) Sheets: All Tasks · Today's Tasks · Team Summary - File input now accepts .csv only; tooltips and modal title updated - Dashboard opens directly from disk (file://) with no network requests https://claude.ai/code/session_01BddfitpYfdVvu7oAjt5i14 --- dashboard/app.js | 183 ++++++++++++++++++++++++++----------------- dashboard/index.html | 13 +-- dashboard/styles.css | 2 +- 3 files changed, 118 insertions(+), 80 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 07c1aee85a2..4488fd27fc3 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -735,7 +735,7 @@ taskForm.addEventListener('submit', (e) => { }); // ═══════════════════════════════════════════════════ -// EXCEL IMPORT / EXPORT (requires SheetJS / xlsx.js) +// CSV IMPORT + SPREADSHEETML EXPORT (no dependencies) // ═══════════════════════════════════════════════════ // ─── TOAST HELPER ───────────────────────────────── @@ -801,19 +801,12 @@ function normaliseAssignee(v) { function normaliseDate(v) { if (!v) return ''; - // SheetJS may give a JS serial number for dates - if (typeof v === 'number') { - const d = XLSX.SSF.parse_date_code(v); - if (d) { - const month = String(d.m).padStart(2, '0'); - const day = String(d.d).padStart(2, '0'); - return `${d.y}-${month}-${day}`; - } - } const s = v.toString().trim(); - // Already ISO - if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; - // Try parsing + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; // Already ISO + if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(s)) { // MM/DD/YYYY or D/M/YYYY + const [a, b, y] = s.split('/'); + return `${y}-${a.padStart(2,'0')}-${b.padStart(2,'0')}`; + } const parsed = new Date(s); if (!isNaN(parsed)) return fmt(parsed); return ''; @@ -852,6 +845,43 @@ function rowsToTasks(rows) { let pendingImportRows = null; let pendingFileName = ''; +// ─── RFC-4180 CSV PARSER ────────────────────────── +// Returns an array of rows; each row is an array of string values. +function parseCSV(text) { + const rows = []; + const src = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + let pos = 0; + + while (pos < src.length) { + const row = []; + // Parse fields until end-of-line or end-of-input + while (pos < src.length && src[pos] !== '\n') { + let field = ''; + if (src[pos] === '"') { + pos++; // skip opening quote + while (pos < src.length) { + if (src[pos] === '"') { + if (src[pos + 1] === '"') { field += '"'; pos += 2; } // escaped quote + else { pos++; break; } // closing quote + } else { + field += src[pos++]; + } + } + } else { + while (pos < src.length && src[pos] !== ',' && src[pos] !== '\n') { + field += src[pos++]; + } + } + row.push(field); + if (pos < src.length && src[pos] === ',') pos++; // skip comma + } + if (pos < src.length && src[pos] === '\n') pos++; // skip newline + // Skip completely empty rows + if (!(row.length === 1 && row[0] === '')) rows.push(row); + } + return rows; +} + // ─── IMPORT: FILE SELECTED ──────────────────────── document.getElementById('xlsxInput').addEventListener('change', (e) => { const file = e.target.files[0]; @@ -861,22 +891,17 @@ document.getElementById('xlsxInput').addEventListener('change', (e) => { const reader = new FileReader(); reader.onload = (ev) => { try { - const data = new Uint8Array(ev.target.result); - const wb = XLSX.read(data, { type: 'array', cellDates: false }); - const ws = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); - + const rows = parseCSV(ev.target.result); const parsed = rowsToTasks(rows); + if (parsed.length === 0) { - showToast('No valid tasks found in file — check column headers.', 'error'); + showToast('No valid tasks found — check column headers.', 'error'); return; } - // Store for confirm step pendingImportRows = parsed; pendingFileName = file.name; - // Fill info panel in modal document.getElementById('importFileInfo').innerHTML = ` @@ -886,16 +911,16 @@ document.getElementById('xlsxInput').addEventListener('change', (e) => {
${file.name}
-
${parsed.length} task${parsed.length !== 1 ? 's' : ''} found across ${rows.length - 1} row${rows.length - 1 !== 1 ? 's' : ''}
+
${parsed.length} task${parsed.length !== 1 ? 's' : ''} found across ${rows.length - 1} data row${rows.length - 1 !== 1 ? 's' : ''}
`; document.getElementById('importOverlay').classList.remove('hidden'); } catch (err) { - showToast('Could not read the file. Make sure it is a valid .xlsx or .csv.', 'error'); + showToast('Could not read the file. Make sure it is a valid .csv.', 'error'); console.error(err); } }; - reader.readAsArrayBuffer(file); + reader.readAsText(file); // plain text — no ArrayBuffer needed }); // ─── IMPORT: CONFIRM ────────────────────────────── @@ -944,11 +969,34 @@ document.getElementById('importBtn').addEventListener('click', () => { document.getElementById('xlsxInput').click(); }); -// ─── EXPORT TO EXCEL ────────────────────────────── -document.getElementById('exportBtn').addEventListener('click', exportToExcel); +// ─── EXPORT (SpreadsheetML + CSV — no dependencies) ─ +document.getElementById('exportBtn').addEventListener('click', exportToSpreadsheet); + +// Build an XML-escaped string safe for SpreadsheetML cell content +function xmlEsc(v) { + return String(v === null || v === undefined ? '' : v) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} -function exportToExcel() { - const wb = XLSX.utils.book_new(); +// Convert a 2-D array to a SpreadsheetML string +function sheetToXML(name, rows) { + const rowsXml = rows.map(row => { + const cells = row.map(val => { + const isNum = typeof val === 'number'; + return `${xmlEsc(val)}`; + }).join(''); + return `${cells}`; + }).join(''); + return `${rowsXml}
`; +} + +// Build a multi-sheet SpreadsheetML workbook and trigger download as .xls +// (SpreadsheetML is XML-based; Excel/LibreOffice open it natively — no ZIP needed) +function exportToSpreadsheet() { + const dateStamp = todayStr.replace(/-/g, ''); // ── Sheet 1: All Tasks ────────────────────────── const allHeaders = ['ID','Title','Assignee ID','Assignee Name','Priority','Status', @@ -959,15 +1007,12 @@ function exportToExcel() { t.assignee, memberById(t.assignee)?.name || t.assignee, PRIORITY[t.priority]?.label || t.priority, - STATUS_LABEL[t.status] || t.status, + STATUS_LABEL[t.status] || t.status, t.project, - t.due || '', + t.due || '', t.desc || '', (t.tags || []).join(', '), ]); - const allSheet = XLSX.utils.aoa_to_sheet([allHeaders, ...allRows]); - applySheetStyle(allSheet, allHeaders.length, allRows.length); - XLSX.utils.book_append_sheet(wb, allSheet, 'All Tasks'); // ── Sheet 2: Today's Tasks ────────────────────── const todayTasks = tasks.filter(t => isToday(t.due) || (isOverdue(t.due) && t.status !== 'done')); @@ -975,51 +1020,49 @@ function exportToExcel() { t.title, memberById(t.assignee)?.name || t.assignee, PRIORITY[t.priority]?.label || t.priority, - STATUS_LABEL[t.status] || t.status, + STATUS_LABEL[t.status] || t.status, t.project, - t.due || '', + t.due || '', t.desc || '', ]); - const todaySheet = XLSX.utils.aoa_to_sheet( - [['Title','Assignee','Priority','Status','Project','Due Date','Description'], ...todayRows] - ); - applySheetStyle(todaySheet, 7, todayRows.length); - XLSX.utils.book_append_sheet(wb, todaySheet, "Today's Tasks"); - - // ── Sheet 3: Per-Member Summary ───────────────── - const summaryHeaders = ['Member','Role','Total Tasks','Active','Done','Overdue']; + + // ── Sheet 3: Team Summary ─────────────────────── const summaryRows = TEAM.map(m => { - const mt = tasks.filter(t => t.assignee === m.id); - const active = mt.filter(t => t.status !== 'done').length; - const done = mt.filter(t => t.status === 'done').length; - const overdue = mt.filter(t => isOverdue(t.due) && t.status !== 'done').length; - return [m.name, m.role, mt.length, active, done, overdue]; + const mt = tasks.filter(t => t.assignee === m.id); + return [ + m.name, m.role, mt.length, + mt.filter(t => t.status !== 'done').length, + mt.filter(t => t.status === 'done').length, + mt.filter(t => isOverdue(t.due) && t.status !== 'done').length, + ]; }); - const summarySheet = XLSX.utils.aoa_to_sheet([summaryHeaders, ...summaryRows]); - applySheetStyle(summarySheet, summaryHeaders.length, summaryRows.length); - XLSX.utils.book_append_sheet(wb, summarySheet, 'Team Summary'); - // ── Download ──────────────────────────────────── - const dateStamp = todayStr.replace(/-/g, ''); - XLSX.writeFile(wb, `board-tasks-${dateStamp}.xlsx`); - showToast(`Exported ${tasks.length} tasks to board-tasks-${dateStamp}.xlsx`); + const xml = [ + '', + '', + '', + sheetToXML('All Tasks', [allHeaders, ...allRows]), + sheetToXML("Today's Tasks", [['Title','Assignee','Priority','Status','Project','Due Date','Description'], ...todayRows]), + sheetToXML('Team Summary', [['Member','Role','Total Tasks','Active','Done','Overdue'], ...summaryRows]), + '', + ].join('\n'); + + triggerDownload(xml, `board-tasks-${dateStamp}.xls`, 'application/vnd.ms-excel;charset=utf-8'); + showToast(`Exported ${tasks.length} tasks to board-tasks-${dateStamp}.xls`); } -// Set column widths for readability -function applySheetStyle(ws, numCols, numRows) { - const colWidths = [ - { wch: 6 }, // ID - { wch: 42 }, // Title - { wch: 12 }, // Assignee ID - { wch: 18 }, // Assignee Name - { wch: 10 }, // Priority - { wch: 14 }, // Status - { wch: 20 }, // Project - { wch: 12 }, // Due Date - { wch: 40 }, // Description - { wch: 20 }, // Tags - ].slice(0, numCols); - ws['!cols'] = colWidths; +// ─── DOWNLOAD HELPER ────────────────────────────── +function triggerDownload(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } // ─── INIT ────────────────────────────────────────── diff --git a/dashboard/index.html b/dashboard/index.html index 1f8a1185329..910b3f860ef 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,12 +4,7 @@ Board Design Team — Task Dashboard - - - - - @@ -133,13 +128,13 @@

Overview

- + - - @@ -440,7 +435,7 @@ +
+ + syncing… +
+ + + Member View +
diff --git a/dashboard/member.html b/dashboard/member.html new file mode 100644 index 00000000000..57212ef5ef6 --- /dev/null +++ b/dashboard/member.html @@ -0,0 +1,518 @@ + + + + + + BoardFlow — My Tasks + + + + + + + + + + + + + + + + + diff --git a/dashboard/member.js b/dashboard/member.js new file mode 100644 index 00000000000..4e1ec84425a --- /dev/null +++ b/dashboard/member.js @@ -0,0 +1,456 @@ +/* ═══════════════════════════════════════════════════ + BOARDFLOW — Team Member View + Reads and writes the same localStorage keys as the + main dashboard (index.html / app.js), keeping all + task data in one central shared store. +════════════════════════════════════════════════════ */ + +// ─── SHARED STORAGE KEYS (must match app.js) ────── +const STORAGE_KEY = 'boardflow_tasks'; +const STORAGE_TEAM_KEY = 'boardflow_team'; +const STORAGE_SYNC_KEY = 'boardflow_sync'; +const STORAGE_MEMBER = 'boardflow_current_member'; + +// ─── FALLBACK TEAM (used when localStorage is empty) ── +const DEFAULT_TEAM = [ + { id: 'AL', name: 'Alex Laurent', role: 'Team Leader', initials: 'AL' }, + { id: 'SM', name: 'Sara Mitchell', role: 'Senior PCB Designer', initials: 'SM' }, + { id: 'JD', name: 'James Dupont', role: 'Schematic Engineer', initials: 'JD' }, + { id: 'MP', name: 'Mia Park', role: 'Signal Integrity Eng', initials: 'MP' }, + { id: 'RK', name: 'Ryan Kim', role: 'BOM & Procurement', initials: 'RK' }, + { id: 'TW', name: 'Tara Wilson', role: 'Layout Engineer', initials: 'TW' }, +]; + +const COLORS = { + AL: '#6366f1', SM: '#22c55e', JD: '#f97316', + MP: '#ec4899', RK: '#0ea5e9', TW: '#a855f7', +}; + +const PRIORITY = { + critical: { label: 'Critical', color: '#ef4444', icon: '▲▲' }, + high: { label: 'High', color: '#f97316', icon: '▲' }, + medium: { label: 'Medium', color: '#eab308', icon: '●' }, + low: { label: 'Low', color: '#94a3b8', icon: '▼' }, +}; + +const STATUS_CYCLE = ['todo', 'in-progress', 'review', 'done']; +const STATUS_LABEL = { + 'todo': 'To Do', 'in-progress': 'In Progress', + 'review': 'In Review', 'done': 'Done', +}; + +// ─── STATE ──────────────────────────────────────── +let tasks = []; +let team = [...DEFAULT_TEAM]; +let currentMember = null; +let statusFilter = 'all'; + +// ─── DATE HELPERS ───────────────────────────────── +const today = new Date(); +const fmt = d => d.toISOString().slice(0, 10); +const todayStr = fmt(today); + +function isOverdue(d) { return d && new Date(d) < today && fmt(new Date(d)) !== todayStr; } +function isToday(d) { return d && fmt(new Date(d)) === todayStr; } +function relDate(d) { + if (!d) return ''; + const diff = Math.round((new Date(d) - today) / 86400000); + if (diff < -1) return `${Math.abs(diff)}d overdue`; + if (diff === -1) return 'Yesterday'; + if (diff === 0) return 'Today'; + if (diff === 1) return 'Tomorrow'; + if (diff < 7) return `In ${diff}d`; + return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +// ─── SHARED STORAGE ─────────────────────────────── +function loadFromStorage() { + try { + const t = localStorage.getItem(STORAGE_KEY); + const m = localStorage.getItem(STORAGE_TEAM_KEY); + if (t) tasks = JSON.parse(t); + if (m) team = JSON.parse(m); + return !!t; + } catch(e) { return false; } +} + +function saveToStorage() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); + localStorage.setItem(STORAGE_SYNC_KEY, new Date().toISOString()); + updateSyncBar(); + } catch(e) {} +} + +function updateSyncBar() { + const dot = document.getElementById('msyncDot'); + const msg = document.getElementById('msyncStatus'); + if (!dot || !msg) return; + const t = localStorage.getItem(STORAGE_SYNC_KEY); + if (!t) { + dot.className = 'msync-dot err'; + msg.textContent = 'Not synced — no shared data found'; + return; + } + const diff = Math.round((Date.now() - new Date(t)) / 60000); + dot.className = 'msync-dot ok'; + msg.textContent = diff < 1 + ? 'Synced just now' + : diff < 60 ? `Synced ${diff}m ago` + : `Synced at ${new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; +} + +// ─── TOAST ──────────────────────────────────────── +let _toastTimer; +function showToast(msg, type = 'success') { + const el = document.getElementById('toast'); + const icon = document.getElementById('toastIcon'); + document.getElementById('toastMsg').textContent = msg; + icon.textContent = type === 'success' ? '✓' : type === 'error' ? '✕' : 'ℹ'; + el.className = `toast ${type}`; + clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => el.classList.add('hidden'), 3500); +} + +// ─── RFC-4180 CSV PARSER ────────────────────────── +function parseCSV(text) { + const rows = []; + const src = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + let pos = 0; + while (pos < src.length) { + const row = []; + while (pos < src.length && src[pos] !== '\n') { + let field = ''; + if (src[pos] === '"') { + pos++; + while (pos < src.length) { + if (src[pos] === '"') { + if (src[pos + 1] === '"') { field += '"'; pos += 2; } + else { pos++; break; } + } else { field += src[pos++]; } + } + } else { + while (pos < src.length && src[pos] !== ',' && src[pos] !== '\n') { + field += src[pos++]; + } + } + row.push(field); + if (pos < src.length && src[pos] === ',') pos++; + } + if (pos < src.length && src[pos] === '\n') pos++; + if (!(row.length === 1 && row[0] === '')) rows.push(row); + } + return rows; +} + +// ─── CSV COLUMN MAPPING ─────────────────────────── +const HEADER_MAP = { + 'title':'title','task':'title','task title':'title','name':'title', + 'assignee':'assignee','assigned to':'assignee','owner':'assignee', + 'member':'assignee','assignee id':'assignee', + 'priority':'priority', + 'status':'status', + 'project':'project', + 'due':'due','due date':'due','deadline':'due','date':'due', + 'description':'desc','desc':'desc','details':'desc','notes':'desc', + 'tags':'tags','labels':'tags','tag':'tags', +}; + +function normHeader(h) { return HEADER_MAP[(h||'').toString().trim().toLowerCase()] || null; } + +function normPriority(v) { + const s = (v||'').toString().trim().toLowerCase(); + if (s.includes('critical')||s==='p0') return 'critical'; + if (s.includes('high')||s==='p1') return 'high'; + if (s.includes('medium')||s==='p2'||s==='med') return 'medium'; + if (s.includes('low')||s==='p3') return 'low'; + return 'medium'; +} + +function normStatus(v) { + const s = (v||'').toString().trim().toLowerCase().replace(/\s+/g,'-'); + if (['todo','to-do','not-started','open'].includes(s)) return 'todo'; + if (['in-progress','in-work','wip','active'].includes(s)) return 'in-progress'; + if (['review','in-review','pending-review'].includes(s)) return 'review'; + if (['done','complete','completed','closed'].includes(s)) return 'done'; + return 'todo'; +} + +function normAssignee(v) { + const s = (v||'').toString().trim(); + const byId = team.find(m => m.id.toLowerCase() === s.toLowerCase()); + if (byId) return byId.id; + const byName = team.find(m => + m.name.toLowerCase().includes(s.toLowerCase()) || + s.toLowerCase().includes(m.name.split(' ')[0].toLowerCase()) + ); + return byName ? byName.id : (team[0]?.id || 'AL'); +} + +function normDate(v) { + if (!v) return ''; + const s = v.toString().trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(s)) { + const [a, b, y] = s.split('/'); + return `${y}-${a.padStart(2,'0')}-${b.padStart(2,'0')}`; + } + const p = new Date(s); + return isNaN(p) ? '' : fmt(p); +} + +function csvRowsToTasks(rows) { + if (!rows || rows.length < 2) return []; + const headers = rows[0].map(normHeader); + const result = []; + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const raw = {}; + headers.forEach((f, idx) => { if (f) raw[f] = row[idx]; }); + if (!raw.title) continue; + result.push({ + id: Date.now() + i, + title: raw.title.toString().trim(), + assignee: normAssignee(raw.assignee), + priority: normPriority(raw.priority), + status: normStatus(raw.status), + project: (raw.project || 'General').toString().trim(), + due: normDate(raw.due), + desc: (raw.desc || '').toString().trim(), + tags: raw.tags ? raw.tags.toString().split(',').map(t=>t.trim()).filter(Boolean) : [], + }); + } + return result; +} + +// ─── CSV IMPORT HANDLER (shared between login + dashboard) ──────────── +function handleCsvFile(file, onDone) { + if (!file) return; + const reader = new FileReader(); + reader.onload = ev => { + try { + const imported = csvRowsToTasks(parseCSV(ev.target.result)); + if (!imported.length) { showToast('No valid tasks found in file', 'error'); return; } + tasks = imported; + saveToStorage(); + showToast(`Loaded ${imported.length} tasks from "${file.name}"`); + if (onDone) onDone(); + } catch(e) { showToast('Could not read the CSV file', 'error'); } + }; + reader.readAsText(file); +} + +// ─── LOGIN SCREEN ───────────────────────────────── +function renderLoginScreen() { + const hasData = loadFromStorage(); + document.getElementById('noDataBanner').style.display = hasData ? 'none' : 'block'; + + const search = (document.getElementById('memberSearch')?.value || '').toLowerCase(); + const picker = document.getElementById('memberPicker'); + + picker.innerHTML = team.map(m => ` +
+
${m.initials}
+
${m.name}
+
${m.role}
+
+ `).join(''); + + picker.querySelectorAll('.member-pick-card').forEach(card => { + card.addEventListener('click', () => selectMember(card.dataset.id)); + }); +} + +// ─── MEMBER SELECTION ───────────────────────────── +function selectMember(id) { + currentMember = team.find(m => m.id === id); + if (!currentMember) return; + localStorage.setItem(STORAGE_MEMBER, id); + document.getElementById('loginScreen').classList.add('hidden'); + document.getElementById('memberDashboard').classList.remove('hidden'); + renderDashboard(); +} + +// ─── DASHBOARD RENDER ───────────────────────────── +function renderDashboard() { + // Header + document.getElementById('headerAvatar').innerHTML = + `
${currentMember.initials}
`; + document.getElementById('headerName').textContent = currentMember.name; + document.getElementById('headerRole').textContent = currentMember.role; + document.getElementById('memberDateText').textContent = today.toLocaleDateString('en-US', { + weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', + }); + + updateSyncBar(); + renderStats(); + renderTodaySection(); + renderAllTasks(); +} + +function myTasks() { + return tasks.filter(t => t.assignee === currentMember.id); +} + +// ─── STATS ──────────────────────────────────────── +function renderStats() { + const mt = myTasks(); + const overdue = mt.filter(t => isOverdue(t.due) && t.status !== 'done').length; + const stats = [ + { label: 'Total Tasks', value: mt.length, color: '#6366f1' }, + { label: 'Due Today', value: mt.filter(t => isToday(t.due) && t.status !== 'done').length, color: '#f97316' }, + { label: 'In Progress', value: mt.filter(t => t.status === 'in-progress').length, color: '#0ea5e9' }, + { label: 'Completed', value: mt.filter(t => t.status === 'done').length, color: '#22c55e' }, + { label: 'Overdue', value: overdue, color: overdue ? '#ef4444' : '#5a5f72' }, + ]; + document.getElementById('memberStatsRow').innerHTML = stats.map(s => ` +
+
${s.value}
+
${s.label}
+
`).join(''); +} + +// ─── TODAY SECTION ──────────────────────────────── +function renderTodaySection() { + const due = myTasks() + .filter(t => (isToday(t.due) || isOverdue(t.due)) && t.status !== 'done') + .sort(byPriority); + document.getElementById('todayCountBadge').textContent = due.length; + document.getElementById('todayList').innerHTML = + due.map(renderTaskRow).join('') || + '
No tasks due today — great work!
'; +} + +// ─── ALL TASKS SECTION ──────────────────────────── +function renderAllTasks() { + let mt = myTasks(); + if (statusFilter !== 'all') mt = mt.filter(t => t.status === statusFilter); + mt.sort(byPriority); + document.getElementById('allCountBadge').textContent = mt.length; + document.getElementById('allTasksList').innerHTML = + mt.map(renderTaskRow).join('') || + '
No tasks found for this filter.
'; +} + +function byPriority(a, b) { + return ['critical','high','medium','low'].indexOf(a.priority) - + ['critical','high','medium','low'].indexOf(b.priority); +} + +// ─── TASK ROW ───────────────────────────────────── +function renderTaskRow(task) { + const done = task.status === 'done'; + const overdue = isOverdue(task.due) && !done; + const todayDue = isToday(task.due) && !done; + const dueLabel = relDate(task.due); + const priColor = PRIORITY[task.priority]?.color || '#94a3b8'; + const nextStatus = STATUS_LABEL[STATUS_CYCLE[(STATUS_CYCLE.indexOf(task.status) + 1) % STATUS_CYCLE.length]]; + + return ` +
+ + +
+
${task.title}
+ ${task.desc ? `
${task.desc}
` : ''} +
+
+ ${task.project} + ${dueLabel ? ` + + + + + ${dueLabel}` : ''} + + ${PRIORITY[task.priority]?.icon} ${PRIORITY[task.priority]?.label} + + ${STATUS_LABEL[task.status]} +
+
`; +} + +function statusIcon(s) { + const w = 'width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"'; + if (s === 'done') return ``; + if (s === 'in-progress') return ``; + if (s === 'review') return ``; + return ``; +} + +// ─── CYCLE TASK STATUS ──────────────────────────── +function cycleStatus(id) { + const task = tasks.find(t => t.id === id); + if (!task) return; + const prev = task.status; + task.status = STATUS_CYCLE[(STATUS_CYCLE.indexOf(task.status) + 1) % STATUS_CYCLE.length]; + saveToStorage(); + renderDashboard(); + const title = task.title.length > 35 ? task.title.slice(0, 35) + '…' : task.title; + showToast(`"${title}" → ${STATUS_LABEL[task.status]}`); +} + +// ─── STATUS TABS ────────────────────────────────── +document.getElementById('statusTabs').addEventListener('click', e => { + const btn = e.target.closest('.stab'); + if (!btn) return; + document.querySelectorAll('.stab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + statusFilter = btn.dataset.s; + renderAllTasks(); +}); + +// ─── SEARCH (login screen) ──────────────────────── +document.getElementById('memberSearch').addEventListener('input', () => { + renderLoginScreen(); +}); + +// ─── LOGOUT ─────────────────────────────────────── +document.getElementById('logoutBtn').addEventListener('click', () => { + currentMember = null; + statusFilter = 'all'; + localStorage.removeItem(STORAGE_MEMBER); + document.getElementById('memberDashboard').classList.add('hidden'); + document.getElementById('loginScreen').classList.remove('hidden'); + renderLoginScreen(); +}); + +// ─── REFRESH ────────────────────────────────────── +document.getElementById('refreshBtn').addEventListener('click', () => { + loadFromStorage(); + renderDashboard(); + showToast('Tasks refreshed from shared storage'); +}); + +// ─── CSV IMPORT (dashboard bar) ─────────────────── +document.getElementById('memberCsvInput').addEventListener('change', e => { + handleCsvFile(e.target.files[0], () => renderDashboard()); + e.target.value = ''; +}); + +// ─── CSV IMPORT (login screen) ──────────────────── +document.getElementById('loginCsvInput').addEventListener('change', e => { + handleCsvFile(e.target.files[0], () => renderLoginScreen()); + e.target.value = ''; +}); + +// ─── SYNC CHIP REFRESH (every minute) ───────────── +setInterval(updateSyncBar, 60000); + +// ─── INIT ───────────────────────────────────────── +// Auto-login if a member was previously selected +(function init() { + loadFromStorage(); + const lastId = localStorage.getItem(STORAGE_MEMBER); + if (lastId) { + const found = team.find(m => m.id === lastId); + if (found) { selectMember(lastId); return; } + } + renderLoginScreen(); +})(); diff --git a/dashboard/styles.css b/dashboard/styles.css index a05dff597df..d69733e56b8 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -225,6 +225,28 @@ body.sidebar-collapsed .main { margin-left: 58px; } } .topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.sync-chip { + display: flex; align-items: center; gap: 5px; + font-size: 11px; color: var(--text-3); +} +.sync-chip-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--green); + box-shadow: 0 0 5px var(--green); +} +.member-view-link { + display: flex; align-items: center; gap: 5px; + font-size: 12px; color: var(--indigo-light); + background: var(--indigo-dim); + border: 1px solid var(--indigo); + border-radius: 99px; + padding: 4px 11px; + text-decoration: none; + transition: var(--transition); + white-space: nowrap; +} +.member-view-link:hover { background: var(--indigo); color: #fff; } + .search-wrap { display: flex; align-items: center; From 3a98455c9706e97129cfbb79a467bd7125e40cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 18:59:58 +0000 Subject: [PATCH 5/5] =?UTF-8?q?Add=20Electron=20desktop=20wrapper=20?= =?UTF-8?q?=E2=80=94=20produces=20Windows=20.exe=20for=20both=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates electron/ directory with everything needed to build two standalone portable executables (no installation required): BoardFlow-Dashboard.exe — team leader dashboard BoardFlow-Member.exe — team member personal task view Architecture: - electron/main-dashboard.js Main process for dashboard window - electron/main-member.js Main process for member window - electron/preload.js Context-bridge IPC: exposes loadData / saveData / getDataPath to renderer without enabling nodeIntegration - electron/package.json npm scripts + electron-builder devDeps - electron/config-dashboard.json electron-builder portable .exe config - electron/config-member.json electron-builder portable .exe config - electron/.gitignore excludes node_modules/ and dist/ Central data file: %APPDATA%\BoardFlow\tasks.json (Windows) Both executables read and write the same file, so task changes made in one app are immediately visible in the other after a Refresh. Storage layer: - dashboard/app.js + member.js updated: all three storage functions (save/load/updateUI) now check window.electronAPI first; fall back to localStorage so the browser version continues to work unchanged. Build steps (run on any machine with Node.js ≥ 18): cd electron npm install npm run build # builds both exes npm run build:dashboard # dashboard only → dist/dashboard/BoardFlow-Dashboard.exe npm run build:member # member only → dist/member/BoardFlow-Member.exe Dev / preview (no build needed): cd electron npm install npm run start:dashboard npm run start:member https://claude.ai/code/session_01BddfitpYfdVvu7oAjt5i14 --- dashboard/app.js | 27 +++++++++-- dashboard/member.js | 26 ++++++++-- electron/.gitignore | 2 + electron/config-dashboard.json | 24 +++++++++ electron/config-member.json | 24 +++++++++ electron/main-dashboard.js | 89 ++++++++++++++++++++++++++++++++++ electron/main-member.js | 86 ++++++++++++++++++++++++++++++++ electron/package.json | 17 +++++++ electron/preload.js | 32 ++++++++++++ 9 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 electron/.gitignore create mode 100644 electron/config-dashboard.json create mode 100644 electron/config-member.json create mode 100644 electron/main-dashboard.js create mode 100644 electron/main-member.js create mode 100644 electron/package.json create mode 100644 electron/preload.js diff --git a/dashboard/app.js b/dashboard/app.js index 3263fb5f074..69e009cf0be 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -293,15 +293,26 @@ const STORAGE_SYNC_KEY = 'boardflow_sync'; function saveToShared() { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); - localStorage.setItem(STORAGE_TEAM_KEY, JSON.stringify(TEAM)); - localStorage.setItem(STORAGE_SYNC_KEY, new Date().toISOString()); - updateSyncChip(); - } catch(e) { /* storage full / private mode */ } + if (window.electronAPI) { + // Desktop: write to %APPDATA%/BoardFlow/tasks.json (fire-and-forget) + window.electronAPI.saveData({ tasks, team: TEAM, sync: new Date().toISOString() }); + } else { + // Browser: use localStorage + localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); + localStorage.setItem(STORAGE_TEAM_KEY, JSON.stringify(TEAM)); + localStorage.setItem(STORAGE_SYNC_KEY, new Date().toISOString()); + } + } catch(e) { /* storage unavailable */ } + updateSyncChip(); } function loadFromShared() { try { + if (window.electronAPI) { + const data = window.electronAPI.loadData(); + if (data?.tasks) { tasks = data.tasks; return true; } + return false; + } const stored = localStorage.getItem(STORAGE_KEY); if (stored) { tasks = JSON.parse(stored); return true; } } catch(e) {} @@ -311,6 +322,12 @@ function loadFromShared() { function updateSyncChip() { const el = document.getElementById('syncChip'); if (!el) return; + if (window.electronAPI) { + const p = window.electronAPI.getDataPath(); + el.textContent = p; + el.title = p; + return; + } const t = localStorage.getItem(STORAGE_SYNC_KEY); if (!t) { el.textContent = 'not synced'; return; } const diff = Math.round((Date.now() - new Date(t)) / 60000); diff --git a/dashboard/member.js b/dashboard/member.js index 4e1ec84425a..21ec779d73c 100644 --- a/dashboard/member.js +++ b/dashboard/member.js @@ -64,8 +64,19 @@ function relDate(d) { } // ─── SHARED STORAGE ─────────────────────────────── +// Desktop (Electron): reads/writes %APPDATA%/BoardFlow/tasks.json +// Browser: reads/writes localStorage function loadFromStorage() { try { + if (window.electronAPI) { + const data = window.electronAPI.loadData(); + if (data) { + if (data.tasks) tasks = data.tasks; + if (data.team) team = data.team; + return !!data.tasks; + } + return false; + } const t = localStorage.getItem(STORAGE_KEY); const m = localStorage.getItem(STORAGE_TEAM_KEY); if (t) tasks = JSON.parse(t); @@ -76,16 +87,25 @@ function loadFromStorage() { function saveToStorage() { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); - localStorage.setItem(STORAGE_SYNC_KEY, new Date().toISOString()); - updateSyncBar(); + if (window.electronAPI) { + window.electronAPI.saveData({ tasks, team, sync: new Date().toISOString() }); + } else { + localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); + localStorage.setItem(STORAGE_SYNC_KEY, new Date().toISOString()); + } } catch(e) {} + updateSyncBar(); } function updateSyncBar() { const dot = document.getElementById('msyncDot'); const msg = document.getElementById('msyncStatus'); if (!dot || !msg) return; + if (window.electronAPI) { + dot.className = 'msync-dot ok'; + msg.textContent = `Data file: ${window.electronAPI.getDataPath()}`; + return; + } const t = localStorage.getItem(STORAGE_SYNC_KEY); if (!t) { dot.className = 'msync-dot err'; diff --git a/electron/.gitignore b/electron/.gitignore new file mode 100644 index 00000000000..b9470778764 --- /dev/null +++ b/electron/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/electron/config-dashboard.json b/electron/config-dashboard.json new file mode 100644 index 00000000000..c86eb6b05e4 --- /dev/null +++ b/electron/config-dashboard.json @@ -0,0 +1,24 @@ +{ + "appId": "com.boardflow.dashboard", + "productName": "BoardFlow Dashboard", + "copyright": "BoardFlow Team", + "extraMetadata": { + "main": "main-dashboard.js" + }, + "files": [ + "main-dashboard.js", + "preload.js", + { "from": "../dashboard", "to": "dashboard", "filter": ["**/*"] } + ], + "win": { + "target": [ + { "target": "portable", "arch": ["x64"] } + ] + }, + "portable": { + "artifactName": "BoardFlow-Dashboard.exe" + }, + "directories": { + "output": "dist/dashboard" + } +} diff --git a/electron/config-member.json b/electron/config-member.json new file mode 100644 index 00000000000..01e471eb299 --- /dev/null +++ b/electron/config-member.json @@ -0,0 +1,24 @@ +{ + "appId": "com.boardflow.member", + "productName": "BoardFlow Member", + "copyright": "BoardFlow Team", + "extraMetadata": { + "main": "main-member.js" + }, + "files": [ + "main-member.js", + "preload.js", + { "from": "../dashboard", "to": "dashboard", "filter": ["**/*"] } + ], + "win": { + "target": [ + { "target": "portable", "arch": ["x64"] } + ] + }, + "portable": { + "artifactName": "BoardFlow-Member.exe" + }, + "directories": { + "output": "dist/member" + } +} diff --git a/electron/main-dashboard.js b/electron/main-dashboard.js new file mode 100644 index 00000000000..ddaa0010a88 --- /dev/null +++ b/electron/main-dashboard.js @@ -0,0 +1,89 @@ +/** + * main-dashboard.js — Electron main process for BoardFlow Dashboard + * Opens dashboard/index.html in a native window. + * + * Data file: %APPDATA%\BoardFlow\tasks.json (Windows) + * ~/Library/Application Support/BoardFlow/tasks.json (macOS) + * ~/.config/BoardFlow/tasks.json (Linux) + */ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +// ─── DATA FILE ──────────────────────────────────── +// app.getPath('userData') resolves to the OS-appropriate AppData folder +const dataDir = path.join(app.getPath('userData'), 'BoardFlow'); +const dataFile = path.join(dataDir, 'tasks.json'); + +function ensureDataDir() { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); +} + +// ─── IPC: LOAD (synchronous) ────────────────────── +ipcMain.on('load-data', event => { + try { + ensureDataDir(); + event.returnValue = fs.existsSync(dataFile) + ? JSON.parse(fs.readFileSync(dataFile, 'utf8')) + : null; + } catch(e) { + console.error('[load-data]', e.message); + event.returnValue = null; + } +}); + +// ─── IPC: SAVE (async, fire-and-forget) ─────────── +ipcMain.on('save-data', (event, data) => { + try { + ensureDataDir(); + fs.writeFileSync(dataFile, JSON.stringify(data, null, 2), 'utf8'); + } catch(e) { + console.error('[save-data]', e.message); + } +}); + +// ─── IPC: DATA FILE PATH ────────────────────────── +ipcMain.on('get-data-path', event => { + event.returnValue = dataFile; +}); + +// ─── WINDOW ─────────────────────────────────────── +function createWindow() { + const win = new BrowserWindow({ + width: 1300, + height: 840, + minWidth: 960, + minHeight: 640, + title: 'BoardFlow — Dashboard', + backgroundColor: '#0d0f14', + show: false, // show only after content loads (avoids white flash) + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, // required for contextBridge + nodeIntegration: false, // keep renderer sandboxed + }, + }); + + // Dev: load from source tree; packaged: load from bundled resources + const htmlFile = app.isPackaged + ? path.join(__dirname, 'dashboard', 'index.html') + : path.join(__dirname, '..', 'dashboard', 'index.html'); + + win.loadFile(htmlFile); + win.once('ready-to-show', () => win.show()); + + // Remove the default menu bar (File / Edit / View…) + win.removeMenu(); +} + +app.whenReady().then(createWindow); + +// Quit when all windows are closed (except on macOS) +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +// macOS: re-open window when clicking dock icon +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); diff --git a/electron/main-member.js b/electron/main-member.js new file mode 100644 index 00000000000..871e7f35acc --- /dev/null +++ b/electron/main-member.js @@ -0,0 +1,86 @@ +/** + * main-member.js — Electron main process for BoardFlow Member View + * Opens dashboard/member.html in a native window. + * + * Reads and writes the SAME data file as main-dashboard.js so both + * executables share one central task store. + * + * Data file: %APPDATA%\BoardFlow\tasks.json (Windows) + * ~/Library/Application Support/BoardFlow/tasks.json (macOS) + * ~/.config/BoardFlow/tasks.json (Linux) + */ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +// ─── DATA FILE (identical path to main-dashboard.js) ─ +const dataDir = path.join(app.getPath('userData'), 'BoardFlow'); +const dataFile = path.join(dataDir, 'tasks.json'); + +function ensureDataDir() { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); +} + +// ─── IPC: LOAD ──────────────────────────────────── +ipcMain.on('load-data', event => { + try { + ensureDataDir(); + event.returnValue = fs.existsSync(dataFile) + ? JSON.parse(fs.readFileSync(dataFile, 'utf8')) + : null; + } catch(e) { + console.error('[load-data]', e.message); + event.returnValue = null; + } +}); + +// ─── IPC: SAVE ──────────────────────────────────── +ipcMain.on('save-data', (event, data) => { + try { + ensureDataDir(); + fs.writeFileSync(dataFile, JSON.stringify(data, null, 2), 'utf8'); + } catch(e) { + console.error('[save-data]', e.message); + } +}); + +// ─── IPC: DATA FILE PATH ────────────────────────── +ipcMain.on('get-data-path', event => { + event.returnValue = dataFile; +}); + +// ─── WINDOW ─────────────────────────────────────── +function createWindow() { + const win = new BrowserWindow({ + width: 960, + height: 780, + minWidth: 700, + minHeight: 560, + title: 'BoardFlow — My Tasks', + backgroundColor: '#0d0f14', + show: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + const htmlFile = app.isPackaged + ? path.join(__dirname, 'dashboard', 'member.html') + : path.join(__dirname, '..', 'dashboard', 'member.html'); + + win.loadFile(htmlFile); + win.once('ready-to-show', () => win.show()); + win.removeMenu(); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 00000000000..4e127413733 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,17 @@ +{ + "name": "boardflow-electron", + "version": "1.0.0", + "description": "BoardFlow — Board Design Team Task Manager (Desktop)", + "private": true, + "scripts": { + "start:dashboard": "electron main-dashboard.js", + "start:member": "electron main-member.js", + "build:dashboard": "electron-builder --config config-dashboard.json", + "build:member": "electron-builder --config config-member.json", + "build": "npm run build:dashboard && npm run build:member" + }, + "devDependencies": { + "electron": "^29.4.6", + "electron-builder": "^24.13.3" + } +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 00000000000..59403468b35 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,32 @@ +/** + * preload.js — Electron context bridge + * + * Exposes a safe, minimal API to the renderer (dashboard/app.js and + * dashboard/member.js) so they can read/write the shared tasks.json file + * without exposing the full Node.js API. + * + * contextIsolation is ON (nodeIntegration is OFF), so this is the only + * channel between the renderer and the main process. + */ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + /** + * Load all data from tasks.json. + * Returns { tasks, team, sync } or null if file doesn't exist yet. + * Called synchronously — app waits for data before first render. + */ + loadData: () => ipcRenderer.sendSync('load-data'), + + /** + * Persist data to tasks.json. + * Fire-and-forget — renderer does not wait for the write to complete. + * @param {{ tasks: Array, team: Array, sync: string }} data + */ + saveData: (data) => ipcRenderer.send('save-data', data), + + /** + * Returns the absolute path to tasks.json (shown in the sync bar). + */ + getDataPath: () => ipcRenderer.sendSync('get-data-path'), +});