initial commit

This commit is contained in:
2026-02-08 16:10:41 +11:00
commit 635193f381
13 changed files with 3190 additions and 0 deletions

361
README.md Normal file
View File

@@ -0,0 +1,361 @@
# timefmt
[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/timefmt.svg)](https://pkg.go.dev/git.haelnorr.com/h/timefmt)
[![Go Report Card](https://goreportcard.com/badge/git.haelnorr.com/h/timefmt)](https://goreportcard.com/report/git.haelnorr.com/h/timefmt)
A Go library for building and converting time format strings with human-readable descriptions. `timefmt` provides a fluent builder API for constructing time formats and can convert between Go's reference time format, LDML tokens, and plain English descriptions.
## Features
- 🏗️ **Builder Pattern**: Fluent, chainable API for constructing time formats
- 🔄 **Multi-Format Output**: Convert to Go format, LDML tokens, or English descriptions
- 📦 **Pre-built Formats**: Common formats like ISO8601, RFC3339, etc. ready to use
- 🔍 **Format Parser**: Parse existing Go time format strings
- 📝 **Type-Safe**: No string concatenation errors
-**Well-Tested**: Comprehensive test coverage
## Installation
```bash
go get git.haelnorr.com/h/timefmt
```
## Quick Start
```go
package main
import (
"fmt"
"time"
"git.haelnorr.com/h/timefmt"
)
func main() {
// Build a format using the fluent API
format := timefmt.NewBuilder().
Year4().Dash().
MonthNumeric2().Dash().
DayNumeric2().Space().
Hour24().Colon().
Minute().Colon().
Second().
Build()
// Get different representations
fmt.Println(format.GoFormat()) // "2006-01-02 15:04:05"
fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss"
fmt.Println(format.Description()) // "Year (4-digit), dash, Month (2-digit), ..."
// Use it to format times
now := time.Now()
fmt.Println(format.Format(now)) // "2026-02-08 15:04:05"
}
```
## Usage
### Building Formats
The builder provides methods for all time components:
```go
format := timefmt.NewBuilder().
WeekdayFull().Comma(). // "Monday, "
MonthFull().Space(). // "January "
DayNumeric().Comma(). // "8, "
Year4().Space(). // "2006 "
Literal("at").Space(). // "at "
Hour12().Colon(). // "3:"
Minute().Space(). // "04 "
AMPM(). // "PM"
Build()
// Outputs: "Monday, January 8, 2006 at 3:04 PM"
```
### Pre-built Formats
Common formats are available as constants:
```go
// ISO 8601 / RFC 3339
fmt.Println(timefmt.ISO8601.GoFormat()) // "2006-01-02T15:04:05Z07:00"
fmt.Println(timefmt.RFC3339Nano.GoFormat()) // "2006-01-02T15:04:05.999999999Z07:00"
// Date and time
fmt.Println(timefmt.DateOnly.GoFormat()) // "2006-01-02"
fmt.Println(timefmt.TimeOnly.GoFormat()) // "15:04:05"
fmt.Println(timefmt.DateTime.GoFormat()) // "2006-01-02 15:04:05"
// Regional formats
fmt.Println(timefmt.DateUS.GoFormat()) // "01/02/2006"
fmt.Println(timefmt.DateEU.GoFormat()) // "02/01/2006"
// Kitchen and stamps
fmt.Println(timefmt.Kitchen.GoFormat()) // "3:04 PM"
fmt.Println(timefmt.StampMilli.GoFormat()) // "Jan _2 15:04:05.000"
```
Available pre-built formats:
- `ISO8601`, `RFC3339`, `RFC3339Nano`
- `DateOnly`, `TimeOnly`, `DateTime`, `DateTimeWithMillis`
- `DateUS`, `DateEU`, `DateTimeUS`, `DateTimeEU`
- `Kitchen`, `Stamp`, `StampMilli`, `StampMicro`, `StampNano`
### Parsing Existing Go Formats
Convert Go time format strings to get LDML and descriptions:
```go
format, err := timefmt.ParseGoFormat("02/01/2006")
if err != nil {
log.Fatal(err)
}
fmt.Println(format.GoFormat()) // "02/01/2006"
fmt.Println(format.LDML()) // "dd/MM/yyyy"
fmt.Println(format.Description()) // "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)"
```
### Multiple Output Formats
Every `Format` can output in three ways:
```go
format := timefmt.NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Build()
// Go reference time format (for time.Format/Parse)
goFmt := format.GoFormat() // "2006-01-02"
// LDML tokens (Unicode standard, used by ICU, Java, Swift, etc.)
ldml := format.LDML() // "yyyy-MM-dd"
// Human-readable English description
desc := format.Description() // "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)"
```
### Format and Parse Times
Use the Format to actually format and parse times:
```go
format := timefmt.DateTime
// Format a time
now := time.Now()
formatted := format.Format(now) // "2026-02-08 15:04:05"
// Parse a time string
parsed, err := format.Parse("2026-02-08 15:04:05")
if err != nil {
log.Fatal(err)
}
// Parse with location
loc, _ := time.LoadLocation("America/New_York")
parsed, err = format.ParseInLocation("2026-02-08 15:04:05", loc)
```
### Get Example Output
See what a format looks like with example data:
```go
format := timefmt.NewBuilder().
MonthShort().Space().DayNumeric().Comma().Year4().
Build()
example := format.Example() // "Feb 8, 2026" (using reference time)
```
## Builder API Reference
### Year Methods
- `Year4()` - 4-digit year (2006)
- `Year2()` - 2-digit year (06)
### Month Methods
- `MonthNumeric()` - Numeric month without leading zero (1-12)
- `MonthNumeric2()` - 2-digit month with leading zero (01-12)
- `MonthShort()` - Abbreviated month name (Jan, Feb, ...)
- `MonthFull()` - Full month name (January, February, ...)
### Day Methods
- `DayNumeric()` - Numeric day without leading zero (1-31)
- `DayNumeric2()` - 2-digit day with leading zero (01-31)
- `DaySpacePadded()` - Space-padded day ( 1-31)
- `DayOfYear()` - 3-digit day of year (001-365)
- `DayOfYearSpacePadded()` - Space-padded day of year ( 1-365)
### Weekday Methods
- `WeekdayShort()` - Abbreviated weekday (Mon, Tue, ...)
- `WeekdayFull()` - Full weekday (Monday, Tuesday, ...)
### Hour Methods
- `Hour24()` - 24-hour format with leading zero (00-23)
- `Hour12()` - 12-hour format without leading zero (1-12)
- `Hour12Padded()` - 12-hour format with leading zero (01-12)
### Minute Methods
- `Minute()` - 2-digit minute with leading zero (00-59)
- `MinuteUnpadded()` - Minute without leading zero (0-59)
### Second Methods
- `Second()` - 2-digit second with leading zero (00-59)
- `SecondUnpadded()` - Second without leading zero (0-59)
### Subsecond Methods
- `Millisecond()` - 3-digit milliseconds (.000)
- `MillisecondTrim()` - Milliseconds with trailing zeros removed (.999)
- `Microsecond()` - 6-digit microseconds (.000000)
- `MicrosecondTrim()` - Microseconds with trailing zeros removed (.999999)
- `Nanosecond()` - 9-digit nanoseconds (.000000000)
- `NanosecondTrim()` - Nanoseconds with trailing zeros removed (.999999999)
### AM/PM Methods
- `AMPM()` - AM/PM in uppercase
- `AMPMLower()` - am/pm in lowercase
### Timezone Methods
- `TimezoneOffset()` - Timezone offset as ±HHMM (e.g., -0700)
- `TimezoneOffsetColon()` - Timezone offset as ±HH:MM (e.g., -07:00)
- `TimezoneOffsetHourOnly()` - Timezone offset hours only ±HH (e.g., -07)
- `TimezoneOffsetSeconds()` - Timezone offset with seconds ±HHMMSS (e.g., -070000)
- `TimezoneOffsetColonSeconds()` - Timezone offset with seconds ±HH:MM:SS (e.g., -07:00:00)
- `TimezoneISO8601()` - ISO 8601 timezone (Z or ±HHMM)
- `TimezoneISO8601Colon()` - ISO 8601 timezone with colon (Z or ±HH:MM)
- `TimezoneName()` - Timezone abbreviation (MST, PST, etc.)
### Literal Methods
- `Literal(s string)` - Add custom literal text
- `Dash()` - Add "-"
- `Slash()` - Add "/"
- `Colon()` - Add ":"
- `Space()` - Add " "
- `T()` - Add "T" (for ISO 8601)
- `Comma()` - Add ", "
- `Period()` - Add "."
### Building
- `Build()` - Finalize and return the Format
## Fragment Constants
All format components are also available as `Fragment` constants for advanced use:
```go
type Fragment struct {
GoFormat string // Go's reference format
LDML string // LDML token
Description string // Human-readable description
}
// Example fragments
timefmt.Year4Digit // Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"}
timefmt.MonthNumeric2 // Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"}
timefmt.Hour24 // Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"}
```
You can use fragments directly with `NewFormat()`:
```go
format := timefmt.NewFormat(
timefmt.Year4Digit,
"-",
timefmt.MonthNumeric2,
"-",
timefmt.DayNumeric2,
)
```
## Examples
### User-Facing Format Display
Perfect for showing users what format is expected:
```go
format := timefmt.ParseGoFormat(userConfigFormat)
// Show LDML tokens (familiar to developers)
fmt.Printf("Format: %s\n", format.LDML())
// Output: "Format: yyyy-MM-dd HH:mm:ss"
// Show description (clear for everyone)
fmt.Printf("Expected format: %s\n", format.Description())
// Output: "Expected format: Year (4-digit), dash, Month (2-digit), ..."
// Show example
fmt.Printf("Example: %s\n", format.Example())
// Output: "Example: 2026-02-08 15:04:05"
```
### Configuration Documentation
Generate documentation for time format configuration:
```go
formats := map[string]*timefmt.Format{
"log_timestamp": timefmt.DateTime,
"api_response": timefmt.RFC3339,
"display_date": timefmt.DateUS,
}
for name, format := range formats {
fmt.Printf("%s:\n", name)
fmt.Printf(" Go format: %s\n", format.GoFormat())
fmt.Printf(" LDML: %s\n", format.LDML())
fmt.Printf(" Description: %s\n", format.Description())
fmt.Printf(" Example: %s\n\n", format.Example())
}
```
### Form Validation Messages
```go
format := timefmt.DateOnly
err := fmt.Errorf(
"Invalid date format. Expected format: %s (example: %s)",
format.Description(),
format.Example(),
)
// Error: Invalid date format. Expected format: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit) (example: 2026-02-08)
```
## LDML Token Compatibility
The LDML output follows the [Unicode LDML standard](https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table), making it compatible with:
- **ICU** (International Components for Unicode)
- **Java** (`SimpleDateFormat`, `DateTimeFormatter`)
- **Swift** (`DateFormatter`)
- **Moment.js** and **Day.js**
- **date-fns**
- Most modern date/time libraries
## Why timefmt?
Go's time formatting using reference times is unique and powerful, but:
1. **Not immediately intuitive** - New developers need to memorize the reference time
2. **Hard to document** - "2006-01-02" doesn't clearly communicate "YYYY-MM-DD"
3. **No validation helpers** - No easy way to show users what format is expected
`timefmt` solves these problems by providing:
- A type-safe builder that prevents format errors
- Automatic conversion to widely-understood LDML tokens
- Plain English descriptions for end users
- Pre-built formats for common use cases
## License
MIT License - see LICENSE file for details
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

538
TIME_FORMAT_RESEARCH.md Normal file
View File

@@ -0,0 +1,538 @@
# Time Format Patterns - Cross-System Comparison
## Overview
This document provides a comprehensive comparison of time format patterns across different programming languages and systems. This research will help inform the design of a widely-recognized, human-readable time format.
---
## 1. Unicode LDML (Locale Data Markup Language)
**Used by:** ICU, Java, Swift, JavaScript (Intl), and many internationalization libraries
### Key Characteristics
- Single letters represent minimal/numeric forms
- Repeated letters increase width or change format type
- Case-sensitive (different meanings for upper/lower case)
- Canonical order matters for skeletons
### Format Tokens
| Element | Token | Examples | Notes |
|---------|-------|----------|-------|
| **Year** |
| 4-digit year | `yyyy` or `y` | 2024, 0070 | `y` adapts to context |
| 2-digit year | `yy` | 24, 70 | 00-99 |
| **Month** |
| Numeric, no zero | `M` | 1, 12 | 1-12 |
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
| Abbreviated name | `MMM` | Jan, Dec | Locale-specific |
| Full name | `MMMM` | January, December | Locale-specific |
| Stand-alone abbrev | `LLL` | Jan, Dec | Nominative form |
| Stand-alone full | `LLLL` | January, December | Nominative form |
| **Day of Month** |
| Numeric, no zero | `d` | 1, 31 | 1-31 |
| Numeric, zero-padded | `dd` | 01, 31 | 01-31 |
| **Weekday** |
| Abbreviated | `EEE` | Mon, Fri | Locale-specific |
| Full name | `EEEE` | Monday, Friday | Locale-specific |
| Narrow | `EEEEE` | M, F | Single character |
| Short | `EEEEEE` | Mo, Fr | Between abbrev & narrow |
| **Hour (12-hour)** |
| No zero (1-12) | `h` | 1, 12 | Requires AM/PM |
| Zero-padded (01-12) | `hh` | 01, 12 | Requires AM/PM |
| **Hour (24-hour)** |
| No zero (0-23) | `H` | 0, 23 | |
| Zero-padded (00-23) | `HH` | 00, 23 | |
| **Minute** |
| No zero | `m` | 0, 59 | 0-59 |
| Zero-padded | `mm` | 00, 59 | 00-59 |
| **Second** |
| No zero | `s` | 0, 59 | 0-59 |
| Zero-padded | `ss` | 00, 59 | 00-59 |
| **Fractional Seconds** |
| Milliseconds | `SSS` | 000, 999 | Always 3 digits |
| Variable precision | `S` to `SSSSSSSSS` | 0 to 9 digits | Fractional precision |
| **AM/PM** |
| Period | `a` | AM, PM | Locale-specific |
| Period, narrow | `aaaaa` | a, p | Single character |
| Day period (flexible) | `b` | at night, in the morning | Locale-specific |
| Day period (specific) | `B` | noon, midnight | Locale-specific |
| **Timezone** |
| ISO offset | `Z` to `ZZZZZ` | -0800, -08:00, Z | Various formats |
| Localized GMT | `O` to `OOOO` | GMT-8, GMT-08:00 | |
| Generic non-location | `v` | PT | Short form |
| Generic non-location | `vvvv` | Pacific Time | Long form |
| Specific non-location | `z` | PST | Short form |
| Specific non-location | `zzzz` | Pacific Standard Time | Long form |
### Common Pattern Examples
- `yyyy-MM-dd` → 2024-02-08
- `MMM d, yyyy` → Feb 8, 2024
- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024
- `h:mm a` → 3:45 PM
- `HH:mm:ss` → 15:45:30
- `yyyy-MM-dd'T'HH:mm:ss` → 2024-02-08T15:45:30
---
## 2. strftime (C, Python, Ruby, PHP, etc.)
**Used by:** C, C++, Python, Ruby, PHP, Perl, Unix/Linux systems
### Key Characteristics
- All format codes start with `%`
- Single character codes (case-sensitive)
- Platform-dependent for some codes
- Locale-aware variants with `E` and `O` modifiers
### Format Codes
| Element | Code | Examples | Notes |
|---------|------|----------|-------|
| **Year** |
| 4-digit year | `%Y` | 2024, 0070 | |
| 2-digit year | `%y` | 24, 70 | 00-99 |
| Century | `%C` | 20 | First 2 digits |
| **Month** |
| Numeric, zero-padded | `%m` | 01, 12 | 01-12 |
| Abbreviated name | `%b` or `%h` | Jan, Dec | Locale-specific |
| Full name | `%B` | January, December | Locale-specific |
| **Day of Month** |
| Zero-padded | `%d` | 01, 31 | 01-31 |
| Space-padded | `%e` | " 1", "31" | 1-31 with space |
| **Weekday** |
| Abbreviated | `%a` | Mon, Fri | Locale-specific |
| Full name | `%A` | Monday, Friday | Locale-specific |
| Numeric (0-6) | `%w` | 0, 6 | Sunday = 0 |
| Numeric (1-7) | `%u` | 1, 7 | Monday = 1 |
| **Hour (12-hour)** |
| Zero-padded | `%I` | 01, 12 | 01-12, requires AM/PM |
| **Hour (24-hour)** |
| Zero-padded | `%H` | 00, 23 | 00-23 |
| Space-padded | `%k` | " 0", "23" | 0-23 with space |
| **Minute** |
| Zero-padded | `%M` | 00, 59 | 00-59 |
| **Second** |
| Zero-padded | `%S` | 00, 59 | 00-59 |
| **Fractional Seconds** |
| Microseconds | `%f` | 000000, 999999 | Python-specific, 6 digits |
| **AM/PM** |
| Period | `%p` | AM, PM | Locale-specific |
| Period lowercase | `%P` | am, pm | GNU extension |
| **Timezone** |
| Name or abbreviation | `%Z` | PST, EST | Platform-dependent |
| Offset | `%z` | -0800, +0530 | ±HHMM format |
| **Composite Formats** |
| Date (MM/DD/YY) | `%D` | 02/08/24 | Equivalent to %m/%d/%y |
| ISO date | `%F` | 2024-02-08 | Equivalent to %Y-%m-%d |
| 12-hour time | `%r` | 03:45:30 PM | Locale-specific |
| 24-hour time (HH:MM) | `%R` | 15:45 | Equivalent to %H:%M |
| Time with seconds | `%T` | 15:45:30 | Equivalent to %H:%M:%S |
| Date and time | `%c` | Thu Feb 8 15:45:30 2024 | Locale-specific |
### Common Pattern Examples
- `%Y-%m-%d` → 2024-02-08
- `%b %d, %Y` → Feb 08, 2024
- `%A, %B %d, %Y` → Thursday, February 08, 2024
- `%I:%M %p` → 03:45 PM
- `%H:%M:%S` → 15:45:30
- `%Y-%m-%dT%H:%M:%S` → 2024-02-08T15:45:30
---
## 3. Moment.js / Day.js (JavaScript)
**Used by:** Moment.js, Day.js (legacy JavaScript libraries)
### Key Characteristics
- No special prefix character
- Case-sensitive
- Repetition changes format
- Inspired by PHP date() function
### Format Tokens
| Element | Token | Examples | Notes |
|---------|-------|----------|-------|
| **Year** |
| 4-digit year | `YYYY` | 2024, 0070 | |
| 2-digit year | `YY` | 24, 70 | 00-99 |
| **Month** |
| Numeric, no zero | `M` | 1, 12 | 1-12 |
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
| Abbreviated name | `MMM` | Jan, Dec | |
| Full name | `MMMM` | January, December | |
| **Day of Month** |
| Numeric, no zero | `D` | 1, 31 | 1-31 |
| Numeric, zero-padded | `DD` | 01, 31 | 01-31 |
| Ordinal | `Do` | 1st, 31st | With suffix |
| **Weekday** |
| Abbreviated | `ddd` | Mon, Fri | |
| Full name | `dddd` | Monday, Friday | |
| Min (2 chars) | `dd` | Mo, Fr | |
| Numeric (0-6) | `d` | 0, 6 | Sunday = 0 |
| **Hour (12-hour)** |
| No zero (1-12) | `h` | 1, 12 | Requires A or a |
| Zero-padded (01-12) | `hh` | 01, 12 | Requires A or a |
| **Hour (24-hour)** |
| No zero (0-23) | `H` | 0, 23 | |
| Zero-padded (00-23) | `HH` | 00, 23 | |
| **Minute** |
| No zero | `m` | 0, 59 | 0-59 |
| Zero-padded | `mm` | 00, 59 | 00-59 |
| **Second** |
| No zero | `s` | 0, 59 | 0-59 |
| Zero-padded | `ss` | 00, 59 | 00-59 |
| **Fractional Seconds** |
| 1-3 digits | `S`, `SS`, `SSS` | 0, 00, 000 | Tenths, hundredths, ms |
| Up to 9 digits | `SSSS` to `SSSSSSSSS` | Variable | Extended precision |
| **AM/PM** |
| Lowercase | `a` | am, pm | |
| Uppercase | `A` | AM, PM | |
| **Timezone** |
| Offset | `Z` | -08:00, +05:30 | With colon |
| Compact offset | `ZZ` | -0800, +0530 | Without colon |
### Common Pattern Examples
- `YYYY-MM-DD` → 2024-02-08
- `MMM D, YYYY` → Feb 8, 2024
- `dddd, MMMM D, YYYY` → Thursday, February 8, 2024
- `h:mm A` → 3:45 PM
- `HH:mm:ss` → 15:45:30
- `YYYY-MM-DDTHH:mm:ss` → 2024-02-08T15:45:30
---
## 4. date-fns (Modern JavaScript)
**Used by:** date-fns library
### Key Characteristics
- Based on Unicode LDML tokens
- More standardized than Moment.js
- Case-sensitive
- Uses escape sequences for literals
### Format Tokens
*date-fns uses Unicode LDML tokens (see Section 1) with some differences:*
| Element | Token | Examples | Notes |
|---------|-------|----------|-------|
| **Year** |
| Extended year | `uuuu` | 2024, -0001 | Recommended over yyyy |
| Calendar year | `yyyy` | 2024 | Use uuuu instead |
| 2-digit year | `yy` or `uu` | 24 | |
| **Month** | | | Same as LDML |
| **Day** |
| Day of month | `d`, `do`, `dd` | 1, 1st, 01 | |
| Day of year | `D`, `Do`, `DD`, `DDD` | 1, 1st, 01, 001 | |
| **Week** |
| Local week | `w`, `wo`, `ww` | 1, 1st, 01 | |
| ISO week | `I`, `Io`, `II` | 1, 1st, 01 | |
| **Weekday** |
| Short | `eee`, `eeeee` | Mon, M | Locale day |
| Long | `eeee`, `eeeeee` | Monday, Mo | Locale day |
| ISO | `i`, `io`, `iii`, `iiii`, `iiiii`, `iiiiii` | 1, 1st, Mon, Monday, M, Mo | |
| **Hour, Minute, Second** | | | Same as LDML |
| **AM/PM** | | | Same as LDML |
| **Timezone** | | | Extended LDML support |
### Common Pattern Examples
- `yyyy-MM-dd` → 2024-02-08
- `MMM d, yyyy` → Feb 8, 2024
- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024
- `h:mm a` → 3:45 PM
- `HH:mm:ss` → 15:45:30
---
## 5. .NET DateTime Formats
**Used by:** C#, F#, VB.NET, .NET platform
### Key Characteristics
- Single character codes (case-sensitive)
- Can use multiple characters for wider format
- Custom format strings combine individual specifiers
- Standard format strings (single character) produce locale-specific output
### Format Specifiers
| Element | Specifier | Examples | Notes |
|---------|-----------|----------|-------|
| **Year** |
| 1-2 digit year | `y` | 8, 24 | Minimum digits |
| 2-digit year | `yy` | 08, 24 | 00-99 |
| 3-digit year | `yyy` | 008, 024 | Minimum 3 digits |
| 4-digit year | `yyyy` | 0008, 2024 | |
| 5-digit year | `yyyyy` | 00008, 02024 | |
| **Month** |
| Numeric, no zero | `M` | 1, 12 | 1-12 |
| Numeric, zero-padded | `MM` | 01, 12 | 01-12 |
| Abbreviated name | `MMM` | Jan, Dec | Locale-specific |
| Full name | `MMMM` | January, December | Locale-specific |
| **Day of Month** |
| Numeric, no zero | `d` | 1, 31 | 1-31 |
| Numeric, zero-padded | `dd` | 01, 31 | 01-31 |
| **Weekday** |
| Abbreviated | `ddd` | Mon, Fri | Locale-specific |
| Full name | `dddd` | Monday, Friday | Locale-specific |
| **Hour (12-hour)** |
| No zero (1-12) | `h` | 1, 12 | Requires tt or t |
| Zero-padded (01-12) | `hh` | 01, 12 | Requires tt or t |
| **Hour (24-hour)** |
| No zero (0-23) | `H` | 0, 23 | |
| Zero-padded (00-23) | `HH` | 00, 23 | |
| **Minute** |
| No zero | `m` | 0, 59 | 0-59 |
| Zero-padded | `mm` | 00, 59 | 00-59 |
| **Second** |
| No zero | `s` | 0, 59 | 0-59 |
| Zero-padded | `ss` | 00, 59 | 00-59 |
| **Fractional Seconds** |
| Tenths | `f` | 0-9 | Always shown |
| Hundredths | `ff` | 00-99 | Always shown |
| Milliseconds | `fff` | 000-999 | Always shown |
| Optional tenths | `F` | 0-9 or nothing | Not shown if zero |
| Optional hundredths | `FF` | 00-99 or nothing | Not shown if zero |
| Optional milliseconds | `FFF` | 000-999 or nothing | Not shown if zero |
| Up to 7 digits | `fffffff`, `FFFFFFF` | Nanosecond precision | |
| **AM/PM** |
| First char | `t` | A, P | Locale-specific |
| Full designator | `tt` | AM, PM | Locale-specific |
| **Timezone** |
| Hours offset | `z` | -8, +5 | No leading zero |
| Hours offset padded | `zz` | -08, +05 | With leading zero |
| Full offset | `zzz` | -08:00, +05:30 | Hours and minutes |
| **Other** |
| Era | `g` or `gg` | A.D. | Period/era |
| Time separator | `:` | : | Locale-specific |
| Date separator | `/` | / | Locale-specific |
### Standard Format Strings
- `d` - Short date pattern (MM/dd/yyyy)
- `D` - Long date pattern (dddd, MMMM dd, yyyy)
- `t` - Short time pattern (h:mm tt)
- `T` - Long time pattern (h:mm:ss tt)
- `f` - Full date/time short (dddd, MMMM dd, yyyy h:mm tt)
- `F` - Full date/time long (dddd, MMMM dd, yyyy h:mm:ss tt)
- `g` - General short (M/d/yyyy h:mm tt)
- `G` - General long (M/d/yyyy h:mm:ss tt)
- `o` - ISO 8601 (yyyy-MM-ddTHH:mm:ss.fffffffK)
- `s` - Sortable (yyyy-MM-ddTHH:mm:ss)
### Common Pattern Examples
- `yyyy-MM-dd` → 2024-02-08
- `MMM d, yyyy` → Feb 8, 2024
- `dddd, MMMM d, yyyy` → Thursday, February 8, 2024
- `h:mm tt` → 3:45 PM
- `HH:mm:ss` → 15:45:30
- `yyyy-MM-ddTHH:mm:ss` → 2024-02-08T15:45:30
---
## 6. ISO 8601 Standard
**International Standard for date and time representation**
### Key Characteristics
- Designed for unambiguous machine-readable formats
- Always uses Gregorian calendar
- Year-month-day order (largest to smallest units)
- Uses T as date/time separator
- Uses Z to denote UTC
### Standard Representations
| Format | Pattern | Example | Notes |
|--------|---------|---------|-------|
| **Date** |
| Calendar date | `YYYY-MM-DD` | 2024-02-08 | Extended format |
| Calendar date | `YYYYMMDD` | 20240208 | Basic format |
| Week date | `YYYY-Www-D` | 2024-W06-4 | Week 6, day 4 |
| Week date | `YYYYWwwD` | 2024W064 | Basic format |
| Ordinal date | `YYYY-DDD` | 2024-039 | Day 39 of year |
| Ordinal date | `YYYYDDD` | 2024039 | Basic format |
| Year and month | `YYYY-MM` | 2024-02 | |
| Year only | `YYYY` | 2024 | |
| **Time** |
| Hours and minutes | `hh:mm` | 15:45 | Extended format |
| Hours and minutes | `hhmm` | 1545 | Basic format |
| Hours, min, sec | `hh:mm:ss` | 15:45:30 | Extended format |
| Hours, min, sec | `hhmmss` | 154530 | Basic format |
| With fractional sec | `hh:mm:ss.sss` | 15:45:30.123 | Variable precision |
| **DateTime** |
| Combined | `YYYY-MM-DDThh:mm:ss` | 2024-02-08T15:45:30 | T separator |
| Combined basic | `YYYYMMDDThhmmss` | 20240208T154530 | No separators |
| **Timezone** |
| UTC indicator | `Z` | 2024-02-08T15:45:30Z | Zulu time |
| Offset | `±hh:mm` | 2024-02-08T15:45:30-08:00 | Extended format |
| Offset | `±hhmm` | 2024-02-08T15:45:30-0800 | Basic format |
| Offset | `±hh` | 2024-02-08T15:45:30-08 | Hours only |
### Common ISO 8601 Examples
- `2024-02-08` (Date only)
- `15:45:30` (Time only)
- `2024-02-08T15:45:30` (Local datetime)
- `2024-02-08T15:45:30Z` (UTC datetime)
- `2024-02-08T15:45:30-08:00` (With timezone offset)
- `2024-W06-4` (Week date: year 2024, week 6, Thursday)
---
## Cross-System Comparison Table
### Year Formats
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| 4-digit | `yyyy` | `%Y` | `YYYY` | `yyyy` | `yyyy` | `YYYY` |
| 2-digit | `yy` | `%y` | `YY` | `yy` | `yy` | `YY` |
### Month Formats
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| Numeric no zero | `M` | - | `M` | `M` | `M` | - |
| Numeric zero-pad | `MM` | `%m` | `MM` | `MM` | `MM` | `MM` |
| Abbreviated name | `MMM` | `%b` | `MMM` | `MMM` | `MMM` | - |
| Full name | `MMMM` | `%B` | `MMMM` | `MMMM` | `MMMM` | - |
### Day Formats
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| Numeric no zero | `d` | - | `D` | `d` | `d` | - |
| Numeric zero-pad | `dd` | `%d` | `DD` | `dd` | `dd` | `DD` |
| Abbreviated weekday | `EEE` | `%a` | `ddd` | `eee` | `ddd` | - |
| Full weekday | `EEEE` | `%A` | `dddd` | `eeee` | `dddd` | - |
### Hour Formats (12-hour)
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| No zero (1-12) | `h` | - | `h` | `h` | `h` | - |
| Zero-pad (01-12) | `hh` | `%I` | `hh` | `hh` | `hh` | - |
### Hour Formats (24-hour)
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| No zero (0-23) | `H` | - | `H` | `H` | `H` | - |
| Zero-pad (00-23) | `HH` | `%H` | `HH` | `HH` | `HH` | `hh` |
### Minute/Second Formats
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| Minutes no zero | `m` | - | `m` | `m` | `m` | - |
| Minutes zero-pad | `mm` | `%M` | `mm` | `mm` | `mm` | `mm` |
| Seconds no zero | `s` | - | `s` | `s` | `s` | - |
| Seconds zero-pad | `ss` | `%S` | `ss` | `ss` | `ss` | `ss` |
### AM/PM Indicators
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| Uppercase | `a` | `%p` | `A` | `a` | `tt` | N/A |
| Lowercase | `aaaaa` | `%P`* | `a` | `aaa` | - | N/A |
| First char only | - | - | - | - | `t` | N/A |
*GNU extension, not POSIX standard
### Timezone Formats
| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 |
|--------|------|----------|--------|----------|------|----------|
| Offset with colon | `ZZZZZ` or `XXXXX` | - | `Z` | `XXX` | `zzz` | `±hh:mm` |
| Offset no colon | `ZZZ` or `XX` | `%z` | `ZZ` | `XX` | - | `±hhmm` |
| UTC indicator | - | - | - | `X` | - | `Z` |
| Named timezone | `z`, `zzzz` | `%Z` | - | - | - | N/A |
---
## Recommendations for Human-Readable Format
Based on this research, here are recommendations for a widely-recognized human-readable format:
### Most Universal Patterns
1. **Date Only - ISO 8601 style (MOST UNIVERSAL)**
- Pattern: `YYYY-MM-DD`
- Example: `2024-02-08`
- Recognized by: ALL systems
- Pros: Unambiguous, sortable, internationally recognized
- Cons: Less readable for some English speakers
2. **Date Only - Text month**
- Pattern: `MMM DD, YYYY` or `DD MMM YYYY`
- Example: `Feb 08, 2024` or `08 Feb 2024`
- Recognized by: All major systems (with slight variations)
- Pros: Very readable, avoids MM/DD vs DD/MM confusion
- Cons: Not sortable, locale-dependent
3. **Time Only - 24-hour (MOST UNIVERSAL)**
- Pattern: `HH:mm:ss`
- Example: `15:45:30`
- Recognized by: ALL systems
- Pros: Unambiguous, no AM/PM needed
- Cons: Some users prefer 12-hour format
4. **Time Only - 12-hour**
- Pattern: `hh:mm:ss AM` or `h:mm:ss a`
- Example: `03:45:30 PM`
- Recognized by: All major systems (with variations)
- Pros: Familiar to many users
- Cons: Requires AM/PM designator
5. **Full DateTime - ISO 8601 (MOST UNIVERSAL)**
- Pattern: `YYYY-MM-DDTHH:mm:ss`
- Example: `2024-02-08T15:45:30`
- Recognized by: ALL systems
- Pros: Unambiguous, sortable, international standard
- Cons: T separator less readable
6. **Full DateTime - Human-readable**
- Pattern: `MMMM DD, YYYY HH:mm:ss` or `DD MMMM YYYY HH:mm:ss`
- Example: `February 08, 2024 15:45:30`
- Recognized by: All major systems (with slight variations)
- Pros: Very readable
- Cons: Verbose, locale-dependent month names
### Token Compatibility Summary
**Highest Compatibility (work in 5-6 systems):**
- `yyyy`/`YYYY` - 4-digit year
- `MM` - 2-digit month (zero-padded)
- `dd`/`DD` - 2-digit day (zero-padded)
- `HH` - 24-hour time (zero-padded)
- `mm` - minutes (zero-padded)
- `ss` - seconds (zero-padded)
**High Compatibility (work in 4-5 systems):**
- `MMM` - Abbreviated month name
- `MMMM` - Full month name
- `hh` - 12-hour time (zero-padded)
- `a`/`A`/`tt` - AM/PM (varies by system)
**Moderate Compatibility:**
- `EEE`/`ddd` - Abbreviated weekday
- `EEEE`/`dddd` - Full weekday
- Single-letter tokens (no zero padding) - variable support
### Recommended Format for timefmt Project
For maximum compatibility and readability, consider:
1. **For machine-readable output**: `YYYY-MM-DD HH:mm:ss` (ISO 8601 without T)
2. **For human-readable output**: `MMM DD, YYYY HH:mm:ss` (e.g., "Feb 08, 2024 15:45:30")
3. **For compact output**: `YYYY-MM-DD HH:mm` (omit seconds if not needed)
These formats:
- Avoid MM/DD vs DD/MM confusion
- Don't require AM/PM logic
- Use widely recognized token patterns
- Are unambiguous across cultures
- Balance readability with precision

278
builder.go Normal file
View File

@@ -0,0 +1,278 @@
package timefmt
// Builder provides a fluent interface for constructing Format instances.
// It uses a mutable pattern where each method modifies the builder and returns it for chaining.
type Builder struct {
format *Format
}
// NewBuilder creates a new format builder with an empty format.
func NewBuilder() *Builder {
return &Builder{
format: &Format{
fragments: make([]interface{}, 0),
},
}
}
// Year4 adds a 4-digit year (e.g., 2026) to the format.
func (b *Builder) Year4() *Builder {
b.format.fragments = append(b.format.fragments, Year4Digit)
return b
}
// Year2 adds a 2-digit year (e.g., 26) to the format.
func (b *Builder) Year2() *Builder {
b.format.fragments = append(b.format.fragments, Year2Digit)
return b
}
// MonthNumeric adds a numeric month without leading zero (1-12) to the format.
func (b *Builder) MonthNumeric() *Builder {
b.format.fragments = append(b.format.fragments, MonthNumeric)
return b
}
// MonthNumeric2 adds a 2-digit month with leading zero (01-12) to the format.
func (b *Builder) MonthNumeric2() *Builder {
b.format.fragments = append(b.format.fragments, MonthNumeric2)
return b
}
// MonthShort adds an abbreviated month name (Jan, Feb, Mar, etc.) to the format.
func (b *Builder) MonthShort() *Builder {
b.format.fragments = append(b.format.fragments, MonthShort)
return b
}
// MonthFull adds a full month name (January, February, March, etc.) to the format.
func (b *Builder) MonthFull() *Builder {
b.format.fragments = append(b.format.fragments, MonthFull)
return b
}
// DayNumeric adds a numeric day without leading zero (1-31) to the format.
func (b *Builder) DayNumeric() *Builder {
b.format.fragments = append(b.format.fragments, DayNumeric)
return b
}
// DayNumeric2 adds a 2-digit day with leading zero (01-31) to the format.
func (b *Builder) DayNumeric2() *Builder {
b.format.fragments = append(b.format.fragments, DayNumeric2)
return b
}
// DaySpacePadded adds a space-padded day ( 1-31) to the format.
func (b *Builder) DaySpacePadded() *Builder {
b.format.fragments = append(b.format.fragments, DaySpacePadded)
return b
}
// DayOfYear adds a 3-digit day of year (001-365) to the format.
func (b *Builder) DayOfYear() *Builder {
b.format.fragments = append(b.format.fragments, DayOfYearNumeric)
return b
}
// DayOfYearSpacePadded adds a space-padded day of year ( 1-365) to the format.
func (b *Builder) DayOfYearSpacePadded() *Builder {
b.format.fragments = append(b.format.fragments, DayOfYearSpacePadded)
return b
}
// WeekdayShort adds an abbreviated weekday name (Mon, Tue, Wed, etc.) to the format.
func (b *Builder) WeekdayShort() *Builder {
b.format.fragments = append(b.format.fragments, WeekdayShort)
return b
}
// WeekdayFull adds a full weekday name (Monday, Tuesday, Wednesday, etc.) to the format.
func (b *Builder) WeekdayFull() *Builder {
b.format.fragments = append(b.format.fragments, WeekdayFull)
return b
}
// Hour24 adds a 24-hour format with leading zero (00-23) to the format.
func (b *Builder) Hour24() *Builder {
b.format.fragments = append(b.format.fragments, Hour24)
return b
}
// Hour12 adds a 12-hour format without leading zero (1-12) to the format.
func (b *Builder) Hour12() *Builder {
b.format.fragments = append(b.format.fragments, Hour12)
return b
}
// Hour12Padded adds a 12-hour format with leading zero (01-12) to the format.
func (b *Builder) Hour12Padded() *Builder {
b.format.fragments = append(b.format.fragments, Hour12Padded)
return b
}
// Minute adds a 2-digit minute with leading zero (00-59) to the format.
func (b *Builder) Minute() *Builder {
b.format.fragments = append(b.format.fragments, Minute)
return b
}
// MinuteUnpadded adds a minute without leading zero (0-59) to the format.
func (b *Builder) MinuteUnpadded() *Builder {
b.format.fragments = append(b.format.fragments, MinuteUnpadded)
return b
}
// Second adds a 2-digit second with leading zero (00-59) to the format.
func (b *Builder) Second() *Builder {
b.format.fragments = append(b.format.fragments, Second)
return b
}
// SecondUnpadded adds a second without leading zero (0-59) to the format.
func (b *Builder) SecondUnpadded() *Builder {
b.format.fragments = append(b.format.fragments, SecondUnpadded)
return b
}
// Millisecond adds milliseconds as 3 digits (.000) to the format.
func (b *Builder) Millisecond() *Builder {
b.format.fragments = append(b.format.fragments, Millisecond)
return b
}
// MillisecondTrim adds milliseconds with trailing zeros removed (.999) to the format.
func (b *Builder) MillisecondTrim() *Builder {
b.format.fragments = append(b.format.fragments, MillisecondTrim)
return b
}
// Microsecond adds microseconds as 6 digits (.000000) to the format.
func (b *Builder) Microsecond() *Builder {
b.format.fragments = append(b.format.fragments, Microsecond)
return b
}
// MicrosecondTrim adds microseconds with trailing zeros removed (.999999) to the format.
func (b *Builder) MicrosecondTrim() *Builder {
b.format.fragments = append(b.format.fragments, MicrosecondTrim)
return b
}
// Nanosecond adds nanoseconds as 9 digits (.000000000) to the format.
func (b *Builder) Nanosecond() *Builder {
b.format.fragments = append(b.format.fragments, Nanosecond)
return b
}
// NanosecondTrim adds nanoseconds with trailing zeros removed (.999999999) to the format.
func (b *Builder) NanosecondTrim() *Builder {
b.format.fragments = append(b.format.fragments, NanosecondTrim)
return b
}
// AMPM adds an AM/PM marker in uppercase to the format.
func (b *Builder) AMPM() *Builder {
b.format.fragments = append(b.format.fragments, AMPM)
return b
}
// AMPMLower adds an am/pm marker in lowercase to the format.
func (b *Builder) AMPMLower() *Builder {
b.format.fragments = append(b.format.fragments, AMPMLower)
return b
}
// TimezoneOffset adds a timezone offset as ±HHMM (e.g., -0700) to the format.
func (b *Builder) TimezoneOffset() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneOffset)
return b
}
// TimezoneOffsetColon adds a timezone offset as ±HH:MM (e.g., -07:00) to the format.
func (b *Builder) TimezoneOffsetColon() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneOffsetColon)
return b
}
// TimezoneOffsetHourOnly adds a timezone offset hours only as ±HH (e.g., -07) to the format.
func (b *Builder) TimezoneOffsetHourOnly() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneOffsetHourOnly)
return b
}
// TimezoneOffsetSeconds adds a timezone offset with seconds as ±HHMMSS (e.g., -070000) to the format.
func (b *Builder) TimezoneOffsetSeconds() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneOffsetSeconds)
return b
}
// TimezoneOffsetColonSeconds adds a timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00) to the format.
func (b *Builder) TimezoneOffsetColonSeconds() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneOffsetColonSeconds)
return b
}
// TimezoneISO8601 adds an ISO 8601 timezone with Z for UTC (e.g., Z or -0700) to the format.
func (b *Builder) TimezoneISO8601() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneISO8601)
return b
}
// TimezoneISO8601Colon adds an ISO 8601 timezone with colon (e.g., Z or -07:00) to the format.
func (b *Builder) TimezoneISO8601Colon() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneISO8601Colon)
return b
}
// TimezoneName adds a timezone abbreviation (e.g., MST, PST) to the format.
func (b *Builder) TimezoneName() *Builder {
b.format.fragments = append(b.format.fragments, TimezoneName)
return b
}
// Literal adds arbitrary literal text to the format.
func (b *Builder) Literal(s string) *Builder {
b.format.fragments = append(b.format.fragments, s)
return b
}
// Dash adds a dash (-) to the format.
func (b *Builder) Dash() *Builder {
return b.Literal("-")
}
// Slash adds a slash (/) to the format.
func (b *Builder) Slash() *Builder {
return b.Literal("/")
}
// Colon adds a colon (:) to the format.
func (b *Builder) Colon() *Builder {
return b.Literal(":")
}
// Space adds a space ( ) to the format.
func (b *Builder) Space() *Builder {
return b.Literal(" ")
}
// T adds a literal 'T' to the format (commonly used in ISO 8601).
func (b *Builder) T() *Builder {
return b.Literal("T")
}
// Comma adds a comma and space (, ) to the format.
func (b *Builder) Comma() *Builder {
return b.Literal(", ")
}
// Period adds a period (.) to the format.
func (b *Builder) Period() *Builder {
return b.Literal(".")
}
// Build finalizes the builder and returns the constructed Format.
func (b *Builder) Build() *Format {
return b.format
}

