@heyramzi/tasks 0.0.0-stage → 0.4.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +285 -2
  3. package/package.json +98 -3
  4. package/src/ambient.d.ts +6 -0
  5. package/src/board-order.ts +137 -0
  6. package/src/dates.ts +31 -0
  7. package/src/define-statuses.ts +32 -0
  8. package/src/demo.svelte +394 -0
  9. package/src/icons/board-icon.svelte +11 -0
  10. package/src/icons/chevron-down.svg +1 -0
  11. package/src/icons/chevron-right.svg +1 -0
  12. package/src/icons/clock.svg +1 -0
  13. package/src/icons/close.svg +1 -0
  14. package/src/icons/gantt-icon.svelte +11 -0
  15. package/src/icons/list-icon.svelte +16 -0
  16. package/src/icons/milestone-flag.svg +1 -0
  17. package/src/icons/play.svg +1 -0
  18. package/src/icons/spinner.svg +1 -0
  19. package/src/inbox/icons/call.svg +1 -0
  20. package/src/inbox/icons/email.svg +1 -0
  21. package/src/inbox/icons/form.svg +1 -0
  22. package/src/inbox/icons/slack.svg +1 -0
  23. package/src/inbox/inbox-card.svelte +279 -0
  24. package/src/inbox/inbox-draft.svelte +84 -0
  25. package/src/inbox/inbox-logic.ts +56 -0
  26. package/src/inbox/inbox-statuses.ts +50 -0
  27. package/src/inbox/inbox-trace.svelte +44 -0
  28. package/src/inbox/inbox-types.ts +108 -0
  29. package/src/inbox/inbox.svelte +203 -0
  30. package/src/inbox/index.ts +23 -0
  31. package/src/index.ts +78 -0
  32. package/src/kanban-board.svelte +560 -0
  33. package/src/kanban-column.svelte +117 -0
  34. package/src/kanban-drag.ts +38 -0
  35. package/src/kanban-types.ts +65 -0
  36. package/src/lib/cn.ts +11 -0
  37. package/src/presets/sales.ts +16 -0
  38. package/src/reorder.ts +20 -0
  39. package/src/status-badge.svelte +57 -0
  40. package/src/status-dot.svelte +228 -0
  41. package/src/status-icon.svelte +30 -0
  42. package/src/status-types.ts +42 -0
  43. package/src/task-gantt.svelte +845 -0
  44. package/src/task-list.svelte +463 -0
  45. package/src/task-types.ts +43 -0
  46. package/src/task-view-switch.svelte +118 -0
  47. package/src/time/duration.ts +69 -0
  48. package/src/time/time-chip.svelte +62 -0
  49. package/src/time/time-dot.svelte +58 -0
  50. package/src/time/time-ring.svelte +74 -0
  51. package/src/time/time-tracker.svelte +567 -0
  52. package/src/time/time-types.ts +74 -0
  53. package/src/tone.ts +54 -0
  54. package/src/use-kanban-dnd.svelte.ts +173 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramzi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,286 @@
