@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/src/task-list.svelte
CHANGED
|
@@ -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 "
|
|
7
|
+
import { cn } from "@heyramzi/utils";
|
|
8
8
|
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
9
|
-
import
|
|
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:
|
|
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<[
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
152
|
-
{@
|
|
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
|
-
/**
|
|
25
|
-
export type
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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>
|