hzl-web 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,14 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>hzl dashboard</title>
6
+ <meta name="theme-color" content="#1a1a1a">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
9
+ <link rel="shortcut icon" href="/favicon.ico">
10
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
11
+ <meta name="apple-mobile-web-app-title" content="HZL">
12
+ <link rel="manifest" href="/site.webmanifest">
13
+ <title>HZL</title>
7
14
  <style>
8
15
  :root {
9
16
  --bg-primary: #1a1a1a;
@@ -42,7 +49,8 @@
42
49
  .header {
43
50
  display: flex;
44
51
  align-items: center;
45
- justify-content: space-between;
52
+ justify-content: flex-start;
53
+ gap: 14px;
46
54
  padding: 12px 16px;
47
55
  background: var(--bg-secondary);
48
56
  border-bottom: 1px solid var(--border);
@@ -55,6 +63,7 @@
55
63
  display: flex;
56
64
  align-items: center;
57
65
  gap: 8px;
66
+ flex-shrink: 0;
58
67
  }
59
68
 
60
69
  .logo {
@@ -66,13 +75,15 @@
66
75
  .header-filters {
67
76
  display: flex;
68
77
  align-items: center;
69
- gap: 12px;
78
+ gap: 8px;
79
+ flex: 1;
80
+ min-width: 0;
70
81
  }
71
82
 
72
83
  .filter-group {
73
84
  display: flex;
74
85
  align-items: center;
75
- gap: 6px;
86
+ gap: 8px;
76
87
  position: relative;
77
88
  }
78
89
 
@@ -87,9 +98,11 @@
87
98
  background: var(--bg-primary);
88
99
  color: var(--text-primary);
89
100
  border: 1px solid var(--border);
90
- padding: 4px 8px;
91
- border-radius: 4px;
101
+ padding: 0 44px 0 12px;
102
+ border-radius: 6px;
92
103
  cursor: pointer;
104
+ min-height: 42px;
105
+ line-height: 1.2;
93
106
  }
94
107
 
95
108
  select:focus {
@@ -97,10 +110,69 @@
97
110
  border-color: var(--accent);
98
111
  }
99
112
 
113
+ .task-search-group {
114
+ gap: 6px;
115
+ }
116
+
117
+ .task-search-input {
118
+ width: 220px;
119
+ font-family: var(--font-mono);
120
+ font-size: 12px;
121
+ background: var(--bg-primary);
122
+ color: var(--text-primary);
123
+ border: 1px solid var(--border);
124
+ padding: 0 12px;
125
+ border-radius: 6px;
126
+ min-height: 42px;
127
+ }
128
+
129
+ .task-search-input:focus {
130
+ outline: none;
131
+ border-color: var(--accent);
132
+ }
133
+
134
+ .task-search-clear {
135
+ border: 1px solid var(--border);
136
+ border-radius: 4px;
137
+ background: var(--bg-primary);
138
+ color: var(--text-secondary);
139
+ font-family: var(--font-mono);
140
+ font-size: 12px;
141
+ line-height: 1;
142
+ padding: 4px 8px;
143
+ cursor: pointer;
144
+ }
145
+
146
+ .task-search-clear:hover {
147
+ border-color: var(--accent);
148
+ color: var(--text-primary);
149
+ }
150
+
151
+ .task-search-clear[hidden] {
152
+ display: none;
153
+ }
154
+
155
+ .task-search-meta {
156
+ min-width: 0;
157
+ width: 0;
158
+ overflow: hidden;
159
+ font-size: 11px;
160
+ color: var(--text-muted);
161
+ text-align: right;
162
+ font-variant-numeric: tabular-nums;
163
+ transition: width 120ms ease;
164
+ }
165
+
166
+ .task-search-group.active .task-search-meta {
167
+ width: 56px;
168
+ }
169
+
100
170
  .header-right {
101
171
  display: flex;
102
172
  align-items: center;
103
173
  gap: 12px;
174
+ margin-left: auto;
175
+ flex-shrink: 0;
104
176
  }
105
177
 
106
178
  .connection-indicator {
@@ -134,8 +206,9 @@
134
206
  background: var(--bg-primary);
135
207
  color: var(--text-primary);
136
208
  border: 1px solid var(--border);
137
- padding: 6px 12px;
138
- border-radius: 4px;
209
+ min-height: 42px;
210
+ padding: 0 14px;
211
+ border-radius: 6px;
139
212
  cursor: pointer;
140
213
  }
141
214
 
@@ -143,6 +216,63 @@
143
216
  border-color: var(--accent);
144
217
  }
145
218
 
219
+ .settings-shortcuts-btn {
220
+ width: 100%;
221
+ font-family: var(--font-mono);
222
+ font-size: 12px;
223
+ background: var(--bg-primary);
224
+ color: var(--text-secondary);
225
+ border: 1px solid var(--border);
226
+ padding: 6px 8px;
227
+ border-radius: 4px;
228
+ cursor: pointer;
229
+ text-align: left;
230
+ white-space: nowrap;
231
+ }
232
+
233
+ .settings-shortcuts-btn:hover {
234
+ color: var(--text-primary);
235
+ border-color: var(--accent);
236
+ }
237
+
238
+ .settings-view-select {
239
+ width: 100%;
240
+ }
241
+
242
+ .collapse-parents-actions {
243
+ display: flex;
244
+ gap: 6px;
245
+ margin-top: 2px;
246
+ }
247
+
248
+ .collapse-parents-btn {
249
+ flex: 1;
250
+ font-family: var(--font-mono);
251
+ font-size: 11px;
252
+ background: var(--bg-primary);
253
+ color: var(--text-secondary);
254
+ border: 1px solid var(--border);
255
+ padding: 5px 6px;
256
+ border-radius: 4px;
257
+ cursor: pointer;
258
+ }
259
+
260
+ .collapse-parents-btn:hover:not(:disabled) {
261
+ color: var(--text-primary);
262
+ border-color: var(--accent);
263
+ }
264
+
265
+ .collapse-parents-btn:disabled {
266
+ opacity: 0.45;
267
+ cursor: not-allowed;
268
+ }
269
+
270
+ .collapse-parents-meta {
271
+ margin-top: 5px;
272
+ font-size: 10px;
273
+ color: var(--text-muted);
274
+ }
275
+
146
276
  /* Column Visibility Dropdown */
147
277
  .columns-toggle {
148
278
  font-family: var(--font-mono);
@@ -207,11 +337,25 @@
207
337
  background: var(--bg-primary);
208
338
  color: var(--text-secondary);
209
339
  border: 1px solid var(--border);
210
- padding: 6px 8px;
211
- border-radius: 4px;
340
+ width: 42px;
341
+ height: 42px;
342
+ padding: 0;
343
+ border-radius: 6px;
212
344
  cursor: pointer;
213
345
  }
214
346
 
347
+ #dateFilter {
348
+ min-width: 150px;
349
+ }
350
+
351
+ #projectFilter {
352
+ min-width: 220px;
353
+ }
354
+
355
+ #assigneeFilter {
356
+ min-width: 180px;
357
+ }
358
+
215
359
  .settings-toggle:hover {
216
360
  border-color: var(--accent);
217
361
  color: var(--text-primary);
@@ -484,6 +628,26 @@
484
628
  margin-bottom: 6px;
485
629
  }
