@joewinke/jatui 0.19.0 → 0.21.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/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ Then import:
26
26
  | `SelectInput` | Dropdown select | `label`, `value` (bindable), `options: SelectOption[]`, `placeholder` |
27
27
  | `PhoneInput` | Intl phone (country dropdown + tel input, outputs E.164; caps national digits per country). Fires `onChange(value)` on every change. | `value` (bindable), `name`, `defaultCountry`, `required`, `onChange` |
28
28
  | `ChipInput` | Tag/chip entry with suggestions | `chips` (bindable), `suggestions: SuggestionGroup[]`, `placeholder` |
29
- | `SearchDropdown` | Searchable dropdown with grouped results, keyboard nav, avatar/initials options. `appendToBody` escapes table/overflow stacking contexts; `variant='chip'` renders a compact pill; `size='sm'` a smaller trigger; `dropup` opens upward; `clearable` shows an × when a value is set. Exposes `openDropdown()`. | `groups: SearchDropdownGroup[]`, `value`, `onChange`, `placeholder`, `variant`, `size`, `dropup`, `appendToBody`, `clearable`, `colorFn` |
29
+ | `SearchDropdown` | Searchable dropdown with grouped results, keyboard nav, avatar/initials options. `appendToBody` escapes table/overflow stacking contexts; `variant='chip'` renders a compact pill; `size='sm'` a smaller trigger; `dropup` opens upward; `clearable` shows an × when a value is set. `onSearch` turns the built-in search box into a debounced server-side search hook (fires on open/clear with `''`; internal filtering is bypassed — the consumer updates `groups`). Exposes `openDropdown()`. | `groups: SearchDropdownGroup[]`, `value`, `onChange`, `placeholder`, `variant`, `size`, `dropup`, `appendToBody`, `clearable`, `colorFn`, `onSearch`, `searchDebounceMs` |
30
30
  | `FilterDropdown` | Multi-select filter dropdown | `label`, `options: string[]`, `selected: Set<string>` (bindable) |
31
31
  | `SortDropdown` | Sort by column with direction | `options: SortOption[]`, `sortBy` (bindable), `sortDir` (bindable) |
32
32
  | `InlineEdit` | Click-to-edit text | `value` (bindable), `segments: DisplaySegment[]`, `onSave` |
@@ -57,6 +57,20 @@
57
57
  * control. For mapped lists, give each row its own `id`/`expanded`/`collapsible`
