@heyramzi/ui 2.0.1 → 2.1.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/package.json +42 -17
- package/src/canvas/README.md +9 -12
- package/src/charts/dashboard/data-panel.svelte +1 -1
- package/src/components/list-page/detail-panel.svelte +1 -1
- package/src/components/list-page/filter-popover.svelte +1 -1
- package/src/components/list-page/index.ts +3 -3
- package/src/components/list-page/rail-head.svelte +1 -1
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.svelte +1 -1
- package/src/components/toolbar/index.ts +2 -0
- package/src/components/toolbar/toolbar.svelte +2 -2
- package/src/inbox/README.md +29 -0
- package/src/inbox/inbox.test.ts +171 -0
- package/src/inbox/index.ts +31 -0
- package/src/inbox/logic.ts +166 -0
- package/src/inbox/thread-inbox.svelte +363 -0
- package/src/inbox/types.ts +139 -0
- package/src/modules.test.ts +95 -0
- package/src/panel/README.md +5 -0
- package/src/panel/index.ts +2 -0
- package/src/{tasks → panel}/side-panel.svelte +1 -1
- package/src/record/README.md +3 -0
- package/src/{tasks/record → record}/field-section.svelte +1 -1
- package/src/{tasks/record → record}/info-list.svelte +9 -9
- package/src/{tasks/record → record}/next-up.svelte +4 -4
- package/src/{tasks/record → record}/project-list.svelte +5 -5
- package/src/{tasks/record → record}/record-demo.svelte +4 -4
- package/src/{tasks/record → record}/record-header.svelte +3 -3
- package/src/{tasks/record → record}/record-shell.svelte +4 -4
- package/src/{tasks/record → record}/record-stats.svelte +2 -2
- package/src/{tasks/record → record}/record-timeline.svelte +6 -6
- package/src/{tasks/record → record}/steps-rail.svelte +7 -7
- package/src/{tasks/record → record}/types.ts +2 -2
- package/src/{tasks/record → record}/where-we-are.svelte +4 -4
- package/src/status/README.md +18 -0
- package/src/status/index.ts +11 -0
- package/src/tasks/README.md +1 -1
- package/src/tasks/demo.svelte +3 -3
- package/src/tasks/detail/detail-demo.svelte +2 -2
- package/src/tasks/gantt/gantt.test.ts +1 -1
- package/src/tasks/inbox/inbox-card.svelte +2 -2
- package/src/tasks/inbox/inbox-statuses.ts +1 -1
- package/src/tasks/inbox/inbox.svelte +1 -1
- package/src/tasks/index.ts +9 -7
- package/src/tasks/presets/sales.ts +1 -1
- package/src/thread/README.md +31 -0
- package/src/thread/index.ts +12 -0
- package/src/thread/logic.ts +58 -0
- package/src/thread/thread.svelte +366 -0
- package/src/thread/thread.test.ts +62 -0
- package/src/thread/types.ts +93 -0
- /package/src/components/{list-page → section-header}/section-label.svelte +0 -0
- /package/src/components/{list-page → toolbar}/toolbar-search.svelte +0 -0
- /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
- /package/src/{tasks → panel}/side-panel-types.ts +0 -0
- /package/src/{tasks/record → record}/demo-data.ts +0 -0
- /package/src/{tasks/record → record}/fields.ts +0 -0
- /package/src/{tasks/record → record}/format.ts +0 -0
- /package/src/{tasks/record → record}/gutter.ts +0 -0
- /package/src/{tasks/record → record}/index.ts +0 -0
- /package/src/{tasks/record → record}/kinds.ts +0 -0
- /package/src/{tasks/record → record}/logic.ts +0 -0
- /package/src/{tasks/record → record}/record.test.ts +0 -0
- /package/src/{tasks → status}/define-statuses.ts +0 -0
- /package/src/{tasks → status}/status-badge.svelte +0 -0
- /package/src/{tasks → status}/status-dot.svelte +0 -0
- /package/src/{tasks → status}/status-icon.svelte +0 -0
- /package/src/{tasks → status}/status-types.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus tasks, charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -172,6 +172,31 @@
|
|
|
172
172
|
"default": "./src/agents/index.ts"
|
|
173
173
|
},
|
|
174
174
|
"./agents/demo.svelte": "./src/agents/demo.svelte",
|
|
175
|
+
"./status": {
|
|
176
|
+
"svelte": "./src/status/index.ts",
|
|
177
|
+
"types": "./src/status/index.ts",
|
|
178
|
+
"default": "./src/status/index.ts"
|
|
179
|
+
},
|
|
180
|
+
"./panel": {
|
|
181
|
+
"svelte": "./src/panel/index.ts",
|
|
182
|
+
"types": "./src/panel/index.ts",
|
|
183
|
+
"default": "./src/panel/index.ts"
|
|
184
|
+
},
|
|
185
|
+
"./record": {
|
|
186
|
+
"svelte": "./src/record/index.ts",
|
|
187
|
+
"types": "./src/record/index.ts",
|
|
188
|
+
"default": "./src/record/index.ts"
|
|
189
|
+
},
|
|
190
|
+
"./thread": {
|
|
191
|
+
"svelte": "./src/thread/index.ts",
|
|
192
|
+
"types": "./src/thread/index.ts",
|
|
193
|
+
"default": "./src/thread/index.ts"
|
|
194
|
+
},
|
|
195
|
+
"./inbox": {
|
|
196
|
+
"svelte": "./src/inbox/index.ts",
|
|
197
|
+
"types": "./src/inbox/index.ts",
|
|
198
|
+
"default": "./src/inbox/index.ts"
|
|
199
|
+
},
|
|
175
200
|
"./tasks": {
|
|
176
201
|
"svelte": "./src/tasks/index.ts",
|
|
177
202
|
"types": "./src/tasks/index.ts",
|
|
@@ -193,19 +218,19 @@
|
|
|
193
218
|
"default": "./src/tasks/task-gantt.svelte"
|
|
194
219
|
},
|
|
195
220
|
"./tasks/status": {
|
|
196
|
-
"svelte": "./src/
|
|
197
|
-
"types": "./src/
|
|
198
|
-
"default": "./src/
|
|
221
|
+
"svelte": "./src/status/status-badge.svelte",
|
|
222
|
+
"types": "./src/status/status-badge.svelte",
|
|
223
|
+
"default": "./src/status/status-badge.svelte"
|
|
199
224
|
},
|
|
200
225
|
"./tasks/status-dot": {
|
|
201
|
-
"svelte": "./src/
|
|
202
|
-
"types": "./src/
|
|
203
|
-
"default": "./src/
|
|
226
|
+
"svelte": "./src/status/status-dot.svelte",
|
|
227
|
+
"types": "./src/status/status-dot.svelte",
|
|
228
|
+
"default": "./src/status/status-dot.svelte"
|
|
204
229
|
},
|
|
205
230
|
"./tasks/statuses": {
|
|
206
|
-
"svelte": "./src/
|
|
207
|
-
"types": "./src/
|
|
208
|
-
"default": "./src/
|
|
231
|
+
"svelte": "./src/status/define-statuses.ts",
|
|
232
|
+
"types": "./src/status/define-statuses.ts",
|
|
233
|
+
"default": "./src/status/define-statuses.ts"
|
|
209
234
|
},
|
|
210
235
|
"./tasks/time": {
|
|
211
236
|
"svelte": "./src/tasks/time/time-tracker.svelte",
|
|
@@ -218,9 +243,9 @@
|
|
|
218
243
|
"default": "./src/tasks/inbox/index.ts"
|
|
219
244
|
},
|
|
220
245
|
"./tasks/side-panel": {
|
|
221
|
-
"svelte": "./src/
|
|
222
|
-
"types": "./src/
|
|
223
|
-
"default": "./src/
|
|
246
|
+
"svelte": "./src/panel/side-panel.svelte",
|
|
247
|
+
"types": "./src/panel/side-panel.svelte",
|
|
248
|
+
"default": "./src/panel/side-panel.svelte"
|
|
224
249
|
},
|
|
225
250
|
"./tasks/presets/sales": {
|
|
226
251
|
"svelte": "./src/tasks/presets/sales.ts",
|
|
@@ -234,11 +259,11 @@
|
|
|
234
259
|
},
|
|
235
260
|
"./tasks/detail-demo.svelte": "./src/tasks/detail/detail-demo.svelte",
|
|
236
261
|
"./tasks/record": {
|
|
237
|
-
"svelte": "./src/
|
|
238
|
-
"types": "./src/
|
|
239
|
-
"default": "./src/
|
|
262
|
+
"svelte": "./src/record/index.ts",
|
|
263
|
+
"types": "./src/record/index.ts",
|
|
264
|
+
"default": "./src/record/index.ts"
|
|
240
265
|
},
|
|
241
|
-
"./tasks/record-demo.svelte": "./src/
|
|
266
|
+
"./tasks/record-demo.svelte": "./src/record/record-demo.svelte",
|
|
242
267
|
"./tasks/demo": {
|
|
243
268
|
"svelte": "./src/tasks/demo.svelte",
|
|
244
269
|
"default": "./src/tasks/demo.svelte"
|
package/src/canvas/README.md
CHANGED
|
@@ -9,7 +9,7 @@ MIT. It runs on any domain and costs nothing per install.
|
|
|
9
9
|
## What's in it
|
|
10
10
|
|
|
11
11
|
- **Pen layer.** Freehand strokes (perfect-freehand), a highlighter, an eraser, plain text, and rectangle, ellipse and diamond shapes. Colours are token names, so a board drawn in light mode reads fine in dark.
|
|
12
|
-
- **Tool bar.** A floating bar with the tools,
|
|
12
|
+
- **Tool bar.** A floating bar with the tools, a colour and a brush size. Pick from 8 colours and 3 brush sizes. Keys: V, H, L, P, M, E, T, R, O, D, F. Undo and redo are yours to wire to `createCanvasDispatcher`.
|
|
13
13
|
- **Plugin API.** Register your own card types, ops and layout passes. One registry builds xyflow's `nodeTypes` for you.
|
|
14
14
|
- **Ops.** 8 core ops (`addNode`, `removeNode`, `patchNode`, `moveNode`, `setParent`, `addEdge`, `removeEdge`, `patchEdge`) as plain functions over nodes and edges. Zod checks every one.
|
|
15
15
|
- **Undo history.** `createCanvasDispatcher` wraps each edit in a transaction. A whole pen stroke is one undo step.
|
|
@@ -68,14 +68,15 @@ Drop this in `src/routes/+page.svelte`.
|
|
|
68
68
|
toolbar: "Canvas tools",
|
|
69
69
|
style: "Style",
|
|
70
70
|
tools: {
|
|
71
|
-
select: "Select", hand: "Hand",
|
|
72
|
-
eraser: "Eraser", text: "Text", rectangle: "Rectangle",
|
|
73
|
-
diamond: "Diamond", shapes: "Shapes",
|
|
71
|
+
select: "Select", hand: "Hand", connect: "Connect", frame: "Frame", pen: "Pen",
|
|
72
|
+
highlighter: "Highlighter", eraser: "Eraser", text: "Text", rectangle: "Rectangle",
|
|
73
|
+
ellipse: "Ellipse", diamond: "Diamond", shapes: "Shapes", note: "Note",
|
|
74
|
+
},
|
|
75
|
+
colors: {
|
|
76
|
+
ink: "Ink", red: "Red", blue: "Blue", green: "Green", amber: "Amber",
|
|
77
|
+
violet: "Violet", indigo: "Indigo", terra: "Terracotta",
|
|
74
78
|
},
|
|
75
|
-
colors: { ink: "Ink", red: "Red", blue: "Blue", green: "Green", amber: "Amber", violet: "Violet" },
|
|
76
79
|
sizes: ["Small", "Medium", "Large"],
|
|
77
|
-
undo: "Undo",
|
|
78
|
-
redo: "Redo",
|
|
79
80
|
} as const;
|
|
80
81
|
</script>
|
|
81
82
|
|
|
@@ -100,10 +101,6 @@ Drop this in `src/routes/+page.svelte`.
|
|
|
100
101
|
bind:color
|
|
101
102
|
bind:level
|
|
102
103
|
{labels}
|
|
103
|
-
canUndo={history.canUndo}
|
|
104
|
-
canRedo={history.canRedo}
|
|
105
|
-
onUndo={history.undo}
|
|
106
|
-
onRedo={history.redo}
|
|
107
104
|
/>
|
|
108
105
|
</SvelteFlow>
|
|
109
106
|
</div>
|
|
@@ -192,7 +189,7 @@ The pen is `PenOverlay` (or `PenLayer` inside xyflow), and it needs 2 callbacks
|
|
|
192
189
|
|
|
193
190
|
The eraser removes whole strokes, never part of one. A swipe across a big shape doesn't wipe it.
|
|
194
191
|
|
|
195
|
-
The pen has
|
|
192
|
+
The pen has 8 colours: `ink`, `red`, `blue`, `green`, `amber`, `violet`, `indigo` and `terra`. `pen.css` maps them to CSS variables with a light and a dark value. Dark mode applies under a `.dark` class on an ancestor or on `.svelte-flow`.
|
|
196
193
|
|
|
197
194
|
## Headless ops
|
|
198
195
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
6
|
import { cn } from "../../lib/utils.js";
|
|
7
7
|
import { panelHeaderClass, panelShellClass } from "../../lib/panel.js";
|
|
8
|
-
import { SectionLabel } from "../../components/
|
|
8
|
+
import { SectionLabel } from "../../components/section-header/index.js";
|
|
9
9
|
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
10
10
|
|
|
11
11
|
// =========================
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// IMPORTS
|
|
4
4
|
// =========================
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
|
-
import SectionLabel from "
|
|
6
|
+
import SectionLabel from "../section-header/section-label.svelte";
|
|
7
7
|
import { cn } from "../../lib/utils.js";
|
|
8
8
|
import { panelHeaderClass, panelShellClass } from "../../lib/panel.js";
|
|
9
9
|
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
@@ -7,7 +7,7 @@ import * as Popover from "../../shadcn/popover/index.js";
|
|
|
7
7
|
import { Funnel as FunnelIcon } from "phosphor-svelte";
|
|
8
8
|
import { cn } from "../../lib/utils.js";
|
|
9
9
|
import { Button } from "../../shadcn/button/index.js";
|
|
10
|
-
import { toolbarTriggerClass } from "
|
|
10
|
+
import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
|
|
11
11
|
import type { PhosphorIcon } from "../../lib/icon-types.js";
|
|
12
12
|
|
|
13
13
|
// phosphor-svelte v2 (resolved here) and v3 (what consumers run) differ only in
|
|
@@ -12,7 +12,7 @@ export { default as RailBody } from "./rail-body.svelte";
|
|
|
12
12
|
export { default as RailEmpty } from "./rail-empty.svelte";
|
|
13
13
|
export { default as RailHead } from "./rail-head.svelte";
|
|
14
14
|
export { default as RailRow } from "./rail-row.svelte";
|
|
15
|
-
export { default as SectionLabel } from "
|
|
15
|
+
export { default as SectionLabel } from "../section-header/section-label.svelte";
|
|
16
16
|
export { queueRailClass, queueRailWidth } from "./queue-rail.js";
|
|
17
17
|
export { default as FilterPopover } from "./filter-popover.svelte";
|
|
18
18
|
export { filterSectionClass } from "./filter-section.js";
|
|
@@ -21,8 +21,8 @@ export { default as ListPageShell } from "./list-page-shell.svelte";
|
|
|
21
21
|
export { default as MasterDetail } from "./master-detail.svelte";
|
|
22
22
|
// The segmented control isn't re-exported here. Two import paths drifted, so
|
|
23
23
|
// its one home is `@heyramzi/ui/components/segmented`.
|
|
24
|
-
export { default as ToolbarSearch } from "
|
|
25
|
-
export { toolbarTriggerClass } from "
|
|
24
|
+
export { default as ToolbarSearch } from "../toolbar/toolbar-search.svelte";
|
|
25
|
+
export { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
|
|
26
26
|
export { listTable } from "./list-table.js";
|
|
27
27
|
export type { ViewMode } from "./types.js";
|
|
28
28
|
export { getSectionHost, setSectionHost, type SectionHost } from "./section-host.js";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// IMPORTS
|
|
4
4
|
// =========================
|
|
5
5
|
import type { Snippet } from "svelte";
|
|
6
|
-
import SectionLabel from "
|
|
6
|
+
import SectionLabel from "../section-header/section-label.svelte";
|
|
7
7
|
import { cn } from "../../lib/utils.js";
|
|
8
8
|
|
|
9
9
|
// =========================
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { CaretDown, MagnifyingGlass } from "phosphor-svelte";
|
|
7
7
|
import { SegmentedFilter } from "../segmented/index.js";
|
|
8
8
|
import type { TabDef } from "../segmented/animated-tabs.svelte";
|
|
9
|
-
import { toolbarTriggerClass } from "
|
|
10
|
-
import ToolbarSearch from "
|
|
9
|
+
import { toolbarTriggerClass } from "./toolbar-trigger.js";
|
|
10
|
+
import ToolbarSearch from "./toolbar-search.svelte";
|
|
11
11
|
import { Input } from "../../shadcn/input/index.js";
|
|
12
12
|
import * as DropdownMenu from "../../shadcn/dropdown-menu/index.js";
|
|
13
13
|
import * as Popover from "../../shadcn/popover/index.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @heyramzi/ui/inbox
|
|
2
|
+
|
|
3
|
+
One threaded inbox. Entries from any source (a comment, a status change, an assignment, a mention, an agent reply, an agent's ask) group into one thread per subject, and the screen shows the thread list on the left and the selected thread on the right. The inbox never fetches. Everything leaves through callbacks.
|
|
4
|
+
|
|
5
|
+
```svelte
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { ThreadInbox, type InboxEntry } from "@heyramzi/ui/inbox";
|
|
8
|
+
|
|
9
|
+
let entries = $state<InboxEntry[]>([]);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<ThreadInbox
|
|
13
|
+
{entries}
|
|
14
|
+
unreadFirst
|
|
15
|
+
onRead={(thread, read) => mark(thread.key, read)}
|
|
16
|
+
onSnooze={({ thread, until }) => snooze(thread.key, until)}
|
|
17
|
+
onDecide={(verdict, thread) => decide(verdict)}
|
|
18
|
+
/>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Grouping.** `threadsOf(entries, { unreadFirst })` keys on `subject.type` plus `subject.id`, sorts threads by their newest entry and entries oldest first. An entry with no subject id is dropped. A malformed timestamp sorts last. Each thread carries `unread`, `latestAt` and `pending`.
|
|
22
|
+
- **Needs you.** A `proposal` entry whose `proposal` (an `InboxItem`) is still open pins to the top of its thread, drawn with `InboxCard`. The "Needs you" filter keeps threads that hold one. Verdicts come out of `onDecide`.
|
|
23
|
+
- **Filters.** By subject type, by actor name and by Needs you. `filterThreads` runs them without the component.
|
|
24
|
+
- **Keys.** `j` and `k` move, `u` marks the thread read (or unread when nothing is unread), `h` opens the snooze row (`1`, `2`, `3` pick a time), and `a`, `d`, `s`, `e` act on the pinned proposal. A key does nothing while focus is in an input, a textarea, a select or an editable element, or while a modifier is held. `inboxKeyOf(event)` is the same check.
|
|
25
|
+
- **Snooze.** `onSnooze({ thread, until })` gets a `Date`: 3 hours on, tomorrow 9:00 or next Monday 9:00 (`snoozeChoices(now)`). The host decides what snoozed means and stops passing the entries.
|
|
26
|
+
- **Read.** `onRead(thread, read)` is true when the thread had unread entries, so the host marks them all read.
|
|
27
|
+
- **Words.** `labels` is a partial of `InboxLabels`. Status and assignment lines have `{from}`, `{to}` and `{assignee}` slots. `typeLabels` names the subject types. The `avatar` and `status` snippets let the app draw its own portrait and status badge.
|
|
28
|
+
|
|
29
|
+
`@heyramzi/ui/tasks/inbox` still exports the approvals-only `Inbox`.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
NO_FILTER,
|
|
4
|
+
entryLine,
|
|
5
|
+
filterThreads,
|
|
6
|
+
inboxKeyOf,
|
|
7
|
+
resolveInboxLabels,
|
|
8
|
+
snoozeChoices,
|
|
9
|
+
threadsOf,
|
|
10
|
+
} from "./logic.js";
|
|
11
|
+
import type { InboxEntry, InboxSubject } from "./types.js";
|
|
12
|
+
import type { InboxItem } from "../tasks/inbox/inbox-types.js";
|
|
13
|
+
|
|
14
|
+
const tsk7: InboxSubject = { type: "task", id: "7", ref: "TSK-7", title: "Write the pricing page" };
|
|
15
|
+
const tsk9: InboxSubject = { type: "task", id: "9", ref: "TSK-9", title: "Fix the footer" };
|
|
16
|
+
const doc1: InboxSubject = { type: "doc", id: "1", title: "Brief" };
|
|
17
|
+
const ada = { kind: "user", name: "Ada" } as const;
|
|
18
|
+
const bot = { kind: "agent", name: "Scribe" } as const;
|
|
19
|
+
|
|
20
|
+
function entry(id: string, at: string, extra: Partial<InboxEntry> = {}): InboxEntry {
|
|
21
|
+
return { id, kind: "comment", subject: tsk7, actor: ada, at, ...extra };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const proposal = (state: InboxItem["state"] = "proposed", tier: InboxItem["tier"] = "ask"): InboxItem => ({
|
|
25
|
+
id: "p",
|
|
26
|
+
kind: "decision",
|
|
27
|
+
tier,
|
|
28
|
+
state,
|
|
29
|
+
title: "Send the quote",
|
|
30
|
+
project: { id: "x", name: "X" },
|
|
31
|
+
proposal: { summary: "Send it" },
|
|
32
|
+
confidence: { score: 0.9 },
|
|
33
|
+
trace: { source: { type: "email", label: "Mail", at: "Mon" }, evidence: [], reasoning: "" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("threadsOf", () => {
|
|
37
|
+
it("makes one thread of an agent comment and a status change on TSK-7", () => {
|
|
38
|
+
const threads = threadsOf([
|
|
39
|
+
entry("c1", "2026-10-22T09:00:00Z", { kind: "agent_response", actor: bot, body: "Drafted" }),
|
|
40
|
+
entry("s1", "2026-10-22T09:05:00Z", { kind: "status", status: { from: "todo", to: "review" } }),
|
|
41
|
+
]);
|
|
42
|
+
expect(threads).toHaveLength(1);
|
|
43
|
+
expect(threads[0].entries).toHaveLength(2);
|
|
44
|
+
expect(threads[0].unread).toBe(2);
|
|
45
|
+
expect(threads[0].subject.ref).toBe("TSK-7");
|
|
46
|
+
expect(threads[0].latestAt).toBe("2026-10-22T09:05:00Z");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("counts only unread entries", () => {
|
|
50
|
+
const [thread] = threadsOf([entry("a", "2026-10-22T09:00:00Z", { read: true }), entry("b", "2026-10-22T09:01:00Z")]);
|
|
51
|
+
expect(thread.unread).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("sorts threads by their latest entry, newest first", () => {
|
|
55
|
+
const threads = threadsOf([
|
|
56
|
+
entry("a", "2026-10-22T09:00:00Z"),
|
|
57
|
+
entry("b", "2026-10-22T11:00:00Z", { subject: tsk9 }),
|
|
58
|
+
entry("c", "2026-10-22T10:00:00Z", { subject: doc1 }),
|
|
59
|
+
]);
|
|
60
|
+
expect(threads.map((t) => t.subject.id)).toEqual(["9", "1", "7"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("puts threads with unread entries first on request", () => {
|
|
64
|
+
const entries = [
|
|
65
|
+
entry("a", "2026-10-22T09:00:00Z"),
|
|
66
|
+
entry("b", "2026-10-22T11:00:00Z", { subject: tsk9, read: true }),
|
|
67
|
+
];
|
|
68
|
+
expect(threadsOf(entries).map((t) => t.subject.id)).toEqual(["9", "7"]);
|
|
69
|
+
expect(threadsOf(entries, { unreadFirst: true }).map((t) => t.subject.id)).toEqual(["7", "9"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("drops an entry with no subject id", () => {
|
|
73
|
+
const threads = threadsOf([
|
|
74
|
+
entry("a", "2026-10-22T09:00:00Z"),
|
|
75
|
+
entry("b", "2026-10-22T09:01:00Z", { subject: undefined }),
|
|
76
|
+
entry("c", "2026-10-22T09:02:00Z", { subject: { ...tsk9, id: "" } }),
|
|
77
|
+
]);
|
|
78
|
+
expect(threads).toHaveLength(1);
|
|
79
|
+
expect(threads[0].entries.map((e) => e.id)).toEqual(["a"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("sorts a malformed timestamp last, among threads and inside one", () => {
|
|
83
|
+
const threads = threadsOf([
|
|
84
|
+
entry("bad", "yesterday", { subject: tsk9 }),
|
|
85
|
+
entry("ok", "2026-10-22T09:00:00Z"),
|
|
86
|
+
entry("late", "2026-10-22T09:30:00Z"),
|
|
87
|
+
entry("junk", "??"),
|
|
88
|
+
]);
|
|
89
|
+
expect(threads.map((t) => t.subject.id)).toEqual(["7", "9"]);
|
|
90
|
+
expect(threads[0].entries.map((e) => e.id)).toEqual(["ok", "late", "junk"]);
|
|
91
|
+
expect(threads[0].latestAt).toBe("2026-10-22T09:30:00Z");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps a pending proposal and ignores a decided one", () => {
|
|
95
|
+
const [open] = threadsOf([entry("p", "2026-10-22T09:00:00Z", { kind: "proposal", proposal: proposal() })]);
|
|
96
|
+
expect(open.pending).toHaveLength(1);
|
|
97
|
+
const [done] = threadsOf([entry("p", "2026-10-22T09:00:00Z", { kind: "proposal", proposal: proposal("approved") })]);
|
|
98
|
+
expect(done.pending).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("filterThreads", () => {
|
|
103
|
+
const threads = threadsOf([
|
|
104
|
+
entry("a", "2026-10-22T09:00:00Z", { actor: bot }),
|
|
105
|
+
entry("b", "2026-10-22T10:00:00Z", { subject: doc1 }),
|
|
106
|
+
entry("p", "2026-10-22T11:00:00Z", { subject: tsk9, kind: "proposal", proposal: proposal() }),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
it("filters by type, by who and by needs you", () => {
|
|
110
|
+
expect(filterThreads(threads, { ...NO_FILTER, type: "doc" }).map((t) => t.subject.id)).toEqual(["1"]);
|
|
111
|
+
expect(filterThreads(threads, { ...NO_FILTER, who: "Scribe" }).map((t) => t.subject.id)).toEqual(["7"]);
|
|
112
|
+
expect(filterThreads(threads, { ...NO_FILTER, needsYou: true }).map((t) => t.subject.id)).toEqual(["9"]);
|
|
113
|
+
expect(filterThreads(threads, NO_FILTER)).toHaveLength(3);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const press = (key: string, target: EventTarget | null = document.body) =>
|
|
118
|
+
inboxKeyOf({ key, metaKey: false, ctrlKey: false, altKey: false, defaultPrevented: false, target });
|
|
119
|
+
|
|
120
|
+
describe("keys", () => {
|
|
121
|
+
it("maps the nine keys", () => {
|
|
122
|
+
expect(["j", "k", "u", "h", "a", "d", "s", "e"].map((k) => press(k))).toEqual([
|
|
123
|
+
"next",
|
|
124
|
+
"previous",
|
|
125
|
+
"toggleRead",
|
|
126
|
+
"snooze",
|
|
127
|
+
"approve",
|
|
128
|
+
"decline",
|
|
129
|
+
"steer",
|
|
130
|
+
"edit",
|
|
131
|
+
]);
|
|
132
|
+
expect(press("x")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does nothing for u while typing", () => {
|
|
136
|
+
for (const tag of ["input", "textarea", "select"]) {
|
|
137
|
+
expect(press("u", document.createElement(tag))).toBeNull();
|
|
138
|
+
}
|
|
139
|
+
const editable = document.createElement("div");
|
|
140
|
+
Object.defineProperty(editable, "isContentEditable", { value: true });
|
|
141
|
+
expect(press("u", editable)).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("ignores a held modifier", () => {
|
|
145
|
+
expect(inboxKeyOf({ key: "u", metaKey: true, ctrlKey: false, altKey: false, defaultPrevented: false, target: document.body })).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("snoozeChoices", () => {
|
|
150
|
+
it("offers 3 hours on, tomorrow at 9 and the next Monday at 9", () => {
|
|
151
|
+
const wed = new Date(2026, 9, 21, 14, 30);
|
|
152
|
+
const [later, tomorrow, nextWeek] = snoozeChoices(wed);
|
|
153
|
+
expect(later.until.getTime() - wed.getTime()).toBe(3 * 60 * 60 * 1000);
|
|
154
|
+
expect([tomorrow.until.getDate(), tomorrow.until.getHours()]).toEqual([22, 9]);
|
|
155
|
+
expect([nextWeek.until.getDay(), nextWeek.until.getDate(), nextWeek.until.getHours()]).toEqual([1, 26, 9]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("moves a Monday to the following Monday", () => {
|
|
159
|
+
const mon = new Date(2026, 9, 26, 10, 0);
|
|
160
|
+
expect(snoozeChoices(mon)[2].until.getDate()).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("entryLine", () => {
|
|
165
|
+
it("fills the host's own words, French included", () => {
|
|
166
|
+
const fr = resolveInboxLabels({ statusMoved: "a déplacé ceci de {from} à {to}" });
|
|
167
|
+
expect(entryLine(entry("s", "2026-10-22T09:00:00Z", { kind: "status", status: { from: "A faire", to: "Revue" } }), fr)).toBe(
|
|
168
|
+
"a déplacé ceci de A faire à Revue",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { default as ThreadInbox } from "./thread-inbox.svelte";
|
|
2
|
+
export {
|
|
3
|
+
NO_FILTER,
|
|
4
|
+
actorNames,
|
|
5
|
+
entryLine,
|
|
6
|
+
filterThreads,
|
|
7
|
+
inboxKeyOf,
|
|
8
|
+
isPending,
|
|
9
|
+
resolveInboxLabels,
|
|
10
|
+
snoozeChoices,
|
|
11
|
+
subjectTypes,
|
|
12
|
+
threadsOf,
|
|
13
|
+
} from "./logic.js";
|
|
14
|
+
export type { SnoozeChoice, ThreadsOptions } from "./logic.js";
|
|
15
|
+
export { ALL, DEFAULT_INBOX_LABELS } from "./types.js";
|
|
16
|
+
export type {
|
|
17
|
+
InboxActor,
|
|
18
|
+
InboxActorKind,
|
|
19
|
+
InboxEntry,
|
|
20
|
+
InboxEntryKind,
|
|
21
|
+
InboxFilter,
|
|
22
|
+
InboxKey,
|
|
23
|
+
InboxLabels,
|
|
24
|
+
InboxSnooze,
|
|
25
|
+
InboxSubject,
|
|
26
|
+
InboxThread,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
|
|
29
|
+
// The approval card a pinned proposal is drawn with, and the types its `proposal` field carries.
|
|
30
|
+
export { default as InboxCard } from "../tasks/inbox/inbox-card.svelte";
|
|
31
|
+
export type { InboxItem, InboxVerdict } from "../tasks/inbox/inbox-types.js";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { isOpen, isTyping } from "../tasks/inbox/inbox-logic.js";
|
|
2
|
+
import {
|
|
3
|
+
ALL,
|
|
4
|
+
DEFAULT_INBOX_LABELS,
|
|
5
|
+
type InboxEntry,
|
|
6
|
+
type InboxFilter,
|
|
7
|
+
type InboxKey,
|
|
8
|
+
type InboxLabels,
|
|
9
|
+
type InboxSubject,
|
|
10
|
+
type InboxThread,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** A timestamp as a number, with NaN for one that doesn't parse. */
|
|
14
|
+
function ms(at: string): number {
|
|
15
|
+
return Date.parse(at);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function latestOf(thread: InboxThread): number {
|
|
19
|
+
const time = ms(thread.latestAt);
|
|
20
|
+
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ThreadsOptions = {
|
|
24
|
+
/** Threads with unread entries come before read ones. */
|
|
25
|
+
unreadFirst?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** True while a proposal entry still waits for a verdict. */
|
|
29
|
+
export function isPending(entry: InboxEntry): boolean {
|
|
30
|
+
return entry.kind === "proposal" && !!entry.proposal && isOpen(entry.proposal);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Group entries into one thread per subject and sort by the newest entry. An entry with no subject id
|
|
35
|
+
* is dropped. A malformed timestamp sorts last, among threads and inside one.
|
|
36
|
+
*/
|
|
37
|
+
export function threadsOf(entries: InboxEntry[], options: ThreadsOptions = {}): InboxThread[] {
|
|
38
|
+
type Row = { entry: InboxEntry; subject: InboxSubject; index: number; time: number };
|
|
39
|
+
const groups = new Map<string, Row[]>();
|
|
40
|
+
entries.forEach((entry, index) => {
|
|
41
|
+
const subject = entry.subject;
|
|
42
|
+
if (!subject?.id) return;
|
|
43
|
+
const key = `${subject.type}:${subject.id}`;
|
|
44
|
+
groups.set(key, [...(groups.get(key) ?? []), { entry, subject, index, time: ms(entry.at) }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const threads: InboxThread[] = [];
|
|
48
|
+
for (const [key, rows] of groups) {
|
|
49
|
+
const ordered = rows.toSorted((a, b) => {
|
|
50
|
+
const aBad = Number.isNaN(a.time);
|
|
51
|
+
const bBad = Number.isNaN(b.time);
|
|
52
|
+
if (aBad !== bBad) return aBad ? 1 : -1;
|
|
53
|
+
return (aBad ? 0 : a.time - b.time) || a.index - b.index;
|
|
54
|
+
});
|
|
55
|
+
const newest = ordered.findLast((row) => !Number.isNaN(row.time)) ?? ordered[0];
|
|
56
|
+
const list = ordered.map((row) => row.entry);
|
|
57
|
+
threads.push({
|
|
58
|
+
key,
|
|
59
|
+
// The newest entry carries the freshest title and status.
|
|
60
|
+
subject: newest.subject,
|
|
61
|
+
entries: list,
|
|
62
|
+
unread: list.filter((entry) => !entry.read).length,
|
|
63
|
+
latestAt: newest.entry.at,
|
|
64
|
+
pending: list.filter(isPending).toReversed(),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return threads
|
|
69
|
+
.map((thread, index) => ({ thread, index }))
|
|
70
|
+
.toSorted((a, b) => {
|
|
71
|
+
if (options.unreadFirst) {
|
|
72
|
+
const unread = Number(b.thread.unread > 0) - Number(a.thread.unread > 0);
|
|
73
|
+
if (unread) return unread;
|
|
74
|
+
}
|
|
75
|
+
const aTime = latestOf(a.thread);
|
|
76
|
+
const bTime = latestOf(b.thread);
|
|
77
|
+
if (aTime === bTime) return a.index - b.index;
|
|
78
|
+
return bTime - aTime;
|
|
79
|
+
})
|
|
80
|
+
.map((row) => row.thread);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const NO_FILTER: InboxFilter = { type: ALL, who: ALL, needsYou: false };
|
|
84
|
+
|
|
85
|
+
/** Keep a thread when it matches the type, holds an entry from the person or agent, and has a pending proposal if asked. */
|
|
86
|
+
export function filterThreads(threads: InboxThread[], filter: InboxFilter): InboxThread[] {
|
|
87
|
+
return threads.filter(
|
|
88
|
+
(thread) =>
|
|
89
|
+
(filter.type === ALL || thread.subject.type === filter.type) &&
|
|
90
|
+
(filter.who === ALL || thread.entries.some((entry) => entry.actor.name === filter.who)) &&
|
|
91
|
+
(!filter.needsYou || thread.pending.length > 0),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function subjectTypes(threads: InboxThread[]): string[] {
|
|
96
|
+
return [...new Set(threads.map((thread) => thread.subject.type))];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function actorNames(threads: InboxThread[]): string[] {
|
|
100
|
+
return [...new Set(threads.flatMap((thread) => thread.entries.map((entry) => entry.actor.name)))];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type KeyEventLike = Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "altKey" | "defaultPrevented" | "target">;
|
|
104
|
+
|
|
105
|
+
const KEYS: Record<string, InboxKey> = {
|
|
106
|
+
j: "next",
|
|
107
|
+
k: "previous",
|
|
108
|
+
u: "toggleRead",
|
|
109
|
+
h: "snooze",
|
|
110
|
+
a: "approve",
|
|
111
|
+
d: "decline",
|
|
112
|
+
s: "steer",
|
|
113
|
+
e: "edit",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** What a key press asks for, or null when it isn't ours: a modifier is held, or focus is in a text field. */
|
|
117
|
+
export function inboxKeyOf(event: KeyEventLike): InboxKey | null {
|
|
118
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return null;
|
|
119
|
+
if (isTyping(event.target)) return null;
|
|
120
|
+
return KEYS[event.key] ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type SnoozeChoice = { id: "later" | "tomorrow" | "nextWeek"; until: Date };
|
|
124
|
+
|
|
125
|
+
/** The three times a snooze offers: 3 hours on, tomorrow at 9, next Monday at 9. */
|
|
126
|
+
export function snoozeChoices(now: Date): SnoozeChoice[] {
|
|
127
|
+
const later = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
|
128
|
+
const tomorrow = new Date(now);
|
|
129
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
130
|
+
tomorrow.setHours(9, 0, 0, 0);
|
|
131
|
+
const nextWeek = new Date(now);
|
|
132
|
+
nextWeek.setDate(nextWeek.getDate() + (((8 - nextWeek.getDay()) % 7) || 7));
|
|
133
|
+
nextWeek.setHours(9, 0, 0, 0);
|
|
134
|
+
return [
|
|
135
|
+
{ id: "later", until: later },
|
|
136
|
+
{ id: "tomorrow", until: tomorrow },
|
|
137
|
+
{ id: "nextWeek", until: nextWeek },
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveInboxLabels(overrides?: Partial<InboxLabels>): InboxLabels {
|
|
142
|
+
return { ...DEFAULT_INBOX_LABELS, ...overrides };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One line for an entry, in the host's words. `{from}`, `{to}` and `{assignee}` are filled. */
|
|
146
|
+
export function entryLine(entry: InboxEntry, labels: InboxLabels): string {
|
|
147
|
+
const fill = (text: string) =>
|
|
148
|
+
text
|
|
149
|
+
.replaceAll("{from}", entry.status?.from ?? "")
|
|
150
|
+
.replaceAll("{to}", entry.status?.to ?? "")
|
|
151
|
+
.replaceAll("{assignee}", entry.assignee ?? "");
|
|
152
|
+
switch (entry.kind) {
|
|
153
|
+
case "status":
|
|
154
|
+
return fill(entry.status?.from ? labels.statusMoved : labels.statusSet);
|
|
155
|
+
case "assigned":
|
|
156
|
+
return fill(labels.assigned);
|
|
157
|
+
case "mention":
|
|
158
|
+
return labels.mentioned;
|
|
159
|
+
case "agent_response":
|
|
160
|
+
return labels.responded;
|
|
161
|
+
case "proposal":
|
|
162
|
+
return labels.proposed;
|
|
163
|
+
default:
|
|
164
|
+
return labels.commented;
|
|
165
|
+
}
|
|
166
|
+
}
|