486
630
 
631
+ .card-subtask-toggle {
632
+ display: inline-flex;
633
+ align-items: center;
634
+ gap: 4px;
635
+ border: 1px solid var(--border);
636
+ border-radius: 4px;
637
+ background: var(--bg-primary);
638
+ color: var(--text-muted);
639
+ font-family: var(--font-mono);
640
+ font-size: 11px;
641
+ padding: 2px 6px;
642
+ margin-bottom: 6px;
643
+ cursor: pointer;
644
+ }
645
+
646
+ .card-subtask-toggle:hover {
647
+ color: var(--text-primary);
648
+ border-color: var(--accent);
649
+ }
650
+
487
651
  .card-blocked {
488
652
  font-size: 10px;
489
653
  color: var(--status-blocked);
@@ -977,6 +1141,91 @@
977
1141
  display: block;
978
1142
  }
979
1143
 
1144
+ .shortcuts-modal-overlay {
1145
+ position: fixed;
1146
+ inset: 0;
1147
+ background: rgba(0, 0, 0, 0.65);
1148
+ display: none;
1149
+ align-items: center;
1150
+ justify-content: center;
1151
+ z-index: 300;
1152
+ }
1153
+
1154
+ .shortcuts-modal-overlay.open {
1155
+ display: flex;
1156
+ }
1157
+
1158
+ .shortcuts-modal {
1159
+ width: min(460px, calc(100vw - 24px));
1160
+ background: var(--bg-secondary);
1161
+ border: 1px solid var(--border);
1162
+ border-radius: 8px;
1163
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
1164
+ overflow: hidden;
1165
+ }
1166
+
1167
+ .shortcuts-header {
1168
+ display: flex;
1169
+ align-items: center;
1170
+ justify-content: space-between;
1171
+ padding: 12px 16px;
1172
+ border-bottom: 1px solid var(--border);
1173
+ }
1174
+
1175
+ .shortcuts-title {
1176
+ font-size: 14px;
1177
+ font-weight: 600;
1178
+ color: var(--text-primary);
1179
+ }
1180
+
1181
+ .shortcuts-close {
1182
+ border: none;
1183
+ background: transparent;
1184
+ color: var(--text-muted);
1185
+ font-size: 18px;
1186
+ cursor: pointer;
1187
+ line-height: 1;
1188
+ }
1189
+
1190
+ .shortcuts-close:hover {
1191
+ color: var(--text-primary);
1192
+ }
1193
+
1194
+ .shortcuts-body {
1195
+ padding: 14px 16px 16px;
1196
+ }
1197
+
1198
+ .shortcuts-list {
1199
+ display: grid;
1200
+ grid-template-columns: auto 1fr;
1201
+ gap: 8px 12px;
1202
+ align-items: center;
1203
+ }
1204
+
1205
+ .shortcut-key {
1206
+ display: inline-block;
1207
+ min-width: 34px;
1208
+ padding: 1px 8px;
1209
+ border: 1px solid var(--border);
1210
+ border-radius: 4px;
1211
+ background: var(--bg-primary);
1212
+ color: var(--text-primary);
1213
+ font-size: 11px;
1214
+ text-align: center;
1215
+ font-weight: 600;
1216
+ }
1217
+
1218
+ .shortcut-desc {
1219
+ color: var(--text-secondary);
1220
+ font-size: 12px;
1221
+ }
1222
+
1223
+ .shortcuts-note {
1224
+ margin-top: 12px;
1225
+ font-size: 11px;
1226
+ color: var(--text-muted);
1227
+ }
1228
+
980
1229
  /* Activity Panel */
981
1230
  .activity-panel {
982
1231
  position: fixed;
@@ -1100,41 +1349,6 @@
1100
1349
  margin-top: 2px;
1101
1350
  }
1102
1351
 
1103
- /* View Toggle */
1104
- .view-toggle {
1105
- display: flex;
1106
- gap: 0;
1107
- background: var(--bg-primary);
1108
- border-radius: 6px;
1109
- padding: 2px;
1110
- border: 1px solid var(--border);
1111
- }
1112
-
1113
- .view-btn {
1114
- padding: 6px 12px;
1115
- border: none;
1116
- background: transparent;
1117
- color: var(--text-secondary);
1118
- cursor: pointer;
1119
- border-radius: 4px;
1120
- font-family: var(--font-mono);
1121
- font-size: 12px;
1122
- }
1123
-
1124
- .view-btn.active {
1125
- background: var(--accent);
1126
- color: var(--bg-primary);
1127
- }
1128
-
1129
- .view-btn:hover:not(.active):not(:disabled) {
1130
- color: var(--text-primary);
1131
- }
1132
-
1133
- .view-btn:disabled {
1134
- opacity: 0.5;
1135
- cursor: not-allowed;
1136
- }
1137
-
1138
1352
  /* Graph Container */