346
builder_test.go Normal file
View File

@@ -0,0 +1,346 @@
package timefmt
import (
"testing"
"time"
)
func TestBuilder_BasicFormats(t *testing.T) {
tests := []struct {
name string
builder func() *Builder
wantGo string
wantLDML string
}{
{
name: "ISO 8601 date",
builder: func() *Builder {
return NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2()
},
wantGo: "2006-01-02",
wantLDML: "yyyy-MM-dd",
},
{
name: "24-hour time",
builder: func() *Builder {
return NewBuilder().
Hour24().Colon().Minute().Colon().Second()
},
wantGo: "15:04:05",
wantLDML: "HH:mm:ss",
},
{
name: "Full datetime",
builder: func() *Builder {
return NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Space().
Hour24().Colon().Minute().Colon().Second()
},
wantGo: "2006-01-02 15:04:05",
wantLDML: "yyyy-MM-dd HH:mm:ss",
},
{
name: "12-hour with AM/PM",
builder: func() *Builder {
return NewBuilder().
Hour12().Colon().Minute().Space().AMPM()
},
wantGo: "3:04 PM",
wantLDML: "h:mm a",
},
{
name: "US date format",
builder: func() *Builder {
return NewBuilder().
MonthNumeric2().Slash().DayNumeric2().Slash().Year4()
},
wantGo: "01/02/2006",
wantLDML: "MM/dd/yyyy",
},
{
name: "European date format",
builder: func() *Builder {
return NewBuilder().
DayNumeric2().Slash().MonthNumeric2().Slash().Year4()
},
wantGo: "02/01/2006",
wantLDML: "dd/MM/yyyy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format := tt.builder().Build()
gotGo := format.GoFormat()
if gotGo != tt.wantGo {
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
}
gotLDML := format.LDML()
if gotLDML != tt.wantLDML {
t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML)
}
})
}
}
func TestBuilder_ChainableMethods(t *testing.T) {
// Test that all methods return *Builder for chaining
builder := NewBuilder()
result := builder.
Year4().Year2().
MonthNumeric().MonthNumeric2().MonthShort().MonthFull().
DayNumeric().DayNumeric2().DaySpacePadded().DayOfYear().
WeekdayShort().WeekdayFull().
Hour24().Hour12().Hour12Padded().
Minute().MinuteUnpadded().
Second().SecondUnpadded().
Millisecond().Microsecond().Nanosecond().
AMPM().AMPMLower().
TimezoneOffset().TimezoneOffsetColon().TimezoneName().
Literal("test").Dash().Slash().Colon().Space().T().Comma().Period()
if result == nil {
t.Error("Builder methods should return *Builder for chaining")
}
// Verify it's the same builder instance (mutable pattern)
if result != builder {
t.Error("Builder methods should return the same builder instance")
}
}
func TestBuilder_LiteralShortcuts(t *testing.T) {
tests := []struct {
name string
builder func() *Builder
want string
}{
{
name: "Dash",
builder: func() *Builder { return NewBuilder().Dash() },
want: "-",
},
{
name: "Slash",
builder: func() *Builder { return NewBuilder().Slash() },
want: "/",
},
{
name: "Colon",
builder: func() *Builder { return NewBuilder().Colon() },
want: ":",
},
{
name: "Space",
builder: func() *Builder { return NewBuilder().Space() },
want: " ",
},
{
name: "T",
builder: func() *Builder { return NewBuilder().T() },
want: "T",
},
{
name: "Comma",
builder: func() *Builder { return NewBuilder().Comma() },
want: ", ",
},
{
name: "Period",
builder: func() *Builder { return NewBuilder().Period() },
want: ".",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format := tt.builder().Build()
got := format.GoFormat()
if got != tt.want {
t.Errorf("GoFormat() = %q, want %q", got, tt.want)
}
})
}
}
func TestBuilder_ComplexFormat(t *testing.T) {
// Build a complex format with multiple components
format := NewBuilder().
WeekdayFull().Comma().
MonthFull().Space().DayNumeric().Comma().Year4().
Space().Literal("at").Space().
Hour12().Colon().Minute().Space().AMPM().
Space().Literal("(").TimezoneName().Literal(")").
Build()
wantGo := "Monday, January 2, 2006 at 3:04 PM (MST)"
gotGo := format.GoFormat()
if gotGo != wantGo {
t.Errorf("GoFormat() = %q, want %q", gotGo, wantGo)
}
// Test that it actually formats correctly
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.FixedZone("MST", -7*3600))
formatted := format.Format(testTime)
expected := "Sunday, February 8, 2026 at 3:04 PM (MST)"
if formatted != expected {
t.Errorf("Format() = %q, want %q", formatted, expected)
}
}
func TestPrebuiltFormats(t *testing.T) {
tests := []struct {
name string
format *Format
wantGo string
}{
{
name: "ISO8601",
format: ISO8601,
wantGo: "2006-01-02T15:04:05Z07:00",
},
{
name: "RFC3339",
format: RFC3339,
wantGo: "2006-01-02T15:04:05Z07:00",
},
{
name: "RFC3339Nano",
format: RFC3339Nano,
wantGo: "2006-01-02T15:04:05.999999999Z07:00",
},
{
name: "DateOnly",
format: DateOnly,
wantGo: "2006-01-02",
},
{
name: "TimeOnly",
format: TimeOnly,
wantGo: "15:04:05",
},
{
name: "DateTime",
format: DateTime,
wantGo: "2006-01-02 15:04:05",
},
{
name: "DateTimeWithMillis",
format: DateTimeWithMillis,
wantGo: "2006-01-02 15:04:05.000",
},
{
name: "DateUS",
format: DateUS,
wantGo: "01/02/2006",
},
{
name: "DateEU",
format: DateEU,
wantGo: "02/01/2006",
},
{
name: "DateTimeUS",
format: DateTimeUS,
wantGo: "01/02/2006 3:04:05 PM",
},
{
name: "DateTimeEU",
format: DateTimeEU,
wantGo: "02/01/2006 15:04:05",
},
{
name: "Kitchen",
format: Kitchen,
wantGo: "3:04 PM",
},
{
name: "Stamp",
format: Stamp,
wantGo: "Jan _2 15:04:05",
},
{
name: "StampMilli",
format: StampMilli,
wantGo: "Jan _2 15:04:05.000",
},
{
name: "StampMicro",
format: StampMicro,
wantGo: "Jan _2 15:04:05.000000",
},
{
name: "StampNano",
format: StampNano,
wantGo: "Jan _2 15:04:05.000000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.format == nil {
t.Fatal("Format is nil")
}
gotGo := tt.format.GoFormat()
if gotGo != tt.wantGo {
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
}
// Verify LDML and Description don't panic
_ = tt.format.LDML()
_ = tt.format.Description()
_ = tt.format.Example()
})
}
}
func TestPrebuiltFormats_Formatting(t *testing.T) {
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC)
tests := []struct {
name string
format *Format
want string
}{
{
name: "DateOnly",
format: DateOnly,
want: "2026-02-08",
},
{
name: "TimeOnly",
format: TimeOnly,
want: "15:04:05",
},
{
name: "DateTime",
format: DateTime,
want: "2026-02-08 15:04:05",
},
{
name: "DateUS",
format: DateUS,
want: "02/08/2026",
},
{
name: "DateEU",
format: DateEU,
want: "08/02/2026",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.Format(testTime)
if got != tt.want {
t.Errorf("Format() = %q, want %q", got, tt.want)
}
})
}
}

