diff --git a/dashboard/app.js b/dashboard/app.js
new file mode 100644
index 00000000000..69e009cf0be
--- /dev/null
+++ b/dashboard/app.js
@@ -0,0 +1,1123 @@
+/* ═══════════════════════════════════════════════════
+ 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'],
+ },
+];
+
+// ─── SHARED STORAGE (central data store shared with member.html) ────
+const STORAGE_KEY = 'boardflow_tasks';
+const STORAGE_TEAM_KEY = 'boardflow_team';
+const STORAGE_SYNC_KEY = 'boardflow_sync';
+
+function saveToShared() {
+ try {
+ 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) {}
+ return false;
+}
+
+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);
+ el.textContent = diff < 1 ? 'synced just now' : `synced ${diff}m ago`;
+}
+
+// ─── 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 `
+
+
+ ${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 `
+
+
+
+
+ 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 `
+
+
+
+ ${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();
+ saveToShared(); // keep central store in sync after every change
+}
+
+// ─── 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();
+});
+
+// ═══════════════════════════════════════════════════
+// CSV IMPORT + SPREADSHEETML EXPORT (no dependencies)
+// ═══════════════════════════════════════════════════
+
+// ─── 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 '';
+ const s = v.toString().trim();
+ 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 '';
+}
+
+// ─── 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 = '';
+
+// ─── 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];
+ if (!file) return;
+ e.target.value = ''; // reset so same file can be re-selected
+
+ const reader = new FileReader();
+ reader.onload = (ev) => {
+ try {
+ const rows = parseCSV(ev.target.result);
+ const parsed = rowsToTasks(rows);
+
+ if (parsed.length === 0) {
+ showToast('No valid tasks found — check column headers.', 'error');
+ return;
+ }
+
+ pendingImportRows = parsed;
+ pendingFileName = file.name;
+
+ document.getElementById('importFileInfo').innerHTML = `
+
+
+
${file.name}
+
${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 .csv.', 'error');
+ console.error(err);
+ }
+ };
+ reader.readAsText(file); // plain text — no ArrayBuffer needed
+});
+
+// ─── 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 (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, '"');
+}
+
+// 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 ``;
+}
+
+// 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',
+ '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(', '),
+ ]);
+
+ // ── 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 || '',
+ ]);
+
+ // ── Sheet 3: Team Summary ───────────────────────
+ const summaryRows = TEAM.map(m => {
+ 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 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`);
+}
+
+// ─── 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 ──────────────────────────────────────────
+// Load from shared storage if available; otherwise seed it with defaults
+loadFromShared();
+renderAll();
+switchView('overview');
+// Refresh sync chip every minute
+setInterval(updateSyncChip, 60000);
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 00000000000..08ff271f690
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,510 @@
+
+
+
+
+
+ Board Design Team — Task Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
same as yesterday
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AL
+
+
Alex Laurent
+
Board Design Team Leader
+
+ 8 tasks
+ 3 in progress
+ 2 overdue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
BoardFlow
+
+
+
Who are you?
+
Select your profile to view your tasks
+
+
+
+
+
+
+
+
+
+
+
+
+
No task data found
+ Open the team leader's dashboard first — it will seed the shared storage.
+ Or import a CSV file directly.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BoardFlow
+
·
+
My Tasks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading…
+
|
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/member.js b/dashboard/member.js
new file mode 100644
index 00000000000..21ec779d73c
--- /dev/null
+++ b/dashboard/member.js
@@ -0,0 +1,476 @@
+/* ═══════════════════════════════════════════════════
+ 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 ───────────────────────────────
+// 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);
+ if (m) team = JSON.parse(m);
+ return !!t;
+ } catch(e) { return false; }
+}
+
+function saveToStorage() {
+ try {
+ 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';
+ 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
new file mode 100644
index 00000000000..d69733e56b8
--- /dev/null
+++ b/dashboard/styles.css
@@ -0,0 +1,943 @@
+/* ═══════════════════════════════════════════════════
+ 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', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, 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; }
+
+.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;
+ 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); }
+
+.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; }
+.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;
+}
+
+/* ─── 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; }
+ .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; }
+}
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'),
+});