1
- # Temporary Holding Version
1
+ # @heyramzi/tasks
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Svelte 5 task views. One array of tasks shows as a drag-and-drop board, a
4
+ grouped list or a gantt timeline, and the same column config drives all three.
5
+ Status badges, dots and icons come from one map too. Live at
6
+ [ui.heyramzi.com/tasks](https://ui.heyramzi.com/tasks).
7
+
8
+ Every view knows nothing about your data. You pass items and a few accessors,
9
+ and it hands back a change to persist. No runtime dependencies: `svelte` is the
10
+ only peer. This package was `@heyramzi/board` up to 0.2.
11
+
12
+ ```
13
+ pnpm add @heyramzi/tasks
14
+ ```
15
+
16
+ ## Board
17
+
18
+ The board owns the layout, the drag state and the drop logic. Drag and drop is
19
+ native HTML5 (`draggable`, `dragover`, `drop`), so cards can also land on
20
+ elements outside the board if you wire those up. Keyboard users move the
21
+ focused card with Alt plus Left or Right arrow. A column with
22
+ `droppable: false` is display only: it never calls `preventDefault`, so it
23
+ isn't a drop target (an Overdue lane, say).
24
+
25
+ ```svelte
26
+ <script lang="ts">
27
+ import { KanbanBoard, type KanbanMove } from "@heyramzi/tasks";
28
+
29
+ type Task = { id: string; column: string; title: string; order: string };
30
+
31
+ let tasks = $state<Task[]>([]);
32
+
33
+ const columns = [
34
+ { id: "todo", label: "To do" },
35
+ { id: "doing", label: "In progress", hue: "var(--color-info)" },
36
+ { id: "overdue", label: "Overdue", droppable: false },
37
+ { id: "done", label: "Done", hue: "var(--color-success)" },
38
+ ];
39
+
40
+ function onMove(id: string, move: KanbanMove) {
41
+ tasks = tasks.map((task) =>
42
+ task.id === id
43
+ ? { ...task, column: move.columnId, order: move.boardOrder ?? task.order }
44
+ : task,
45
+ );
46
+ }
47
+ </script>
48
+
49
+ <KanbanBoard
50
+ {columns}
51
+ items={tasks}
52
+ getId={(task) => task.id}
53
+ getColumn={(task) => task.column}
54
+ boardOrder={(task) => task.order}
55
+ {onMove}
56
+ >
57
+ {#snippet renderCard(task)}
58
+ <article class="rounded-xl border border-border bg-card p-2.5">{task.title}</article>
59
+ {/snippet}
60
+ </KanbanBoard>
61
+ ```
62
+
63
+ | Prop | Type | Notes |
64
+ | --------------- | ------------------------------------------------------- | ------------------------------------------------------------ |
65
+ | `columns` | `KanbanColumn[]` | `{ id, label, hue?, ariaLabel?, droppable? }`. `hue` tints the column while a card is over it. `droppable: false` makes it display only. |
66
+ | `items` | `T[]` | Every card. |
67
+ | `getId` | `(item: T) => string` | Stable id, used for keys and drag payloads. |
68
+ | `getColumn` | `(item: T) => string` | Which column the card belongs to. |
69
+ | `onMove` | `(id: string, move: KanbanMove) => void \| Promise<void>` | Called on drop. Awaited. |
70
+ | `renderCard` | `Snippet<[T, { columnId, index }]>` | Card markup. |
71
+ | `boardOrder` | `(item: T) => string \| null \| undefined` | Manual order key. Required for `sortKey="manual"`. |
72
+ | `sortKeys` | `KanbanSortKey<T>[]` | Extra sorts, `{ id, label, compare }`. |
73
+ | `sortKey` | `string` | Active sort. Defaults to `"manual"`. |
74
+ | `columnHeader` | `Snippet<[KanbanColumn, { count, active }]>` | Optional header. Defaults to label plus count. |
75
+ | `emptyLabel` | `string` | Shown in an empty column. |
76
+ | `dropAtTop` | `(columnId: string) => boolean` | Drops on a capped column go to the top. Defaults to "is `limit` hiding its tail". |
77
+ | `isDroppable` | `(columnId: string) => boolean` | Overrides the per-column `droppable` flag. |
78
+ | `manualTiebreak` | `(a: T, b: T) => number` | Settles two cards with the same manual key, or none. |
79
+ | `getLabel` | `(item: T) => string` | The screen-reader name of a card. Defaults to a keyboard hint. |
80
+ | `onSelect` | `(id: string) => void` | Enter or Space on a focused card. |
81
+ | `columnFooter` | `Snippet<[Column, KanbanColumnContext]>` | Under a column's cards: a "Show all" or an add button. |
82
+ | `wrapColumn` | `Snippet<[Column, Snippet<[props]>]>` | Wraps a whole column, for a context menu. Render the inner snippet once with the props you are handed. |
83
+ | `limit` | `(columnId: string) => number \| undefined` | Cards a column shows before the rest fold. `expanded` and `onExpandedChange` lift it per column. |
84
+ | `hidden` | `string[]` | Columns folded into a rail on the right. Fold and unfold animate, and a card dropped on a rail entry brings the column back. `onHiddenChange`, `hiddenLabel`, `showAllLabel` and `hiddenItem` go with it. |
85
+
86
+ `columns` can be your own type, as long as it extends `KanbanColumn`: the
87
+ header, footer and wrap snippets get it back as you passed it, so a column can
88
+ carry a status or a stage for your own markup. The header and footer context is
89
+ `{ count, active, shown, capped, hide, expand }`.
90
+
91
+ `onMove` gives `{ columnId, boardOrder?, beforeId?, afterId? }`. On a manual
92
+ drop `beforeId` and `afterId` name the cards above and below the new slot (null
93
+ at an end), for a backend that orders by neighbour. `boardOrder` is set when you
94
+ pass a `boardOrder` accessor. It's a fractional key that places the card in its
95
+ new slot, so a move writes one row instead of renumbering a column.
96
+
97
+ ## List
98
+
99
+ `TaskList` takes the board's columns as `groups` and the board's `onMove`, so a
100
+ page can switch between the two without a second code path. Rows sit under a
101
+ header per group that folds open and shut. Drag a row to another group or
102
+ another slot, or focus it and press Alt plus Up or Down to send it to the next
103
+ group. Rows slide into their new place when the order changes.
104
+
105
+ ```svelte
106
+ <TaskList
107
+ groups={columns}
108
+ items={tasks}
109
+ getId={(task) => task.id}
110
+ getGroup={(task) => task.column}
111
+ boardOrder={(task) => task.order}
112
+ {onMove}
113
+ >
114
+ {#snippet head()}
115
+ <div class="grid w-full grid-cols-[1fr_6rem] px-3.5"><span>Task</span><span>Due</span></div>
116
+ {/snippet}
117
+ {#snippet renderRow(task)}
118
+ <div class="grid h-10 grid-cols-[1fr_6rem] items-center px-2">{task.title}<span>{task.due}</span></div>
119
+ {/snippet}
120
+ </TaskList>
121
+ ```
122
+
123
+ Beyond the board's props it takes `head` (a sticky header row), `groupHeader`
124
+ (`Snippet<[group, { count, active, open, depth, hasChildren }]>`), `actions` (controls at the end of
125
+ a group header, shown on hover), `groupFooter` (an add-task row under the rows),
126
+ `collapsible` (default true), `collapsed` (group ids that start folded) and
127
+ `onCollapsedChange`. Pass `flat` to drop the card border, radius and fill so the list sits on the page (the gantt takes it too); group dividers stay. Pass `draggable={false}` for a list that only groups: rows
128
+ lose the drag handlers and the keyboard move, so links and buttons inside them
129
+ keep every key.
130
+
131
+ Pass `getParent` to nest one group under another, such as a project under its
132
+ client. It takes a group and returns the id of its parent, or nothing for a
133
+ top-level group. A child draws inside its parent's section, indented, with its
134
+ own sticky header under the parent's, and folds when the parent folds. A parent
135
+ takes no drops and its `count` adds up its children. A child whose parent id
136
+ matches no group is drawn at the top level.
137
+
138
+ ```svelte
139
+ <TaskList
140
+ groups={[
141
+ { id: "acme", label: "Acme" },
142
+ { id: "acme-site", label: "Website", parent: "acme" },
143
+ { id: "acme-audit", label: "Audit", parent: "acme" },
144
+ ]}
145
+ getParent={(group) => group.parent}
146
+ ...
147
+ />
148
+ ```
149
+
150
+ ## Gantt
151
+
152
+ `TaskGantt` draws each task as a bar from its start to its end date, both
153
+ inclusive ISO dates (`"2026-10-16"`). Drag a bar to move it, drag an edge to
154
+ resize it, and the bar glides from day to day while a tip shows the new range.
155
+ A focused bar moves a day with the arrow keys, and Shift plus an arrow moves
156
+ its end. Leave out `onChange` and the chart is read only.
157
+
158
+ ```svelte
159
+ <TaskGantt
160
+ items={tasks}
161
+ getId={(task) => task.id}
162
+ getTitle={(task) => task.title}
163
+ getStart={(task) => task.start}
164
+ getEnd={(task) => task.end}
165
+ getDependsOn={(task) => task.blockedBy}
166
+ groups={columns}
167
+ getGroup={(task) => task.column}
168
+ onChange={(id, { start, end }) => save(id, start, end)}
169
+ />
170
+ ```
171
+
172
+ | Prop | Type | Notes |
173
+ | -------------- | ----------------------------------------- | ----------------------------------------------------------- |
174
+ | `items`, `getId`, `getTitle` | | Required. |
175
+ | `getStart`, `getEnd` | `(item: T) => string \| null \| undefined` | ISO dates, inclusive. A one-day task has the same start and end. One date alone is a one-day task, none is undated. A parent with no dates draws the span of its children as an outline bar. |
176
+ | `onChange` | `(id, { start, end }) => void \| Promise<void>` | Called after a drag, a resize or a key nudge. |
177
+ | `onSelect` | `(id) => void` | A click or Enter that didn't move the bar. |
178
+ | `getHue` | `(item: T) => string \| undefined` | Bar color. Falls back to the group hue, then `--color-primary`. |
179
+ | `getProgress` | `(item: T) => number \| undefined` | 0 to 1, filled from the left. |
180
+ | `getDependsOn` | `(item: T) => string[] \| undefined` | Draws an arrow from each listed task's end to this one's start. |
181
+ | `groups`, `getGroup` | | Optional. Groups rows under a header with a span line. |
182
+ | `getParent` | `(item: T) => string \| null \| undefined` | Nests a task under another by id: indented, foldable, depth first. |
183
+ | `collapsed` | `string[]` | Parent ids that start folded. |
184
+ | `isMilestone` | `(item: T) => boolean` | Draws a flag at the end date instead of a bar. Not draggable. |
185
+ | `noDatesLabel` | `string` | Shown for a task with no dates. Default "No dates". |
186
+ | `renderLabel` | `Snippet<[T, { depth, hasChildren, open }]>` | The left column. Defaults to the title. |
187
+ | `from`, `to` | `string` | The shown range. Defaults to the tasks' span, padded, from a Monday. |
188
+ | `today` | `string` | Where the today line sits. Defaults to the viewer's today. |
189
+ | `dayWidth` | `number` | Default 32. Under 18 the header shows week starts only. |
190
+ | `rowHeight`, `labelWidth`, `labelHeading`, `locale` | | Layout and text. |
191
+
192
+ It opens scrolled to today, a third of the way in. Call `scrollToToday()` on a
193
+ `bind:this` reference to go back there.
194
+
195
+ ## View switch
196
+
197
+ `TaskViewSwitch` is the segmented control the demo uses to flip between views,
198
+ with a pill that slides to the active option. Pass `views`
199
+ (`{ id, label, icon? }[]`) and `bind:value`. `BoardIcon`, `ListIcon` and
200
+ `GanttIcon` are exported for it.
201
+
202
+ ## Time tracking
203
+
204
+ Optional. `TimeTracker` logs time against one task: a start and stop timer, quick
205
+ entry that reads `1h30`, `90m`, `1.5h` or `1:30`, a date and a note, entries
206
+ grouped by day with day totals, a per-person split and an optional estimate shown
207
+ as a ring that turns warning at 90% and destructive past 100%. It holds no data
208
+ and fetches nothing. You pass entries in and persist what it hands back.
209
+
210
+ ```svelte
211
+ <TimeTracker
212
+ entries={entries}
213
+ getId={(e) => e.id}
214
+ getMinutes={(e) => e.minutes}
215
+ getDay={(e) => e.day}
216
+ getPerson={(e) => e.person}
217
+ estimate={480}
218
+ {running}
219
+ onStart={() => (running = { startedAt: Date.now() })}
220
+ onStop={(info) => save(info.minutes, info.day)}
221
+ onLog={(input) => save(input.minutes, input.day, input.note)}
222
+ onDelete={(entry) => remove(entry.id)}
223
+ />
224
+ ```
225
+
226
+ | Prop | Type | Notes |
227
+ | --- | --- | --- |
228
+ | `entries` | `T[]` | Every entry on the task. |
229
+ | `getId`, `getMinutes`, `getDay`, `getPerson` | accessors | `getDay` returns `YYYY-MM-DD`. |
230
+ | `getPersonId`, `getNote` | accessors | Optional. The id keeps two people with one name apart. |
231
+ | `running` | `{ startedAt } \| null` | The host stores it, so it survives a reload. |
232
+ | `estimate` | `number` | Minutes budgeted. Draws the ring. |
233
+ | `onStart` | `() => void` | The host sets `running`. |
234
+ | `onStop` | `(info) => void` | `info` is `{ minutes, startedAt, stoppedAt, day }`. Clear `running` after saving. |
235
+ | `onLog` | `(input) => void \| boolean` | `input` is `{ minutes, day, note? }`. Return `false` to keep the fields. |
236
+ | `onDelete`, `canDelete` | callback | The delete button shows when `onDelete` is set and `canDelete` allows it. |
237
+ | `renderMeta` | `Snippet<[T]>` | Extra text after a row's note. |
238
+ | `flat` | `boolean` | Drops the card (border, radius, background, shadow, padding) for a host that already has a panel. The running state then shows through the dot, readout and button. |
239
+ | `locale`, `labels`, `class` | | `labels` overrides any string in `DEFAULT_TIME_LABELS`. |
240
+
241
+ Alt+S starts and stops the timer. A screen reader hears the start, one update a
242
+ minute and the stop. Import it alone from `@heyramzi/tasks/time`.
243
+
244
+ For boards and lists: `TimeChip` (`minutes`, `estimate?`, `running?`) shows the
245
+ logged total, with a pulsing dot while a timer runs and a small ring against an
246
+ estimate. `TimeRing` and `TimeDot` are the pieces under it.
247
+ `parseMinutes`, `formatMinutes`, `formatClock`, `budgetTone` and `localDay` are
248
+ exported helpers.
249
+
250
+ ## Also exported
251
+
252
+ - `KanbanColumn`: the column shell, if you want to build your own board.
253
+ - `createKanbanDnd`: the drag state and handlers behind the board and the list.
254
+ - `liftDragImage`: gives the browser a tilted, lifted drag snapshot.
255
+ - `boardOrderAt`, `keyBetween`: fractional order keys.
256
+ - `reorderByIds`: move an item to another item's index in a flat list.
257
+ - `addDays`, `dayNumber`, `isoFromDay`, `todayIso`: the date helpers the gantt uses.
258
+ - `StatusBadge`, `StatusDot`, `StatusIcon`: chips, dots and icons from one config.
259
+ A badge also takes its text as children, for a caller that already has the word.
260
+ - `defineStatuses`: builds a status registry from a plain map.
261
+ - `TONE_CLASS`, `TONE_COLOR`, `TONE_ICON`, `TONE_TEXT`, `insetWellClass`: the severity scale.
262
+ - `salesStatuses`: an example pipeline map.
263
+
264
+ ## Styling
265
+
266
+ The accent tone reads `--tone-accent` and falls back to `--color-primary`. Set
267
+ `--tone-accent` when your primary is a neutral and your accent is a brand color.
268
+
269
+ Classes are Tailwind, on shadcn-style tokens (`bg-card`, `border-border`,
270
+ `text-muted-foreground`, `--color-primary`). Point Tailwind at the package with
271
+ `@source` so it generates them. Motion reads the `--duration-*` and `--ease-*`
272
+ tokens from `@heyramzi/ui/animations` when they're there and falls back to the
273
+ same values when they aren't. Everything goes still under
274
+ `prefers-reduced-motion`.
275
+
276
+ ## Demo
277
+
278
+ `src/demo.svelte` puts one task list behind a Board, List and Gantt switch, with
279
+ sorting, a Days and Weeks scale and a status badge strip.
280
+
281
+ The fractional ordering in `board-order.ts` is the CC0 algorithm from
282
+ [Implementing Fractional Indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing).
283
+
284
+ ## License
285
+
286
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,101 @@
1
1
  {
2
2
  "name": "@heyramzi/tasks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.0",
4
+ "description": "Svelte 5 task views: a drag-and-drop board, a grouped list and a gantt timeline from one config, plus status badges, dots and icons.",
5
+ "keywords": [
6
+ "svelte",
7
+ "svelte5",
8
+ "kanban",
9
+ "board",
10
+ "gantt",
11
+ "timeline",
12
+ "tasks",
13
+ "drag-and-drop"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/heyramzi/ui.git",
19
+ "directory": "packages/tasks"
20
+ },
21
+ "homepage": "https://ui.heyramzi.com/tasks",
22
+ "type": "module",
23
+ "svelte": "./src/index.ts",
24
+ "types": "./src/index.ts",
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "LICENSE",
29
+ "package.json"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "svelte": "./src/index.ts",
34
+ "types": "./src/index.ts",
35
+ "default": "./src/index.ts"
36
+ },
37
+ "./kanban": {
38
+ "svelte": "./src/kanban-board.svelte",
39
+ "types": "./src/kanban-board.svelte",
40
+ "default": "./src/kanban-board.svelte"
41
+ },
42
+ "./list": {
43
+ "svelte": "./src/task-list.svelte",
44
+ "types": "./src/task-list.svelte",
45
+ "default": "./src/task-list.svelte"
46
+ },
47
+ "./gantt": {
48
+ "svelte": "./src/task-gantt.svelte",
49
+ "types": "./src/task-gantt.svelte",
50
+ "default": "./src/task-gantt.svelte"
51
+ },
52
+ "./status": {
53
+ "svelte": "./src/status-badge.svelte",
54
+ "types": "./src/status-badge.svelte",
55
+ "default": "./src/status-badge.svelte"
56
+ },
57
+ "./status-dot": {
58
+ "svelte": "./src/status-dot.svelte",
59
+ "types": "./src/status-dot.svelte",
60
+ "default": "./src/status-dot.svelte"
61
+ },
62
+ "./statuses": {
63
+ "svelte": "./src/define-statuses.ts",
64
+ "types": "./src/define-statuses.ts",
65
+ "default": "./src/define-statuses.ts"
66
+ },
67
+ "./time": {
68
+ "svelte": "./src/time/time-tracker.svelte",
69
+ "types": "./src/time/time-tracker.svelte",
70
+ "default": "./src/time/time-tracker.svelte"
71
+ },
72
+ "./inbox": {
73
+ "svelte": "./src/inbox/index.ts",
74
+ "types": "./src/inbox/index.ts",
75
+ "default": "./src/inbox/index.ts"
76
+ },
77
+ "./presets/sales": {
78
+ "svelte": "./src/presets/sales.ts",
79
+ "types": "./src/presets/sales.ts",
80
+ "default": "./src/presets/sales.ts"
81
+ },
82
+ "./demo": {
83
+ "svelte": "./src/demo.svelte",
84
+ "default": "./src/demo.svelte"
85
+ },
86
+ "./demo.svelte": "./src/demo.svelte"
87
+ },
88
+ "peerDependencies": {
89
+ "@heyramzi/ui": "*",
90
+ "svelte": "^5.0.0"
91
+ },
92
+ "devDependencies": {
93
+ "@heyramzi/ui": "7.0.0",
94
+ "@types/node": "^26.6.4",
95
+ "svelte-check": "^4.7.6",
96
+ "typescript": "~7.0.2"
97
+ },
98
+ "scripts": {
99
+ "check": "svelte-check --tsconfig ./tsconfig.json"
100
+ }
6
101
  }
