@heyramzi/ui 1.54.2 → 2.0.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.
Files changed (361) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +198 -6
  4. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  5. package/src/ambient.d.ts +8 -0
  6. package/src/brand/brand.test.ts +67 -0
  7. package/src/brand/index.ts +137 -0
  8. package/src/canvas/README.md +236 -0
  9. package/src/canvas/card/card-frame.svelte +61 -0
  10. package/src/canvas/card/card.css +39 -0
  11. package/src/canvas/card/resize-handles.svelte +119 -0
  12. package/src/canvas/card/resize.ts +33 -0
  13. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  14. package/src/canvas/chrome/chrome.css +231 -0
  15. package/src/canvas/chrome/lock-badge.svelte +18 -0
  16. package/src/canvas/chrome/side-handles.svelte +30 -0
  17. package/src/canvas/chrome/size-chip.svelte +45 -0
  18. package/src/canvas/chrome/snap-guides.svelte +69 -0
  19. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  20. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  21. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  22. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  23. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  24. package/src/canvas/edges/bend-handle.svelte +76 -0
  25. package/src/canvas/edges/edge-gradient.svelte +30 -0
  26. package/src/canvas/edges/edge-path.test.ts +58 -0
  27. package/src/canvas/edges/edge-path.ts +293 -0
  28. package/src/canvas/edges/gradient-edge.svelte +43 -0
  29. package/src/canvas/editor/context-menu.test.ts +65 -0
  30. package/src/canvas/editor/context-menu.ts +48 -0
  31. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  32. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  33. package/src/canvas/editor/editor-context.ts +62 -0
  34. package/src/canvas/editor/editor-mode.ts +67 -0
  35. package/src/canvas/editor/frame.ts +98 -0
  36. package/src/canvas/editor/hand-context.ts +15 -0
  37. package/src/canvas/editor/history-core.test.ts +239 -0
  38. package/src/canvas/editor/history-core.ts +247 -0
  39. package/src/canvas/editor/keymap.test.ts +161 -0
  40. package/src/canvas/editor/keymap.ts +151 -0
  41. package/src/canvas/editor/layout-actions.test.ts +156 -0
  42. package/src/canvas/editor/layout-actions.ts +219 -0
  43. package/src/canvas/editor/lock.test.ts +68 -0
  44. package/src/canvas/editor/lock.ts +87 -0
  45. package/src/canvas/editor/rebase.test.ts +208 -0
  46. package/src/canvas/editor/rebase.ts +272 -0
  47. package/src/canvas/editor/style.ts +184 -0
  48. package/src/canvas/editor/text-field-context.ts +41 -0
  49. package/src/canvas/editor/z-order.test.ts +75 -0
  50. package/src/canvas/editor/z-order.ts +87 -0
  51. package/src/canvas/headless/alignment.test.ts +59 -0
  52. package/src/canvas/headless/alignment.ts +23 -0
  53. package/src/canvas/headless/break-apart-find.ts +418 -0
  54. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  55. package/src/canvas/headless/break-apart-plan.ts +439 -0
  56. package/src/canvas/headless/break-apart-style.ts +106 -0
  57. package/src/canvas/headless/break-apart.ts +244 -0
  58. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  59. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  60. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  61. package/src/canvas/headless/edge-anchors.ts +143 -0
  62. package/src/canvas/headless/edge-memo.test.ts +58 -0
  63. package/src/canvas/headless/edge-memo.ts +32 -0
  64. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  65. package/src/canvas/headless/edge-serialization.ts +32 -0
  66. package/src/canvas/headless/figure-geometry.ts +293 -0
  67. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  68. package/src/canvas/headless/fit-viewport.ts +137 -0
  69. package/src/canvas/headless/grid.test.ts +34 -0
  70. package/src/canvas/headless/grid.ts +94 -0
  71. package/src/canvas/headless/icon-node.test.ts +23 -0
  72. package/src/canvas/headless/icon-node.ts +66 -0
  73. package/src/canvas/headless/index.ts +9 -0
  74. package/src/canvas/headless/linkify.test.ts +23 -0
  75. package/src/canvas/headless/linkify.ts +25 -0
  76. package/src/canvas/headless/pen-marks.ts +133 -0
  77. package/src/canvas/headless/sketch.test.ts +99 -0
  78. package/src/canvas/headless/sketch.ts +302 -0
  79. package/src/canvas/headless/snap-guides.test.ts +61 -0
  80. package/src/canvas/headless/snap-guides.ts +146 -0
  81. package/src/canvas/headless/svg-figure.test.ts +49 -0
  82. package/src/canvas/headless/svg-figure.ts +176 -0
  83. package/src/canvas/headless/table-layout.test.ts +158 -0
  84. package/src/canvas/headless/table-layout.ts +309 -0
  85. package/src/canvas/index.ts +35 -0
  86. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  87. package/src/canvas/lod/lod-switch.svelte +140 -0
  88. package/src/canvas/lod/render-all.svelte.ts +52 -0
  89. package/src/canvas/ops/arrange.ts +108 -0
  90. package/src/canvas/ops/ops.test.ts +98 -0
  91. package/src/canvas/ops/ops.ts +347 -0
  92. package/src/canvas/pen/PenField.svelte +128 -0
  93. package/src/canvas/pen/PenField.test.ts +87 -0
  94. package/src/canvas/pen/PenOverlay.svelte +172 -0
  95. package/src/canvas/pen/ShapeNode.svelte +64 -0
  96. package/src/canvas/pen/TextNode.svelte +97 -0
  97. package/src/canvas/pen/colors.ts +28 -0
  98. package/src/canvas/pen/eraser.ts +46 -0
  99. package/src/canvas/pen/geometry.ts +148 -0
  100. package/src/canvas/pen/host-field.test.svelte +13 -0
  101. package/src/canvas/pen/index.ts +40 -0
  102. package/src/canvas/pen/ops.ts +16 -0
  103. package/src/canvas/pen/pen-session.ts +177 -0
  104. package/src/canvas/pen/pen.css +149 -0
  105. package/src/canvas/pen/plugin.ts +139 -0
  106. package/src/canvas/pen/shapes.ts +104 -0
  107. package/src/canvas/pen/stroke-path.ts +54 -0
  108. package/src/canvas/pen/types.ts +96 -0
  109. package/src/canvas/pen/ui.ts +26 -0
  110. package/src/canvas/plugin.test.ts +45 -0
  111. package/src/canvas/plugin.ts +159 -0
  112. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  113. package/src/canvas/screen/PartView.svelte +176 -0
  114. package/src/canvas/screen/ScreenBody.svelte +142 -0
  115. package/src/canvas/screen/ScreenCard.svelte +32 -0
  116. package/src/canvas/screen/glyphs/button.svg +1 -0
  117. package/src/canvas/screen/glyphs/close.svg +1 -0
  118. package/src/canvas/screen/glyphs/down.svg +1 -0
  119. package/src/canvas/screen/glyphs/header.svg +1 -0
  120. package/src/canvas/screen/glyphs/hills.svg +1 -0
  121. package/src/canvas/screen/glyphs/image.svg +1 -0
  122. package/src/canvas/screen/glyphs/input.svg +1 -0
  123. package/src/canvas/screen/glyphs/list.svg +1 -0
  124. package/src/canvas/screen/glyphs/modal.svg +1 -0
  125. package/src/canvas/screen/glyphs/plus.svg +1 -0
  126. package/src/canvas/screen/glyphs/up.svg +1 -0
  127. package/src/canvas/screen/glyphs.ts +40 -0
  128. package/src/canvas/screen/labels.ts +41 -0
  129. package/src/canvas/screen/parts.ts +164 -0
  130. package/src/canvas/screen/plugin.ts +67 -0
  131. package/src/canvas/screen/screen.css +593 -0
  132. package/src/canvas/screen/types.ts +47 -0
  133. package/src/canvas/screen/ui.ts +13 -0
  134. package/src/canvas/test-utils.ts +18 -0
  135. package/src/canvas/types.ts +53 -0
  136. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  137. package/src/canvas/ui/FrameLayer.svelte +116 -0
  138. package/src/canvas/ui/PenLayer.svelte +39 -0
  139. package/src/canvas/ui/StylePanel.svelte +268 -0
  140. package/src/canvas/ui/canvas-grid.svelte +156 -0
  141. package/src/canvas/ui/canvas-ui.css +350 -0
  142. package/src/canvas/ui/forward-wheel.ts +29 -0
  143. package/src/canvas/ui/icons.ts +124 -0
  144. package/src/canvas/ui/place-panel.ts +80 -0
  145. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  146. package/src/canvas/ui/shortcut-tip.ts +17 -0
  147. package/src/canvas/ui/tools.ts +146 -0
  148. package/src/charts/README.md +104 -0
  149. package/src/charts/ambient.d.ts +4 -0
  150. package/src/charts/animated-value.css +7 -0
  151. package/src/charts/animated-value.svelte +110 -0
  152. package/src/charts/area-chart.css +29 -0
  153. package/src/charts/area-chart.svelte +204 -0
  154. package/src/charts/bar-chart.css +119 -0
  155. package/src/charts/bar-chart.svelte +275 -0
  156. package/src/charts/charts.css +201 -0
  157. package/src/charts/crosshair.svelte +35 -0
  158. package/src/charts/dashboard/chart.ts +5 -0
  159. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  160. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  161. package/src/charts/dashboard/data-panel.svelte +81 -0
  162. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  163. package/src/charts/dashboard/data-table.svelte +278 -0
  164. package/src/charts/dashboard/data-table.ts +26 -0
  165. package/src/charts/dashboard/index.ts +25 -0
  166. package/src/charts/dashboard/panel-grid.svelte +43 -0
  167. package/src/charts/dashboard/period-filter.svelte +53 -0
  168. package/src/charts/dashboard/rating-chip.svelte +35 -0
  169. package/src/charts/dashboard/stat-band.svelte +47 -0
  170. package/src/charts/dashboard/stat-tile.svelte +176 -0
  171. package/src/charts/dashboard/windows.ts +8 -0
  172. package/src/charts/donut-chart.css +56 -0
  173. package/src/charts/donut-chart.svelte +158 -0
  174. package/src/charts/edge-fade.svelte +43 -0
  175. package/src/charts/edge-fade.ts +16 -0
  176. package/src/charts/figure.css +100 -0
  177. package/src/charts/figure.svelte +122 -0
  178. package/src/charts/gauge-chart.css +94 -0
  179. package/src/charts/gauge-chart.svelte +302 -0
  180. package/src/charts/hover-band.test.ts +30 -0
  181. package/src/charts/hover-band.ts +21 -0
  182. package/src/charts/hover-focus.svelte +85 -0
  183. package/src/charts/index.ts +31 -0
  184. package/src/charts/legend.css +136 -0
  185. package/src/charts/legend.svelte +112 -0
  186. package/src/charts/line-chart.css +26 -0
  187. package/src/charts/line-chart.svelte +256 -0
  188. package/src/charts/motion.ts +19 -0
  189. package/src/charts/number-chart.css +179 -0
  190. package/src/charts/number-chart.svelte +187 -0
  191. package/src/charts/primitives/chart-container.svelte +57 -0
  192. package/src/charts/primitives/chart-style.svelte +36 -0
  193. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  194. package/src/charts/primitives/chart-utils.ts +54 -0
  195. package/src/charts/primitives/index.ts +11 -0
  196. package/src/charts/reference-line.css +18 -0
  197. package/src/charts/reference-line.svelte +31 -0
  198. package/src/charts/reveal.svelte +61 -0
  199. package/src/charts/ring-chart.css +104 -0
  200. package/src/charts/ring-chart.svelte +166 -0
  201. package/src/charts/sparkline.css +45 -0
  202. package/src/charts/sparkline.svelte +141 -0
  203. package/src/charts/stat-card.css +105 -0
  204. package/src/charts/stat-card.svelte +267 -0
  205. package/src/charts/time-series-chart.css +28 -0
  206. package/src/charts/time-series-chart.svelte +179 -0
  207. package/src/charts/trend-pill.css +38 -0
  208. package/src/charts/trend-pill.svelte +48 -0
  209. package/src/components/copy/copy-button.svelte +1 -1
  210. package/src/components/docs/doc-page.svelte +1 -1
  211. package/src/components/docs/on-this-page.svelte +2 -2
  212. package/src/components/list-page/empty-state.svelte +1 -1
  213. package/src/components/list-page/filter-popover.svelte +1 -1
  214. package/src/components/list-page/section-label.svelte +1 -1
  215. package/src/components/segmented/segment.svelte +4 -4
  216. package/src/components/theme/theme-provider.svelte +6 -4
  217. package/src/components/toolbar/index.ts +5 -0
  218. package/src/components/toolbar/toolbar.svelte +168 -0
  219. package/src/components/toolbar/toolbar.test.ts +66 -0
  220. package/src/editor/README.md +58 -0
  221. package/src/editor/ambient.d.ts +1 -0
  222. package/src/editor/block-id.ts +77 -0
  223. package/src/editor/block-ops.test.ts +237 -0
  224. package/src/editor/block-ops.ts +157 -0
  225. package/src/editor/content.css +418 -0
  226. package/src/editor/editor-toolbar.svelte +303 -0
  227. package/src/editor/editor.svelte +462 -0
  228. package/src/editor/extensions.ts +145 -0
  229. package/src/editor/image-toolbar.svelte +123 -0
  230. package/src/editor/index.ts +17 -0
  231. package/src/editor/inline-editor.svelte +261 -0
  232. package/src/editor/markdown-escaping.test.ts +158 -0
  233. package/src/editor/markdown-marks.test.ts +89 -0
  234. package/src/editor/markdown-test-helpers.ts +13 -0
  235. package/src/editor/markdown-youtube.test.ts +93 -0
  236. package/src/editor/markdown.test.ts +250 -0
  237. package/src/editor/markdown.ts +511 -0
  238. package/src/editor/renderer.svelte +92 -0
  239. package/src/editor/serializer.test.ts +133 -0
  240. package/src/editor/serializer.ts +370 -0
  241. package/src/editor/slash-items.ts +115 -0
  242. package/src/editor/slash-menu.svelte +159 -0
  243. package/src/editor/youtube-url.ts +35 -0
  244. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  245. package/src/lib/result.ts +19 -0
  246. package/src/onboarding/README.md +120 -0
  247. package/src/onboarding/define-tours.test.ts +50 -0
  248. package/src/onboarding/define-tours.ts +31 -0
  249. package/src/onboarding/demo.svelte +86 -0
  250. package/src/onboarding/index.ts +12 -0
  251. package/src/onboarding/onboarding-provider.svelte +120 -0
  252. package/src/onboarding/onboarding.svelte.ts +24 -0
  253. package/src/onboarding/pick.test.ts +79 -0
  254. package/src/onboarding/pick.ts +36 -0
  255. package/src/onboarding/stores.test.ts +103 -0
  256. package/src/onboarding/stores.ts +58 -0
  257. package/src/onboarding/tour-card.svelte +70 -0
  258. package/src/onboarding/tour-runner.svelte +197 -0
  259. package/src/onboarding/types.ts +34 -0
  260. package/src/onboarding/wait.ts +29 -0
  261. package/src/shadcn/command/command-input.svelte +1 -1
  262. package/src/slides/Controls.svelte +138 -0
  263. package/src/slides/Deck.svelte +271 -0
  264. package/src/slides/Fragment.svelte +71 -0
  265. package/src/slides/Overview.svelte +113 -0
  266. package/src/slides/Presenter.svelte +292 -0
  267. package/src/slides/README.md +89 -0
  268. package/src/slides/Slide.svelte +179 -0
  269. package/src/slides/SlideList.svelte +19 -0
  270. package/src/slides/ambient.d.ts +1 -0
  271. package/src/slides/bridges.ts +69 -0
  272. package/src/slides/deck-controller.svelte.ts +292 -0
  273. package/src/slides/demo-slides.svelte +358 -0
  274. package/src/slides/demo.svelte +176 -0
  275. package/src/slides/export/capture.ts +113 -0
  276. package/src/slides/export/ffmpeg.ts +120 -0
  277. package/src/slides/export/index.ts +9 -0
  278. package/src/slides/export/launch.ts +10 -0
  279. package/src/slides/export/pdf.ts +152 -0
  280. package/src/slides/export/types.ts +48 -0
  281. package/src/slides/index.ts +19 -0
  282. package/src/slides/labels.ts +48 -0
  283. package/src/slides/layout.ts +51 -0
  284. package/src/slides/model.ts +47 -0
  285. package/src/slides/navigation.ts +100 -0
  286. package/src/slides/theme.css +205 -0
  287. package/src/slides/types.ts +86 -0
  288. package/src/tasks/README.md +312 -0
  289. package/src/tasks/ambient.d.ts +3 -0
  290. package/src/tasks/board-order.ts +137 -0
  291. package/src/tasks/dates.ts +31 -0
  292. package/src/tasks/define-statuses.ts +32 -0
  293. package/src/tasks/demo.svelte +389 -0
  294. package/src/tasks/detail/detail-demo.svelte +186 -0
  295. package/src/tasks/detail/detail-types.ts +54 -0
  296. package/src/tasks/detail/task-chip.svelte +28 -0
  297. package/src/tasks/detail/task-detail.svelte +315 -0
  298. package/src/tasks/detail/task-property.svelte +17 -0
  299. package/src/tasks/detail/task-thread.svelte +258 -0
  300. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  301. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  302. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  303. package/src/tasks/gantt/gantt-header.svelte +71 -0
  304. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  305. package/src/tasks/gantt/gantt.css +22 -0
  306. package/src/tasks/gantt/gantt.test.ts +77 -0
  307. package/src/tasks/gantt/logic.test.ts +163 -0
  308. package/src/tasks/gantt/logic.ts +248 -0
  309. package/src/tasks/inbox/inbox-card.svelte +283 -0
  310. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  311. package/src/tasks/inbox/inbox-logic.ts +55 -0
  312. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  313. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  314. package/src/tasks/inbox/inbox-types.ts +108 -0
  315. package/src/tasks/inbox/inbox.svelte +203 -0
  316. package/src/tasks/inbox/index.ts +23 -0
  317. package/src/tasks/index.ts +85 -0
  318. package/src/tasks/kanban-board.svelte +561 -0
  319. package/src/tasks/kanban-column.svelte +117 -0
  320. package/src/tasks/kanban-drag.ts +38 -0
  321. package/src/tasks/kanban-types.ts +65 -0
  322. package/src/tasks/presets/sales.ts +16 -0
  323. package/src/tasks/record/demo-data.ts +286 -0
  324. package/src/tasks/record/field-section.svelte +53 -0
  325. package/src/tasks/record/fields.ts +142 -0
  326. package/src/tasks/record/format.ts +87 -0
  327. package/src/tasks/record/gutter.ts +2 -0
  328. package/src/tasks/record/index.ts +54 -0
  329. package/src/tasks/record/info-list.svelte +316 -0
  330. package/src/tasks/record/kinds.ts +42 -0
  331. package/src/tasks/record/logic.ts +324 -0
  332. package/src/tasks/record/next-up.svelte +70 -0
  333. package/src/tasks/record/project-list.svelte +133 -0
  334. package/src/tasks/record/record-demo.svelte +324 -0
  335. package/src/tasks/record/record-header.svelte +70 -0
  336. package/src/tasks/record/record-shell.svelte +138 -0
  337. package/src/tasks/record/record-stats.svelte +48 -0
  338. package/src/tasks/record/record-timeline.svelte +231 -0
  339. package/src/tasks/record/record.test.ts +350 -0
  340. package/src/tasks/record/steps-rail.svelte +474 -0
  341. package/src/tasks/record/types.ts +280 -0
  342. package/src/tasks/record/where-we-are.svelte +121 -0
  343. package/src/tasks/reorder.ts +19 -0
  344. package/src/tasks/side-panel-types.ts +16 -0
  345. package/src/tasks/side-panel.svelte +193 -0
  346. package/src/tasks/status-badge.svelte +63 -0
  347. package/src/tasks/status-dot.svelte +228 -0
  348. package/src/tasks/status-icon.svelte +30 -0
  349. package/src/tasks/status-types.ts +40 -0
  350. package/src/tasks/task-gantt.svelte +230 -0
  351. package/src/tasks/task-list.svelte +470 -0
  352. package/src/tasks/task-types.ts +33 -0
  353. package/src/tasks/time/duration.ts +69 -0
  354. package/src/tasks/time/time-chip.svelte +62 -0
  355. package/src/tasks/time/time-dot.svelte +58 -0
  356. package/src/tasks/time/time-ring.svelte +74 -0
  357. package/src/tasks/time/time-tracker.svelte +565 -0
  358. package/src/tasks/time/time-types.ts +74 -0
  359. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  360. package/src/tokens/base.css +5 -5
  361. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import { cn } from "../../lib/utils.js";