57
cmd/demo/demo.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"fmt"
"time"
"git.haelnorr.com/h/timefmt"
)
func main() {
fmt.Println("=== timefmt Demo ===\n")
// 1. Building a custom format
fmt.Println("1. Building a custom format:")
format := timefmt.NewBuilder().
Year4().Dash().
MonthNumeric2().Dash().
DayNumeric2().Space().
Hour24().Colon().
Minute().Colon().
Second().
Build()
fmt.Printf(" Go format: %s\n", format.GoFormat())
fmt.Printf(" LDML: %s\n", format.LDML())
fmt.Printf(" Description: %s\n\n", format.Description())
// 2. Using pre-built formats
fmt.Println("2. Pre-built formats:")
now := time.Now()
fmt.Printf(" ISO8601: %s\n", timefmt.ISO8601.Format(now))
fmt.Printf(" DateTime: %s\n", timefmt.DateTime.Format(now))
fmt.Printf(" DateUS: %s\n", timefmt.DateUS.Format(now))
fmt.Printf(" Kitchen: %s\n\n", timefmt.Kitchen.Format(now))
// 3. Parsing existing Go formats
fmt.Println("3. Parsing an existing Go format:")
parsed, _ := timefmt.ParseGoFormat("02/01/2006")
fmt.Printf(" Input: 02/01/2006\n")
fmt.Printf(" LDML: %s\n", parsed.LDML())
fmt.Printf(" Description: %s\n\n", parsed.Description())
// 4. Complex format example
fmt.Println("4. Complex format with custom text:")
complex := timefmt.NewBuilder().
WeekdayFull().Comma().
MonthFull().Space().
DayNumeric().Comma().
Year4().Space().
Literal("at").Space().
Hour12().Colon().
Minute().Space().
AMPM().
Build()
fmt.Printf(" Formatted: %s\n", complex.Format(now))
fmt.Printf(" LDML: %s\n", complex.LDML())
}