1139
1353
  .graph-container {
1140
1354
  flex: 1;
@@ -1175,13 +1389,66 @@
1175
1389
  @media (max-width: 768px) {
1176
1390
  .header {
1177
1391
  flex-wrap: wrap;
1392
+ row-gap: 10px;
1393
+ align-items: center;
1394
+ align-content: flex-start;
1395
+ }
1396
+
1397
+ .header-left {
1398
+ order: 1;
1399
+ flex: 0 0 auto;
1400
+ min-height: 42px;
1401
+ }
1402
+
1403
+ .header-right {
1404
+ order: 2;
1405
+ flex: 0 0 auto;
1406
+ margin-left: auto;
1407
+ min-height: 42px;
1178
1408
  gap: 8px;
1179
1409
  }
1180
1410
 
1181
1411
  .header-filters {
1182
1412
  order: 3;
1413
+ flex: 0 0 100%;
1183
1414
  width: 100%;
1184
- flex-wrap: wrap;
1415
+ max-width: 100%;
1416
+ min-width: 100%;
1417
+ display: none;
1418
+ flex-direction: column;
1419
+ align-items: stretch;
1420
+ gap: 8px;
1421
+ border-top: 1px solid var(--border);
1422
+ padding-top: 8px;
1423
+ }
1424
+
1425
+ .header-filters.open {
1426
+ display: flex;
1427
+ }
1428
+
1429
+ .filter-group {
1430
+ width: 100%;
1431
+ max-width: 100%;
1432
+ }
1433
+
1434
+ .task-search-group {
1435
+ width: 100%;
1436
+ }
1437
+
1438
+ .task-search-input {
1439
+ flex: 1;
1440
+ width: 100%;
1441
+ }
1442
+
1443
+ #dateFilter,
1444
+ #projectFilter,
1445
+ #assigneeFilter {
1446
+ min-width: 0;
1447
+ width: 100%;
1448
+ }
1449
+
1450
+ .task-search-meta {
1451
+ display: none;
1185
1452
  }
1186
1453
 
1187
1454
  .board {
@@ -1192,10 +1459,6 @@
1192
1459
  min-height: calc(100vh - 150px);
1193
1460
  }
1194
1461
 
1195
- .view-toggle {
1196
- margin-left: auto;
1197
- }
1198
-
1199
1462
  .mobile-tabs {
1200
1463
  display: flex;
1201
1464
  overflow-x: auto;
@@ -1428,12 +1691,7 @@
1428
1691
  <header class="header">
1429
1692
  <div class="header-left">
1430
1693
  <button class="hamburger" id="hamburgerBtn">&#9776;</button>
1431
- <span class="logo">hzl dashboard</span>
1432
- <div class="view-toggle" id="viewToggle">
1433
- <button class="view-btn active" data-view="kanban">Kanban</button>
1434
- <button class="view-btn" data-view="calendar">Calendar</button>
1435
- <button class="view-btn" data-view="graph">Graph</button>
1436
- </div>
1694
+ <span class="logo">HZL</span>
1437
1695
  </div>
1438
1696
  <div class="header-filters">
1439
1697
  <div class="filter-group">
@@ -1455,6 +1713,17 @@
1455
1713
  <option value="">Any Agent</option>
1456
1714
  </select>
1457
1715
  </div>
1716
+ <div class="filter-group task-search-group" id="taskSearchGroup">
1717
+ <input
1718
+ type="search"
1719
+ id="taskSearchInput"
1720
+ class="task-search-input"
1721
+ placeholder="Find task (/)"
1722
+ aria-label="Search tasks"
1723
+ >
1724
+ <button type="button" class="task-search-clear" id="taskSearchClear" hidden>&times;</button>
1725
+ <span class="task-search-meta" id="taskSearchMeta"></span>
1726
+ </div>
1458
1727
  <div class="filter-group settings-group">
1459
1728
  <button class="settings-toggle" id="settingsToggle" title="Settings">
1460
1729
  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
@@ -1463,6 +1732,14 @@
1463
1732
  </svg>
1464
1733
  </button>
1465
1734
  <div class="settings-dropdown" id="settingsDropdown">
1735
+ <div class="settings-section">
1736
+ <label class="settings-label" for="viewFilter">View</label>
1737
+ <select id="viewFilter" class="settings-view-select" aria-label="Select view">
1738
+ <option value="kanban">Kanban</option>
1739
+ <option value="calendar">Calendar</option>
1740
+ <option value="graph">Graph</option>
1741
+ </select>
1742
+ </div>
1466
1743
  <div class="settings-section">
1467
1744
  <label class="settings-label">Refresh</label>
1468
1745
  <select id="refreshFilter">
@@ -1498,6 +1775,25 @@
1498
1775
  <input type="checkbox" id="showSubtasks" checked> Show subtasks
1499
1776
  </label>
1500
1777
  </div>
1778
+ <div class="settings-section">
1779
+ <label class="settings-label">Parent View</label>
1780
+ <div class="collapse-parents-actions">
1781
+ <button type="button" class="collapse-parents-btn" id="collapseAllParentsBtn">Collapse all</button>
1782
+ <button type="button" class="collapse-parents-btn" id="expandAllParentsBtn">Expand all</button>
1783
+ </div>
1784
+ <div class="collapse-parents-meta" id="collapseParentsMeta"></div>
1785
+ </div>
1786
+ <div class="settings-section">
1787
+ <button
1788
+ type="button"
1789
+ class="settings-shortcuts-btn"
1790
+ id="shortcutsBtn"
1791
+ title="Keyboard shortcuts (?)"
1792
+ aria-label="Keyboard shortcuts"
1793
+ >
1794
+ Shortcuts (?)
1795
+ </button>
1796
+ </div>
1501
1797
  </div>
1502
1798
  </div>
1503
1799
  </div>
@@ -1613,6 +1909,24 @@
1613
1909
  </div>
1614
1910
  </div>
1615
1911
 
1912
+ <div class="shortcuts-modal-overlay" id="shortcutsModalOverlay">
1913
+ <div class="shortcuts-modal">
1914
+ <div class="shortcuts-header">
1915
+ <span class="shortcuts-title">Keyboard Shortcuts</span>
1916
+ <button type="button" class="shortcuts-close" id="shortcutsClose">&times;</button>
1917
+ </div>
1918
+ <div class="shortcuts-body">
1919
+ <div class="shortcuts-list">
1920
+ <span class="shortcut-key">/</span><span class="shortcut-desc">Focus task search</span>
1921
+ <span class="shortcut-key">a</span><span class="shortcut-desc">Toggle activity panel</span>
1922
+ <span class="shortcut-key">?</span><span class="shortcut-desc">Open this shortcuts dialog</span>
1923
+ <span class="shortcut-key">Esc</span><span class="shortcut-desc">Close open dialogs/panels</span>
1924
+ </div>
1925
+ <div class="shortcuts-note">Shortcuts are disabled while typing in inputs.</div>
1926
+ </div>
1927
+ </div>
1928
+ </div>
1929
+
1616
1930
  <script>
1617
1931
  // State
1618
1932
  let tasks = [];
@@ -1641,6 +1955,8 @@
1641
1955
  const TASK_ACTIVITY_DISPLAY_LIMIT = 20;
1642
1956
  let activeTab = 'ready';
1643
1957
  let activeView = 'kanban';
1958
+ let taskSearchQuery = '';
1959
+ let collapsedParents = new Set();
1644
1960
  let graphInstance = null;
1645
1961
  let graphInitialized = false;
1646
1962
  let nodeStatusMap = new Map();
@@ -1649,14 +1965,23 @@
1649
1965
  let copyFeedbackTimer = null;
1650
1966
  let calendarYear = new Date().getFullYear();
1651
1967
  let calendarMonth = new Date().getMonth(); // 0-indexed
1968
+ let initialTaskIdFromUrl = null;
1969
+ let initialActivityPanelOpen = false;
1652
1970
 
1653
1971
  // DOM Elements
1654
1972
  const dateFilter = document.getElementById('dateFilter');
1973
+ const taskSearchInput = document.getElementById('taskSearchInput');
1974
+ const taskSearchClear = document.getElementById('taskSearchClear');
1975
+ const taskSearchMeta = document.getElementById('taskSearchMeta');
1976
+ const taskSearchGroup = document.getElementById('taskSearchGroup');
1655
1977
  const projectFilter = document.getElementById('projectFilter');
1656
1978
  const assigneeFilter = document.getElementById('assigneeFilter');
1657
1979
  const refreshFilter = document.getElementById('refreshFilter');
1658
1980
  const connectionDot = document.getElementById('connectionDot');
1659
1981
  const connectionText = document.getElementById('connectionText');
1982
+ const shortcutsBtn = document.getElementById('shortcutsBtn');
1983
+ const shortcutsModalOverlay = document.getElementById('shortcutsModalOverlay');
1984
+ const shortcutsClose = document.getElementById('shortcutsClose');
1660
1985
  const activityBtn = document.getElementById('activityBtn');
1661
1986
  const activityPanel = document.getElementById('activityPanel');
1662
1987
  const activityClose = document.getElementById('activityClose');
@@ -1673,12 +1998,16 @@
1673
1998
  const mobileTabs = document.getElementById('mobileTabs');
1674
1999
  const settingsToggle = document.getElementById('settingsToggle');
1675
2000
  const settingsDropdown = document.getElementById('settingsDropdown');
2001
+ const viewFilter = document.getElementById('viewFilter');
1676
2002
  const showSubtasksCheckbox = document.getElementById('showSubtasks');
1677
- const viewToggle = document.getElementById('viewToggle');
2003
+ const collapseAllParentsBtn = document.getElementById('collapseAllParentsBtn');
2004
+ const expandAllParentsBtn = document.getElementById('expandAllParentsBtn');
2005
+ const collapseParentsMeta = document.getElementById('collapseParentsMeta');
1678
2006
  const board = document.getElementById('board');
1679
2007
  const calendarContainer = document.getElementById('calendarContainer');
1680
2008
  const graphContainer = document.getElementById('graphContainer');
1681
2009
  const graphLoading = document.getElementById('graphLoading');
2010
+ let pendingProjectPreference = null;
1682
2011
  let pendingAssigneePreference = null;
1683
2012
  let pendingActivityAssigneePreference = null;
1684
2013
  const columnScrollTimers = new WeakMap();
@@ -1768,17 +2097,266 @@
1768
2097
  });
