@heyramzi/ui 2.0.1 → 2.2.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 +48 -24
- package/scripts/sync-shadcn.mjs +3 -4
- package/src/agents/agent-avatar.svelte +604 -575
- package/src/agents/agent-cast.svelte +179 -183
- package/src/agents/demo.svelte +125 -113
- package/src/agents/helpers.ts +23 -17
- package/src/agents/index.ts +4 -4
- package/src/agents/page-state.ts +6 -6
- package/src/agents/types.ts +6 -6
- package/src/ai/ai-button/AiButton.svelte +4 -12
- package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
- package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
- package/src/ai/response/Response.css +2 -3
- package/src/animations/avatar-group-hover.css +2 -16
- package/src/animations/card-tilt.css +3 -6
- package/src/animations/digit-pop-in.css +2 -15
- package/src/animations/dropdown.css +4 -17
- package/src/animations/input-clear-dissolve.css +5 -18
- package/src/animations/input-shake.css +9 -41
- package/src/animations/learn-more-hover.css +17 -9
- package/src/animations/like-button.css +2 -4
- package/src/animations/page-side-by-side.css +2 -10
- package/src/animations/plus-menu-morph.css +2 -5
- package/src/animations/shimmer-text.css +5 -18
- package/src/animations/spinning-counter.css +2 -6
- package/src/animations/success-check.css +2 -15
- package/src/animations/thinking-states.css +2 -7
- package/src/brand/brand.test.ts +21 -5
- package/src/brand/index.ts +4 -10
- package/src/canvas/README.md +9 -12
- package/src/canvas/card/card-frame.svelte +43 -39
- package/src/canvas/card/resize-handles.svelte +89 -80
- package/src/canvas/chrome/chrome-zoom.svelte +101 -100
- package/src/canvas/chrome/lock-badge.svelte +8 -8
- package/src/canvas/chrome/side-handles.svelte +24 -19
- package/src/canvas/chrome/size-chip.svelte +30 -30
- package/src/canvas/chrome/snap-guides.svelte +20 -20
- package/src/canvas/components/icons/ToolIcon.svelte +8 -8
- package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
- package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
- package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
- package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
- package/src/canvas/edges/bend-handle.svelte +46 -46
- package/src/canvas/edges/edge-gradient.svelte +10 -10
- package/src/canvas/edges/gradient-edge.svelte +45 -29
- package/src/canvas/editor/lock.test.ts +6 -1
- package/src/canvas/editor/style.ts +7 -1
- package/src/canvas/headless/svg-figure.test.ts +5 -1
- package/src/canvas/lod/lod-switch.svelte +111 -106
- package/src/canvas/pen/PenField.svelte +87 -81
- package/src/canvas/pen/PenOverlay.svelte +127 -127
- package/src/canvas/pen/ShapeNode.svelte +31 -32
- package/src/canvas/pen/TextNode.svelte +54 -53
- package/src/canvas/pen/host-field.test.svelte +7 -7
- package/src/canvas/pen/shapes.ts +7 -1
- package/src/canvas/screen/GlyphIcon.svelte +22 -17
- package/src/canvas/screen/PartView.svelte +138 -72
- package/src/canvas/screen/ScreenBody.svelte +81 -60
- package/src/canvas/screen/ScreenCard.svelte +20 -20
- package/src/canvas/ui/CanvasToolbar.svelte +173 -85
- package/src/canvas/ui/FrameLayer.svelte +59 -59
- package/src/canvas/ui/PenLayer.svelte +39 -30
- package/src/canvas/ui/StylePanel.svelte +119 -102
- package/src/canvas/ui/canvas-grid.svelte +137 -129
- package/src/charts/animated-value.svelte +1 -1
- package/src/charts/area-chart.css +2 -5
- package/src/charts/area-chart.svelte +1 -1
- package/src/charts/bar-chart.css +2 -3
- package/src/charts/bar-chart.svelte +1 -1
- package/src/charts/charts.css +5 -17
- package/src/charts/crosshair.svelte +1 -1
- package/src/charts/dashboard/dashboard-empty.svelte +25 -25
- package/src/charts/dashboard/dashboard-section.svelte +24 -24
- package/src/charts/dashboard/data-panel.svelte +50 -50
- package/src/charts/dashboard/data-sort-head.svelte +56 -56
- package/src/charts/dashboard/data-table.svelte +138 -138
- package/src/charts/dashboard/panel-grid.svelte +30 -30
- package/src/charts/dashboard/period-filter.svelte +25 -25
- package/src/charts/dashboard/rating-chip.svelte +19 -19
- package/src/charts/dashboard/stat-band.svelte +32 -32
- package/src/charts/dashboard/stat-tile.svelte +106 -99
- package/src/charts/donut-chart.svelte +1 -1
- package/src/charts/edge-fade.svelte +1 -1
- package/src/charts/figure.svelte +1 -1
- package/src/charts/gauge-chart.css +4 -7
- package/src/charts/gauge-chart.svelte +1 -1
- package/src/charts/hover-focus.svelte +1 -1
- package/src/charts/legend.css +1 -3
- package/src/charts/legend.svelte +1 -1
- package/src/charts/line-chart.svelte +1 -1
- package/src/charts/number-chart.css +6 -9
- package/src/charts/number-chart.svelte +1 -1
- package/src/charts/reference-line.svelte +19 -19
- package/src/charts/reveal.svelte +1 -1
- package/src/charts/ring-chart.css +2 -4
- package/src/charts/ring-chart.svelte +1 -1
- package/src/charts/sparkline.svelte +5 -2
- package/src/charts/stat-card.svelte +1 -1
- package/src/charts/time-series-chart.css +2 -5
- package/src/charts/trend-pill.css +2 -4
- package/src/charts/trend-pill.svelte +1 -1
- package/src/components/copy/copy-button.svelte +93 -98
- package/src/components/copy/copy-button.test.ts +10 -2
- package/src/components/data-table/infinite-scroll.svelte +42 -42
- package/src/components/data-table/pagination.svelte +34 -22
- package/src/components/data-table/row-arrow.svelte +19 -16
- package/src/components/data-table/row-context-menu.svelte +52 -52
- package/src/components/data-table/sortable-head.svelte +25 -25
- package/src/components/data-table/table-skeleton.svelte +41 -36
- package/src/components/docs/doc-page.svelte +18 -9
- package/src/components/docs/on-this-page.svelte +16 -3
- package/src/components/docs/scroll-spy.svelte.ts +2 -3
- package/src/components/field-row/field-row.svelte +10 -8
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
- package/src/components/icon/icon.svelte +14 -14
- package/src/components/language-flag/language-flag.svelte +3 -1
- package/src/components/list-page/detail-empty.svelte +34 -34
- package/src/components/list-page/detail-header.svelte +55 -47
- package/src/components/list-page/detail-panel.svelte +51 -59
- package/src/components/list-page/detail-row.svelte +19 -19
- package/src/components/list-page/detail-stack.svelte +21 -21
- package/src/components/list-page/empty-state.svelte +30 -30
- package/src/components/list-page/filter-item.svelte +20 -20
- package/src/components/list-page/filter-popover.svelte +60 -55
- package/src/components/list-page/grouped-list.svelte +4 -2
- package/src/components/list-page/index.ts +8 -4
- package/src/components/list-page/list-page-shell.svelte +2 -5
- package/src/components/list-page/master-detail.svelte +87 -88
- package/src/components/list-page/notice-bar.svelte +38 -38
- package/src/components/list-page/prev-next.svelte +63 -63
- package/src/components/list-page/rail-body.svelte +18 -18
- package/src/components/list-page/rail-empty.svelte +23 -30
- package/src/components/list-page/rail-head.svelte +24 -24
- package/src/components/list-page/rail-row.svelte +77 -73
- package/src/components/list-page/section-host.ts +2 -3
- package/src/components/locale-switch/locale-switch.svelte +41 -41
- package/src/components/mark/mark.svelte +34 -38
- package/src/components/open-in/index.ts +1 -5
- package/src/components/open-in/open-in.svelte +40 -40
- package/src/components/picker/mark-picker.svelte +49 -49
- package/src/components/picker/picker.svelte +5 -3
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.svelte +1 -1
- package/src/components/section-header/section-header.test.ts +7 -1
- package/src/components/section-header/section-label.svelte +39 -0
- package/src/components/segmented/animated-tabs.svelte +31 -2
- package/src/components/segmented/collapse.test.ts +2 -1
- package/src/components/segmented/index.ts +2 -0
- package/src/components/segmented/segment.svelte +52 -49
- package/src/components/segmented/segmented-filter.svelte +79 -80
- package/src/components/segmented/segmented-nav.svelte +34 -40
- package/src/components/segmented/segmented-overflow.svelte +53 -50
- package/src/components/segmented/segmented-track.svelte +62 -46
- package/src/components/segmented/segmented.ts +2 -3
- package/src/components/star-rating-input/star-rating-input.svelte +103 -103
- package/src/components/svg/svg.svelte +4 -4
- package/src/components/table-view/table-view.svelte +17 -2
- package/src/components/table-view/table-view.test.ts +7 -1
- package/src/components/table-view/types.ts +11 -11
- package/src/components/tags-input/index.ts +13 -0
- package/src/components/tags-input/tags-input.svelte +305 -0
- package/src/components/tags-input/tags.test.ts +64 -0
- package/src/components/tags-input/tags.ts +142 -0
- package/src/components/theme/index.ts +2 -4
- package/src/components/theme/theme-provider.svelte +1 -3
- package/src/components/toolbar/index.ts +2 -0
- package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
- package/src/components/toolbar/toolbar.svelte +13 -9
- package/src/components/toolbar/toolbar.test.ts +10 -1
- package/src/design/index.css +21 -78
- package/src/design/polish.css +26 -35
- package/src/editor/editor-toolbar.svelte +104 -99
- package/src/editor/editor.svelte +296 -266
- package/src/editor/image-toolbar.svelte +20 -22
- package/src/editor/index.ts +1 -4
- package/src/editor/inline-editor.svelte +280 -238
- package/src/editor/markdown.ts +15 -15
- package/src/editor/renderer.svelte +71 -71
- package/src/editor/slash-menu.svelte +24 -27
- package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
- package/src/effects/image-comparison/ImageComparison.svelte +11 -1
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
- package/src/effects/magic-card/MagicCard.css +14 -7
- package/src/effects/marquee/Marquee.svelte +3 -5
- package/src/effects/reveal/observe.ts +0 -3
- package/src/effects/spotlight/Spotlight.svelte +1 -1
- package/src/effects/theme-toggle/ThemeToggle.css +25 -22
- package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
- package/src/gallery.test.ts +14 -4
- package/src/inbox/README.md +27 -0
- package/src/inbox/inbox.test.ts +214 -0
- package/src/inbox/index.ts +31 -0
- package/src/inbox/logic.ts +169 -0
- package/src/inbox/thread-inbox.svelte +439 -0
- package/src/inbox/types.ts +161 -0
- package/src/lib/theme-transition.ts +2 -4
- package/src/lib/tone.ts +2 -4
- package/src/lib/utils.ts +2 -2
- package/src/modules.test.ts +105 -0
- package/src/onboarding/define-tours.test.ts +55 -40
- package/src/onboarding/define-tours.ts +23 -23
- package/src/onboarding/demo.svelte +24 -5
- package/src/onboarding/index.ts +1 -8
- package/src/onboarding/onboarding-provider.svelte +19 -7
- package/src/onboarding/onboarding.svelte.ts +16 -16
- package/src/onboarding/pick.test.ts +61 -52
- package/src/onboarding/pick.ts +16 -13
- package/src/onboarding/stores.test.ts +80 -80
- package/src/onboarding/stores.ts +41 -39
- package/src/onboarding/tour-card.svelte +30 -5
- package/src/onboarding/tour-runner.svelte +12 -3
- package/src/onboarding/types.ts +20 -12
- package/src/onboarding/wait.ts +22 -22
- package/src/panel/README.md +5 -0
- package/src/panel/index.ts +2 -0
- package/src/panel/side-panel.svelte +194 -0
- package/src/record/README.md +3 -0
- package/src/record/demo-data.ts +352 -0
- package/src/record/field-section.svelte +57 -0
- package/src/record/fields.ts +194 -0
- package/src/record/format.ts +94 -0
- package/src/{tasks/record → record}/index.ts +49 -35
- package/src/{tasks/record → record}/info-list.svelte +145 -114
- package/src/record/kinds.ts +42 -0
- package/src/record/logic.ts +326 -0
- package/src/record/next-up.svelte +81 -0
- package/src/{tasks/record → record}/project-list.svelte +56 -50
- package/src/{tasks/record → record}/record-demo.svelte +130 -110
- package/src/{tasks/record → record}/record-header.svelte +37 -28
- package/src/{tasks/record → record}/record-shell.svelte +54 -54
- package/src/record/record-stats.svelte +48 -0
- package/src/record/record-timeline.svelte +251 -0
- package/src/record/record.test.ts +471 -0
- package/src/{tasks/record → record}/steps-rail.svelte +225 -216
- package/src/record/types.ts +299 -0
- package/src/record/where-we-are.svelte +125 -0
- package/src/shadcn/button/button.svelte +1 -1
- package/src/shadcn/card/card-title.svelte +1 -5
- package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
- package/src/shadcn/sheet/sheet-content.svelte +2 -4
- package/src/shadcn/table/table-cell.svelte +2 -3
- package/src/shadcn/utilities.css +3 -8
- package/src/slides/Controls.svelte +150 -124
- package/src/slides/Deck.svelte +243 -238
- package/src/slides/Fragment.svelte +64 -57
- package/src/slides/Overview.svelte +106 -101
- package/src/slides/Presenter.svelte +300 -266
- package/src/slides/Slide.svelte +185 -170
- package/src/slides/SlideList.svelte +16 -12
- package/src/slides/ambient.d.ts +1 -1
- package/src/slides/bridges.ts +42 -23
- package/src/slides/deck-controller.svelte.ts +47 -35
- package/src/slides/demo-slides.svelte +330 -306
- package/src/slides/demo.svelte +164 -160
- package/src/slides/export/capture.ts +20 -16
- package/src/slides/export/ffmpeg.ts +43 -38
- package/src/slides/export/index.ts +5 -5
- package/src/slides/export/launch.ts +3 -1
- package/src/slides/export/pdf.ts +30 -27
- package/src/slides/export/types.ts +10 -8
- package/src/slides/index.ts +11 -11
- package/src/slides/labels.ts +22 -19
- package/src/slides/layout.ts +9 -2
- package/src/slides/model.ts +4 -4
- package/src/slides/navigation.ts +43 -31
- package/src/slides/theme.css +5 -7
- package/src/slides/types.ts +9 -10
- package/src/status/README.md +18 -0
- package/src/status/define-statuses.ts +25 -0
- package/src/status/index.ts +11 -0
- package/src/{tasks → status}/status-badge.svelte +3 -1
- package/src/{tasks → status}/status-dot.svelte +1 -3
- package/src/{tasks → status}/status-icon.svelte +1 -1
- package/src/status/status-types.ts +40 -0
- package/src/tasks/README.md +1 -1
- package/src/tasks/board-order.ts +2 -4
- package/src/tasks/demo.svelte +192 -34
- package/src/tasks/detail/detail-demo.svelte +75 -15
- package/src/tasks/detail/detail-types.ts +45 -45
- package/src/tasks/detail/task-detail.svelte +40 -10
- package/src/tasks/detail/task-property.svelte +8 -2
- package/src/tasks/detail/task-thread.svelte +53 -12
- package/src/tasks/gantt/gantt-bar.svelte +17 -6
- package/src/tasks/gantt/gantt-grid.svelte +40 -9
- package/src/tasks/gantt/gantt-group-row.svelte +8 -2
- package/src/tasks/gantt/gantt-header.svelte +9 -3
- package/src/tasks/gantt/gantt-task-row.svelte +26 -6
- package/src/tasks/gantt/gantt.css +2 -1
- package/src/tasks/gantt/gantt.test.ts +4 -2
- package/src/tasks/gantt/logic.test.ts +110 -20
- package/src/tasks/gantt/logic.ts +42 -6
- package/src/tasks/inbox/inbox-card.svelte +15 -7
- package/src/tasks/inbox/inbox-draft.svelte +1 -1
- package/src/tasks/inbox/inbox-logic.ts +6 -40
- package/src/tasks/inbox/inbox-statuses.ts +24 -24
- package/src/tasks/inbox/inbox-trace.svelte +9 -3
- package/src/tasks/inbox/inbox-types.ts +54 -58
- package/src/tasks/inbox/index.ts +22 -21
- package/src/tasks/index.ts +52 -53
- package/src/tasks/kanban-board.svelte +16 -12
- package/src/tasks/kanban-types.ts +2 -3
- package/src/tasks/presets/sales.ts +7 -7
- package/src/tasks/task-gantt.svelte +28 -8
- package/src/tasks/task-list.svelte +41 -17
- package/src/tasks/time/duration.ts +40 -40
- package/src/tasks/time/time-chip.svelte +9 -2
- package/src/tasks/time/time-dot.svelte +1 -1
- package/src/tasks/time/time-ring.svelte +11 -3
- package/src/tasks/time/time-tracker.svelte +57 -15
- package/src/tasks/time/time-types.ts +57 -57
- package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
- package/src/thread/README.md +31 -0
- package/src/thread/index.ts +12 -0
- package/src/thread/logic.ts +61 -0
- package/src/thread/thread.svelte +432 -0
- package/src/thread/thread.test.ts +73 -0
- package/src/thread/types.ts +93 -0
- package/src/tokens/craft.css +17 -53
- package/src/tokens/index.css +3 -13
- package/src/tokens/palette.css +13 -11
- package/src/tokens/primitives.css +10 -23
- package/src/components/list-page/section-label.svelte +0 -39
- package/src/tasks/define-statuses.ts +0 -32
- package/src/tasks/inbox/inbox.svelte +0 -203
- package/src/tasks/record/demo-data.ts +0 -286
- package/src/tasks/record/field-section.svelte +0 -53
- package/src/tasks/record/fields.ts +0 -142
- package/src/tasks/record/format.ts +0 -87
- package/src/tasks/record/kinds.ts +0 -42
- package/src/tasks/record/logic.ts +0 -324
- package/src/tasks/record/next-up.svelte +0 -70
- package/src/tasks/record/record-stats.svelte +0 -48
- package/src/tasks/record/record-timeline.svelte +0 -231
- package/src/tasks/record/record.test.ts +0 -350
- package/src/tasks/record/types.ts +0 -280
- package/src/tasks/record/where-we-are.svelte +0 -121
- package/src/tasks/side-panel.svelte +0 -193
- package/src/tasks/status-types.ts +0 -40
- /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
- /package/src/{tasks → panel}/side-panel-types.ts +0 -0
- /package/src/{tasks/record → record}/gutter.ts +0 -0
|
@@ -2,68 +2,68 @@ import type { Tone } from "../../lib/tone.js";
|
|
|
2
2
|
|
|
3
3
|
/** "90", "45m", "1h", "1.5h", "1h30", "1h 30m" and "1:30" to whole minutes. Null when it isn't a duration. */
|
|
4
4
|
export function parseMinutes(text: string): number | null {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
const value = text.trim().toLowerCase();
|
|
6
|
+
if (!value) return null;
|
|
7
|
+
if (/^\d+$/.test(value)) return Number(value);
|
|
8
|
+
const clock = /^(\d+):([0-5]?\d)$/.exec(value);
|
|
9
|
+
if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
|
|
10
|
+
const decimalHours = /^(\d+(?:[.,]\d+)?)\s*h$/.exec(value);
|
|
11
|
+
if (decimalHours?.[1]) return Math.round(Number(decimalHours[1].replace(",", ".")) * 60);
|
|
12
|
+
const parts = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?$/.exec(value);
|
|
13
|
+
if (!parts || (!parts[1] && !parts[2])) return null;
|
|
14
|
+
return Number(parts[1] ?? 0) * 60 + Number(parts[2] ?? 0);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** 150 to "2h 30m". */
|
|
18
18
|
export function formatMinutes(minutes: number): string {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const whole = Math.max(0, Math.round(Number.isFinite(minutes) ? minutes : 0));
|
|
20
|
+
const hours = Math.floor(whole / 60);
|
|
21
|
+
const rest = whole % 60;
|
|
22
|
+
if (hours === 0) return `${rest}m`;
|
|
23
|
+
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Milliseconds to the timer readout, "1:05:09". */
|
|
27
27
|
export function formatClock(ms: number): { hours: string; minutes: string; seconds: string } {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
29
|
+
return {
|
|
30
|
+
hours: String(Math.floor(total / 3600)),
|
|
31
|
+
minutes: String(Math.floor((total % 3600) / 60)).padStart(2, "0"),
|
|
32
|
+
seconds: String(total % 60).padStart(2, "0"),
|
|
33
|
+
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** The reader's own calendar day as `YYYY-MM-DD`. */
|
|
37
37
|
export function localDay(date: Date = new Date()): string {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
39
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
40
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** "Today", "Yesterday", then "Mon, Oct 12", in the given locale. */
|
|
44
44
|
export function dayLabel(day: string, locale?: string): string {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
const date = new Date(`${day}T12:00:00`);
|
|
46
|
+
const now = new Date();
|
|
47
|
+
now.setHours(12, 0, 0, 0);
|
|
48
|
+
const gap = Math.round((date.getTime() - now.getTime()) / 86_400_000);
|
|
49
|
+
if (gap === 0 || gap === -1) {
|
|
50
|
+
const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(gap, "day");
|
|
51
|
+
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
|
52
|
+
}
|
|
53
|
+
return date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** The tone of a budget: quiet until 90%, warning to 100%, destructive past it. */
|
|
57
57
|
export function budgetTone(logged: number, estimate: number | null | undefined): Tone {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
if (!estimate || estimate <= 0) return "neutral";
|
|
59
|
+
const ratio = logged / estimate;
|
|
60
|
+
if (ratio > 1) return "destructive";
|
|
61
|
+
return ratio >= 0.9 ? "warning" : "neutral";
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** A stable color for a person, from their name or id. */
|
|
65
65
|
export function personColor(key: string): string {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
let hash = 0;
|
|
67
|
+
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 360;
|
|
68
|
+
return `oklch(0.72 0.12 ${hash})`;
|
|
69
69
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
/** Replaces the spoken name. Defaults to a sentence built from the numbers. */
|
|
17
17
|
label?: string;
|
|
18
18
|
class?: string;
|
|
19
|
-
}
|
|
19
|
+
}
|
|
20
20
|
|
|
21
21
|
let { minutes, estimate, running = false, label, class: className }: Props = $props();
|
|
22
22
|
|
|
@@ -56,7 +56,14 @@
|
|
|
56
56
|
<span class="size-3 shrink-0 [&>svg]:size-full" aria-hidden="true"><Clock /></span>
|
|
57
57
|
{/if}
|
|
58
58
|
<span aria-hidden="true">
|
|
59
|
-
{formatMinutes(minutes)}{#if estimate}<span
|
|
59
|
+
{formatMinutes(minutes)}{#if estimate}<span
|
|
60
|
+
class={cn(
|
|
61
|
+
"font-normal",
|
|
62
|
+
tone === "neutral" ? "text-muted-foreground/70" : TONE_TEXT[tone],
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
/ {formatMinutes(estimate)}</span
|
|
66
|
+
>{/if}
|
|
60
67
|
</span>
|
|
61
68
|
</span>
|
|
62
69
|
{/if}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
/** Rendered in the middle, e.g. the percentage. */
|
|
18
18
|
children?: Snippet;
|
|
19
19
|
class?: string;
|
|
20
|
-
}
|
|
20
|
+
}
|
|
21
21
|
|
|
22
22
|
let { value, max, size = 56, stroke = 4, label, children, class: className }: Props = $props();
|
|
23
23
|
|
|
@@ -36,7 +36,14 @@
|
|
|
36
36
|
aria-hidden={label ? undefined : "true"}
|
|
37
37
|
>
|
|
38
38
|
<svg viewBox="0 0 36 36" class="size-full -rotate-90" fill="none" aria-hidden="true">
|
|
39
|
-
<circle
|
|
39
|
+
<circle
|
|
40
|
+
cx="18"
|
|
41
|
+
cy="18"
|
|
42
|
+
r={R}
|
|
43
|
+
stroke="currentColor"
|
|
44
|
+
stroke-width={stroke}
|
|
45
|
+
class="text-foreground/10"
|
|
46
|
+
/>
|
|
40
47
|
<circle
|
|
41
48
|
cx="18"
|
|
42
49
|
cy="18"
|
|
@@ -58,7 +65,8 @@
|
|
|
58
65
|
<style>
|
|
59
66
|
.time-ring-arc {
|
|
60
67
|
transition:
|
|
61
|
-
stroke-dasharray var(--duration-slow, 400ms)
|
|
68
|
+
stroke-dasharray var(--duration-slow, 400ms)
|
|
69
|
+
var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
62
70
|
color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
63
71
|
}
|
|
64
72
|
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
personColor,
|
|
14
14
|
} from "./duration.js";
|
|
15
15
|
import { DEFAULT_TIME_LABELS } from "./time-types.js";
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
TimeLabels,
|
|
18
|
+
TimeLogInput,
|
|
19
|
+
TimeRunning,
|
|
20
|
+
TimeStopInfo,
|
|
21
|
+
} from "./time-types.js";
|
|
17
22
|
import TimeDot from "./time-dot.svelte";
|
|
18
23
|
import { CaretDown, Play, Stop, X } from "phosphor-svelte";
|
|
19
24
|
|
|
@@ -135,7 +140,9 @@
|
|
|
135
140
|
let invalid = $state(false);
|
|
136
141
|
let durationInput = $state<HTMLInputElement>();
|
|
137
142
|
let parsed = $derived(parseMinutes(duration));
|
|
138
|
-
let preview = $derived(
|
|
143
|
+
let preview = $derived(
|
|
144
|
+
parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "",
|
|
145
|
+
);
|
|
139
146
|
|
|
140
147
|
async function submit(event: SubmitEvent): Promise<void> {
|
|
141
148
|
event.preventDefault();
|
|
@@ -228,9 +235,15 @@
|
|
|
228
235
|
<Popover.Trigger class="time-open" aria-label={labels.title}>
|
|
229
236
|
{#if running}
|
|
230
237
|
<TimeDot running size={6} />
|
|
231
|
-
<span role="timer" aria-label={labels.elapsed}
|
|
238
|
+
<span role="timer" aria-label={labels.elapsed}
|
|
239
|
+
>{clock.hours}:{clock.minutes}:{clock.seconds}</span
|
|
240
|
+
>
|
|
232
241
|
{:else if total > 0}
|
|
233
|
-
<span
|
|
242
|
+
<span
|
|
243
|
+
>{formatMinutes(total)}{#if estimate}<span
|
|
244
|
+
class="ms-1 font-normal text-muted-foreground">/ {formatMinutes(estimate)}</span
|
|
245
|
+
>{/if}</span
|
|
246
|
+
>
|
|
234
247
|
{:else}
|
|
235
248
|
<span class="text-muted-foreground">{labels.timer}</span>
|
|
236
249
|
{/if}
|
|
@@ -254,12 +267,20 @@
|
|
|
254
267
|
class={cn("time-field w-full tabular-nums", preview && "pr-12")}
|
|
255
268
|
/>
|
|
256
269
|
{#if preview && preview !== duration.trim()}
|
|
257
|
-
<span
|
|
270
|
+
<span
|
|
271
|
+
class="time-preview pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground tabular-nums"
|
|
272
|
+
aria-hidden="true"
|
|
273
|
+
>
|
|
258
274
|
{preview}
|
|
259
275
|
</span>
|
|
260
276
|
{/if}
|
|
261
277
|
</div>
|
|
262
|
-
<input
|
|
278
|
+
<input
|
|
279
|
+
type="date"
|
|
280
|
+
bind:value={day}
|
|
281
|
+
aria-label={labels.date}
|
|
282
|
+
class="time-field w-full tabular-nums"
|
|
283
|
+
/>
|
|
263
284
|
<input
|
|
264
285
|
bind:value={note}
|
|
265
286
|
placeholder={labels.notePlaceholder}
|
|
@@ -288,11 +309,18 @@
|
|
|
288
309
|
<div class="h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
|
289
310
|
<div
|
|
290
311
|
class="time-segment h-full rounded-full"
|
|
291
|
-
style:background={tone === "neutral"
|
|
312
|
+
style:background={tone === "neutral"
|
|
313
|
+
? "var(--color-foreground)"
|
|
314
|
+
: TONE_COLOR[tone]}
|
|
292
315
|
style:width="{Math.min(100, percent)}%"
|
|
293
316
|
></div>
|
|
294
317
|
</div>
|
|
295
|
-
<p
|
|
318
|
+
<p
|
|
319
|
+
class={cn(
|
|
320
|
+
"text-xs tabular-nums",
|
|
321
|
+
tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone],
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
296
324
|
{liveTotal > estimate
|
|
297
325
|
? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
|
|
298
326
|
: labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
|
|
@@ -303,7 +331,11 @@
|
|
|
303
331
|
<ul class="flex flex-wrap gap-x-3 gap-y-1">
|
|
304
332
|
{#each people as person (person.key)}
|
|
305
333
|
<li class="flex items-center gap-1.5 text-xs">
|
|
306
|
-
<span
|
|
334
|
+
<span
|
|
335
|
+
class="size-2 rounded-full"
|
|
336
|
+
style:background={personColor(person.key)}
|
|
337
|
+
aria-hidden="true"
|
|
338
|
+
></span>
|
|
307
339
|
<span class="text-muted-foreground">{person.name}</span>
|
|
308
340
|
<span class="font-medium tabular-nums">{formatMinutes(person.minutes)}</span>
|
|
309
341
|
</li>
|
|
@@ -318,8 +350,12 @@
|
|
|
318
350
|
{#each groups as group (group.day)}
|
|
319
351
|
<div>
|
|
320
352
|
<div class="flex items-baseline justify-between border-b border-border/60 pb-1">
|
|
321
|
-
<h3 class="text-xs font-medium text-muted-foreground">
|
|
322
|
-
|
|
353
|
+
<h3 class="text-xs font-medium text-muted-foreground">
|
|
354
|
+
{dayLabel(group.day, locale)}
|
|
355
|
+
</h3>
|
|
356
|
+
<span class="text-xs font-medium text-muted-foreground tabular-nums"
|
|
357
|
+
>{formatMinutes(group.minutes)}</span
|
|
358
|
+
>
|
|
323
359
|
</div>
|
|
324
360
|
<ul>
|
|
325
361
|
{#each group.items as entry (getId(entry))}
|
|
@@ -341,9 +377,13 @@
|
|
|
341
377
|
<span class="min-w-0 flex-1 truncate">
|
|
342
378
|
<span class="sr-only">{name},</span>
|
|
343
379
|
{#if text}{text}{:else}<span class="text-muted-foreground">{name}</span>{/if}
|
|
344
|
-
{#if renderMeta}<span class="ms-1.5 text-xs text-muted-foreground"
|
|
380
|
+
{#if renderMeta}<span class="ms-1.5 text-xs text-muted-foreground"
|
|
381
|
+
>{@render renderMeta(entry)}</span
|
|
382
|
+
>{/if}
|
|
345
383
|
</span>
|
|
346
|
-
<span class="shrink-0 font-medium tabular-nums"
|
|
384
|
+
<span class="shrink-0 font-medium tabular-nums"
|
|
385
|
+
>{formatMinutes(getMinutes(entry))}</span
|
|
386
|
+
>
|
|
347
387
|
{#if onDelete && (canDelete?.(entry) ?? true)}
|
|
348
388
|
<button
|
|
349
389
|
type="button"
|
|
@@ -498,7 +538,8 @@
|
|
|
498
538
|
}
|
|
499
539
|
|
|
500
540
|
.time-segment {
|
|
501
|
-
transition: width var(--duration-slow, 400ms)
|
|
541
|
+
transition: width var(--duration-slow, 400ms)
|
|
542
|
+
var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
502
543
|
}
|
|
503
544
|
|
|
504
545
|
.time-delete {
|
|
@@ -527,7 +568,8 @@
|
|
|
527
568
|
}
|
|
528
569
|
|
|
529
570
|
.time-row.is-new {
|
|
530
|
-
animation: time-row-in var(--duration-fast, 250ms)
|
|
571
|
+
animation: time-row-in var(--duration-fast, 250ms)
|
|
572
|
+
var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
531
573
|
}
|
|
532
574
|
|
|
533
575
|
@keyframes time-pop {
|
|
@@ -1,74 +1,74 @@
|
|
|
1
1
|
/** A running timer. The host persists it, the tracker only reads it. */
|
|
2
2
|
export type TimeRunning = {
|
|
3
|
-
|
|
3
|
+
startedAt: Date | string | number;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
6
|
/** What the tracker hands back when a manual entry is submitted. */
|
|
7
7
|
export type TimeLogInput = {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
minutes: number;
|
|
9
|
+
/** The day worked, `YYYY-MM-DD`. */
|
|
10
|
+
day: string;
|
|
11
|
+
note?: string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
/** What the tracker hands back when the timer stops. */
|
|
15
15
|
export type TimeStopInfo = {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
/** Elapsed whole minutes, never below 1. */
|
|
17
|
+
minutes: number;
|
|
18
|
+
startedAt: Date;
|
|
19
|
+
stoppedAt: Date;
|
|
20
|
+
/** The day it started, `YYYY-MM-DD`, in the reader's calendar. */
|
|
21
|
+
day: string;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export type TimeLabels = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
25
|
+
title: string;
|
|
26
|
+
timer: string;
|
|
27
|
+
start: string;
|
|
28
|
+
stop: string;
|
|
29
|
+
running: string;
|
|
30
|
+
since: (time: string) => string;
|
|
31
|
+
started: string;
|
|
32
|
+
runningFor: (duration: string) => string;
|
|
33
|
+
stopped: (duration: string) => string;
|
|
34
|
+
elapsed: string;
|
|
35
|
+
logged: string;
|
|
36
|
+
duration: string;
|
|
37
|
+
durationPlaceholder: string;
|
|
38
|
+
date: string;
|
|
39
|
+
note: string;
|
|
40
|
+
notePlaceholder: string;
|
|
41
|
+
log: string;
|
|
42
|
+
saving: string;
|
|
43
|
+
invalid: string;
|
|
44
|
+
empty: string;
|
|
45
|
+
remaining: (left: string, estimate: string) => string;
|
|
46
|
+
over: (over: string, estimate: string) => string;
|
|
47
|
+
deleteEntry: (duration: string) => string;
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
export const DEFAULT_TIME_LABELS: TimeLabels = {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
51
|
+
title: "Time",
|
|
52
|
+
timer: "Timer",
|
|
53
|
+
start: "Start timer",
|
|
54
|
+
stop: "Stop timer",
|
|
55
|
+
running: "Running",
|
|
56
|
+
since: (time) => `Since ${time}`,
|
|
57
|
+
started: "Timer started",
|
|
58
|
+
runningFor: (duration) => `Timer running, ${duration}`,
|
|
59
|
+
stopped: (duration) => `Timer stopped, ${duration} to log`,
|
|
60
|
+
elapsed: "Elapsed time",
|
|
61
|
+
logged: "Logged",
|
|
62
|
+
duration: "Time spent",
|
|
63
|
+
durationPlaceholder: "1h30",
|
|
64
|
+
date: "Date worked",
|
|
65
|
+
note: "Note",
|
|
66
|
+
notePlaceholder: "What was done",
|
|
67
|
+
log: "Log",
|
|
68
|
+
saving: "Saving",
|
|
69
|
+
invalid: "Try 45m, 1h30 or 1.5h",
|
|
70
|
+
empty: "Log your first hour",
|
|
71
|
+
remaining: (left, estimate) => `${left} left of ${estimate}`,
|
|
72
|
+
over: (over, estimate) => `${over} over ${estimate}`,
|
|
73
|
+
deleteEntry: (duration) => `Delete ${duration} entry`,
|
|
74
74
|
};
|
|
@@ -22,9 +22,8 @@ export type KanbanDndConfig<T> = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* land on any other element a caller wired up.
|
|
25
|
+
* A board's drag state and handlers, on native HTML5 drag so a card can leave the board for
|
|
26
|
+
* any drop target a caller wired up.
|
|
28
27
|
*/
|
|
29
28
|
export function createKanbanDnd<T>(config: KanbanDndConfig<T>) {
|
|
30
29
|
let draggedId = $state<string | null>(null);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @heyramzi/ui/thread
|
|
2
|
+
|
|
3
|
+
One comment thread for any subject: a task, a project, a doc or a diagram. The thread owns the layout and the draft state. It never fetches. You pass comments and callbacks, and it hands back what the person did.
|
|
4
|
+
|
|
5
|
+
```svelte
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { Thread, type ThreadComment, type ThreadPost } from "@heyramzi/ui/thread";
|
|
8
|
+
|
|
9
|
+
let comments = $state<ThreadComment[]>([]);
|
|
10
|
+
|
|
11
|
+
async function onPost({ body, parentId }: ThreadPost) {
|
|
12
|
+
const saved = await save(body, parentId);
|
|
13
|
+
if (!saved) return false; // keeps the draft
|
|
14
|
+
comments = [...comments, saved];
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<Thread {comments} {onPost} {onEdit} {onDelete} {onResolve} canEdit={(c) => c.author.name === me} />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **Replies.** `parentId` points at the comment answered. A reply to a reply sits under the same root, a reply whose parent is gone becomes a root, and `buildThread(comments)` gives you the same grouping without the component.
|
|
22
|
+
- **Authors.** `author.kind` is `user` or `agent`. An agent shows an "AI" chip. The `avatar` snippet replaces the face for both kinds, so an app passes its cast portrait there.
|
|
23
|
+
- **Rich bodies.** A comment with `content` (Tiptap JSON) draws through `@heyramzi/ui/editor`'s renderer. `body` stays the plain-text fallback. A rich comment has no edit button, because the thread edits text only.
|
|
24
|
+
- **Resolve.** Pass `onResolve` and each root gets a check. Resolved roots fold away behind a "Show N resolved" button.
|
|
25
|
+
- **Anchor.** `anchor` carries `{ nodeId, dx, dy, x, y }` for a canvas pin. A pinned comment shows a chip, and `onAnchor` fires on a click so the canvas can move to the node. `anchorChip` replaces the chip.
|
|
26
|
+
- **Attachments.** The `attachments` snippet draws under a comment's body.
|
|
27
|
+
- **Labels.** Every string comes from `labels`, a partial of `ThreadLabels`. `showResolved` holds a `{count}` slot. `when(comment)` formats the time.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const fr = { title: "Activité", send: "Commenter", showResolved: "Afficher {count} résolu" };
|
|
31
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as Thread } from "./thread.svelte";
|
|
2
|
+
export { buildThread, fillCount, resolveThreadLabels, resolvedCount } from "./logic.js";
|
|
3
|
+
export { DEFAULT_THREAD_LABELS } from "./types.js";
|
|
4
|
+
export type {
|
|
5
|
+
ThreadAnchor,
|
|
6
|
+
ThreadAuthor,
|
|
7
|
+
ThreadAuthorKind,
|
|
8
|
+
ThreadComment,
|
|
9
|
+
ThreadLabels,
|
|
10
|
+
ThreadNode,
|
|
11
|
+
ThreadPost,
|
|
12
|
+
} from "./types.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ThreadComment, ThreadLabels, ThreadNode } from "./types.js";
|
|
2
|
+
import { DEFAULT_THREAD_LABELS } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function time(comment: ThreadComment): number {
|
|
5
|
+
const ms = Date.parse(comment.createdAt);
|
|
6
|
+
return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Oldest first, a malformed timestamp last, ties in the order they came. */
|
|
10
|
+
function byTime(comments: ThreadComment[]): ThreadComment[] {
|
|
11
|
+
return comments
|
|
12
|
+
.map((comment, index) => ({ comment, index }))
|
|
13
|
+
.toSorted((a, b) => time(a.comment) - time(b.comment) || a.index - b.index)
|
|
14
|
+
.map((row) => row.comment);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Roots in time order with their replies: a reply to a reply joins the root, an orphan becomes a
|
|
19
|
+
* root, and a parent loop can't hang the walk.
|
|
20
|
+
*/
|
|
21
|
+
export function buildThread(comments: ThreadComment[]): ThreadNode[] {
|
|
22
|
+
const byId = new Map(comments.map((comment) => [comment.id, comment]));
|
|
23
|
+
|
|
24
|
+
function rootOf(comment: ThreadComment): ThreadComment {
|
|
25
|
+
const seen = new Set<string>([comment.id]);
|
|
26
|
+
let current = comment;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const parent = current.parentId ? byId.get(current.parentId) : undefined;
|
|
29
|
+
if (!parent) return current;
|
|
30
|
+
if (seen.has(parent.id)) return comment;
|
|
31
|
+
seen.add(parent.id);
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const replies = new Map<string, ThreadComment[]>();
|
|
37
|
+
const roots: ThreadComment[] = [];
|
|
38
|
+
for (const comment of comments) {
|
|
39
|
+
const root = rootOf(comment);
|
|
40
|
+
if (root.id === comment.id) roots.push(comment);
|
|
41
|
+
else replies.set(root.id, [...(replies.get(root.id) ?? []), comment]);
|
|
42
|
+
}
|
|
43
|
+
return byTime(roots).map((comment) => ({
|
|
44
|
+
comment,
|
|
45
|
+
replies: byTime(replies.get(comment.id) ?? []),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How many conversations are resolved. */
|
|
50
|
+
export function resolvedCount(nodes: ThreadNode[]): number {
|
|
51
|
+
return nodes.filter((node) => node.comment.resolved).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveThreadLabels(overrides?: Partial<ThreadLabels>): ThreadLabels {
|
|
55
|
+
return { ...DEFAULT_THREAD_LABELS, ...overrides };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fills `{count}` in a label, so a French host writes its own sentence. */
|
|
59
|
+
export function fillCount(label: string, count: number): string {
|
|
60
|
+
return label.replaceAll("{count}", String(count));
|
|
61
|
+
}
|