264
constants.go Normal file
View File

@@ -0,0 +1,264 @@
package timefmt
// Fragment represents a single component of a time format.
// It maps between Go's reference time format, LDML tokens, and human-readable descriptions.
type Fragment struct {
// GoFormat is Go's reference time format (e.g., "2006" for 4-digit year)
GoFormat string
// LDML is the Unicode LDML token (e.g., "yyyy" for 4-digit year)
LDML string
// Description is a human-readable English description (e.g., "Year (4-digit)")
Description string
}
// Year fragments represent different year formats
var (
// Year4Digit represents a 4-digit year (e.g., 2026)
Year4Digit = Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"}
// Year2Digit represents a 2-digit year (e.g., 26)
Year2Digit = Fragment{GoFormat: "06", LDML: "yy", Description: "Year (2-digit)"}
)
// Month fragments represent different month formats
var (
// MonthNumeric represents a numeric month without leading zero (1-12)
MonthNumeric = Fragment{GoFormat: "1", LDML: "M", Description: "Month (numeric)"}
// MonthNumeric2 represents a 2-digit month with leading zero (01-12)
MonthNumeric2 = Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"}
// MonthShort represents an abbreviated month name (Jan, Feb, Mar, etc.)
MonthShort = Fragment{GoFormat: "Jan", LDML: "MMM", Description: "Month (abbreviated)"}
// MonthFull represents a full month name (January, February, March, etc.)
MonthFull = Fragment{GoFormat: "January", LDML: "MMMM", Description: "Month (full name)"}
)
// Day fragments represent different day-of-month formats
var (
// DayNumeric represents a numeric day without leading zero (1-31)
DayNumeric = Fragment{GoFormat: "2", LDML: "d", Description: "Day (numeric)"}
// DayNumeric2 represents a 2-digit day with leading zero (01-31)
DayNumeric2 = Fragment{GoFormat: "02", LDML: "dd", Description: "Day (2-digit)"}
// DaySpacePadded represents a space-padded day ( 1-31)
DaySpacePadded = Fragment{GoFormat: "_2", LDML: "d", Description: "Day (space-padded)"}
// DayOfYearNumeric represents the day of year as a 3-digit number (001-365)
DayOfYearNumeric = Fragment{GoFormat: "002", LDML: "DDD", Description: "Day of year (3-digit)"}
// DayOfYearSpacePadded represents the day of year as a space-padded number ( 1-365)
DayOfYearSpacePadded = Fragment{GoFormat: "__2", LDML: "DDD", Description: "Day of year (space-padded)"}
)
// Weekday fragments represent different weekday formats
var (
// WeekdayShort represents an abbreviated weekday name (Mon, Tue, Wed, etc.)
WeekdayShort = Fragment{GoFormat: "Mon", LDML: "EEE", Description: "Weekday (abbreviated)"}
// WeekdayFull represents a full weekday name (Monday, Tuesday, Wednesday, etc.)
WeekdayFull = Fragment{GoFormat: "Monday", LDML: "EEEE", Description: "Weekday (full name)"}
)
// Hour fragments represent different hour formats
var (
// Hour24 represents 24-hour format with leading zero (00-23)
Hour24 = Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"}
// Hour12 represents 12-hour format without leading zero (1-12)
Hour12 = Fragment{GoFormat: "3", LDML: "h", Description: "Hour (12-hour)"}
// Hour12Padded represents 12-hour format with leading zero (01-12)
Hour12Padded = Fragment{GoFormat: "03", LDML: "hh", Description: "Hour (12-hour, 2-digit)"}
)
// Minute fragments represent different minute formats
var (
// Minute represents minutes with leading zero (00-59)
Minute = Fragment{GoFormat: "04", LDML: "mm", Description: "Minute (2-digit)"}
// MinuteUnpadded represents minutes without leading zero (0-59)
MinuteUnpadded = Fragment{GoFormat: "4", LDML: "m", Description: "Minute"}
)
// Second fragments represent different second formats
var (
// Second represents seconds with leading zero (00-59)
Second = Fragment{GoFormat: "05", LDML: "ss", Description: "Second (2-digit)"}
// SecondUnpadded represents seconds without leading zero (0-59)
SecondUnpadded = Fragment{GoFormat: "5", LDML: "s", Description: "Second"}
)
// Subsecond fragments represent fractional seconds
var (
// Millisecond represents milliseconds as 3 digits (.000)
Millisecond = Fragment{GoFormat: ".000", LDML: ".SSS", Description: "Millisecond (3-digit)"}
// MillisecondTrim represents milliseconds with trailing zeros removed (.999)
MillisecondTrim = Fragment{GoFormat: ".999", LDML: ".SSS", Description: "Millisecond (trim zeros)"}
// Microsecond represents microseconds as 6 digits (.000000)
Microsecond = Fragment{GoFormat: ".000000", LDML: ".SSSSSS", Description: "Microsecond (6-digit)"}
// MicrosecondTrim represents microseconds with trailing zeros removed (.999999)
MicrosecondTrim = Fragment{GoFormat: ".999999", LDML: ".SSSSSS", Description: "Microsecond (trim zeros)"}
// Nanosecond represents nanoseconds as 9 digits (.000000000)
Nanosecond = Fragment{GoFormat: ".000000000", LDML: ".SSSSSSSSS", Description: "Nanosecond (9-digit)"}
// NanosecondTrim represents nanoseconds with trailing zeros removed (.999999999)
NanosecondTrim = Fragment{GoFormat: ".999999999", LDML: ".SSSSSSSSS", Description: "Nanosecond (trim zeros)"}
)
// AMPM fragments represent AM/PM markers
var (
// AMPM represents AM/PM in uppercase
AMPM = Fragment{GoFormat: "PM", LDML: "a", Description: "AM/PM (uppercase)"}
// AMPMLower represents am/pm in lowercase
AMPMLower = Fragment{GoFormat: "pm", LDML: "a", Description: "AM/PM (lowercase)"}
)
// Timezone fragments represent different timezone formats
var (
// TimezoneOffset represents timezone offset as ±HHMM (e.g., -0700)
TimezoneOffset = Fragment{GoFormat: "-0700", LDML: "ZZZ", Description: "Timezone offset (±HHMM)"}
// TimezoneOffsetColon represents timezone offset as ±HH:MM (e.g., -07:00)
TimezoneOffsetColon = Fragment{GoFormat: "-07:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM)"}
// TimezoneOffsetHourOnly represents timezone offset hours only as ±HH (e.g., -07)
TimezoneOffsetHourOnly = Fragment{GoFormat: "-07", LDML: "ZZ", Description: "Timezone offset (±HH)"}
// TimezoneOffsetSeconds represents timezone offset with seconds as ±HHMMSS (e.g., -070000)
TimezoneOffsetSeconds = Fragment{GoFormat: "-070000", LDML: "ZZZZ", Description: "Timezone offset (±HHMMSS)"}
// TimezoneOffsetColonSeconds represents timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00)
TimezoneOffsetColonSeconds = Fragment{GoFormat: "-07:00:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM:SS)"}
// TimezoneISO8601 represents ISO 8601 timezone with Z for UTC (e.g., Z or -0700)
TimezoneISO8601 = Fragment{GoFormat: "Z0700", LDML: "ZZZ", Description: "ISO 8601 timezone (Z or ±HHMM)"}
// TimezoneISO8601Colon represents ISO 8601 timezone with colon (e.g., Z or -07:00)
TimezoneISO8601Colon = Fragment{GoFormat: "Z07:00", LDML: "ZZZZZ", Description: "ISO 8601 timezone (Z or ±HH:MM)"}
// TimezoneName represents timezone abbreviation (e.g., MST, PST)
TimezoneName = Fragment{GoFormat: "MST", LDML: "zzz", Description: "Timezone abbreviation"}
)
// Pre-built common formats for convenience.
// These are initialized in init() to avoid initialization cycles.
var (
// ISO8601 represents the ISO 8601 datetime format: 2006-01-02T15:04:05Z07:00
ISO8601 *Format
// RFC3339 represents the RFC 3339 datetime format (same as ISO8601): 2006-01-02T15:04:05Z07:00
RFC3339 *Format
// RFC3339Nano represents the RFC 3339 datetime format with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00
RFC3339Nano *Format
// DateOnly represents a date-only format: 2006-01-02
DateOnly *Format
// TimeOnly represents a time-only format: 15:04:05
TimeOnly *Format
// DateTime represents a simple datetime format: 2006-01-02 15:04:05
DateTime *Format
// DateTimeWithMillis represents datetime with milliseconds: 2006-01-02 15:04:05.000
DateTimeWithMillis *Format
// DateUS represents US date format: 01/02/2006
DateUS *Format
// DateEU represents European date format: 02/01/2006
DateEU *Format
// DateTimeUS represents US datetime format: 01/02/2006 3:04:05 PM
DateTimeUS *Format
// DateTimeEU represents European datetime format: 02/01/2006 15:04:05
DateTimeEU *Format
// Kitchen represents kitchen time format: 3:04 PM
Kitchen *Format
// Stamp represents a timestamp format: Jan _2 15:04:05
Stamp *Format
// StampMilli represents a timestamp with milliseconds: Jan _2 15:04:05.000
StampMilli *Format
// StampMicro represents a timestamp with microseconds: Jan _2 15:04:05.000000
StampMicro *Format
// StampNano represents a timestamp with nanoseconds: Jan _2 15:04:05.000000000
StampNano *Format
)
func init() {
// ISO 8601 / RFC 3339: 2006-01-02T15:04:05Z07:00
ISO8601 = NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
T().
Hour24().Colon().Minute().Colon().Second().
TimezoneISO8601Colon().
Build()
RFC3339 = ISO8601 // RFC 3339 is the same as ISO 8601
// RFC 3339 with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00
RFC3339Nano = NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
T().
Hour24().Colon().Minute().Colon().Second().
NanosecondTrim().
TimezoneISO8601Colon().
Build()
// Date only: 2006-01-02
DateOnly = NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Build()
// Time only: 15:04:05
TimeOnly = NewBuilder().
Hour24().Colon().Minute().Colon().Second().
Build()
// DateTime: 2006-01-02 15:04:05
DateTime = NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Space().
Hour24().Colon().Minute().Colon().Second().
Build()
// DateTime with milliseconds: 2006-01-02 15:04:05.000
DateTimeWithMillis = NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Space().
Hour24().Colon().Minute().Colon().Second().
Millisecond().
Build()
// US date: 01/02/2006
DateUS = NewBuilder().
MonthNumeric2().Slash().DayNumeric2().Slash().Year4().
Build()
// European date: 02/01/2006
DateEU = NewBuilder().
DayNumeric2().Slash().MonthNumeric2().Slash().Year4().
Build()
// US datetime: 01/02/2006 3:04:05 PM
DateTimeUS = NewBuilder().
MonthNumeric2().Slash().DayNumeric2().Slash().Year4().
Space().
Hour12().Colon().Minute().Colon().Second().
Space().AMPM().
Build()
// European datetime: 02/01/2006 15:04:05
DateTimeEU = NewBuilder().
DayNumeric2().Slash().MonthNumeric2().Slash().Year4().
Space().
Hour24().Colon().Minute().Colon().Second().
Build()
// Kitchen: 3:04 PM
Kitchen = NewBuilder().
Hour12().Colon().Minute().
Space().AMPM().
Build()
// Stamp: Jan _2 15:04:05
Stamp = NewBuilder().
MonthShort().Space().DaySpacePadded().Space().
Hour24().Colon().Minute().Colon().Second().
Build()
// Stamp with milliseconds: Jan _2 15:04:05.000
StampMilli = NewBuilder().
MonthShort().Space().DaySpacePadded().Space().
Hour24().Colon().Minute().Colon().Second().
Millisecond().
Build()
// Stamp with microseconds: Jan _2 15:04:05.000000
StampMicro = NewBuilder().
MonthShort().Space().DaySpacePadded().Space().
Hour24().Colon().Minute().Colon().Second().
Microsecond().
Build()
// Stamp with nanoseconds: Jan _2 15:04:05.000000000
StampNano = NewBuilder().
MonthShort().Space().DaySpacePadded().Space().
Hour24().Colon().Minute().Colon().Second().
Nanosecond().
Build()
}

