@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
@@ -0,0 +1,204 @@
1
+ <script lang="ts" module>
2
+ /** How wide the panel opens from `sm` (a short form) to `2xl` (a reader). Under
3
+ the `sm` breakpoint every panel is full width. */
4
+ export type SidePanelWidth = "sm" | "md" | "lg" | "xl" | "2xl";
5
+
6
+ /** One number the record is judged on. They sit in the header, in the order given. */
7
+ export type PanelStat = {
8
+ label: string;
9
+ value: string;
10
+ /** A quiet note beside the value: the rate behind a count, or what the figure is. */
11
+ sub?: string;
12
+ /** One severity vocabulary: a count that should be zero is `bad`. */
13
+ tone?: "default" | "muted" | "good" | "bad";
14
+ };
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ // =========================
19
+ // IMPORTS
20
+ // =========================
21
+ import type { Snippet } from "svelte";
22
+ import * as Sheet from "@heyramzi/ui/shadcn/sheet";
23
+ import { OpenIn, type OpenInTool } from "@heyramzi/ui/components/open-in";
24
+ import { CopyButton } from "@heyramzi/ui/components/copy";
25
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
26
+ import { cn } from "@heyramzi/utils";
27
+ import StatusBadge from "./status-badge.svelte";
28
+
29
+ // =========================
30
+ // PROPS
31
+ // =========================
32
+ type Props = {
33
+ open: boolean;
34
+ /** Names the dialog for screen readers and heads the panel. */
35
+ title: string;
36
+ /** Merged onto the title. A short path is set `font-mono`, a subject is not. */
37
+ titleClass?: string;
38
+ /** The facts the record is filed under, read out under the title. */
39
+ description?: string;
40
+ /** Status chips, left of the action row. */
41
+ badges?: { label: string; tone: Tone }[];
42
+ /** Doors out to another tool. `text` replaces the tool's name on the button. */
43
+ openIn?: { tool: OpenInTool; href: string; label: string; text?: string }[];
44
+ /** A prompt for an AI about this record, copied on click. */
45
+ handoff?: { value: () => string | Promise<string>; label: string; copiedLabel: string };
46
+ /** The record's numbers, as a strip under the badges. Same place in every panel. */
47
+ stats?: PanelStat[];
48
+ /** Anything else the record can do, drawn last in the action row. */
49
+ actions?: Snippet;
50
+ /** Pinned actions. The body scrolls above it; the caller aligns the row. */
51
+ footer?: Snippet;
52
+ children: Snippet;
53
+ width?: SidePanelWidth;
54
+ /** The body brings its own header: the title is read out but not drawn, and
55
+ the body is neither padded nor scrolled by the panel. */
56
+ bare?: boolean;
57
+ /** Fires once when the panel closes, from the close button, Escape or the overlay. */
58
+ onClose?: () => void;
59
+ };
60
+ let {
61
+ open = $bindable(),
62
+ title,
63
+ titleClass,
64
+ description,
65
+ badges = [],
66
+ openIn = [],
67
+ handoff,
68
+ stats = [],
69
+ actions,
70
+ footer,
71
+ children,
72
+ width = "sm",
73
+ bare = false,
74
+ onClose,
75
+ }: Props = $props();
76
+
77
+ // =========================
78
+ // STATE
79
+ // =========================
80
+ let panel = $state<HTMLElement | null>(null);
81
+
82
+ // =========================
83
+ // DERIVED
84
+ // =========================
85
+ let hasRow = $derived(badges.length > 0 || openIn.length > 0 || Boolean(handoff || actions));
86
+ const STAT_TONE = {
87
+ default: "",
88
+ muted: "text-muted-foreground",
89
+ good: "text-success",
90
+ bad: "text-destructive",
91
+ } as const;
92
+ const WIDTH: Record<SidePanelWidth, string> = {
93
+ sm: "sm:max-w-sm",
94
+ md: "sm:max-w-md",
95
+ lg: "sm:max-w-xl",
96
+ xl: "sm:max-w-2xl",
97
+ "2xl": "sm:max-w-3xl",
98
+ };
99
+
100
+ // =========================
101
+ // HANDLERS
102
+ // =========================
103
+ // WHY focus goes to the panel unless a text field comes first: the dialog otherwise
104
+ // opens on its first control, which draws a ring on a record nobody has touched.
105
+ // A form that opens on its field keeps the default, since that is where you type.
106
+ const FIRST_CONTROL =
107
+ 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
108
+ function onOpenAutoFocus(event: Event) {
109
+ if (!panel || panel.querySelector(FIRST_CONTROL)?.matches("input, textarea, select")) return;
110
+ event.preventDefault();
111
+ panel.focus();
112
+ }
113
+ </script>
114
+
115
+ <!-- =========================== -->
116
+ <!-- MARKUP -->
117
+ <!-- =========================== -->
118
+ <Sheet.Root
119
+ bind:open
120
+ onOpenChange={(next) => {
121
+ if (!next) onClose?.();
122
+ }}
123
+ >
124
+ <Sheet.Content
125
+ side="right"
126
+ bind:ref={panel}
127
+ tabindex={-1}
128
+ class={cn("flex w-full flex-col gap-0 p-0", WIDTH[width], bare && "overflow-hidden")}
129
+ {onOpenAutoFocus}
130
+ >
131
+ {#if bare}
132
+ <Sheet.Header class="sr-only">
133
+ <Sheet.Title>{title}</Sheet.Title>
134
+ </Sheet.Header>
135
+ {@render children()}
136
+ {:else}
137
+ <Sheet.Header class="gap-1 border-b border-border">
138
+ <!-- WHY the right padding: the close button sits over this corner and
139
+ a long title runs straight under it. -->
140
+ <Sheet.Title class={cn("pr-9 text-base leading-snug", titleClass)}>{title}</Sheet.Title>
141
+ {#if description}
142
+ <Sheet.Description>{description}</Sheet.Description>
143
+ {/if}
144
+ {#if hasRow}
145
+ <div class="flex items-center justify-between gap-3 pt-1.5">
146
+ <div class="flex min-w-0 flex-wrap items-center gap-2">
147
+ {#each badges as badge (`${badge.tone}:${badge.label}`)}
148
+ <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge>
149
+ {/each}
150
+ </div>
151
+ <div class="flex shrink-0 items-center gap-1">
152
+ {#each openIn as door (door.href)}
153
+ <OpenIn
154
+ tool={door.tool}
155
+ href={door.href}
156
+ title={door.label}
157
+ label={door.text}
158
+ variant="button"
159
+ size="sm"
160
+ />
161
+ {/each}
162
+ {#if handoff}
163
+ <CopyButton
164
+ agent
165
+ value={handoff.value}
166
+ label={handoff.label}
167
+ copiedLabel={handoff.copiedLabel}
168
+ />
169
+ {/if}
170
+ {@render actions?.()}
171
+ </div>
172
+ </div>
173
+ {/if}
174
+ {#if stats.length > 0}
175
+ <dl class="flex flex-wrap gap-x-6 gap-y-3 pt-3">
176
+ {#each stats as stat (stat.label)}
177
+ <div class="min-w-0">
178
+ <dt class="text-xs text-muted-foreground">{stat.label}</dt>
179
+ <dd class={cn("text-sm font-medium tabular-nums", STAT_TONE[stat.tone ?? "default"])}>
180
+ {stat.value}
181
+ {#if stat.sub}
182
+ <span class="text-xs font-normal text-muted-foreground">{stat.sub}</span>
183
+ {/if}
184
+ </dd>
185
+ </div>
186
+ {/each}
187
+ </dl>
188
+ {/if}
189
+ </Sheet.Header>
190
+
191
+ <!-- WHY the body pads and spaces its sections itself: a panel that lets the
192
+ caller choose drifts, one with a grey band and one with a white page. -->
193
+ <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
194
+ {@render children()}
195
+ </div>
196
+
197
+ {#if footer}
198
+ <Sheet.Footer class="mt-0 flex-row items-center justify-end border-t border-border">
199
+ {@render footer()}
200
+ </Sheet.Footer>
201
+ {/if}
202
+ {/if}
203
+ </Sheet.Content>
204
+ </Sheet.Root>
@@ -1,19 +1,29 @@
1
1
  <script lang="ts">
2
- import { cn } from "./lib/cn.js";
3
- import { TONE_CLASS } from "./tone.js";
4
- import type { Tone, Statuses } from "./status-types.js";
5
- import spinnerIcon from "./icons/spinner.svg?raw";
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "@heyramzi/utils";
5
+ import { Badge } from "@heyramzi/ui/shadcn/badge";
6
+ import { Spinner } from "@heyramzi/ui/shadcn/spinner";
7
+ import { TONE_COLOR, type Tone } from "@heyramzi/ui/lib/tone.js";
8
+ import type { Statuses } from "./status-types.js";
6
9
 
7
- type Props = {
10
+ type Props = Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> & {
8
11
  /** A registry from `defineStatuses`. Supplies the label and tone. */
9
12
  statuses?: Statuses;
10
- status: string;
13
+ /** The status id to look up in `statuses`. Leave it out when you pass `children`. */
14
+ status?: string;
11
15
  /** Override the registry label. */
12
16
  label?: string;
13
17
  /** Override the registry tone. */
14
18
  tone?: Tone;
19
+ /** Shows a dot in the tone's colour before the label. */
20
+ dot?: boolean;
15
21
  /** Shows a spinner: a background job is still working on the record. */
16
22
  busy?: boolean;
23
+ /** Capitalizes the first letter of each word. Turn off for a name or a number. */
24
+ capitalize?: boolean;
25
+ /** The chip's text, for a caller that already has the word. Wins over `label` and the registry. */
26
+ children?: Snippet;
17
27
  class?: string;
18
28
  };
19
29
 
@@ -22,23 +32,32 @@
22
32
  status,
23
33
  label,
24
34
  tone,
35
+ dot = false,
25
36
  busy = false,
37
+ capitalize = true,
38
+ children,
26
39
  class: className,
40
+ ...restProps
27
41
  }: Props = $props();
28
42
 
29
- let resolvedTone = $derived(tone ?? (statuses ? statuses.tone(status) : "neutral"));
30
- let resolvedLabel = $derived(label ?? (statuses ? statuses.label(status) : status));
43
+ let resolvedTone = $derived(tone ?? (statuses && status ? statuses.tone(status) : "neutral"));
44
+ let resolvedLabel = $derived(label ?? (statuses && status ? statuses.label(status) : (status ?? "")));
31
45
  </script>
32
46
 
33
- <span
34
- class={cn(
35
- "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
36
- TONE_CLASS[resolvedTone],
37
- className,
38
- )}
39
- >
47
+ <Badge tone={resolvedTone} class={cn(capitalize && "capitalize", className)} {...restProps}>
48
+ {#if dot}
49
+ <span
50
+ class="size-1.5 shrink-0 rounded-full"
51
+ style:background-color={TONE_COLOR[resolvedTone]}
52
+ aria-hidden="true"
53
+ ></span>
54
+ {/if}
40
55
  {#if busy}
41
- <span class="size-3 animate-spin [&>svg]:size-full" aria-hidden="true">{@html spinnerIcon}</span>
56
+ <Spinner class="size-3" aria-hidden="true" />
57
+ {/if}
58
+ {#if children}
59
+ {@render children()}
60
+ {:else}
61
+ {resolvedLabel}
42
62
  {/if}
43
- {resolvedLabel}
44
- </span>
63
+ </Badge>
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
- import { cn } from "./lib/cn.js";
3
- import { TONE_COLOR } from "./tone.js";
2
+ import { cn } from "@heyramzi/utils";
3
+ import { TONE_COLOR } from "@heyramzi/ui/lib/tone.js";
4
4
  import type { StatusDotVariant, Statuses } from "./status-types.js";
5
5
 
6
- type Props = {
6
+ interface Props {
7
7
  /** A registry from `defineStatuses`. Resolves color, variant and progress. */
8
8
  statuses?: Statuses;
9
9
  status?: string;
@@ -14,7 +14,7 @@
14
14
  size?: number;
15
15
  animate?: boolean;
16
16
  class?: string;
17
- };
17
+ }
18
18
 
19
19
  let {
20
20
  statuses,
@@ -66,17 +66,16 @@
66
66
  viewBox="0 0 14 14"
67
67
  fill="none"
68
68
  aria-hidden="true"
69
- class={cn("shrink-0", className)}
69
+ class={cn("sd", className)}
70
70
  >
71
71
  <circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
72
72
  <polyline
73
73
  points="4.5,7 6.5,9 9.5,5"
74
74
  fill="none"
75
- stroke="white"
75
+ class={cn("sd-mark", animate && "sd-check")}
76
76
  stroke-width="1.5"
77
77
  stroke-linecap="round"
78
78
  stroke-linejoin="round"
79
- class={animate ? "sd-check" : ""}
80
79
  ></polyline>
81
80
  </svg>
82
81
  {:else if resolvedVariant === "lost"}
@@ -86,10 +85,10 @@
86
85
  viewBox="0 0 14 14"
87
86
  fill="none"
88
87
  aria-hidden="true"
89
- class={cn("shrink-0", className)}
88
+ class={cn("sd", className)}
90
89
  >
91
90
  <circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
92
- <g stroke="white" stroke-width="1.5" stroke-linecap="round">
91
+ <g class="sd-mark" stroke-width="1.5" stroke-linecap="round">
93
92
  <line x1="5" y1="5" x2="9" y2="9" class={animate ? "sd-cross" : ""}></line>
94
93
  <line x1="9" y1="5" x2="5" y2="9" class={animate ? "sd-cross sd-cross-second" : ""}></line>
95
94
  </g>
@@ -101,7 +100,7 @@
101
100
  viewBox="0 0 14 14"
102
101
  fill="none"
103
102
  aria-hidden="true"
104
- class={cn("shrink-0", className)}
103
+ class={cn("sd", className)}
105
104
  >
106
105
  <circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
107
106
  </svg>
@@ -112,9 +111,16 @@
112
111
  viewBox="0 0 14 14"
113
112
  fill="none"
114
113
  aria-hidden="true"
115
- class={cn("shrink-0", className)}
114
+ class={cn("sd", className)}
116
115
  >
117
- <circle cx="7" cy="7" r="5.5" stroke={resolvedColor} stroke-width="1.5"></circle>
116
+ <circle
117
+ cx="7"
118
+ cy="7"
119
+ r="5.5"
120
+ stroke={resolvedColor}
121
+ stroke-width="1.5"
122
+ class={resolvedVariant === "dashed" ? "sd-dashed" : ""}
123
+ ></circle>
118
124
  {#if dasharray}
119
125
  <circle
120
126
  cx="7"
@@ -133,6 +139,20 @@
133
139
  {/if}
134
140
 
135
141
  <style>
142
+ .sd {
143
+ flex-shrink: 0;
144
+ }
145
+
146
+ /* The tick and cross read as cut out of the disc. A host sets the mark to its own ground. */
147
+ .sd-mark {
148
+ stroke: var(--status-dot-mark, white);
149
+ }
150
+
151
+ /* 12 dashes that close evenly: 2 * PI * 5.5 = 34.56, cut into 12 periods of 2.88. */
152
+ .sd-dashed {
153
+ stroke-dasharray: 1.3 1.58;
154
+ }
155
+
136
156
  .sd-fill {
137
157
  transform-origin: 7px 7px;
138
158
  animation: sd-fill-in var(--duration-slow, 320ms) var(--ease-smooth-out, ease-out) forwards;
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Component } from "svelte";
3
3
  import StatusDot from "./status-dot.svelte";
4
- import { cn } from "./lib/cn.js";
5
- import { TONE_ICON } from "./tone.js";
4
+ import { cn } from "@heyramzi/utils";
5
+ import { TONE_ICON } from "@heyramzi/ui/lib/tone.js";
6
6
  import type { Statuses } from "./status-types.js";
7
7
 
8
8
  type Props = {
@@ -1,10 +1,11 @@
1
1
  import type { Component } from "svelte";
2
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
2
3
 
3
- /** The one severity scale the kit ships. */
4
- export type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "destructive";
5
-
6
- /** The shape the dot can take. `progress` needs a `progress` value between 0 and 1. */
7
- export type StatusDotVariant = "open" | "progress" | "filled" | "completed" | "lost";
4
+ /**
5
+ * The shape the dot can take. `progress` needs a `progress` value between 0 and 1.
6
+ * `dashed` is an open ring cut into dashes, for an aggregate such as "All".
7
+ */
8
+ export type StatusDotVariant = "open" | "dashed" | "progress" | "filled" | "completed" | "lost";
8
9
 
9
10
  export type StatusDef = {
10
11
  label: string;