@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.
Files changed (39) hide show
  1. package/README.md +138 -18
  2. package/package.json +34 -1
  3. package/src/ambient.d.ts +3 -6
  4. package/src/demo.svelte +109 -19
  5. package/src/inbox/inbox-card.svelte +280 -0
  6. package/src/inbox/inbox-draft.svelte +84 -0
  7. package/src/inbox/inbox-logic.ts +56 -0
  8. package/src/inbox/inbox-statuses.ts +48 -0
  9. package/src/inbox/inbox-trace.svelte +45 -0
  10. package/src/inbox/inbox-types.ts +108 -0
  11. package/src/inbox/inbox.svelte +203 -0
  12. package/src/inbox/index.ts +23 -0
  13. package/src/index.ts +39 -7
  14. package/src/kanban-board.svelte +421 -104
  15. package/src/kanban-column.svelte +1 -1
  16. package/src/kanban-types.ts +18 -1
  17. package/src/side-panel.svelte +204 -0
  18. package/src/status-badge.svelte +37 -18
  19. package/src/status-dot.svelte +32 -12
  20. package/src/status-icon.svelte +2 -2
  21. package/src/status-types.ts +6 -5
  22. package/src/task-gantt.svelte +208 -36
  23. package/src/task-list.svelte +206 -101
  24. package/src/task-types.ts +11 -8
  25. package/src/time/duration.ts +69 -0
  26. package/src/time/time-chip.svelte +62 -0
  27. package/src/time/time-dot.svelte +58 -0
  28. package/src/time/time-ring.svelte +74 -0
  29. package/src/time/time-tracker.svelte +564 -0
  30. package/src/time/time-types.ts +74 -0
  31. package/src/use-kanban-dnd.svelte.ts +6 -1
  32. package/src/icons/board-icon.svelte +0 -9
  33. package/src/icons/chevron-right.svg +0 -1
  34. package/src/icons/gantt-icon.svelte +0 -11
  35. package/src/icons/list-icon.svelte +0 -16
  36. package/src/icons/spinner.svg +0 -1
  37. package/src/lib/cn.ts +0 -11
  38. package/src/task-view-switch.svelte +0 -118
  39. 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. No runtime dependencies: `svelte` is the
10
- only peer. This package was `@heyramzi/board` up to 0.2.
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
- `onMove` gives `{ columnId, boardOrder? }`. `boardOrder` is only set for manual
80
- sorting. It's a fractional key that places the card in its new slot, so a move
81
- writes one row instead of renumbering a column.
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 }]>`), `collapsible` (default true) and
111
- `collapsed` (group ids that start folded).
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` | ISO dates, inclusive. A one-day task has the same start and end. |
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
- | `renderLabel` | `Snippet<[T]>` | The left column. Defaults to the title. |
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
- `TaskViewSwitch` is the segmented control the demo uses to flip between views,
157
- with a pill that slides to the active option. Pass `views`
158
- (`{ id, label, icon? }[]`) and `bind:value`. `BoardIcon`, `ListIcon` and
159
- `GanttIcon` are exported for it.
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
- - `TONE_CLASS`, `TONE_COLOR`, `TONE_ICON`, `TONE_TEXT`, `insetWellClass`: the severity scale.
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 switch, with
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.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
- // 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
- }
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 TaskViewSwitch from "./task-view-switch.svelte";
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
- { id: "board", label: "Board", icon: BoardIcon },
64
- { id: "list", label: "List", icon: ListIcon },
65
- { id: "gantt", label: "Gantt", icon: GanttIcon },
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
- <TaskViewSwitch {views} bind:value={view} label="Task view" />
127
- <div class="ml-auto flex items-center gap-2">
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
- <TaskViewSwitch
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">