4
+ import { TONE_TEXT } from "../../lib/tone.js";
5
+ import { budgetTone } from "./duration.js";
6
+
7
+ interface Props {
8
+ /** Minutes logged. */
9
+ value: number;
10
+ /** Minutes budgeted. The ring turns warning at 90% and destructive past 100%. */
11
+ max: number;
12
+ /** Pixels. */
13
+ size?: number;
14
+ /** Stroke width in a 36 unit box. */
15
+ stroke?: number;
16
+ label?: string;
17
+ /** Rendered in the middle, e.g. the percentage. */
18
+ children?: Snippet;
19
+ class?: string;
20
+ };
21
+
22
+ let { value, max, size = 56, stroke = 4, label, children, class: className }: Props = $props();
23
+
24
+ const R = 16;
25
+ let tone = $derived(budgetTone(value, max));
26
+ let fraction = $derived(max > 0 ? Math.min(1, Math.max(0, value / max)) : 0);
27
+ let colorClass = $derived(tone === "neutral" ? TONE_TEXT.accent : TONE_TEXT[tone]);
28
+ </script>
29
+
30
+ <span
31
+ class={cn("relative inline-grid shrink-0 place-items-center", className)}
32
+ style:width="{size}px"
33
+ style:height="{size}px"
34
+ role={label ? "img" : undefined}
35
+ aria-label={label}
36
+ aria-hidden={label ? undefined : "true"}
37
+ >
38
+ <svg viewBox="0 0 36 36" class="size-full -rotate-90" fill="none" aria-hidden="true">
39
+ <circle cx="18" cy="18" r={R} stroke="currentColor" stroke-width={stroke} class="text-foreground/10" />
40
+ <circle
41
+ cx="18"
42
+ cy="18"
43
+ r={R}
44
+ pathLength="100"
45
+ stroke="currentColor"
46
+ stroke-width={stroke}
47
+ stroke-linecap="round"
48
+ stroke-dasharray="{fraction * 100} 100"
49
+ class={cn("time-ring-arc", colorClass)}
50
+ class:is-empty={fraction === 0}
51
+ />
52
+ </svg>
53
+ {#if children}
54
+ <span class="absolute inset-0 grid place-items-center">{@render children()}</span>
55
+ {/if}
56
+ </span>
57
+
58
+ <style>
59
+ .time-ring-arc {
60
+ transition:
61
+ stroke-dasharray var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
62
+ color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
63
+ }
64
+
65
+ .time-ring-arc.is-empty {
66
+ opacity: 0;
67
+ }
68
+
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .time-ring-arc {
71
+ transition: none;
72
+ }
73
+ }
74
+ </style>
@@ -0,0 +1,565 @@
1
+ <script lang="ts" generics="T">
2
+ import type { Snippet } from "svelte";
3
+ import { cn } from "../../lib/utils.js";
4
+ import { TONE_COLOR, TONE_TEXT } from "../../lib/tone.js";
5
+ import * as Popover from "../../shadcn/popover/index.js";
6
+ import {
7
+ budgetTone,
8
+ dayLabel,
9
+ formatClock,
10
+ formatMinutes,
11
+ localDay,
12
+ parseMinutes,
13
+ personColor,
14
+ } from "./duration.js";
15
+ import { DEFAULT_TIME_LABELS } from "./time-types.js";
16
+ import type { TimeLabels, TimeLogInput, TimeRunning, TimeStopInfo } from "./time-types.js";
17
+ import TimeDot from "./time-dot.svelte";
18
+ import { CaretDown, Play, Stop, X } from "phosphor-svelte";
19
+
20
+ interface Props<T> {
21
+ /** Every entry logged on the task. Order within a day is kept. */
22
+ entries: T[];
23
+ getId: (entry: T) => string;
24
+ getMinutes: (entry: T) => number;
25
+ /** The day worked, `YYYY-MM-DD`. */
26
+ getDay: (entry: T) => string;
27
+ /** The name shown for the person who logged it. */
28
+ getPerson: (entry: T) => string;
29
+ /** Groups the split by id when two people share a name. Defaults to the name. */
30
+ getPersonId?: (entry: T) => string;
31
+ getNote?: (entry: T) => string | null | undefined;
32
+ /** The timer in progress, or null. The host stores it, so it survives a reload. */
33
+ running?: TimeRunning | null;
34
+ /** Minutes budgeted for the task. Draws the progress ring. */
35
+ estimate?: number;
36
+ onStart?: () => void | Promise<void>;
37
+ /** Called with the elapsed time. Log it, then clear `running`. */
38
+ onStop?: (info: TimeStopInfo) => void | Promise<void>;
39
+ /** A manual entry. Return false, or throw, to keep what was typed. */
40
+ onLog: (input: TimeLogInput) => void | boolean | Promise<void | boolean>;
41
+ /** Shows a delete button on rows where `canDelete` allows it. */
42
+ onDelete?: (entry: T) => void | Promise<void>;
43
+ canDelete?: (entry: T) => boolean;
44
+ /** Extra text after the note, e.g. the project. */
45
+ renderMeta?: Snippet<[T]>;
46
+ /** BCP 47 tag for day names. Defaults to the browser's. */
47
+ locale?: string;
48
+ labels?: Partial<TimeLabels>;
49
+ /** Kept so existing callers compile. The tracker is always the compact chip now, so it does nothing. */
50
+ flat?: boolean;
51
+ class?: string;
52
+ }
53
+
54
+ let {
55
+ entries,
56
+ getId,
57
+ getMinutes,
58
+ getDay,
59
+ getPerson,
60
+ getPersonId,
61
+ getNote,
62
+ running = null,
63
+ estimate,
64
+ onStart,
65
+ onStop,
66
+ onLog,
67
+ onDelete,
68
+ canDelete,
69
+ renderMeta,
70
+ locale,
71
+ labels: labelOverrides,
72
+ class: className,
73
+ }: Props<T> = $props();
74
+
75
+ let labels = $derived({ ...DEFAULT_TIME_LABELS, ...labelOverrides });
76
+
77
+ // ---- timer
78
+ let now = $state(Date.now());
79
+ let open = $state(false);
80
+ let stoppedNote = $state("");
81
+ let startedMs = $derived(running ? new Date(running.startedAt).getTime() : null);
82
+ let elapsedMs = $derived(startedMs === null ? 0 : Math.max(0, now - startedMs));
83
+ let clock = $derived(formatClock(elapsedMs));
84
+ let elapsedMinutes = $derived(Math.floor(elapsedMs / 60_000));
85
+
86
+ // The tick runs only while a timer does, and re-reads the clock so a sleeping tab catches up.
87
+ $effect(() => {
88
+ if (startedMs === null) return;
89
+ now = Date.now();
90
+ const id = setInterval(() => (now = Date.now()), 1000);
91
+ return () => clearInterval(id);
92
+ });
93
+
94
+ // Screen readers hear the start, then one update a minute, then the stop.
95
+ let status = $derived(
96
+ running
97
+ ? elapsedMinutes === 0
98
+ ? labels.started
99
+ : labels.runningFor(formatMinutes(elapsedMinutes))
100
+ : stoppedNote,
101
+ );
102
+
103
+ function sinceLabel(): string {
104
+ if (startedMs === null) return "";
105
+ return labels.since(
106
+ new Date(startedMs).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }),
107
+ );
108
+ }
109
+
110
+ async function toggle(): Promise<void> {
111
+ if (!running || startedMs === null) {
112
+ stoppedNote = "";
113
+ await onStart?.();
114
+ return;
115
+ }
116
+ const stoppedAt = new Date();
117
+ const startedAt = new Date(startedMs);
118
+ const minutes = Math.max(1, Math.round((stoppedAt.getTime() - startedMs) / 60_000));
119
+ stoppedNote = labels.stopped(formatMinutes(minutes));
120
+ await onStop?.({ minutes, startedAt, stoppedAt, day: localDay(startedAt) });
121
+ }
122
+
123
+ function onRootKeyDown(event: KeyboardEvent): void {
124
+ if (event.altKey && event.code === "KeyS" && !event.repeat) {
125
+ event.preventDefault();
126
+ void toggle();
127
+ }
128
+ }
129
+
130
+ // ---- manual entry
131
+ let duration = $state("");
132
+ let day = $state(localDay());
133
+ let note = $state("");
134
+ let saving = $state(false);
135
+ let invalid = $state(false);
136
+ let durationInput = $state<HTMLInputElement>();
137
+ let parsed = $derived(parseMinutes(duration));
138
+ let preview = $derived(parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "");
139
+
140
+ async function submit(event: SubmitEvent): Promise<void> {
141
+ event.preventDefault();
142
+ invalid = parsed === null || parsed < 1 || parsed > 1440;
143
+ if (invalid || parsed === null || saving) return;
144
+ saving = true;
145
+ try {
146
+ const result = await onLog({ minutes: parsed, day, note: note.trim() || undefined });
147
+ if (result !== false) {
148
+ duration = "";
149
+ note = "";
150
+ durationInput?.focus();
151
+ }
152
+ } catch {
153
+ // The host owns the error message. The fields stay as typed.
154
+ } finally {
155
+ saving = false;
156
+ }
157
+ }
158
+
159
+ // ---- summary
160
+ let total = $derived(entries.reduce((sum, entry) => sum + getMinutes(entry), 0));
161
+ let liveTotal = $derived(total + elapsedMinutes);
162
+ let tone = $derived(budgetTone(liveTotal, estimate));
163
+ let percent = $derived(estimate ? Math.round((liveTotal / estimate) * 100) : 0);
164
+
165
+ let groups = $derived.by(() => {
166
+ const byDay = new Map<string, { day: string; items: T[]; minutes: number }>();
167
+ for (const entry of entries) {
168
+ const key = getDay(entry);
169
+ const group = byDay.get(key) ?? { day: key, items: [], minutes: 0 };
170
+ group.items.push(entry);
171
+ group.minutes += getMinutes(entry);
172
+ byDay.set(key, group);
173
+ }
174
+ return [...byDay.values()].sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0));
175
+ });
176
+
177
+ let people = $derived.by(() => {
178
+ const byPerson = new Map<string, { key: string; name: string; minutes: number }>();
179
+ for (const entry of entries) {
180
+ const key = getPersonId?.(entry) ?? getPerson(entry);
181
+ const person = byPerson.get(key) ?? { key, name: getPerson(entry), minutes: 0 };
182
+ person.minutes += getMinutes(entry);
183
+ byPerson.set(key, person);
184
+ }
185
+ return [...byPerson.values()].sort((a, b) => b.minutes - a.minutes);
186
+ });
187
+
188
+ // Rows that arrive after the first paint ease in. The first paint shows them as they are.
189
+ // svelte-ignore state_referenced_locally
190
+ const firstIds = new Set(entries.map(getId));
191
+
192
+ let deleting = $state<string | null>(null);
193
+
194
+ async function remove(entry: T): Promise<void> {
195
+ deleting = getId(entry);
196
+ try {
197
+ await onDelete?.(entry);
198
+ } finally {
199
+ deleting = null;
200
+ }
201
+ }
202
+
203
+ function initial(name: string): string {
204
+ return name.trim().charAt(0).toLocaleUpperCase() || "?";
205
+ }
206
+ </script>
207
+
208
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
209
+ <section class={cn("inline-flex", className)} aria-label={labels.title} onkeydown={onRootKeyDown}>
210
+ <div class="sr-only" role="status" aria-live="polite">{status}</div>
211
+
212
+ <Popover.Root bind:open>
213
+ <div class="time-chip" class:is-running={!!running}>
214
+ <button
215
+ type="button"
216
+ onclick={toggle}
217
+ aria-label={running ? labels.stop : labels.start}
218
+ aria-keyshortcuts="Alt+S"
219
+ title="{running ? labels.stop : labels.start} (Alt+S)"
220
+ class="time-toggle"
221
+ >
222
+ {#if running}
223
+ <Stop weight="fill" class="size-3.5" aria-hidden="true" />
224
+ {:else}
225
+ <Play weight="fill" class="size-3.5" aria-hidden="true" />
226
+ {/if}
227
+ </button>
228
+ <Popover.Trigger class="time-open" aria-label={labels.title}>
229
+ {#if running}
230
+ <TimeDot running size={6} />
231
+ <span role="timer" aria-label={labels.elapsed}>{clock.hours}:{clock.minutes}:{clock.seconds}</span>
232
+ {:else if total > 0}
233
+ <span>{formatMinutes(total)}{#if estimate}<span class="ms-1 font-normal text-muted-foreground">/ {formatMinutes(estimate)}</span>{/if}</span>
234
+ {:else}
235
+ <span class="text-muted-foreground">{labels.timer}</span>
236
+ {/if}
237
+ <CaretDown class="size-3 text-muted-foreground" aria-hidden="true" />
238
+ </Popover.Trigger>
239
+ </div>
240
+
241
+ <Popover.Content align="start" class="flex w-80 flex-col gap-3 p-3 text-[13px]">
242
+ <form onsubmit={submit} class="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] gap-2">
243
+ <div class="relative">
244
+ <input
245
+ bind:this={durationInput}
246
+ bind:value={duration}
247
+ oninput={() => (invalid = false)}
248
+ placeholder={labels.durationPlaceholder}
249
+ aria-label={labels.duration}
250
+ aria-invalid={invalid}
251
+ aria-describedby={invalid ? "time-invalid" : undefined}
252
+ autocomplete="off"
253
+ inputmode="text"
254
+ class={cn("time-field w-full tabular-nums", preview && "pr-12")}
255
+ />
256
+ {#if preview && preview !== duration.trim()}
257
+ <span class="time-preview pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground tabular-nums" aria-hidden="true">
258
+ {preview}
259
+ </span>
260
+ {/if}
261
+ </div>
262
+ <input type="date" bind:value={day} aria-label={labels.date} class="time-field w-full tabular-nums" />
263
+ <input
264
+ bind:value={note}
265
+ placeholder={labels.notePlaceholder}
266
+ aria-label={labels.note}
267
+ autocomplete="off"
268
+ class="time-field"
269
+ />
270
+ <button type="submit" disabled={saving || !duration.trim()} class="time-log">
271
+ {saving ? labels.saving : labels.log}
272
+ </button>
273
+ </form>
274
+ {#if invalid}
275
+ <p id="time-invalid" class="text-xs text-destructive" role="alert">{labels.invalid}</p>
276
+ {/if}
277
+
278
+ {#if entries.length > 0 || estimate}
279
+ <div class="flex flex-col gap-2 border-t border-border/60 pt-3">
280
+ <div class="flex items-baseline justify-between gap-2">
281
+ <span class="text-xs font-medium text-muted-foreground">{labels.logged}</span>
282
+ {#key total}
283
+ <span class="time-pop font-medium tabular-nums">{formatMinutes(total)}</span>
284
+ {/key}
285
+ </div>
286
+ {#if estimate}
287
+ <div class="flex flex-col gap-1">
288
+ <div class="h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
289
+ <div
290
+ class="time-segment h-full rounded-full"
291
+ style:background={tone === "neutral" ? "var(--color-foreground)" : TONE_COLOR[tone]}
292
+ style:width="{Math.min(100, percent)}%"
293
+ ></div>
294
+ </div>
295
+ <p class={cn("text-xs tabular-nums", tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone])}>
296
+ {liveTotal > estimate
297
+ ? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
298
+ : labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
299
+ </p>
300
+ </div>
301
+ {/if}
302
+ {#if people.length > 1}
303
+ <ul class="flex flex-wrap gap-x-3 gap-y-1">
304
+ {#each people as person (person.key)}
305
+ <li class="flex items-center gap-1.5 text-xs">
306
+ <span class="size-2 rounded-full" style:background={personColor(person.key)} aria-hidden="true"></span>
307
+ <span class="text-muted-foreground">{person.name}</span>
308
+ <span class="font-medium tabular-nums">{formatMinutes(person.minutes)}</span>
309
+ </li>
310
+ {/each}
311
+ </ul>
312
+ {/if}
313
+ </div>
314
+ {/if}
315
+
316
+ {#if groups.length > 0}
317
+ <div class="-mx-1 flex max-h-56 flex-col gap-2 overflow-y-auto px-1">
318
+ {#each groups as group (group.day)}
319
+ <div>
320
+ <div class="flex items-baseline justify-between border-b border-border/60 pb-1">
321
+ <h3 class="text-xs font-medium text-muted-foreground">{dayLabel(group.day, locale)}</h3>
322
+ <span class="text-xs font-medium text-muted-foreground tabular-nums">{formatMinutes(group.minutes)}</span>
323
+ </div>
324
+ <ul>
325
+ {#each group.items as entry (getId(entry))}
326
+ {@const name = getPerson(entry)}
327
+ {@const text = getNote?.(entry)}
328
+ <li
329
+ class="time-row group flex h-7 items-center gap-2 rounded-md px-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
330
+ class:is-new={!firstIds.has(getId(entry))}
331
+ class:opacity-50={deleting === getId(entry)}
332
+ >
333
+ <span
334
+ class="grid size-5 shrink-0 place-items-center rounded-full text-xs font-semibold text-white"
335
+ style:background={personColor(getPersonId?.(entry) ?? name)}
336
+ title={name}
337
+ aria-hidden="true"
338
+ >
339
+ {initial(name)}
340
+ </span>
341
+ <span class="min-w-0 flex-1 truncate">
342
+ <span class="sr-only">{name},</span>
343
+ {#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">{@render renderMeta(entry)}</span>{/if}
345
+ </span>
346
+ <span class="shrink-0 font-medium tabular-nums">{formatMinutes(getMinutes(entry))}</span>
347
+ {#if onDelete && (canDelete?.(entry) ?? true)}
348
+ <button
349
+ type="button"
350
+ onclick={() => remove(entry)}
351
+ disabled={deleting === getId(entry)}
352
+ aria-label={labels.deleteEntry(formatMinutes(getMinutes(entry)))}
353
+ class="time-delete grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50"
354
+ >
355
+ <X weight="bold" class="size-3" aria-hidden="true" />
356
+ </button>
357
+ {/if}
358
+ </li>
359
+ {/each}
360
+ </ul>
361
+ </div>
362
+ {/each}
363
+ </div>
364
+ {:else}
365
+ <p class="py-2 text-center text-xs text-muted-foreground">{labels.empty}</p>
366
+ {/if}
367
+ </Popover.Content>
368
+ </Popover.Root>
369
+ </section>
370
+
371
+ <style>
372
+ .time-chip {
373
+ display: inline-flex;
374
+ align-items: stretch;
375
+ height: 1.75rem;
376
+ border-radius: var(--radius-md, 0.5rem);
377
+ font-size: 0.8125rem;
378
+ color: var(--color-foreground);
379
+ transition: background-color var(--duration-fast, 250ms) var(--ease-smooth-out, ease-out);
380
+ }
381
+
382
+ .time-chip.is-running {
383
+ background: color-mix(in oklab, var(--tone-accent, var(--color-primary)) 10%, transparent);
384
+ }
385
+
386
+ .time-chip.is-running :global(.time-open) {
387
+ color: var(--tone-accent, var(--color-primary));
388
+ }
389
+
390
+ .time-toggle,
391
+ :global(.time-open) {
392
+ display: inline-flex;
393
+ align-items: center;
394
+ outline: none;
395
+ transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
396
+ }
397
+
398
+ .time-toggle {
399
+ justify-content: center;
400
+ width: 1.75rem;
401
+ border-start-start-radius: inherit;
402
+ border-end-start-radius: inherit;
403
+ }
404
+
405
+ :global(.time-open) {
406
+ gap: 0.375rem;
407
+ padding-inline: 0.25rem 0.5rem;
408
+ font-variant-numeric: tabular-nums;
409
+ white-space: nowrap;
410
+ border-start-end-radius: inherit;
411
+ border-end-end-radius: inherit;
412
+ }
413
+
414
+ :global(.time-open[data-state="open"]) {
415
+ background: color-mix(in oklab, var(--color-foreground) 7%, transparent);
416
+ }
417
+
418
+ @media (hover: hover) and (pointer: fine) {
419
+ .time-toggle:hover,
420
+ :global(.time-open:hover) {
421
+ background: color-mix(in oklab, var(--color-foreground) 7%, transparent);
422
+ }
423
+ }
424
+
425
+ .time-toggle:focus-visible,
426
+ :global(.time-open:focus-visible) {
427
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 50%, transparent);
428
+ }
429
+
430
+ .time-toggle:active {
431
+ transform: scale(0.94);
432
+ }
433
+
434
+ @media (any-pointer: coarse) {
435
+ .time-chip {
436
+ height: 2.5rem;
437
+ }
438
+
439
+ .time-toggle {
440
+ width: 2.5rem;
441
+ }
442
+ }
443
+
444
+ .time-field {
445
+ height: 1.75rem;
446
+ min-width: 0;
447
+ border-radius: var(--radius-md, 0.5rem);
448
+ border: 1px solid var(--color-input, var(--color-border));
449
+ background: var(--color-background);
450
+ padding-inline: 0.5rem;
451
+ font-size: 0.8125rem;
452
+ color: var(--color-foreground);
453
+ outline: none;
454
+ transition:
455
+ border-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out),
456
+ box-shadow var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
457
+ }
458
+
459
+ .time-field::placeholder {
460
+ color: var(--color-muted-foreground);
461
+ opacity: 0.7;
462
+ }
463
+
464
+ .time-field:focus-visible {
465
+ border-color: var(--color-ring);
466
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 40%, transparent);
467
+ }
468
+
469
+ .time-field[aria-invalid="true"] {
470
+ border-color: var(--color-destructive);
471
+ }
472
+
473
+ .time-log {
474
+ height: 1.75rem;
475
+ border-radius: var(--radius-md, 0.5rem);
476
+ border: 1px solid var(--color-border);
477
+ background: var(--color-background);
478
+ padding-inline: 0.75rem;
479
+ font-size: 0.8125rem;
480
+ font-weight: 500;
481
+ outline: none;
482
+ transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
483
+ }
484
+
485
+ @media (hover: hover) and (pointer: fine) {
486
+ .time-log:hover:not(:disabled) {
487
+ background: var(--color-muted);
488
+ }
489
+ }
490
+
491
+ .time-log:focus-visible {
492
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 50%, transparent);
493
+ }
494
+
495
+ .time-log:disabled {
496
+ opacity: 0.5;
497
+ pointer-events: none;
498
+ }
499
+
500
+ .time-segment {
501
+ transition: width var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
502
+ }
503
+
504
+ .time-delete {
505
+ opacity: 0;
506
+ }
507
+
508
+ .time-row:focus-within .time-delete {
509
+ opacity: 1;
510
+ }
511
+
512
+ @media (hover: hover) and (pointer: fine) {
513
+ .time-row:hover .time-delete {
514
+ opacity: 1;
515
+ }
516
+ }
517
+
518
+ @media (hover: none) {
519
+ .time-delete {
520
+ opacity: 1;
521
+ }
522
+ }
523
+
524
+ .time-pop,
525
+ .time-preview {
526
+ animation: time-pop var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) backwards;
527
+ }
528
+
529
+ .time-row.is-new {
530
+ animation: time-row-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
531
+ }
532
+
533
+ @keyframes time-pop {
534
+ from {
535
+ opacity: 0;
536
+ transform: translateY(4px);
537
+ filter: blur(var(--blur-small, 2px));
538
+ }
539
+ }
540
+
541
+ @keyframes time-row-in {
542
+ from {
543
+ opacity: 0;
544
+ transform: translateY(var(--distance-base, 8px));
545
+ filter: blur(var(--blur-small, 2px));
546
+ }
547
+ }
548
+
549
+ @media (prefers-reduced-motion: reduce) {
550
+ .time-chip,
551
+ .time-toggle,
552
+ :global(.time-open),
553
+ .time-field,
554
+ .time-log,
555
+ .time-segment {
556
+ transition: none;
557
+ }
558
+
559
+ .time-pop,
560
+ .time-preview,
561
+ .time-row.is-new {
562
+ animation: none;
563
+ }
564
+ }
565
+ </style>
@@ -0,0 +1,74 @@
1
+ /** A running timer. The host persists it, the tracker only reads it. */
2
+ export type TimeRunning = {
3
+ startedAt: Date | string | number;
4
+ };
5
+
6
+ /** What the tracker hands back when a manual entry is submitted. */
7
+ export type TimeLogInput = {
8
+ minutes: number;
9
+ /** The day worked, `YYYY-MM-DD`. */
10
+ day: string;
11
+ note?: string;
12
+ };
13
+
14
+ /** What the tracker hands back when the timer stops. */
15
+ export type TimeStopInfo = {
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
+ };
23
+
24
+ export type TimeLabels = {
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
+ };
49
+
50
+ export const DEFAULT_TIME_LABELS: TimeLabels = {
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
+ };