1769
2098
  }
1770
2099
 
2100
+ function normalizeTaskSearchQuery(value) {
2101
+ if (typeof value !== 'string') return '';
2102
+ return value.trim().replace(/\s+/g, ' ').slice(0, 120);
2103
+ }
2104
+
2105
+ function getTaskSearchQuery() {
2106
+ return taskSearchQuery.toLowerCase();
2107
+ }
2108
+
2109
+ function taskMatchesSearch(task, query) {
2110
+ if (!query) return true;
2111
+ const terms = query.split(' ');
2112
+ const haystack = [
2113
+ task.task_id,
2114
+ task.title,
2115
+ task.project,
2116
+ getAssigneeValue(task.assignee),
2117
+ task.description,
2118
+ Array.isArray(task.tags) ? task.tags.join(' ') : '',
2119
+ Array.isArray(task.blocked_by) ? task.blocked_by.join(' ') : '',
2120
+ ]
2121
+ .filter(Boolean)
2122
+ .join(' ')
2123
+ .toLowerCase();
2124
+ return terms.every((term) => haystack.includes(term));
2125
+ }
2126
+
2127
+ function getParentTaskIds(taskList = tasks) {
2128
+ return taskList
2129
+ .filter((task) => (task.subtask_total ?? 0) > 0)
2130
+ .map((task) => task.task_id);
2131
+ }
2132
+
2133
+ function pruneCollapsedParents(taskList = tasks) {
2134
+ const validParents = new Set(getParentTaskIds(taskList));
2135
+ let changed = false;
2136
+ for (const parentId of Array.from(collapsedParents)) {
2137
+ if (!validParents.has(parentId)) {
2138
+ collapsedParents.delete(parentId);
2139
+ changed = true;
2140
+ }
2141
+ }
2142
+ return changed;
2143
+ }
2144
+
2145
+ function updateCollapseControls() {
2146
+ const parentIds = getParentTaskIds(tasks);
2147
+ const collapsedCount = parentIds.filter((parentId) => collapsedParents.has(parentId)).length;
2148
+ const showSubtasks = showSubtasksCheckbox.checked;
2149
+
2150
+ collapseAllParentsBtn.disabled = !showSubtasks || parentIds.length === 0 || collapsedCount === parentIds.length;
2151
+ expandAllParentsBtn.disabled = !showSubtasks || collapsedCount === 0;
2152
+
2153
+ if (parentIds.length === 0) {
2154
+ collapseParentsMeta.textContent = 'No parent tasks';
2155
+ return;
2156
+ }
2157
+
2158
+ if (!showSubtasks) {
2159
+ collapseParentsMeta.textContent = 'Enable "Show subtasks" to expand by parent';
2160
+ return;
2161
+ }
2162
+
2163
+ collapseParentsMeta.textContent = `${collapsedCount}/${parentIds.length} collapsed`;
2164
+ }
2165
+
2166
+ function toggleParentCollapsed(parentId) {
2167
+ if (!parentId) return;
2168
+ if (collapsedParents.has(parentId)) {
2169
+ collapsedParents.delete(parentId);
2170
+ } else {
2171
+ collapsedParents.add(parentId);
2172
+ }
2173
+ savePreferences();
2174
+ renderBoard();
2175
+ renderActivity();
2176
+ }
2177
+
2178
+ function collapseAllParents() {
2179
+ if (!showSubtasksCheckbox.checked) return;
2180
+ for (const parentId of getParentTaskIds(tasks)) {
2181
+ collapsedParents.add(parentId);
2182
+ }
2183
+ savePreferences();
2184
+ renderBoard();
2185
+ renderActivity();
2186
+ }
2187
+
2188
+ function expandAllParents() {
2189
+ for (const parentId of getParentTaskIds(tasks)) {
2190
+ collapsedParents.delete(parentId);
2191
+ }
2192
+ savePreferences();
2193
+ renderBoard();
2194
+ renderActivity();
2195
+ }
2196
+
2197
+ function parseYearMonth(value) {
2198
+ const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value);
2199
+ if (!match) return null;
2200
+ return {
2201
+ year: Number(match[1]),
2202
+ month: Number(match[2]) - 1,
2203
+ };
2204
+ }
2205
+
2206
+ function getActiveTaskId() {
2207
+ const taskId = selectedTask?.task?.task_id;
2208
+ return typeof taskId === 'string' && taskId ? taskId : null;
2209
+ }
2210
+
2211
+ function setShortcutsModalOpen(open) {
2212
+ shortcutsModalOverlay.classList.toggle('open', open);
2213
+ }
2214
+
2215
+ function setActivityPanelOpen(open, options = {}) {
2216
+ const { persist = true } = options;
2217
+ activityPanel.classList.toggle('open', open);
2218
+ if (persist) {
2219
+ syncUrlState();
2220
+ }
2221
+ }
2222
+
2223
+ function setActiveTab(status, options = {}) {
2224
+ const { persist = true } = options;
2225
+ if (!COLUMNS.includes(status)) return;
2226
+ activeTab = status;
2227
+ document.querySelectorAll('.mobile-tab').forEach((tab) => {
2228
+ tab.classList.toggle('active', tab.dataset.status === activeTab);
2229
+ });
2230
+ document.querySelectorAll('.mobile-cards').forEach((cards) => {
2231
+ cards.classList.toggle('active', cards.dataset.status === activeTab);
2232
+ });
2233
+ if (persist) {
2234
+ savePreferences();
2235
+ }
2236
+ }
2237
+
2238
+ function buildUrlStateParams() {
2239
+ const params = new URLSearchParams();
2240
+
2241
+ if (activeView !== 'kanban') {
2242
+ params.set('view', activeView);
2243
+ }
2244
+
2245
+ if (activeView === 'calendar') {
2246
+ const month = String(calendarMonth + 1).padStart(2, '0');
2247
+ params.set('month', `${calendarYear}-${month}`);
2248
+ } else if (dateFilter.value !== '3d') {
2249
+ params.set('since', dateFilter.value);
2250
+ }
2251
+
2252
+ if (projectFilter.value) params.set('project', projectFilter.value);
2253
+ if (assigneeFilter.value) params.set('assignee', assigneeFilter.value);
2254
+ if (taskSearchQuery) params.set('q', taskSearchQuery);
2255
+ if (!showSubtasksCheckbox.checked) params.set('subtasks', '0');
2256
+ if (activeTab !== 'ready') params.set('tab', activeTab);
2257
+ if (activityAssigneeFilter.value) params.set('activity_assignee', activityAssigneeFilter.value);
2258
+ if (activityKeywordFilter.value.trim()) params.set('activity_q', activityKeywordFilter.value.trim());
2259
+ if (activityPanel.classList.contains('open')) params.set('activity', '1');
2260
+
2261
+ const taskId = getActiveTaskId();
2262
+ if (taskId) params.set('task', taskId);
2263
+
2264
+ return params;
2265
+ }
2266
+
2267
+ function syncUrlState() {
2268
+ const params = buildUrlStateParams();
2269
+ const query = params.toString();
2270
+ const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash || ''}`;
2271
+ history.replaceState(null, '', nextUrl);
2272
+ }
2273
+
2274
+ function applyUrlStateOverrides() {
2275
+ const params = new URLSearchParams(window.location.search);
2276
+ let preferredView = null;
2277
+
2278
+ const since = params.get('since');
2279
+ const validDateValues = new Set(Array.from(dateFilter.options).map((option) => option.value));
2280
+ if (since && validDateValues.has(since)) {
2281
+ dateFilter.value = since;
2282
+ }
2283
+
2284
+ const view = params.get('view');
2285
+ if (view && (view === 'kanban' || view === 'calendar' || view === 'graph')) {
2286
+ preferredView = view;
2287
+ }
2288
+
2289
+ const monthParam = params.get('month');
2290
+ if (monthParam) {
2291
+ const parsedMonth = parseYearMonth(monthParam);
2292
+ if (parsedMonth) {
2293
+ calendarYear = parsedMonth.year;
2294
+ calendarMonth = parsedMonth.month;
2295
+ if (!preferredView) preferredView = 'calendar';
2296
+ }
2297
+ }
2298
+
2299
+ const project = params.get('project');
2300
+ if (project !== null) {
2301
+ pendingProjectPreference = project;
2302
+ }
2303
+
2304
+ const assignee = params.get('assignee');
2305
+ if (assignee !== null) {
2306
+ pendingAssigneePreference = assignee;
2307
+ }
2308
+
2309
+ const activityAssignee = params.get('activity_assignee');
2310
+ if (activityAssignee !== null) {
2311
+ pendingActivityAssigneePreference = activityAssignee;
2312
+ }
2313
+
2314
+ const activityKeyword = params.get('activity_q');
2315
+ if (activityKeyword !== null) {
2316
+ activityKeywordFilter.value = activityKeyword;
2317
+ }
2318
+
2319
+ const searchQuery = params.get('q');
2320
+ if (searchQuery !== null) {
2321
+ taskSearchQuery = normalizeTaskSearchQuery(searchQuery);
2322
+ taskSearchInput.value = taskSearchQuery;
2323
+ }
2324
+
2325
+ const subtasks = params.get('subtasks');
2326
+ if (subtasks === '0') showSubtasksCheckbox.checked = false;
2327
+ if (subtasks === '1') showSubtasksCheckbox.checked = true;
2328
+
2329
+ const tab = params.get('tab');
2330
+ if (tab && COLUMNS.includes(tab)) {
2331
+ activeTab = tab;
2332
+ }
2333
+
2334
+ const taskId = params.get('task');
2335
+ if (taskId && taskId.trim()) {
2336
+ initialTaskIdFromUrl = taskId.trim();
2337
+ }
2338
+
2339
+ initialActivityPanelOpen = params.get('activity') === '1';
2340
+
2341
+ return { preferredView };
2342
+ }
2343
+
1771
2344
  // Load saved preferences
1772
2345
  function loadPreferences() {
2346
+ let preferredView = null;
1773
2347
  const saved = localStorage.getItem('hzl-dashboard-prefs');
1774
2348
  if (saved) {
1775
2349
  try {
1776
2350
  const prefs = JSON.parse(saved);
1777
2351
  if (prefs.dateFilter) dateFilter.value = prefs.dateFilter;
1778
- if (prefs.projectFilter) projectFilter.value = prefs.projectFilter;
2352
+ if (typeof prefs.projectFilter === 'string') pendingProjectPreference = prefs.projectFilter;
1779
2353
  if (typeof prefs.assigneeFilter === 'string') pendingAssigneePreference = prefs.assigneeFilter;
1780
2354
  if (typeof prefs.activityAssigneeFilter === 'string') pendingActivityAssigneePreference = prefs.activityAssigneeFilter;
1781
2355
  if (typeof prefs.activityKeywordFilter === 'string') activityKeywordFilter.value = prefs.activityKeywordFilter;
2356
+ if (typeof prefs.taskSearch === 'string') {
2357
+ taskSearchQuery = normalizeTaskSearchQuery(prefs.taskSearch);
2358
+ taskSearchInput.value = taskSearchQuery;
2359
+ }
1782
2360
  if (prefs.refreshFilter) refreshFilter.value = prefs.refreshFilter;
1783
2361
  if (Array.isArray(prefs.columnVisibility)) {
1784
2362
  settingsDropdown.querySelectorAll('.column-checkboxes input[type="checkbox"]').forEach(cb => {
@@ -1789,11 +2367,36 @@
1789
2367
  if (prefs.showSubtasks !== undefined) {
1790
2368
  showSubtasksCheckbox.checked = prefs.showSubtasks;
1791
2369
  }
2370
+ if (Array.isArray(prefs.collapsedParents)) {
2371
+ collapsedParents = new Set(
2372
+ prefs.collapsedParents.filter((value) => typeof value === 'string' && value.length > 0)
2373
+ );
2374
+ }
2375
+ if (typeof prefs.activeTab === 'string' && COLUMNS.includes(prefs.activeTab)) {
2376
+ activeTab = prefs.activeTab;
2377
+ }
1792
2378
  if (prefs.activeView && prefs.activeView !== 'kanban') {
1793
- // Defer view switch until after ForceGraph may have loaded
1794
- setTimeout(() => setActiveView(prefs.activeView), 100);
2379
+ preferredView = prefs.activeView;
1795
2380
  }
1796
- } catch (e) {}
2381
+ } catch {}
2382
+ }
2383
+
2384
+ const urlOverrides = applyUrlStateOverrides();
2385
+ if (urlOverrides.preferredView) {
2386
+ preferredView = urlOverrides.preferredView;
2387
+ }
2388
+
2389
+ if (initialActivityPanelOpen) {
2390
+ setActivityPanelOpen(true, { persist: false });
2391
+ }
2392
+
2393
+ setActiveTab(activeTab, { persist: false });
2394
+ updateTaskSearchUi();
2395
+ updateCollapseControls();
2396
+
2397
+ if (preferredView && preferredView !== 'kanban') {
2398
+ // Defer view switch until after ForceGraph may have loaded
2399
+ setTimeout(() => setActiveView(preferredView), 100);
1797
2400
  }
1798
2401
  }
1799
2402
 
@@ -1804,23 +2407,30 @@
1804
2407
  assigneeFilter: assigneeFilter.value,
1805
2408
  activityAssigneeFilter: activityAssigneeFilter.value,
1806
2409
  activityKeywordFilter: activityKeywordFilter.value,
2410
+ taskSearch: taskSearchQuery,
1807
2411
  refreshFilter: refreshFilter.value,
1808
2412
  columnVisibility: Array.from(
1809
2413
  settingsDropdown.querySelectorAll('.column-checkboxes input[type="checkbox"]:checked')
1810
2414
  ).map(cb => cb.value),
1811
2415
  showSubtasks: showSubtasksCheckbox.checked,
2416
+ collapsedParents: Array.from(collapsedParents),
1812
2417
  activeView: activeView,
2418
+ activeTab: activeTab,
1813
2419
  };
1814
2420
  localStorage.setItem('hzl-dashboard-prefs', JSON.stringify(prefs));
2421
+ syncUrlState();
1815
2422
  }
1816
2423
 
1817
2424
  // Graph View Functions
1818
2425
  function handleGraphLibError() {
1819
2426
  console.warn('[hzl] force-graph CDN failed to load');
1820
- const graphBtn = viewToggle.querySelector('[data-view="graph"]');
1821
- if (graphBtn) {
1822
- graphBtn.disabled = true;
1823
- graphBtn.title = 'Graph unavailable - CDN failed to load';
2427
+ const graphOption = viewFilter.querySelector('option[value="graph"]');
2428
+ if (graphOption) {
2429
+ graphOption.disabled = true;
2430
+ graphOption.textContent = 'Graph (unavailable)';
2431
+ }
2432
+ if (activeView === 'graph') {
2433
+ setActiveView('kanban');
1824
2434
  }
1825
2435
  }
1826
2436
 
@@ -2161,7 +2771,22 @@
2161
2771
  const countLabel = visibleCount === totalCount
2162
2772
  ? `${visibleCount} ${label}`
2163
2773
  : `${visibleCount}/${totalCount} ${label}`;
2164
- subtaskHtml = `<div class="card-subtask-count">[${countLabel}]</div>`;
2774
+ if (showSubtasks) {
2775
+ const isCollapsed = collapsedParents.has(task.task_id);
2776
+ const symbol = isCollapsed ? '&#9654;' : '&#9660;';
2777
+ subtaskHtml = `
2778
+ <button
2779
+ type="button"
2780
+ class="card-subtask-toggle"
2781
+ data-action="toggle-subtasks"
2782
+ data-parent-id="${escapeHtml(task.task_id)}"
2783
+ aria-expanded="${isCollapsed ? 'false' : 'true'}"
2784
+ title="${isCollapsed ? 'Expand subtasks' : 'Collapse subtasks'}"
2785
+ >${symbol} [${countLabel}]</button>
2786
+ `;
2787
+ } else {
2788
+ subtaskHtml = `<div class="card-subtask-count">[${countLabel}]</div>`;
2789
+ }
2165
2790
  }
2166
2791
 
2167
2792
  // Build progress badge
@@ -2224,8 +2849,47 @@
2224
2849
  );
2225
2850
  }
2226
2851
 
2852
+ function updateTaskSearchUi() {
2853
+ const hasQuery = taskSearchQuery.length > 0;
2854
+ taskSearchGroup.classList.toggle('active', hasQuery);
2855
+ taskSearchClear.hidden = !hasQuery;
2856
+
2857
+ if (!hasQuery) {
2858
+ taskSearchMeta.textContent = '';
2859
+ return;
2860
+ }
2861
+
2862
+ const totalCandidates = getFilteredBoardTasks(tasks, { applySearchFilter: false }).length;
2863
+ const matchedCount = getFilteredBoardTasks(tasks).length;
2864
+ const label = totalCandidates === 1 ? 'task' : 'tasks';
2865
+ taskSearchMeta.textContent = `${matchedCount}/${totalCandidates} ${label}`;
2866
+ }
2867
+
2868
+ function applyTaskSearch(value, options = {}) {
2869
+ const { persist = true } = options;
2870
+ const normalized = normalizeTaskSearchQuery(value);
2871
+ taskSearchInput.value = normalized;
2872
+ if (normalized === taskSearchQuery) return;
2873
+
2874
+ taskSearchQuery = normalized;
2875
+ updateTaskSearchUi();
2876
+ updateAssigneeOptions();
2877
+ updateActivityAssigneeOptions();
2878
+ renderBoard();
2879
+ renderActivity();
2880
+
2881
+ if (persist) {
2882
+ savePreferences();
2883
+ }
2884
+ }
2885
+
2227
2886
  function getFilteredBoardTasks(taskList = tasks, options = {}) {
2228
- const { onlyVisibleColumns = false, applyAssigneeFilter = true } = options;
2887
+ const {
2888
+ onlyVisibleColumns = false,
2889
+ applyAssigneeFilter = true,
2890
+ applySearchFilter = true,
2891
+ applyCollapsedParents = true,
2892
+ } = options;
2229
2893
  const showSubtasks = showSubtasksCheckbox.checked;
2230
2894
 
2231
2895
  let filtered = showSubtasks ? taskList : taskList.filter(task => !task.parent_id);
@@ -2239,6 +2903,26 @@
2239
2903
  filtered = filtered.filter(task => getAssigneeValue(task.assignee) === assigneeFilter.value);
2240
2904
  }
2241
2905
 
2906
+ if (applySearchFilter) {
2907
+ const query = getTaskSearchQuery();
2908
+ if (query) {
2909
+ filtered = filtered.filter((task) => taskMatchesSearch(task, query));
2910
+ }
2911
+ }
2912
+
2913
+ if (showSubtasks && applyCollapsedParents) {
2914
+ // Search mode should always show matching subtasks, regardless of collapsed parents.
2915
+ const query = getTaskSearchQuery();
2916
+ if (!query) {
2917
+ const visibleTaskIds = new Set(filtered.map((task) => task.task_id));
2918
+ filtered = filtered.filter((task) => {
2919
+ if (!task.parent_id) return true;
2920
+ if (!visibleTaskIds.has(task.parent_id)) return true;
2921
+ return !collapsedParents.has(task.parent_id);
2922
+ });
2923
+ }
2924
+ }
2925
+
2242
2926
  return filtered;
2243
2927
  }
2244
2928
 
@@ -2483,17 +3167,20 @@
2483
3167
  function navPrev() {
2484
3168
  calendarMonth--;
2485
3169
  if (calendarMonth < 0) { calendarMonth = 11; calendarYear--; }
3170
+ syncUrlState();
2486
3171
  requestPoll();
2487
3172
  }
2488
3173
  function navNext() {
2489
3174
  calendarMonth++;
2490
3175
  if (calendarMonth > 11) { calendarMonth = 0; calendarYear++; }
3176
+ syncUrlState();
2491
3177
  requestPoll();
2492
3178
  }
2493
3179
  function navToday() {
2494
3180
  const today = new Date();
2495
3181
  calendarYear = today.getFullYear();
2496
3182
  calendarMonth = today.getMonth();
3183
+ syncUrlState();
2497
3184
  requestPoll();
2498
3185
  }
2499
3186
 
@@ -2591,6 +3278,7 @@
2591
3278
  const showSubtasks = showSubtasksCheckbox.checked;
2592
3279
  const emojiMap = buildEmojiMap(tasks);
2593
3280
  const visibleTasks = getFilteredBoardTasks(tasks);
3281
+ const emptyMessage = taskSearchQuery ? 'No matching tasks' : 'No tasks';
2594
3282
 
2595
3283
  const columns = groupTasksByStatus(visibleTasks);
2596
3284
 
@@ -2601,7 +3289,7 @@
2601
3289
 
2602
3290
  if (container) {
2603
3291
  if (statusTasks.length === 0) {
2604
- container.innerHTML = '<div class="empty-column">No tasks</div>';
3292
+ container.innerHTML = `<div class="empty-column">${emptyMessage}</div>`;
2605
3293
  } else {
2606
3294
  container.innerHTML = statusTasks.map(task => {
2607
3295
  const emojiInfo = emojiMap.get(task.task_id);
@@ -2615,6 +3303,8 @@
2615
3303
 
2616
3304
  // Render mobile cards for active tab
2617
3305
  renderMobileCards(showSubtasks, emojiMap);
3306
+ updateTaskSearchUi();
3307
+ updateCollapseControls();
2618
3308
 
2619
3309
  // Card click handlers are set up via event delegation in init
2620
3310
  }
@@ -2641,13 +3331,14 @@
2641
3331
  if (!container) return;
2642
3332
 
2643
3333
  const visibleTasks = getFilteredBoardTasks(tasks);
3334
+ const emptyMessage = taskSearchQuery ? 'No matching tasks' : 'No tasks';
2644
3335
 
2645
3336
  const columns = groupTasksByStatus(visibleTasks);
2646
3337
 
2647
3338
  container.innerHTML = Object.entries(columns).map(([status, statusTasks]) => `
