@joewinke/jatui 0.1.11 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/package.json +2 -1
- package/src/lib/actions/railNav.ts +473 -0
- package/src/lib/components/AnnotationLayer.svelte +108 -0
- package/src/lib/components/AnnotationPanel.svelte +319 -0
- package/src/lib/components/AudioWaveform.svelte +9 -5
- package/src/lib/components/AvailabilityModal.svelte +7 -3
- package/src/lib/components/AvatarUpload.svelte +27 -4
- package/src/lib/components/BookingForm.svelte +11 -9
- package/src/lib/components/BurndownChart.svelte +778 -0
- package/src/lib/components/Button.svelte +10 -1
- package/src/lib/components/CalendarPicker.svelte +3 -3
- package/src/lib/components/Card.svelte +2 -2
- package/src/lib/components/ChipInput.svelte +8 -3
- package/src/lib/components/ColorSelector.svelte +17 -13
- package/src/lib/components/CommentThread.svelte +773 -0
- package/src/lib/components/ConfirmDialog.svelte +348 -0
- package/src/lib/components/ConfirmModal.svelte +78 -11
- package/src/lib/components/ContextMenu.svelte +59 -19
- package/src/lib/components/CountdownTimer.svelte +1 -1
- package/src/lib/components/DateRangePicker.svelte +6 -4
- package/src/lib/components/Drawer.svelte +36 -3
- package/src/lib/components/EntityPreviewCard.svelte +104 -0
- package/src/lib/components/FileDropzone.svelte +493 -0
- package/src/lib/components/FilePicker.svelte +83 -14
- package/src/lib/components/FileThumbnail.svelte +80 -0
- package/src/lib/components/FilterDropdown.svelte +11 -11
- package/src/lib/components/GPSTracker.svelte +202 -0
- package/src/lib/components/HunkDiffView.svelte +348 -0
- package/src/lib/components/ImageLightbox.svelte +274 -0
- package/src/lib/components/ImageUpload.svelte +58 -9
- package/src/lib/components/InlineEdit.svelte +6 -2
- package/src/lib/components/InputDialog.svelte +327 -0
- package/src/lib/components/KeyboardShortcutsOverlay.svelte +296 -0
- package/src/lib/components/LazyImage.svelte +1 -0
- package/src/lib/components/LinkShortener.svelte +1 -1
- package/src/lib/components/LoadingSpinner.svelte +6 -2
- package/src/lib/components/LocationMap.svelte +186 -0
- package/src/lib/components/MapView.svelte +341 -0
- package/src/lib/components/MarkupEditor.svelte +485 -0
- package/src/lib/components/MarkupOverlay.svelte +55 -0
- package/src/lib/components/MediaWorkbench.svelte +871 -0
- package/src/lib/components/MilestoneCard.svelte +1 -1
- package/src/lib/components/MilestoneTimeline.svelte +1 -1
- package/src/lib/components/Modal.svelte +39 -4
- package/src/lib/components/PDFViewer.svelte +105 -0
- package/src/lib/components/PdfThumbnail.svelte +3 -1
- package/src/lib/components/PhoneInput.svelte +1 -1
- package/src/lib/components/ResizablePanel.svelte +4 -4
- package/src/lib/components/SearchDropdown.svelte +26 -13
- package/src/lib/components/SelectInput.svelte +26 -4
- package/src/lib/components/SidebarUserFooter.svelte +1 -1
- package/src/lib/components/SignaturePad.svelte +8 -4
- package/src/lib/components/SmartImageEditor.svelte +720 -0
- package/src/lib/components/SortDropdown.svelte +9 -3
- package/src/lib/components/Sparkline.svelte +9 -0
- package/src/lib/components/StatusBadge.svelte +20 -18
- package/src/lib/components/TextArea.svelte +24 -5
- package/src/lib/components/TextInput.svelte +29 -6
- package/src/lib/components/ThemeSelector.svelte +15 -4
- package/src/lib/components/TimeSlotPicker.svelte +7 -7
- package/src/lib/components/UserAvatar.svelte +14 -1
- package/src/lib/components/VariablePicker.svelte +170 -0
- package/src/lib/components/VoicePlayer.svelte +4 -3
- package/src/lib/components/linked-columns/LinkedColumns.svelte +520 -0
- package/src/lib/components/markup.ts +287 -0
- package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
- package/src/lib/components/messaging/ChannelList.svelte +1 -1
- package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
- package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
- package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
- package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
- package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
- package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
- package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
- package/src/lib/components/messaging/MessageInput.svelte +1 -1
- package/src/lib/components/messaging/MessageItem.svelte +6 -3
- package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
- package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
- package/src/lib/components/messaging/StartDMModal.svelte +1 -1
- package/src/lib/components/pipeline/Pipeline.svelte +4 -4
- package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
- package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
- package/src/lib/components/replay/ChapterTimeline.svelte +326 -0
- package/src/lib/components/session-nav/transcriptModel.ts +352 -0
- package/src/lib/index.ts +138 -0
- package/src/lib/stores/confirmDialog.svelte.ts +48 -0
- package/src/lib/stores/inputDialog.svelte.ts +51 -0
- package/src/lib/styles/rail.css +63 -0
- package/src/lib/types/annotation.ts +38 -0
- package/src/lib/types/comments.ts +97 -0
- package/src/lib/types/entityPreview.ts +45 -0
- package/src/lib/types/filePicker.ts +2 -0
- package/src/lib/types/googleMaps.d.ts +51 -0
- package/src/lib/types/maps.ts +43 -0
- package/src/lib/types/smartImageEditor.ts +39 -0
- package/src/lib/types/templateVars.ts +36 -0
- package/src/lib/utils/dateFormatters.ts +12 -10
- package/src/lib/utils/googleMapsLoader.ts +84 -0
- package/src/lib/utils/taskUtils.ts +21 -7
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface OtherMilestone {
|
|
3
|
+
ordinal: string // "01", "02", etc.
|
|
4
|
+
name: string
|
|
5
|
+
acceptedAt: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
tasks,
|
|
10
|
+
milestoneFilter = "",
|
|
11
|
+
currentMilestoneId = null,
|
|
12
|
+
milestones = [],
|
|
13
|
+
otherMilestones = [],
|
|
14
|
+
showHealth = false,
|
|
15
|
+
paymentDate = null,
|
|
16
|
+
}: {
|
|
17
|
+
tasks: any[]
|
|
18
|
+
milestoneFilter?: string
|
|
19
|
+
currentMilestoneId?: string | null
|
|
20
|
+
milestones?: any[]
|
|
21
|
+
otherMilestones?: OtherMilestone[]
|
|
22
|
+
showHealth?: boolean
|
|
23
|
+
paymentDate?: string | null
|
|
24
|
+
} = $props()
|
|
25
|
+
|
|
26
|
+
const DONE = new Set(["accepted", "deployed", "closed", "completed"])
|
|
27
|
+
|
|
28
|
+
// Layout constants
|
|
29
|
+
const MARGIN = { top: 16, right: 20, bottom: 32, left: 40 }
|
|
30
|
+
const MAIN_H = 150
|
|
31
|
+
const GAP = 10
|
|
32
|
+
const VOL_H = 50
|
|
33
|
+
const TOTAL_H = MARGIN.top + MAIN_H + GAP + VOL_H + MARGIN.bottom
|
|
34
|
+
|
|
35
|
+
const PALETTE = [
|
|
36
|
+
"oklch(0.65 0.20 160)",
|
|
37
|
+
"oklch(0.70 0.15 220)",
|
|
38
|
+
"oklch(0.74 0.16 85)",
|
|
39
|
+
"oklch(0.66 0.19 30)",
|
|
40
|
+
"oklch(0.68 0.16 290)",
|
|
41
|
+
"oklch(0.68 0.17 200)",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
let chartEl: HTMLDivElement | undefined = $state()
|
|
45
|
+
let chartWidth = $state(0)
|
|
46
|
+
|
|
47
|
+
let activeMilestones = $derived.by(() => {
|
|
48
|
+
const ids = new Set(
|
|
49
|
+
tasks
|
|
50
|
+
.filter((t) => t.status !== "dev")
|
|
51
|
+
.map((t) => t.milestone_id)
|
|
52
|
+
.filter(Boolean),
|
|
53
|
+
)
|
|
54
|
+
return (milestones as any[])
|
|
55
|
+
.filter((m) => ids.has(m.id))
|
|
56
|
+
.sort((a, b) => a.sort_order - b.sort_order)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// svelte-ignore state_referenced_locally
|
|
60
|
+
let selected = $state(milestoneFilter)
|
|
61
|
+
$effect(() => {
|
|
62
|
+
selected = milestoneFilter
|
|
63
|
+
})
|
|
64
|
+
|
|
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
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
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")
|
|
106
|
+
if (!relevant.length) return { startDay: Date.now(), totalDays: 1 }
|
|
107
|
+
const min = relevant.reduce(
|
|
108
|
+
(m, t) => Math.min(m, new Date(t.created_at).getTime()),
|
|
109
|
+
Infinity,
|
|
110
|
+
)
|
|
111
|
+
const startDay = new Date(new Date(min).setHours(0, 0, 0, 0)).getTime()
|
|
112
|
+
const totalDays = Math.ceil((Date.now() - startDay) / 86400000)
|
|
113
|
+
return { startDay, totalDays }
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
let lines = $derived.by(() => {
|
|
117
|
+
const { startDay, totalDays } = range
|
|
118
|
+
if (selected === "") {
|
|
119
|
+
return activeMilestones
|
|
120
|
+
.map((m, i) => {
|
|
121
|
+
const pts = buildPts(m.id, startDay, totalDays)
|
|
122
|
+
return pts
|
|
123
|
+
? {
|
|
124
|
+
id: m.id,
|
|
125
|
+
name: m.name,
|
|
126
|
+
color: PALETTE[i % PALETTE.length],
|
|
127
|
+
pts,
|
|
128
|
+
}
|
|
129
|
+
: null
|
|
130
|
+
})
|
|
131
|
+
.filter(Boolean) as {
|
|
132
|
+
id: string
|
|
133
|
+
name: string
|
|
134
|
+
color: string
|
|
135
|
+
pts: { x: number; y: number }[]
|
|
136
|
+
}[]
|
|
137
|
+
} else {
|
|
138
|
+
const m = (milestones as any[]).find((m) => m.id === selected)
|
|
139
|
+
const pts = buildPts(selected, startDay, totalDays)
|
|
140
|
+
return pts
|
|
141
|
+
? [{ id: selected, name: m?.name ?? "", color: PALETTE[0], pts }]
|
|
142
|
+
: []
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Augment each line with its zero-crossing day (first day it hits 0 from above)
|
|
147
|
+
let linesWithZero = $derived(
|
|
148
|
+
lines.map((line) => {
|
|
149
|
+
let zeroCrossingDay: number | null = null
|
|
150
|
+
for (let i = 1; i < line.pts.length; i++) {
|
|
151
|
+
if (line.pts[i].y === 0 && line.pts[i - 1].y > 0) {
|
|
152
|
+
zeroCrossingDay = i
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { ...line, zeroCrossingDay }
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
let anyZeroCrossing = $derived(
|
|
161
|
+
linesWithZero.some((l) => l.zeroCrossingDay !== null),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
let paymentDay = $derived.by((): number | null => {
|
|
165
|
+
if (!paymentDate) return null
|
|
166
|
+
const ts = new Date(new Date(paymentDate).setHours(0, 0, 0, 0)).getTime()
|
|
167
|
+
const day = Math.round((ts - range.startDay) / 86400000)
|
|
168
|
+
return day >= 0 && day <= range.totalDays ? day : null
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Predicted completion ETA from a 7-day rolling net burn rate.
|
|
172
|
+
// Single-milestone mode only.
|
|
173
|
+
let burnEta = $derived.by((): string | null => {
|
|
174
|
+
if (linesWithZero.length !== 1) return null
|
|
175
|
+
const line = linesWithZero[0]
|
|
176
|
+
if (line.zeroCrossingDay !== null) return null
|
|
177
|
+
if (range.totalDays <= 7) return null
|
|
178
|
+
const pts = line.pts
|
|
179
|
+
if (pts.length < 8) return null
|
|
180
|
+
const remaining = pts[pts.length - 1].y
|
|
181
|
+
if (remaining <= 0) return null
|
|
182
|
+
const burnRate = (pts[pts.length - 8].y - pts[pts.length - 1].y) / 7
|
|
183
|
+
if (burnRate <= 0) return null
|
|
184
|
+
const daysToZero = Math.ceil(remaining / burnRate)
|
|
185
|
+
const eta = new Date(Date.now() + daysToZero * 86400000)
|
|
186
|
+
const sameYear = eta.getFullYear() === new Date().getFullYear()
|
|
187
|
+
return eta.toLocaleDateString(
|
|
188
|
+
"en-US",
|
|
189
|
+
sameYear
|
|
190
|
+
? { month: "short", day: "numeric" }
|
|
191
|
+
: { month: "short", day: "numeric", year: "numeric" },
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Other milestone markers (their accepted dates mapped into this chart's day range)
|
|
196
|
+
let milestoneMarkers = $derived.by(() => {
|
|
197
|
+
return otherMilestones
|
|
198
|
+
.map((m) => {
|
|
199
|
+
const ts = new Date(
|
|
200
|
+
new Date(m.acceptedAt).setHours(0, 0, 0, 0),
|
|
201
|
+
).getTime()
|
|
202
|
+
const day = Math.round((ts - range.startDay) / 86400000)
|
|
203
|
+
return { ordinal: m.ordinal, name: m.name, day }
|
|
204
|
+
})
|
|
205
|
+
.filter((m) => m.day > 0 && m.day < range.totalDays)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
let volatility = $derived.by(() => {
|
|
209
|
+
const { startDay, totalDays } = range
|
|
210
|
+
const added = new Array(totalDays + 1).fill(0)
|
|
211
|
+
const closed = new Array(totalDays + 1).fill(0)
|
|
212
|
+
|
|
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")
|
|
217
|
+
|
|
218
|
+
for (const t of relevantTasks) {
|
|
219
|
+
const cd = Math.round(
|
|
220
|
+
(new Date(new Date(t.created_at).setHours(0, 0, 0, 0)).getTime() -
|
|
221
|
+
startDay) /
|
|
222
|
+
86400000,
|
|
223
|
+
)
|
|
224
|
+
if (cd >= 0 && cd <= totalDays) added[cd]++
|
|
225
|
+
if (DONE.has(t.status)) {
|
|
226
|
+
const d = t.closed_at
|
|
227
|
+
? new Date(t.closed_at)
|
|
228
|
+
: t.completed_at
|
|
229
|
+
? new Date(t.completed_at)
|
|
230
|
+
: new Date(t.updated_at)
|
|
231
|
+
const dd = Math.round(
|
|
232
|
+
(new Date(d.setHours(0, 0, 0, 0)).getTime() - startDay) / 86400000,
|
|
233
|
+
)
|
|
234
|
+
if (dd >= 0 && dd <= totalDays) closed[dd]++
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const maxVal = Math.max(1, ...added, ...closed)
|
|
238
|
+
return { added, closed, maxVal }
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Scope creep / stalled health signal
|
|
242
|
+
let health = $derived.by(
|
|
243
|
+
(): {
|
|
244
|
+
state: "stalled" | "scope_creep" | "on_track" | "complete"
|
|
245
|
+
message: string
|
|
246
|
+
} | null => {
|
|
247
|
+
if (!showHealth) return null
|
|
248
|
+
if (linesWithZero.length === 0) return null
|
|
249
|
+
const { added, closed } = volatility
|
|
250
|
+
const total = added.length
|
|
251
|
+
if (total < 7) return null
|
|
252
|
+
|
|
253
|
+
const allComplete = linesWithZero.every((l) => l.zeroCrossingDay !== null)
|
|
254
|
+
if (allComplete) {
|
|
255
|
+
return { state: "complete", message: "All tasks complete" }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let stalledDays = 0
|
|
259
|
+
for (let i = total - 1; i >= 0; i--) {
|
|
260
|
+
if (closed[i] === 0) stalledDays++
|
|
261
|
+
else break
|
|
262
|
+
}
|
|
263
|
+
if (stalledDays >= 5) {
|
|
264
|
+
return {
|
|
265
|
+
state: "stalled",
|
|
266
|
+
message: `No completions in ${stalledDays} days — potentially stalled`,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const windowStart = Math.max(0, total - 7)
|
|
271
|
+
let added7 = 0,
|
|
272
|
+
closed7 = 0
|
|
273
|
+
for (let i = windowStart; i < total; i++) {
|
|
274
|
+
added7 += added[i]
|
|
275
|
+
closed7 += closed[i]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (closed7 > 0 && added7 > closed7 * 1.5) {
|
|
279
|
+
const ratio = added7 / closed7
|
|
280
|
+
const ratioDisp =
|
|
281
|
+
ratio >= 10 ? Math.round(ratio).toString() : ratio.toFixed(1)
|
|
282
|
+
return {
|
|
283
|
+
state: "scope_creep",
|
|
284
|
+
message: `Adding ${ratioDisp}× faster than completing — watch scope`,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const closedPerDay = closed7 / 7
|
|
289
|
+
const closedDisp =
|
|
290
|
+
closedPerDay >= 1
|
|
291
|
+
? Math.round(closedPerDay).toString()
|
|
292
|
+
: closedPerDay.toFixed(1)
|
|
293
|
+
const noun = closedDisp === "1" ? "task" : "tasks"
|
|
294
|
+
return {
|
|
295
|
+
state: "on_track",
|
|
296
|
+
message: `Completing ${closedDisp} ${noun}/day — on track`,
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
let maxY = $derived(
|
|
302
|
+
Math.max(1, ...linesWithZero.flatMap((l) => l.pts.map((p) => p.y))),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
let innerW = $derived(Math.max(0, chartWidth - MARGIN.left - MARGIN.right))
|
|
306
|
+
let volBaseline = $derived(MARGIN.top + MAIN_H + GAP + VOL_H / 2)
|
|
307
|
+
|
|
308
|
+
function xPos(d: number) {
|
|
309
|
+
return (d / range.totalDays) * innerW
|
|
310
|
+
}
|
|
311
|
+
function yMain(y: number) {
|
|
312
|
+
return MARGIN.top + MAIN_H - (y / maxY) * MAIN_H
|
|
313
|
+
}
|
|
314
|
+
function toPolyline(pts: { x: number; y: number }[]) {
|
|
315
|
+
return pts
|
|
316
|
+
.map((p) => `${xPos(p.x).toFixed(1)},${yMain(p.y).toFixed(1)}`)
|
|
317
|
+
.join(" ")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let barW = $derived(Math.max(1, innerW / (range.totalDays + 1) - 0.5))
|
|
321
|
+
function volBarH(count: number) {
|
|
322
|
+
return (count / volatility.maxVal) * (VOL_H / 2)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let yTicks = $derived.by(() => {
|
|
326
|
+
const step =
|
|
327
|
+
maxY <= 10 ? 2 : maxY <= 50 ? 10 : Math.ceil(maxY / 5 / 10) * 10
|
|
328
|
+
const t: number[] = []
|
|
329
|
+
for (let v = 0; v <= maxY; v += step) t.push(v)
|
|
330
|
+
return t
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
let xTicks = $derived.by(() => {
|
|
334
|
+
const d = range.totalDays
|
|
335
|
+
const step = d <= 14 ? 7 : d <= 60 ? 14 : d <= 180 ? 30 : 60
|
|
336
|
+
const t = [0]
|
|
337
|
+
for (let v = step; v < d; v += step) t.push(v)
|
|
338
|
+
t.push(d)
|
|
339
|
+
return [...new Set(t)]
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
function xLabel(d: number) {
|
|
343
|
+
const ts = range.startDay + d * 86400000
|
|
344
|
+
return new Date(ts).toLocaleDateString("en-US", {
|
|
345
|
+
month: "short",
|
|
346
|
+
day: "numeric",
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let tooltipDay = $state<number | null>(null)
|
|
351
|
+
function onMove(e: MouseEvent) {
|
|
352
|
+
if (!chartEl) return
|
|
353
|
+
const rect = chartEl.getBoundingClientRect()
|
|
354
|
+
tooltipDay = Math.max(
|
|
355
|
+
0,
|
|
356
|
+
Math.min(
|
|
357
|
+
range.totalDays,
|
|
358
|
+
Math.round(
|
|
359
|
+
((e.clientX - rect.left - MARGIN.left) / innerW) * range.totalDays,
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
$effect(() => {
|
|
366
|
+
if (!chartEl) return
|
|
367
|
+
const ro = new ResizeObserver((e) => {
|
|
368
|
+
chartWidth = e[0].contentRect.width
|
|
369
|
+
})
|
|
370
|
+
ro.observe(chartEl)
|
|
371
|
+
chartWidth = chartEl.clientWidth
|
|
372
|
+
return () => ro.disconnect()
|
|
373
|
+
})
|
|
374
|
+
</script>
|
|
375
|
+
|
|
376
|
+
<div>
|
|
377
|
+
<div class="mb-4">
|
|
378
|
+
<p class="font-serif italic text-xs text-base-content/40 mb-0.5">
|
|
379
|
+
tasks remaining over time
|
|
380
|
+
</p>
|
|
381
|
+
<h3
|
|
382
|
+
class="font-serif font-medium text-lg text-base-content tracking-tight leading-tight"
|
|
383
|
+
>
|
|
384
|
+
Burn-down
|
|
385
|
+
</h3>
|
|
386
|
+
{#if burnEta}
|
|
387
|
+
<p
|
|
388
|
+
class="font-serif italic text-xs text-base-content/60 mt-1 tabular-nums"
|
|
389
|
+
>
|
|
390
|
+
At current pace: done ~{burnEta}
|
|
391
|
+
</p>
|
|
392
|
+
{/if}
|
|
393
|
+
{#if health}
|
|
394
|
+
<p
|
|
395
|
+
class="font-serif italic text-xs mt-1 tabular-nums"
|
|
396
|
+
style="color:var(--color-{health.state === 'stalled'
|
|
397
|
+
? 'error'
|
|
398
|
+
: health.state === 'scope_creep'
|
|
399
|
+
? 'warning'
|
|
400
|
+
: 'success'})"
|
|
401
|
+
>
|
|
402
|
+
{health.message}
|
|
403
|
+
</p>
|
|
404
|
+
{/if}
|
|
405
|
+
<p class="font-serif italic text-xs text-base-content/40 mt-0.5">
|
|
406
|
+
<span style="color:var(--color-success)">+ added</span>
|
|
407
|
+
<span class="mx-1 text-base-content/20">·</span>
|
|
408
|
+
<span style="color:var(--color-error)">− completed</span>
|
|
409
|
+
{#if anyZeroCrossing}
|
|
410
|
+
<span class="mx-1 text-base-content/20">·</span>
|
|
411
|
+
<span style="color:var(--color-warning)">● done</span>
|
|
412
|
+
{/if}
|
|
413
|
+
{#if paymentDay !== null}
|
|
414
|
+
<span class="mx-1 text-base-content/20">·</span>
|
|
415
|
+
<span style="color:var(--color-success)">$ paid</span>
|
|
416
|
+
{/if}
|
|
417
|
+
{#if selected !== "" && linesWithZero[0]}
|
|
418
|
+
<span class="mx-1 text-base-content/20">·</span>
|
|
419
|
+
{linesWithZero[0].name}
|
|
420
|
+
{/if}
|
|
421
|
+
</p>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<!-- Line legend (multi-milestone) -->
|
|
425
|
+
{#if linesWithZero.length > 1}
|
|
426
|
+
<div
|
|
427
|
+
class="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-serif italic text-base-content/50"
|
|
428
|
+
>
|
|
429
|
+
{#each linesWithZero as line}
|
|
430
|
+
<span class="flex items-center gap-1.5">
|
|
431
|
+
<svg width="16" height="4"
|
|
432
|
+
><line
|
|
433
|
+
x1="0"
|
|
434
|
+
y1="2"
|
|
435
|
+
x2="16"
|
|
436
|
+
y2="2"
|
|
437
|
+
style="stroke:{line.color}"
|
|
438
|
+
stroke-width="2"
|
|
439
|
+
/></svg
|
|
440
|
+
>
|
|
441
|
+
{line.name}
|
|
442
|
+
{#if line.zeroCrossingDay !== null}<span
|
|
443
|
+
style="color:var(--color-warning)"
|
|
444
|
+
>
|
|
445
|
+
●</span
|
|
446
|
+
>{/if}
|
|
447
|
+
</span>
|
|
448
|
+
{/each}
|
|
449
|
+
</div>
|
|
450
|
+
{/if}
|
|
451
|
+
|
|
452
|
+
{#if linesWithZero.length === 0}
|
|
453
|
+
<div
|
|
454
|
+
class="flex items-center justify-center text-sm font-serif italic text-base-content/40"
|
|
455
|
+
style="height:{TOTAL_H}px"
|
|
456
|
+
>
|
|
457
|
+
No data
|
|
458
|
+
</div>
|
|
459
|
+
{:else}
|
|
460
|
+
<div
|
|
461
|
+
bind:this={chartEl}
|
|
462
|
+
class="relative select-none"
|
|
463
|
+
role="img"
|
|
464
|
+
aria-label="Burn-down chart with daily task change"
|
|
465
|
+
onmousemove={onMove}
|
|
466
|
+
onmouseleave={() => (tooltipDay = null)}
|
|
467
|
+
>
|
|
468
|
+
{#if chartWidth > 0}
|
|
469
|
+
<svg width={chartWidth} height={TOTAL_H} class="overflow-visible">
|
|
470
|
+
<g transform="translate({MARGIN.left},0)">
|
|
471
|
+
<!-- Other milestone completion markers (render behind everything) -->
|
|
472
|
+
{#each milestoneMarkers as marker}
|
|
473
|
+
{@const x = xPos(marker.day)}
|
|
474
|
+
<line
|
|
475
|
+
x1={x}
|
|
476
|
+
y1={MARGIN.top}
|
|
477
|
+
x2={x}
|
|
478
|
+
y2={MARGIN.top + MAIN_H}
|
|
479
|
+
stroke="currentColor"
|
|
480
|
+
stroke-opacity="0.1"
|
|
481
|
+
stroke-width="1"
|
|
482
|
+
stroke-dasharray="2,3"
|
|
483
|
+
/>
|
|
484
|
+
<text
|
|
485
|
+
{x}
|
|
486
|
+
y={MARGIN.top + 9}
|
|
487
|
+
text-anchor="middle"
|
|
488
|
+
font-size="8"
|
|
489
|
+
fill="currentColor"
|
|
490
|
+
opacity="0.28"
|
|
491
|
+
font-style="italic">{marker.ordinal}</text
|
|
492
|
+
>
|
|
493
|
+
{/each}
|
|
494
|
+
|
|
495
|
+
<!-- Y grid + labels -->
|
|
496
|
+
{#each yTicks as tick}
|
|
497
|
+
<line
|
|
498
|
+
x1={0}
|
|
499
|
+
y1={yMain(tick)}
|
|
500
|
+
x2={innerW}
|
|
501
|
+
y2={yMain(tick)}
|
|
502
|
+
stroke="currentColor"
|
|
503
|
+
stroke-opacity="0.07"
|
|
504
|
+
/>
|
|
505
|
+
<text
|
|
506
|
+
x={-6}
|
|
507
|
+
y={yMain(tick) + 4}
|
|
508
|
+
text-anchor="end"
|
|
509
|
+
font-size="10"
|
|
510
|
+
fill="currentColor"
|
|
511
|
+
opacity="0.4">{tick}</text
|
|
512
|
+
>
|
|
513
|
+
{/each}
|
|
514
|
+
|
|
515
|
+
<!-- Burn-down lines with zero-crossing split -->
|
|
516
|
+
{#each linesWithZero as line}
|
|
517
|
+
{#if line.zeroCrossingDay !== null}
|
|
518
|
+
<polyline
|
|
519
|
+
points={toPolyline(
|
|
520
|
+
line.pts.slice(0, line.zeroCrossingDay + 1),
|
|
521
|
+
)}
|
|
522
|
+
fill="none"
|
|
523
|
+
stroke={line.color}
|
|
524
|
+
stroke-width="2"
|
|
525
|
+
stroke-linejoin="round"
|
|
526
|
+
stroke-linecap="round"
|
|
527
|
+
/>
|
|
528
|
+
{#if line.zeroCrossingDay < line.pts.length - 1}
|
|
529
|
+
<polyline
|
|
530
|
+
points={toPolyline(line.pts.slice(line.zeroCrossingDay))}
|
|
531
|
+
fill="none"
|
|
532
|
+
stroke={line.color}
|
|
533
|
+
stroke-width="1.5"
|
|
534
|
+
stroke-opacity="0.2"
|
|
535
|
+
stroke-dasharray="3,2"
|
|
536
|
+
stroke-linejoin="round"
|
|
537
|
+
stroke-linecap="round"
|
|
538
|
+
/>
|
|
539
|
+
{/if}
|
|
540
|
+
{@const zcX = xPos(line.zeroCrossingDay)}
|
|
541
|
+
{@const zcY = yMain(0)}
|
|
542
|
+
<circle
|
|
543
|
+
cx={zcX}
|
|
544
|
+
cy={zcY}
|
|
545
|
+
r="5"
|
|
546
|
+
style="fill:var(--color-warning)"
|
|
547
|
+
stroke="var(--fallback-b2,oklch(var(--b2)/1))"
|
|
548
|
+
stroke-width="2"
|
|
549
|
+
/>
|
|
550
|
+
<line
|
|
551
|
+
x1={zcX}
|
|
552
|
+
y1={zcY + 5}
|
|
553
|
+
x2={zcX}
|
|
554
|
+
y2={MARGIN.top + MAIN_H}
|
|
555
|
+
style="stroke:var(--color-warning)"
|
|
556
|
+
stroke-opacity="0.2"
|
|
557
|
+
stroke-width="1"
|
|
558
|
+
/>
|
|
559
|
+
{:else}
|
|
560
|
+
<polyline
|
|
561
|
+
points={toPolyline(line.pts)}
|
|
562
|
+
fill="none"
|
|
563
|
+
stroke={line.color}
|
|
564
|
+
stroke-width="2"
|
|
565
|
+
stroke-linejoin="round"
|
|
566
|
+
stroke-linecap="round"
|
|
567
|
+
/>
|
|
568
|
+
{/if}
|
|
569
|
+
{/each}
|
|
570
|
+
|
|
571
|
+
<!-- Payment marker -->
|
|
572
|
+
{#if paymentDay !== null}
|
|
573
|
+
{@const px = xPos(paymentDay)}
|
|
574
|
+
{@const py = yMain(0)}
|
|
575
|
+
<line
|
|
576
|
+
x1={px}
|
|
577
|
+
y1={MARGIN.top}
|
|
578
|
+
x2={px}
|
|
579
|
+
y2={py - 7}
|
|
580
|
+
style="stroke:var(--color-success)"
|
|
581
|
+
stroke-opacity="0.25"
|
|
582
|
+
stroke-width="1"
|
|
583
|
+
stroke-dasharray="2,3"
|
|
584
|
+
/>
|
|
585
|
+
<circle
|
|
586
|
+
cx={px}
|
|
587
|
+
cy={py}
|
|
588
|
+
r="7"
|
|
589
|
+
style="fill:var(--color-success)"
|
|
590
|
+
stroke="var(--fallback-b2,oklch(var(--b2)/1))"
|
|
591
|
+
stroke-width="2"
|
|
592
|
+
/>
|
|
593
|
+
<text
|
|
594
|
+
x={px}
|
|
595
|
+
y={py + 4}
|
|
596
|
+
text-anchor="middle"
|
|
597
|
+
font-size="9"
|
|
598
|
+
font-weight="700"
|
|
599
|
+
fill="white"
|
|
600
|
+
opacity="0.95">$</text
|
|
601
|
+
>
|
|
602
|
+
<line
|
|
603
|
+
x1={px}
|
|
604
|
+
y1={py + 7}
|
|
605
|
+
x2={px}
|
|
606
|
+
y2={MARGIN.top + MAIN_H}
|
|
607
|
+
style="stroke:var(--color-success)"
|
|
608
|
+
stroke-opacity="0.15"
|
|
609
|
+
stroke-width="1"
|
|
610
|
+
/>
|
|
611
|
+
{/if}
|
|
612
|
+
|
|
613
|
+
<!-- Main chart baseline -->
|
|
614
|
+
<line
|
|
615
|
+
x1={0}
|
|
616
|
+
y1={MARGIN.top + MAIN_H}
|
|
617
|
+
x2={innerW}
|
|
618
|
+
y2={MARGIN.top + MAIN_H}
|
|
619
|
+
stroke="currentColor"
|
|
620
|
+
stroke-opacity="0.12"
|
|
621
|
+
/>
|
|
622
|
+
|
|
623
|
+
<!-- Volatility zero line -->
|
|
624
|
+
<line
|
|
625
|
+
x1={0}
|
|
626
|
+
y1={volBaseline}
|
|
627
|
+
x2={innerW}
|
|
628
|
+
y2={volBaseline}
|
|
629
|
+
stroke="currentColor"
|
|
630
|
+
stroke-opacity="0.15"
|
|
631
|
+
/>
|
|
632
|
+
|
|
633
|
+
<!-- Volatility bars -->
|
|
634
|
+
<g>
|
|
635
|
+
{#each volatility.added as count, d}
|
|
636
|
+
{@const cx = xPos(d)}
|
|
637
|
+
{@const bL = Math.max(0, cx - barW / 2)}
|
|
638
|
+
{@const bR = Math.min(innerW, cx + barW / 2)}
|
|
639
|
+
{@const bW = Math.max(0, bR - bL)}
|
|
640
|
+
{@const ah = volBarH(count)}
|
|
641
|
+
{@const ch = volBarH(volatility.closed[d])}
|
|
642
|
+
{#if count > 0 && bW > 0}
|
|
643
|
+
<rect
|
|
644
|
+
x={bL}
|
|
645
|
+
y={volBaseline - ah}
|
|
646
|
+
width={bW}
|
|
647
|
+
height={ah}
|
|
648
|
+
style="fill:var(--color-success)"
|
|
649
|
+
opacity="0.7"
|
|
650
|
+
rx="0.5"
|
|
651
|
+
/>
|
|
652
|
+
{/if}
|
|
653
|
+
{#if volatility.closed[d] > 0 && bW > 0}
|
|
654
|
+
<rect
|
|
655
|
+
x={bL}
|
|
656
|
+
y={volBaseline}
|
|
657
|
+
width={bW}
|
|
658
|
+
height={ch}
|
|
659
|
+
style="fill:var(--color-error)"
|
|
660
|
+
opacity="0.6"
|
|
661
|
+
rx="0.5"
|
|
662
|
+
/>
|
|
663
|
+
{/if}
|
|
664
|
+
{/each}
|
|
665
|
+
</g>
|
|
666
|
+
|
|
667
|
+
<!-- Crosshair + dots -->
|
|
668
|
+
{#if tooltipDay !== null}
|
|
669
|
+
<line
|
|
670
|
+
x1={xPos(tooltipDay)}
|
|
671
|
+
y1={MARGIN.top}
|
|
672
|
+
x2={xPos(tooltipDay)}
|
|
673
|
+
y2={MARGIN.top + MAIN_H + GAP + VOL_H}
|
|
674
|
+
stroke="currentColor"
|
|
675
|
+
stroke-opacity="0.2"
|
|
676
|
+
stroke-width="1"
|
|
677
|
+
stroke-dasharray="3,3"
|
|
678
|
+
/>
|
|
679
|
+
{#each linesWithZero as line}
|
|
680
|
+
{@const pt =
|
|
681
|
+
line.pts[Math.min(tooltipDay, line.pts.length - 1)]}
|
|
682
|
+
{#if pt}
|
|
683
|
+
<circle
|
|
684
|
+
cx={xPos(pt.x)}
|
|
685
|
+
cy={yMain(pt.y)}
|
|
686
|
+
r="4"
|
|
687
|
+
fill={line.color}
|
|
688
|
+
stroke="var(--fallback-b2,oklch(var(--b2)/1))"
|
|
689
|
+
stroke-width="1.5"
|
|
690
|
+
/>
|
|
691
|
+
{/if}
|
|
692
|
+
{/each}
|
|
693
|
+
{/if}
|
|
694
|
+
|
|
695
|
+
<!-- X axis labels -->
|
|
696
|
+
{#each xTicks as d}
|
|
697
|
+
<text
|
|
698
|
+
x={xPos(d)}
|
|
699
|
+
y={MARGIN.top + MAIN_H + GAP + VOL_H + 18}
|
|
700
|
+
text-anchor="middle"
|
|
701
|
+
font-size="10"
|
|
702
|
+
fill="currentColor"
|
|
703
|
+
opacity="0.4">{xLabel(d)}</text
|
|
704
|
+
>
|
|
705
|
+
{/each}
|
|
706
|
+
</g>
|
|
707
|
+
</svg>
|
|
708
|
+
|
|
709
|
+
<!-- Tooltip -->
|
|
710
|
+
{#if tooltipDay !== null}
|
|
711
|
+
{@const added = volatility.added[tooltipDay] ?? 0}
|
|
712
|
+
{@const done = volatility.closed[tooltipDay] ?? 0}
|
|
713
|
+
{@const marker = milestoneMarkers.find((m) => m.day === tooltipDay)}
|
|
714
|
+
{@const tipL = Math.max(
|
|
715
|
+
60,
|
|
716
|
+
Math.min(chartWidth - 90, MARGIN.left + xPos(tooltipDay)),
|
|
717
|
+
)}
|
|
718
|
+
<div
|
|
719
|
+
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded-lg border border-base-300 bg-base-100 px-3 py-2 text-xs shadow-lg"
|
|
720
|
+
style="left:{tipL}px"
|
|
721
|
+
>
|
|
722
|
+
<div class="mb-1 font-serif italic text-xs text-base-content/50">
|
|
723
|
+
{xLabel(tooltipDay)}
|
|
724
|
+
</div>
|
|
725
|
+
{#each linesWithZero as line}
|
|
726
|
+
{@const pt = line.pts[Math.min(tooltipDay, line.pts.length - 1)]}
|
|
727
|
+
{@const isZero = line.zeroCrossingDay === tooltipDay}
|
|
728
|
+
{#if pt}
|
|
729
|
+
<div
|
|
730
|
+
class="flex items-center gap-1.5 mb-0.5"
|
|
731
|
+
style="color:{isZero ? 'var(--color-warning)' : line.color}"
|
|
732
|
+
>
|
|
733
|
+
<span
|
|
734
|
+
class="inline-block h-1.5 w-3 rounded-full"
|
|
735
|
+
style="background:currentColor"
|
|
736
|
+
></span>
|
|
737
|
+
{#if selected === ""}<span
|
|
738
|
+
class="text-[10px] text-base-content/40"
|
|
739
|
+
>{line.name}:</span
|
|
740
|
+
>{/if}
|
|
741
|
+
{isZero ? "complete ·" : ""}
|
|
742
|
+
{pt.y} remaining
|
|
743
|
+
</div>
|
|
744
|
+
{/if}
|
|
745
|
+
{/each}
|
|
746
|
+
{#if marker}
|
|
747
|
+
<div
|
|
748
|
+
class="font-serif italic text-[10px] text-base-content/50 mt-0.5"
|
|
749
|
+
>
|
|
750
|
+
{marker.ordinal} accepted
|
|
751
|
+
</div>
|
|
752
|
+
{/if}
|
|
753
|
+
{#if paymentDay !== null && tooltipDay === paymentDay}
|
|
754
|
+
<div
|
|
755
|
+
class="font-serif italic text-[10px] mt-0.5"
|
|
756
|
+
style="color:var(--color-success)"
|
|
757
|
+
>
|
|
758
|
+
$ payment received
|
|
759
|
+
</div>
|
|
760
|
+
{/if}
|
|
761
|
+
{#if added > 0 || done > 0}
|
|
762
|
+
<div
|
|
763
|
+
class="mt-1 border-t border-base-300 pt-1 flex gap-3 font-serif italic text-[10px]"
|
|
764
|
+
>
|
|
765
|
+
{#if added > 0}<span style="color:var(--color-success)"
|
|
766
|
+
>+{added} added</span
|
|
767
|
+
>{/if}
|
|
768
|
+
{#if done > 0}<span style="color:var(--color-error)"
|
|
769
|
+
>−{done} completed</span
|
|
770
|
+
>{/if}
|
|
771
|
+
</div>
|
|
772
|
+
{/if}
|
|
773
|
+
</div>
|
|
774
|
+
{/if}
|
|
775
|
+
{/if}
|
|
776
|
+
</div>
|
|
777
|
+
{/if}
|
|
778
|
+
</div>
|