143
converter.go Normal file
View File

@@ -0,0 +1,143 @@
package timefmt
import (
"fmt"
"strings"
)
// ParseGoFormat parses a Go time format string and returns a Format.
// It analyzes the Go reference time format (e.g., "2006-01-02 15:04:05")
// and converts it into a Format with the appropriate fragments.
//
// Example:
//
// format, err := ParseGoFormat("2006-01-02 15:04:05")
// if err != nil {
// log.Fatal(err)
// }
// fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss"
// fmt.Println(format.Description()) // Full English description
func ParseGoFormat(goFormat string) (*Format, error) {
if goFormat == "" {
return nil, fmt.Errorf("empty format string")
}
// Define token mappings in order of precedence to avoid partial matches
// CRITICAL: Patterns that could conflict must be ordered carefully!
// - Longer patterns before shorter
// - More specific patterns before general
// - Unique identifiers (like "15" for hour, "04" for minute) before ambiguous ones
tokens := []struct {
goToken string
fragment Fragment
}{
// Subseconds (must come before other number patterns due to decimal point)
{".000000000", Nanosecond},
{".999999999", NanosecondTrim},
{".000000", Microsecond},
{".999999", MicrosecondTrim},
{".000", Millisecond},
{".999", MillisecondTrim},
// Timezone (longer patterns first)
{"-07:00:00", TimezoneOffsetColonSeconds},
{"-070000", TimezoneOffsetSeconds},
{"Z07:00", TimezoneISO8601Colon},
{"Z0700", TimezoneISO8601},
{"-07:00", TimezoneOffsetColon},
{"-0700", TimezoneOffset},
{"-07", TimezoneOffsetHourOnly},
{"MST", TimezoneName},
// Year (must come before month numbers)
{"2006", Year4Digit},
{"06", Year2Digit},
// Month names (before numeric months to avoid conflicts)
{"January", MonthFull},
{"Jan", MonthShort},
// Weekday names (before numeric days)
{"Monday", WeekdayFull},
{"Mon", WeekdayShort},
// Day of year (before regular days, longer patterns first)
{"__2", DayOfYearSpacePadded},
{"002", DayOfYearNumeric},
// Time components (MUST come before month/day numbers to avoid conflicts!)
// "15" is unique to 24-hour format, "04" is unique to minutes, "05" to seconds
{"15", Hour24}, // Must come before "1" (month) and "5" (second)
{"04", Minute}, // Must come before "4" (minute unpadded)
{"05", Second}, // Must come before "5" (second unpadded)
{"03", Hour12Padded}, // Must come before "3" (hour)
// Month and day numbers (after time components!)
{"01", MonthNumeric2}, // Padded month
{"02", DayNumeric2}, // Padded day
{"_2", DaySpacePadded}, // Space-padded day
// Single digit patterns (LAST to avoid premature matching!)
{"1", MonthNumeric},
{"2", DayNumeric},
{"3", Hour12},
{"4", MinuteUnpadded},
{"5", SecondUnpadded},
// AM/PM
{"PM", AMPM},
{"pm", AMPMLower},
}
var fragments []interface{}
i := 0
for i < len(goFormat) {
matched := false
// Try to match tokens (longest first)
for _, token := range tokens {
if strings.HasPrefix(goFormat[i:], token.goToken) {
fragments = append(fragments, token.fragment)
i += len(token.goToken)
matched = true
break
}
}
if !matched {
// This is a literal character
// Collect consecutive literal characters
literalStart := i
i++
// Continue collecting until we hit a token
for i < len(goFormat) {
foundToken := false
for _, token := range tokens {
if strings.HasPrefix(goFormat[i:], token.goToken) {
foundToken = true
break
}
}
if foundToken {
break
}
i++
}
literal := goFormat[literalStart:i]
fragments = append(fragments, literal)
}
}
return &Format{fragments: fragments}, nil
}
// MustParseGoFormat is like ParseGoFormat but panics on error.
// It's useful for initialization of package-level variables.
func MustParseGoFormat(goFormat string) *Format {
format, err := ParseGoFormat(goFormat)
if err != nil {
panic(fmt.Sprintf("MustParseGoFormat: %v", err))
}
return format
}

