@kernhq/module-tracker 0.10.1 → 0.11.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/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { RichDoc } from './index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rendering and editing for the ProseMirror documents that descriptions and comments are stored as.
|
|
5
|
+
*
|
|
6
|
+
* The tracker stores rich text as Tiptap JSON so it can round-trip through the API, search and
|
|
7
|
+
* email without becoming HTML soup. Rendering it here rather than trusting a server-side string
|
|
8
|
+
* keeps the rule simple: only the node and mark types below ever reach the DOM, and every piece of
|
|
9
|
+
* text is escaped, so a pasted `<script>` is text and nothing else.
|
|
10
|
+
*
|
|
11
|
+
* The editor is a plain textarea for now; the surrounding format is already the real one, so
|
|
12
|
+
* swapping in a Tiptap surface later changes the input and nothing that reads it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ESCAPES: Record<string, string> = {
|
|
16
|
+
'&': '&',
|
|
17
|
+
'<': '<',
|
|
18
|
+
'>': '>',
|
|
19
|
+
'"': '"',
|
|
20
|
+
"'": ''',
|
|
21
|
+
}
|
|
22
|
+
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ESCAPES[c] as string)
|
|
23
|
+
|
|
24
|
+
/** Only links that navigate somewhere safe survive. */
|
|
25
|
+
function safeHref(href: unknown): string | null {
|
|
26
|
+
if (typeof href !== 'string') return null
|
|
27
|
+
const trimmed = href.trim()
|
|
28
|
+
return /^(https?:|mailto:|\/)/i.test(trimmed) ? trimmed : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Node {
|
|
32
|
+
type?: string
|
|
33
|
+
text?: string
|
|
34
|
+
content?: Node[]
|
|
35
|
+
attrs?: Record<string, unknown>
|
|
36
|
+
marks?: Array<{ type?: string; attrs?: Record<string, unknown> }>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderMarks(html: string, marks: Node['marks']): string {
|
|
40
|
+
let out = html
|
|
41
|
+
for (const mark of marks ?? []) {
|
|
42
|
+
switch (mark.type) {
|
|
43
|
+
case 'bold':
|
|
44
|
+
case 'strong':
|
|
45
|
+
out = `<strong>${out}</strong>`
|
|
46
|
+
break
|
|
47
|
+
case 'italic':
|
|
48
|
+
case 'em':
|
|
49
|
+
out = `<em>${out}</em>`
|
|
50
|
+
break
|
|
51
|
+
case 'code':
|
|
52
|
+
out = `<code>${out}</code>`
|
|
53
|
+
break
|
|
54
|
+
case 'strike':
|
|
55
|
+
out = `<s>${out}</s>`
|
|
56
|
+
break
|
|
57
|
+
case 'link': {
|
|
58
|
+
const href = safeHref(mark.attrs?.href)
|
|
59
|
+
if (href) out = `<a href="${escapeHtml(href)}" rel="noreferrer noopener" target="_blank">${out}</a>`
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderNode(node: Node): string {
|
|
70
|
+
if (node.type === 'text') return renderMarks(escapeHtml(node.text ?? ''), node.marks)
|
|
71
|
+
if (node.type === 'mention') {
|
|
72
|
+
// A mention is an inline leaf: it has no children, so the default branch below would render
|
|
73
|
+
// nothing and the mention would simply disappear from the comment.
|
|
74
|
+
const label = String(node.attrs?.label ?? '')
|
|
75
|
+
const id = String(node.attrs?.id ?? '')
|
|
76
|
+
return `<span class="kern-mention" data-user-id="${escapeHtml(id)}">@${escapeHtml(label)}</span>`
|
|
77
|
+
}
|
|
78
|
+
const children = (node.content ?? []).map(renderNode).join('')
|
|
79
|
+
switch (node.type) {
|
|
80
|
+
case 'paragraph':
|
|
81
|
+
return children ? `<p>${children}</p>` : '<p><br></p>'
|
|
82
|
+
case 'heading': {
|
|
83
|
+
const level = Math.min(4, Math.max(2, Number(node.attrs?.level ?? 2)))
|
|
84
|
+
return `<h${level}>${children}</h${level}>`
|
|
85
|
+
}
|
|
86
|
+
case 'bulletList':
|
|
87
|
+
return `<ul>${children}</ul>`
|
|
88
|
+
case 'orderedList':
|
|
89
|
+
return `<ol>${children}</ol>`
|
|
90
|
+
case 'listItem':
|
|
91
|
+
return `<li>${children}</li>`
|
|
92
|
+
case 'blockquote':
|
|
93
|
+
return `<blockquote>${children}</blockquote>`
|
|
94
|
+
case 'codeBlock':
|
|
95
|
+
return `<pre><code>${children}</code></pre>`
|
|
96
|
+
case 'hardBreak':
|
|
97
|
+
return '<br>'
|
|
98
|
+
case 'horizontalRule':
|
|
99
|
+
return '<hr>'
|
|
100
|
+
default:
|
|
101
|
+
return children
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Sanitised HTML for a stored document. Returns an empty string for an empty or missing document. */
|
|
106
|
+
export function renderDoc(doc: RichDoc | null | undefined): string {
|
|
107
|
+
if (!doc) return ''
|
|
108
|
+
return (doc.content ?? []).map((n) => renderNode(n as Node)).join('')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Plain text, for previews, search and the textarea editor. */
|
|
112
|
+
export function textFromDoc(doc: RichDoc | null | undefined): string {
|
|
113
|
+
if (!doc) return ''
|
|
114
|
+
const walk = (node: Node): string => {
|
|
115
|
+
if (typeof node.text === 'string') return node.text
|
|
116
|
+
// A mention becomes the literal that produced it, so editing a comment round-trips instead of
|
|
117
|
+
// quietly deleting whoever was mentioned.
|
|
118
|
+
if (node.type === 'mention') return `@${String(node.attrs?.label ?? '')}`
|
|
119
|
+
return (node.content ?? []).map(walk).join('')
|
|
120
|
+
}
|
|
121
|
+
return (doc.content ?? [])
|
|
122
|
+
.map((n) => walk(n as Node))
|
|
123
|
+
.join('\n\n')
|
|
124
|
+
.trim()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Wrap typed text back into a document: blank lines separate paragraphs, single ones break. */
|
|
128
|
+
export function docFromText(text: string): RichDoc | null {
|
|
129
|
+
const trimmed = text.trim()
|
|
130
|
+
if (!trimmed) return null
|
|
131
|
+
const paragraphs = trimmed.split(/\n{2,}/)
|
|
132
|
+
return {
|
|
133
|
+
type: 'doc',
|
|
134
|
+
content: paragraphs.map((para) => ({
|
|
135
|
+
type: 'paragraph',
|
|
136
|
+
content: para
|
|
137
|
+
.split('\n')
|
|
138
|
+
.flatMap((line, i) =>
|
|
139
|
+
i === 0 ? [{ type: 'text', text: line }] : [{ type: 'hardBreak' }, { type: 'text', text: line }],
|
|
140
|
+
)
|
|
141
|
+
.filter((n) => n.type !== 'text' || (n as { text?: string }).text),
|
|
142
|
+
})),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a document has nothing in it.
|
|
148
|
+
*
|
|
149
|
+
* The editor stores an empty document as `null`, but a document that once had text and was cleared
|
|
150
|
+
* arrives as a doc holding one empty paragraph — perfectly truthy, and the reason a "Send" button
|
|
151
|
+
* that only checked for `null` stayed enabled on an empty comment.
|
|
152
|
+
*/
|
|
153
|
+
export function isEmptyDoc(doc: RichDoc | null | undefined): boolean {
|
|
154
|
+
return !doc || textFromDoc(doc).trim() === ''
|
|
155
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, Button, IconButton, Input, messageLocale, Select, Spinner } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import type { Cycle } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A project's cycles — the sprint, whatever a team calls it.
|
|
8
|
+
*
|
|
9
|
+
* Not a `PlanningList` because a cycle is not a name: it is a name and a window, and the window is
|
|
10
|
+
* the whole point. A cycle also has a life — upcoming, active, completed — and the two moves that
|
|
11
|
+
* advance it are the reason the screen exists at all.
|
|
12
|
+
*
|
|
13
|
+
* Completing asks where unfinished work goes rather than deciding quietly. Leaving a cycle is the
|
|
14
|
+
* one moment a team looks at what did not get done, and moving it somewhere they did not choose is
|
|
15
|
+
* how work disappears.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
cycles: Cycle[]
|
|
19
|
+
loading?: boolean
|
|
20
|
+
editable: boolean
|
|
21
|
+
busy?: boolean
|
|
22
|
+
oncreate: (input: { name: string; startAt: string; endAt: string }) => void
|
|
23
|
+
onupdate: (input: { id: string; name: string; startAt: string; endAt: string }) => void
|
|
24
|
+
onstart: (id: string) => void
|
|
25
|
+
oncomplete: (input: { id: string; rollToCycleId: string | null }) => void
|
|
26
|
+
onremove: (id: string) => void
|
|
27
|
+
}
|
|
28
|
+
let {
|
|
29
|
+
cycles,
|
|
30
|
+
loading = false,
|
|
31
|
+
editable,
|
|
32
|
+
busy = false,
|
|
33
|
+
oncreate,
|
|
34
|
+
onupdate,
|
|
35
|
+
onstart,
|
|
36
|
+
oncomplete,
|
|
37
|
+
onremove,
|
|
38
|
+
}: Props = $props()
|
|
39
|
+
|
|
40
|
+
let name = $state('')
|
|
41
|
+
let startAt = $state('')
|
|
42
|
+
let endAt = $state('')
|
|
43
|
+
let confirmingId = $state<string | null>(null)
|
|
44
|
+
/**
|
|
45
|
+
* A cycle whose window is being corrected.
|
|
46
|
+
*
|
|
47
|
+
* `cycles.update` existed and nothing called it, so a sprint entered with the wrong end date could
|
|
48
|
+
* only be deleted — which takes its issues out of it — and made again.
|
|
49
|
+
*/
|
|
50
|
+
let editingId = $state<string | null>(null)
|
|
51
|
+
let editName = $state('')
|
|
52
|
+
let editStart = $state('')
|
|
53
|
+
let editEnd = $state('')
|
|
54
|
+
|
|
55
|
+
/** `<input type="date">` wants `YYYY-MM-DD` in local time; an ISO timestamp's UTC date can be the
|
|
56
|
+
* day before. */
|
|
57
|
+
const dateValue = (iso: string) => {
|
|
58
|
+
const d = new Date(iso)
|
|
59
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const beginEdit = (c: Cycle) => {
|
|
63
|
+
editingId = c.id
|
|
64
|
+
editName = c.name
|
|
65
|
+
editStart = dateValue(c.startAt)
|
|
66
|
+
editEnd = dateValue(c.endAt)
|
|
67
|
+
completingId = null
|
|
68
|
+
confirmingId = null
|
|
69
|
+
}
|
|
70
|
+
const editInvalid = $derived(Boolean(editStart && editEnd && editEnd < editStart))
|
|
71
|
+
const commitEdit = () => {
|
|
72
|
+
if (!editingId || !editName.trim() || !editStart || !editEnd || editInvalid) return
|
|
73
|
+
onupdate({
|
|
74
|
+
id: editingId,
|
|
75
|
+
name: editName.trim(),
|
|
76
|
+
startAt: new Date(editStart).toISOString(),
|
|
77
|
+
endAt: new Date(editEnd).toISOString(),
|
|
78
|
+
})
|
|
79
|
+
editingId = null
|
|
80
|
+
}
|
|
81
|
+
/** The cycle being completed, and where its leftovers are headed. */
|
|
82
|
+
let completingId = $state<string | null>(null)
|
|
83
|
+
let rollTo = $state('')
|
|
84
|
+
|
|
85
|
+
const dates = $derived(
|
|
86
|
+
new Intl.DateTimeFormat(messageLocale(), { day: 'numeric', month: 'short', year: 'numeric' }),
|
|
87
|
+
)
|
|
88
|
+
/** `formatRange`, not two dates joined by a dash: in an RTL locale a hand-built range reads
|
|
89
|
+
* backwards, and the shared parts of the two dates are collapsed for free. */
|
|
90
|
+
const range = (c: Cycle) => dates.formatRange(new Date(c.startAt), new Date(c.endAt))
|
|
91
|
+
|
|
92
|
+
/** An end before its start is the one mistake this form can make; it says so instead of failing. */
|
|
93
|
+
const invalidRange = $derived(Boolean(startAt && endAt && endAt < startAt))
|
|
94
|
+
const canAdd = $derived(Boolean(name.trim() && startAt && endAt) && !invalidRange && !busy)
|
|
95
|
+
|
|
96
|
+
const submit = () => {
|
|
97
|
+
if (!canAdd) return
|
|
98
|
+
oncreate({
|
|
99
|
+
name: name.trim(),
|
|
100
|
+
startAt: new Date(startAt).toISOString(),
|
|
101
|
+
endAt: new Date(endAt).toISOString(),
|
|
102
|
+
})
|
|
103
|
+
name = ''
|
|
104
|
+
startAt = ''
|
|
105
|
+
endAt = ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Where leftovers could go: an upcoming cycle of the same project, never the one being closed. */
|
|
109
|
+
const rollTargets = $derived(
|
|
110
|
+
cycles
|
|
111
|
+
.filter((c) => c.status === 'upcoming' && c.id !== completingId)
|
|
112
|
+
.map((c) => ({ value: c.id, label: c.name })),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const beginComplete = (c: Cycle) => {
|
|
116
|
+
completingId = c.id
|
|
117
|
+
rollTo = ''
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const statusLabel = (status: Cycle['status']) =>
|
|
121
|
+
status === 'active'
|
|
122
|
+
? t('cycle_active')
|
|
123
|
+
: status === 'completed'
|
|
124
|
+
? t('cycle_completed')
|
|
125
|
+
: t('cycle_upcoming')
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<section class="clist" data-testid="planning-cycles">
|
|
129
|
+
<header>
|
|
130
|
+
<h3>{t('planning_cycles')}</h3>
|
|
131
|
+
<p>{t('planning_cycles_hint')}</p>
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
{#if loading}
|
|
135
|
+
<div class="state"><Spinner /></div>
|
|
136
|
+
{:else}
|
|
137
|
+
<ul>
|
|
138
|
+
{#each cycles as cycle (cycle.id)}
|
|
139
|
+
<!-- Two lines, not one: a cycle carries a number, a name, a state, a window, its progress
|
|
140
|
+
and what it inherited, and on one line the name is what gets squeezed out. The name is
|
|
141
|
+
the thing being looked for. -->
|
|
142
|
+
<li class="cycle" data-testid="cycle-row">
|
|
143
|
+
<span class="num">{cycle.number}</span>
|
|
144
|
+
<span class="body">
|
|
145
|
+
<span class="top">
|
|
146
|
+
<span class="name" data-item={cycle.name}>{cycle.name}</span>
|
|
147
|
+
<Badge tone={cycle.status === 'completed' ? 'done' : cycle.status}>
|
|
148
|
+
{statusLabel(cycle.status)}
|
|
149
|
+
</Badge>
|
|
150
|
+
</span>
|
|
151
|
+
<span class="meta">
|
|
152
|
+
<span>{range(cycle)}</span>
|
|
153
|
+
<span>{t('planning_done_of', { done: cycle.stats.done, total: cycle.stats.total })}</span>
|
|
154
|
+
{#if cycle.carryOverCount > 0}
|
|
155
|
+
<!-- Where a cycle's work came from is worth as much as where it went: a cycle that
|
|
156
|
+
is mostly last cycle's leftovers is the thing a team needs to see. -->
|
|
157
|
+
<span>{t('cycle_carried', { count: cycle.carryOverCount })}</span>
|
|
158
|
+
{/if}
|
|
159
|
+
</span>
|
|
160
|
+
</span>
|
|
161
|
+
{#if editable && cycle.status !== 'completed'}
|
|
162
|
+
<Button size="sm" variant="ghost" onclick={() => beginEdit(cycle)} data-testid="cycle-edit">
|
|
163
|
+
{t('common.edit')}
|
|
164
|
+
</Button>
|
|
165
|
+
{/if}
|
|
166
|
+
{#if editable && cycle.status === 'upcoming'}
|
|
167
|
+
<Button size="sm" variant="ghost" onclick={() => onstart(cycle.id)} data-testid="cycle-start">
|
|
168
|
+
{t('cycle_start')}
|
|
169
|
+
</Button>
|
|
170
|
+
{/if}
|
|
171
|
+
{#if editable && cycle.status === 'active'}
|
|
172
|
+
<Button
|
|
173
|
+
size="sm"
|
|
174
|
+
variant="ghost"
|
|
175
|
+
onclick={() => beginComplete(cycle)}
|
|
176
|
+
data-testid="cycle-complete"
|
|
177
|
+
>
|
|
178
|
+
{t('cycle_complete')}
|
|
179
|
+
</Button>
|
|
180
|
+
{/if}
|
|
181
|
+
{#if editable}
|
|
182
|
+
<IconButton
|
|
183
|
+
icon="x"
|
|
184
|
+
size={22}
|
|
185
|
+
label={t('planning_remove', { name: cycle.name })}
|
|
186
|
+
onclick={() => (confirmingId = cycle.id)}
|
|
187
|
+
/>
|
|
188
|
+
{/if}
|
|
189
|
+
</li>
|
|
190
|
+
|
|
191
|
+
{#if editingId === cycle.id}
|
|
192
|
+
<li class="panel" data-testid="cycle-edit-panel">
|
|
193
|
+
<form class="add" onsubmit={(e) => { e.preventDefault(); commitEdit() }}>
|
|
194
|
+
<span class="wide">
|
|
195
|
+
<Input bind:value={editName} data-testid="cycle-edit-name" />
|
|
196
|
+
</span>
|
|
197
|
+
<input
|
|
198
|
+
class="date"
|
|
199
|
+
type="date"
|
|
200
|
+
bind:value={editStart}
|
|
201
|
+
aria-label={t('cycle_starts')}
|
|
202
|
+
data-testid="cycle-edit-start"
|
|
203
|
+
/>
|
|
204
|
+
<input
|
|
205
|
+
class="date"
|
|
206
|
+
type="date"
|
|
207
|
+
bind:value={editEnd}
|
|
208
|
+
aria-label={t('cycle_ends')}
|
|
209
|
+
data-testid="cycle-edit-end"
|
|
210
|
+
/>
|
|
211
|
+
<Button
|
|
212
|
+
size="sm"
|
|
213
|
+
disabled={!editName.trim() || !editStart || !editEnd || editInvalid}
|
|
214
|
+
onclick={commitEdit}
|
|
215
|
+
data-testid="cycle-edit-save"
|
|
216
|
+
>
|
|
217
|
+
{t('common.save')}
|
|
218
|
+
</Button>
|
|
219
|
+
</form>
|
|
220
|
+
{#if editInvalid}
|
|
221
|
+
<p class="warn">{t('cycle_range_error')}</p>
|
|
222
|
+
{/if}
|
|
223
|
+
<div class="row">
|
|
224
|
+
<Button size="sm" variant="ghost" onclick={() => (editingId = null)}>{t('common.cancel')}</Button>
|
|
225
|
+
</div>
|
|
226
|
+
</li>
|
|
227
|
+
{/if}
|
|
228
|
+
|
|
229
|
+
{#if completingId === cycle.id}
|
|
230
|
+
<li class="panel" data-testid="cycle-complete-panel">
|
|
231
|
+
<p>{t('cycle_complete_body', { name: cycle.name })}</p>
|
|
232
|
+
<div class="row">
|
|
233
|
+
<span data-testid="cycle-roll">
|
|
234
|
+
<Select
|
|
235
|
+
value={rollTo}
|
|
236
|
+
options={[{ value: '', label: t('cycle_roll_backlog') }, ...rollTargets]}
|
|
237
|
+
onValueChange={(v: string) => (rollTo = v)}
|
|
238
|
+
/>
|
|
239
|
+
</span>
|
|
240
|
+
<Button
|
|
241
|
+
size="sm"
|
|
242
|
+
onclick={() => {
|
|
243
|
+
oncomplete({ id: cycle.id, rollToCycleId: rollTo || null })
|
|
244
|
+
completingId = null
|
|
245
|
+
}}
|
|
246
|
+
data-testid="cycle-complete-confirm"
|
|
247
|
+
>
|
|
248
|
+
{t('cycle_complete')}
|
|
249
|
+
</Button>
|
|
250
|
+
<Button size="sm" variant="ghost" onclick={() => (completingId = null)}>{t('common.cancel')}</Button>
|
|
251
|
+
</div>
|
|
252
|
+
</li>
|
|
253
|
+
{/if}
|
|
254
|
+
|
|
255
|
+
{#if confirmingId === cycle.id}
|
|
256
|
+
<li class="panel danger" role="alertdialog">
|
|
257
|
+
<span>{t('planning_remove_body', { name: cycle.name })}</span>
|
|
258
|
+
<div class="row">
|
|
259
|
+
<Button
|
|
260
|
+
size="sm"
|
|
261
|
+
variant="danger"
|
|
262
|
+
onclick={() => {
|
|
263
|
+
onremove(cycle.id)
|
|
264
|
+
confirmingId = null
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{t('common.delete')}
|
|
268
|
+
</Button>
|
|
269
|
+
<Button size="sm" variant="ghost" onclick={() => (confirmingId = null)}>{t('common.cancel')}</Button>
|
|
270
|
+
</div>
|
|
271
|
+
</li>
|
|
272
|
+
{/if}
|
|
273
|
+
{:else}
|
|
274
|
+
<li class="empty-row">{t('planning_cycles_empty')}</li>
|
|
275
|
+
{/each}
|
|
276
|
+
</ul>
|
|
277
|
+
|
|
278
|
+
{#if editable}
|
|
279
|
+
<!-- The name spans the row and the two dates sit side by side, because a start and an end
|
|
280
|
+
that land on different lines stop reading as one window. -->
|
|
281
|
+
<form class="add" onsubmit={(e) => { e.preventDefault(); submit() }}>
|
|
282
|
+
<span class="wide">
|
|
283
|
+
<Input bind:value={name} placeholder={t('planning_cycle_add')} data-testid="cycle-name" />
|
|
284
|
+
</span>
|
|
285
|
+
<input
|
|
286
|
+
class="date"
|
|
287
|
+
type="date"
|
|
288
|
+
bind:value={startAt}
|
|
289
|
+
aria-label={t('cycle_starts')}
|
|
290
|
+
data-testid="cycle-start-date"
|
|
291
|
+
/>
|
|
292
|
+
<input
|
|
293
|
+
class="date"
|
|
294
|
+
type="date"
|
|
295
|
+
bind:value={endAt}
|
|
296
|
+
aria-label={t('cycle_ends')}
|
|
297
|
+
data-testid="cycle-end-date"
|
|
298
|
+
/>
|
|
299
|
+
<Button size="sm" disabled={!canAdd} onclick={submit} data-testid="cycle-add">{t('common.add')}</Button>
|
|
300
|
+
</form>
|
|
301
|
+
{#if invalidRange}
|
|
302
|
+
<p class="warn" data-testid="cycle-range-error">{t('cycle_range_error')}</p>
|
|
303
|
+
{/if}
|
|
304
|
+
{/if}
|
|
305
|
+
{/if}
|
|
306
|
+
</section>
|
|
307
|
+
|
|
308
|
+
<style>
|
|
309
|
+
.clist {
|
|
310
|
+
padding: 14px 0;
|
|
311
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
312
|
+
}
|
|
313
|
+
h3 {
|
|
314
|
+
margin: 0;
|
|
315
|
+
font-size: 13px;
|
|
316
|
+
font-weight: 600;
|
|
317
|
+
}
|
|
318
|
+
header p {
|
|
319
|
+
margin: 2px 0 0;
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
color: var(--kern-ink-400);
|
|
322
|
+
}
|
|
323
|
+
ul {
|
|
324
|
+
list-style: none;
|
|
325
|
+
margin: 8px 0 0;
|
|
326
|
+
padding: 0;
|
|
327
|
+
}
|
|
328
|
+
li {
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
gap: 8px;
|
|
332
|
+
padding: 4px 0;
|
|
333
|
+
}
|
|
334
|
+
.cycle {
|
|
335
|
+
align-items: flex-start;
|
|
336
|
+
padding: 7px 0;
|
|
337
|
+
}
|
|
338
|
+
.num {
|
|
339
|
+
min-width: 20px;
|
|
340
|
+
padding-top: 2px;
|
|
341
|
+
font-size: 12px;
|
|
342
|
+
font-variant-numeric: tabular-nums;
|
|
343
|
+
color: var(--kern-ink-400);
|
|
344
|
+
}
|
|
345
|
+
.body {
|
|
346
|
+
flex: 1;
|
|
347
|
+
min-width: 0;
|
|
348
|
+
}
|
|
349
|
+
.top {
|
|
350
|
+
display: flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
gap: 8px;
|
|
353
|
+
}
|
|
354
|
+
.name {
|
|
355
|
+
min-width: 0;
|
|
356
|
+
font-size: 13px;
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
text-overflow: ellipsis;
|
|
359
|
+
white-space: nowrap;
|
|
360
|
+
}
|
|
361
|
+
.meta {
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-wrap: wrap;
|
|
364
|
+
gap: 4px 10px;
|
|
365
|
+
margin-top: 2px;
|
|
366
|
+
font-size: 12px;
|
|
367
|
+
color: var(--kern-ink-400);
|
|
368
|
+
}
|
|
369
|
+
.panel {
|
|
370
|
+
display: block;
|
|
371
|
+
padding: 8px 0 10px;
|
|
372
|
+
font-size: 12.5px;
|
|
373
|
+
}
|
|
374
|
+
.panel p {
|
|
375
|
+
margin: 0 0 6px;
|
|
376
|
+
}
|
|
377
|
+
.panel.danger {
|
|
378
|
+
color: var(--kern-danger);
|
|
379
|
+
}
|
|
380
|
+
.row {
|
|
381
|
+
display: flex;
|
|
382
|
+
align-items: center;
|
|
383
|
+
gap: 8px;
|
|
384
|
+
flex-wrap: wrap;
|
|
385
|
+
}
|
|
386
|
+
.empty-row {
|
|
387
|
+
font-size: 13px;
|
|
388
|
+
color: var(--kern-ink-400);
|
|
389
|
+
}
|
|
390
|
+
.warn {
|
|
391
|
+
margin: 6px 0 0;
|
|
392
|
+
font-size: 12px;
|
|
393
|
+
color: var(--kern-danger);
|
|
394
|
+
}
|
|
395
|
+
.add {
|
|
396
|
+
display: grid;
|
|
397
|
+
grid-template-columns: 1fr 1fr auto;
|
|
398
|
+
align-items: center;
|
|
399
|
+
gap: 8px;
|
|
400
|
+
margin-top: 10px;
|
|
401
|
+
}
|
|
402
|
+
.wide {
|
|
403
|
+
grid-column: 1 / -1;
|
|
404
|
+
}
|
|
405
|
+
.date {
|
|
406
|
+
/* the whole field opens a picker; a date input does not take free text, so it is not a caret */
|
|
407
|
+
cursor: pointer;
|
|
408
|
+
width: 100%;
|
|
409
|
+
padding: 4px 8px;
|
|
410
|
+
border: 1px solid var(--kern-border);
|
|
411
|
+
border-radius: var(--kern-r-sm);
|
|
412
|
+
background: var(--kern-surface);
|
|
413
|
+
color: inherit;
|
|
414
|
+
font: inherit;
|
|
415
|
+
font-size: 13px;
|
|
416
|
+
}
|
|
417
|
+
.state {
|
|
418
|
+
display: grid;
|
|
419
|
+
place-items: center;
|
|
420
|
+
padding: 18px;
|
|
421
|
+
}
|
|
422
|
+
</style>
|