@sixthshift/design-system 0.1.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 (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/dist/styles.css +2 -0
  4. package/dist/theme.generated.css +438 -0
  5. package/package.json +238 -0
  6. package/src/components/Avatar/Avatar.tsx +19 -0
  7. package/src/components/Avatar/index.ts +1 -0
  8. package/src/components/Badge/Badge.tsx +59 -0
  9. package/src/components/Badge/index.ts +1 -0
  10. package/src/components/BarChart/BarChart.tsx +51 -0
  11. package/src/components/BarChart/index.ts +1 -0
  12. package/src/components/Breadcrumb/Breadcrumb.tsx +47 -0
  13. package/src/components/Breadcrumb/index.ts +5 -0
  14. package/src/components/Button/Button.tsx +157 -0
  15. package/src/components/Button/index.ts +1 -0
  16. package/src/components/Calendar/Calendar.tsx +433 -0
  17. package/src/components/Calendar/calendar.hooks.ts +329 -0
  18. package/src/components/Calendar/calendar.types.ts +117 -0
  19. package/src/components/Calendar/index.ts +28 -0
  20. package/src/components/Card/Card.tsx +47 -0
  21. package/src/components/Card/index.ts +1 -0
  22. package/src/components/Checkbox/Checkbox.tsx +144 -0
  23. package/src/components/Checkbox/index.ts +1 -0
  24. package/src/components/CheckboxGroup/CheckboxGroup.tsx +173 -0
  25. package/src/components/CheckboxGroup/index.ts +5 -0
  26. package/src/components/Code/Editor/Editor.tsx +161 -0
  27. package/src/components/Code/Editor/index.ts +2 -0
  28. package/src/components/Code/Editor/semanticTokenProvider.ts +69 -0
  29. package/src/components/Code/Editor/themes.ts +112 -0
  30. package/src/components/Code/ValidationStatus/ValidationStatus.tsx +81 -0
  31. package/src/components/Code/ValidationStatus/index.ts +1 -0
  32. package/src/components/Code/Workspace/StatusBar.tsx +47 -0
  33. package/src/components/Code/Workspace/Toolbar.tsx +47 -0
  34. package/src/components/Code/Workspace/Workspace.tsx +109 -0
  35. package/src/components/Code/Workspace/index.ts +3 -0
  36. package/src/components/ColorDot/ColorDot.tsx +50 -0
  37. package/src/components/ColorDot/index.ts +1 -0
  38. package/src/components/DatePicker/DatePicker.tsx +236 -0
  39. package/src/components/DatePicker/datepicker.hooks.ts +381 -0
  40. package/src/components/DatePicker/datepicker.types.ts +140 -0
  41. package/src/components/DatePicker/index.ts +12 -0
  42. package/src/components/DateRangePicker/DateRangePicker.tsx +127 -0
  43. package/src/components/DateRangePicker/daterangepicker.types.ts +53 -0
  44. package/src/components/DateRangePicker/index.ts +2 -0
  45. package/src/components/DateTimePicker/DateTimePicker.tsx +399 -0
  46. package/src/components/DateTimePicker/datetimepicker.types.ts +48 -0
  47. package/src/components/DateTimePicker/index.ts +2 -0
  48. package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +679 -0
  49. package/src/components/DateTimeRangePicker/datetimerangepicker.types.ts +62 -0
  50. package/src/components/DateTimeRangePicker/index.ts +2 -0
  51. package/src/components/EmptyState/EmptyState.tsx +29 -0
  52. package/src/components/EmptyState/index.ts +1 -0
  53. package/src/components/Field/Field.tsx +60 -0
  54. package/src/components/Field/index.ts +1 -0
  55. package/src/components/FormField/FormField.tsx +55 -0
  56. package/src/components/FormField/index.ts +2 -0
  57. package/src/components/HeatMap/HeatMapCalendar.tsx +127 -0
  58. package/src/components/HeatMap/HeatMapMatrix.tsx +75 -0
  59. package/src/components/HeatMap/heat-map.utils.ts +15 -0
  60. package/src/components/HeatMap/index.ts +3 -0
  61. package/src/components/HoverCard/HoverCard.tsx +83 -0
  62. package/src/components/HoverCard/components/HoverCardContent.tsx +30 -0
  63. package/src/components/HoverCard/components/HoverCardContext.tsx +22 -0
  64. package/src/components/HoverCard/components/HoverCardTrigger.tsx +32 -0
  65. package/src/components/HoverCard/index.ts +3 -0
  66. package/src/components/Input/Input.tsx +48 -0
  67. package/src/components/Input/index.ts +1 -0
  68. package/src/components/LineChart/LineChart.tsx +251 -0
  69. package/src/components/LineChart/index.ts +1 -0
  70. package/src/components/Markdown/Markdown.tsx +40 -0
  71. package/src/components/Markdown/index.ts +1 -0
  72. package/src/components/Message/Message.tsx +59 -0
  73. package/src/components/Message/components/MessageBody.tsx +9 -0
  74. package/src/components/Message/components/MessageDescription.tsx +9 -0
  75. package/src/components/Message/components/MessageIcon.tsx +13 -0
  76. package/src/components/Message/components/MessageTitle.tsx +9 -0
  77. package/src/components/Message/index.ts +10 -0
  78. package/src/components/MetricList/MetricList.tsx +13 -0
  79. package/src/components/MetricList/MetricRow.tsx +30 -0
  80. package/src/components/MetricList/index.ts +2 -0
  81. package/src/components/Modal/Modal.tsx +146 -0
  82. package/src/components/Modal/components/ModalBody.tsx +9 -0
  83. package/src/components/Modal/components/ModalContext.tsx +27 -0
  84. package/src/components/Modal/components/ModalFooter.tsx +9 -0
  85. package/src/components/Modal/components/ModalHeader.tsx +29 -0
  86. package/src/components/Modal/components/index.ts +9 -0
  87. package/src/components/Modal/index.ts +13 -0
  88. package/src/components/NavBottom/NavBottom.tsx +53 -0
  89. package/src/components/NavBottom/index.ts +3 -0
  90. package/src/components/NavSide/NavItem.tsx +39 -0
  91. package/src/components/NavSide/NavSide.tsx +40 -0
  92. package/src/components/NavSide/index.ts +2 -0
  93. package/src/components/NavSide/types.ts +36 -0
  94. package/src/components/Pagination/Pagination.tsx +86 -0
  95. package/src/components/Pagination/index.ts +2 -0
  96. package/src/components/Popover/Popover.tsx +68 -0
  97. package/src/components/Popover/components/PopoverBody.tsx +32 -0
  98. package/src/components/Popover/components/PopoverClose.tsx +26 -0
  99. package/src/components/Popover/components/PopoverContext.tsx +24 -0
  100. package/src/components/Popover/components/PopoverTrigger.tsx +31 -0
  101. package/src/components/Popover/index.ts +10 -0
  102. package/src/components/ProgressBar/ProgressBar.tsx +42 -0
  103. package/src/components/ProgressBar/index.ts +1 -0
  104. package/src/components/RadioButton/RadioButton.tsx +89 -0
  105. package/src/components/RadioButton/index.ts +1 -0
  106. package/src/components/RadioButtonGroup/RadioButtonGroup.tsx +193 -0
  107. package/src/components/RadioButtonGroup/index.ts +5 -0
  108. package/src/components/SearchInput/SearchInput.tsx +45 -0
  109. package/src/components/SearchInput/index.ts +2 -0
  110. package/src/components/Select/Select.tsx +289 -0
  111. package/src/components/Select/SelectDropdown.tsx +90 -0
  112. package/src/components/Select/SelectTrigger.tsx +130 -0
  113. package/src/components/Select/index.ts +3 -0
  114. package/src/components/Select/useSelectKeyboard.ts +111 -0
  115. package/src/components/Separator/Separator.tsx +20 -0
  116. package/src/components/Separator/index.ts +1 -0
  117. package/src/components/Sheet/Sheet.tsx +136 -0
  118. package/src/components/Sheet/components/SheetBody.tsx +9 -0
  119. package/src/components/Sheet/components/SheetContext.tsx +23 -0
  120. package/src/components/Sheet/components/SheetFooter.tsx +9 -0
  121. package/src/components/Sheet/components/SheetHeader.tsx +29 -0
  122. package/src/components/Sheet/components/index.ts +9 -0
  123. package/src/components/Sheet/index.ts +13 -0
  124. package/src/components/Skeleton/Skeleton.tsx +7 -0
  125. package/src/components/Skeleton/index.ts +1 -0
  126. package/src/components/Sparkline/Sparkline.tsx +93 -0
  127. package/src/components/Sparkline/index.ts +1 -0
  128. package/src/components/Spinner/Spinner.tsx +41 -0
  129. package/src/components/Spinner/index.ts +1 -0
  130. package/src/components/StatsCard/StatsCard.tsx +40 -0
  131. package/src/components/StatsCard/index.ts +1 -0
  132. package/src/components/Switch/Switch.tsx +128 -0
  133. package/src/components/Switch/index.ts +1 -0
  134. package/src/components/Tabs/Tabs.tsx +49 -0
  135. package/src/components/Tabs/components/TabsContext.tsx +35 -0
  136. package/src/components/Tabs/components/TabsList.tsx +98 -0
  137. package/src/components/Tabs/components/TabsPanels.tsx +26 -0
  138. package/src/components/Tabs/components/TabsTrigger.tsx +69 -0
  139. package/src/components/Tabs/index.ts +3 -0
  140. package/src/components/TagChip/TagChip.tsx +46 -0
  141. package/src/components/TagChip/index.ts +1 -0
  142. package/src/components/TagInput/TagInput.tsx +60 -0
  143. package/src/components/TagInput/index.ts +1 -0
  144. package/src/components/Text/Text.tsx +35 -0
  145. package/src/components/Text/index.ts +1 -0
  146. package/src/components/TextInline/TextInline.tsx +35 -0
  147. package/src/components/TextInline/index.ts +1 -0
  148. package/src/components/Textarea/Textarea.tsx +20 -0
  149. package/src/components/Textarea/index.ts +1 -0
  150. package/src/components/TimePicker/PeriodSelector.tsx +28 -0
  151. package/src/components/TimePicker/TimeColumn.tsx +62 -0
  152. package/src/components/TimePicker/TimePicker.tsx +323 -0
  153. package/src/components/TimePicker/index.ts +7 -0
  154. package/src/components/TimePicker/timepicker.hooks.ts +173 -0
  155. package/src/components/TimePicker/timepicker.types.ts +101 -0
  156. package/src/components/Toast/Toast.tsx +111 -0
  157. package/src/components/Toast/index.ts +1 -0
  158. package/src/components/Toggle/Toggle.tsx +72 -0
  159. package/src/components/Toggle/index.ts +1 -0
  160. package/src/components/ToggleGroup/ToggleGroup.tsx +14 -0
  161. package/src/components/ToggleGroup/ToggleGroupContainer.tsx +19 -0
  162. package/src/components/ToggleGroup/ToggleGroupItem.tsx +51 -0
  163. package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +61 -0
  164. package/src/components/ToggleGroup/ToggleGroupSingle.tsx +105 -0
  165. package/src/components/ToggleGroup/index.ts +2 -0
  166. package/src/components/ToggleGroup/toggleGroup.types.ts +55 -0
  167. package/src/components/Tooltip/Tooltip.tsx +66 -0
  168. package/src/components/Tooltip/components/TooltipBody.tsx +30 -0
  169. package/src/components/Tooltip/components/TooltipContext.tsx +22 -0
  170. package/src/components/Tooltip/components/TooltipTrigger.tsx +27 -0
  171. package/src/components/Tooltip/index.ts +9 -0
  172. package/src/date-time/arithmetic.ts +101 -0
  173. package/src/date-time/boundaries.ts +91 -0
  174. package/src/date-time/compare.ts +85 -0
  175. package/src/date-time/duration.ts +63 -0
  176. package/src/date-time/elapsed.ts +18 -0
  177. package/src/date-time/format.ts +722 -0
  178. package/src/date-time/index.ts +279 -0
  179. package/src/date-time/now.ts +70 -0
  180. package/src/date-time/parse.ts +170 -0
  181. package/src/date-time/serialize.ts +90 -0
  182. package/src/date-time/timezone.ts +47 -0
  183. package/src/date-time/validate.ts +278 -0
  184. package/src/hooks/index.ts +10 -0
  185. package/src/hooks/useCollapsible.ts +16 -0
  186. package/src/hooks/useControllableState.ts +54 -0
  187. package/src/hooks/useDebouncedCallback.ts +21 -0
  188. package/src/hooks/useDebouncedState.ts +20 -0
  189. package/src/hooks/useDeviceFormFactor.ts +49 -0
  190. package/src/hooks/useLocalStorage.ts +67 -0
  191. package/src/hooks/useMergedFloatingRef.ts +29 -0
  192. package/src/hooks/usePresence.ts +127 -0
  193. package/src/hooks/useStack.ts +99 -0
  194. package/src/hooks/useTheme.ts +105 -0
  195. package/src/internal/Slot.tsx +79 -0
  196. package/src/internal/index.ts +2 -0
  197. package/src/internal/rovingFocus.ts +39 -0
  198. package/src/internal/types.ts +10 -0
  199. package/src/lib/EmptyBoundary.tsx +12 -0
  200. package/src/lib/ErrorBoundary.tsx +48 -0
  201. package/src/lib/contexts/ComponentsContext/ComponentsContext.tsx +34 -0
  202. package/src/lib/contexts/ComponentsContext/index.ts +1 -0
  203. package/src/lib/contexts/OverlayContext/OverlayContext.tsx +93 -0
  204. package/src/lib/contexts/OverlayContext/hooks/useModal.tsx +125 -0
  205. package/src/lib/contexts/OverlayContext/hooks/useToast.ts +80 -0
  206. package/src/lib/contexts/OverlayContext/index.ts +6 -0
  207. package/src/lib/format.ts +51 -0
  208. package/src/lib/utils.ts +10 -0
  209. package/src/lib/withEmpty.tsx +14 -0
  210. package/src/lib/withErrorBoundary.tsx +16 -0
  211. package/src/lib/withSuspense.tsx +16 -0
  212. package/src/lib/withSuspenseAndErrorBoundary.tsx +26 -0
  213. package/src/styles/base.css +41 -0
  214. package/src/theme/index.ts +37 -0
  215. package/src/theme/palette.json +100 -0
  216. package/src/theme/palette.schema.json +40 -0
  217. package/src/theme/schema.ts +122 -0
  218. package/src/theme/theme.json +349 -0
  219. package/src/theme/theme.schema.json +876 -0
  220. package/src/theme/typography.json +7 -0
  221. package/src/theme/typography.schema.json +36 -0
  222. package/src/typography/Body.tsx +8 -0
  223. package/src/typography/Caption.tsx +8 -0
  224. package/src/typography/Code.tsx +8 -0
  225. package/src/typography/Display.tsx +8 -0
  226. package/src/typography/Emphasis.tsx +8 -0
  227. package/src/typography/Heading.tsx +23 -0
  228. package/src/typography/Label.tsx +10 -0
  229. package/src/typography/Lead.tsx +8 -0
  230. package/src/typography/Mono.tsx +8 -0
  231. package/src/typography/Muted.tsx +8 -0
  232. package/src/typography/SectionTitle.tsx +8 -0
  233. package/src/typography/Subtitle.tsx +8 -0
  234. package/src/typography/Timestamp.tsx +8 -0
  235. package/src/typography/index.ts +10 -0
  236. package/tailwind.config.ts +261 -0
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Formatting utilities: Human-readable display strings
3
+ *
4
+ * For ISO 8601 serialization (APIs/storage), use serialize() from serialize.ts
5
+ * This module provides only human-readable formatting for UI display.
6
+ *
7
+ * API Overview (31 functions):
8
+ *
9
+ * Date Formats (PlainDate input):
10
+ * formatDateShort "Jan 15"
11
+ * formatDateShortYear "Jan 15, 2025"
12
+ * formatDateMedium "January 15"
13
+ * formatDateMediumYear "January 15, 2025"
14
+ * formatDateShortWeekday "Wed, Jan 15"
15
+ * formatDateShortWeekdayYear "Wed, Jan 15, 2025"
16
+ * formatDateLong "Wednesday, January 15"
17
+ * formatDateFull "Wednesday, January 15, 2025"
18
+ * formatDateNumeric "1/15"
19
+ * formatDateNumericYear "1/15/2025"
20
+ * formatMonthYear "January 2025"
21
+ * formatMonthYearShort "Jan 2025"
22
+ *
23
+ * Time Formats (PlainTime input):
24
+ * formatTime "2:30 PM"
25
+ * formatTimePadded "02:30 PM"
26
+ * formatTimeCompact "2 PM" / "2:30 PM"
27
+ * formatTimeHour "2pm"
28
+ * formatTime24 "14:30:45"
29
+ *
30
+ * Composite Type Formats (DateTime/Zoned/Instant input):
31
+ * formatDateTimeTime PlainDateTime → "2:30 PM"
32
+ * formatZonedTime ZonedDateTime → "2:30 PM"
33
+ * formatInstantTime Instant → "2:30 PM"
34
+ * formatInstantDateTime Instant → "Jan 24, 2026, 3:30 PM"
35
+ * formatInstantShortYear Instant → "Jan 24, 2026"
36
+ *
37
+ * Pattern-Based Formatting:
38
+ * formatDate PlainDate + pattern → custom string
39
+ *
40
+ * Date Range Formats:
41
+ * formatDateRange "Jan 24 - 25, 2026"
42
+ * formatWeekRange "Jan 12 - 18, 2025"
43
+ *
44
+ * Relative Formats:
45
+ * formatRelative "today" / "tomorrow" / "3 days ago"
46
+ * formatRelativeInstant "just now" / "5m ago" / "2h ago"
47
+ * formatRelativeToNow "3 hours ago" / "in 2 days"
48
+ *
49
+ * Duration Formats:
50
+ * formatMilliseconds "1.5s" / "5m 30s"
51
+ * formatDurationShort "5min" / "1hr 30min"
52
+ */
53
+
54
+ import { Temporal } from "@js-temporal/polyfill";
55
+ import { diffInDays } from "./arithmetic";
56
+ import { toHours, toMinutes } from "./duration";
57
+ import { now, today } from "./now";
58
+
59
+ // =============================================================================
60
+ // 1. Date Formats (PlainDate → string)
61
+ // =============================================================================
62
+
63
+ /**
64
+ * Format a date as "Jan 15" (short month, day)
65
+ *
66
+ * @example
67
+ * formatDateShort(parseDate('2025-01-15'))
68
+ * // → 'Jan 15'
69
+ */
70
+ export function formatDateShort(date: Temporal.PlainDate): string {
71
+ return date.toLocaleString("en-US", {
72
+ month: "short",
73
+ day: "numeric",
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Format a date as "Jan 15, 2025" (short month, day, year)
79
+ *
80
+ * @example
81
+ * formatDateShortYear(parseDate('2025-01-15'))
82
+ * // → 'Jan 15, 2025'
83
+ */
84
+ export function formatDateShortYear(date: Temporal.PlainDate): string {
85
+ return date.toLocaleString("en-US", {
86
+ month: "short",
87
+ day: "numeric",
88
+ year: "numeric",
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Format a date as "January 15" (long month, day)
94
+ *
95
+ * @example
96
+ * formatDateMedium(parseDate('2025-01-15'))
97
+ * // → 'January 15'
98
+ */
99
+ export function formatDateMedium(date: Temporal.PlainDate): string {
100
+ return date.toLocaleString("en-US", {
101
+ month: "long",
102
+ day: "numeric",
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Format a date as "January 15, 2025" (long month, day, year)
108
+ *
109
+ * @example
110
+ * formatDateMediumYear(parseDate('2025-01-15'))
111
+ * // → 'January 15, 2025'
112
+ */
113
+ export function formatDateMediumYear(date: Temporal.PlainDate): string {
114
+ return date.toLocaleString("en-US", {
115
+ month: "long",
116
+ day: "numeric",
117
+ year: "numeric",
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Format a date as "Wed, Jan 15" (short weekday, short month, day)
123
+ *
124
+ * @example
125
+ * formatDateShortWeekday(parseDate('2025-01-15'))
126
+ * // → 'Wed, Jan 15'
127
+ */
128
+ export function formatDateShortWeekday(date: Temporal.PlainDate): string {
129
+ return date.toLocaleString("en-US", {
130
+ weekday: "short",
131
+ month: "short",
132
+ day: "numeric",
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Format a date as "Wed, Jan 15, 2025" (short weekday, short month, day, year)
138
+ *
139
+ * @example
140
+ * formatDateShortWeekdayYear(parseDate('2025-01-15'))
141
+ * // → 'Wed, Jan 15, 2025'
142
+ */
143
+ export function formatDateShortWeekdayYear(date: Temporal.PlainDate): string {
144
+ return date.toLocaleString("en-US", {
145
+ weekday: "short",
146
+ month: "short",
147
+ day: "numeric",
148
+ year: "numeric",
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Format a date as "Wednesday, January 15" (long weekday, long month, day)
154
+ *
155
+ * @example
156
+ * formatDateLong(parseDate('2025-01-15'))
157
+ * // → 'Wednesday, January 15'
158
+ */
159
+ export function formatDateLong(date: Temporal.PlainDate): string {
160
+ return date.toLocaleString("en-US", {
161
+ weekday: "long",
162
+ month: "long",
163
+ day: "numeric",
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Format a date as "Wednesday, January 15, 2025" (long weekday, long month, day, year)
169
+ *
170
+ * @example
171
+ * formatDateFull(parseDate('2025-01-15'))
172
+ * // → 'Wednesday, January 15, 2025'
173
+ */
174
+ export function formatDateFull(date: Temporal.PlainDate): string {
175
+ return date.toLocaleString("en-US", {
176
+ weekday: "long",
177
+ month: "long",
178
+ day: "numeric",
179
+ year: "numeric",
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Format a date as "1/15" (numeric month/day)
185
+ *
186
+ * @example
187
+ * formatDateNumeric(parseDate('2025-01-15'))
188
+ * // → '1/15'
189
+ */
190
+ export function formatDateNumeric(date: Temporal.PlainDate): string {
191
+ return `${date.month}/${date.day}`;
192
+ }
193
+
194
+ /**
195
+ * Format a date as "1/15/2025" (numeric month/day/year)
196
+ *
197
+ * @example
198
+ * formatDateNumericYear(parseDate('2025-01-15'))
199
+ * // → '1/15/2025'
200
+ */
201
+ export function formatDateNumericYear(date: Temporal.PlainDate): string {
202
+ return `${date.month}/${date.day}/${date.year}`;
203
+ }
204
+
205
+ /**
206
+ * Format a date as "January 2025" (long month, year)
207
+ *
208
+ * @example
209
+ * formatMonthYear(parseDate('2025-01-15'))
210
+ * // → 'January 2025'
211
+ */
212
+ export function formatMonthYear(date: Temporal.PlainDate): string {
213
+ return date.toLocaleString("en-US", {
214
+ month: "long",
215
+ year: "numeric",
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Format a date as "Jan 2025" (short month, year)
221
+ *
222
+ * @example
223
+ * formatMonthYearShort(parseDate('2025-01-15'))
224
+ * // → 'Jan 2025'
225
+ */
226
+ export function formatMonthYearShort(date: Temporal.PlainDate): string {
227
+ return date.toLocaleString("en-US", {
228
+ month: "short",
229
+ year: "numeric",
230
+ });
231
+ }
232
+
233
+ // =============================================================================
234
+ // 2. Time Formats (PlainTime → string)
235
+ // =============================================================================
236
+
237
+ /**
238
+ * Format a time as "2:30 PM" (unpadded hour, standard UI format)
239
+ *
240
+ * @example
241
+ * formatTime(parseTime('14:30:00'))
242
+ * // → '2:30 PM'
243
+ */
244
+ export function formatTime(time: Temporal.PlainTime): string {
245
+ return time.toLocaleString("en-US", {
246
+ hour: "numeric",
247
+ minute: "2-digit",
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Format a time as "02:30 PM" (padded hour)
253
+ *
254
+ * @example
255
+ * formatTimePadded(parseTime('14:30:00'))
256
+ * // → '02:30 PM'
257
+ */
258
+ export function formatTimePadded(time: Temporal.PlainTime): string {
259
+ return time.toLocaleString("en-US", {
260
+ hour: "2-digit",
261
+ minute: "2-digit",
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Format a time compactly: omits minutes when :00 (e.g., "2 PM", "2:30 PM")
267
+ * Ideal for calendar entries and space-constrained time displays.
268
+ *
269
+ * @example
270
+ * formatTimeCompact(parseTime('14:00:00'))
271
+ * // → '2 PM'
272
+ *
273
+ * formatTimeCompact(parseTime('14:30:00'))
274
+ * // → '2:30 PM'
275
+ *
276
+ * formatTimeCompact(parseTime('00:00:00'))
277
+ * // → '12 AM'
278
+ */
279
+ export function formatTimeCompact(time: Temporal.PlainTime): string {
280
+ const hour12 = time.hour === 0 ? 12 : time.hour > 12 ? time.hour - 12 : time.hour;
281
+ const period = time.hour >= 12 ? "PM" : "AM";
282
+ if (time.minute === 0) return `${hour12} ${period}`;
283
+ const m = String(time.minute).padStart(2, "0");
284
+ return `${hour12}:${m} ${period}`;
285
+ }
286
+
287
+ /**
288
+ * Format an hour as compact string (e.g., "2pm", "11am")
289
+ * No space, lowercase - ideal for timeline x-axis labels
290
+ *
291
+ * @example
292
+ * formatTimeHour(parseTime('14:00:00'))
293
+ * // → '2pm'
294
+ *
295
+ * formatTimeHour(parseTime('09:00:00'))
296
+ * // → '9am'
297
+ */
298
+ export function formatTimeHour(time: Temporal.PlainTime): string {
299
+ const hour12 = time.hour === 0 ? 12 : time.hour > 12 ? time.hour - 12 : time.hour;
300
+ const period = time.hour >= 12 ? "pm" : "am";
301
+ return `${hour12}${period}`;
302
+ }
303
+
304
+ /**
305
+ * Format a time as 24-hour HH:mm:ss (e.g., "14:30:45")
306
+ * Useful for logs and technical displays
307
+ *
308
+ * @example
309
+ * formatTime24(parseTime('14:30:45'))
310
+ * // → '14:30:45'
311
+ *
312
+ * formatTime24(parseTime('09:05:03'))
313
+ * // → '09:05:03'
314
+ */
315
+ export function formatTime24(time: Temporal.PlainTime): string {
316
+ const h = String(time.hour).padStart(2, "0");
317
+ const m = String(time.minute).padStart(2, "0");
318
+ const s = String(time.second).padStart(2, "0");
319
+ return `${h}:${m}:${s}`;
320
+ }
321
+
322
+ // =============================================================================
323
+ // 3. Composite Type Formats (DateTime/Zoned/Instant → string)
324
+ // =============================================================================
325
+
326
+ /**
327
+ * Format a datetime's time portion for display
328
+ *
329
+ * @example
330
+ * formatDateTimeTime(parseDateTime('2025-01-15T14:30:00'))
331
+ * // → '2:30 PM'
332
+ */
333
+ export function formatDateTimeTime(dateTime: Temporal.PlainDateTime): string {
334
+ return formatTime(dateTime.toPlainTime());
335
+ }
336
+
337
+ /**
338
+ * Format a zoned datetime's time portion for display
339
+ *
340
+ * @example
341
+ * formatZonedTime(parseZoned('2025-01-15T14:30:00-05:00[America/New_York]'))
342
+ * // → '2:30 PM'
343
+ */
344
+ export function formatZonedTime(zoned: Temporal.ZonedDateTime): string {
345
+ return formatTime(zoned.toPlainTime());
346
+ }
347
+
348
+ /**
349
+ * Format an instant's time portion for display in the local timezone
350
+ *
351
+ * @example
352
+ * formatInstantTime(parseInstant('2025-01-15T19:30:00Z'))
353
+ * // → '2:30 PM' (in America/New_York)
354
+ */
355
+ export function formatInstantTime(instant: Temporal.Instant): string {
356
+ const zoned = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId());
357
+ return formatTime(zoned.toPlainTime());
358
+ }
359
+
360
+ /**
361
+ * Format an instant as date and time with year (e.g., "Jan 24, 2026, 3:30 PM")
362
+ *
363
+ * @example
364
+ * formatInstantDateTime(parseInstant('2026-01-24T23:30:00Z'), 'America/Los_Angeles')
365
+ * // → 'Jan 24, 2026, 3:30 PM'
366
+ */
367
+ export function formatInstantDateTime(instant: Temporal.Instant, timeZone?: string): string {
368
+ const tz = timeZone ?? Temporal.Now.timeZoneId();
369
+ const zoned = instant.toZonedDateTimeISO(tz);
370
+ const datePart = formatDateShortYear(zoned.toPlainDate());
371
+
372
+ const options: Intl.DateTimeFormatOptions = {
373
+ hour: "numeric",
374
+ minute: "2-digit",
375
+ hour12: true,
376
+ timeZone: tz,
377
+ };
378
+ const timePart = instant.toLocaleString("en-US", options);
379
+
380
+ return `${datePart}, ${timePart}`;
381
+ }
382
+
383
+ /**
384
+ * Format Instant as short date with year in user's timezone
385
+ *
386
+ * @example
387
+ * formatInstantShortYear(Temporal.Instant.from("2026-01-24T08:00:00Z"), "America/New_York")
388
+ * // → "Jan 24, 2026"
389
+ */
390
+ export function formatInstantShortYear(instant: Temporal.Instant, timeZone?: string): string {
391
+ const tz = timeZone ?? Temporal.Now.timeZoneId();
392
+ const zoned = instant.toZonedDateTimeISO(tz);
393
+ return formatDateShortYear(zoned.toPlainDate());
394
+ }
395
+
396
+ // =============================================================================
397
+ // 4. Pattern-Based Formatting
398
+ // =============================================================================
399
+
400
+ /**
401
+ * Format a Temporal.PlainDate with a pattern string
402
+ *
403
+ * Supported patterns:
404
+ * - yyyy: 4-digit year (e.g., 2025)
405
+ * - MMMM: Full month name (e.g., January)
406
+ * - MMM: Short month name (e.g., Jan)
407
+ * - MM: 2-digit month (e.g., 01)
408
+ * - dd: 2-digit day (e.g., 05)
409
+ * - d: Day without leading zero (e.g., 5)
410
+ * - EEEE: Full weekday name (e.g., Monday)
411
+ * - EEE: Short weekday name (e.g., Mon)
412
+ *
413
+ * @example
414
+ * formatDate(parseDate('2025-01-15'), 'MMMM yyyy')
415
+ * // → 'January 2025'
416
+ *
417
+ * formatDate(parseDate('2025-01-15'), 'MMM d')
418
+ * // → 'Jan 15'
419
+ *
420
+ * formatDate(parseDate('2025-01-15'), 'EEEE, MMMM d, yyyy')
421
+ * // → 'Wednesday, January 15, 2025'
422
+ */
423
+ // Lookup tables for pattern-based formatting (as const for tuple types)
424
+ const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] as const;
425
+
426
+ const MONTH_NAMES_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
427
+
428
+ const WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
429
+
430
+ const WEEKDAY_NAMES_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
431
+
432
+ // Type-safe indices (Temporal guarantees month 1-12, dayOfWeek 1-7)
433
+ type MonthIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
434
+ type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
435
+
436
+ export function formatDate(date: Temporal.PlainDate, pattern: string): string {
437
+ // Temporal guarantees these indices are always valid
438
+ const monthIndex = (date.month - 1) as MonthIndex;
439
+ const weekdayIndex = (date.dayOfWeek - 1) as WeekdayIndex;
440
+
441
+ let result = pattern;
442
+
443
+ // Replace patterns (order matters - longer patterns first)
444
+ result = result.replace(/MMMM/g, MONTH_NAMES[monthIndex]);
445
+ result = result.replace(/MMM/g, MONTH_NAMES_SHORT[monthIndex]);
446
+ result = result.replace(/MM/g, String(date.month).padStart(2, "0"));
447
+ result = result.replace(/yyyy/g, String(date.year));
448
+ result = result.replace(/dd/g, String(date.day).padStart(2, "0"));
449
+ result = result.replace(/d/g, String(date.day));
450
+ result = result.replace(/EEEE/g, WEEKDAY_NAMES[weekdayIndex]);
451
+ result = result.replace(/EEE/g, WEEKDAY_NAMES_SHORT[weekdayIndex]);
452
+
453
+ return result;
454
+ }
455
+
456
+ // =============================================================================
457
+ // 5. Date Range Formats
458
+ // =============================================================================
459
+
460
+ /**
461
+ * Format a week date range (e.g., "Jan 12 - 18, 2025")
462
+ *
463
+ * @example
464
+ * formatWeekRange(parseDate('2025-01-12'), parseDate('2025-01-18'))
465
+ * // → 'Jan 12 - 18, 2025' (same month)
466
+ *
467
+ * formatWeekRange(parseDate('2025-01-26'), parseDate('2025-02-01'))
468
+ * // → 'Jan 26 - Feb 1, 2025' (different months, same year)
469
+ *
470
+ * formatWeekRange(parseDate('2024-12-29'), parseDate('2025-01-04'))
471
+ * // → 'Dec 29, 2024 - Jan 4, 2025' (different years)
472
+ */
473
+ export function formatWeekRange(startDate: Temporal.PlainDate, endDate: Temporal.PlainDate): string {
474
+ const sameMonth = startDate.month === endDate.month && startDate.year === endDate.year;
475
+ const sameYear = startDate.year === endDate.year;
476
+
477
+ if (sameMonth) {
478
+ // "Jan 12 - 18, 2025"
479
+ return `${formatDate(startDate, "MMM")} ${startDate.day} - ${endDate.day}, ${startDate.year}`;
480
+ }
481
+
482
+ if (sameYear) {
483
+ // "Jan 26 - Feb 1, 2025"
484
+ return `${formatDate(startDate, "MMM")} ${startDate.day} - ${formatDate(endDate, "MMM")} ${endDate.day}, ${startDate.year}`;
485
+ }
486
+
487
+ // "Dec 29, 2024 - Jan 4, 2025"
488
+ return `${formatDateShortYear(startDate)} - ${formatDateShortYear(endDate)}`;
489
+ }
490
+
491
+ /**
492
+ * Format a date range intelligently
493
+ * - Same day: "Jan 24, 2026"
494
+ * - Same month: "Jan 24 - 25, 2026"
495
+ * - Same year: "Jan 24 - Feb 2, 2026"
496
+ * - Different years: "Dec 31, 2025 - Jan 1, 2026"
497
+ *
498
+ * @example
499
+ * formatDateRange(parseDate('2026-01-24'), parseDate('2026-01-24'))
500
+ * // → 'Jan 24, 2026'
501
+ *
502
+ * formatDateRange(parseDate('2026-01-24'), parseDate('2026-01-25'))
503
+ * // → 'Jan 24 - 25, 2026'
504
+ *
505
+ * formatDateRange(parseDate('2026-01-24'), parseDate('2026-02-02'))
506
+ * // → 'Jan 24 - Feb 2, 2026'
507
+ */
508
+ export function formatDateRange(startDate: Temporal.PlainDate, endDate: Temporal.PlainDate): string {
509
+ const sameDay = Temporal.PlainDate.compare(startDate, endDate) === 0;
510
+ const sameMonth = startDate.month === endDate.month && startDate.year === endDate.year;
511
+ const sameYear = startDate.year === endDate.year;
512
+
513
+ if (sameDay) {
514
+ return formatDateShortYear(startDate);
515
+ }
516
+
517
+ if (sameMonth) {
518
+ // "Jan 24 - 25, 2026"
519
+ return `${formatDate(startDate, "MMM")} ${startDate.day} - ${endDate.day}, ${startDate.year}`;
520
+ }
521
+
522
+ if (sameYear) {
523
+ // "Jan 24 - Feb 2, 2026"
524
+ return `${formatDate(startDate, "MMM")} ${startDate.day} - ${formatDate(endDate, "MMM")} ${endDate.day}, ${startDate.year}`;
525
+ }
526
+
527
+ // "Dec 31, 2025 - Jan 1, 2026"
528
+ return `${formatDateShortYear(startDate)} - ${formatDateShortYear(endDate)}`;
529
+ }
530
+
531
+ // =============================================================================
532
+ // 6. Relative Formats
533
+ // =============================================================================
534
+
535
+ /**
536
+ * Format a date as a relative string (today, tomorrow, 3 days ago, etc.)
537
+ *
538
+ * @example
539
+ * formatRelative(parseDate('2025-01-15'))
540
+ * // → 'today' | 'tomorrow' | 'yesterday' | 'in 3 days' | '3 days ago' | 'Jan 15, 2025'
541
+ */
542
+ export function formatRelative(date: Temporal.PlainDate): string {
543
+ const diff = diffInDays(today(), date);
544
+
545
+ if (diff === 0) return "today";
546
+ if (diff === 1) return "tomorrow";
547
+ if (diff === -1) return "yesterday";
548
+ if (diff < -1) return `${Math.abs(diff)} days ago`;
549
+ if (diff < 7) return `in ${diff} days`;
550
+
551
+ return date.toLocaleString("en-US", {
552
+ month: "short",
553
+ day: "numeric",
554
+ year: "numeric",
555
+ });
556
+ }
557
+
558
+ /**
559
+ * Format an instant as a relative string (just now, 5m ago, 2h ago, etc.)
560
+ *
561
+ * @example
562
+ * formatRelativeInstant(parseInstant('2025-01-15T14:30:00Z'))
563
+ * // → 'just now' | '5m ago' | '2h ago' | '3d ago' | 'Jan 15, 2025'
564
+ */
565
+ export function formatRelativeInstant(instant: Temporal.Instant): string {
566
+ const now = Temporal.Now.instant();
567
+ const diffNs = now.since(instant).total("nanoseconds");
568
+ const diffMs = Math.floor(diffNs / 1_000_000);
569
+ const diffMins = Math.floor(diffMs / 60_000);
570
+ const diffHours = Math.floor(diffMs / 3_600_000);
571
+ const diffDays = Math.floor(diffMs / 86_400_000);
572
+
573
+ if (diffMins < 1) return "just now";
574
+ if (diffMins < 60) return `${diffMins}m ago`;
575
+ if (diffHours < 24) return `${diffHours}h ago`;
576
+ if (diffDays < 7) return `${diffDays}d ago`;
577
+
578
+ // Fall back to absolute date for older timestamps
579
+ const zoned = instant.toZonedDateTimeISO("UTC");
580
+ return zoned.toPlainDate().toLocaleString("en-US", {
581
+ month: "short",
582
+ day: "numeric",
583
+ year: "numeric",
584
+ });
585
+ }
586
+
587
+ /**
588
+ * Format a Temporal.Instant relative to now ("3 hours ago", "in 2 days")
589
+ *
590
+ * @example
591
+ * formatRelativeToNow(parseInstant('2025-01-15T14:30:00Z'))
592
+ * // → '3 hours ago' | 'in 2 days' | 'just now'
593
+ */
594
+ export function formatRelativeToNow(instant: Temporal.Instant): string {
595
+ const currentInstant = now();
596
+ const diff = currentInstant.since(instant);
597
+ const totalSeconds = Math.abs(diff.total("seconds"));
598
+ const isPast = diff.total("seconds") > 0;
599
+
600
+ // Less than a minute
601
+ if (totalSeconds < 60) {
602
+ return "just now";
603
+ }
604
+
605
+ // Minutes
606
+ if (totalSeconds < 3600) {
607
+ const mins = Math.floor(totalSeconds / 60);
608
+ return isPast ? `${mins} minute${mins === 1 ? "" : "s"} ago` : `in ${mins} minute${mins === 1 ? "" : "s"}`;
609
+ }
610
+
611
+ // Hours
612
+ if (totalSeconds < 86400) {
613
+ const hrs = Math.floor(totalSeconds / 3600);
614
+ return isPast ? `${hrs} hour${hrs === 1 ? "" : "s"} ago` : `in ${hrs} hour${hrs === 1 ? "" : "s"}`;
615
+ }
616
+
617
+ // Days
618
+ if (totalSeconds < 604800) {
619
+ const days = Math.floor(totalSeconds / 86400);
620
+ return isPast ? `${days} day${days === 1 ? "" : "s"} ago` : `in ${days} day${days === 1 ? "" : "s"}`;
621
+ }
622
+
623
+ // Weeks
624
+ if (totalSeconds < 2592000) {
625
+ const weeks = Math.floor(totalSeconds / 604800);
626
+ return isPast ? `${weeks} week${weeks === 1 ? "" : "s"} ago` : `in ${weeks} week${weeks === 1 ? "" : "s"}`;
627
+ }
628
+
629
+ // Fall back to absolute date for longer periods
630
+ const zoned = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId());
631
+ return zoned.toPlainDate().toLocaleString("en-US", {
632
+ month: "short",
633
+ day: "numeric",
634
+ year: "numeric",
635
+ });
636
+ }
637
+
638
+ /**
639
+ * Compact recency label. Used in lists and rows where space is tight and
640
+ * relative time scans as a single rhythm across pages.
641
+ *
642
+ * today → "today"
643
+ * 1–60 days → "1d", "2d", … "60d"
644
+ * 61–364 days → "2mo", "3mo", … "11mo"
645
+ * 365+ days → "1y", "2y", …
646
+ *
647
+ * Negative ranges (future instants) are not handled — this is for past-only
648
+ * recency on entity rows.
649
+ *
650
+ * @example
651
+ * formatRecency(Temporal.Now.instant()) // → 'today'
652
+ * formatRecency(twoDaysAgo) // → '2d'
653
+ * formatRecency(threeMonthsAgo) // → '3mo'
654
+ */
655
+ export function formatRecency(instant: Temporal.Instant): string {
656
+ const date = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()).toPlainDate();
657
+ const todayDate = Temporal.Now.plainDateISO();
658
+ const days = Math.floor(todayDate.since(date).total({ unit: "day", relativeTo: date }));
659
+ if (days <= 0) return "today";
660
+ if (days <= 60) return `${days}d`;
661
+ if (days < 365) return `${Math.round(days / 30)}mo`;
662
+ return `${Math.floor(days / 365)}y`;
663
+ }
664
+
665
+ // =============================================================================
666
+ // 7. Duration Formats
667
+ // =============================================================================
668
+
669
+ /**
670
+ * Format milliseconds duration as human-readable string (e.g., "1.2s", "5m 30s")
671
+ *
672
+ * @example
673
+ * formatMilliseconds(1500) // → '1.5s'
674
+ * formatMilliseconds(65000) // → '1m 5s'
675
+ * formatMilliseconds(500) // → '500ms'
676
+ */
677
+ export function formatMilliseconds(ms: number): string {
678
+ if (ms < 1000) return `${ms}ms`;
679
+ const seconds = Math.floor(ms / 1000);
680
+ if (seconds < 60) return `${seconds}s`;
681
+ const minutes = Math.floor(seconds / 60);
682
+ const remainingSeconds = seconds % 60;
683
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
684
+ }
685
+
686
+ /**
687
+ * Format a Temporal.Duration as a compact human-readable string
688
+ *
689
+ * @example
690
+ * formatDurationShort(minutes(5)) // → '5min'
691
+ * formatDurationShort(hours(1)) // → '1hr'
692
+ * formatDurationShort(hours(6)) // → '6hr'
693
+ * formatDurationShort(days(1)) // → '1 day'
694
+ * formatDurationShort(days(7)) // → '7 days'
695
+ * formatDurationShort(Temporal.Duration.from({ hours: 1, minutes: 30 })) // → '1hr 30min'
696
+ */
697
+ export function formatDurationShort(duration: Temporal.Duration): string {
698
+ const totalMinutes = toMinutes(duration);
699
+ const totalHours = toHours(duration);
700
+
701
+ // Pure days (24h+, no remainder)
702
+ if (totalMinutes >= 1440 && totalMinutes % 1440 === 0) {
703
+ const d = totalMinutes / 1440;
704
+ return d === 1 ? "1 day" : `${d} days`;
705
+ }
706
+
707
+ // Pure hours (no remaining minutes)
708
+ if (totalMinutes >= 60 && totalMinutes % 60 === 0) {
709
+ const h = totalHours;
710
+ return `${h}hr`;
711
+ }
712
+
713
+ // Mixed hours + minutes
714
+ if (totalMinutes >= 60) {
715
+ const h = Math.floor(totalHours);
716
+ const m = totalMinutes - h * 60;
717
+ return `${h}hr ${m}min`;
718
+ }
719
+
720
+ // Pure minutes
721
+ return `${totalMinutes}min`;
722
+ }