@x12i/static-memorix 2.0.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.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +278 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +109 -0
- package/dist/engine/aliases.d.ts +32 -0
- package/dist/engine/aliases.js +74 -0
- package/dist/engine/associations.d.ts +26 -0
- package/dist/engine/associations.js +60 -0
- package/dist/engine/identity.d.ts +22 -0
- package/dist/engine/identity.js +50 -0
- package/dist/engine/inventory.d.ts +29 -0
- package/dist/engine/inventory.js +133 -0
- package/dist/engine/lists.d.ts +18 -0
- package/dist/engine/lists.js +62 -0
- package/dist/engine/narratives.d.ts +16 -0
- package/dist/engine/narratives.js +67 -0
- package/dist/engine/objectTypes.d.ts +11 -0
- package/dist/engine/objectTypes.js +71 -0
- package/dist/engine/query.d.ts +38 -0
- package/dist/engine/query.js +191 -0
- package/dist/engine/records.d.ts +23 -0
- package/dist/engine/records.js +152 -0
- package/dist/engine/snapshots.d.ts +17 -0
- package/dist/engine/snapshots.js +110 -0
- package/dist/engine/write.d.ts +30 -0
- package/dist/engine/write.js +163 -0
- package/dist/routes/helpers.d.ts +4 -0
- package/dist/routes/helpers.js +29 -0
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +402 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +130 -0
- package/dist/storage/InMemoryStore.d.ts +31 -0
- package/dist/storage/InMemoryStore.js +242 -0
- package/dist/storage/fs.d.ts +6 -0
- package/dist/storage/fs.js +54 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +37 -0
- package/guides/demo-app.md +527 -0
- package/guides/managing-json-files.md +384 -0
- package/guides/running-the-service.md +269 -0
- package/guides/using-the-api.md +265 -0
- package/mocks/data/admin/snapshots.json +52 -0
- package/mocks/data/assets/analysis.json +14 -0
- package/mocks/data/assets/decisions.json +14 -0
- package/mocks/data/assets/events.json +4 -0
- package/mocks/data/assets/snapshots.json +49 -0
- package/mocks/data/findings/analysis.json +4 -0
- package/mocks/data/findings/decisions.json +4 -0
- package/mocks/data/findings/events.json +3 -0
- package/mocks/data/findings/snapshots.json +21 -0
- package/mocks/data/marketing/snapshots.json +75 -0
- package/mocks/data/memory/memory.json +31 -0
- package/mocks/data/product/snapshots.json +84 -0
- package/mocks/data/system/inventory.json +8 -0
- package/mocks/data/users/snapshots.json +5 -0
- package/mocks/demo.html +828 -0
- package/mocks/metadata/agents.json +7 -0
- package/mocks/metadata/lists/assets-default.json +22 -0
- package/mocks/metadata/lists/backlog.json +10 -0
- package/mocks/metadata/lists/findings-default.json +9 -0
- package/mocks/metadata/lists/my-plate.json +10 -0
- package/mocks/metadata/lists/this-week.json +10 -0
- package/mocks/metadata/narratives/admin.json +7 -0
- package/mocks/metadata/narratives/assets.json +17 -0
- package/mocks/metadata/narratives/findings.json +7 -0
- package/mocks/metadata/narratives/marketing.json +17 -0
- package/mocks/metadata/narratives/product.json +12 -0
- package/mocks/metadata/object-types/admin.json +6 -0
- package/mocks/metadata/object-types/assets.json +9 -0
- package/mocks/metadata/object-types/findings.json +8 -0
- package/mocks/metadata/object-types/marketing.json +6 -0
- package/mocks/metadata/object-types/memory.json +6 -0
- package/mocks/metadata/object-types/product.json +6 -0
- package/mocks/metadata/object-types/users.json +13 -0
- package/mocks/metadata/write-descriptors/admin-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/assets-analysis-write.json +14 -0
- package/mocks/metadata/write-descriptors/marketing-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/memory-write.json +14 -0
- package/mocks/metadata/write-descriptors/product-task-write.json +20 -0
- package/package.json +67 -0
package/mocks/demo.html
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="theme-color" content="#0c0f16" />
|
|
7
|
+
<title>FlowState</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0c0f16; --panel: #11151f; --panel-2: #161c28;
|
|
11
|
+
--line: #222c3c; --line-soft: rgba(255,255,255,.065);
|
|
12
|
+
--text: #f0f2f6; --muted: #7e8da0; --muted-2: #556072;
|
|
13
|
+
--accent: #8b7cff; --accent-rgb: 139,124,255;
|
|
14
|
+
--product: #8b7cff; --marketing: #5cb8ff; --admin: #f1b761;
|
|
15
|
+
--todo: #556072; --doing: #5cb8ff; --done: #52d4a8;
|
|
16
|
+
--urgent: #ff7088; --normal: #7e8da0; --low: #444e4e;
|
|
17
|
+
--danger: #ff7088;
|
|
18
|
+
--shadow: 0 20px 60px rgba(0,0,0,.4);
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
html, body { height: 100%; margin: 0; }
|
|
22
|
+
body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
23
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
24
|
+
button, input, select, textarea { background: var(--panel); border: 1px solid var(--line-soft); border-radius: 6px; padding: 6px 10px; outline: none; transition: .12s; }
|
|
25
|
+
button, input, select, textarea:focus { border-color: var(--accent); }
|
|
26
|
+
button { cursor: pointer; }
|
|
27
|
+
button.primary { background: var(--accent); border-color: transparent; color: white; }
|
|
28
|
+
button.primary:hover { filter: brightness(1.08); }
|
|
29
|
+
button.danger { background: transparent; border-color: rgba(255,112,136,.25); color: var(--danger); }
|
|
30
|
+
button.danger:hover { background: rgba(255,112,136,.08); }
|
|
31
|
+
textarea { resize: vertical; min-height: 80px; }
|
|
32
|
+
|
|
33
|
+
.app { display: grid; grid-template-columns: 240px 1fr 360px; height: 100vh; }
|
|
34
|
+
|
|
35
|
+
/* ── Sidebar ─────────────────────────────── */
|
|
36
|
+
.sidebar { background: var(--panel); border-right: 1px solid var(--line-soft); display: flex; flex-direction: column; overflow: hidden; }
|
|
37
|
+
.brand { padding: 20px 18px 16px; border-bottom: 1px solid var(--line-soft); display: flex; align-items: center; gap: 11px; }
|
|
38
|
+
.brand-icon { width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, #aa9fff, #6657e8); box-shadow: 0 8px 24px rgba(102,87,232,.35); flex-shrink: 0; display: grid; place-items: center; }
|
|
39
|
+
.brand-icon svg { width: 16px; height: 16px; }
|
|
40
|
+
.brand h1 { font-size: 15px; letter-spacing: -.02em; margin: 0; }
|
|
41
|
+
.brand small { display: block; font-size: 9px; color: var(--muted-2); letter-spacing: .06em; text-transform: uppercase; margin-top: 1px; }
|
|
42
|
+
|
|
43
|
+
.nav-group { padding: 14px 12px 6px; }
|
|
44
|
+
.nav-label { font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--muted-2); padding: 0 8px 7px; }
|
|
45
|
+
.nav-list { list-style: none; margin: 0; padding: 0; }
|
|
46
|
+
.nav-item { margin: 1px 0; }
|
|
47
|
+
.nav-item button { width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid transparent; background: transparent; text-align: left; font-size: 12px; }
|
|
48
|
+
.nav-item button:hover { background: rgba(255,255,255,.035); }
|
|
49
|
+
.nav-item.active button { background: rgba(var(--accent-rgb),.1); border-color: rgba(var(--accent-rgb),.25); }
|
|
50
|
+
.nav-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
51
|
+
.nav-text { min-width: 0; }
|
|
52
|
+
.nav-text strong { display: block; font-weight: 600; font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
53
|
+
.nav-text small { display: block; font-size: 9px; color: var(--muted-2); margin-top: 1px; }
|
|
54
|
+
.nav-count { margin-left: auto; font-size: 9px; font-weight: 700; color: var(--muted-2); background: rgba(255,255,255,.04); padding: 1px 6px; border-radius: 5px; }
|
|
55
|
+
|
|
56
|
+
.sidebar-footer { margin-top: auto; border-top: 1px solid var(--line-soft); padding: 12px 14px; }
|
|
57
|
+
.status-pill { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; background: rgba(255,255,255,.025); border: 1px solid var(--line-soft); font-size: 9px; }
|
|
58
|
+
.status-light { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
59
|
+
.status-light.on { background: var(--done); box-shadow: 0 0 8px rgba(82,212,168,.5); }
|
|
60
|
+
.status-light.off { background: var(--muted-2); }
|
|
61
|
+
.status-light.busy { background: var(--doing); animation: pulse 1s ease-in-out infinite; }
|
|
62
|
+
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
|
63
|
+
|
|
64
|
+
/* ── Main ────────────────────────────────── */
|
|
65
|
+
.main { min-width: 0; display: flex; flex-direction: column; background: radial-gradient(ellipse at 50% 20%, #131a28 0%, #0c0f16 60%); }
|
|
66
|
+
.topbar { display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-bottom: 1px solid var(--line-soft); background: rgba(12,15,22,.7); backdrop-filter: blur(12px); flex-shrink: 0; }
|
|
67
|
+
.search-box { position: relative; flex: 1; max-width: 400px; }
|
|
68
|
+
.search-box svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; stroke: var(--muted-2); fill: none; }
|
|
69
|
+
.search-box input { width: 100%; height: 34px; padding: 0 30px 0 30px; background: rgba(255,255,255,.03); }
|
|
70
|
+
.search-box kbd { position: absolute; right: 8px; top: 7px; font-size: 9px; color: var(--muted-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-family: inherit; }
|
|
71
|
+
.filter-btn { height: 34px; padding: 0 10px; font-size: 10px; font-weight: 600; background: rgba(255,255,255,.03); color: var(--muted); }
|
|
72
|
+
.filter-btn:hover { background: rgba(255,255,255,.06); color: var(--text); }
|
|
73
|
+
.filter-btn.active { background: rgba(var(--accent-rgb),.12); border-color: rgba(var(--accent-rgb),.3); color: var(--text); }
|
|
74
|
+
|
|
75
|
+
.content-head { padding: 16px 22px 12px; border-bottom: 1px solid var(--line-soft); flex-shrink: 0; }
|
|
76
|
+
.content-head h2 { margin: 0; font-size: 20px; letter-spacing: -.035em; font-weight: 750; }
|
|
77
|
+
.content-head p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
|
78
|
+
|
|
79
|
+
.content-body { flex: 1; overflow: auto; padding: 14px 22px 32px; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
|
|
80
|
+
|
|
81
|
+
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }
|
|
82
|
+
.metric { padding: 10px 12px; border: 1px solid var(--line-soft); border-radius: 9px; background: rgba(255,255,255,.018); }
|
|
83
|
+
.metric strong { display: block; font-size: 18px; letter-spacing: -.03em; }
|
|
84
|
+
.metric span { display: block; font-size: 9px; color: var(--muted); margin-top: 1px; }
|
|
85
|
+
|
|
86
|
+
.task-list { display: grid; gap: 6px; }
|
|
87
|
+
.task-card { display: grid; grid-template-columns: 3px 1fr auto; gap: 0 12px; padding: 11px 13px; border: 1px solid var(--line-soft); border-radius: 9px; background: rgba(255,255,255,.015); cursor: pointer; transition: .12s; align-items: center; }
|
|
88
|
+
.task-card:hover { border-color: rgba(var(--accent-rgb),.3); background: rgba(var(--accent-rgb),.03); }
|
|
89
|
+
.task-card.selected { border-color: var(--accent); background: rgba(var(--accent-rgb),.06); }
|
|
90
|
+
.task-bar { width: 3px; height: 36px; border-radius: 2px; grid-row: 1; }
|
|
91
|
+
.task-title { font-size: 12px; font-weight: 600; line-height: 1.3; }
|
|
92
|
+
.task-meta { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
|
93
|
+
.task-badges { display: flex; gap: 4px; align-self: center; flex-wrap: wrap; justify-content: end; }
|
|
94
|
+
.badge { padding: 2px 6px; border-radius: 5px; font-size: 8px; font-weight: 700; letter-spacing: .02em; white-space: nowrap; }
|
|
95
|
+
.badge.urgent { background: rgba(255,112,136,.12); color: var(--urgent); }
|
|
96
|
+
.badge.normal { background: rgba(255,255,255,.05); color: var(--muted); }
|
|
97
|
+
.badge.low { background: rgba(255,255,255,.03); color: var(--low); }
|
|
98
|
+
.badge.todo { background: rgba(85,96,114,.15); color: var(--todo); }
|
|
99
|
+
.badge.doing { background: rgba(92,184,255,.12); color: var(--doing); }
|
|
100
|
+
.badge.done { background: rgba(82,212,168,.12); color: var(--done); }
|
|
101
|
+
.badge.someday { background: rgba(85,96,114,.08); color: var(--muted-2); }
|
|
102
|
+
.badge.user { background: rgba(139,124,255,.12); color: var(--accent); }
|
|
103
|
+
|
|
104
|
+
.empty { text-align: center; padding: 48px 20px; color: var(--muted); }
|
|
105
|
+
.empty .ico { font-size: 24px; margin-bottom: 10px; opacity: .4; }
|
|
106
|
+
|
|
107
|
+
/* ── Detail Panel ────────────────────────── */
|
|
108
|
+
.detail { background: var(--panel); border-left: 1px solid var(--line-soft); display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
|
109
|
+
.detail-head { padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft); position: relative; }
|
|
110
|
+
.detail-head-actions { position: absolute; top: 14px; right: 14px; display: flex; gap: 6px; }
|
|
111
|
+
.icon-btn { padding: 4px 8px; font-size: 11px; line-height: 1; border-radius: 6px; background: rgba(255,255,255,.03); border-color: var(--line-soft); color: var(--muted); }
|
|
112
|
+
.icon-btn:hover { background: rgba(255,255,255,.07); color: var(--text); }
|
|
113
|
+
.detail-kicker { font-size: 9px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
|
114
|
+
.detail-head h2 { margin: 5px 0 3px; font-size: 17px; letter-spacing: -.03em; line-height: 1.2; }
|
|
115
|
+
.detail-head p { margin: 0; font-size: 10px; color: var(--muted); line-height: 1.5; }
|
|
116
|
+
.title-edit { width: 100%; font-size: 17px; font-weight: 700; letter-spacing: -.03em; padding: 4px 6px; }
|
|
117
|
+
|
|
118
|
+
.detail-tabs { display: flex; border-bottom: 1px solid var(--line-soft); padding: 0 14px; flex-shrink: 0; }
|
|
119
|
+
.dtab { flex: 1; padding: 10px 4px 8px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 9px; font-weight: 750; }
|
|
120
|
+
.dtab.active { color: white; border-bottom-color: var(--accent); }
|
|
121
|
+
.dtab:hover { color: var(--text); }
|
|
122
|
+
|
|
123
|
+
.detail-body { flex: 1; overflow: auto; padding: 16px 16px 28px; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
|
|
124
|
+
.dsection { margin-bottom: 20px; }
|
|
125
|
+
.dsection h3 { font-size: 8px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; color: var(--muted-2); margin: 0 0 9px; }
|
|
126
|
+
|
|
127
|
+
.edit-grid { display: grid; gap: 10px; }
|
|
128
|
+
.edit-row { display: grid; grid-template-columns: 90px 1fr; gap: 10px; align-items: center; }
|
|
129
|
+
.edit-label { font-size: 10px; color: var(--muted-2); font-weight: 600; }
|
|
130
|
+
.edit-input { width: 100%; }
|
|
131
|
+
|
|
132
|
+
.status-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
133
|
+
.status-btn { flex: 1; min-width: 60px; padding: 7px; font-size: 10px; font-weight: 600; border-radius: 6px; background: rgba(255,255,255,.04); color: var(--muted); transition: .12s; }
|
|
134
|
+
.status-btn:hover { background: rgba(255,255,255,.08); }
|
|
135
|
+
.status-btn.active { color: white; }
|
|
136
|
+
|
|
137
|
+
.chat-list { display: grid; gap: 10px; margin-bottom: 14px; }
|
|
138
|
+
.chat-msg { padding: 10px 12px; border-radius: 9px; background: rgba(255,255,255,.025); border: 1px solid var(--line-soft); }
|
|
139
|
+
.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
140
|
+
.chat-author { font-size: 10px; font-weight: 650; color: var(--accent); }
|
|
141
|
+
.chat-time { font-size: 9px; color: var(--muted-2); }
|
|
142
|
+
.chat-text { font-size: 11px; line-height: 1.5; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
|
143
|
+
|
|
144
|
+
.chat-input-area { display: grid; gap: 8px; }
|
|
145
|
+
.chat-input { width: 100%; min-height: 60px; }
|
|
146
|
+
.chat-row { display: flex; gap: 8px; }
|
|
147
|
+
.chat-row select { flex: 1; }
|
|
148
|
+
.chat-row button { padding: 0 16px; }
|
|
149
|
+
|
|
150
|
+
.empty-detail { text-align: center; padding: 48px 16px; color: var(--muted); }
|
|
151
|
+
.empty-detail .ico { font-size: 22px; margin-bottom: 8px; opacity: .35; }
|
|
152
|
+
|
|
153
|
+
.toast { position: fixed; left: 50%; bottom: 20px; transform: translate(-50%, 16px); opacity: 0; pointer-events: none; padding: 8px 14px; border-radius: 9px; border: 1px solid rgba(var(--accent-rgb),.25); background: rgba(22,28,40,.95); color: var(--text); font-size: 10px; box-shadow: var(--shadow); transition: .25s; z-index: 100; }
|
|
154
|
+
.toast.show { transform: translate(-50%, 0); opacity: 1; }
|
|
155
|
+
.toast.error { border-color: rgba(255,112,136,.4); }
|
|
156
|
+
|
|
157
|
+
/* ── Create Modal ────────────────────────── */
|
|
158
|
+
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.6); backdrop-filter: blur(3px); display: none; align-items: center; justify-content: center; z-index: 200; }
|
|
159
|
+
.modal-bg.show { display: flex; }
|
|
160
|
+
.modal { width: min(440px, calc(100vw - 32px)); background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); padding: 20px; }
|
|
161
|
+
.modal h3 { margin: 0 0 4px; font-size: 16px; letter-spacing: -.02em; }
|
|
162
|
+
.modal p { margin: 0 0 16px; font-size: 10px; color: var(--muted); }
|
|
163
|
+
.modal .field { display: grid; gap: 4px; margin-bottom: 12px; }
|
|
164
|
+
.modal .field label { font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--muted-2); }
|
|
165
|
+
.modal .field input, .modal .field select { width: 100%; }
|
|
166
|
+
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; }
|
|
167
|
+
|
|
168
|
+
@media (max-width: 1000px) { .app { grid-template-columns: 220px 1fr; } .detail { display: none; } }
|
|
169
|
+
@media (max-width: 640px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } }
|
|
170
|
+
</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<div class="app">
|
|
174
|
+
<aside class="sidebar">
|
|
175
|
+
<div class="brand">
|
|
176
|
+
<div class="brand-icon"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><path d="M5 12h14M12 5v14"/></svg></div>
|
|
177
|
+
<div><h1>FlowState</h1><small>Explorer demo</small></div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="nav-group">
|
|
181
|
+
<div class="nav-label">Areas</div>
|
|
182
|
+
<ul id="areaList" class="nav-list"></ul>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="nav-group">
|
|
185
|
+
<div class="nav-label">Quick Views</div>
|
|
186
|
+
<ul id="viewList" class="nav-list"></ul>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div class="sidebar-footer">
|
|
190
|
+
<div class="status-pill"><span id="statusDot" class="status-light off"></span><span id="statusText">Connecting…</span><span id="savingIndicator" class="nav-count" style="display:none; margin-left:auto">Saving…</span></div>
|
|
191
|
+
</div>
|
|
192
|
+
</aside>
|
|
193
|
+
|
|
194
|
+
<main class="main">
|
|
195
|
+
<header class="topbar">
|
|
196
|
+
<div class="search-box">
|
|
197
|
+
<svg viewBox="0 0 24 24" stroke-width="1.8"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg>
|
|
198
|
+
<input id="search" type="search" placeholder="Search tasks…" autocomplete="off" />
|
|
199
|
+
<kbd>/</kbd>
|
|
200
|
+
</div>
|
|
201
|
+
<button class="filter-btn" data-filter="status:todo">To Do</button>
|
|
202
|
+
<button class="filter-btn" data-filter="status:doing">Doing</button>
|
|
203
|
+
<button class="filter-btn" data-filter="status:done">Done</button>
|
|
204
|
+
<button class="filter-btn" data-filter="priority:urgent">Urgent</button>
|
|
205
|
+
<button id="addTaskBtn" class="primary" style="margin-left:auto; padding: 0 14px;">+ New Task</button>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
<div id="contentHead" class="content-head">
|
|
209
|
+
<h2 id="headTitle">All Tasks</h2>
|
|
210
|
+
<p id="headDesc">Everything across every area.</p>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<div id="contentBody" class="content-body"></div>
|
|
214
|
+
</main>
|
|
215
|
+
|
|
216
|
+
<aside class="detail">
|
|
217
|
+
<div class="detail-head">
|
|
218
|
+
<div id="detailHeadActions" class="detail-head-actions" style="display:none">
|
|
219
|
+
<button id="deleteBtn" class="icon-btn" title="Delete task">Delete</button>
|
|
220
|
+
</div>
|
|
221
|
+
<div id="dKicker" class="detail-kicker" style="color:var(--accent)">Task</div>
|
|
222
|
+
<h2 id="dTitle">Select a task</h2>
|
|
223
|
+
<p id="dDesc">Click any task to see details.</p>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="detail-tabs">
|
|
226
|
+
<button class="dtab active" data-tab="overview">Overview</button>
|
|
227
|
+
<button class="dtab" data-tab="chat">Chat</button>
|
|
228
|
+
</div>
|
|
229
|
+
<div id="dBody" class="detail-body">
|
|
230
|
+
<div class="empty-detail"><div class="ico">◇</div><p>No task selected.</p></div>
|
|
231
|
+
</div>
|
|
232
|
+
</aside>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<!-- Create Modal -->
|
|
236
|
+
<div id="createModal" class="modal-bg" role="dialog" aria-modal="true">
|
|
237
|
+
<div class="modal">
|
|
238
|
+
<h3>New Task</h3>
|
|
239
|
+
<p>Creates a record via <code>POST /api/explorer/records/write</code>.</p>
|
|
240
|
+
<div class="field">
|
|
241
|
+
<label for="newTitle">Title</label>
|
|
242
|
+
<input id="newTitle" type="text" placeholder="What needs to be done?" />
|
|
243
|
+
</div>
|
|
244
|
+
<div class="field">
|
|
245
|
+
<label for="newArea">Area</label>
|
|
246
|
+
<select id="newArea"></select>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="field">
|
|
249
|
+
<label for="newStatus">Status</label>
|
|
250
|
+
<select id="newStatus"></select>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="field">
|
|
253
|
+
<label for="newPriority">Priority</label>
|
|
254
|
+
<select id="newPriority"></select>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="field">
|
|
257
|
+
<label for="newAssignee">Assignee</label>
|
|
258
|
+
<select id="newAssignee"></select>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="modal-actions">
|
|
261
|
+
<button id="cancelCreateBtn">Cancel</button>
|
|
262
|
+
<button id="confirmCreateBtn" class="primary">Create task</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div id="toast" class="toast" role="status"></div>
|
|
268
|
+
|
|
269
|
+
<script>
|
|
270
|
+
"use strict";
|
|
271
|
+
|
|
272
|
+
// ──────────────────────────────────────────────────
|
|
273
|
+
// API client (uses the Explorer API exclusively)
|
|
274
|
+
// ──────────────────────────────────────────────────
|
|
275
|
+
const API = location.origin + "/api/explorer";
|
|
276
|
+
|
|
277
|
+
async function apiGet(path) {
|
|
278
|
+
const r = await fetch(`${API}${path}`);
|
|
279
|
+
if (!r.ok) throw new Error(`GET ${path} → ${r.status}`);
|
|
280
|
+
return r.json();
|
|
281
|
+
}
|
|
282
|
+
async function apiWrite(writeDescriptorId, operation, input) {
|
|
283
|
+
const r = await fetch(`${API}/records/write`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "Content-Type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ writeDescriptorId, operation, input }),
|
|
287
|
+
});
|
|
288
|
+
if (!r.ok) {
|
|
289
|
+
const body = await r.json().catch(() => ({}));
|
|
290
|
+
throw new Error(`write ${operation} → ${r.status}: ${body.error || r.statusText}`);
|
|
291
|
+
}
|
|
292
|
+
return r.json();
|
|
293
|
+
}
|
|
294
|
+
async function apiDelete(entityName, recordId) {
|
|
295
|
+
const writeDescriptorId = `${entityName}-task-write`;
|
|
296
|
+
const r = await fetch(
|
|
297
|
+
`${API}/records/item?entityName=${encodeURIComponent(entityName)}&recordId=${encodeURIComponent(recordId)}&writeDescriptorId=${encodeURIComponent(writeDescriptorId)}`,
|
|
298
|
+
{ method: "DELETE" },
|
|
299
|
+
);
|
|
300
|
+
if (!r.ok) throw new Error(`delete → ${r.status}`);
|
|
301
|
+
return r.json();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
305
|
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
306
|
+
const h = s => String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
307
|
+
|
|
308
|
+
const AREAS = {
|
|
309
|
+
product: { label: "Product", color: "var(--product)", desc: "Building the thing." },
|
|
310
|
+
marketing: { label: "Marketing", color: "var(--marketing)", desc: "Getting people to see the thing." },
|
|
311
|
+
admin: { label: "Admin", color: "var(--admin)", desc: "Keeping the lights on." },
|
|
312
|
+
};
|
|
313
|
+
const AREA_KEYS = Object.keys(AREAS);
|
|
314
|
+
const STATUS_COLORS = { todo: "var(--todo)", doing: "var(--doing)", done: "var(--done)", someday: "var(--muted-2)" };
|
|
315
|
+
const STATUS_ORDER = { doing: 0, todo: 1, someday: 2, done: 3 };
|
|
316
|
+
const STATUS_OPTIONS = ["todo", "doing", "done", "someday"];
|
|
317
|
+
const PRIORITY_OPTIONS = ["urgent", "normal", "low"];
|
|
318
|
+
const label = value => value ? value[0].toUpperCase() + value.slice(1) : "";
|
|
319
|
+
|
|
320
|
+
const state = {
|
|
321
|
+
users: [],
|
|
322
|
+
tasks: [],
|
|
323
|
+
view: "all",
|
|
324
|
+
filterStatus: "",
|
|
325
|
+
filterPriority: "",
|
|
326
|
+
search: "",
|
|
327
|
+
selectedId: null,
|
|
328
|
+
detailTab: "overview",
|
|
329
|
+
savingInFlight: 0,
|
|
330
|
+
toastTimer: null,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function toast(msg, isError = false) {
|
|
334
|
+
const el = $("#toast");
|
|
335
|
+
el.textContent = msg;
|
|
336
|
+
el.classList.toggle("error", isError);
|
|
337
|
+
el.classList.add("show");
|
|
338
|
+
clearTimeout(state.toastTimer);
|
|
339
|
+
state.toastTimer = setTimeout(() => el.classList.remove("show"), 2400);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function setBusy(isBusy) {
|
|
343
|
+
const el = $("#savingIndicator");
|
|
344
|
+
el.style.display = isBusy ? "inline-block" : "none";
|
|
345
|
+
el.textContent = "Saving…";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ──────────────────────────────────────────────────
|
|
349
|
+
// Data loading
|
|
350
|
+
// ──────────────────────────────────────────────────
|
|
351
|
+
async function loadUsers() {
|
|
352
|
+
const res = await apiGet("/records/collection?entityName=users&limit=500");
|
|
353
|
+
return res.rows || [];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function loadAllTasks() {
|
|
357
|
+
// Tasks span 3 area collections; fan out and merge.
|
|
358
|
+
const results = await Promise.all(
|
|
359
|
+
AREA_KEYS.map(a => apiGet(`/records/collection?entityName=${a}&limit=500`)),
|
|
360
|
+
);
|
|
361
|
+
return results.flatMap(r => r.rows || []);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function refreshAll() {
|
|
365
|
+
const [users, tasks] = await Promise.all([loadUsers(), loadAllTasks()]);
|
|
366
|
+
state.users = users;
|
|
367
|
+
state.tasks = tasks;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ──────────────────────────────────────────────────
|
|
371
|
+
// Mutations (all go through /records/write)
|
|
372
|
+
// ──────────────────────────────────────────────────
|
|
373
|
+
async function persistTask(task, operation) {
|
|
374
|
+
const wd = `${task.area}-task-write`;
|
|
375
|
+
state.savingInFlight++;
|
|
376
|
+
setBusy(true);
|
|
377
|
+
try {
|
|
378
|
+
const res = await apiWrite(wd, operation, task);
|
|
379
|
+
return res;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
toast(`Save failed: ${e.message}`, true);
|
|
382
|
+
throw e;
|
|
383
|
+
} finally {
|
|
384
|
+
state.savingInFlight--;
|
|
385
|
+
if (state.savingInFlight === 0) setBusy(false);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function updateTask(taskId, updates) {
|
|
390
|
+
const idx = state.tasks.findIndex(t => t.recordId === taskId);
|
|
391
|
+
if (idx === -1) return;
|
|
392
|
+
const previous = state.tasks[idx];
|
|
393
|
+
const next = { ...previous, ...updates };
|
|
394
|
+
state.tasks[idx] = next;
|
|
395
|
+
renderMain();
|
|
396
|
+
renderDetail();
|
|
397
|
+
try {
|
|
398
|
+
await persistTask(next, "upsert");
|
|
399
|
+
} catch {
|
|
400
|
+
state.tasks[idx] = previous; // rollback
|
|
401
|
+
renderMain();
|
|
402
|
+
renderDetail();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function createTask(draft) {
|
|
407
|
+
try {
|
|
408
|
+
const res = await persistTask(draft, "add");
|
|
409
|
+
// Server may return created records; otherwise use the draft as-is.
|
|
410
|
+
const created = (res.results && res.results[0]) || draft;
|
|
411
|
+
state.tasks.unshift(created);
|
|
412
|
+
state.selectedId = created.recordId;
|
|
413
|
+
state.view = "all";
|
|
414
|
+
renderSidebar();
|
|
415
|
+
renderMain();
|
|
416
|
+
renderDetail();
|
|
417
|
+
toast("Task created");
|
|
418
|
+
} catch {
|
|
419
|
+
/* error already toasted */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function deleteTask(taskId) {
|
|
424
|
+
const idx = state.tasks.findIndex(t => t.recordId === taskId);
|
|
425
|
+
if (idx === -1) return;
|
|
426
|
+
const task = state.tasks[idx];
|
|
427
|
+
state.savingInFlight++;
|
|
428
|
+
setBusy(true);
|
|
429
|
+
try {
|
|
430
|
+
await apiDelete(task.area, task.recordId);
|
|
431
|
+
state.tasks.splice(idx, 1);
|
|
432
|
+
if (state.selectedId === taskId) state.selectedId = null;
|
|
433
|
+
renderSidebar();
|
|
434
|
+
renderMain();
|
|
435
|
+
renderDetail();
|
|
436
|
+
toast("Task deleted");
|
|
437
|
+
} catch (e) {
|
|
438
|
+
toast(`Delete failed: ${e.message}`, true);
|
|
439
|
+
} finally {
|
|
440
|
+
state.savingInFlight--;
|
|
441
|
+
if (state.savingInFlight === 0) setBusy(false);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function addComment(taskId, text, authorId) {
|
|
446
|
+
const idx = state.tasks.findIndex(t => t.recordId === taskId);
|
|
447
|
+
if (idx === -1) return;
|
|
448
|
+
const task = state.tasks[idx];
|
|
449
|
+
const chat = Array.isArray(task.chat) ? [...task.chat] : [];
|
|
450
|
+
chat.push({
|
|
451
|
+
id: `cmt-${Date.now()}`,
|
|
452
|
+
authorId,
|
|
453
|
+
timestamp: new Date().toISOString(),
|
|
454
|
+
text,
|
|
455
|
+
});
|
|
456
|
+
await updateTask(taskId, { chat });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ──────────────────────────────────────────────────
|
|
460
|
+
// Filtering
|
|
461
|
+
// ──────────────────────────────────────────────────
|
|
462
|
+
function filtered() {
|
|
463
|
+
let tasks = state.tasks;
|
|
464
|
+
const v = state.view;
|
|
465
|
+
if (v.startsWith("area:")) tasks = tasks.filter(t => t.area === v.split(":")[1]);
|
|
466
|
+
else if (v === "view:my-plate") tasks = tasks.filter(t => t.assigneeId);
|
|
467
|
+
else if (v === "view:this-week") tasks = tasks.filter(t => t.dueDate && t.status !== "Done");
|
|
468
|
+
else if (v === "view:backlog") tasks = tasks.filter(t => t.status === "todo" && t.priority === "normal");
|
|
469
|
+
if (state.filterStatus) tasks = tasks.filter(t => t.status === state.filterStatus);
|
|
470
|
+
if (state.filterPriority) tasks = tasks.filter(t => t.priority === state.filterPriority);
|
|
471
|
+
if (state.search) {
|
|
472
|
+
const q = state.search.toLowerCase();
|
|
473
|
+
tasks = tasks.filter(t => (t.title + " " + t.area + " " + (t.notes || "")).toLowerCase().includes(q));
|
|
474
|
+
}
|
|
475
|
+
tasks.sort((a, b) => (STATUS_ORDER[(a.status || "").toLowerCase()] ?? 9) - (STATUS_ORDER[(b.status || "").toLowerCase()] ?? 9));
|
|
476
|
+
return tasks;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ──────────────────────────────────────────────────
|
|
480
|
+
// Rendering
|
|
481
|
+
// ──────────────────────────────────────────────────
|
|
482
|
+
function renderSidebar() {
|
|
483
|
+
const counts = {};
|
|
484
|
+
state.tasks.forEach(t => { counts[t.area] = (counts[t.area] || 0) + 1; });
|
|
485
|
+
|
|
486
|
+
$("#areaList").innerHTML = AREA_KEYS.map(id => {
|
|
487
|
+
const a = AREAS[id];
|
|
488
|
+
const active = state.view === `area:${id}`;
|
|
489
|
+
return `<li class="nav-item${active ? " active" : ""}"><button data-view="area:${id}">
|
|
490
|
+
<span class="nav-dot" style="background:${a.color}"></span>
|
|
491
|
+
<span class="nav-text"><strong>${h(a.label)}</strong><small>${counts[id] || 0} tasks</small></span>
|
|
492
|
+
</button></li>`;
|
|
493
|
+
}).join("");
|
|
494
|
+
|
|
495
|
+
const views = [
|
|
496
|
+
["my-plate", "My Plate", "Assigned tasks"],
|
|
497
|
+
["this-week", "This Week", "Active & due soon"],
|
|
498
|
+
["backlog", "Backlog", "Todo / normal priority"],
|
|
499
|
+
];
|
|
500
|
+
$("#viewList").innerHTML = views.map(([id, label, sub]) => {
|
|
501
|
+
const active = state.view === `view:${id}`;
|
|
502
|
+
return `<li class="nav-item${active ? " active" : ""}"><button data-view="view:${id}">
|
|
503
|
+
<span class="nav-dot" style="background:var(--muted-2)"></span>
|
|
504
|
+
<span class="nav-text"><strong>${label}</strong><small>${sub}</small></span>
|
|
505
|
+
</button></li>`;
|
|
506
|
+
}).join("");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderMain() {
|
|
510
|
+
const tasks = filtered();
|
|
511
|
+
const total = state.tasks.length;
|
|
512
|
+
const doing = state.tasks.filter(t => t.status === "doing").length;
|
|
513
|
+
const done = state.tasks.filter(t => t.status === "done").length;
|
|
514
|
+
const urgent = state.tasks.filter(t => t.priority === "urgent" && t.status !== "done").length;
|
|
515
|
+
|
|
516
|
+
let title = "All Tasks", desc = "Everything across every area.";
|
|
517
|
+
if (state.view.startsWith("area:")) {
|
|
518
|
+
const key = state.view.split(":")[1];
|
|
519
|
+
const a = AREAS[key];
|
|
520
|
+
title = a?.label || title;
|
|
521
|
+
desc = a?.desc || desc;
|
|
522
|
+
}
|
|
523
|
+
else if (state.view === "view:my-plate") { title = "My Plate"; desc = "Tasks assigned to someone."; }
|
|
524
|
+
else if (state.view === "view:this-week") { title = "This Week"; desc = "Active tasks with due dates."; }
|
|
525
|
+
else if (state.view === "view:backlog") { title = "Backlog"; desc = "Todo items with normal priority."; }
|
|
526
|
+
$("#headTitle").textContent = title;
|
|
527
|
+
$("#headDesc").textContent = desc;
|
|
528
|
+
|
|
529
|
+
let html = `<div class="metrics">
|
|
530
|
+
<div class="metric"><strong>${total}</strong><span>Total</span></div>
|
|
531
|
+
<div class="metric"><strong>${doing}</strong><span>In Progress</span></div>
|
|
532
|
+
<div class="metric"><strong>${done}</strong><span>Done</span></div>
|
|
533
|
+
<div class="metric" style="${urgent ? "" : "opacity:.4"}"><strong style="${urgent ? "color:var(--urgent)" : ""}">${urgent}</strong><span>Urgent</span></div>
|
|
534
|
+
</div>`;
|
|
535
|
+
|
|
536
|
+
if (!tasks.length) html += `<div class="empty"><div class="ico">☐</div><p>No tasks here.</p></div>`;
|
|
537
|
+
else html += `<div class="task-list">${tasks.map(t => {
|
|
538
|
+
const sel = state.selectedId === t.recordId;
|
|
539
|
+
const areaKey = (t.area || "").toLowerCase();
|
|
540
|
+
const areaColor = AREAS[areaKey]?.color || "var(--muted)";
|
|
541
|
+
const user = state.users.find(u => u.recordId === t.assigneeId);
|
|
542
|
+
const statusKey = (t.status || "").toLowerCase();
|
|
543
|
+
const priorityKey = (t.priority || "").toLowerCase();
|
|
544
|
+
return `<div class="task-card${sel ? " selected" : ""}" data-id="${h(t.recordId)}">
|
|
545
|
+
<div class="task-bar" style="background:${areaColor}"></div>
|
|
546
|
+
<div>
|
|
547
|
+
<div class="task-title">${h(t.title)}</div>
|
|
548
|
+
<div class="task-meta">${user ? h(user.name) : "Unassigned"}${t.dueDate ? " · " + h(t.dueDate.split("T")[0]) : ""}</div>
|
|
549
|
+
</div>
|
|
550
|
+
<div class="task-badges">
|
|
551
|
+
<span class="badge ${priorityKey}">${h(t.priority)}</span>
|
|
552
|
+
<span class="badge ${statusKey}">${h(t.status)}</span>
|
|
553
|
+
</div>
|
|
554
|
+
</div>`;
|
|
555
|
+
}).join("")}</div>`;
|
|
556
|
+
$("#contentBody").innerHTML = html;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderDetail() {
|
|
560
|
+
const t = state.tasks.find(x => x.recordId === state.selectedId);
|
|
561
|
+
const actions = $("#detailHeadActions");
|
|
562
|
+
|
|
563
|
+
if (!t) {
|
|
564
|
+
actions.style.display = "none";
|
|
565
|
+
$("#dKicker").textContent = "Task";
|
|
566
|
+
$("#dKicker").style.color = "var(--accent)";
|
|
567
|
+
$("#dTitle").textContent = "Select a task";
|
|
568
|
+
$("#dDesc").textContent = "Click any task to see details.";
|
|
569
|
+
$("#dBody").innerHTML = `<div class="empty-detail"><div class="ico">◇</div><p>No task selected.</p></div>`;
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
actions.style.display = "flex";
|
|
574
|
+
const areaKey = (t.area || "").toLowerCase();
|
|
575
|
+
const areaColor = AREAS[areaKey]?.color || "var(--accent)";
|
|
576
|
+
$("#dKicker").textContent = AREAS[areaKey]?.label || t.area || "Task";
|
|
577
|
+
$("#dKicker").style.color = areaColor;
|
|
578
|
+
$("#dTitle").textContent = t.title;
|
|
579
|
+
$("#dDesc").textContent = `${t.status} · ${t.priority} priority${t.dueDate ? " · due " + t.dueDate.split("T")[0] : ""}`;
|
|
580
|
+
|
|
581
|
+
if (state.detailTab === "overview") {
|
|
582
|
+
const userOptions = [{ recordId: "", name: "Unassigned" }, ...state.users];
|
|
583
|
+
$("#dBody").innerHTML = `
|
|
584
|
+
<div class="dsection"><h3>Properties</h3><div class="edit-grid">
|
|
585
|
+
<div class="edit-row"><span class="edit-label">Status</span><div class="status-btns">
|
|
586
|
+
${STATUS_OPTIONS.map(s => `<button class="status-btn ${t.status === s ? "active" : ""}" style="${t.status === s ? `background:${STATUS_COLORS[s] || "var(--accent)"};border-color:transparent` : ""}" data-field="status" data-value="${h(s)}">${h(label(s))}</button>`).join("")}
|
|
587
|
+
</div></div>
|
|
588
|
+
<div class="edit-row"><span class="edit-label">Priority</span><div class="status-btns">
|
|
589
|
+
${PRIORITY_OPTIONS.map(p => `<button class="status-btn ${t.priority === p ? "active" : ""}" style="${t.priority === p ? "background:var(--accent);border-color:transparent" : ""}" data-field="priority" data-value="${h(p)}">${h(label(p))}</button>`).join("")}
|
|
590
|
+
</div></div>
|
|
591
|
+
<div class="edit-row"><span class="edit-label">Area</span>
|
|
592
|
+
<select class="edit-input" id="areaSelect" data-field="area">
|
|
593
|
+
${AREA_KEYS.map(k => `<option value="${h(k)}" ${areaKey === k ? "selected" : ""}>${h(AREAS[k].label)}</option>`).join("")}
|
|
594
|
+
</select>
|
|
595
|
+
</div>
|
|
596
|
+
<div class="edit-row"><span class="edit-label">Assignee</span>
|
|
597
|
+
<select class="edit-input" id="assigneeSelect" data-field="assigneeId">
|
|
598
|
+
${userOptions.map(u => `<option value="${h(u.recordId)}" ${t.assigneeId === u.recordId ? "selected" : ""}>${h(u.name)}</option>`).join("")}
|
|
599
|
+
</select>
|
|
600
|
+
</div>
|
|
601
|
+
<div class="edit-row"><span class="edit-label">Due Date</span>
|
|
602
|
+
<input type="date" class="edit-input" id="dueDateInput" data-field="dueDate" value="${h(t.dueDate?.split("T")[0] || "")}" />
|
|
603
|
+
</div>
|
|
604
|
+
</div></div>
|
|
605
|
+
<div class="dsection"><h3>Notes</h3>
|
|
606
|
+
<textarea id="notesInput" class="edit-input" placeholder="Add notes...">${h(t.notes || "")}</textarea>
|
|
607
|
+
</div>`;
|
|
608
|
+
bindOverviewInputs();
|
|
609
|
+
} else {
|
|
610
|
+
const userMap = new Map(state.users.map(u => [u.recordId, u.name]));
|
|
611
|
+
$("#dBody").innerHTML = `
|
|
612
|
+
<div class="dsection"><h3>Chat (${(t.chat || []).length})</h3>
|
|
613
|
+
<div class="chat-list">${(t.chat || []).map(c => {
|
|
614
|
+
const author = userMap.get(c.authorId) || "Unknown";
|
|
615
|
+
const time = new Date(c.timestamp).toLocaleString();
|
|
616
|
+
return `<div class="chat-msg">
|
|
617
|
+
<div class="chat-header">
|
|
618
|
+
<span class="chat-author">${h(author)}</span>
|
|
619
|
+
<span class="chat-time">${h(time)}</span>
|
|
620
|
+
</div>
|
|
621
|
+
<div class="chat-text">${h(c.text)}</div>
|
|
622
|
+
</div>`;
|
|
623
|
+
}).join("")}</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="dsection"><h3>Add Comment</h3>
|
|
626
|
+
<div class="chat-input-area">
|
|
627
|
+
<textarea id="chatInput" class="chat-input edit-input" placeholder="Write a comment..."></textarea>
|
|
628
|
+
<div class="chat-row">
|
|
629
|
+
<select id="chatAuthor">${state.users.map(u => `<option value="${h(u.recordId)}">${h(u.name)}</option>`).join("")}</select>
|
|
630
|
+
<button id="sendChatBtn" class="primary">Send</button>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
</div>`;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ──────────────────────────────────────────────────
|
|
638
|
+
// Create Modal
|
|
639
|
+
// ──────────────────────────────────────────────────
|
|
640
|
+
function openCreateModal() {
|
|
641
|
+
$("#newArea").innerHTML = AREA_KEYS.map(k => `<option value="${h(k)}">${h(AREAS[k].label)}</option>`).join("");
|
|
642
|
+
$("#newStatus").innerHTML = STATUS_OPTIONS.map(s => `<option value="${h(s)}">${h(label(s))}</option>`).join("");
|
|
643
|
+
$("#newPriority").innerHTML = PRIORITY_OPTIONS.map(p => `<option value="${h(p)}">${h(label(p))}</option>`).join("");
|
|
644
|
+
$("#newAssignee").innerHTML =
|
|
645
|
+
`<option value="">Unassigned</option>` +
|
|
646
|
+
state.users.map(u => `<option value="${h(u.recordId)}">${h(u.name)}</option>`).join("");
|
|
647
|
+
// Default area to current view if looking at a single area
|
|
648
|
+
if (state.view.startsWith("area:")) $("#newArea").value = state.view.split(":")[1];
|
|
649
|
+
$("#newTitle").value = "";
|
|
650
|
+
$("#createModal").classList.add("show");
|
|
651
|
+
setTimeout(() => $("#newTitle").focus(), 30);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function closeCreateModal() {
|
|
655
|
+
$("#createModal").classList.remove("show");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function confirmCreate() {
|
|
659
|
+
const title = $("#newTitle").value.trim();
|
|
660
|
+
if (!title) { toast("Title is required", true); return; }
|
|
661
|
+
const area = $("#newArea").value;
|
|
662
|
+
const draft = {
|
|
663
|
+
recordId: `t-${Date.now()}`,
|
|
664
|
+
title,
|
|
665
|
+
area,
|
|
666
|
+
status: $("#newStatus").value,
|
|
667
|
+
priority: $("#newPriority").value,
|
|
668
|
+
assigneeId: $("#newAssignee").value || null,
|
|
669
|
+
dueDate: "",
|
|
670
|
+
notes: "",
|
|
671
|
+
chat: [],
|
|
672
|
+
narratives: {},
|
|
673
|
+
};
|
|
674
|
+
closeCreateModal();
|
|
675
|
+
await createTask(draft);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ──────────────────────────────────────────────────
|
|
679
|
+
// Events
|
|
680
|
+
// ──────────────────────────────────────────────────
|
|
681
|
+
function bindOverviewInputs() {
|
|
682
|
+
const areaSel = $("#areaSelect");
|
|
683
|
+
if (areaSel) areaSel.addEventListener("change", async () => {
|
|
684
|
+
// Moving areas requires delete + add on the old/new collections.
|
|
685
|
+
const task = state.tasks.find(t => t.recordId === state.selectedId);
|
|
686
|
+
if (!task) return;
|
|
687
|
+
const newArea = areaSel.value;
|
|
688
|
+
if (newArea === task.area) return;
|
|
689
|
+
const moved = { ...task, area: newArea };
|
|
690
|
+
try {
|
|
691
|
+
await apiDelete(task.area, task.recordId);
|
|
692
|
+
try {
|
|
693
|
+
await persistTask(moved, "add");
|
|
694
|
+
} catch (moveError) {
|
|
695
|
+
// A move spans two collection writes. Restore the source record if
|
|
696
|
+
// the destination write fails so the task is never silently lost.
|
|
697
|
+
await persistTask(task, "add");
|
|
698
|
+
throw moveError;
|
|
699
|
+
}
|
|
700
|
+
const idx = state.tasks.findIndex(t => t.recordId === task.recordId);
|
|
701
|
+
state.tasks[idx] = moved;
|
|
702
|
+
renderSidebar();
|
|
703
|
+
renderMain();
|
|
704
|
+
renderDetail();
|
|
705
|
+
toast("Task moved");
|
|
706
|
+
} catch (e) {
|
|
707
|
+
toast(`Move failed: ${e.message}`, true);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const assigneeSel = $("#assigneeSelect");
|
|
712
|
+
if (assigneeSel) assigneeSel.addEventListener("change", () =>
|
|
713
|
+
updateTask(state.selectedId, { assigneeId: assigneeSel.value || null }));
|
|
714
|
+
|
|
715
|
+
const dueInput = $("#dueDateInput");
|
|
716
|
+
if (dueInput) dueInput.addEventListener("change", () =>
|
|
717
|
+
updateTask(state.selectedId, { dueDate: dueInput.value }));
|
|
718
|
+
|
|
719
|
+
const notesInput = $("#notesInput");
|
|
720
|
+
if (notesInput) {
|
|
721
|
+
let timer;
|
|
722
|
+
notesInput.addEventListener("input", () => {
|
|
723
|
+
clearTimeout(timer);
|
|
724
|
+
const v = notesInput.value;
|
|
725
|
+
timer = setTimeout(() => updateTask(state.selectedId, { notes: v }), 400);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function bindEvents() {
|
|
731
|
+
document.addEventListener("click", e => {
|
|
732
|
+
const navBtn = e.target.closest("[data-view]");
|
|
733
|
+
if (navBtn) { state.view = navBtn.dataset.view; state.selectedId = null; renderSidebar(); renderMain(); renderDetail(); return; }
|
|
734
|
+
|
|
735
|
+
const taskCard = e.target.closest("[data-id]");
|
|
736
|
+
if (taskCard) { state.selectedId = taskCard.dataset.id; renderMain(); renderDetail(); return; }
|
|
737
|
+
|
|
738
|
+
const statusBtn = e.target.closest("[data-field]");
|
|
739
|
+
if (statusBtn && (statusBtn.dataset.field === "status" || statusBtn.dataset.field === "priority")) {
|
|
740
|
+
updateTask(state.selectedId, { [statusBtn.dataset.field]: statusBtn.dataset.value });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (e.target.id === "addTaskBtn") { openCreateModal(); return; }
|
|
745
|
+
if (e.target.id === "cancelCreateBtn") { closeCreateModal(); return; }
|
|
746
|
+
if (e.target.id === "confirmCreateBtn") { confirmCreate(); return; }
|
|
747
|
+
if (e.target.id === "deleteBtn") {
|
|
748
|
+
if (state.selectedId && confirm("Delete this task? This calls DELETE /api/explorer/records/item.")) {
|
|
749
|
+
deleteTask(state.selectedId);
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (e.target.id === "sendChatBtn") {
|
|
754
|
+
const text = $("#chatInput")?.value.trim();
|
|
755
|
+
const author = $("#chatAuthor")?.value;
|
|
756
|
+
if (text && author) {
|
|
757
|
+
addComment(state.selectedId, text, author);
|
|
758
|
+
$("#chatInput").value = "";
|
|
759
|
+
}
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
$$(".dtab").forEach(t => t.addEventListener("click", () => {
|
|
765
|
+
state.detailTab = t.dataset.tab;
|
|
766
|
+
$$(".dtab").forEach(x => x.classList.toggle("active", x === t));
|
|
767
|
+
renderDetail();
|
|
768
|
+
}));
|
|
769
|
+
|
|
770
|
+
$$(".filter-btn").forEach(btn => btn.addEventListener("click", () => {
|
|
771
|
+
const [kind, val] = btn.dataset.filter.split(":");
|
|
772
|
+
const currentlyActive = btn.classList.contains("active");
|
|
773
|
+
$$(".filter-btn").forEach(b => b.classList.remove("active"));
|
|
774
|
+
if (kind === "status") state.filterStatus = currentlyActive ? "" : val;
|
|
775
|
+
if (kind === "priority") state.filterPriority = currentlyActive ? "" : val;
|
|
776
|
+
if (!currentlyActive) btn.classList.add("active");
|
|
777
|
+
renderMain();
|
|
778
|
+
}));
|
|
779
|
+
|
|
780
|
+
let searchTimer;
|
|
781
|
+
$("#search").addEventListener("input", () => {
|
|
782
|
+
clearTimeout(searchTimer);
|
|
783
|
+
searchTimer = setTimeout(() => { state.search = $("#search").value; renderMain(); }, 150);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
$("#newTitle").addEventListener("keydown", e => {
|
|
787
|
+
if (e.key === "Enter") { e.preventDefault(); confirmCreate(); }
|
|
788
|
+
if (e.key === "Escape") closeCreateModal();
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
document.addEventListener("keydown", e => {
|
|
792
|
+
if ($("#createModal").classList.contains("show")) return;
|
|
793
|
+
if (e.key === "/" && !/input|textarea|select/i.test(document.activeElement.tagName)) {
|
|
794
|
+
e.preventDefault(); $("#search").focus();
|
|
795
|
+
}
|
|
796
|
+
if (e.key === "Escape") {
|
|
797
|
+
if (state.selectedId) { state.selectedId = null; renderMain(); renderDetail(); }
|
|
798
|
+
}
|
|
799
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "n") { e.preventDefault(); openCreateModal(); }
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ──────────────────────────────────────────────────
|
|
804
|
+
// Init
|
|
805
|
+
// ──────────────────────────────────────────────────
|
|
806
|
+
async function init() {
|
|
807
|
+
bindEvents();
|
|
808
|
+
const dot = $("#statusDot"), txt = $("#statusText");
|
|
809
|
+
dot.className = "status-light busy";
|
|
810
|
+
txt.textContent = "Loading…";
|
|
811
|
+
try {
|
|
812
|
+
await refreshAll();
|
|
813
|
+
dot.className = "status-light on";
|
|
814
|
+
txt.textContent = `Connected · ${state.tasks.length} tasks`;
|
|
815
|
+
} catch (e) {
|
|
816
|
+
dot.className = "status-light off";
|
|
817
|
+
txt.textContent = "Failed to load";
|
|
818
|
+
toast(`Load failed: ${e.message}`, true);
|
|
819
|
+
}
|
|
820
|
+
renderSidebar();
|
|
821
|
+
renderMain();
|
|
822
|
+
renderDetail();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
init();
|
|
826
|
+
</script>
|
|
827
|
+
</body>
|
|
828
|
+
</html>
|