2648
3339
  <div class="mobile-cards ${status === activeTab ? 'active' : ''}" data-status="${status}">
2649
3340
  ${statusTasks.length === 0
2650
- ? '<div class="empty-column">No tasks</div>'
3341
+ ? `<div class="empty-column">${emptyMessage}</div>`
2651
3342
  : statusTasks.map(task => {
2652
3343
  const emojiInfo = emojiMap.get(task.task_id);
2653
3344
  return renderCard(task, emojiInfo, showSubtasks);
@@ -2901,6 +3592,7 @@
2901
3592
 
2902
3593
  modalBody.innerHTML = html;
2903
3594
  modalOverlay.classList.add('open');
3595
+ syncUrlState();
2904
3596
 
2905
3597
  // Attach tab switching handlers (DOM-only, no re-fetch)
2906
3598
  modalBody.querySelectorAll('.modal-tab').forEach(tab => {
@@ -2953,12 +3645,16 @@
2953
3645
  }
2954
3646
 
2955
3647
  function closeModal() {
3648
+ const wasOpen = modalOverlay.classList.contains('open');
2956
3649
  modalOverlay.classList.remove('open');
2957
3650
  selectedTask = null;
2958
3651
  modalTaskIdValue.textContent = '-';
2959
3652
  modalTaskIdCopy.dataset.taskId = '';
2960
3653
  modalTaskIdCopy.disabled = true;
2961
3654
  setTaskIdCopyFeedback('idle');
3655
+ if (wasOpen) {
3656
+ syncUrlState();
3657
+ }
2962
3658
  }
2963
3659
 
2964
3660
  // Live updates + refresh
@@ -2996,11 +3692,12 @@
2996
3692
  ]);
2997
3693
 
2998
3694
  tasks = newTasks;
3695
+ const collapsedParentsPruned = pruneCollapsedParents(tasks);
2999
3696
  const selectionUpdate = updateAssigneeOptions(pendingAssigneePreference);
3000
3697
  pendingAssigneePreference = null;
3001
3698
  const activitySelectionUpdate = updateActivityAssigneeOptions(pendingActivityAssigneePreference);
3002
3699
  pendingActivityAssigneePreference = null;
3003
- if (selectionUpdate.reset || activitySelectionUpdate.reset) {
3700
+ if (selectionUpdate.reset || activitySelectionUpdate.reset || collapsedParentsPruned) {
3004
3701
  savePreferences();
3005
3702
  }
3006
3703
 
@@ -3010,9 +3707,12 @@
3010
3707
  }
3011
3708
 
3012
3709
  // Update project filter
3013
- const currentProject = projectFilter.value;
3710
+ const currentProject = pendingProjectPreference ?? projectFilter.value;
3014
3711
  projectFilter.innerHTML = '<option value="">All projects</option>' +
3015
- stats.projects.map(p => `<option value="${escapeHtml(p)}" ${p === currentProject ? 'selected' : ''}>${escapeHtml(p)}</option>`).join('');
3712
+ stats.projects.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
3713
+ const hasProject = currentProject && stats.projects.includes(currentProject);
3714
+ projectFilter.value = hasProject ? currentProject : '';
3715
+ pendingProjectPreference = null;
3016
3716
 
3017
3717
  if (activeView === 'calendar') {
3018
3718
  renderCalendar();
@@ -3021,6 +3721,14 @@
3021
3721
  updateGraphData();
3022
3722
  }
3023
3723
  renderActivity();
3724
+ updateTaskSearchUi();
3725
+ syncUrlState();
3726
+
3727
+ if (initialTaskIdFromUrl) {
3728
+ const taskToOpen = initialTaskIdFromUrl;
3729
+ initialTaskIdFromUrl = null;
3730
+ void openTaskModal(taskToOpen);
3731
+ }
3024
3732
 
3025
3733
  lastPollTime = Date.now();
3026
3734
  lastPollError = false;
@@ -3457,12 +4165,27 @@
3457
4165
  return '';
3458
4166
  }
3459
4167
 
4168
+ function isTypingTarget(target) {
4169
+ if (!target || !(target instanceof Element)) return false;
4170
+ if (target.closest('input, textarea, select')) return true;
4171
+ return target.isContentEditable;
4172
+ }
4173
+
3460
4174
  // Event listeners
3461
4175
  dateFilter.addEventListener('change', () => {
3462
4176
  savePreferences();
3463
4177
  requestPoll();
3464
4178
  });
3465
4179
 
4180
+ taskSearchInput.addEventListener('input', () => {
4181
+ applyTaskSearch(taskSearchInput.value);
4182
+ });
4183
+
4184
+ taskSearchClear.addEventListener('click', () => {
4185
+ applyTaskSearch('');
4186
+ taskSearchInput.focus();
4187
+ });
4188
+
3466
4189
  projectFilter.addEventListener('change', () => {
3467
4190
  savePreferences();
3468
4191
  requestPoll();
@@ -3493,12 +4216,26 @@
3493
4216
  requestPoll();
3494
4217
  });
3495
4218
 
4219
+ shortcutsBtn.addEventListener('click', () => {
4220
+ setShortcutsModalOpen(true);
4221
+ });
4222
+
4223
+ shortcutsClose.addEventListener('click', () => {
4224
+ setShortcutsModalOpen(false);
4225
+ });
4226
+
4227
+ shortcutsModalOverlay.addEventListener('click', (e) => {
4228
+ if (e.target === shortcutsModalOverlay) {
4229
+ setShortcutsModalOpen(false);
4230
+ }
4231
+ });
4232
+
3496
4233
  activityBtn.addEventListener('click', () => {
3497
- activityPanel.classList.add('open');
4234
+ setActivityPanelOpen(true);
3498
4235
  });
3499
4236
 
3500
4237
  activityClose.addEventListener('click', () => {
3501
- activityPanel.classList.remove('open');
4238
+ setActivityPanelOpen(false);
3502
4239
  });
3503
4240
 
3504
4241
  activityList.addEventListener('click', (e) => {
@@ -3525,8 +4262,32 @@
3525
4262
  document.addEventListener('keydown', (e) => {
3526
4263
  if (e.key === 'Escape') {
3527
4264
  closeModal();
3528
- activityPanel.classList.remove('open');
4265
+ setActivityPanelOpen(false);
4266
+ setShortcutsModalOpen(false);
3529
4267
  settingsDropdown.classList.remove('open');
4268
+ return;
4269
+ }
4270
+
4271
+ if (isTypingTarget(e.target)) {
4272
+ return;
4273
+ }
4274
+
4275
+ if (e.key === '/') {
4276
+ e.preventDefault();
4277
+ taskSearchInput.focus();
4278
+ taskSearchInput.select();
4279
+ return;
4280
+ }
4281
+
4282
+ if (e.key === '?') {
4283
+ e.preventDefault();
4284
+ setShortcutsModalOpen(true);
4285
+ return;
4286
+ }
4287
+
4288
+ if (e.key.toLowerCase() === 'a') {
4289
+ e.preventDefault();
4290
+ setActivityPanelOpen(!activityPanel.classList.contains('open'));
3530
4291
  }
3531
4292
  });
3532
4293
 
@@ -3554,20 +4315,13 @@
3554
4315
  mobileTabs.addEventListener('click', (e) => {
3555
4316
  const tab = e.target.closest('.mobile-tab');
3556
4317
  if (!tab) return;
3557
-
3558
- activeTab = tab.dataset.status;
3559
-
3560
- document.querySelectorAll('.mobile-tab').forEach(t => t.classList.remove('active'));
3561
- tab.classList.add('active');
3562
-
3563
- document.querySelectorAll('.mobile-cards').forEach(c => c.classList.remove('active'));
3564
- document.querySelector(`.mobile-cards[data-status="${activeTab}"]`)?.classList.add('active');
4318
+ setActiveTab(tab.dataset.status);
3565
4319
  });
3566
4320
 
3567
4321
  // Hamburger menu (toggle filters on mobile)
3568
4322
  hamburgerBtn.addEventListener('click', () => {
3569
4323
  const filters = document.querySelector('.header-filters');
3570
- filters.style.display = filters.style.display === 'none' ? 'flex' : 'none';
4324
+ filters.classList.toggle('open');
3571
4325
  });
3572
4326
 
3573
4327
  // Settings dropdown
@@ -3603,19 +4357,33 @@
3603
4357
  renderActivity();
3604
4358
  });
3605
4359
 
3606
- // View toggle (Kanban/Graph)
4360
+ collapseAllParentsBtn.addEventListener('click', () => {
4361
+ collapseAllParents();
4362
+ });
4363
+
4364
+ expandAllParentsBtn.addEventListener('click', () => {
4365
+ expandAllParents();
4366
+ });
4367
+
4368
+ // View selection
3607
4369
  function setActiveView(view) {
4370
+ const allowedViews = new Set(['kanban', 'calendar', 'graph']);
4371
+ if (!allowedViews.has(view)) {
4372
+ view = 'kanban';
4373
+ }
4374
+
4375
+ const graphOption = viewFilter.querySelector('option[value="graph"]');
4376
+ if (view === 'graph' && graphOption && graphOption.disabled) {
4377
+ view = 'kanban';
4378
+ }
4379
+
3608
4380
  // Dismiss calendar popover when leaving calendar view
3609
4381
  if (activeView === 'calendar' && view !== 'calendar') {
3610
4382
  dismissPopover();
3611
4383
  }
3612
4384
 
3613
4385
  activeView = view;
3614
-
3615
- // Update toggle buttons
3616
- viewToggle.querySelectorAll('.view-btn').forEach(btn => {
3617
- btn.classList.toggle('active', btn.dataset.view === view);
3618
- });
4386
+ viewFilter.value = view;
3619
4387
 
3620
4388
  // Show/hide containers
3621
4389
  board.style.display = view === 'kanban' ? '' : 'none';
@@ -3642,27 +4410,46 @@
3642
4410
  requestPoll();
3643
4411
  }
3644
4412
 
3645
- viewToggle.addEventListener('click', (e) => {
3646
- const btn = e.target.closest('.view-btn');
3647
- if (btn && !btn.disabled && btn.dataset.view !== activeView) {
3648
- setActiveView(btn.dataset.view);
4413
+ viewFilter.addEventListener('change', () => {
4414
+ if (viewFilter.value !== activeView) {
4415
+ setActiveView(viewFilter.value);
3649
4416
  }
3650
4417
  });
3651
4418
 
3652
4419
  // Event delegation for card clicks (avoids re-adding handlers on every render)
3653
- board.addEventListener('click', (e) => {
3654
- const card = e.target.closest('.card');
3655
- if (card) openTaskModal(card.dataset.taskId);
3656
- });
4420
+ function handleCardContainerClick(e) {
4421
+ const toggle = e.target.closest('[data-action="toggle-subtasks"]');
4422
+ if (toggle) {
4423
+ e.preventDefault();
4424
+ e.stopPropagation();
4425
+ const parentId = toggle.dataset.parentId;
4426
+ toggleParentCollapsed(parentId);
4427
+ return;
4428
+ }
3657
4429
 
3658
- document.getElementById('mobileCardsContainer').addEventListener('click', (e) => {
3659
4430
  const card = e.target.closest('.card');
3660
4431
  if (card) openTaskModal(card.dataset.taskId);
3661
- });
4432
+ }
4433
+
4434
+ board.addEventListener('click', handleCardContainerClick);
4435
+ document.getElementById('mobileCardsContainer').addEventListener('click', handleCardContainerClick);
4436
+
4437
+ function registerServiceWorker() {
4438
+ if (!('serviceWorker' in navigator)) {
4439
+ return;
4440
+ }
4441
+
4442
+ window.addEventListener('load', () => {
4443
+ navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
4444
+ // PWA support is optional; ignore registration failures.
4445
+ });
4446
+ });
4447
+ }
3662
4448
 
3663
4449
  // Initialize
3664
4450
  loadPreferences();
3665
4451
  bindColumnScrollIndicators();
4452
+ registerServiceWorker();
3666
4453
  connectEventStream();
3667
4454
  requestPoll();
3668
4455