302
converter_test.go Normal file
View File

@@ -0,0 +1,302 @@
package timefmt
import (
"testing"
"time"
)
func TestParseGoFormat(t *testing.T) {
tests := []struct {
name string
goFormat string
wantLDML string
wantDesc string
shouldError bool
}{
{
name: "ISO 8601 date",
goFormat: "2006-01-02",
wantLDML: "yyyy-MM-dd",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)",
},
{
name: "24-hour time",
goFormat: "15:04:05",
wantLDML: "HH:mm:ss",
wantDesc: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
},
{
name: "Full datetime",
goFormat: "2006-01-02 15:04:05",
wantLDML: "yyyy-MM-dd HH:mm:ss",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
},
{
name: "12-hour with AM/PM",
goFormat: "3:04 PM",
wantLDML: "h:mm a",
wantDesc: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)",
},
{
name: "US date format",
goFormat: "01/02/2006",
wantLDML: "MM/dd/yyyy",
wantDesc: "Month (2-digit), slash, Day (2-digit), slash, Year (4-digit)",
},
{
name: "European date format",
goFormat: "02/01/2006",
wantLDML: "dd/MM/yyyy",
wantDesc: "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)",
},
{
name: "RFC3339",
goFormat: "2006-01-02T15:04:05Z07:00",
wantLDML: "yyyy-MM-dd'T'HH:mm:ssZZZZZ",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), ISO 8601 timezone (Z or ±HH:MM)",
},
{
name: "With milliseconds",
goFormat: "2006-01-02 15:04:05.000",
wantLDML: "yyyy-MM-dd HH:mm:ss.SSS",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Millisecond (3-digit)",
},
{
name: "With nanoseconds",
goFormat: "2006-01-02 15:04:05.000000000",
wantLDML: "yyyy-MM-dd HH:mm:ss.SSSSSSSSS",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Nanosecond (9-digit)",
},
{
name: "Month names",
goFormat: "January 2, 2006",
wantLDML: "MMMM d, yyyy",
wantDesc: "Month (full name), space, Day (numeric), comma-space, Year (4-digit)",
},
{
name: "Abbreviated month and weekday",
goFormat: "Mon, Jan 2 2006",
wantLDML: "EEE, MMM d yyyy",
wantDesc: "Weekday (abbreviated), comma-space, Month (abbreviated), space, Day (numeric), space, Year (4-digit)",
},
{
name: "Full weekday and month",
goFormat: "Monday, January 2, 2006",
wantLDML: "EEEE, MMMM d, yyyy",
wantDesc: "Weekday (full name), comma-space, Month (full name), space, Day (numeric), comma-space, Year (4-digit)",
},
{
name: "With timezone name",
goFormat: "2006-01-02 15:04:05 MST",
wantLDML: "yyyy-MM-dd HH:mm:ss zzz",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation",
},
{
name: "With timezone offset",
goFormat: "2006-01-02 15:04:05 -0700",
wantLDML: "yyyy-MM-dd HH:mm:ss ZZZ",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone offset (±HHMM)",
},
{
name: "Kitchen time",
goFormat: "3:04PM",
wantLDML: "h:mma",
wantDesc: "Hour (12-hour), colon, Minute (2-digit), AM/PM (uppercase)",
},
{
name: "With literal text",
goFormat: "2006-01-02 at 15:04",
wantLDML: "yyyy-MM-dd' at 'HH:mm",
wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal ' at ', Hour (24-hour, 2-digit), colon, Minute (2-digit)",
},
{
name: "Space-padded day",
goFormat: "Jan _2 15:04:05",
wantLDML: "MMM d HH:mm:ss",
wantDesc: "Month (abbreviated), space, Day (space-padded), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
},
{
name: "Empty string",
goFormat: "",
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format, err := ParseGoFormat(tt.goFormat)
if tt.shouldError {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("ParseGoFormat() error = %v", err)
}
// Verify the format can reproduce the original Go format
gotGoFormat := format.GoFormat()
if gotGoFormat != tt.goFormat {
t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat)
}
// Verify LDML conversion
gotLDML := format.LDML()
if gotLDML != tt.wantLDML {
t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML)
}
// Verify description
gotDesc := format.Description()
if gotDesc != tt.wantDesc {
t.Errorf("Description() = %q, want %q", gotDesc, tt.wantDesc)
}
})
}
}
func TestParseGoFormat_StdlibFormats(t *testing.T) {
// Test that we can parse all the standard library time format constants
tests := []struct {
name string
goFormat string
}{
{"time.ANSIC", "Mon Jan _2 15:04:05 2006"},
{"time.UnixDate", "Mon Jan _2 15:04:05 MST 2006"},
{"time.RubyDate", "Mon Jan 02 15:04:05 -0700 2006"},
{"time.RFC822", "02 Jan 06 15:04 MST"},
{"time.RFC822Z", "02 Jan 06 15:04 -0700"},
{"time.RFC850", "Monday, 02-Jan-06 15:04:05 MST"},
{"time.RFC1123", "Mon, 02 Jan 2006 15:04:05 MST"},
{"time.RFC1123Z", "Mon, 02 Jan 2006 15:04:05 -0700"},
{"time.RFC3339", "2006-01-02T15:04:05Z07:00"},
{"time.RFC3339Nano", "2006-01-02T15:04:05.999999999Z07:00"},
{"time.Kitchen", "3:04PM"},
{"time.Stamp", "Jan _2 15:04:05"},
{"time.StampMilli", "Jan _2 15:04:05.000"},
{"time.StampMicro", "Jan _2 15:04:05.000000"},
{"time.StampNano", "Jan _2 15:04:05.000000000"},
{"time.DateTime", "2006-01-02 15:04:05"},
{"time.DateOnly", "2006-01-02"},
{"time.TimeOnly", "15:04:05"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format, err := ParseGoFormat(tt.goFormat)
if err != nil {
t.Fatalf("ParseGoFormat() error = %v", err)
}
// Verify roundtrip
gotGoFormat := format.GoFormat()
if gotGoFormat != tt.goFormat {
t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat)
}
// Verify it can actually format a time
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.FixedZone("MST", -7*3600))
formatted := format.Format(testTime)
if formatted == "" {
t.Error("Format() returned empty string")
}
// Verify LDML and Description don't panic
_ = format.LDML()
_ = format.Description()
})
}
}
func TestParseGoFormat_RoundTrip(t *testing.T) {
// Test that parsing a format and converting back to Go format is lossless
formats := []string{
"2006-01-02",
"15:04:05",
"2006-01-02 15:04:05",
"01/02/2006 3:04:05 PM",
"Monday, January 2, 2006",
"Jan _2 15:04:05.000",
"2006-01-02T15:04:05Z07:00",
"02 Jan 06 15:04 MST",
}
for _, original := range formats {
t.Run(original, func(t *testing.T) {
format, err := ParseGoFormat(original)
if err != nil {
t.Fatalf("ParseGoFormat() error = %v", err)
}
result := format.GoFormat()
if result != original {
t.Errorf("Round trip failed: got %q, want %q", result, original)
}
})
}
}
func TestMustParseGoFormat(t *testing.T) {
t.Run("Valid format", func(t *testing.T) {
// Should not panic
format := MustParseGoFormat("2006-01-02")
if format == nil {
t.Error("MustParseGoFormat returned nil")
}
})
t.Run("Invalid format panics", func(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustParseGoFormat should panic on empty string")
}
}()
MustParseGoFormat("")
})
}
func TestParseGoFormat_EdgeCases(t *testing.T) {
tests := []struct {
name string
goFormat string
wantGo string
}{
{
name: "Consecutive literals",
goFormat: "2006-01-02T15:04:05",
wantGo: "2006-01-02T15:04:05",
},
{
name: "Just literals",
goFormat: "Hello, World!",
wantGo: "Hello, World!",
},
{
name: "Mixed tokens and literals",
goFormat: "Year: 2006, Month: 01",
wantGo: "Year: 2006, Month: 01",
},
{
name: "Special characters",
goFormat: "2006/01/02 @ 15:04:05",
wantGo: "2006/01/02 @ 15:04:05",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
format, err := ParseGoFormat(tt.goFormat)
if err != nil {
t.Fatalf("ParseGoFormat() error = %v", err)
}
gotGo := format.GoFormat()
if gotGo != tt.wantGo {
t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo)
}
})
}
}

