@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
@@ -1,29 +1,49 @@
1
- <script lang="ts" generics="T">
1
+ <script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
2
2
  import type { Snippet } from "svelte";
3
3
  import { flip } from "svelte/animate";
4
4
  import { cubicOut } from "svelte/easing";
5
5
  import { liftDragImage } from "./kanban-drag.js";
6
6
  import { boardOrderAt } from "./board-order.js";
7
- import { cn } from "./lib/cn.js";
7
+ import { cn } from "@heyramzi/utils";
8
8
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
9
- import chevronIcon from "./icons/chevron-right.svg?raw";
9
+ import { CaretRight } from "phosphor-svelte";
10
10
  import type { KanbanColumn as KanbanColumnDef, KanbanMove, KanbanSortKey } from "./kanban-types.js";
11
11
  import type { TaskListGroupContext, TaskListRowContext } from "./task-types.js";
12
12
 
13
- type Props<T> = {
13
+ type Props<T, C extends KanbanColumnDef> = {
14
14
  /** The same shape as board columns, so one config feeds both views. */
15
- groups: KanbanColumnDef[];
15
+ groups: C[];
16
16
  items: T[];
17
17
  getId: (item: T) => string;
18
18
  /** The group id a row belongs to. */
19
19
  getGroup: (item: T) => string;
20
+ /**
21
+ * Nests a group under another by its id, e.g. a project under its client. A child draws
22
+ * inside its parent's section, indented, and folds with it. Return nothing for a top-level group.
23
+ */
24
+ getParent?: (group: C) => string | null | undefined;
20
25
  /** Called on drop with what to persist. Same contract as the board. */
21
26
  onMove: (id: string, move: KanbanMove) => void | Promise<void>;
22
27
  renderRow: Snippet<[T, TaskListRowContext]>;
23
28
  /** Optional sticky header row above every group, e.g. column labels. */
24
29
  head?: Snippet;
25
- groupHeader?: Snippet<[KanbanColumnDef, TaskListGroupContext]>;
30
+ groupHeader?: Snippet<[C, TaskListGroupContext]>;
31
+ /** Controls at the end of a group header, shown on hover, focus or a coarse pointer. */
32
+ actions?: Snippet<[C, TaskListGroupContext]>;
33
+ /** Rendered under a group's rows, e.g. an add-task row. Folds with the group. */
34
+ groupFooter?: Snippet<[C, TaskListGroupContext]>;
26
35
  boardOrder?: (item: T) => string | null | undefined;
36
+ /** Breaks a tie in manual order: two rows with the same key, or both without one. */
37
+ manualTiebreak?: (a: T, b: T) => number;
38
+ /**
39
+ * Rows are draggable and take Alt plus Up or Down. Turn it off for a list that only groups,
40
+ * where rows hold their own links and buttons and a drop makes no sense.
41
+ */
42
+ draggable?: boolean;
43
+ /** The screen-reader name of a row. Defaults to a hint about the keyboard move. */
44
+ getLabel?: (item: T) => string;
45
+ /** Fires when a group is folded or unfolded, with every folded id. */
46
+ onCollapsedChange?: (ids: string[]) => void;
27
47
  sortKeys?: KanbanSortKey<T>[];
28
48
  sortKey?: string;
29
49
  /** Groups can be folded from their header. Defaults to true. */
@@ -32,6 +52,8 @@
32
52
  collapsed?: string[];
33
53
  emptyLabel?: string;
34
54
  isDroppable?: (groupId: string) => boolean;
55
+ /** Drops the card chrome (border, radius, fill) so the list sits flat on the page. Group dividers stay. */
56
+ flat?: boolean;
35
57
  class?: string;
36
58
  };
37
59
 
@@ -40,19 +62,27 @@
40
62
  items,
41
63
  getId,
42
64
  getGroup,
65
+ getParent,
43
66
  onMove,
44
67
  renderRow,
45
68
  head,
46
69
  groupHeader,
70
+ actions,
71
+ groupFooter,
47
72
  boardOrder,
73
+ manualTiebreak,
74
+ draggable = true,
75
+ getLabel,
76
+ onCollapsedChange,
48
77
  sortKeys = [],
49
78
  sortKey = "manual",
50
79
  collapsible = true,
51
80
  collapsed = [],
52
81
  emptyLabel = "Nothing here yet",
53
82
  isDroppable,
83
+ flat = false,
54
84
  class: className,
55
- }: Props<T> = $props();
85
+ }: Props<T, C> = $props();
56
86
 
