@heyramzi/tasks 0.3.0 → 0.5.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/README.md +138 -18
- package/package.json +34 -1
- package/src/ambient.d.ts +3 -6
- package/src/demo.svelte +109 -19
- package/src/inbox/inbox-card.svelte +280 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +48 -0
- package/src/inbox/inbox-trace.svelte +45 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +39 -7
- package/src/kanban-board.svelte +421 -104
- package/src/kanban-column.svelte +1 -1
- package/src/kanban-types.ts +18 -1
- package/src/side-panel.svelte +204 -0
- package/src/status-badge.svelte +37 -18
- package/src/status-dot.svelte +32 -12
- package/src/status-icon.svelte +2 -2
- package/src/status-types.ts +6 -5
- package/src/task-gantt.svelte +208 -36
- package/src/task-list.svelte +206 -101
- package/src/task-types.ts +11 -8
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +564 -0
- package/src/time/time-types.ts +74 -0
- package/src/use-kanban-dnd.svelte.ts +6 -1
- package/src/icons/board-icon.svelte +0 -9
- package/src/icons/chevron-right.svg +0 -1
- package/src/icons/gantt-icon.svelte +0 -11
- package/src/icons/list-icon.svelte +0 -16
- package/src/icons/spinner.svg +0 -1
- package/src/lib/cn.ts +0 -11
- package/src/task-view-switch.svelte +0 -118
- package/src/tone.ts +0 -51
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@ Status badges, dots and icons come from one map too. Live at
|
|
|
6
6
|
[ui.heyramzi.com/tasks](https://ui.heyramzi.com/tasks).
|
|
7
7
|
|
|
8
8
|
Every view knows nothing about your data. You pass items and a few accessors,
|
|
9
|
-
and it hands back a change to persist.
|
|
10
|
-
|
|
9
|
+
and it hands back a change to persist. It builds on `@heyramzi/ui` and
|
|
10
|
+
`phosphor-svelte`, which are peers, plus `svelte`. This package was
|
|
11
|
+
`@heyramzi/board` up to 0.2.
|
|
11
12
|
|
|
12
13
|
```
|
|
13
|
-
pnpm add @heyramzi/tasks
|
|
14
|
+
pnpm add @heyramzi/tasks @heyramzi/ui phosphor-svelte
|
|
14
15
|
```
|
|
15
16
|
|
|
16
17
|
## Board
|
|
@@ -73,12 +74,26 @@ isn't a drop target (an Overdue lane, say).
|
|
|
73
74
|
| `sortKey` | `string` | Active sort. Defaults to `"manual"`. |
|
|
74
75
|
| `columnHeader` | `Snippet<[KanbanColumn, { count, active }]>` | Optional header. Defaults to label plus count. |
|
|
75
76
|
| `emptyLabel` | `string` | Shown in an empty column. |
|
|
76
|
-
| `dropAtTop` | `(columnId: string) => boolean` | Drops on a capped column go to the top.
|
|
77
|
+
| `dropAtTop` | `(columnId: string) => boolean` | Drops on a capped column go to the top. Defaults to "is `limit` hiding its tail". |
|
|
77
78
|
| `isDroppable` | `(columnId: string) => boolean` | Overrides the per-column `droppable` flag. |
|
|
78
|
-
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
| `manualTiebreak` | `(a: T, b: T) => number` | Settles two cards with the same manual key, or none. |
|
|
80
|
+
| `getLabel` | `(item: T) => string` | The screen-reader name of a card. Defaults to a keyboard hint. |
|
|
81
|
+
| `onSelect` | `(id: string) => void` | Enter or Space on a focused card. |
|
|
82
|
+
| `columnFooter` | `Snippet<[Column, KanbanColumnContext]>` | Under a column's cards: a "Show all" or an add button. |
|
|
83
|
+
| `wrapColumn` | `Snippet<[Column, Snippet<[props]>]>` | Wraps a whole column, for a context menu. Render the inner snippet once with the props you are handed. |
|
|
84
|
+
| `limit` | `(columnId: string) => number \| undefined` | Cards a column shows before the rest fold. `expanded` and `onExpandedChange` lift it per column. |
|
|
85
|
+
| `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. |
|
|
86
|
+
|
|
87
|
+
`columns` can be your own type, as long as it extends `KanbanColumn`: the
|
|
88
|
+
header, footer and wrap snippets get it back as you passed it, so a column can
|
|
89
|
+
carry a status or a stage for your own markup. The header and footer context is
|
|
90
|
+
`{ count, active, shown, capped, hide, expand }`.
|
|
91
|
+
|
|
92
|
+
`onMove` gives `{ columnId, boardOrder?, beforeId?, afterId? }`. On a manual
|
|
93
|
+
drop `beforeId` and `afterId` name the cards above and below the new slot (null
|
|
94
|
+
at an end), for a backend that orders by neighbour. `boardOrder` is set when you
|
|
95
|
+
pass a `boardOrder` accessor. It's a fractional key that places the card in its
|
|
96
|
+
new slot, so a move writes one row instead of renumbering a column.
|
|
82
97
|
|
|
83
98
|
## List
|
|
84
99
|
|
|
@@ -107,8 +122,31 @@ group. Rows slide into their new place when the order changes.
|
|
|
107
122
|
```
|
|
108
123
|
|
|
109
124
|
Beyond the board's props it takes `head` (a sticky header row), `groupHeader`
|
|
110
|
-
(`Snippet<[group, { count, active, open }]>`), `
|
|
111
|
-
`
|
|
125
|
+
(`Snippet<[group, { count, active, open, depth, hasChildren }]>`), `actions` (controls at the end of
|
|
126
|
+
a group header, shown on hover), `groupFooter` (an add-task row under the rows),
|
|
127
|
+
`collapsible` (default true), `collapsed` (group ids that start folded) and
|
|
128
|
+
`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
|
|
129
|
+
lose the drag handlers and the keyboard move, so links and buttons inside them
|
|
130
|
+
keep every key.
|
|
131
|
+
|
|
132
|
+
Pass `getParent` to nest one group under another, such as a project under its
|
|
133
|
+
client. It takes a group and returns the id of its parent, or nothing for a
|
|
134
|
+
top-level group. A child draws inside its parent's section, indented, with its
|
|
135
|
+
own sticky header under the parent's, and folds when the parent folds. A parent
|
|
136
|
+
takes no drops and its `count` adds up its children. A child whose parent id
|
|
137
|
+
matches no group is drawn at the top level.
|
|
138
|
+
|
|
139
|
+
```svelte
|
|
140
|
+
<TaskList
|
|
141
|
+
groups={[
|
|
142
|
+
{ id: "acme", label: "Acme" },
|
|
143
|
+
{ id: "acme-site", label: "Website", parent: "acme" },
|
|
144
|
+
{ id: "acme-audit", label: "Audit", parent: "acme" },
|
|
145
|
+
]}
|
|
146
|
+
getParent={(group) => group.parent}
|
|
147
|
+
...
|
|
148
|
+
/>
|
|
149
|
+
```
|
|
112
150
|
|
|
113
151
|
## Gantt
|
|
114
152
|
|
|
@@ -135,14 +173,18 @@ its end. Leave out `onChange` and the chart is read only.
|
|
|
135
173
|
| Prop | Type | Notes |
|
|
136
174
|
| -------------- | ----------------------------------------- | ----------------------------------------------------------- |
|
|
137
175
|
| `items`, `getId`, `getTitle` | | Required. |
|
|
138
|
-
| `getStart`, `getEnd` | `(item: T) => string`
|
|
176
|
+
| `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. |
|
|
139
177
|
| `onChange` | `(id, { start, end }) => void \| Promise<void>` | Called after a drag, a resize or a key nudge. |
|
|
140
178
|
| `onSelect` | `(id) => void` | A click or Enter that didn't move the bar. |
|
|
141
179
|
| `getHue` | `(item: T) => string \| undefined` | Bar color. Falls back to the group hue, then `--color-primary`. |
|
|
142
180
|
| `getProgress` | `(item: T) => number \| undefined` | 0 to 1, filled from the left. |
|
|
143
181
|
| `getDependsOn` | `(item: T) => string[] \| undefined` | Draws an arrow from each listed task's end to this one's start. |
|
|
144
182
|
| `groups`, `getGroup` | | Optional. Groups rows under a header with a span line. |
|
|
145
|
-
| `
|
|
183
|
+
| `getParent` | `(item: T) => string \| null \| undefined` | Nests a task under another by id: indented, foldable, depth first. |
|
|
184
|
+
| `collapsed` | `string[]` | Parent ids that start folded. |
|
|
185
|
+
| `isMilestone` | `(item: T) => boolean` | Draws a flag at the end date instead of a bar. Not draggable. |
|
|
186
|
+
| `noDatesLabel` | `string` | Shown for a task with no dates. Default "No dates". |
|
|
187
|
+
| `renderLabel` | `Snippet<[T, { depth, hasChildren, open }]>` | The left column. Defaults to the title. |
|
|
146
188
|
| `from`, `to` | `string` | The shown range. Defaults to the tasks' span, padded, from a Monday. |
|
|
147
189
|
| `today` | `string` | Where the today line sits. Defaults to the viewer's today. |
|
|
148
190
|
| `dayWidth` | `number` | Default 32. Under 18 the header shows week starts only. |
|
|
@@ -153,10 +195,82 @@ It opens scrolled to today, a third of the way in. Call `scrollToToday()` on a
|
|
|
153
195
|
|
|
154
196
|
## View switch
|
|
155
197
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`
|
|
198
|
+
There is no switch in this package. The demo flips between views with
|
|
199
|
+
`SegmentedFilter` from `@heyramzi/ui/components/segmented` and the Phosphor
|
|
200
|
+
icons `Kanban`, `ListBullets` and `ChartBarHorizontal`. Phosphor has no gantt
|
|
201
|
+
icon, so `ChartBarHorizontal` stands in.
|
|
202
|
+
|
|
203
|
+
## Side panel
|
|
204
|
+
|
|
205
|
+
`SidePanel` is a sheet for one record: a title, a description, status chips, doors
|
|
206
|
+
out to other tools (`openIn`), a copy-a-prompt `handoff`, a strip of `stats`, then
|
|
207
|
+
the body and an optional pinned `footer`. You pass the tools; the panel knows none.
|
|
208
|
+
Import it from `@heyramzi/tasks` or alone from `@heyramzi/tasks/side-panel`.
|
|
209
|
+
|
|
210
|
+
```svelte
|
|
211
|
+
<SidePanel
|
|
212
|
+
bind:open
|
|
213
|
+
title="Pricing page"
|
|
214
|
+
description="Ada, due Friday"
|
|
215
|
+
badges={[{ label: "Review", tone: "warning" }]}
|
|
216
|
+
openIn={[{ tool: github, href: issueUrl, label: "Open the issue on GitHub" }]}
|
|
217
|
+
stats={[{ label: "Logged", value: "6h 30m", sub: "of 8h" }]}
|
|
218
|
+
>
|
|
219
|
+
...
|
|
220
|
+
</SidePanel>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`tool` is an `OpenInTool` from `@heyramzi/ui/components/open-in`:
|
|
224
|
+
`{ name, action, icon }`. `bare` hands the whole panel to the body, and `width`
|
|
225
|
+
runs from `sm` to `2xl`.
|
|
226
|
+
|
|
227
|
+
## Time tracking
|
|
228
|
+
|
|
229
|
+
Optional. `TimeTracker` logs time against one task: a start and stop timer, quick
|
|
230
|
+
entry that reads `1h30`, `90m`, `1.5h` or `1:30`, a date and a note, entries
|
|
231
|
+
grouped by day with day totals, a per-person split and an optional estimate shown
|
|
232
|
+
as a ring that turns warning at 90% and destructive past 100%. It holds no data
|
|
233
|
+
and fetches nothing. You pass entries in and persist what it hands back.
|
|
234
|
+
|
|
235
|
+
```svelte
|
|
236
|
+
<TimeTracker
|
|
237
|
+
entries={entries}
|
|
238
|
+
getId={(e) => e.id}
|
|
239
|
+
getMinutes={(e) => e.minutes}
|
|
240
|
+
getDay={(e) => e.day}
|
|
241
|
+
getPerson={(e) => e.person}
|
|
242
|
+
estimate={480}
|
|
243
|
+
{running}
|
|
244
|
+
onStart={() => (running = { startedAt: Date.now() })}
|
|
245
|
+
onStop={(info) => save(info.minutes, info.day)}
|
|
246
|
+
onLog={(input) => save(input.minutes, input.day, input.note)}
|
|
247
|
+
onDelete={(entry) => remove(entry.id)}
|
|
248
|
+
/>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
| Prop | Type | Notes |
|
|
252
|
+
| --- | --- | --- |
|
|
253
|
+
| `entries` | `T[]` | Every entry on the task. |
|
|
254
|
+
| `getId`, `getMinutes`, `getDay`, `getPerson` | accessors | `getDay` returns `YYYY-MM-DD`. |
|
|
255
|
+
| `getPersonId`, `getNote` | accessors | Optional. The id keeps two people with one name apart. |
|
|
256
|
+
| `running` | `{ startedAt } \| null` | The host stores it, so it survives a reload. |
|
|
257
|
+
| `estimate` | `number` | Minutes budgeted. Draws the ring. |
|
|
258
|
+
| `onStart` | `() => void` | The host sets `running`. |
|
|
259
|
+
| `onStop` | `(info) => void` | `info` is `{ minutes, startedAt, stoppedAt, day }`. Clear `running` after saving. |
|
|
260
|
+
| `onLog` | `(input) => void \| boolean` | `input` is `{ minutes, day, note? }`. Return `false` to keep the fields. |
|
|
261
|
+
| `onDelete`, `canDelete` | callback | The delete button shows when `onDelete` is set and `canDelete` allows it. |
|
|
262
|
+
| `renderMeta` | `Snippet<[T]>` | Extra text after a row's note. |
|
|
263
|
+
| `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. |
|
|
264
|
+
| `locale`, `labels`, `class` | | `labels` overrides any string in `DEFAULT_TIME_LABELS`. |
|
|
265
|
+
|
|
266
|
+
Alt+S starts and stops the timer. A screen reader hears the start, one update a
|
|
267
|
+
minute and the stop. Import it alone from `@heyramzi/tasks/time`.
|
|
268
|
+
|
|
269
|
+
For boards and lists: `TimeChip` (`minutes`, `estimate?`, `running?`) shows the
|
|
270
|
+
logged total, with a pulsing dot while a timer runs and a small ring against an
|
|
271
|
+
estimate. `TimeRing` and `TimeDot` are the pieces under it.
|
|
272
|
+
`parseMinutes`, `formatMinutes`, `formatClock`, `budgetTone` and `localDay` are
|
|
273
|
+
exported helpers.
|
|
160
274
|
|
|
161
275
|
## Also exported
|
|
162
276
|
|
|
@@ -167,12 +281,18 @@ with a pill that slides to the active option. Pass `views`
|
|
|
167
281
|
- `reorderByIds`: move an item to another item's index in a flat list.
|
|
168
282
|
- `addDays`, `dayNumber`, `isoFromDay`, `todayIso`: the date helpers the gantt uses.
|
|
169
283
|
- `StatusBadge`, `StatusDot`, `StatusIcon`: chips, dots and icons from one config.
|
|
284
|
+
A badge also takes its text as children, for a caller that already has the word.
|
|
170
285
|
- `defineStatuses`: builds a status registry from a plain map.
|
|
171
|
-
- `
|
|
286
|
+
- `SidePanel`: a sheet for one record, above.
|
|
172
287
|
- `salesStatuses`: an example pipeline map.
|
|
173
288
|
|
|
174
289
|
## Styling
|
|
175
290
|
|
|
291
|
+
The severity scale (`TONE_CLASS`, `TONE_TEXT`, `TONE_ICON`, `TONE_COLOR`) lives in
|
|
292
|
+
`@heyramzi/ui/lib/tone.js`, and `insetWellClass` in `@heyramzi/ui/lib/panel.js`.
|
|
293
|
+
The accent tone reads `--tone-accent` and falls back to `--color-primary`. Set
|
|
294
|
+
`--tone-accent` when your primary is a neutral and your accent is a brand color.
|
|
295
|
+
|
|
176
296
|
Classes are Tailwind, on shadcn-style tokens (`bg-card`, `border-border`,
|
|
177
297
|
`text-muted-foreground`, `--color-primary`). Point Tailwind at the package with
|
|
178
298
|
`@source` so it generates them. Motion reads the `--duration-*` and `--ease-*`
|
|
@@ -182,7 +302,7 @@ same values when they aren't. Everything goes still under
|
|
|
182
302
|
|
|
183
303
|
## Demo
|
|
184
304
|
|
|
185
|
-
`src/demo.svelte` puts one task list behind a Board, List and Gantt
|
|
305
|
+
`src/demo.svelte` puts one task list behind a Board, List and Gantt filter, with
|
|
186
306
|
sorting, a Days and Weeks scale and a status badge strip.
|
|
187
307
|
|
|
188
308
|
The fractional ordering in `board-order.ts` is the CC0 algorithm from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -54,6 +54,31 @@
|
|
|
54
54
|
"types": "./src/status-badge.svelte",
|
|
55
55
|
"default": "./src/status-badge.svelte"
|
|
56
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
|
+
"./side-panel": {
|
|
78
|
+
"svelte": "./src/side-panel.svelte",
|
|
79
|
+
"types": "./src/side-panel.svelte",
|
|
80
|
+
"default": "./src/side-panel.svelte"
|
|
81
|
+
},
|
|
57
82
|
"./presets/sales": {
|
|
58
83
|
"svelte": "./src/presets/sales.ts",
|
|
59
84
|
"types": "./src/presets/sales.ts",
|
|
@@ -65,10 +90,18 @@
|
|
|
65
90
|
},
|
|
66
91
|
"./demo.svelte": "./src/demo.svelte"
|
|
67
92
|
},
|
|
93
|
+
"dependencies": {
|
|
94
|
+
"@heyramzi/utils": "^1.3.3"
|
|
95
|
+
},
|
|
68
96
|
"peerDependencies": {
|
|
97
|
+
"@heyramzi/ui": "*",
|
|
98
|
+
"phosphor-svelte": "^3.1.0",
|
|
69
99
|
"svelte": "^5.0.0"
|
|
70
100
|
},
|
|
71
101
|
"devDependencies": {
|
|
102
|
+
"@heyramzi/ui": "8.0.0",
|
|
103
|
+
"@types/node": "^26.6.4",
|
|
104
|
+
"phosphor-svelte": "^3.1.0",
|
|
72
105
|
"svelte-check": "^4.7.6",
|
|
73
106
|
"typescript": "~7.0.2"
|
|
74
107
|
},
|
package/src/ambient.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
//
|
|
2
|
-
// without pulling in vite/client.
|
|
3
|
-
declare module "*.
|
|
4
|
-
const svg: string;
|
|
5
|
-
export default svg;
|
|
6
|
-
}
|
|
1
|
+
// A kit component pulls its stylesheet in with a side-effect import. Vite handles it at
|
|
2
|
+
// build time; this declaration lets svelte-check accept it without pulling in vite/client.
|
|
3
|
+
declare module "*.css";
|
package/src/demo.svelte
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
import KanbanBoard from "./kanban-board.svelte";
|
|
3
3
|
import TaskList from "./task-list.svelte";
|
|
4
4
|
import TaskGantt from "./task-gantt.svelte";
|
|
5
|
-
import
|
|
5
|
+
import { SegmentedFilter } from "@heyramzi/ui/components/segmented";
|
|
6
|
+
import { ChartBarHorizontal, Kanban, ListBullets } from "phosphor-svelte";
|
|
6
7
|
import StatusBadge from "./status-badge.svelte";
|
|
7
8
|
import StatusDot from "./status-dot.svelte";
|
|
8
|
-
import BoardIcon from "./icons/board-icon.svelte";
|
|
9
|
-
import ListIcon from "./icons/list-icon.svelte";
|
|
10
|
-
import GanttIcon from "./icons/gantt-icon.svelte";
|
|
11
9
|
import { defineStatuses } from "./define-statuses.js";
|
|
12
10
|
import { addDays, dayNumber, todayIso } from "./dates.js";
|
|
13
11
|
import { salesStatuses } from "./presets/sales.js";
|
|
14
12
|
import type { KanbanMove } from "./kanban-types.js";
|
|
15
13
|
import type { GanttChange } from "./task-types.js";
|
|
14
|
+
import TimeTracker from "./time/time-tracker.svelte";
|
|
15
|
+
import TimeChip from "./time/time-chip.svelte";
|
|
16
|
+
import type { TimeLogInput, TimeStopInfo } from "./time/time-types.js";
|
|
16
17
|
|
|
17
18
|
type Priority = "low" | "medium" | "high";
|
|
18
19
|
|
|
@@ -60,12 +61,16 @@
|
|
|
60
61
|
]);
|
|
61
62
|
|
|
62
63
|
const views = [
|
|
63
|
-
{
|
|
64
|
-
{
|
|
65
|
-
{
|
|
66
|
-
];
|
|
64
|
+
{ value: "board", label: "Board", icon: Kanban },
|
|
65
|
+
{ value: "list", label: "List", icon: ListBullets },
|
|
66
|
+
{ value: "gantt", label: "Gantt", icon: ChartBarHorizontal },
|
|
67
|
+
] as const;
|
|
68
|
+
const scales = [
|
|
69
|
+
{ value: "days", label: "Days" },
|
|
70
|
+
{ value: "weeks", label: "Weeks" },
|
|
71
|
+
] as const;
|
|
67
72
|
|
|
68
|
-
let view = $state("board");
|
|
73
|
+
let view = $state<(typeof views)[number]["value"]>("board");
|
|
69
74
|
let sortKey = $state("manual");
|
|
70
75
|
let scale = $state<"days" | "weeks">("days");
|
|
71
76
|
|
|
@@ -107,6 +112,52 @@
|
|
|
107
112
|
tasks = tasks.map((task) => (task.id === id ? { ...task, ...change } : task));
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
// ---- time tracking, an option the host turns on
|
|
116
|
+
type Entry = { id: string; taskId: string; person: string; day: string; minutes: number; note?: string };
|
|
117
|
+
|
|
118
|
+
const estimates: Record<string, number> = { t3: 360, t4: 480, t5: 180, t6: 240 };
|
|
119
|
+
|
|
120
|
+
let tracking = $state(true);
|
|
121
|
+
let timeTaskId = $state("t4");
|
|
122
|
+
let nextEntry = 100;
|
|
123
|
+
let entries = $state<Entry[]>([
|
|
124
|
+
{ id: "e1", taskId: "t4", person: "Dana", day: on(-3), minutes: 150, note: "Signup and email step" },
|
|
125
|
+
{ id: "e2", taskId: "t4", person: "Ada", day: on(-2), minutes: 95, note: "Copy for the welcome screen" },
|
|
126
|
+
{ id: "e3", taskId: "t4", person: "Dana", day: on(-2), minutes: 125, note: "Progress bar and empty states" },
|
|
127
|
+
{ id: "e4", taskId: "t4", person: "Dana", day: on(-1), minutes: 80 },
|
|
128
|
+
{ id: "e5", taskId: "t3", person: "Ada", day: on(-5), minutes: 210, note: "First draft" },
|
|
129
|
+
{ id: "e6", taskId: "t3", person: "Bo", day: on(-3), minutes: 95, note: "Pricing table" },
|
|
130
|
+
{ id: "e7", taskId: "t3", person: "Ada", day: on(-1), minutes: 85, note: "Review changes" },
|
|
131
|
+
{ id: "e8", taskId: "t5", person: "Bo", day: on(0), minutes: 40 },
|
|
132
|
+
{ id: "e9", taskId: "t1", person: "Bo", day: on(-12), minutes: 60 },
|
|
133
|
+
]);
|
|
134
|
+
// The host keeps the running timer. A reload would read it back from storage.
|
|
135
|
+
let timer = $state<{ taskId: string; startedAt: number } | null>({
|
|
136
|
+
taskId: "t4",
|
|
137
|
+
startedAt: Date.now() - (12 * 60 + 41) * 1000,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let minutesByTask = $derived.by(() => {
|
|
141
|
+
const totals: Record<string, number> = {};
|
|
142
|
+
for (const entry of entries) totals[entry.taskId] = (totals[entry.taskId] ?? 0) + entry.minutes;
|
|
143
|
+
return totals;
|
|
144
|
+
});
|
|
145
|
+
let timeEntries = $derived(entries.filter((entry) => entry.taskId === timeTaskId));
|
|
146
|
+
let timeRunning = $derived(timer?.taskId === timeTaskId ? { startedAt: timer.startedAt } : null);
|
|
147
|
+
|
|
148
|
+
function addEntry(taskId: string, day: string, minutes: number, note?: string) {
|
|
149
|
+
entries = [{ id: `e${nextEntry++}`, taskId, person: "Dana", day, minutes, note }, ...entries];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onLog(input: TimeLogInput) {
|
|
153
|
+
addEntry(timeTaskId, input.day, input.minutes, input.note);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onStop(info: TimeStopInfo) {
|
|
157
|
+
if (timer) addEntry(timer.taskId, info.day, info.minutes);
|
|
158
|
+
timer = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
110
161
|
const listGrid = "grid grid-cols-[minmax(0,1fr)_7rem_5.5rem_4.5rem] items-center gap-3 max-sm:grid-cols-[minmax(0,1fr)_4.5rem]";
|
|
111
162
|
</script>
|
|
112
163
|
|
|
@@ -123,17 +174,14 @@
|
|
|
123
174
|
<div class="flex flex-col gap-6 rounded-xl border border-border bg-background p-4">
|
|
124
175
|
<div class="flex h-[600px] flex-col gap-3">
|
|
125
176
|
<div class="flex flex-wrap items-center gap-2">
|
|
126
|
-
<
|
|
127
|
-
<div class="ml-auto flex items-center gap-
|
|
177
|
+
<SegmentedFilter options={views} bind:value={view} ariaLabel="Task view" block={false} />
|
|
178
|
+
<div class="ml-auto flex items-center gap-3">
|
|
179
|
+
<label class="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
180
|
+
<input type="checkbox" bind:checked={tracking} class="size-3.5 accent-[var(--tone-accent,var(--color-primary))]" />
|
|
181
|
+
Time tracking
|
|
182
|
+
</label>
|
|
128
183
|
{#if view === "gantt"}
|
|
129
|
-
<
|
|
130
|
-
views={[
|
|
131
|
-
{ id: "days", label: "Days" },
|
|
132
|
-
{ id: "weeks", label: "Weeks" },
|
|
133
|
-
]}
|
|
134
|
-
bind:value={scale}
|
|
135
|
-
label="Timeline scale"
|
|
136
|
-
/>
|
|
184
|
+
<SegmentedFilter options={scales} bind:value={scale} ariaLabel="Timeline scale" block={false} />
|
|
137
185
|
{:else}
|
|
138
186
|
<label for="tasks-sort" class="text-xs font-medium text-muted-foreground">Sort</label>
|
|
139
187
|
<select
|
|
@@ -170,6 +218,9 @@
|
|
|
170
218
|
<div class="mt-2.5 flex items-center gap-2 text-xs">
|
|
171
219
|
{@render avatar(task.assignee)}
|
|
172
220
|
<span class={d.late ? "font-medium text-destructive" : "text-muted-foreground"}>{d.label}</span>
|
|
221
|
+
{#if tracking}
|
|
222
|
+
<TimeChip minutes={minutesByTask[task.id] ?? 0} estimate={estimates[task.id]} running={timer?.taskId === task.id} />
|
|
223
|
+
{/if}
|
|
173
224
|
<span class="ml-auto rounded-full px-1.5 py-0.5 font-medium capitalize {priorityClass[task.priority]}">
|
|
174
225
|
{task.priority}
|
|
175
226
|
</span>
|
|
@@ -206,6 +257,9 @@
|
|
|
206
257
|
<span class="truncate" class:text-muted-foreground={task.status === "done"} class:line-through={task.status === "done"}>
|
|
207
258
|
{task.title}
|
|
208
259
|
</span>
|
|
260
|
+
{#if tracking}
|
|
261
|
+
<TimeChip minutes={minutesByTask[task.id] ?? 0} estimate={estimates[task.id]} running={timer?.taskId === task.id} />
|
|
262
|
+
{/if}
|
|
209
263
|
</span>
|
|
210
264
|
<span class="flex min-w-0 items-center gap-2 text-muted-foreground max-sm:hidden">
|
|
211
265
|
{@render avatar(task.assignee)}
|
|
@@ -259,6 +313,42 @@
|
|
|
259
313
|
</p>
|
|
260
314
|
</div>
|
|
261
315
|
|
|
316
|
+
{#if tracking}
|
|
317
|
+
<section class="flex flex-col gap-3 border-t border-border pt-4">
|
|
318
|
+
<div class="flex items-center justify-between gap-3">
|
|
319
|
+
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Task time</h3>
|
|
320
|
+
<select
|
|
321
|
+
bind:value={timeTaskId}
|
|
322
|
+
aria-label="Task"
|
|
323
|
+
class="h-8 max-w-[60%] rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
|
324
|
+
>
|
|
325
|
+
{#each tasks as task (task.id)}
|
|
326
|
+
<option value={task.id}>{task.title}</option>
|
|
327
|
+
{/each}
|
|
328
|
+
</select>
|
|
329
|
+
</div>
|
|
330
|
+
<TimeTracker
|
|
331
|
+
class="mx-auto w-full max-w-xl"
|
|
332
|
+
entries={timeEntries}
|
|
333
|
+
getId={(entry) => entry.id}
|
|
334
|
+
getMinutes={(entry) => entry.minutes}
|
|
335
|
+
getDay={(entry) => entry.day}
|
|
336
|
+
getPerson={(entry) => entry.person}
|
|
337
|
+
getNote={(entry) => entry.note}
|
|
338
|
+
estimate={estimates[timeTaskId]}
|
|
339
|
+
running={timeRunning}
|
|
340
|
+
onStart={() => {
|
|
341
|
+
timer = { taskId: timeTaskId, startedAt: Date.now() };
|
|
342
|
+
}}
|
|
343
|
+
{onStop}
|
|
344
|
+
{onLog}
|
|
345
|
+
onDelete={(entry) => {
|
|
346
|
+
entries = entries.filter((row) => row.id !== entry.id);
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
</section>
|
|
350
|
+
{/if}
|
|
351
|
+
|
|
262
352
|
<section class="flex flex-col gap-2 border-t border-border pt-4">
|
|
263
353
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Status badges</h3>
|
|
264
354
|
<div class="flex flex-wrap items-center gap-2">
|