@joewinke/jatui 0.1.10 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +123 -0
  2. package/package.json +3 -1
  3. package/src/lib/actions/railNav.ts +473 -0
  4. package/src/lib/components/AnnotationLayer.svelte +108 -0
  5. package/src/lib/components/AnnotationPanel.svelte +319 -0
  6. package/src/lib/components/AudioWaveform.svelte +9 -5
  7. package/src/lib/components/AvailabilityModal.svelte +7 -3
  8. package/src/lib/components/AvatarUpload.svelte +27 -4
  9. package/src/lib/components/BookingForm.svelte +11 -9
  10. package/src/lib/components/BurndownChart.svelte +778 -0
  11. package/src/lib/components/Button.svelte +10 -1
  12. package/src/lib/components/CalendarPicker.svelte +3 -3
  13. package/src/lib/components/Card.svelte +2 -2
  14. package/src/lib/components/ChipInput.svelte +21 -15
  15. package/src/lib/components/ColorSelector.svelte +17 -13
  16. package/src/lib/components/CommentThread.svelte +773 -0
  17. package/src/lib/components/ConfirmDialog.svelte +348 -0
  18. package/src/lib/components/ConfirmModal.svelte +78 -11
  19. package/src/lib/components/ContextMenu.svelte +188 -0
  20. package/src/lib/components/CountdownTimer.svelte +1 -1
  21. package/src/lib/components/DateRangePicker.svelte +6 -4
  22. package/src/lib/components/Drawer.svelte +36 -3
  23. package/src/lib/components/EntityPreviewCard.svelte +104 -0
  24. package/src/lib/components/FileDropzone.svelte +493 -0
  25. package/src/lib/components/FilePicker.svelte +83 -14
  26. package/src/lib/components/FileThumbnail.svelte +80 -0
  27. package/src/lib/components/FilterDropdown.svelte +11 -11
  28. package/src/lib/components/HunkDiffView.svelte +348 -0
  29. package/src/lib/components/ImageLightbox.svelte +274 -0
  30. package/src/lib/components/ImageUpload.svelte +58 -9
  31. package/src/lib/components/InlineEdit.svelte +15 -9
  32. package/src/lib/components/InputDialog.svelte +327 -0
  33. package/src/lib/components/LazyImage.svelte +1 -0
  34. package/src/lib/components/LinkShortener.svelte +1 -1
  35. package/src/lib/components/LoadingSpinner.svelte +6 -2
  36. package/src/lib/components/MarkupEditor.svelte +485 -0
  37. package/src/lib/components/MarkupOverlay.svelte +55 -0
  38. package/src/lib/components/MediaWorkbench.svelte +871 -0
  39. package/src/lib/components/MilestoneCard.svelte +1 -1
  40. package/src/lib/components/MilestoneTimeline.svelte +1 -1
  41. package/src/lib/components/Modal.svelte +39 -4
  42. package/src/lib/components/PDFViewer.svelte +105 -0
  43. package/src/lib/components/PdfThumbnail.svelte +3 -1
  44. package/src/lib/components/PhoneInput.svelte +183 -63
  45. package/src/lib/components/ResizablePanel.svelte +4 -4
  46. package/src/lib/components/SearchDropdown.svelte +26 -13
  47. package/src/lib/components/SelectInput.svelte +26 -4
  48. package/src/lib/components/SidebarUserFooter.svelte +1 -1
  49. package/src/lib/components/SignaturePad.svelte +8 -4
  50. package/src/lib/components/SmartImageEditor.svelte +720 -0
  51. package/src/lib/components/SortDropdown.svelte +9 -3
  52. package/src/lib/components/Sparkline.svelte +9 -0
  53. package/src/lib/components/StatusBadge.svelte +20 -18
  54. package/src/lib/components/TextArea.svelte +24 -5
  55. package/src/lib/components/TextInput.svelte +29 -6
  56. package/src/lib/components/ThemeSelector.svelte +15 -4
  57. package/src/lib/components/TimeSlotPicker.svelte +7 -7
  58. package/src/lib/components/UserAvatar.svelte +14 -1
  59. package/src/lib/components/VariablePicker.svelte +170 -0
  60. package/src/lib/components/VoicePlayer.svelte +4 -3
  61. package/src/lib/components/markup.ts +287 -0
  62. package/src/lib/components/messaging/ChannelInfoModal.svelte +9 -9
  63. package/src/lib/components/messaging/ChannelList.svelte +1 -1
  64. package/src/lib/components/messaging/ChannelMembersModal.svelte +1 -1
  65. package/src/lib/components/messaging/CreateChannelModal.svelte +1 -1
  66. package/src/lib/components/messaging/DirectMessageList.svelte +1 -1
  67. package/src/lib/components/messaging/EmojiSelector.svelte +2 -1
  68. package/src/lib/components/messaging/MentionAutocomplete.svelte +1 -1
  69. package/src/lib/components/messaging/MessageAttachment.svelte +3 -3
  70. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +3 -3
  71. package/src/lib/components/messaging/MessageInput.svelte +1 -1
  72. package/src/lib/components/messaging/MessageItem.svelte +6 -3
  73. package/src/lib/components/messaging/NotificationSettingsModal.svelte +1 -1
  74. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +6 -1
  75. package/src/lib/components/messaging/StartDMModal.svelte +1 -1
  76. package/src/lib/components/pipeline/Pipeline.svelte +4 -4
  77. package/src/lib/components/pipeline/PipelineCard.svelte +1 -1
  78. package/src/lib/components/pipeline/PipelineColumn.svelte +8 -3
  79. package/src/lib/index.ts +105 -1
  80. package/src/lib/stores/confirmDialog.svelte.ts +48 -0
  81. package/src/lib/stores/inputDialog.svelte.ts +51 -0
  82. package/src/lib/styles/rail.css +63 -0
  83. package/src/lib/types/annotation.ts +38 -0
  84. package/src/lib/types/comments.ts +97 -0
  85. package/src/lib/types/entityPreview.ts +45 -0
  86. package/src/lib/types/filePicker.ts +2 -0
  87. package/src/lib/types/smartImageEditor.ts +39 -0
  88. package/src/lib/types/templateVars.ts +36 -0
  89. package/src/lib/utils/dateFormatters.ts +12 -10
  90. package/src/lib/utils/phone.ts +80 -0
  91. 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>