@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 +1 -1
- package/dist/actions/collapse.d.ts +27 -0
- package/dist/actions/collapse.js +29 -1
- package/dist/components/BurndownChart.svelte +43 -45
- package/dist/components/Collapse.svelte +15 -0
- package/dist/components/Collapse.svelte.d.ts +10 -0
- package/dist/components/SearchDropdown.svelte +34 -0
- package/dist/components/SearchDropdown.svelte.d.ts +15 -0
- package/dist/components/burndownSeries.d.ts +45 -0
- package/dist/components/burndownSeries.js +76 -0
- package/package.json +1 -1
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;
|
package/dist/actions/collapse.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
66
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|