Skip to content

Commit d0df446

Browse files
committed
chore: wip
1 parent 678bf24 commit d0df446

File tree

15 files changed

+2461
-32
lines changed

15 files changed

+2461
-32
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@
156156
"WebFetch(domain:github.com)",
157157
"Bash(gh pr view:*)",
158158
"Bash(gh pr diff:*)",
159-
"Bash(gh api:*)"
159+
"Bash(gh api:*)",
160+
"Bash(node:*)"
160161
],
161162
"deny": [],
162163
"ask": []

bun.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// Export date utilities directly to avoid circular dependency with @stacksjs/datetime
22
export { useDateFormat, useNow } from '@vueuse/core'
3-
export { format, parse } from '@formkit/tempo'
3+
export { format } from '@stacksjs/datetime'
4+
export { parse } from '@stacksjs/datetime'

storage/framework/core/datetime/README.md

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# Stacks Datetime
22

3-
Easily work with dates.
3+
Easily work with dates. Zero external dependencies, Carbon-inspired API.
44

5-
## ☘️ Features
5+
## Features
66

7+
- Carbon-inspired `DateTime` class with fluent chainable API
78
- Display & visualize dates in a human-friendly way
89
- Easily convert dates to different formats & timezones
910
- Simply calculate the difference between 2 dates
10-
- Modify dates with ease
11+
- Modify dates with ease (immutable operations)
12+
- Zero external dependencies
1113

12-
## 🤖 Usage
14+
## Usage
1315

