@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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/styles.css +2 -0
- package/dist/theme.generated.css +438 -0
- package/package.json +238 -0
- package/src/components/Avatar/Avatar.tsx +19 -0
- package/src/components/Avatar/index.ts +1 -0
- package/src/components/Badge/Badge.tsx +59 -0
- package/src/components/Badge/index.ts +1 -0
- package/src/components/BarChart/BarChart.tsx +51 -0
- package/src/components/BarChart/index.ts +1 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +47 -0
- package/src/components/Breadcrumb/index.ts +5 -0
- package/src/components/Button/Button.tsx +157 -0
- package/src/components/Button/index.ts +1 -0
- package/src/components/Calendar/Calendar.tsx +433 -0
- package/src/components/Calendar/calendar.hooks.ts +329 -0
- package/src/components/Calendar/calendar.types.ts +117 -0
- package/src/components/Calendar/index.ts +28 -0
- package/src/components/Card/Card.tsx +47 -0
- package/src/components/Card/index.ts +1 -0
- package/src/components/Checkbox/Checkbox.tsx +144 -0
- package/src/components/Checkbox/index.ts +1 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +173 -0
- package/src/components/CheckboxGroup/index.ts +5 -0
- package/src/components/Code/Editor/Editor.tsx +161 -0
- package/src/components/Code/Editor/index.ts +2 -0
- package/src/components/Code/Editor/semanticTokenProvider.ts +69 -0
- package/src/components/Code/Editor/themes.ts +112 -0
- package/src/components/Code/ValidationStatus/ValidationStatus.tsx +81 -0
- package/src/components/Code/ValidationStatus/index.ts +1 -0
- package/src/components/Code/Workspace/StatusBar.tsx +47 -0
- package/src/components/Code/Workspace/Toolbar.tsx +47 -0
- package/src/components/Code/Workspace/Workspace.tsx +109 -0
- package/src/components/Code/Workspace/index.ts +3 -0
- package/src/components/ColorDot/ColorDot.tsx +50 -0
- package/src/components/ColorDot/index.ts +1 -0
- package/src/components/DatePicker/DatePicker.tsx +236 -0
- package/src/components/DatePicker/datepicker.hooks.ts +381 -0
- package/src/components/DatePicker/datepicker.types.ts +140 -0
- package/src/components/DatePicker/index.ts +12 -0
- package/src/components/DateRangePicker/DateRangePicker.tsx +127 -0
- package/src/components/DateRangePicker/daterangepicker.types.ts +53 -0
- package/src/components/DateRangePicker/index.ts +2 -0
- package/src/components/DateTimePicker/DateTimePicker.tsx +399 -0
- package/src/components/DateTimePicker/datetimepicker.types.ts +48 -0
- package/src/components/DateTimePicker/index.ts +2 -0
- package/src/components/DateTimeRangePicker/DateTimeRangePicker.tsx +679 -0
- package/src/components/DateTimeRangePicker/datetimerangepicker.types.ts +62 -0
- package/src/components/DateTimeRangePicker/index.ts +2 -0
- package/src/components/EmptyState/EmptyState.tsx +29 -0
- package/src/components/EmptyState/index.ts +1 -0
- package/src/components/Field/Field.tsx +60 -0
- package/src/components/Field/index.ts +1 -0
- package/src/components/FormField/FormField.tsx +55 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/HeatMap/HeatMapCalendar.tsx +127 -0
- package/src/components/HeatMap/HeatMapMatrix.tsx +75 -0
- package/src/components/HeatMap/heat-map.utils.ts +15 -0
- package/src/components/HeatMap/index.ts +3 -0
- package/src/components/HoverCard/HoverCard.tsx +83 -0
- package/src/components/HoverCard/components/HoverCardContent.tsx +30 -0
- package/src/components/HoverCard/components/HoverCardContext.tsx +22 -0
- package/src/components/HoverCard/components/HoverCardTrigger.tsx +32 -0
- package/src/components/HoverCard/index.ts +3 -0
- package/src/components/Input/Input.tsx +48 -0
- package/src/components/Input/index.ts +1 -0
- package/src/components/LineChart/LineChart.tsx +251 -0
- package/src/components/LineChart/index.ts +1 -0
- package/src/components/Markdown/Markdown.tsx +40 -0
- package/src/components/Markdown/index.ts +1 -0
- package/src/components/Message/Message.tsx +59 -0
- package/src/components/Message/components/MessageBody.tsx +9 -0
- package/src/components/Message/components/MessageDescription.tsx +9 -0
- package/src/components/Message/components/MessageIcon.tsx +13 -0
- package/src/components/Message/components/MessageTitle.tsx +9 -0
- package/src/components/Message/index.ts +10 -0
- package/src/components/MetricList/MetricList.tsx +13 -0
- package/src/components/MetricList/MetricRow.tsx +30 -0
- package/src/components/MetricList/index.ts +2 -0
- package/src/components/Modal/Modal.tsx +146 -0
- package/src/components/Modal/components/ModalBody.tsx +9 -0
- package/src/components/Modal/components/ModalContext.tsx +27 -0
- package/src/components/Modal/components/ModalFooter.tsx +9 -0
- package/src/components/Modal/components/ModalHeader.tsx +29 -0
- package/src/components/Modal/components/index.ts +9 -0
- package/src/components/Modal/index.ts +13 -0
- package/src/components/NavBottom/NavBottom.tsx +53 -0
- package/src/components/NavBottom/index.ts +3 -0
- package/src/components/NavSide/NavItem.tsx +39 -0
- package/src/components/NavSide/NavSide.tsx +40 -0
- package/src/components/NavSide/index.ts +2 -0
- package/src/components/NavSide/types.ts +36 -0
- package/src/components/Pagination/Pagination.tsx +86 -0
- package/src/components/Pagination/index.ts +2 -0
- package/src/components/Popover/Popover.tsx +68 -0
- package/src/components/Popover/components/PopoverBody.tsx +32 -0
- package/src/components/Popover/components/PopoverClose.tsx +26 -0
- package/src/components/Popover/components/PopoverContext.tsx +24 -0
- package/src/components/Popover/components/PopoverTrigger.tsx +31 -0
- package/src/components/Popover/index.ts +10 -0
- package/src/components/ProgressBar/ProgressBar.tsx +42 -0
- package/src/components/ProgressBar/index.ts +1 -0
- package/src/components/RadioButton/RadioButton.tsx +89 -0
- package/src/components/RadioButton/index.ts +1 -0
- package/src/components/RadioButtonGroup/RadioButtonGroup.tsx +193 -0
- package/src/components/RadioButtonGroup/index.ts +5 -0
- package/src/components/SearchInput/SearchInput.tsx +45 -0
- package/src/components/SearchInput/index.ts +2 -0
- package/src/components/Select/Select.tsx +289 -0
- package/src/components/Select/SelectDropdown.tsx +90 -0
- package/src/components/Select/SelectTrigger.tsx +130 -0
- package/src/components/Select/index.ts +3 -0
- package/src/components/Select/useSelectKeyboard.ts +111 -0
- package/src/components/Separator/Separator.tsx +20 -0
- package/src/components/Separator/index.ts +1 -0
- package/src/components/Sheet/Sheet.tsx +136 -0
- package/src/components/Sheet/components/SheetBody.tsx +9 -0
- package/src/components/Sheet/components/SheetContext.tsx +23 -0
- package/src/components/Sheet/components/SheetFooter.tsx +9 -0
- package/src/components/Sheet/components/SheetHeader.tsx +29 -0
- package/src/components/Sheet/components/index.ts +9 -0
- package/src/components/Sheet/index.ts +13 -0
- package/src/components/Skeleton/Skeleton.tsx +7 -0
- package/src/components/Skeleton/index.ts +1 -0
- package/src/components/Sparkline/Sparkline.tsx +93 -0
- package/src/components/Sparkline/index.ts +1 -0
- package/src/components/Spinner/Spinner.tsx +41 -0
- package/src/components/Spinner/index.ts +1 -0
- package/src/components/StatsCard/StatsCard.tsx +40 -0
- package/src/components/StatsCard/index.ts +1 -0
- package/src/components/Switch/Switch.tsx +128 -0
- package/src/components/Switch/index.ts +1 -0
- package/src/components/Tabs/Tabs.tsx +49 -0
- package/src/components/Tabs/components/TabsContext.tsx +35 -0
- package/src/components/Tabs/components/TabsList.tsx +98 -0
- package/src/components/Tabs/components/TabsPanels.tsx +26 -0
- package/src/components/Tabs/components/TabsTrigger.tsx +69 -0
- package/src/components/Tabs/index.ts +3 -0
- package/src/components/TagChip/TagChip.tsx +46 -0
- package/src/components/TagChip/index.ts +1 -0
- package/src/components/TagInput/TagInput.tsx +60 -0
- package/src/components/TagInput/index.ts +1 -0
- package/src/components/Text/Text.tsx +35 -0
- package/src/components/Text/index.ts +1 -0
- package/src/components/TextInline/TextInline.tsx +35 -0
- package/src/components/TextInline/index.ts +1 -0
- package/src/components/Textarea/Textarea.tsx +20 -0
- package/src/components/Textarea/index.ts +1 -0
- package/src/components/TimePicker/PeriodSelector.tsx +28 -0
- package/src/components/TimePicker/TimeColumn.tsx +62 -0
- package/src/components/TimePicker/TimePicker.tsx +323 -0
- package/src/components/TimePicker/index.ts +7 -0
- package/src/components/TimePicker/timepicker.hooks.ts +173 -0
- package/src/components/TimePicker/timepicker.types.ts +101 -0
- package/src/components/Toast/Toast.tsx +111 -0
- package/src/components/Toast/index.ts +1 -0
- package/src/components/Toggle/Toggle.tsx +72 -0
- package/src/components/Toggle/index.ts +1 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +14 -0
- package/src/components/ToggleGroup/ToggleGroupContainer.tsx +19 -0
- package/src/components/ToggleGroup/ToggleGroupItem.tsx +51 -0
- package/src/components/ToggleGroup/ToggleGroupMultiple.tsx +61 -0
- package/src/components/ToggleGroup/ToggleGroupSingle.tsx +105 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/ToggleGroup/toggleGroup.types.ts +55 -0
- package/src/components/Tooltip/Tooltip.tsx +66 -0
- package/src/components/Tooltip/components/TooltipBody.tsx +30 -0
- package/src/components/Tooltip/components/TooltipContext.tsx +22 -0
- package/src/components/Tooltip/components/TooltipTrigger.tsx +27 -0
- package/src/components/Tooltip/index.ts +9 -0
- package/src/date-time/arithmetic.ts +101 -0
- package/src/date-time/boundaries.ts +91 -0
- package/src/date-time/compare.ts +85 -0
- package/src/date-time/duration.ts +63 -0
- package/src/date-time/elapsed.ts +18 -0
- package/src/date-time/format.ts +722 -0
- package/src/date-time/index.ts +279 -0
- package/src/date-time/now.ts +70 -0
- package/src/date-time/parse.ts +170 -0
- package/src/date-time/serialize.ts +90 -0
- package/src/date-time/timezone.ts +47 -0
- package/src/date-time/validate.ts +278 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/useCollapsible.ts +16 -0
- package/src/hooks/useControllableState.ts +54 -0
- package/src/hooks/useDebouncedCallback.ts +21 -0
- package/src/hooks/useDebouncedState.ts +20 -0
- package/src/hooks/useDeviceFormFactor.ts +49 -0
- package/src/hooks/useLocalStorage.ts +67 -0
- package/src/hooks/useMergedFloatingRef.ts +29 -0
- package/src/hooks/usePresence.ts +127 -0
- package/src/hooks/useStack.ts +99 -0
- package/src/hooks/useTheme.ts +105 -0
- package/src/internal/Slot.tsx +79 -0
- package/src/internal/index.ts +2 -0
- package/src/internal/rovingFocus.ts +39 -0
- package/src/internal/types.ts +10 -0
- package/src/lib/EmptyBoundary.tsx +12 -0
- package/src/lib/ErrorBoundary.tsx +48 -0
- package/src/lib/contexts/ComponentsContext/ComponentsContext.tsx +34 -0
- package/src/lib/contexts/ComponentsContext/index.ts +1 -0
- package/src/lib/contexts/OverlayContext/OverlayContext.tsx +93 -0
- package/src/lib/contexts/OverlayContext/hooks/useModal.tsx +125 -0
- package/src/lib/contexts/OverlayContext/hooks/useToast.ts +80 -0
- package/src/lib/contexts/OverlayContext/index.ts +6 -0
- package/src/lib/format.ts +51 -0
- package/src/lib/utils.ts +10 -0
- package/src/lib/withEmpty.tsx +14 -0
- package/src/lib/withErrorBoundary.tsx +16 -0
- package/src/lib/withSuspense.tsx +16 -0
- package/src/lib/withSuspenseAndErrorBoundary.tsx +26 -0
- package/src/styles/base.css +41 -0
- package/src/theme/index.ts +37 -0
- package/src/theme/palette.json +100 -0
- package/src/theme/palette.schema.json +40 -0
- package/src/theme/schema.ts +122 -0
- package/src/theme/theme.json +349 -0
- package/src/theme/theme.schema.json +876 -0
- package/src/theme/typography.json +7 -0
- package/src/theme/typography.schema.json +36 -0
- package/src/typography/Body.tsx +8 -0
- package/src/typography/Caption.tsx +8 -0
- package/src/typography/Code.tsx +8 -0
- package/src/typography/Display.tsx +8 -0
- package/src/typography/Emphasis.tsx +8 -0
- package/src/typography/Heading.tsx +23 -0
- package/src/typography/Label.tsx +10 -0
- package/src/typography/Lead.tsx +8 -0
- package/src/typography/Mono.tsx +8 -0
- package/src/typography/Muted.tsx +8 -0
- package/src/typography/SectionTitle.tsx +8 -0
- package/src/typography/Subtitle.tsx +8 -0
- package/src/typography/Timestamp.tsx +8 -0
- package/src/typography/index.ts +10 -0
- 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
|
+
}
|