57
87
  // Rows past this index enter together, so a long list never waits on its stagger.
58
88
  const STAGGER_CAP = 8;
@@ -62,13 +92,14 @@
62
92
 
63
93
  function compare(a: T, b: T): number {
64
94
  if (sortKey === "manual") {
65
- if (!boardOrder) return 0;
95
+ const tie = manualTiebreak?.(a, b) ?? 0;
96
+ if (!boardOrder) return tie;
66
97
  const ao = boardOrder(a);
67
98
  const bo = boardOrder(b);
68
- if (ao == null && bo == null) return 0;
99
+ if (ao == null && bo == null) return tie;
69
100
  if (ao == null) return 1;
70
101
  if (bo == null) return -1;
71
- return ao < bo ? -1 : ao > bo ? 1 : 0;
102
+ return ao < bo ? -1 : ao > bo ? 1 : tie;
72
103
  }
73
104
  const key = sortKeys.find((entry) => entry.id === sortKey);
74
105
  return key ? key.compare(a, b) : 0;
@@ -78,7 +109,34 @@
78
109
  return items.filter((item) => getGroup(item) === groupId).sort(compare);
79
110
  }
80
111
 
112
+ // A group nested under a missing parent is top-level, so no row is ever dropped from view.
113
+ let groupIds = $derived(new Set(groups.map((g) => g.id)));
114
+ let roots = $derived(
115
+ groups.filter((g) => {
116
+ const parent = getParent?.(g);
117
+ return !parent || !groupIds.has(parent);
118
+ })
119
+ );
120
+ let childrenOf = $derived.by(() => {
121
+ const map = new Map<string, C[]>();
122
+ for (const g of groups) {
123
+ const parent = getParent?.(g);
124
+ if (!parent || !groupIds.has(parent)) continue;
125
+ map.set(parent, [...(map.get(parent) ?? []), g]);
126
+ }
127
+ return map;
128
+ });
129
+
130
+ /** Rows in a group and in every group nested under it. */
131
+ function countWithin(groupId: string): number {
132
+ let n = sortedByGroup(groupId).length;
133
+ for (const child of childrenOf.get(groupId) ?? []) n += countWithin(child.id);
134
+ return n;
135
+ }
136
+
81
137
  function canDrop(groupId: string): boolean {
138
+ // A group that only holds other groups has no rows of its own to drop into.
139
+ if (childrenOf.has(groupId)) return false;
82
140
  return isDroppable?.(groupId) ?? (groups.find((g) => g.id === groupId)?.droppable ?? true);
83
141
  }
84
142
 
@@ -103,6 +161,7 @@
103
161
  if (next.has(groupId)) next.delete(groupId);
104
162
  else next.add(groupId);
105
163
  folded = next;
164
+ onCollapsedChange?.([...next]);
106
165
  }
107
166
 
108
167
  /** Alt + ArrowUp/Down sends the focused row to the end of the group above or below. */
@@ -118,11 +177,16 @@
118
177
  const target = groups[i];
119
178
  if (!canDrop(target.id)) continue;
120
179
  e.preventDefault();
121
- if (sortKey === "manual" && boardOrder) {
180
+ if (sortKey === "manual") {
122
181
  const rows = sortedByGroup(target.id).filter((entry) => getId(entry) !== id);
182
+ const last = rows.at(-1);
123
183
  void onMove(id, {
124
184
  columnId: target.id,
125
- boardOrder: boardOrderAt(rows, rows.length, (entry) => boardOrder(entry)),
185
+ ...(boardOrder
186
+ ? { boardOrder: boardOrderAt(rows, rows.length, (entry) => boardOrder(entry)) }
187
+ : {}),
188
+ beforeId: last === undefined ? null : getId(last),
189
+ afterId: null,
126
190
  });
127
191
  } else {
128
192
  void onMove(id, { columnId: target.id });
@@ -131,109 +195,145 @@
131
195
  }
132
196
  }