1416
```bash
1517
bun install -d @stacksjs/datetime
@@ -18,29 +20,49 @@ bun install -d @stacksjs/datetime
1820
Now, you can easily access it in your project:
1921

2022
```js
21-
import { now, useDateFormat } from '@stacksjs/datetime'
22-
23-
const formatted = useDateFormat(now(), 'YYYY-MM-DD HH:mm:ss')
24-
console.log(formatted) // 2022-11-01 17:06:01
23+
import { DateTime, now } from '@stacksjs/datetime'
24+
25+
// Laravel-inspired now() helper
26+
now().toDateString() // '2024-03-15'
27+
now().format('MMMM D, YYYY') // 'March 15, 2024'
28+
now().addDays(7).toDateString()
29+
now().startOfMonth().toDateString()
30+
31+
// DateTime class
32+
const dt = DateTime.create(2024, 3, 15, 10, 30, 0)
33+
dt.format('YYYY-MM-DD HH:mm:ss') // '2024-03-15 10:30:00'
34+
dt.addHours(3).toTimeString() // '13:30:00'
35+
dt.isFuture() // depends on current time
36+
dt.diffInDays(DateTime.now()) // days between dates
37+
38+
// Parse dates
39+
DateTime.parse('2024-03-15', 'YYYY-MM-DD')
40+
DateTime.parse('March 15, 2024', 'MMMM D, YYYY')
41+
42+
// Standalone format/parse functions
43+
import { format, parse } from '@stacksjs/datetime'
44+
format(new Date(), 'YYYY-MM-DD HH:mm:ss')
45+
format(new Date(), 'MMMM D, YYYY', { tz: 'Asia/Tokyo' })
46+
parse('2024-03-15 10:30:00', 'YYYY-MM-DD HH:mm:ss')
2547
```
2648

2749
To view the full documentation, please visit [https://stacksjs.com/datetime](https://stacksjs.com/datetime).
2850

29-
## 🧪 Testing
51+
## Testing
3052

3153
```bash
3254
bun test
3355
```
3456

35-
## 📈 Changelog
57+
## Changelog
3658

3759
Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently.
3860

39-
## 🚜 Contributing
61+
## Contributing
4062

4163
Please review the [Contributing Guide](https://github.com/stacksjs/contributing) for details.
4264

43-
## 🏝 Community
65+
## Community
4466

4567
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
4668

@@ -50,16 +72,15 @@ For casual chit-chat with others using this package:
5072

5173
[Join the Stacks Discord Server](https://discord.gg/stacksjs)
5274

53-
## 🙏🏼 Credits
75+
## Credits
5476

5577
Many thanks to the following core technologies & people who have contributed to this package:
5678

57-
- [VueUse](https://vueuse.org)
5879
- [Carbon](https://carbon.nesbot.com)
5980
- [Chris Breuer](https://github.com/chrisbbreuer)
6081
- [All Contributors](../../contributors)
6182

62-
## 📄 License
83+
## License
6384

6485
The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/tree/main/LICENSE.md) for more information.
6586

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
type DateInput = Date | string
2+
3+
interface FormatOptions {
4+
locale?: string
5+
tz?: string
6+
}
7+
8+
function toDate(input: DateInput): Date {
9+
if (input instanceof Date) return input
10+
const d = new Date(input)
11+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid date: ${input}`)
12+
return d
13+
}
14+
15+
function pad(n: number, len = 2): string {
16+
return String(n).padStart(len, '0')
17+
}
18+
19+
/**
20+
* Extract individual date/time parts for a given timezone using Intl.DateTimeFormat.
21+
* Returns numeric values for year, month, day, hour, minute, second, and weekday.
22+
*/
23+
function getPartsInTz(date: Date, tz: string, locale: string): {
24+
year: number
25+
month: number
26+
day: number
27+
hour: number
28+
minute: number
29+
second: number
30+
weekday: string
31+
weekdayShort: string
32+
weekdayNarrow: string
33+
monthLong: string
34+
monthShort: string
35+
tzOffset: string
36+
} {
37+
// Extract numeric parts
38+
const numFmt = new Intl.DateTimeFormat('en-US', {
39+
timeZone: tz,
40+
year: 'numeric',
41+
month: '2-digit',
42+
day: '2-digit',
43+
hour: '2-digit',
44+
minute: '2-digit',
45+
second: '2-digit',
46+
hour12: false,
47+
})
48+
49+
const parts = numFmt.formatToParts(date)
50+
const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''
51+
52+
const year = Number.parseInt(get('year'), 10)
53+
const month = Number.parseInt(get('month'), 10)
54+
const day = Number.parseInt(get('day'), 10)
55+
let hour = Number.parseInt(get('hour'), 10)
56+
// Intl may return 24 for midnight in some locales
57+
if (hour === 24) hour = 0
58+
const minute = Number.parseInt(get('minute'), 10)
59+
const second = Number.parseInt(get('second'), 10)
60+
61+
// Named parts need the actual target locale
62+
const weekday = new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: 'long' }).format(date)
63+
const weekdayShort = new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: 'short' }).format(date)
64+
const weekdayNarrow = new Intl.DateTimeFormat(locale, { timeZone: tz, weekday: 'narrow' }).format(date)
65+
const monthLong = new Intl.DateTimeFormat(locale, { timeZone: tz, month: 'long' }).format(date)
66+
const monthShort = new Intl.DateTimeFormat(locale, { timeZone: tz, month: 'short' }).format(date)
67+
68+
// Compute the offset for this timezone at this instant
69+
// We do this by comparing the UTC time to the timezone's wall-clock time
70+
const utcFmt = new Intl.DateTimeFormat('en-US', {
71+
timeZone: 'UTC',
72+
year: 'numeric',
73+
month: '2-digit',
74+
day: '2-digit',
75+
hour: '2-digit',
76+
minute: '2-digit',
77+
second: '2-digit',
78+
hour12: false,
79+
})
80+
const utcParts = utcFmt.formatToParts(date)
81+
const getUtc = (type: string) => utcParts.find(p => p.type === type)?.value ?? ''
82+
const utcDate = new Date(
83+
Date.UTC(
84+
Number.parseInt(getUtc('year'), 10),
85+
Number.parseInt(getUtc('month'), 10) - 1,
86+
Number.parseInt(getUtc('day'), 10),
87+
Number.parseInt(getUtc('hour'), 10) === 24 ? 0 : Number.parseInt(getUtc('hour'), 10),
88+
Number.parseInt(getUtc('minute'), 10),
89+
Number.parseInt(getUtc('second'), 10),
90+
),
91+
)
92+
const tzDate = new Date(
93+
Date.UTC(year, month - 1, day, hour, minute, second),
94+
)
95+
const offsetMs = tzDate.getTime() - utcDate.getTime()
96+
const offsetMin = Math.round(offsetMs / 60000)
97+
const sign = offsetMin >= 0 ? '+' : '-'
98+
const absMin = Math.abs(offsetMin)
99+
const tzOffset = `${sign}${pad(Math.floor(absMin / 60))}${pad(absMin % 60)}`
100+
101+
return { year, month, day, hour, minute, second, weekday, weekdayShort, weekdayNarrow, monthLong, monthShort, tzOffset }
102+
}
103+
104+
function buildTokens(p: ReturnType<typeof getPartsInTz>): Record<string, string> {
105+
const hours12 = p.hour % 12 || 12
106+
return {
107+
YYYY: String(p.year),
108+
YY: String(p.year).slice(-2),
109+
MMMM: p.monthLong,
110+
MMM: p.monthShort,
111+
MM: pad(p.month),
112+
M: String(p.month),
113+
DD: pad(p.day),
114+
D: String(p.day),
115+
dddd: p.weekday,
116+
ddd: p.weekdayShort,
117+
d: p.weekdayNarrow,
118+
HH: pad(p.hour),
119+
H: String(p.hour),
120+
hh: pad(hours12),
121+
h: String(hours12),
122+
mm: pad(p.minute),
123+
m: String(p.minute),
124+
ss: pad(p.second),
125+
s: String(p.second),
126+
A: p.hour < 12 ? 'AM' : 'PM',
127+
a: p.hour < 12 ? 'am' : 'pm',
128+
Z: p.tzOffset,
129+
}
130+
}
131+
132+
// Build regex matching all tokens, longest first
133+
const TOKEN_KEYS = ['YYYY', 'MMMM', 'dddd', 'MMM', 'ddd', 'YY', 'MM', 'DD', 'HH', 'hh', 'mm', 'ss', 'M', 'D', 'd', 'H', 'h', 'm', 's', 'A', 'a', 'Z']
134+
const TOKEN_PATTERN = new RegExp(
135+
TOKEN_KEYS
136+
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
137+
.join('|'),
138+
'g',
139+
)
140+
141+
/**
142+
* Format a date using token-based format strings.
143+
*
144+
* Supported tokens:
145+
* - YYYY: 4-digit year
146+
* - YY: 2-digit year
147+
* - MMMM: Full month name (January)
148+
* - MMM: Short month name (Jan)
149+
* - MM: 2-digit month (01-12)
150+
* - M: Month (1-12)
151+
* - DD: 2-digit day (01-31)
152+
* - D: Day (1-31)
153+
* - dddd: Full weekday name (Wednesday)
154+
* - ddd: Short weekday name (Wed)
155+
* - d: Narrow weekday (W)
156+
* - HH: 24-hour padded (00-23)
157+
* - H: 24-hour (0-23)
158+
* - hh: 12-hour padded (01-12)
159+
* - h: 12-hour (1-12)
160+
* - mm: Minutes padded (00-59)
161+
* - m: Minutes (0-59)
162+
* - ss: Seconds padded (00-59)
163+
* - s: Seconds (0-59)
164+
* - A: AM/PM
165+
* - a: am/pm
166+
* - Z: Timezone offset (+0800)
167+
*
168+
* @param inputDate - A Date object or ISO 8601 string
169+
* @param formatStr - Token-based format string (default: 'YYYY-MM-DD')
170+
* @param localeOrOptions - A locale string or options object with locale and tz (IANA timezone)
171+
*/
172+
export function format(inputDate: DateInput, formatStr = 'YYYY-MM-DD', localeOrOptions: string | FormatOptions = 'en'): string {
173+
const date = toDate(inputDate)
174+
const opts = typeof localeOrOptions === 'string' ? { locale: localeOrOptions } : localeOrOptions
175+
const locale = opts.locale ?? 'en'
176+
const tz = opts.tz
177+
178+
if (tz) {
179+
const parts = getPartsInTz(date, tz, locale)
180+
const tokens = buildTokens(parts)
181+
return formatStr.replace(TOKEN_PATTERN, match => tokens[match] ?? match)
182+
}
183+
184+
// Local-time fast path (no Intl overhead for numeric parts)
185+
const hours24 = date.getHours()
186+
const hours12 = hours24 % 12 || 12
187+
188+
const tokens: Record<string, string> = {
189+
YYYY: String(date.getFullYear()),
190+
YY: String(date.getFullYear()).slice(-2),
191+
MMMM: new Intl.DateTimeFormat(locale, { month: 'long' }).format(date),
192+
MMM: new Intl.DateTimeFormat(locale, { month: 'short' }).format(date),
193+
MM: pad(date.getMonth() + 1),
194+
M: String(date.getMonth() + 1),
195+
DD: pad(date.getDate()),
196+
D: String(date.getDate()),
197+
dddd: new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date),
198+
ddd: new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(date),
199+
d: new Intl.DateTimeFormat(locale, { weekday: 'narrow' }).format(date),
200+
HH: pad(hours24),
201+
H: String(hours24),
202+
hh: pad(hours12),
203+
h: String(hours12),
204+
mm: pad(date.getMinutes()),
205+
m: String(date.getMinutes()),
206+
ss: pad(date.getSeconds()),
207+
s: String(date.getSeconds()),
208+
A: hours24 < 12 ? 'AM' : 'PM',
209+
a: hours24 < 12 ? 'am' : 'pm',
210+
Z: (() => {
211+
const offset = -date.getTimezoneOffset()
212+
const sign = offset >= 0 ? '+' : '-'
213+
const abs = Math.abs(offset)
214+
return `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`
215+
})(),
216+
}
217+
218+
return formatStr.replace(TOKEN_PATTERN, match => tokens[match] ?? match)
219+
}

0 commit comments

Comments
 (0)