@@ -0,0 +1,6 @@
1
+ // Vite's ?raw import hands back the file as a string. Declared here so svelte-check passes
2
+ // without pulling in vite/client.
3
+ declare module "*.svg?raw" {
4
+ const svg: string;
5
+ export default svg;
6
+ }
@@ -0,0 +1,137 @@
1
+ // Fractional indexing: an order key that always sorts between its neighbours,
2
+ // so a move writes one row instead of renumbering a column. The algorithm is
3
+ // the CC0 one from https://observablehq.com/@dgreensp/implementing-fractional-indexing
4
+ // (see the README credit). Kept inline so this package carries no runtime deps.
5
+ const DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
6
+ const INT_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7
+ const ZERO = DIGITS[0];
8
+
9
+ function integerLength(head: string): number {
10
+ const i = INT_DIGITS.indexOf(head);
11
+ if (i === -1) throw new Error("invalid order key head: " + head);
12
+ const half = INT_DIGITS.length / 2;
13
+ return i < half ? half - i + 1 : i - half + 2;
14
+ }
15
+
16
+ function integerPart(key: string): string {
17
+ const length = integerLength(key[0]);
18
+ if (length > key.length) throw new Error("invalid order key: " + key);
19
+ return key.slice(0, length);
20
+ }
21
+
22
+ function smallestInteger(): string {
23
+ return INT_DIGITS[0] + ZERO.repeat(INT_DIGITS.length / 2);
24
+ }
25
+
26
+ function midpoint(a: string, b: string | null): string {
27
+ if (b != null && a >= b) throw new Error(a + " >= " + b);
28
+ if (a.slice(-1) === ZERO || (b && b.slice(-1) === ZERO)) throw new Error("trailing zero");
29
+
30
+ if (b) {
31
+ let n = 0;
32
+ while ((a[n] ?? ZERO) === b[n]) n++;
33
+ if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n));
34
+ }
35
+
36
+ const digitA = a ? DIGITS.indexOf(a[0]) : 0;
37
+ const digitB = b != null ? DIGITS.indexOf(b[0]) : DIGITS.length;
38
+ if (digitB - digitA > 1) {
39
+ return DIGITS[Math.round(0.5 * (digitA + digitB))];
40
+ }
41
+ if (b && b.length > 1) return b.slice(0, 1);
42
+ return DIGITS[digitA] + midpoint(a.slice(1), null);
43
+ }
44
+
45
+ function incrementInteger(x: string): string | null {
46
+ const head = x[0];
47
+ let trailing = "";
48
+ for (let i = x.length - 1; i >= 1; i--) {
49
+ const d = DIGITS.indexOf(x[i]) + 1;
50
+ if (d === DIGITS.length) trailing = ZERO + trailing;
51
+ else return head + x.slice(1, i) + DIGITS[d] + trailing;
52
+ }
53
+
54
+ const headIndex = INT_DIGITS.indexOf(head);
55
+ if (headIndex === INT_DIGITS.length - 1) return null;
56
+ const h = INT_DIGITS[headIndex + 1];
57
+ const delta = integerLength(h) - integerLength(head);
58
+ return h + (delta > 0 ? trailing + ZERO : delta < 0 ? trailing.slice(1) : trailing);
59
+ }
60
+
61
+ function decrementInteger(x: string): string | null {
62
+ const head = x[0];
63
+ const last = DIGITS[DIGITS.length - 1];
64
+ let trailing = "";
65
+ for (let i = x.length - 1; i >= 1; i--) {
66
+ const d = DIGITS.indexOf(x[i]) - 1;
67
+ if (d === -1) trailing = last + trailing;
68
+ else return head + x.slice(1, i) + DIGITS[d] + trailing;
69
+ }
70
+
71
+ const headIndex = INT_DIGITS.indexOf(head);
72
+ if (headIndex === 0) return null;
73
+ const h = INT_DIGITS[headIndex - 1];
74
+ const delta = integerLength(h) - integerLength(head);
75
+ return h + (delta > 0 ? trailing + last : delta < 0 ? trailing.slice(1) : trailing);
76
+ }
77
+
78
+ /** An order key that sorts strictly between `a` and `b`. Null means an open end. */
79
+ export function keyBetween(a: string | null, b: string | null): string {
80
+ if (a != null && b != null && a > b) [a, b] = [b, a];
81
+
82
+ if (a == null) {
83
+ if (b == null) return INT_DIGITS[INT_DIGITS.length / 2] + ZERO;
84
+ const ib = integerPart(b);
85
+ const fb = b.slice(ib.length);
86
+ if (ib === smallestInteger()) return ib + midpoint("", fb);
87
+ if (ib < b) return ib;
88
+ const res = decrementInteger(ib);
89
+ if (res == null) throw new Error("cannot decrement any more");
90
+ return res;
91
+ }
92
+
93
+ if (b == null) {
94
+ const ia = integerPart(a);
95
+ const fa = a.slice(ia.length);
96
+ const i = incrementInteger(ia);
97
+ return i == null ? ia + midpoint(fa, null) : i;
98
+ }
99
+
100
+ const ia = integerPart(a);
101
+ const fa = a.slice(ia.length);
102
+ const ib = integerPart(b);
103
+ const fb = b.slice(ib.length);
104
+ if (ia === ib) return ia + midpoint(fa, fb);
105
+
106
+ const i = incrementInteger(ia);
107
+ if (i == null) throw new Error("cannot increment any more");
108
+ if (i < b) return i;
109
+ return ia + midpoint(fa, null);
110
+ }
111
+
112
+ /**
113
+ * The key a card should take when dropped at `index`. Rows may share a key or
114
+ * have none, so the bounds skip both and only compare against real keys.
115
+ */
116
+ export function boardOrderAt<T>(
117
+ column: readonly T[],
118
+ index: number,
119
+ order: (item: T) => string | null | undefined,
120
+ ): string {
121
+ let lower: string | null = null;
122
+ for (const item of column.slice(0, index)) {
123
+ const key = order(item);
124
+ if (key != null && (lower === null || key > lower)) lower = key;
125
+ }
126
+
127
+ let upper: string | null = null;
128
+ for (const item of column.slice(index)) {
129
+ const key = order(item);
130
+ if (key != null && (lower === null || key > lower)) {
131
+ upper = key;
132
+ break;
133
+ }
134
+ }
135
+
136
+ return keyBetween(lower, upper);
137
+ }
package/src/dates.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Dates are plain "YYYY-MM-DD" strings, counted as whole UTC days. A timeline
2
+ // only needs days, and day numbers dodge every timezone and DST edge.
3
+ const DAY_MS = 86_400_000;
4
+
5
+ /** Days since 1970-01-01 for an ISO date. Anything after the date part is ignored. */
6
+ export function dayNumber(iso: string): number {
7
+ const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
8
+ return Math.round(Date.UTC(y, m - 1, d) / DAY_MS);
9
+ }
10
+
11
+ /** The ISO date for a day number. */
12
+ export function isoFromDay(day: number): string {
13
+ return new Date(day * DAY_MS).toISOString().slice(0, 10);
14
+ }
15
+
16
+ /** Shift an ISO date by whole days. */
17
+ export function addDays(iso: string, days: number): string {
18
+ return isoFromDay(dayNumber(iso) + days);
19
+ }
20
+
21
+ /** Today in the viewer's own timezone, as an ISO date. */
22
+ export function todayIso(): string {
23
+ const now = new Date();
24
+ const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
25
+ return local.toISOString().slice(0, 10);
26
+ }
27
+
28
+ /** 0 is Sunday, as in `Date.getUTCDay`. */
29
+ export function weekday(day: number): number {
30
+ return new Date(day * DAY_MS).getUTCDay();
31
+ }
@@ -0,0 +1,32 @@
1
+ import type { StatusDef, StatusId, StatusMap, Statuses } from "./status-types.js";
2
+
3
+ /**
4
+ * Build a status registry from a plain map. The registry keeps the map's keys as
5
+ * a union, so `statuses.ids` and lookups stay typed.
6
+ *
7
+ * ```ts
8
+ * const statuses = defineStatuses({
9
+ * draft: { label: "Draft", tone: "neutral" },
10
+ * sent: { label: "Sent", tone: "info" },
11
+ * });
12
+ * ```
13
+ */
14
+ export function defineStatuses<const M extends StatusMap>(
15
+ map: M,
16
+ ): Statuses & { ids: StatusId<M>[] } {
17
+ const lookup = map as Record<string, StatusDef | undefined>;
18
+ const find = (id: string): StatusDef | undefined => lookup[id];
19
+
20
+ return {
21
+ map,
22
+ ids: Object.keys(map) as StatusId<M>[],
23
+ has: (id) => find(id) !== undefined,
24
+ get: find,
25
+ label: (id) => find(id)?.label ?? id,
26
+ tone: (id) => find(id)?.tone ?? "neutral",
27
+ icon: (id) => find(id)?.icon,
28
+ variant: (id) => find(id)?.variant ?? "open",
29
+ progress: (id) => find(id)?.progress,
30
+ color: (id) => find(id)?.color,
31
+ };
32
+ }