133
197
 
134
- let view = $derived(groups.map((group) => ({ group, rows: sortedByGroup(group.id) })));
135
198
  </script>
136
199
 
200
+ {#snippet section(group: C, depth: number)}
201
+ {@const rows = sortedByGroup(group.id)}
202
+ {@const children = childrenOf.get(group.id) ?? []}
203
+ {@const count = countWithin(group.id)}
204
+ {@const active = dnd.dragOver?.columnId === group.id}
205
+ {@const open = !folded.has(group.id)}
206
+ {@const hue = group.hue ?? "var(--color-primary)"}
207
+ {@const context = { count, active, open, depth, hasChildren: children.length > 0 }}
208
+ <section
209
+ aria-label={group.ariaLabel ?? group.label}
210
+ class={cn("tl-group group/tl", depth === 0 ? "border-b border-border last:border-b-0" : "border-t border-border/60")}
211
+ class:tl-group-active={active}
212
+ style:--tl-hue={hue}
213
+ ondragover={children.length ? undefined : (e) => dnd.onColumnDragOver(e, group.id)}
214
+ ondragleave={children.length ? undefined : dnd.onDragLeave}
215
+ ondrop={children.length ? undefined : (e) => dnd.onDrop(e, group.id)}
216
+ >
217
+ <div
218
+ class={cn("sticky z-10 flex h-10 items-center gap-2 px-3 backdrop-blur-sm", flat ? "bg-background/90" : "bg-card/90", depth === 0 && (head ? "top-9" : "top-0"))}
219
+ style:top={depth > 0 ? `${(head ? 2.25 : 0) + depth * 2.5}rem` : undefined}
220
+ style:padding-inline-start={depth > 0 ? `${0.75 + depth * 1.25}rem` : undefined}
221
+ >
222
+ {#if collapsible}
223
+ <button
224
+ type="button"
225
+ aria-expanded={open}
226
+ aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
227
+ onclick={() => toggle(group.id)}
228
+ class="-ml-1 grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,150ms)] hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
229
+ >
230
+ <span class="tl-chevron size-3.5 [&>svg]:size-full" class:tl-chevron-open={open} aria-hidden="true">
231
+ <CaretRight weight="bold" />
232
+ </span>
233
+ </button>
234
+ {/if}
235
+ {#if groupHeader}
236
+ {@render groupHeader(group, context)}
237
+ {:else}
238
+ <span class="size-2 shrink-0 rounded-full" style:background={hue}></span>
239
+ <span class="truncate text-sm font-medium text-foreground">{group.label}</span>
240
+ <span
241
+ class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
242
+ >
243
+ {count}
244
+ </span>
245
+ {/if}
246
+ {#if actions}
247
+ <div
248
+ class="tl-actions ms-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity duration-[var(--duration-quick,150ms)] focus-within:opacity-100 any-pointer-coarse:opacity-100"
249
+ >
250
+ {@render actions(group, context)}
251
+ </div>
252
+ {/if}
253
+ </div>
254
+
255
+ <div class="tl-fold" class:tl-fold-open={open}>
256
+ <div class="tl-fold-inner">
257
+ <div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
258
+ {#each rows as item, index (getId(item))}
259
+ {@const id = getId(item)}
260
+ {@const dropBefore =
261
+ dnd.showIndicatorBefore(group.id, id) &&
262
+ !dnd.isDragSource(id) &&
263
+ idAt(rows, index - 1) !== dnd.draggedId}
264
+ <!-- WHY: a row is a list item that takes focus for the Alt+arrow move; making it a button would hide the list from screen readers. -->
265
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
266
+ <div
267
+ role="listitem"
268
+ tabindex={draggable ? 0 : undefined}
269
+ aria-label={getLabel?.(item) ??
270
+ (draggable ? "Row. Press Alt plus Up or Down arrow to move between groups." : undefined)}
271
+ aria-keyshortcuts={draggable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
272
+ draggable={draggable ? "true" : undefined}
273
+ animate:flip={{ duration: 250, easing: cubicOut }}
274
+ ondragstart={draggable
275
+ ? (e) => {
276
+ liftDragImage(e, -1);
277
+ dnd.onDragStart(e, id);
278
+ }
279
+ : undefined}
280
+ ondragend={draggable ? () => dnd.resetDrag() : undefined}
281
+ ondragover={draggable
282
+ ? (e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))
283
+ : undefined}
284
+ onkeydown={draggable ? (e) => onRowKeyDown(e, id) : undefined}
285
+ style:--tl-i={Math.min(index, STAGGER_CAP)}
286
+ class={cn(
287
+ "tl-row relative mx-1.5 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
288
+ draggable && "cursor-grab active:cursor-grabbing",
289
+ dropBefore && "tl-drop-before",
290
+ dnd.isDragSource(id) && "opacity-40",
291
+ )}
292
+ >
293
+ {@render renderRow(item, { groupId: group.id, index })}
294
+ </div>
295
+ {/each}
296
+
297
+ {#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
298
+ <div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
299
+ {/if}
300
+
301
+ {#if groupFooter}
302
+ {@render groupFooter(group, context)}
303
+ {/if}
304
+
305
+ {#if rows.length === 0 && children.length === 0 && !active}
306
+ <p class="mx-1.5 rounded-lg border border-dashed border-border px-3 py-3 text-xs text-muted-foreground">
307
+ {emptyLabel}
308
+ </p>
309
+ {/if}
310
+ </div>
311
+
312
+ {#each children as child (child.id)}
313
+ {@render section(child, depth + 1)}
314
+ {/each}
315
+ </div>
316
+ </div>
317
+ </section>
318
+ {/snippet}
319
+
137
320
  <div
138
321
  class={cn(
139
- "task-list flex min-h-0 flex-1 flex-col overflow-y-auto rounded-xl border border-border bg-card text-card-foreground",
322
+ "task-list flex min-h-0 flex-1 flex-col overflow-y-auto",
323
+ flat ? "bg-background" : "rounded-xl border border-border bg-card text-card-foreground",
140
324
  className,
141
325
  )}
142
326
  >
143
327
  {#if head}
144
328
  <div
145
- class="sticky top-0 z-20 flex h-9 shrink-0 items-center border-b border-border bg-card/90 text-xs font-medium text-muted-foreground backdrop-blur-sm"
329
+ class={cn("sticky top-0 z-20 flex h-9 shrink-0 items-center border-b border-border text-xs font-medium text-muted-foreground backdrop-blur-sm", flat ? "bg-background/90" : "bg-card/90")}
146
330
  >
147
331
  {@render head()}
148
332
  </div>
149
333
  {/if}
150
334
 
151
- {#each view as { group, rows } (group.id)}
152
- {@const active = dnd.dragOver?.columnId === group.id}
153
- {@const open = !folded.has(group.id)}
154
- {@const hue = group.hue ?? "var(--color-primary)"}
155
- <section
156
- aria-label={group.ariaLabel ?? group.label}
157
- class="tl-group border-b border-border last:border-b-0"
158
- class:tl-group-active={active}
159
- style:--tl-hue={hue}
160
- ondragover={(e) => dnd.onColumnDragOver(e, group.id)}
161
- ondragleave={dnd.onDragLeave}
162
- ondrop={(e) => dnd.onDrop(e, group.id)}
163
- >
164
- <div class={cn("sticky z-10 flex h-10 items-center gap-2 bg-card/90 px-3 backdrop-blur-sm", head ? "top-9" : "top-0")}>
165
- {#if collapsible}
166
- <button
167
- type="button"
168
- aria-expanded={open}
169
- aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
170
- onclick={() => toggle(group.id)}
171
- class="-ml-1 grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,150ms)] hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
172
- >
173
- <span class="tl-chevron size-3.5 [&>svg]:size-full" class:tl-chevron-open={open} aria-hidden="true">
174
- {@html chevronIcon}
175
- </span>
176
- </button>
177
- {/if}
178
- {#if groupHeader}
179
- {@render groupHeader(group, { count: rows.length, active, open })}
180
- {:else}
181
- <span class="size-2 shrink-0 rounded-full" style:background={hue}></span>
182
- <span class="truncate text-sm font-medium text-foreground">{group.label}</span>
183
- <span
184
- class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
185
- >
186
- {rows.length}
187
- </span>
188
- {/if}
189
- </div>
190
-
191
- <div class="tl-fold" class:tl-fold-open={open}>
192
- <div class="tl-fold-inner">
193
- <div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
194
- {#each rows as item, index (getId(item))}
195
- {@const id = getId(item)}
196
- {@const dropBefore =
197
- dnd.showIndicatorBefore(group.id, id) &&
198
- !dnd.isDragSource(id) &&
199
- idAt(rows, index - 1) !== dnd.draggedId}
200
- <div
201
- role="listitem"
202
- tabindex="0"
203
- aria-label="Row. Press Alt plus Up or Down arrow to move between groups."
204
- draggable="true"
205
- animate:flip={{ duration: 250, easing: cubicOut }}
206
- ondragstart={(e) => {
207
- liftDragImage(e, -1);
208
- dnd.onDragStart(e, id);
209
- }}
210
- ondragend={() => dnd.resetDrag()}
211
- ondragover={(e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))}
212
- onkeydown={(e) => onRowKeyDown(e, id)}
213
- style:--tl-i={Math.min(index, STAGGER_CAP)}
214
- class={cn(
215
- "tl-row relative mx-1.5 cursor-grab rounded-lg outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring/60",
216
- dropBefore && "tl-drop-before",
217
- dnd.isDragSource(id) && "opacity-40",
218
- )}
219
- >
220
- {@render renderRow(item, { groupId: group.id, index })}
221
- </div>
222
- {/each}
223
-
224
- {#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
225
- <div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
226
- {/if}
227
-
228
- {#if rows.length === 0 && !active}
229
- <p class="mx-1.5 rounded-lg border border-dashed border-border px-3 py-3 text-xs text-muted-foreground">
230
- {emptyLabel}
231
- </p>
232
- {/if}
233
- </div>
234
- </div>
235
- </div>
236
- </section>
335
+ {#each roots as group (group.id)}
336
+ {@render section(group, 0)}
237
337
  {/each}
238
338
  </div>
239
339
 
@@ -242,6 +342,11 @@
242
342
  transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
243
343
  }
244
344
 
345
+ .tl-group:hover :global(.tl-actions),
346
+ .tl-group :global(.tl-actions:focus-within) {
347
+ opacity: 1;
348
+ }
349
+
245
350
  .tl-group-active {
246
351
  background: color-mix(in oklab, var(--tl-hue) 6%, transparent);
247
352
  }
package/src/task-types.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { Component } from "svelte";
2
-
3
1
  /** Second argument handed to the list row snippet. */
4
2
  export type TaskListRowContext = {
5
3
  groupId: string;
@@ -8,9 +6,13 @@ export type TaskListRowContext = {
8
6
 
9
7
  /** Second argument handed to the list group header snippet. */
10
8
  export type TaskListGroupContext = {
9
+ /** Rows in the group, and in every group nested under it. */
11
10
  count: number;
12
11
  active: boolean;
13
12
  open: boolean;
13
+ /** 0 for a top-level group. Only grows when `getParent` is set. */
14
+ depth: number;
15
+ hasChildren: boolean;
14
16
  };
15
17
 
16
18
  /** What the gantt asks the caller to persist after a bar is moved or resized. */
@@ -21,10 +23,11 @@ export type GanttChange = {
21
23
  end: string;
22
24
  };
23
25
 
24
- /** One option in the view switch. */
25
- export type TaskView = {
26
- id: string;
27
- label: string;
28
- /** Any Svelte component that takes `size` and `class`, e.g. `BoardIcon`. */
29
- icon?: Component<{ size?: number; class?: string }>;
26
+ /** Second argument handed to the gantt label snippet. */
27
+ export type GanttLabelContext = {
28
+ /** 0 for a top-level task. Only grows when `getParent` is set. */
29
+ depth: number;
30
+ hasChildren: boolean;
31
+ /** False when the task's children are folded away. */
32
+ open: boolean;
30
33
  };
@@ -0,0 +1,69 @@
1
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
2
+
3
+ /** "90", "45m", "1h", "1.5h", "1h30", "1h 30m" and "1:30" to whole minutes. Null when it isn't a duration. */
4
+ export function parseMinutes(text: string): number | null {
5
+ const value = text.trim().toLowerCase();
6
+ if (!value) return null;
7
+ if (/^\d+$/.test(value)) return Number(value);
8
+ const clock = /^(\d+):([0-5]?\d)$/.exec(value);
9
+ if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
10
+ const decimalHours = /^(\d+(?:[.,]\d+)?)\s*h$/.exec(value);
11
+ if (decimalHours?.[1]) return Math.round(Number(decimalHours[1].replace(",", ".")) * 60);
12
+ const parts = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?$/.exec(value);
13
+ if (!parts || (!parts[1] && !parts[2])) return null;
14
+ return Number(parts[1] ?? 0) * 60 + Number(parts[2] ?? 0);
15
+ }
16
+
17
+ /** 150 to "2h 30m". */
18
+ export function formatMinutes(minutes: number): string {
19
+ const whole = Math.max(0, Math.round(Number.isFinite(minutes) ? minutes : 0));
20
+ const hours = Math.floor(whole / 60);
21
+ const rest = whole % 60;
22
+ if (hours === 0) return `${rest}m`;
23
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
24
+ }
25
+
26
+ /** Milliseconds to the timer readout, "1:05:09". */
27
+ export function formatClock(ms: number): { hours: string; minutes: string; seconds: string } {
28
+ const total = Math.max(0, Math.floor(ms / 1000));
29
+ return {
30
+ hours: String(Math.floor(total / 3600)),
31
+ minutes: String(Math.floor((total % 3600) / 60)).padStart(2, "0"),
32
+ seconds: String(total % 60).padStart(2, "0"),
33
+ };
34
+ }
35
+
36
+ /** The reader's own calendar day as `YYYY-MM-DD`. */
37
+ export function localDay(date: Date = new Date()): string {
38
+ const month = String(date.getMonth() + 1).padStart(2, "0");
39
+ const day = String(date.getDate()).padStart(2, "0");
40
+ return `${date.getFullYear()}-${month}-${day}`;
41
+ }
42
+
43
+ /** "Today", "Yesterday", then "Mon, Oct 12", in the given locale. */
44
+ export function dayLabel(day: string, locale?: string): string {
45
+ const date = new Date(`${day}T12:00:00`);
46
+ const now = new Date();
47
+ now.setHours(12, 0, 0, 0);
48
+ const gap = Math.round((date.getTime() - now.getTime()) / 86_400_000);
49
+ if (gap === 0 || gap === -1) {
50
+ const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(gap, "day");
51
+ return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
52
+ }
53
+ return date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
54
+ }
55
+
56
+ /** The tone of a budget: quiet until 90%, warning to 100%, destructive past it. */
57
+ export function budgetTone(logged: number, estimate: number | null | undefined): Tone {
58
+ if (!estimate || estimate <= 0) return "neutral";
59
+ const ratio = logged / estimate;
60
+ if (ratio > 1) return "destructive";
61
+ return ratio >= 0.9 ? "warning" : "neutral";
62
+ }
63
+
64
+ /** A stable color for a person, from their name or id. */
65
+ export function personColor(key: string): string {
66
+ let hash = 0;
67
+ for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 360;
68
+ return `oklch(0.72 0.12 ${hash})`;
69
+ }
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import { cn } from "@heyramzi/utils";
3
+ import { TONE_CLASS, TONE_TEXT } from "@heyramzi/ui/lib/tone.js";
4
+ import { budgetTone, formatMinutes } from "./duration.js";
5
+ import TimeDot from "./time-dot.svelte";
6
+ import TimeRing from "./time-ring.svelte";
7
+ import { Clock } from "phosphor-svelte";
8
+
9
+ type Props = {
10
+ /** Minutes logged on the task. */
11
+ minutes: number;
12
+ /** Minutes budgeted. Draws a small ring and tints the chip as the budget runs out. */
13
+ estimate?: number;
14
+ /** A timer is running on the task. */
15
+ running?: boolean;
16
+ /** Replaces the spoken name. Defaults to a sentence built from the numbers. */
17
+ label?: string;
18
+ class?: string;
19
+ };
20
+
21
+ let { minutes, estimate, running = false, label, class: className }: Props = $props();
22
+
23
+ let tone = $derived(budgetTone(minutes, estimate));
24
+ let visible = $derived(minutes > 0 || running || !!estimate);
25
+ let spoken = $derived(
26
+ label ??
27
+ [
28
+ `${formatMinutes(minutes)} logged`,
29
+ estimate ? `of ${formatMinutes(estimate)}` : "",
30
+ running ? "timer running" : "",
31
+ ]
32
+ .filter(Boolean)
33
+ .join(", "),
34
+ );
35
+ </script>
36
+
37
+ {#if visible}
38
+ <span
39
+ role="img"
40
+ aria-label={spoken}
41
+ class={cn(
42
+ "inline-flex h-5 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-1.5 text-xs font-medium tabular-nums",
43
+ running
44
+ ? TONE_CLASS.accent
45
+ : tone === "neutral"
46
+ ? "border-transparent text-muted-foreground"
47
+ : TONE_CLASS[tone],
48
+ className,
49
+ )}
50
+ >
51
+ {#if running}
52
+ <TimeDot running size={6} />
53
+ {:else if estimate}
54
+ <TimeRing value={minutes} max={estimate} size={12} stroke={6} />
55
+ {:else}
56
+ <span class="size-3 shrink-0 [&>svg]:size-full" aria-hidden="true"><Clock /></span>
57
+ {/if}
58
+ <span aria-hidden="true">
59
+ {formatMinutes(minutes)}{#if estimate}<span class={cn("font-normal", tone === "neutral" ? "text-muted-foreground/70" : TONE_TEXT[tone])}> / {formatMinutes(estimate)}</span>{/if}
60
+ </span>
61
+ </span>
62
+ {/if}
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import { cn } from "@heyramzi/utils";
3
+
4
+ type Props = {
5
+ /** Pulses while true. The dot takes its color from the text around it. */
6
+ running?: boolean;
7
+ size?: number;
8
+ class?: string;
9
+ };
10
+
11
+ let { running = false, size = 8, class: className }: Props = $props();
12
+ </script>
13
+
14
+ <span
15
+ class={cn("time-dot", running && "is-running", className)}
16
+ style:--time-dot-size="{size}px"
17
+ aria-hidden="true"
18
+ ></span>
19
+
20
+ <style>
21
+ .time-dot {
22
+ position: relative;
23
+ display: inline-block;
24
+ flex: none;
25
+ width: var(--time-dot-size);
26
+ height: var(--time-dot-size);
27
+ border-radius: 9999px;
28
+ background: currentColor;
29
+ }
30
+
31
+ .time-dot.is-running::after {
32
+ content: "";
33
+ position: absolute;
34
+ inset: 0;
35
+ border-radius: inherit;
36
+ background: currentColor;
37
+ animation: time-dot-pulse var(--time-pulse-duration, 2000ms) var(--ease-out, ease-out) infinite;
38
+ }
39
+
40
+ @keyframes time-dot-pulse {
41
+ from {
42
+ transform: scale(1);
43
+ opacity: 0.45;
44
+ }
45
+ to {
46
+ transform: scale(2.8);
47
+ opacity: 0;
48
+ }
49
+ }
50
+
51
+ @media (prefers-reduced-motion: reduce) {
52
+ .time-dot.is-running::after {
53
+ animation: none;
54
+ transform: scale(1.9);
55
+ opacity: 0.25;
56
+ }
57
+ }
58
+ </style>