58
58
  * state (e.g. a `SvelteSet` of ids, with the id interpolating the row's own id) —
59
59
  * one action instance attaches per row element.
60
+ *
61
+ * ─── Expand on focus (expandOnFocus / onExpand) ───────────────────────────────
62
+ *
63
+ * By default a `focusin` inside a COLLAPSED region asks the consumer to expand
64
+ * it (upstream of the jat-ngdm1 fix, where tabbing into a clipped inline-edit
65
+ * textarea left it unreadable and clicking Show more blurred it). The action
66
+ * never mutates the consumer's state — it fires `onExpand` and the consumer
67
+ * sets `expanded = true`, keeping the toggle label and `aria-expanded` in sync:
68
+ *
69
+ * <div use:collapse={{ expanded, onCollapsible, onExpand: () => (expanded = true) }}>
70
+ *
71
+ * Pass `expandOnFocus: false` to opt out (e.g. read-only log dumps where
72
+ * focus-driven expansion would be surprising). The `<Collapse>` component
73
+ * wires this out of the box.
60
74
  */
61
75
  export interface CollapseOptions {
62
76
  /** Max collapsed height in px. Content taller than this collapses. Default 120. */
@@ -73,6 +87,19 @@ export interface CollapseOptions {
73
87
  * content is shorter than the threshold.
74
88
  */
75
89
  onCollapsible?: (collapsible: boolean) => void;
90
+ /**
91
+ * Auto-expand when focus moves into the collapsed region (focusin).
92
+ * Default true. Expansion goes through `onExpand` — the consumer still owns
93
+ * the `expanded` state (same contract as `onCollapsible`), so wire it:
94
+ * `use:collapse={{ expanded, onExpand: () => (expanded = true) }}`.
95
+ * With no `onExpand` there is nothing to call and focusin is a no-op.
96
+ */
97
+ expandOnFocus?: boolean;
98
+ /**
99
+ * Called when the action decides the region should expand (focusin
100
+ * auto-expand while collapsed). Set your `expanded` state to true here.
101
+ */
102
+ onExpand?: () => void;
76
103
  }
77
104
  export declare function collapse(node: HTMLElement, options?: CollapseOptions): {
78
105
  update(next?: CollapseOptions): void;
@@ -57,8 +57,22 @@
57
57
  * control. For mapped lists, give each row its own `id`/`expanded`/`collapsible`
58
58
  * state (e.g. a `SvelteSet` of ids, with the id interpolating the row's own id) —
59
59
  * one action instance attaches per row element.
60
+ *
61
+ * ─── Expand on focus (expandOnFocus / onExpand) ───────────────────────────────
62
+ *
63
+ * By default a `focusin` inside a COLLAPSED region asks the consumer to expand
64
+ * it (upstream of the jat-ngdm1 fix, where tabbing into a clipped inline-edit
65
+ * textarea left it unreadable and clicking Show more blurred it). The action
66
+ * never mutates the consumer's state — it fires `onExpand` and the consumer
67
+ * sets `expanded = true`, keeping the toggle label and `aria-expanded` in sync:
68
+ *
69
+ * <div use:collapse={{ expanded, onCollapsible, onExpand: () => (expanded = true) }}>
70
+ *
71
+ * Pass `expandOnFocus: false` to opt out (e.g. read-only log dumps where
72
+ * focus-driven expansion would be surprising). The `<Collapse>` component
73
+ * wires this out of the box.
60
74
  */
61
- const DEFAULTS = { threshold: 120, expanded: false, fadeStart: 55, epsilon: 4 };
75
+ const DEFAULTS = { threshold: 120, expanded: false, fadeStart: 55, epsilon: 4, expandOnFocus: true };
62
76
  export function collapse(node, options = {}) {
63
77
  let opts = { ...DEFAULTS, ...options };
64
78
  let collapsible = false;
@@ -98,6 +112,19 @@ export function collapse(node, options = {}) {
98
112
  cancelAnimationFrame(rafId);
99
113
  rafId = requestAnimationFrame(measure);
100
114
  }
115
+ // Focus moving into a collapsed region means the user is interacting with
116
+ // content they can't fully see (the jat-ngdm1 case: tabbing into a clipped
117
+ // inline-edit textarea, then clicking Show more blurred it — three clicks
118
+ // for one edit). Expand instead of leaving the focused child clipped.
119
+ function onFocusin() {
120
+ if (!opts.expandOnFocus)
121
+ return;
122
+ // Only when actually collapsed — already-expanded or too-short content is a no-op.
123
+ if (!collapsible || opts.expanded)
124
+ return;
125
+ opts.onExpand?.();
126
+ }
127
+ node.addEventListener('focusin', onFocusin);
101
128
  // Re-measure when the content's natural size changes (streaming, {@html}, lazy images).
102
129
  const ro = new ResizeObserver(scheduleMeasure);
103
130
  ro.observe(node);
@@ -115,6 +142,7 @@ export function collapse(node, options = {}) {
115
142
  destroy() {
116
143
  cancelAnimationFrame(rafId);
117
144
  ro.disconnect();
145
+ node.removeEventListener('focusin', onFocusin);
118
146
  },
119
147
  };
120
148
  }
@@ -1,4 +1,11 @@
1
1
  <script lang="ts">
2
+ import {
3
+ BURNDOWN_DONE,
4
+ burndownScope,
5
+ buildSeriesPoints,
6
+ type BurndownTask,
7
+ } from "./burndownSeries"
8
+
2
9
  interface OtherMilestone {
3
10
  ordinal: string // "01", "02", etc.
4
11
  name: string
@@ -23,7 +30,7 @@
23
30
  paymentDate?: string | null
24
31
  } = $props()
25
32
 
26
- const DONE = new Set(["accepted", "deployed", "closed", "completed"])
33
+ const DONE = BURNDOWN_DONE
27
34
 
28
35
  // Layout constants
29
36
  const MARGIN = { top: 16, right: 20, bottom: 32, left: 40 }
@@ -62,47 +69,14 @@
62
69
  selected = milestoneFilter
63
70
  })
64
71
 
65
- function buildPts(milestoneId: string, startDay: number, totalDays: number) {
66
- const mt = tasks.filter(
67
- (t) => t.milestone_id === milestoneId && t.status !== "dev",
68
- )
69
- if (!mt.length) return null
70
- const creations = mt
71
- .map((t) =>
72
- new Date(new Date(t.created_at).setHours(0, 0, 0, 0)).getTime(),
73
- )
74
- .sort((a, b) => a - b)
75
- const completions = mt
76
- .filter((t) => DONE.has(t.status))
77
- .map((t) => {
78
- const d = t.closed_at
79
- ? new Date(t.closed_at)
80
- : t.completed_at
81
- ? new Date(t.completed_at)
82
- : new Date(t.updated_at)
83
- return new Date(d.setHours(0, 0, 0, 0)).getTime()
84
- })
85
- .sort((a, b) => a - b)
86
- const pts: { x: number; y: number }[] = []
87
- for (let d = 0; d <= totalDays; d++) {
88
- const ts = startDay + d * 86400000
89
- pts.push({
90
- x: d,
91
- y: Math.max(
92
- 0,
93
- creations.filter((c) => c <= ts).length -
94
- completions.filter((c) => c <= ts).length,
95
- ),
96
- })
97
- }
98
- return pts
72
+ function buildPts(taskList: BurndownTask[], startDay: number, totalDays: number) {
73
+ return buildSeriesPoints(taskList, startDay, totalDays, DONE)
99
74
  }
100
75
 
76
+ let wholeList = $derived(selected === "" && activeMilestones.length === 0)
77
+
101
78
  let range = $derived.by((): { startDay: number; totalDays: number } => {
102
- const relevant =
103
- selected === ""
104
- ? tasks.filter((t) => t.status !== "dev" && t.milestone_id)
105
- : tasks.filter((t) => t.milestone_id === selected && t.status !== "dev")
79
+ const relevant = burndownScope(tasks, selected, activeMilestones.map((m) => m.id)).tasks
106
80
  if (!relevant.length) return { startDay: Date.now(), totalDays: 1 }
107
81
  const min = relevant.reduce(
108
82
  (m, t) => Math.min(m, new Date(t.created_at).getTime()),
@@ -116,9 +90,30 @@
116
90
  let lines = $derived.by(() => {
117
91
  const { startDay, totalDays } = range
118
92
  if (selected === "") {
93
+ // jatui-rt1no: no task carries a milestone_id → one series over the
94
+ // whole list, instead of the silent "No data" empty state.
95
+ if (wholeList) {
96
+ const pts = buildPts(tasks, startDay, totalDays)
97
+ return pts
98
+ ? [
99
+ {
100
+ id: "__all__",
101
+ name: "All tasks",
102
+ color: PALETTE[0],
103
+ pts,
104
+ },
105
+ ]
106
+ : []
107
+ }
119
108
  return activeMilestones
120
109
  .map((m, i) => {
121
- const pts = buildPts(m.id, startDay, totalDays)
110
+ const pts = buildPts(
111
+ tasks.filter(
112
+ (t) => t.milestone_id === m.id && t.status !== "dev",
113
+ ),
114
+ startDay,
115
+ totalDays,
116
+ )
122
117
  return pts
123
118
  ? {
124
119
  id: m.id,
@@ -136,7 +131,13 @@
136
131
  }[]
137
132
  } else {
138
133
  const m = (milestones as any[]).find((m) => m.id === selected)
139
- const pts = buildPts(selected, startDay, totalDays)
134
+ const pts = buildPts(
135
+ tasks.filter(
136
+ (t) => t.milestone_id === selected && t.status !== "dev",
137
+ ),
138
+ startDay,
139
+ totalDays,
140
+ )
140
141
  return pts
141
142
  ? [{ id: selected, name: m?.name ?? "", color: PALETTE[0], pts }]
142
143
  : []
@@ -210,10 +211,7 @@
210
211
  const added = new Array(totalDays + 1).fill(0)
211
212
  const closed = new Array(totalDays + 1).fill(0)
212
213
 
213
- const relevantTasks =
214
- selected === ""
215
- ? tasks.filter((t) => t.status !== "dev" && t.milestone_id)
216
- : tasks.filter((t) => t.milestone_id === selected && t.status !== "dev")
214
+ const relevantTasks = burndownScope(tasks, selected, activeMilestones.map((m) => m.id)).tasks
217
215
 
218
216
  for (const t of relevantTasks) {
219
217
  const cd = Math.round(
@@ -11,6 +11,10 @@
11
11
  * <Collapse>
12
12
  * {@html renderedMarkdown}
13
13
  * </Collapse>
14
+ *
15
+ * Focus-driven expansion: by default (`expandOnFocus`, default true) a
16
+ * focusin inside a collapsed region expands it — tabbing into a clipped
17
+ * editable child never leaves it unreadable (upstream of jat-ngdm1).
14
18
  */
15
19
 
16
20
  interface Props {
@@ -24,6 +28,12 @@
24
28
  moreLabel?: string;
25
29
  /** Toggle label when expanded. Default 'Show less ↑'. */
26
30
  lessLabel?: string;
31
+ /**
32
+ * Auto-expand when focus moves into the collapsed region (focusin).
33
+ * Default true — tabbing into a clipped editable child expands the region
34
+ * instead of leaving it unreadable (upstream of jat-ngdm1).
35
+ */
36
+ expandOnFocus?: boolean;
27
37
  /** Extra classes on the content region. */
28
38
  class?: string;
29
39
  children?: import('svelte').Snippet;
@@ -35,6 +45,7 @@
35
45
  expanded = $bindable(false),
36
46
  moreLabel = 'Show more ↓',
37
47
  lessLabel = 'Show less ↑',
48
+ expandOnFocus = true,
38
49
  class: className = '',
39
50
  children,
40
51
  }: Props = $props();
@@ -61,12 +72,16 @@
61
72
  );
62
73
  </script>
63
74
 
75
+ <!-- svelte-ignore a11y_no_static_element_interactions -- focusin only observes focus moving into an editable child; the wrapper takes no focus itself -->
64
76
  <div
65
77
  id={bodyId}
66
78
  class={`jatui-collapse-outer ${className}`.trim()}
67
79
  class:jatui-collapse-outer-closed={collapsible && !expanded}
68
80
  style="--collapse-threshold: {threshold}px; --collapse-gradient: {gradient};"
69
81
  aria-expanded={expanded || !collapsible}
82
+ onfocusin={() => {
83
+ if (expandOnFocus && collapsible && !expanded) expanded = true;
84
+ }}
70
85
  >
71
86
  <div bind:this={innerEl} class="jatui-collapse-inner">
72
87
  {#if children}
@@ -10,6 +10,10 @@
10
10
  * <Collapse>
11
11
  * {@html renderedMarkdown}
12
12
  * </Collapse>
13
+ *
14
+ * Focus-driven expansion: by default (`expandOnFocus`, default true) a
15
+ * focusin inside a collapsed region expands it — tabbing into a clipped
16
+ * editable child never leaves it unreadable (upstream of jat-ngdm1).
13
17
  */
14
18
  interface Props {
15
19
  /** Max collapsed height in px. Default 120. */
@@ -22,6 +26,12 @@ interface Props {
22
26
  moreLabel?: string;
23
27
  /** Toggle label when expanded. Default 'Show less ↑'. */
24
28
  lessLabel?: string;
29
+ /**
30
+ * Auto-expand when focus moves into the collapsed region (focusin).
31
+ * Default true — tabbing into a clipped editable child expands the region
32
+ * instead of leaving it unreadable (upstream of jat-ngdm1).
33
+ */
34
+ expandOnFocus?: boolean;
25
35
  /** Extra classes on the content region. */
26
36
  class?: string;
27
37
  children?: import('svelte').Snippet;
@@ -36,6 +36,8 @@
36
36
  triggerAttrs,
37
37
  embedded = false,
38
38
  onChange,
39
+ onSearch,
40
+ searchDebounceMs = 250,
39
41
  onCreate,
40
42
  createLabel = (q: string) => `Create "${q}"`,
41
43
  }: {
@@ -65,6 +67,21 @@
65
67
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
66
68
  embedded?: boolean;
67
69
  onChange: (value: string) => void;
70
+ /**
71
+ * Server-side search hook (mos-ibafm). When set, the built-in search box
72
+ * drives ASYNC results instead of the client-side substring filter: every
73
+ * keystroke (debounced to `searchDebounceMs`) calls onSearch with the
74
+ * current query, and the consumer updates `groups` with the server's
75
+ * response. Also fires on panel open (typically with '') so the consumer
76
+ * can load the initial result set, and again when the query is cleared —
77
+ * so '' means "reset to the default list". Internal filtering is bypassed
78
+ * entirely while this is set: the consumer owns exactly what the list
79
+ * shows (a fuzzy server match must not be re-filtered by substring).
80
+ * With it absent, behavior is unchanged (client-side filter).
81
+ */
82
+ onSearch?: (query: string) => void;
83
+ /** Debounce applied to onSearch keystrokes before the callback fires. */
84
+ searchDebounceMs?: number;
68
85
  /**
69
86
  * When set, an always-present "create" row is rendered as the last option
70
87
  * whenever the search box is non-empty — it survives a zero-match filter (so
@@ -108,6 +125,10 @@
108
125
  const activeColor = $derived(colorFn ? colorFn(value) : undefined);
109
126
 
110
127
  const filteredGroups = $derived.by(() => {
128
+ // Server-search mode: the consumer owns the result set (groups arrive
129
+ // from onSearch responses) — re-filtering them client-side would hide
130
+ // fuzzy server matches that don't substring-match the query.
131
+ if (onSearch) return groups;
111
132
  if (!searchQuery.trim()) return groups;
112
133
  const q = searchQuery.toLowerCase();
113
134
  const result: SearchDropdownGroup[] = [];
@@ -166,6 +187,19 @@
166
187
  });
167
188
  }
168
189
 
190
+ // Debounced onSearch: one timer per open panel; each keystroke reruns the
191
+ // effect, which clears the pending timer and re-arms it — a burst of
192
+ // keystrokes collapses into a single callback firing `searchDebounceMs`
193
+ // after the last one. Firing on open too (query is '' after a reset) is
194
+ // what lets the consumer load the initial result set from the server.
195
+ $effect(() => {
196
+ if (!onSearch) return;
197
+ if (!open && !embedded) return;
198
+ const q = searchQuery;
199
+ const t = setTimeout(() => onSearch(q), searchDebounceMs);
200
+ return () => clearTimeout(t);
201
+ });
202
+
169
203
  function handleSearchKeydown(e: KeyboardEvent) {
170
204
  if (e.key === 'Escape') {
171
205
  e.stopPropagation();
@@ -36,6 +36,21 @@ type $$ComponentProps = {
36
36
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
37
37
  embedded?: boolean;
38
38
  onChange: (value: string) => void;
39
+ /**
40
+ * Server-side search hook (mos-ibafm). When set, the built-in search box
41
+ * drives ASYNC results instead of the client-side substring filter: every
42
+ * keystroke (debounced to `searchDebounceMs`) calls onSearch with the
43
+ * current query, and the consumer updates `groups` with the server's
44
+ * response. Also fires on panel open (typically with '') so the consumer
45
+ * can load the initial result set, and again when the query is cleared —
46
+ * so '' means "reset to the default list". Internal filtering is bypassed
47
+ * entirely while this is set: the consumer owns exactly what the list
48
+ * shows (a fuzzy server match must not be re-filtered by substring).
49
+ * With it absent, behavior is unchanged (client-side filter).
50
+ */
51
+ onSearch?: (query: string) => void;
52
+ /** Debounce applied to onSearch keystrokes before the callback fires. */
53
+ searchDebounceMs?: number;
39
54
  /**
40
55
  * When set, an always-present "create" row is rendered as the last option
41
56
  * whenever the search box is non-empty — it survives a zero-match filter (so
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure, DOM-free core of BurndownChart (jatui-rt1no).
3
+ *
4
+ * Extracted from the component so the series-building decisions are unit
5
+ * testable (`npm run test:unit`) — same pattern as sectionNavSpy.ts.
6
+ *
7
+ * Key behaviour: when no task in the list carries a milestone_id, the chart
8
+ * treats the WHOLE list as one series instead of rendering "No data".
9
+ * Consumers previously had to synthesize a milestone_id (the IDE's epic Burn
10
+ * tab passes the epicId as one) to see anything at all.
11
+ */
12
+ export declare const BURNDOWN_DONE: Set<string>;
13
+ export interface BurndownTask {
14
+ status: string;
15
+ milestone_id?: string | null;
16
+ created_at: string;
17
+ closed_at?: string | null;
18
+ completed_at?: string | null;
19
+ updated_at: string;
20
+ [key: string]: unknown;
21
+ }
22
+ /**
23
+ * Which tasks does the chart render for the current selection?
24
+ *
25
+ * - A specific milestone selected → only that milestone's non-dev tasks.
26
+ * - "All" selected with at least one milestone-tagged non-dev task → the
27
+ * milestone-tagged tasks (per-milestone series, unchanged legacy behaviour).
28
+ * - "All" selected and NO task has a milestone_id → the whole non-dev list
29
+ * as one series (the jatui-rt1no fallback).
30
+ */
31
+ export declare function burndownScope(tasks: BurndownTask[], selected: string, activeMilestoneIds: Iterable<string>): {
32
+ tasks: BurndownTask[];
33
+ wholeList: boolean;
34
+ };
35
+ /** Completion timestamp with the closed_at → completed_at → updated_at fallback. */
36
+ export declare function completionTs(t: BurndownTask): Date;
37
+ /**
38
+ * Open-task count per day over the chart's range — the burn-down line.
39
+ * Returns null when the task list is empty (the chart then shows "No data",
40
+ * which is correct for a genuinely empty list).
41
+ */
42
+ export declare function buildSeriesPoints(tasks: BurndownTask[], startDay: number, totalDays: number, done?: Set<string>): {
43
+ x: number;
44
+ y: number;
45
+ }[] | null;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pure, DOM-free core of BurndownChart (jatui-rt1no).
3
+ *
4
+ * Extracted from the component so the series-building decisions are unit
5
+ * testable (`npm run test:unit`) — same pattern as sectionNavSpy.ts.
6
+ *
7
+ * Key behaviour: when no task in the list carries a milestone_id, the chart
8
+ * treats the WHOLE list as one series instead of rendering "No data".
9
+ * Consumers previously had to synthesize a milestone_id (the IDE's epic Burn
10
+ * tab passes the epicId as one) to see anything at all.
11
+ */
12
+ export const BURNDOWN_DONE = new Set([
13
+ "accepted",
14
+ "deployed",
15
+ "closed",
16
+ "completed",
17
+ ]);
18
+ /**
19
+ * Which tasks does the chart render for the current selection?
20
+ *
21
+ * - A specific milestone selected → only that milestone's non-dev tasks.
22
+ * - "All" selected with at least one milestone-tagged non-dev task → the
23
+ * milestone-tagged tasks (per-milestone series, unchanged legacy behaviour).
24
+ * - "All" selected and NO task has a milestone_id → the whole non-dev list
25
+ * as one series (the jatui-rt1no fallback).
26
+ */
27
+ export function burndownScope(tasks, selected, activeMilestoneIds) {
28
+ const nonDev = tasks.filter((t) => t.status !== "dev");
29
+ if (selected !== "") {
30
+ return {
31
+ tasks: nonDev.filter((t) => t.milestone_id === selected),
32
+ wholeList: false,
33
+ };
34
+ }
35
+ const wholeList = [...activeMilestoneIds].length === 0;
36
+ return {
37
+ tasks: wholeList ? nonDev : nonDev.filter((t) => t.milestone_id),
38
+ wholeList,
39
+ };
40
+ }
41
+ /** Completion timestamp with the closed_at → completed_at → updated_at fallback. */
42
+ export function completionTs(t) {
43
+ const d = t.closed_at
44
+ ? new Date(t.closed_at)
45
+ : t.completed_at
46
+ ? new Date(t.completed_at)
47
+ : new Date(t.updated_at);
48
+ return new Date(d.setHours(0, 0, 0, 0));
49
+ }
50
+ /**
51
+ * Open-task count per day over the chart's range — the burn-down line.
52
+ * Returns null when the task list is empty (the chart then shows "No data",
53
+ * which is correct for a genuinely empty list).
54
+ */
55
+ export function buildSeriesPoints(tasks, startDay, totalDays, done = BURNDOWN_DONE) {
56
+ if (!tasks.length)
57
+ return null;
58
+ const creations = tasks
59
+ .map((t) => new Date(new Date(t.created_at).setHours(0, 0, 0, 0)).getTime())
60
+ .sort((a, b) => a - b);
61
+ const completions = tasks
62
+ .filter((t) => done.has(t.status))
63
+ .map(completionTs)
64
+ .map((d) => d.getTime())
65
+ .sort((a, b) => a - b);
66
+ const pts = [];
67
+ for (let d = 0; d <= totalDays; d++) {
68
+ const ts = startDay + d * 86400000;
69
+ pts.push({
70
+ x: d,
71
+ y: Math.max(0, creations.filter((c) => c <= ts).length -
72
+ completions.filter((c) => c <= ts).length),
73
+ });
74
+ }
75
+ return pts;
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",