@eifi1/ui-kit 0.4.0 → 0.5.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/LICENSE +32 -0
- package/README.md +498 -57
- package/dist/chart.d.ts +10 -0
- package/dist/chart.js +10 -0
- package/dist/chart.js.map +1 -0
- package/dist/components/account-settings.d.ts +11 -0
- package/dist/components/account-settings.js +19 -12
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +10 -6
- package/dist/components/alert-banner.js +20 -16
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +30 -4
- package/dist/components/amount-input.js +36 -14
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calculator.d.ts +24 -7
- package/dist/components/calculator.js +37 -20
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.d.ts +156 -0
- package/dist/components/chart-zoom.js +262 -0
- package/dist/components/chart-zoom.js.map +1 -0
- package/dist/components/chart.d.ts +28 -3
- package/dist/components/chart.js +71 -32
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox.d.ts +51 -0
- package/dist/components/checkbox.js +123 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip.d.ts +128 -0
- package/dist/components/chip.js +357 -0
- package/dist/components/chip.js.map +1 -0
- package/dist/components/choice-parts.d.ts +24 -0
- package/dist/components/choice-parts.js +18 -0
- package/dist/components/choice-parts.js.map +1 -0
- package/dist/components/combobox-core.d.ts +52 -9
- package/dist/components/combobox-core.js +91 -20
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +43 -33
- package/dist/components/combobox.js +409 -340
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.d.ts +48 -8
- package/dist/components/currency-select.js +107 -65
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +15 -3
- package/dist/components/data-table-filter-popover.js +23 -17
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +3 -2
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +53 -2
- package/dist/components/data-table-labels.js +31 -2
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table-pagination.js +16 -10
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.js +1 -0
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +201 -85
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +25 -9
- package/dist/components/date-picker.js +232 -94
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dropdown.d.ts +96 -20
- package/dist/components/dropdown.js +77 -33
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +5 -4
- package/dist/components/entity-combobox.js +107 -76
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +36 -0
- package/dist/components/facing-pair.js +40 -0
- package/dist/components/facing-pair.js.map +1 -0
- package/dist/components/field-sync.d.ts +164 -0
- package/dist/components/field-sync.js +264 -0
- package/dist/components/field-sync.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +38 -6
- package/dist/components/file-dropzone.js +34 -9
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +37 -26
- package/dist/components/full-bleed-dialog.js +20 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/grouped-picker.d.ts +21 -10
- package/dist/components/grouped-picker.js +66 -52
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +20 -4
- package/dist/components/hover-menu.js +15 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +102 -14
- package/dist/components/mini-calendar.js +256 -70
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +9 -3
- package/dist/components/modal.js +34 -45
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +79 -0
- package/dist/components/month-picker.js +342 -0
- package/dist/components/month-picker.js.map +1 -0
- package/dist/components/multi-entity-combobox.d.ts +7 -6
- package/dist/components/multi-entity-combobox.js +112 -72
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +13 -11
- package/dist/components/multi-select.js +161 -84
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +92 -0
- package/dist/components/number-field.js +111 -0
- package/dist/components/number-field.js.map +1 -0
- package/dist/components/number-input.d.ts +28 -2
- package/dist/components/number-input.js +12 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +32 -4
- package/dist/components/numpad-sheet.js +30 -13
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/page-contents.d.ts +90 -0
- package/dist/components/page-contents.js +170 -0
- package/dist/components/page-contents.js.map +1 -0
- package/dist/components/password-strength.d.ts +106 -0
- package/dist/components/password-strength.js +132 -0
- package/dist/components/password-strength.js.map +1 -0
- package/dist/components/picker-sheet.d.ts +23 -15
- package/dist/components/picker-sheet.js +34 -14
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.d.ts +62 -4
- package/dist/components/popover.js +52 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +25 -7
- package/dist/components/search-field.js +7 -5
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +14 -0
- package/dist/components/series-chart-labels.js +11 -0
- package/dist/components/series-chart-labels.js.map +1 -0
- package/dist/components/series-chart.d.ts +207 -0
- package/dist/components/series-chart.js +223 -0
- package/dist/components/series-chart.js.map +1 -0
- package/dist/components/settings-fields.d.ts +14 -4
- package/dist/components/settings-fields.js +19 -8
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/signature-pad.d.ts +119 -0
- package/dist/components/signature-pad.js +405 -0
- package/dist/components/signature-pad.js.map +1 -0
- package/dist/components/slider.d.ts +80 -0
- package/dist/components/slider.js +155 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sparkline.d.ts +93 -0
- package/dist/components/sparkline.js +205 -0
- package/dist/components/sparkline.js.map +1 -0
- package/dist/components/stat-tile.d.ts +151 -0
- package/dist/components/stat-tile.js +236 -0
- package/dist/components/stat-tile.js.map +1 -0
- package/dist/components/swipeable-row.d.ts +25 -2
- package/dist/components/swipeable-row.js +31 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/switch.d.ts +60 -0
- package/dist/components/switch.js +81 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/time-input.d.ts +58 -0
- package/dist/components/time-input.js +50 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/toggle-group.d.ts +18 -3
- package/dist/components/toggle-group.js +10 -6
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +115 -0
- package/dist/components/toggle-legend.js +114 -0
- package/dist/components/toggle-legend.js.map +1 -0
- package/dist/components/tooltip.d.ts +40 -11
- package/dist/components/tooltip.js +77 -20
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/treemap.d.ts +132 -0
- package/dist/components/treemap.js +179 -0
- package/dist/components/treemap.js.map +1 -0
- package/dist/components/ui.d.ts +159 -46
- package/dist/components/ui.js +220 -115
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-mobile-reveal.js +1 -0
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/use-table-state.js +38 -17
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +15 -4
- package/dist/components/user-avatar.js +5 -4
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/components/wizard-stepper.js +3 -2
- package/dist/components/wizard-stepper.js.map +1 -1
- package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
- package/dist/data-table.d.ts +7 -0
- package/dist/data-table.js +12 -0
- package/dist/data-table.js.map +1 -0
- package/dist/feedback/feedback-attachment.js +12 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.js +2 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +33 -7
- package/dist/feedback/feedback-inbox.js +49 -37
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback.d.ts +5 -0
- package/dist/feedback.js +5 -0
- package/dist/feedback.js.map +1 -0
- package/dist/hooks/use-anchored-panel.js +1 -0
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js +1 -0
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-announce.d.ts +46 -0
- package/dist/hooks/use-announce.js +37 -0
- package/dist/hooks/use-announce.js.map +1 -0
- package/dist/hooks/use-body-scroll-lock.js +1 -0
- package/dist/hooks/use-body-scroll-lock.js.map +1 -1
- package/dist/hooks/use-close-transition.js +1 -0
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-dismiss.js +1 -0
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-focus-trap.d.ts +16 -0
- package/dist/hooks/use-focus-trap.js +67 -0
- package/dist/hooks/use-focus-trap.js.map +1 -0
- package/dist/hooks/use-media-query.js +1 -0
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +27 -1
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.js +1 -0
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.d.ts +31 -0
- package/dist/i18n/defaults.js +63 -0
- package/dist/i18n/defaults.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +250 -0
- package/dist/i18n/kit-labels.js +184 -0
- package/dist/i18n/kit-labels.js.map +1 -0
- package/dist/index.d.ts +47 -22
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/calc.js +1 -0
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/cn.js.map +1 -1
- package/dist/lib/dates.js +1 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/logger.js +1 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/safe-storage.js +1 -0
- package/dist/lib/safe-storage.js.map +1 -1
- package/dist/search/command-palette.d.ts +11 -1
- package/dist/search/command-palette.js +21 -15
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.js +3 -0
- package/dist/search.js.map +1 -0
- package/dist/shell/app-shell.d.ts +33 -4
- package/dist/shell/app-shell.js +275 -78
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.js +3 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/role-switcher.d.ts +6 -0
- package/dist/shell/role-switcher.js +7 -4
- package/dist/shell/role-switcher.js.map +1 -1
- package/dist/shell/top-bar.d.ts +15 -7
- package/dist/shell/top-bar.js +3 -5
- package/dist/shell/top-bar.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +3 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +5 -2
- package/dist/shell/topbar-controls.js +20 -15
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell.d.ts +13 -0
- package/dist/shell.js +8 -0
- package/dist/shell.js.map +1 -0
- package/dist/theme/chart-palette.d.ts +19 -12
- package/dist/theme/chart-palette.js +24 -30
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/color.d.ts +78 -0
- package/dist/theme/color.js +148 -0
- package/dist/theme/color.js.map +1 -0
- package/dist/theme/palette-derive.d.ts +140 -0
- package/dist/theme/palette-derive.js +376 -0
- package/dist/theme/palette-derive.js.map +1 -0
- package/dist/theme/palette-presets.d.ts +27 -5
- package/dist/theme/palette-presets.js +231 -33
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js +1 -0
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour.d.ts +2 -1
- package/dist/tour/tour.js +15 -9
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour.d.ts +2 -0
- package/dist/tour.js +3 -0
- package/dist/tour.js.map +1 -0
- package/dist/wizard/stepper-nav.js +10 -8
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +18 -2
- package/dist/wizard/types.js +4 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +12 -4
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js +1 -0
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js +1 -0
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.js +1 -0
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js +5 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +8 -0
- package/dist/wizard.js +9 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +67 -14
- package/src/chart.ts +22 -0
- package/src/components/account-settings.tsx +275 -0
- package/src/components/alert-banner.tsx +75 -0
- package/src/components/amount-input.tsx +471 -0
- package/src/components/calculator.tsx +252 -0
- package/src/components/chart-zoom.tsx +499 -0
- package/src/components/chart.tsx +363 -0
- package/src/components/checkbox.tsx +222 -0
- package/src/components/chip.tsx +570 -0
- package/src/components/choice-parts.ts +33 -0
- package/src/components/combobox-core.tsx +540 -0
- package/src/components/combobox.tsx +1026 -0
- package/src/components/currency-select.tsx +269 -0
- package/src/components/data-table-filter-popover.tsx +252 -0
- package/src/components/data-table-filters.ts +176 -0
- package/src/components/data-table-labels.ts +190 -0
- package/src/components/data-table-pagination.tsx +126 -0
- package/src/components/data-table-sort.ts +70 -0
- package/src/components/data-table.tsx +1740 -0
- package/src/components/date-picker.tsx +631 -0
- package/src/components/dropdown.tsx +341 -0
- package/src/components/entity-combobox.tsx +215 -0
- package/src/components/facing-pair.ts +86 -0
- package/src/components/field-sync.tsx +472 -0
- package/src/components/file-dropzone.tsx +181 -0
- package/src/components/full-bleed-dialog.tsx +179 -0
- package/src/components/grouped-picker.tsx +157 -0
- package/src/components/hover-menu.tsx +183 -0
- package/src/components/mini-calendar.tsx +554 -0
- package/src/components/modal.tsx +287 -0
- package/src/components/month-picker.tsx +549 -0
- package/src/components/multi-entity-combobox.tsx +226 -0
- package/src/components/multi-select.tsx +303 -0
- package/src/components/number-field.tsx +225 -0
- package/src/components/number-input.tsx +211 -0
- package/src/components/numpad-sheet.tsx +264 -0
- package/src/components/page-contents.tsx +289 -0
- package/src/components/password-strength.tsx +258 -0
- package/src/components/picker-sheet.tsx +251 -0
- package/src/components/popover.tsx +195 -0
- package/src/components/search-field.tsx +146 -0
- package/src/components/series-chart-labels.ts +26 -0
- package/src/components/series-chart.tsx +481 -0
- package/src/components/settings-fields.tsx +68 -0
- package/src/components/signature-pad.tsx +630 -0
- package/src/components/slider.tsx +313 -0
- package/src/components/sparkline.tsx +329 -0
- package/src/components/stat-tile.tsx +448 -0
- package/src/components/swipeable-row.tsx +271 -0
- package/src/components/switch.tsx +170 -0
- package/src/components/time-input.tsx +107 -0
- package/src/components/toggle-group.tsx +153 -0
- package/src/components/toggle-legend.tsx +229 -0
- package/src/components/tooltip.tsx +477 -0
- package/src/components/treemap.tsx +390 -0
- package/src/components/ui.tsx +1284 -0
- package/src/components/use-mobile-reveal.ts +54 -0
- package/src/components/use-table-state.ts +295 -0
- package/src/components/user-avatar.tsx +61 -0
- package/src/components/wizard-stepper.tsx +37 -0
- package/src/data-table.ts +19 -0
- package/src/feedback/feedback-attachment.tsx +246 -0
- package/src/feedback/feedback-dialog.tsx +209 -0
- package/src/feedback/feedback-inbox.tsx +615 -0
- package/src/feedback.ts +8 -0
- package/src/hooks/use-anchored-panel.ts +169 -0
- package/src/hooks/use-anchored-rect.ts +78 -0
- package/src/hooks/use-announce.ts +85 -0
- package/src/hooks/use-body-scroll-lock.ts +94 -0
- package/src/hooks/use-close-transition.ts +99 -0
- package/src/hooks/use-dismiss.ts +77 -0
- package/src/hooks/use-focus-trap.ts +160 -0
- package/src/hooks/use-media-query.ts +23 -0
- package/src/hooks/use-overlay-history.ts +345 -0
- package/src/hooks/use-row-swipe.ts +212 -0
- package/src/i18n/defaults.ts +68 -0
- package/src/i18n/kit-labels.tsx +447 -0
- package/src/index.ts +169 -0
- package/src/lib/calc.ts +309 -0
- package/src/lib/cn.ts +5 -0
- package/src/lib/dates.ts +187 -0
- package/src/lib/logger.ts +73 -0
- package/src/lib/safe-storage.ts +37 -0
- package/src/search/command-palette.tsx +342 -0
- package/src/search.ts +3 -0
- package/src/shell/app-shell.tsx +772 -0
- package/src/shell/option-switcher-menu.tsx +72 -0
- package/src/shell/role-switcher.tsx +46 -0
- package/src/shell/top-bar.tsx +39 -0
- package/src/shell/topbar-action-menu.tsx +91 -0
- package/src/shell/topbar-controls.tsx +229 -0
- package/src/shell.ts +10 -0
- package/src/theme/chart-palette.ts +145 -0
- package/src/theme/color.ts +266 -0
- package/src/theme/palette-derive.ts +681 -0
- package/src/theme/palette-presets.ts +760 -0
- package/src/theme/palette-store.ts +94 -0
- package/src/theme/theme-store.ts +145 -0
- package/src/tour/tour.tsx +586 -0
- package/src/tour.ts +3 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wizard/stepper-nav.tsx +221 -0
- package/src/wizard/types.ts +178 -0
- package/src/wizard/use-wizard.ts +345 -0
- package/src/wizard/validation.ts +37 -0
- package/src/wizard/wizard-context.tsx +29 -0
- package/src/wizard/wizard-step.tsx +14 -0
- package/src/wizard/wizard-summary.tsx +69 -0
- package/src/wizard.ts +14 -0
- package/tokens.css +222 -20
package/src/lib/calc.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, dependency-free arithmetic evaluator for the numeric input fields.
|
|
3
|
+
* It lets users type a calculation (e.g. "12.50+3.20" or "100/3") straight into
|
|
4
|
+
* an amount field, or build one in the calculator popover, and have it resolve
|
|
5
|
+
* to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled
|
|
6
|
+
* tokenizer + recursive-descent parser over a closed grammar (digits, decimals,
|
|
7
|
+
* `+ - * /`, unary minus, parentheses), so no arbitrary code can run.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Token =
|
|
11
|
+
| { kind: "num"; value: number }
|
|
12
|
+
| { kind: "op"; value: "+" | "-" | "*" | "/" }
|
|
13
|
+
| { kind: "lparen" }
|
|
14
|
+
| { kind: "rparen" };
|
|
15
|
+
|
|
16
|
+
function tokenize(input: string): Token[] | null {
|
|
17
|
+
const tokens: Token[] = [];
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < input.length) {
|
|
20
|
+
const ch = input[i];
|
|
21
|
+
if (ch === " ") {
|
|
22
|
+
i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (ch === "+" || ch === "-" || ch === "*" || ch === "/") {
|
|
26
|
+
tokens.push({ kind: "op", value: ch });
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (ch === "(") {
|
|
31
|
+
tokens.push({ kind: "lparen" });
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (ch === ")") {
|
|
36
|
+
tokens.push({ kind: "rparen" });
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if ((ch >= "0" && ch <= "9") || ch === ".") {
|
|
41
|
+
let j = i;
|
|
42
|
+
let dotSeen = false;
|
|
43
|
+
while (j < input.length && ((input[j] >= "0" && input[j] <= "9") || input[j] === ".")) {
|
|
44
|
+
if (input[j] === ".") {
|
|
45
|
+
if (dotSeen) return null; // two dots in one number → invalid
|
|
46
|
+
dotSeen = true;
|
|
47
|
+
}
|
|
48
|
+
j++;
|
|
49
|
+
}
|
|
50
|
+
const num = Number(input.slice(i, j));
|
|
51
|
+
if (!Number.isFinite(num)) return null;
|
|
52
|
+
tokens.push({ kind: "num", value: num });
|
|
53
|
+
i = j;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
return null; // unknown character
|
|
57
|
+
}
|
|
58
|
+
return tokens;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Recursive-descent parse honouring `* /` over `+ -` and left-associativity.
|
|
63
|
+
* Returns `null` for any malformed input (the `null` sentinel is safe because a
|
|
64
|
+
* valid result of 0 is distinct from it). Division by zero is treated as invalid.
|
|
65
|
+
*
|
|
66
|
+
* expr := term (('+' | '-') term)*
|
|
67
|
+
* term := factor (('*' | '/') factor)*
|
|
68
|
+
* factor := ('+' | '-') factor | number | '(' expr ')'
|
|
69
|
+
*/
|
|
70
|
+
function parse(tokens: Token[]): number | null {
|
|
71
|
+
let pos = 0;
|
|
72
|
+
|
|
73
|
+
function parseExpr(): number | null {
|
|
74
|
+
let value = parseTerm();
|
|
75
|
+
if (value === null) return null;
|
|
76
|
+
while (pos < tokens.length) {
|
|
77
|
+
const tok = tokens[pos];
|
|
78
|
+
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
|
79
|
+
pos++;
|
|
80
|
+
const rhs = parseTerm();
|
|
81
|
+
if (rhs === null) return null;
|
|
82
|
+
value = tok.value === "+" ? value + rhs : value - rhs;
|
|
83
|
+
} else break;
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseTerm(): number | null {
|
|
89
|
+
let value = parseFactor();
|
|
90
|
+
if (value === null) return null;
|
|
91
|
+
while (pos < tokens.length) {
|
|
92
|
+
const tok = tokens[pos];
|
|
93
|
+
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
|
94
|
+
pos++;
|
|
95
|
+
const rhs = parseFactor();
|
|
96
|
+
if (rhs === null) return null;
|
|
97
|
+
if (tok.value === "/") {
|
|
98
|
+
if (rhs === 0) return null;
|
|
99
|
+
value = value / rhs;
|
|
100
|
+
} else {
|
|
101
|
+
value = value * rhs;
|
|
102
|
+
}
|
|
103
|
+
} else break;
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseFactor(): number | null {
|
|
109
|
+
const tok = tokens[pos];
|
|
110
|
+
if (!tok) return null;
|
|
111
|
+
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
|
112
|
+
pos++;
|
|
113
|
+
const operand = parseFactor();
|
|
114
|
+
if (operand === null) return null;
|
|
115
|
+
return tok.value === "-" ? -operand : operand;
|
|
116
|
+
}
|
|
117
|
+
if (tok.kind === "lparen") {
|
|
118
|
+
pos++;
|
|
119
|
+
const inner = parseExpr();
|
|
120
|
+
if (inner === null) return null;
|
|
121
|
+
if (tokens[pos]?.kind !== "rparen") return null;
|
|
122
|
+
pos++;
|
|
123
|
+
return inner;
|
|
124
|
+
}
|
|
125
|
+
if (tok.kind === "num") {
|
|
126
|
+
pos++;
|
|
127
|
+
return tok.value;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = parseExpr();
|
|
133
|
+
if (result === null) return null;
|
|
134
|
+
if (pos !== tokens.length) return null; // leftover tokens → invalid
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.
|
|
139
|
+
* Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */
|
|
140
|
+
export function evaluateExpression(expr: string): number | null {
|
|
141
|
+
const normalized = expr
|
|
142
|
+
.replace(/×/g, "*")
|
|
143
|
+
.replace(/÷/g, "/")
|
|
144
|
+
.replace(/[−–—]/g, "-") // unicode minus / en / em dash → ascii
|
|
145
|
+
.replace(/,/g, ".")
|
|
146
|
+
.trim();
|
|
147
|
+
if (normalized === "") return null;
|
|
148
|
+
const tokens = tokenize(normalized);
|
|
149
|
+
if (tokens === null || tokens.length === 0) return null;
|
|
150
|
+
const result = parse(tokens);
|
|
151
|
+
if (result === null || !Number.isFinite(result)) return null;
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain
|
|
156
|
+
* number (or a lone negative like "-5") is NOT an expression, but "12+5" is.
|
|
157
|
+
* Used to decide whether a field's text should be evaluated on commit.
|
|
158
|
+
*
|
|
159
|
+
* The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs
|
|
160
|
+
* included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not
|
|
161
|
+
* arithmetic went on to be read as a plain number — where the digits-only fallback
|
|
162
|
+
* in {@link commitExpression} silently ate it, turning "-42−−50" (= 8) into a bare
|
|
163
|
+
* 8 with no record that a sum had happened at all. */
|
|
164
|
+
export function looksLikeExpression(s: string): boolean {
|
|
165
|
+
const t = s.trim();
|
|
166
|
+
if (t === "") return false;
|
|
167
|
+
if (/^-?\d*[.,]?\d*$/.test(t)) return false; // plain (optionally negative) decimal
|
|
168
|
+
return /[+\-*/×÷−–—()]/.test(t.replace(/^-/, ""));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Render an evaluated number back to a field-friendly string, trimming binary
|
|
172
|
+
* float noise (so 0.1 + 0.2 reads "0.3", not "0.30000000000000004").
|
|
173
|
+
*
|
|
174
|
+
* FIXED notation, always, and that is the whole point of the function rather than a
|
|
175
|
+
* detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and
|
|
176
|
+
* `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or
|
|
177
|
+
* above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a
|
|
178
|
+
* field's text back accepts it: {@link tokenize} rejects `e` as an unknown character
|
|
179
|
+
* and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to
|
|
180
|
+
* its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to
|
|
181
|
+
* the exponent. A field committed its own output as a different number:
|
|
182
|
+
*
|
|
183
|
+
* "1/10000000" -> "1e-7" -> "17" (AmountInput: "6")
|
|
184
|
+
* "999999999999*999999999999" -> "9.99999999998e+23" -> "9.9999999999823"
|
|
185
|
+
*
|
|
186
|
+
* Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on
|
|
187
|
+
* its own, it was silent, and it was in a money field.
|
|
188
|
+
*
|
|
189
|
+
* The invariant, which the test asserts directly rather than by example:
|
|
190
|
+
* **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite
|
|
191
|
+
* `n`.** Anything rendered here has to survive being read back. */
|
|
192
|
+
export function formatResult(n: number): string {
|
|
193
|
+
if (!Number.isFinite(n)) return "";
|
|
194
|
+
// At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —
|
|
195
|
+
// but every double that large is an integer, so BigInt renders it exactly.
|
|
196
|
+
if (Math.abs(n) >= 1e21) return BigInt(n).toString();
|
|
197
|
+
return trimTrailingZeros(n.toFixed(10));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** "15.7000000000" -> "15.7", "0.0000000000" -> "0". The ten decimals are where the
|
|
201
|
+
* binary float noise gets rounded off; this is only about not showing the padding. */
|
|
202
|
+
function trimTrailingZeros(fixed: string): string {
|
|
203
|
+
if (!fixed.includes(".")) return fixed;
|
|
204
|
+
const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, "");
|
|
205
|
+
// `toFixed` keeps the sign on a magnitude that rounded away to nothing
|
|
206
|
+
// ("-0.0000000000"); `toString` did not, and "-0" in an amount field is noise.
|
|
207
|
+
return trimmed === "-0" || trimmed === "" ? "0" : trimmed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The single "on blur / Enter" transform a numeric field applies: evaluate the
|
|
211
|
+
* text if it's an expression, otherwise normalise it to a bare number string
|
|
212
|
+
* (comma → dot, dropping stray characters, non-leading minuses and anything after
|
|
213
|
+
* a second decimal point).
|
|
214
|
+
*
|
|
215
|
+
* The last clause is the backstop for Keksdose live #201. A field the user typed in
|
|
216
|
+
* cannot reach here malformed any more — {@link sanitizeLive} runs on every change,
|
|
217
|
+
* including a paste — but a value handed in from elsewhere (a recurring rule, an
|
|
218
|
+
* import, a test) still can, and "1.2.3" is not an expression (no operator), so it
|
|
219
|
+
* used to fall straight through unchanged and be read as NaN. Here the text is CUT
|
|
220
|
+
* at the second dot ("1.2") rather than joined ("1.23"): with no keystroke to
|
|
221
|
+
* ignore there is nothing to say which reading was meant, and the shorter one
|
|
222
|
+
* cannot silently invent a bigger number. */
|
|
223
|
+
export function commitExpression(raw: string): string {
|
|
224
|
+
const s = raw.trim();
|
|
225
|
+
if (looksLikeExpression(s)) {
|
|
226
|
+
const n = evaluateExpression(s);
|
|
227
|
+
if (n !== null) return formatResult(n);
|
|
228
|
+
// Unparseable (e.g. a half-typed "12+"): fall through to a digits-only clean.
|
|
229
|
+
}
|
|
230
|
+
// Global, like sanitizeLive's: these two are the two halves of one rule and must
|
|
231
|
+
// not appear to state it differently. (The `[^0-9.-]` strip on the same line
|
|
232
|
+
// already ate whatever commas a non-global replace missed, so this changes no
|
|
233
|
+
// behaviour — it removes the need for the next reader to run it to find that out.)
|
|
234
|
+
const cleaned = s.replace(/,/g, ".").replace(/[^0-9.-]/g, "").replace(/(?!^)-/g, "");
|
|
235
|
+
return cleaned.replace(/^(-?\d*\.?\d*).*$/, "$1");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The live, per-keystroke transform a numeric field applies: comma → dot and
|
|
239
|
+
* drop anything that isn't a digit, decimal point, arithmetic operator (incl.
|
|
240
|
+
* the on-screen `× ÷` glyphs) or paren — so a typed calculation survives
|
|
241
|
+
* keystroke-by-keystroke but stray characters can't be entered. A lone leading
|
|
242
|
+
* `-` is kept — in a plain field it is simply a negative amount; in a field whose
|
|
243
|
+
* caller owns the sign it is read off again by {@link splitLeadingSign}. The text
|
|
244
|
+
* is resolved to a plain number on blur/Enter via {@link commitExpression}. */
|
|
245
|
+
export function sanitizeLive(raw: string): string {
|
|
246
|
+
return dropExtraDots(raw.replace(/,/g, ".").replace(/[^0-9.+\-*/×÷()]/g, ""));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* At most ONE decimal point per number (Keksdose live #201).
|
|
251
|
+
*
|
|
252
|
+
* Per NUMBER, not per string: "1.5+2.5" is a legitimate calculation with two dots
|
|
253
|
+
* in two operands, so the counter resets at every operator and parenthesis.
|
|
254
|
+
*
|
|
255
|
+
* A second dot inside one operand is a **no-op keystroke** — the digits after it
|
|
256
|
+
* keep landing, so "1.2" + "." + "3" is "1.23". That is what every calculator does
|
|
257
|
+
* with a repeated decimal point, and it is the only option that does not make the
|
|
258
|
+
* field feel broken ("my 3 disappeared"). This runs on paste as well as on typing,
|
|
259
|
+
* so the malformed value never reaches the rest of the app: everything downstream
|
|
260
|
+
* reads the field with `Number()`, NaN became a silent 0, and on the create form
|
|
261
|
+
* that 0 was painted with the income green — a figure that was not a number at all
|
|
262
|
+
* asserting money was coming in.
|
|
263
|
+
*/
|
|
264
|
+
function dropExtraDots(s: string): string {
|
|
265
|
+
let out = "";
|
|
266
|
+
let dotInNumber = false;
|
|
267
|
+
for (const ch of s) {
|
|
268
|
+
if (ch === ".") {
|
|
269
|
+
if (dotInNumber) continue;
|
|
270
|
+
dotInNumber = true;
|
|
271
|
+
} else if (ch < "0" || ch > "9") {
|
|
272
|
+
// An operator or a paren ends the current operand.
|
|
273
|
+
dotInNumber = false;
|
|
274
|
+
}
|
|
275
|
+
out += ch;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A bare, unsigned amount: digits with at most one decimal separator and nothing
|
|
281
|
+
* else — "42", "42.", ".5", "0,5". NOT "" (an empty field has no figure to put a
|
|
282
|
+
* sign in front of), not "-42", not "12+5". It is the only shape a DISPLAYED sign
|
|
283
|
+
* prefix makes sense in front of; an expression carries its own arithmetic and has
|
|
284
|
+
* to survive keystroke-for-keystroke. */
|
|
285
|
+
export function isBareAmount(s: string): boolean {
|
|
286
|
+
const t = s.trim();
|
|
287
|
+
return t !== "" && /^\d*[.,]?\d*$/.test(t);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Split a LEADING sign off a plain signed number (Keksdose live #201 rework):
|
|
292
|
+
* `"-42"` → `{ sign: "-", rest: "42" }`, everything else → `{ sign: "", rest: text }`.
|
|
293
|
+
*
|
|
294
|
+
* Only in front of a bare amount, because a minus with arithmetic after it IS
|
|
295
|
+
* arithmetic: "12-30" is a subtraction, and "-12+5" is -7, not -(12+5). Those come
|
|
296
|
+
* back untouched and are resolved by {@link commitExpression} on blur — at which
|
|
297
|
+
* point the RESULT is a plain signed number and this function does get to speak.
|
|
298
|
+
*
|
|
299
|
+
* A lone "-" splits (rest ""): the sign is a statement about the direction the
|
|
300
|
+
* moment it is typed, before any digit exists.
|
|
301
|
+
*/
|
|
302
|
+
export function splitLeadingSign(text: string): { sign: "" | "-" | "+"; rest: string } {
|
|
303
|
+
const trimmed = text.trimStart();
|
|
304
|
+
const sign = trimmed.charAt(0);
|
|
305
|
+
if (sign !== "-" && sign !== "+") return { sign: "", rest: text };
|
|
306
|
+
const rest = trimmed.slice(1);
|
|
307
|
+
if (rest !== "" && !isBareAmount(rest)) return { sign: "", rest: text };
|
|
308
|
+
return { sign, rest };
|
|
309
|
+
}
|
package/src/lib/cn.ts
ADDED
package/src/lib/dates.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO date helpers, collected from copies that were scattered across the pages.
|
|
3
|
+
*
|
|
4
|
+
* **Every "what day is it" helper here reads the LOCAL calendar** — one family, not
|
|
5
|
+
* two. It used to be two: these all built a local Date and then read it back through
|
|
6
|
+
* `toISOString()`, which is UTC, so east of Greenwich they all returned YESTERDAY
|
|
7
|
+
* between local midnight and the UTC rollover. The old module note called that
|
|
8
|
+
* deliberate and warned against changing it. It was not defensible, and dev#471 is
|
|
9
|
+
* what it cost:
|
|
10
|
+
*
|
|
11
|
+
* > *"Just entered a tx now from scheduled. But it still shows as upcoming. Is there
|
|
12
|
+
* > a problem with timezones? It is currently 1 o clock middle european summer time."*
|
|
13
|
+
*
|
|
14
|
+
* There was: at 01:00 CEST `todayIso()` answered with the 14th, the row the scheduler
|
|
15
|
+
* had just booked was dated the 15th, and `isUpcoming` compares the two strings — so a
|
|
16
|
+
* transaction entered a second ago read as one still to come. The same slip moved
|
|
17
|
+
* `startOfMonthIso()` onto the last day of the PREVIOUS month for an hour every first
|
|
18
|
+
* of the month, which is a report range nobody would have questioned.
|
|
19
|
+
*
|
|
20
|
+
* A calendar day only means anything to the human reading it, and every caller here —
|
|
21
|
+
* the register's today, the date-range presets, the budget cursor, a new
|
|
22
|
+
* transaction's default date — is asking that question. There is no caller for whom
|
|
23
|
+
* "the UTC day" is the right answer; the server compares plain dates with no zone at
|
|
24
|
+
* all, so sending it the user's local day is also what it wants.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Zero-pad a number to two digits ("3" -> "03"). */
|
|
28
|
+
export function pad(n: number): string {
|
|
29
|
+
return String(n).padStart(2, "0");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Today as "YYYY-MM-DD" on the LOCAL calendar (see the module note). */
|
|
33
|
+
export function todayIso(): string {
|
|
34
|
+
return toLocalIso(new Date());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A Date as a local-time "YYYY-MM-DD" string. */
|
|
38
|
+
export function toLocalIso(d: Date): string {
|
|
39
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A Date as a local-time "YYYY-MM" month key. */
|
|
43
|
+
export function monthKey(d: Date): string {
|
|
44
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- Date-range presets + calendar helpers ----------
|
|
48
|
+
// Local-calendar like todayIso (see the module note); used by the data-table date
|
|
49
|
+
// filter and its MiniCalendar. Each one mutates a local Date and must therefore READ
|
|
50
|
+
// it back locally — going out through toISOString() is what shifted every one of
|
|
51
|
+
// these by a day near midnight (dev#471).
|
|
52
|
+
|
|
53
|
+
/** Today shifted by `days`, as "YYYY-MM-DD". */
|
|
54
|
+
export function shiftIso(days: number): string {
|
|
55
|
+
const d = new Date();
|
|
56
|
+
d.setDate(d.getDate() + days);
|
|
57
|
+
return toLocalIso(d);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First day (Monday) of the current week, as "YYYY-MM-DD". */
|
|
61
|
+
export function startOfWeekIso(): string {
|
|
62
|
+
const d = new Date();
|
|
63
|
+
const day = d.getDay();
|
|
64
|
+
const diff = (day + 6) % 7; // monday = 0
|
|
65
|
+
d.setDate(d.getDate() - diff);
|
|
66
|
+
return toLocalIso(d);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** First day of the month `offsetMonths` from now, as "YYYY-MM-DD". */
|
|
70
|
+
export function startOfMonthIso(offsetMonths = 0): string {
|
|
71
|
+
const d = new Date();
|
|
72
|
+
d.setDate(1);
|
|
73
|
+
d.setMonth(d.getMonth() + offsetMonths);
|
|
74
|
+
return toLocalIso(d);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Last day of the month `offsetMonths` from now, as "YYYY-MM-DD". */
|
|
78
|
+
export function endOfMonthIso(offsetMonths = 0): string {
|
|
79
|
+
const d = new Date();
|
|
80
|
+
d.setDate(1);
|
|
81
|
+
d.setMonth(d.getMonth() + offsetMonths + 1);
|
|
82
|
+
d.setDate(0);
|
|
83
|
+
return toLocalIso(d);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** First day of the year `offsetYears` from now, as "YYYY-MM-DD". */
|
|
87
|
+
export function startOfYearIso(offsetYears = 0): string {
|
|
88
|
+
const d = new Date();
|
|
89
|
+
d.setMonth(0);
|
|
90
|
+
d.setDate(1);
|
|
91
|
+
d.setFullYear(d.getFullYear() + offsetYears);
|
|
92
|
+
return toLocalIso(d);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Last day of the year `offsetYears` from now, as "YYYY-MM-DD". */
|
|
96
|
+
export function endOfYearIso(offsetYears = 0): string {
|
|
97
|
+
const d = new Date();
|
|
98
|
+
d.setMonth(11);
|
|
99
|
+
d.setDate(31);
|
|
100
|
+
d.setFullYear(d.getFullYear() + offsetYears);
|
|
101
|
+
return toLocalIso(d);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse a "YYYY-MM-DD" string to a local-midnight Date, or null if malformed —
|
|
105
|
+
* where "malformed" includes a date that does not exist.
|
|
106
|
+
*
|
|
107
|
+
* The falsy check alone was not enough. `new Date(y, m - 1, d)` ROLLS OVER out-of-range
|
|
108
|
+
* components rather than refusing them, so "2026-13-45" came back as 2027-02-14 and
|
|
109
|
+
* "2026-02-30" as 2026-03-02: a valid-looking Date for a day that never happened. No
|
|
110
|
+
* caller could tell — {@link formatIsoDate} blanks only on `null`, so the invented
|
|
111
|
+
* date formatted and displayed exactly like a real one.
|
|
112
|
+
*
|
|
113
|
+
* The round-trip is the check: build the Date, then read the components back off it.
|
|
114
|
+
* A value that rolled over cannot answer with the numbers it was given. That is the
|
|
115
|
+
* same shape {@link sameYmd} already expresses, applied to a Date and its source. */
|
|
116
|
+
export function parseIsoDate(s: string): Date | null {
|
|
117
|
+
if (!s) return null;
|
|
118
|
+
const [y, m, d] = s.split("-").map(Number);
|
|
119
|
+
if (!y || !m || !d) return null;
|
|
120
|
+
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;
|
|
121
|
+
const dt = new Date(y, m - 1, d);
|
|
122
|
+
if (Number.isNaN(dt.getTime())) return null;
|
|
123
|
+
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
|
|
124
|
+
return dt;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Format a "YYYY-MM-DD" string for display in `locale` (empty string when
|
|
128
|
+
* unparseable). Defaults to the locale's short numeric date; pass `options` to
|
|
129
|
+
* override (e.g. `{ dateStyle: "medium" }`). */
|
|
130
|
+
export function formatIsoDate(
|
|
131
|
+
iso: string,
|
|
132
|
+
locale: string,
|
|
133
|
+
options?: Intl.DateTimeFormatOptions,
|
|
134
|
+
): string {
|
|
135
|
+
const d = parseIsoDate(iso);
|
|
136
|
+
return d ? d.toLocaleDateString(locale, options) : "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A "YYYY-MM-DD" string shifted by `days` (negative shifts back), or the input
|
|
140
|
+
* unchanged when it isn't a parseable date.
|
|
141
|
+
*
|
|
142
|
+
* Local-time throughout — {@link parseIsoDate} builds a local-midnight Date and
|
|
143
|
+
* {@link toLocalIso} reads local components back, so this never slides a day the way
|
|
144
|
+
* a `toISOString()` round-trip does west of UTC. */
|
|
145
|
+
export function addDaysIso(iso: string, days: number): string {
|
|
146
|
+
const d = parseIsoDate(iso);
|
|
147
|
+
if (!d) return iso;
|
|
148
|
+
d.setDate(d.getDate() + days);
|
|
149
|
+
return toLocalIso(d);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether two Dates fall on the same local calendar day. */
|
|
153
|
+
export function sameYmd(a: Date, b: Date): boolean {
|
|
154
|
+
return (
|
|
155
|
+
a.getFullYear() === b.getFullYear() &&
|
|
156
|
+
a.getMonth() === b.getMonth() &&
|
|
157
|
+
a.getDate() === b.getDate()
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface DateRangePreset {
|
|
162
|
+
key: string;
|
|
163
|
+
from: string;
|
|
164
|
+
to: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The named ranges offered by the data-table date filter (local-calendar, like
|
|
169
|
+
* {@link todayIso} — see the module note). `key` maps to the consumer's
|
|
170
|
+
* `table.preset_*` labels.
|
|
171
|
+
*/
|
|
172
|
+
export function dateRangePresets(): DateRangePreset[] {
|
|
173
|
+
const mondayOffset = (new Date().getDay() + 6) % 7; // monday = 0
|
|
174
|
+
return [
|
|
175
|
+
{ key: "today", from: todayIso(), to: todayIso() },
|
|
176
|
+
{ key: "yesterday", from: shiftIso(-1), to: shiftIso(-1) },
|
|
177
|
+
{ key: "this_week", from: startOfWeekIso(), to: todayIso() },
|
|
178
|
+
{ key: "last_week", from: shiftIso(-7 - mondayOffset), to: shiftIso(-1 - mondayOffset) },
|
|
179
|
+
{ key: "last_7_days", from: shiftIso(-6), to: todayIso() },
|
|
180
|
+
{ key: "last_30_days", from: shiftIso(-29), to: todayIso() },
|
|
181
|
+
{ key: "this_month", from: startOfMonthIso(0), to: todayIso() },
|
|
182
|
+
{ key: "last_month", from: startOfMonthIso(-1), to: endOfMonthIso(-1) },
|
|
183
|
+
{ key: "last_3_months", from: shiftIso(-90), to: todayIso() },
|
|
184
|
+
{ key: "ytd", from: startOfYearIso(0), to: todayIso() },
|
|
185
|
+
{ key: "last_year", from: startOfYearIso(-1), to: endOfYearIso(-1) },
|
|
186
|
+
];
|
|
187
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { StateCreator, StoreMutatorIdentifier } from "zustand";
|
|
2
|
+
|
|
3
|
+
import { readStored } from "./safe-storage";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* zustand logging middleware.
|
|
7
|
+
*
|
|
8
|
+
* Logs every state transition (a store "action") to the DevTools console as a
|
|
9
|
+
* single grouped line showing the prev/next state. Enabled in dev (Vite
|
|
10
|
+
* `import.meta.env.DEV`) by default; override at runtime with
|
|
11
|
+
* `localStorage.setItem("store_log", "0")` to silence or `"1"` to force-on in a
|
|
12
|
+
* production build.
|
|
13
|
+
*/
|
|
14
|
+
type Logger = <
|
|
15
|
+
T,
|
|
16
|
+
Mps extends [StoreMutatorIdentifier, unknown][] = [],
|
|
17
|
+
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
|
|
18
|
+
>(
|
|
19
|
+
f: StateCreator<T, Mps, Mcs>,
|
|
20
|
+
name?: string,
|
|
21
|
+
) => StateCreator<T, Mps, Mcs>;
|
|
22
|
+
|
|
23
|
+
type LoggerImpl = <T>(f: StateCreator<T, [], []>, name?: string) => StateCreator<T, [], []>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read ONCE, at module scope, not on every `set`.
|
|
27
|
+
*
|
|
28
|
+
* This used to run inside the wrapped `set`, which means a synchronous
|
|
29
|
+
* `localStorage.getItem` on every state transition of every logged store — in
|
|
30
|
+
* production builds too, because the override has to stay readable there by design.
|
|
31
|
+
* The stores this middleware wraps include ones that transition per pointer event.
|
|
32
|
+
*
|
|
33
|
+
* The override cannot change without someone opening devtools and reloading, so
|
|
34
|
+
* once per session is the same behaviour at a fraction of the cost. {@link setStoreLog}
|
|
35
|
+
* keeps the ability to flip it live from a console, which is the only thing the
|
|
36
|
+
* per-call read was actually buying.
|
|
37
|
+
*/
|
|
38
|
+
let storeLog: boolean = (() => {
|
|
39
|
+
const override = readStored("store_log");
|
|
40
|
+
if (override === "0") return false;
|
|
41
|
+
if (override === "1") return true;
|
|
42
|
+
// NOT under vitest. `DEV` distinguishes a dev build from a production one; a test
|
|
43
|
+
// run is neither, and vitest sets `DEV=true` — so every transition of every logged
|
|
44
|
+
// store printed a prev/next object into the suite output. Measured on the consumer's
|
|
45
|
+
// suite: 822 blocks, several thousand lines, in a log whose whole job is to make one
|
|
46
|
+
// real `console.error` findable. `store_log=1` still forces it on.
|
|
47
|
+
// Optional-chained because `import.meta.env` is a Vite injection: it is
|
|
48
|
+
// undefined under plain Node ESM, and this package is published publicly,
|
|
49
|
+
// so a non-Vite consumer must be able to import it without throwing.
|
|
50
|
+
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
/** Turn store logging on or off for the rest of the session (devtools escape hatch). */
|
|
54
|
+
export function setStoreLog(enabled: boolean): void {
|
|
55
|
+
storeLog = enabled;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
|
|
59
|
+
const loggedSet = ((...args: unknown[]) => {
|
|
60
|
+
const prev = get();
|
|
61
|
+
(set as (...a: unknown[]) => void)(...args);
|
|
62
|
+
if (storeLog) {
|
|
63
|
+
console.debug(
|
|
64
|
+
`%c[store${name ? `:${name}` : ""}]`,
|
|
65
|
+
"color:#0d9488",
|
|
66
|
+
{ prev, next: get() },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}) as typeof set;
|
|
70
|
+
return f(loggedSet, get, store);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const logger = loggerImpl as unknown as Logger;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `localStorage` that cannot take the page down with it.
|
|
3
|
+
*
|
|
4
|
+
* Accessing `window.localStorage` **throws** — rather than returning null — when a
|
|
5
|
+
* browser has site data blocked: Safari private browsing, Chrome with third-party
|
|
6
|
+
* site data disabled, a partitioned webview. Three places in this package already
|
|
7
|
+
* knew that and wrapped their calls in try/catch (`logger.ts`, and the DataTable's
|
|
8
|
+
* `loadPersisted`/`savePersisted`). One did not: `AppShell` read the sidebar's
|
|
9
|
+
* collapse flag straight out of a `useState` initialiser, so the throw happened
|
|
10
|
+
* during render of the application's top-level shell. That is not a lost preference
|
|
11
|
+
* — nothing mounts, in every consumer of this package at once.
|
|
12
|
+
*
|
|
13
|
+
* So the guard lives here, once, and the next storage-backed preference cannot
|
|
14
|
+
* forget it. A read that cannot happen is indistinguishable from a key that was
|
|
15
|
+
* never set, which is the right answer for every caller: the default.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The stored string for `key`, or null when absent, unreadable, or blocked. */
|
|
19
|
+
export function readStored(key: string): string | null {
|
|
20
|
+
if (typeof window === "undefined") return null;
|
|
21
|
+
try {
|
|
22
|
+
return window.localStorage.getItem(key);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Store `value` under `key`, silently doing nothing when storage is unavailable
|
|
29
|
+
* or full. A preference that cannot be persisted is not worth an exception. */
|
|
30
|
+
export function writeStored(key: string, value: string): void {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
try {
|
|
33
|
+
window.localStorage.setItem(key, value);
|
|
34
|
+
} catch {
|
|
35
|
+
// ignore (private mode, quota, partitioned storage)
|
|
36
|
+
}
|
|
37
|
+
}
|