86
doc.go Normal file
View File

@@ -0,0 +1,86 @@
// Package timefmt provides tools for building and converting time format strings
// with human-readable descriptions.
//
// It offers a fluent builder API for constructing time formats and can convert
// between Go's reference time format, LDML (Unicode Locale Data Markup Language)
// tokens, and plain English descriptions.
//
// # Basic Usage
//
// Build a time format using the fluent builder:
//
// format := timefmt.NewBuilder().
// Year4().Dash().
// MonthNumeric2().Dash().
// DayNumeric2().
// Build()
//
// fmt.Println(format.GoFormat()) // "2006-01-02"
// fmt.Println(format.LDML()) // "yyyy-MM-dd"
// fmt.Println(format.Description()) // "Year (4-digit), dash, ..."
//
// # Pre-built Formats
//
// Common formats are available as package-level constants:
//
// now := time.Now()
// fmt.Println(timefmt.ISO8601.Format(now))
// fmt.Println(timefmt.DateTime.Format(now))
// fmt.Println(timefmt.DateUS.Format(now))
//
// # Parsing Existing Formats
//
// Parse an existing Go time format string to get human-readable descriptions:
//
// format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05")
// if err != nil {
// log.Fatal(err)
// }
//
// fmt.Println(format.LDML()) // "dd/MM/yyyy HH:mm:ss"
// fmt.Println(format.Description()) // Full English description
//
// # Multiple Output Formats
//
// Every Format provides three representations:
//
// - GoFormat(): Go's reference time format (e.g., "2006-01-02")
// - LDML(): Unicode LDML tokens (e.g., "yyyy-MM-dd")
// - Description(): Plain English description
//
// This makes it easy to document time formats for both developers and end users.
//
// # Use Cases
//
// Display format information to users:
//
// format := timefmt.DateOnly
// fmt.Printf("Expected format: %s\n", format.Description())
// fmt.Printf("Example: %s\n", format.Example())
//
// Validate user input with helpful error messages:
//
// if _, err := format.Parse(userInput); err != nil {
// return fmt.Errorf("invalid date. Expected: %s (example: %s)",
// format.Description(), format.Example())
// }
//
// Generate documentation for configuration:
//
// for name, fmt := range configFormats {
// fmt.Printf("%s: %s (LDML: %s)\n",
// name, fmt.Description(), fmt.LDML())
// }
//
// # LDML Compatibility
//
// The LDML output follows the Unicode LDML standard, making it compatible with:
//
// - ICU (International Components for Unicode)
// - Java (SimpleDateFormat, DateTimeFormatter)
// - Swift (DateFormatter)
// - JavaScript (Moment.js, Day.js, date-fns)
// - Most modern date/time libraries
//
// This allows seamless integration with systems that use LDML for time formatting.
package timefmt

189
examples/example_test.go Normal file
View File

@@ -0,0 +1,189 @@
package examples
import (
"fmt"
"log"
"time"
"git.haelnorr.com/h/timefmt"
)
// Example demonstrating basic usage of the builder
func Example_basic() {
format := timefmt.NewBuilder().
Year4().Dash().
MonthNumeric2().Dash().
DayNumeric2().
Build()
fmt.Println("Go format:", format.GoFormat())
fmt.Println("LDML:", format.LDML())
fmt.Println("Description:", format.Description())
// Output:
// Go format: 2006-01-02
// LDML: yyyy-MM-dd
// Description: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)
}
// Example showing how to use pre-built formats
func Example_prebuilt() {
now := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
fmt.Println("ISO8601:", timefmt.ISO8601.Format(now))
fmt.Println("DateOnly:", timefmt.DateOnly.Format(now))
fmt.Println("DateTime:", timefmt.DateTime.Format(now))
fmt.Println("DateUS:", timefmt.DateUS.Format(now))
fmt.Println("Kitchen:", timefmt.Kitchen.Format(now))
// Output:
// ISO8601: 2026-02-08T15:04:05Z
// DateOnly: 2026-02-08
// DateTime: 2026-02-08 15:04:05
// DateUS: 02/08/2026
// Kitchen: 3:04 PM
}
// Example parsing an existing Go format string
func Example_parsing() {
format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05")
if err != nil {
log.Fatal(err)
}
fmt.Println("LDML:", format.LDML())
fmt.Println("Description:", format.Description())
// Output:
// LDML: dd/MM/yyyy HH:mm:ss
// Description: Day (2-digit), slash, Month (2-digit), slash, Year (4-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)
}
// Example creating a complex custom format
func Example_complex() {
format := timefmt.NewBuilder().
WeekdayFull().Comma().
MonthFull().Space().
DayNumeric().Comma().
Year4().Space().
Literal("at").Space().
Hour12().Colon().
Minute().Space().
AMPM().
Build()
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
fmt.Println("Formatted:", format.Format(testTime))
fmt.Println("LDML:", format.LDML())
// Output:
// Formatted: Sunday, February 8, 2026 at 3:04 PM
// LDML: EEEE, MMMM d, yyyy 'at' h:mm a
}
// Example showing format conversion for user documentation
func Example_documentation() {
// Imagine this comes from user configuration
userFormat := "2006-01-02 15:04:05 MST"
format, err := timefmt.ParseGoFormat(userFormat)
if err != nil {
log.Fatal(err)
}
// Display to user
fmt.Println("Your timestamp format:")
fmt.Printf(" Format: %s\n", format.LDML())
fmt.Printf(" Example: %s\n", format.Example())
fmt.Println()
fmt.Println("Detailed breakdown:")
fmt.Printf(" %s\n", format.Description())
// Output:
// Your timestamp format:
// Format: yyyy-MM-dd HH:mm:ss zzz
// Example: 2026-02-08 15:04:05 MST
//
// Detailed breakdown:
// Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation
}
// Example using format fragments directly
func Example_fragments() {
// Create a format using fragments directly
format := timefmt.NewFormat(
timefmt.Year4Digit,
"/",
timefmt.MonthNumeric2,
"/",
timefmt.DayNumeric2,
)
fmt.Println("Go format:", format.GoFormat())
fmt.Println("LDML:", format.LDML())
// Output:
// Go format: 2006/01/02
// LDML: yyyy/MM/dd
}
// Example for form validation error messages
func Example_validation() {
format := timefmt.DateOnly
// Simulated user input validation
userInput := "02-08-2026" // Wrong format
_, err := format.Parse(userInput)
if err != nil {
fmt.Printf("Error: Invalid date format\n")
fmt.Printf("Expected: %s\n", format.Description())
fmt.Printf("Example: %s\n", format.Example())
}
// Output:
// Error: Invalid date format
// Expected: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)
// Example: 2026-02-08
}
// Example showing timezone handling
func Example_timezone() {
format := timefmt.NewBuilder().
Year4().Dash().MonthNumeric2().Dash().DayNumeric2().
Space().
Hour24().Colon().Minute().Colon().Second().
Space().
TimezoneOffsetColon().
Build()
loc := time.FixedZone("EST", -5*3600)
t := time.Date(2026, time.February, 8, 15, 4, 5, 0, loc)
fmt.Println("Formatted:", format.Format(t))
fmt.Println("LDML:", format.LDML())
// Output:
// Formatted: 2026-02-08 15:04:05 -05:00
// LDML: yyyy-MM-dd HH:mm:ss ZZZZZ
}
// Example demonstrating millisecond precision
func Example_subseconds() {
format := timefmt.NewBuilder().
Hour24().Colon().
Minute().Colon().
Second().
Millisecond().
Build()
t := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC)
fmt.Println("Formatted:", format.Format(t))
fmt.Println("Go format:", format.GoFormat())
// Output:
// Formatted: 15:04:05.123
// Go format: 15:04:05.000
}

143
format.go Normal file
View File

@@ -0,0 +1,143 @@
package timefmt
import (
"strings"
"time"
)
// Format represents a time format pattern composed of fragments and literals.
// It can convert between Go's reference time format, LDML tokens, and human-readable descriptions.
type Format struct {
fragments []interface{} // Can be Fragment or string (for literals)
}
// NewFormat creates a new Format from a slice of fragments and literals.
// Each element can be either a Fragment or a string literal.
func NewFormat(fragments ...interface{}) *Format {
return &Format{fragments: fragments}
}
// GoFormat returns the Go reference time format string.
// Example: "2006-01-02 15:04:05"
func (f *Format) GoFormat() string {
var sb strings.Builder
for _, frag := range f.fragments {
switch v := frag.(type) {
case Fragment:
sb.WriteString(v.GoFormat)
case string:
sb.WriteString(v)
}
}
return sb.String()
}
// LDML returns the LDML-style human-readable format string.
// Example: "yyyy-MM-dd HH:mm:ss"
func (f *Format) LDML() string {
var sb strings.Builder
for _, frag := range f.fragments {
switch v := frag.(type) {
case Fragment:
sb.WriteString(v.LDML)
case string:
// Escape literals that might conflict with LDML tokens
// by wrapping them in single quotes if they contain letters
if needsEscaping(v) {
sb.WriteRune('\'')
sb.WriteString(v)
sb.WriteRune('\'')
} else {
sb.WriteString(v)
}
}
}
return sb.String()
}
// Description returns a full English description of the format.
// Example: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)"
func (f *Format) Description() string {
var parts []string
for _, frag := range f.fragments {
switch v := frag.(type) {
case Fragment:
parts = append(parts, v.Description)
case string:
parts = append(parts, describeLiteral(v))
}
}
return strings.Join(parts, ", ")
}
// Format formats a time.Time using this format pattern.
// It uses Go's time.Format internally.
func (f *Format) Format(t time.Time) string {
return t.Format(f.GoFormat())
}
// Parse parses a time string using this format pattern.
// It uses Go's time.Parse internally.
func (f *Format) Parse(value string) (time.Time, error) {
return time.Parse(f.GoFormat(), value)
}
// ParseInLocation parses a time string using this format pattern in the specified location.
// It uses Go's time.ParseInLocation internally.
func (f *Format) ParseInLocation(value string, loc *time.Location) (time.Time, error) {
return time.ParseInLocation(f.GoFormat(), value, loc)
}
// Example returns an example of what this format looks like using a reference time.
// The reference time used is: February 8, 2026 at 15:04:05.999999999 in UTC-7
func (f *Format) Example() string {
// Use a specific reference time that shows all components clearly
referenceTime := time.Date(2026, time.February, 8, 15, 4, 5, 999999999, time.FixedZone("MST", -7*3600))
return f.Format(referenceTime)
}
// Fragments returns a copy of the format's fragments slice.
// This is useful for inspecting the format's structure.
func (f *Format) Fragments() []interface{} {
result := make([]interface{}, len(f.fragments))
copy(result, f.fragments)
return result
}
// needsEscaping returns true if a literal string contains letters that might
// be confused with LDML format tokens and should be escaped with quotes.
func needsEscaping(s string) bool {
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
return true
}
}
return false
}
// describeLiteral converts a literal string into a human-readable description.
func describeLiteral(s string) string {
switch s {
case "-":
return "dash"
case "/":
return "slash"
case ":":
return "colon"
case " ":
return "space"
case ".":
return "period"
case ",":
return "comma"
case ", ":
return "comma-space"
case "T":
return "literal 'T'"
case "Z":
return "literal 'Z'"
default:
// For other literals, describe as "literal 'X'"
return "literal '" + s + "'"
}
}

480
format_test.go Normal file
View File

@@ -0,0 +1,480 @@
package timefmt
import (
"testing"
"time"
)
func TestFormat_GoFormat(t *testing.T) {
tests := []struct {
name string
format *Format
want string
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
want: "2006-01-02",
},
{
name: "24-hour time",
format: NewFormat(
Hour24,
":",
Minute,
":",
Second,
),
want: "15:04:05",
},
{
name: "Full datetime",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
" ",
Hour24,
":",
Minute,
":",
Second,
),
want: "2006-01-02 15:04:05",
},
{
name: "12-hour with AM/PM",
format: NewFormat(
Hour12,
":",
Minute,
" ",
AMPM,
),
want: "3:04 PM",
},
{
name: "Full date with weekday and month name",
format: NewFormat(
WeekdayFull,
", ",
MonthFull,
" ",
DayNumeric,
", ",
Year4Digit,
),
want: "Monday, January 2, 2006",
},
{
name: "ISO 8601 with timezone",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
"T",
Hour24,
":",
Minute,
":",
Second,
TimezoneISO8601Colon,
),
want: "2006-01-02T15:04:05Z07:00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.GoFormat()
if got != tt.want {
t.Errorf("GoFormat() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormat_LDML(t *testing.T) {
tests := []struct {
name string
format *Format
want string
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
want: "yyyy-MM-dd",
},
{
name: "24-hour time",
format: NewFormat(
Hour24,
":",
Minute,
":",
Second,
),
want: "HH:mm:ss",
},
{
name: "Full datetime",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
" ",
Hour24,
":",
Minute,
":",
Second,
),
want: "yyyy-MM-dd HH:mm:ss",
},
{
name: "12-hour with AM/PM",
format: NewFormat(
Hour12,
":",
Minute,
" ",
AMPM,
),
want: "h:mm a",
},
{
name: "ISO 8601 with T literal",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
"T",
Hour24,
":",
Minute,
":",
Second,
),
want: "yyyy-MM-dd'T'HH:mm:ss",
},
{
name: "Month name (abbreviated)",
format: NewFormat(
MonthShort,
" ",
DayNumeric2,
", ",
Year4Digit,
),
want: "MMM dd, yyyy",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.LDML()
if got != tt.want {
t.Errorf("LDML() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormat_Description(t *testing.T) {
tests := []struct {
name string
format *Format
want string
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)",
},
{
name: "24-hour time",
format: NewFormat(
Hour24,
":",
Minute,
":",
Second,
),
want: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)",
},
{
name: "12-hour with AM/PM",
format: NewFormat(
Hour12,
":",
Minute,
" ",
AMPM,
),
want: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)",
},
{
name: "Date with literal T",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
"T",
Hour24,
),
want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.Description()
if got != tt.want {
t.Errorf("Description() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormat_Format(t *testing.T) {
// Use a specific test time
testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC)
tests := []struct {
name string
format *Format
want string
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
want: "2026-02-08",
},
{
name: "24-hour time",
format: NewFormat(
Hour24,
":",
Minute,
":",
Second,
),
want: "15:04:05",
},
{
name: "Full datetime",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
" ",
Hour24,
":",
Minute,
":",
Second,
),
want: "2026-02-08 15:04:05",
},
{
name: "Month name",
format: NewFormat(
MonthFull,
" ",
DayNumeric,
", ",
Year4Digit,
),
want: "February 8, 2026",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.Format(testTime)
if got != tt.want {
t.Errorf("Format() = %q, want %q", got, tt.want)
}
})
}
}
func TestFormat_Parse(t *testing.T) {
tests := []struct {
name string
format *Format
input string
wantYear int
wantMonth time.Month
wantDay int
wantHour int
wantMin int
wantSec int
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
input: "2026-02-08",
wantYear: 2026,
wantMonth: time.February,
wantDay: 8,
},
{
name: "Full datetime",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
" ",
Hour24,
":",
Minute,
":",
Second,
),
input: "2026-02-08 15:04:05",
wantYear: 2026,
wantMonth: time.February,
wantDay: 8,
wantHour: 15,
wantMin: 4,
wantSec: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.format.Parse(tt.input)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got.Year() != tt.wantYear {
t.Errorf("Parse() year = %v, want %v", got.Year(), tt.wantYear)
}
if got.Month() != tt.wantMonth {
t.Errorf("Parse() month = %v, want %v", got.Month(), tt.wantMonth)
}
if got.Day() != tt.wantDay {
t.Errorf("Parse() day = %v, want %v", got.Day(), tt.wantDay)
}
if got.Hour() != tt.wantHour {
t.Errorf("Parse() hour = %v, want %v", got.Hour(), tt.wantHour)
}
if got.Minute() != tt.wantMin {
t.Errorf("Parse() minute = %v, want %v", got.Minute(), tt.wantMin)
}
if got.Second() != tt.wantSec {
t.Errorf("Parse() second = %v, want %v", got.Second(), tt.wantSec)
}
})
}
}
func TestFormat_Example(t *testing.T) {
tests := []struct {
name string
format *Format
// We'll just check that Example() returns a non-empty string
// The exact output depends on the reference time used
}{
{
name: "ISO 8601 date",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
),
},
{
name: "Full datetime",
format: NewFormat(
Year4Digit,
"-",
MonthNumeric2,
"-",
DayNumeric2,
" ",
Hour24,
":",
Minute,
":",
Second,
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.format.Example()
if got == "" {
t.Errorf("Example() returned empty string")
}
})
}
}
func TestFormat_Fragments(t *testing.T) {
format := NewFormat(
Year4Digit,
"-",
MonthNumeric2,
)
fragments := format.Fragments()
if len(fragments) != 3 {
t.Errorf("Fragments() returned %d fragments, want 3", len(fragments))
}
// Verify it's a copy (modifying it doesn't affect the original)
fragments[0] = "modified"
originalFragments := format.Fragments()
if len(originalFragments) != 3 {
t.Errorf("Original format was modified")
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.haelnorr.com/h/timefmt
go 1.25.6