diff --git a/v/usage.md b/v/usage.md new file mode 100644 index 0000000..5d2bdb0 --- /dev/null +++ b/v/usage.md @@ -0,0 +1,203 @@ +# whenwords for V + +Human-friendly time formatting and parsing for the V programming language. + +## Installation + +Copy the `whenwords.v` file into your project or import it as a module: + +```v +import whenwords +``` + +## Quick start + +```v +import whenwords +import time + +fn main() { + now := time.now().unix() + past := now - 3600 + + // Relative time + println(whenwords.timeago(past, now)) // "1 hour ago" + + // Duration formatting + duration := whenwords.duration(3661, whenwords.DurationOptions{})! + println(duration) // "1 hour, 1 minute" + + // Parse duration strings + seconds := whenwords.parse_duration('2h 30m')! + println(seconds) // 9000 + + // Contextual dates + println(whenwords.human_date(past, now)) // "Today" or relative date + + // Date ranges + start := time.parse('2024-01-01')!.unix() + end := time.parse('2024-01-15')!.unix() + println(whenwords.date_range(start, end)) // "January 1–15, 2024" +} +``` + +## Functions + +### timeago(timestamp i64, reference i64) string + +Returns a human-readable relative time string comparing two Unix timestamps. + +**Parameters:** +- `timestamp`: The time to format (Unix seconds) +- `reference`: The reference time to compare against (Unix seconds) + +**Examples:** +```v +now := 1704067200 +past := now - 3600 +println(whenwords.timeago(past, now)) // "1 hour ago" + +future := now + 7200 +println(whenwords.timeago(future, now)) // "in 2 hours" +``` + +### duration(seconds i64, options DurationOptions) !string + +Formats a duration in seconds as a human-readable string. + +**Parameters:** +- `seconds`: Duration in seconds (must be non-negative) +- `options`: Configuration struct with fields: + - `compact`: If true, uses short format like "2h 30m" (default: false) + - `max_units`: Maximum number of units to display (default: 2) + +**Examples:** +```v +// Standard format +d1 := whenwords.duration(3661, whenwords.DurationOptions{})! +println(d1) // "1 hour, 1 minute" + +// Compact format +d2 := whenwords.duration(3661, whenwords.DurationOptions{ compact: true })! +println(d2) // "1h 1m" + +// Limit units +d3 := whenwords.duration(93661, whenwords.DurationOptions{ max_units: 1 })! +println(d3) // "1 day" +``` + +### parse_duration(input string) !i64 + +Parses a human-written duration string into seconds. + +**Parameters:** +- `input`: Duration string (e.g., "2h30m", "2 hours 30 minutes", "2:30") + +**Supported formats:** +- Compact: `2h30m`, `2h 30m` +- Verbose: `2 hours 30 minutes`, `2 hours and 30 minutes` +- Decimal: `2.5 hours`, `1.5h` +- Colon notation: `2:30` (h:mm), `1:30:00` (h:mm:ss) + +**Unit aliases:** +- seconds: s, sec, secs, second, seconds +- minutes: m, min, mins, minute, minutes +- hours: h, hr, hrs, hour, hours +- days: d, day, days +- weeks: w, wk, wks, week, weeks + +**Examples:** +```v +s1 := whenwords.parse_duration('2h 30m')! +println(s1) // 9000 + +s2 := whenwords.parse_duration('1.5 hours')! +println(s2) // 5400 + +s3 := whenwords.parse_duration('2:30')! +println(s3) // 9000 +``` + +### human_date(timestamp i64, reference i64) string + +Returns a contextual date string based on proximity to the reference date. + +**Parameters:** +- `timestamp`: The date to format (Unix seconds) +- `reference`: The reference date for comparison (Unix seconds) + +**Output format:** +- Same day: "Today" +- Previous day: "Yesterday" +- Next day: "Tomorrow" +- Within past 7 days: "Last {weekday}" +- Within next 7 days: "This {weekday}" +- Same year: "{Month} {day}" +- Different year: "{Month} {day}, {year}" + +**Examples:** +```v +ref := 1705276800 // 2024-01-15 (Monday) + +println(whenwords.human_date(ref, ref)) // "Today" +println(whenwords.human_date(ref - 86400, ref)) // "Yesterday" +println(whenwords.human_date(ref - 2*86400, ref)) // "Last Saturday" +``` + +### date_range(start i64, end i64) string + +Formats a date range with smart abbreviation. + +**Parameters:** +- `start`: Start timestamp (Unix seconds) +- `end`: End timestamp (Unix seconds) + +**Format rules:** +- Same day: "March 5, 2024" +- Same month: "March 5–7, 2024" +- Same year: "March 5 – April 7, 2024" +- Different years: "December 28, 2024 – January 3, 2025" + +**Examples:** +```v +start := 1705276800 // Jan 15, 2024 +end := 1705881600 // Jan 22, 2024 + +println(whenwords.date_range(start, end)) // "January 15–22, 2024" + +// Swapped inputs are auto-corrected +println(whenwords.date_range(end, start)) // "January 15–22, 2024" +``` + +## Error handling + +Functions that can fail return V's Result type (`!string` or `!i64`). Use the `!` operator or explicit error handling: + +```v +// Propagate errors with ! +seconds := whenwords.parse_duration('2h 30m')! + +// Or handle errors explicitly +result := whenwords.parse_duration('invalid') or { + eprintln('Parse error: ${err}') + return +} +``` + +**Error conditions:** +- `duration`: Negative seconds +- `parse_duration`: Empty string, no parseable units, negative values + +## Accepted types + +All timestamp parameters accept Unix seconds as `i64`. To convert from V's `time.Time`: + +```v +import time + +t := time.now() +unix_timestamp := t.unix() +result := whenwords.timeago(unix_timestamp, time.now().unix()) +``` + +All functions are pure and deterministic—they never access the system clock. The reference time must always be passed explicitly. diff --git a/v/whenwords.v b/v/whenwords.v new file mode 100644 index 0000000..93c96d4 --- /dev/null +++ b/v/whenwords.v @@ -0,0 +1,391 @@ +module whenwords + +import time +import math + +pub struct DurationOptions { +pub: + compact bool + max_units int = 2 +} + +// timeago returns a human-readable relative time string +pub fn timeago(timestamp i64, reference i64) string { + diff := reference - timestamp + abs_diff := if diff < 0 { -diff } else { diff } + + // Determine if future or past + future := diff < 0 + + // Convert to appropriate unit and format + mut result := '' + + if abs_diff < 45 { + result = 'just now' + } else if abs_diff < 90 { + result = if future { 'in 1 minute' } else { '1 minute ago' } + } else if abs_diff < 45 * 60 { + minutes := round(f64(abs_diff) / 60.0) + result = if future { 'in ${minutes} minutes' } else { '${minutes} minutes ago' } + } else if abs_diff < 90 * 60 { + result = if future { 'in 1 hour' } else { '1 hour ago' } + } else if abs_diff < 22 * 3600 { + hours := round(f64(abs_diff) / 3600.0) + result = if future { 'in ${hours} hours' } else { '${hours} hours ago' } + } else if abs_diff < 36 * 3600 { + result = if future { 'in 1 day' } else { '1 day ago' } + } else if abs_diff < 26 * 86400 { + days := round(f64(abs_diff) / 86400.0) + result = if future { 'in ${days} days' } else { '${days} days ago' } + } else if abs_diff < 46 * 86400 { + result = if future { 'in 1 month' } else { '1 month ago' } + } else if abs_diff < 320 * 86400 { + months := round(f64(abs_diff) / (30.0 * 86400)) + result = if future { 'in ${months} months' } else { '${months} months ago' } + } else if abs_diff < 548 * 86400 { + result = if future { 'in 1 year' } else { '1 year ago' } + } else { + years := round(f64(abs_diff) / (365.0 * 86400)) + result = if future { 'in ${years} years' } else { '${years} years ago' } + } + + return result +} + +// round rounds a number to the nearest integer using half-up rounding +fn round(x f64) int { + return int(math.floor(x + 0.5)) +} + +// duration formats a duration in seconds to a human-readable string +pub fn duration(seconds i64, options DurationOptions) !string { + if seconds < 0 { + return error('negative seconds not allowed') + } + + if seconds == 0 { + return if options.compact { '0s' } else { '0 seconds' } + } + + mut remaining := seconds + mut parts := []string{} + + // Define units in order: years, months, days, hours, minutes, seconds + units := [ + Unit{'year', 'y', 365 * 86400}, + Unit{'month', 'mo', 30 * 86400}, + Unit{'day', 'd', 86400}, + Unit{'hour', 'h', 3600}, + Unit{'minute', 'm', 60}, + Unit{'second', 's', 1}, + ] + + mut unit_idx := 0 + for unit_idx < units.len { + unit := units[unit_idx] + if remaining >= unit.seconds { + mut count := remaining / unit.seconds + remaining = remaining % unit.seconds + + // If we're at the last unit we can show (max_units), round based on remaining + if parts.len + 1 >= options.max_units && unit_idx + 1 < units.len { + next_unit := units[unit_idx + 1] + next_count := remaining / next_unit.seconds + // Round up if the next unit has >= half of its threshold + if next_count * next_unit.seconds * 2 >= unit.seconds { + count++ + } + remaining = 0 + } + + if options.compact { + parts << '${count}${unit.short}' + } else { + unit_name := if count == 1 { unit.name } else { unit.name + 's' } + parts << '${count} ${unit_name}' + } + + if parts.len >= options.max_units { + break + } + } + unit_idx++ + } + + separator := if options.compact { ' ' } else { ', ' } + return parts.join(separator) +} + +struct Unit { + name string + short string + seconds i64 +} + +// parse_duration parses a human-written duration string into seconds +pub fn parse_duration(input string) !i64 { + trimmed := input.trim_space() + if trimmed.len == 0 { + return error('empty string') + } + + // Check for colon notation first (e.g., "2:30" or "1:30:00") + if trimmed.contains(':') { + return parse_colon_notation(trimmed) + } + + // Parse unit-based durations + mut total := i64(0) + mut found_any := false + + // Convert to lowercase for case-insensitive matching + lower := trimmed.to_lower() + + // Define regex-like patterns manually + // We'll iterate through the string and extract number-unit pairs + mut i := 0 + for i < lower.len { + // Skip whitespace and separators + if lower[i] in [` `, `,`] { + i++ + continue + } + + // Skip "and" + if i + 3 <= lower.len && lower[i..i + 3] == 'and' { + i += 3 + continue + } + + // Try to parse a number + mut num_start := i + mut num_end := i + mut has_decimal := false + + // Check for negative sign + if lower[i] == `-` { + return error('negative values not allowed') + } + + // Parse digits and decimal point + for num_end < lower.len && (lower[num_end].is_digit() || lower[num_end] == `.`) { + if lower[num_end] == `.` { + has_decimal = true + } + num_end++ + } + + if num_end == num_start { + i++ + continue + } + + num_str := lower[num_start..num_end] + value := if has_decimal { num_str.f64() } else { num_str.f64() } + + i = num_end + + // Skip whitespace between number and unit + for i < lower.len && lower[i] == ` ` { + i++ + } + + // Parse unit + mut unit_start := i + mut unit_end := i + for unit_end < lower.len && lower[unit_end].is_letter() { + unit_end++ + } + + if unit_end == unit_start { + // No unit found after number + if !found_any { + return error('no units found') + } + break + } + + unit_str := lower[unit_start..unit_end] + i = unit_end + + // Map unit to seconds + seconds := match unit_str { + 's', 'sec', 'secs', 'second', 'seconds' { i64(value) } + 'm', 'min', 'mins', 'minute', 'minutes' { i64(value * 60) } + 'h', 'hr', 'hrs', 'hour', 'hours' { i64(value * 3600) } + 'd', 'day', 'days' { i64(value * 86400) } + 'w', 'wk', 'wks', 'week', 'weeks' { i64(value * 604800) } + 'mo', 'month', 'months' { i64(value * 2592000) } + 'y', 'year', 'years' { i64(value * 31536000) } + else { return error('unknown unit: ${unit_str}') } + } + + total += seconds + found_any = true + } + + if !found_any { + return error('no valid duration found') + } + + if total < 0 { + return error('negative duration') + } + + return total +} + +fn parse_colon_notation(input string) !i64 { + parts := input.split(':') + if parts.len < 2 || parts.len > 3 { + return error('invalid colon notation') + } + + mut total := i64(0) + + if parts.len == 2 { + // h:mm format + hours := parts[0].int() + minutes := parts[1].int() + total = i64(hours * 3600 + minutes * 60) + } else if parts.len == 3 { + // h:mm:ss format + hours := parts[0].int() + minutes := parts[1].int() + seconds := parts[2].int() + total = i64(hours * 3600 + minutes * 60 + seconds) + } + + return total +} + +// human_date returns a contextual date string +pub fn human_date(timestamp i64, reference i64) string { + // Convert to time.Time for easier date manipulation + t := time.unix(timestamp) + ref := time.unix(reference) + + // Get day boundaries in UTC + t_day := day_start(t) + ref_day := day_start(ref) + + diff_days := (t_day - ref_day) / 86400 + + // Same day + if diff_days == 0 { + return 'Today' + } + + // Yesterday + if diff_days == -1 { + return 'Yesterday' + } + + // Tomorrow + if diff_days == 1 { + return 'Tomorrow' + } + + // Within past 7 days (2-6 days ago) + if diff_days >= -6 && diff_days < -1 { + weekday := get_weekday_name(t) + return 'Last ${weekday}' + } + + // Within next 7 days (2-6 days future) + if diff_days >= 2 && diff_days <= 6 { + weekday := get_weekday_name(t) + return 'This ${weekday}' + } + + // Same year - show month and day + if t.year == ref.year { + month := get_month_name(t) + return '${month} ${t.day}' + } + + // Different year - show month, day, and year + month := get_month_name(t) + return '${month} ${t.day}, ${t.year}' +} + +// day_start returns the Unix timestamp for the start of the day (00:00:00 UTC) +fn day_start(t time.Time) i64 { + // Calculate the start of the day by zeroing hours, minutes, seconds + day_seconds := t.hour * 3600 + t.minute * 60 + t.second + return t.unix() - day_seconds +} + +fn get_weekday_name(t time.Time) string { + // V's time module uses 1=Monday, 2=Tuesday, ..., 7=Sunday + weekday := t.day_of_week() + return match weekday { + 1 { 'Monday' } + 2 { 'Tuesday' } + 3 { 'Wednesday' } + 4 { 'Thursday' } + 5 { 'Friday' } + 6 { 'Saturday' } + 7 { 'Sunday' } + else { 'Unknown' } + } +} + +fn get_month_name(t time.Time) string { + return match t.month { + 1 { 'January' } + 2 { 'February' } + 3 { 'March' } + 4 { 'April' } + 5 { 'May' } + 6 { 'June' } + 7 { 'July' } + 8 { 'August' } + 9 { 'September' } + 10 { 'October' } + 11 { 'November' } + 12 { 'December' } + else { 'Unknown' } + } +} + +// date_range formats a date range with smart abbreviation +pub fn date_range(start i64, end i64) string { + // Swap if start is after end + mut s := start + mut e := end + if s > e { + s, e = e, s + } + + s_time := time.unix(s) + e_time := time.unix(e) + + // Get day boundaries + s_day := day_start(s_time) + e_day := day_start(e_time) + + // Same day + if s_day == e_day { + month := get_month_name(s_time) + return '${month} ${s_time.day}, ${s_time.year}' + } + + // Same month and year + if s_time.year == e_time.year && s_time.month == e_time.month { + month := get_month_name(s_time) + return '${month} ${s_time.day}–${e_time.day}, ${s_time.year}' + } + + // Same year, different months + if s_time.year == e_time.year { + s_month := get_month_name(s_time) + e_month := get_month_name(e_time) + return '${s_month} ${s_time.day} – ${e_month} ${e_time.day}, ${s_time.year}' + } + + // Different years + s_month := get_month_name(s_time) + e_month := get_month_name(e_time) + return '${s_month} ${s_time.day}, ${s_time.year} – ${e_month} ${e_time.day}, ${e_time.year}' +} diff --git a/v/whenwords_test.v b/v/whenwords_test.v new file mode 100644 index 0000000..7c2c42d --- /dev/null +++ b/v/whenwords_test.v @@ -0,0 +1,628 @@ +module main + +import whenwords + +// timeago tests + +fn test_timeago_just_now_identical_timestamps() { + result := whenwords.timeago(1704067200, 1704067200) + assert result == 'just now' +} + +fn test_timeago_just_now_30_seconds_ago() { + result := whenwords.timeago(1704067170, 1704067200) + assert result == 'just now' +} + +fn test_timeago_just_now_44_seconds_ago() { + result := whenwords.timeago(1704067156, 1704067200) + assert result == 'just now' +} + +fn test_timeago_1_minute_ago_45_seconds() { + result := whenwords.timeago(1704067155, 1704067200) + assert result == '1 minute ago' +} + +fn test_timeago_1_minute_ago_89_seconds() { + result := whenwords.timeago(1704067111, 1704067200) + assert result == '1 minute ago' +} + +fn test_timeago_2_minutes_ago_90_seconds() { + result := whenwords.timeago(1704067110, 1704067200) + assert result == '2 minutes ago' +} + +fn test_timeago_30_minutes_ago() { + result := whenwords.timeago(1704065400, 1704067200) + assert result == '30 minutes ago' +} + +fn test_timeago_44_minutes_ago() { + result := whenwords.timeago(1704064560, 1704067200) + assert result == '44 minutes ago' +} + +fn test_timeago_1_hour_ago_45_minutes() { + result := whenwords.timeago(1704064500, 1704067200) + assert result == '1 hour ago' +} + +fn test_timeago_1_hour_ago_89_minutes() { + result := whenwords.timeago(1704061860, 1704067200) + assert result == '1 hour ago' +} + +fn test_timeago_2_hours_ago_90_minutes() { + result := whenwords.timeago(1704061800, 1704067200) + assert result == '2 hours ago' +} + +fn test_timeago_5_hours_ago() { + result := whenwords.timeago(1704049200, 1704067200) + assert result == '5 hours ago' +} + +fn test_timeago_21_hours_ago() { + result := whenwords.timeago(1703991600, 1704067200) + assert result == '21 hours ago' +} + +fn test_timeago_1_day_ago_22_hours() { + result := whenwords.timeago(1703988000, 1704067200) + assert result == '1 day ago' +} + +fn test_timeago_1_day_ago_35_hours() { + result := whenwords.timeago(1703941200, 1704067200) + assert result == '1 day ago' +} + +fn test_timeago_2_days_ago_36_hours() { + result := whenwords.timeago(1703937600, 1704067200) + assert result == '2 days ago' +} + +fn test_timeago_7_days_ago() { + result := whenwords.timeago(1703462400, 1704067200) + assert result == '7 days ago' +} + +fn test_timeago_25_days_ago() { + result := whenwords.timeago(1701907200, 1704067200) + assert result == '25 days ago' +} + +fn test_timeago_1_month_ago_26_days() { + result := whenwords.timeago(1701820800, 1704067200) + assert result == '1 month ago' +} + +fn test_timeago_1_month_ago_45_days() { + result := whenwords.timeago(1700179200, 1704067200) + assert result == '1 month ago' +} + +fn test_timeago_2_months_ago_46_days() { + result := whenwords.timeago(1700092800, 1704067200) + assert result == '2 months ago' +} + +fn test_timeago_6_months_ago() { + result := whenwords.timeago(1688169600, 1704067200) + assert result == '6 months ago' +} + +fn test_timeago_11_months_ago_319_days() { + result := whenwords.timeago(1676505600, 1704067200) + assert result == '11 months ago' +} + +fn test_timeago_1_year_ago_320_days() { + result := whenwords.timeago(1676419200, 1704067200) + assert result == '1 year ago' +} + +fn test_timeago_1_year_ago_547_days() { + result := whenwords.timeago(1656806400, 1704067200) + assert result == '1 year ago' +} + +fn test_timeago_2_years_ago_548_days() { + result := whenwords.timeago(1656720000, 1704067200) + assert result == '2 years ago' +} + +fn test_timeago_5_years_ago() { + result := whenwords.timeago(1546300800, 1704067200) + assert result == '5 years ago' +} + +fn test_timeago_future_in_just_now_30_seconds() { + result := whenwords.timeago(1704067230, 1704067200) + assert result == 'just now' +} + +fn test_timeago_future_in_1_minute() { + result := whenwords.timeago(1704067260, 1704067200) + assert result == 'in 1 minute' +} + +fn test_timeago_future_in_5_minutes() { + result := whenwords.timeago(1704067500, 1704067200) + assert result == 'in 5 minutes' +} + +fn test_timeago_future_in_1_hour() { + result := whenwords.timeago(1704070200, 1704067200) + assert result == 'in 1 hour' +} + +fn test_timeago_future_in_3_hours() { + result := whenwords.timeago(1704078000, 1704067200) + assert result == 'in 3 hours' +} + +fn test_timeago_future_in_1_day() { + result := whenwords.timeago(1704150000, 1704067200) + assert result == 'in 1 day' +} + +fn test_timeago_future_in_2_days() { + result := whenwords.timeago(1704240000, 1704067200) + assert result == 'in 2 days' +} + +fn test_timeago_future_in_1_month() { + result := whenwords.timeago(1706745600, 1704067200) + assert result == 'in 1 month' +} + +fn test_timeago_future_in_1_year() { + result := whenwords.timeago(1735689600, 1704067200) + assert result == 'in 1 year' +} + +// duration tests + +fn test_duration_zero_seconds() { + result := whenwords.duration(0, whenwords.DurationOptions{})! + assert result == '0 seconds' +} + +fn test_duration_1_second() { + result := whenwords.duration(1, whenwords.DurationOptions{})! + assert result == '1 second' +} + +fn test_duration_45_seconds() { + result := whenwords.duration(45, whenwords.DurationOptions{})! + assert result == '45 seconds' +} + +fn test_duration_1_minute() { + result := whenwords.duration(60, whenwords.DurationOptions{})! + assert result == '1 minute' +} + +fn test_duration_1_minute_30_seconds() { + result := whenwords.duration(90, whenwords.DurationOptions{})! + assert result == '1 minute, 30 seconds' +} + +fn test_duration_2_minutes() { + result := whenwords.duration(120, whenwords.DurationOptions{})! + assert result == '2 minutes' +} + +fn test_duration_1_hour() { + result := whenwords.duration(3600, whenwords.DurationOptions{})! + assert result == '1 hour' +} + +fn test_duration_1_hour_1_minute() { + result := whenwords.duration(3661, whenwords.DurationOptions{})! + assert result == '1 hour, 1 minute' +} + +fn test_duration_1_hour_30_minutes() { + result := whenwords.duration(5400, whenwords.DurationOptions{})! + assert result == '1 hour, 30 minutes' +} + +fn test_duration_2_hours_30_minutes() { + result := whenwords.duration(9000, whenwords.DurationOptions{})! + assert result == '2 hours, 30 minutes' +} + +fn test_duration_1_day() { + result := whenwords.duration(86400, whenwords.DurationOptions{})! + assert result == '1 day' +} + +fn test_duration_1_day_2_hours() { + result := whenwords.duration(93600, whenwords.DurationOptions{})! + assert result == '1 day, 2 hours' +} + +fn test_duration_7_days() { + result := whenwords.duration(604800, whenwords.DurationOptions{})! + assert result == '7 days' +} + +fn test_duration_1_month_30_days() { + result := whenwords.duration(2592000, whenwords.DurationOptions{})! + assert result == '1 month' +} + +fn test_duration_1_year_365_days() { + result := whenwords.duration(31536000, whenwords.DurationOptions{})! + assert result == '1 year' +} + +fn test_duration_1_year_2_months() { + result := whenwords.duration(36720000, whenwords.DurationOptions{})! + assert result == '1 year, 2 months' +} + +fn test_duration_compact_1h_1m() { + result := whenwords.duration(3661, whenwords.DurationOptions{ compact: true })! + assert result == '1h 1m' +} + +fn test_duration_compact_2h_30m() { + result := whenwords.duration(9000, whenwords.DurationOptions{ compact: true })! + assert result == '2h 30m' +} + +fn test_duration_compact_1d_2h() { + result := whenwords.duration(93600, whenwords.DurationOptions{ compact: true })! + assert result == '1d 2h' +} + +fn test_duration_compact_45s() { + result := whenwords.duration(45, whenwords.DurationOptions{ compact: true })! + assert result == '45s' +} + +fn test_duration_compact_0s() { + result := whenwords.duration(0, whenwords.DurationOptions{ compact: true })! + assert result == '0s' +} + +fn test_duration_max_units_1_hours_only() { + result := whenwords.duration(3661, whenwords.DurationOptions{ max_units: 1 })! + assert result == '1 hour' +} + +fn test_duration_max_units_1_days_only() { + result := whenwords.duration(93600, whenwords.DurationOptions{ max_units: 1 })! + assert result == '1 day' +} + +fn test_duration_max_units_3() { + result := whenwords.duration(93661, whenwords.DurationOptions{ max_units: 3 })! + assert result == '1 day, 2 hours, 1 minute' +} + +fn test_duration_compact_max_units_1() { + result := whenwords.duration(9000, whenwords.DurationOptions{ compact: true, max_units: 1 })! + assert result == '3h' +} + +fn test_duration_error_negative_seconds() { + whenwords.duration(-100, whenwords.DurationOptions{}) or { return } + assert false, 'should have returned an error' +} + +// parse_duration tests + +fn test_parse_duration_compact_hours_minutes() { + result := whenwords.parse_duration('2h30m')! + assert result == 9000 +} + +fn test_parse_duration_compact_with_space() { + result := whenwords.parse_duration('2h 30m')! + assert result == 9000 +} + +fn test_parse_duration_compact_with_comma() { + result := whenwords.parse_duration('2h, 30m')! + assert result == 9000 +} + +fn test_parse_duration_verbose() { + result := whenwords.parse_duration('2 hours 30 minutes')! + assert result == 9000 +} + +fn test_parse_duration_verbose_with_and() { + result := whenwords.parse_duration('2 hours and 30 minutes')! + assert result == 9000 +} + +fn test_parse_duration_verbose_with_comma_and() { + result := whenwords.parse_duration('2 hours, and 30 minutes')! + assert result == 9000 +} + +fn test_parse_duration_decimal_hours() { + result := whenwords.parse_duration('2.5 hours')! + assert result == 9000 +} + +fn test_parse_duration_decimal_compact() { + result := whenwords.parse_duration('1.5h')! + assert result == 5400 +} + +fn test_parse_duration_single_unit_minutes_verbose() { + result := whenwords.parse_duration('90 minutes')! + assert result == 5400 +} + +fn test_parse_duration_single_unit_minutes_compact() { + result := whenwords.parse_duration('90m')! + assert result == 5400 +} + +fn test_parse_duration_single_unit_min() { + result := whenwords.parse_duration('90min')! + assert result == 5400 +} + +fn test_parse_duration_colon_notation_h_mm() { + result := whenwords.parse_duration('2:30')! + assert result == 9000 +} + +fn test_parse_duration_colon_notation_h_mm_ss() { + result := whenwords.parse_duration('1:30:00')! + assert result == 5400 +} + +fn test_parse_duration_colon_notation_with_seconds() { + result := whenwords.parse_duration('0:05:30')! + assert result == 330 +} + +fn test_parse_duration_days_verbose() { + result := whenwords.parse_duration('2 days')! + assert result == 172800 +} + +fn test_parse_duration_days_compact() { + result := whenwords.parse_duration('2d')! + assert result == 172800 +} + +fn test_parse_duration_weeks_verbose() { + result := whenwords.parse_duration('1 week')! + assert result == 604800 +} + +fn test_parse_duration_weeks_compact() { + result := whenwords.parse_duration('1w')! + assert result == 604800 +} + +fn test_parse_duration_mixed_verbose() { + result := whenwords.parse_duration('1 day, 2 hours, and 30 minutes')! + assert result == 95400 +} + +fn test_parse_duration_mixed_compact() { + result := whenwords.parse_duration('1d 2h 30m')! + assert result == 95400 +} + +fn test_parse_duration_seconds_only_verbose() { + result := whenwords.parse_duration('45 seconds')! + assert result == 45 +} + +fn test_parse_duration_seconds_compact_s() { + result := whenwords.parse_duration('45s')! + assert result == 45 +} + +fn test_parse_duration_seconds_compact_sec() { + result := whenwords.parse_duration('45sec')! + assert result == 45 +} + +fn test_parse_duration_hours_hr() { + result := whenwords.parse_duration('2hr')! + assert result == 7200 +} + +fn test_parse_duration_hours_hrs() { + result := whenwords.parse_duration('2hrs')! + assert result == 7200 +} + +fn test_parse_duration_minutes_mins() { + result := whenwords.parse_duration('30mins')! + assert result == 1800 +} + +fn test_parse_duration_case_insensitive() { + result := whenwords.parse_duration('2H 30M')! + assert result == 9000 +} + +fn test_parse_duration_whitespace_tolerance() { + result := whenwords.parse_duration(' 2 hours 30 minutes ')! + assert result == 9000 +} + +fn test_parse_duration_error_empty_string() { + whenwords.parse_duration('') or { return } + assert false, 'should have returned an error' +} + +fn test_parse_duration_error_no_units() { + whenwords.parse_duration('hello world') or { return } + assert false, 'should have returned an error' +} + +fn test_parse_duration_error_negative() { + whenwords.parse_duration('-5 hours') or { return } + assert false, 'should have returned an error' +} + +fn test_parse_duration_error_just_number() { + whenwords.parse_duration('42') or { return } + assert false, 'should have returned an error' +} + +// human_date tests + +fn test_human_date_today() { + result := whenwords.human_date(1705276800, 1705276800) + assert result == 'Today' +} + +fn test_human_date_today_same_day_different_time() { + result := whenwords.human_date(1705320000, 1705276800) + assert result == 'Today' +} + +fn test_human_date_yesterday() { + result := whenwords.human_date(1705190400, 1705276800) + assert result == 'Yesterday' +} + +fn test_human_date_tomorrow() { + result := whenwords.human_date(1705363200, 1705276800) + assert result == 'Tomorrow' +} + +fn test_human_date_last_sunday_1_day_before_monday() { + result := whenwords.human_date(1705190400, 1705276800) + assert result == 'Yesterday' +} + +fn test_human_date_last_saturday_2_days_ago() { + result := whenwords.human_date(1705104000, 1705276800) + assert result == 'Last Saturday' +} + +fn test_human_date_last_friday_3_days_ago() { + result := whenwords.human_date(1705017600, 1705276800) + assert result == 'Last Friday' +} + +fn test_human_date_last_thursday_4_days_ago() { + result := whenwords.human_date(1704931200, 1705276800) + assert result == 'Last Thursday' +} + +fn test_human_date_last_wednesday_5_days_ago() { + result := whenwords.human_date(1704844800, 1705276800) + assert result == 'Last Wednesday' +} + +fn test_human_date_last_tuesday_6_days_ago() { + result := whenwords.human_date(1704758400, 1705276800) + assert result == 'Last Tuesday' +} + +fn test_human_date_last_monday_7_days_ago_becomes_date() { + result := whenwords.human_date(1704672000, 1705276800) + assert result == 'January 8' +} + +fn test_human_date_this_tuesday_1_day_future() { + result := whenwords.human_date(1705363200, 1705276800) + assert result == 'Tomorrow' +} + +fn test_human_date_this_wednesday_2_days_future() { + result := whenwords.human_date(1705449600, 1705276800) + assert result == 'This Wednesday' +} + +fn test_human_date_this_thursday_3_days_future() { + result := whenwords.human_date(1705536000, 1705276800) + assert result == 'This Thursday' +} + +fn test_human_date_this_sunday_6_days_future() { + result := whenwords.human_date(1705795200, 1705276800) + assert result == 'This Sunday' +} + +fn test_human_date_next_monday_7_days_future_becomes_date() { + result := whenwords.human_date(1705881600, 1705276800) + assert result == 'January 22' +} + +fn test_human_date_same_year_different_month() { + result := whenwords.human_date(1709251200, 1705276800) + assert result == 'March 1' +} + +fn test_human_date_same_year_end_of_year() { + result := whenwords.human_date(1735603200, 1705276800) + assert result == 'December 31' +} + +fn test_human_date_previous_year() { + result := whenwords.human_date(1672531200, 1705276800) + assert result == 'January 1, 2023' +} + +fn test_human_date_next_year() { + result := whenwords.human_date(1736121600, 1705276800) + assert result == 'January 6, 2025' +} + +// date_range tests + +fn test_date_range_same_day() { + result := whenwords.date_range(1705276800, 1705276800) + assert result == 'January 15, 2024' +} + +fn test_date_range_same_day_different_times() { + result := whenwords.date_range(1705276800, 1705320000) + assert result == 'January 15, 2024' +} + +fn test_date_range_consecutive_days_same_month() { + result := whenwords.date_range(1705276800, 1705363200) + assert result == 'January 15–16, 2024' +} + +fn test_date_range_same_month_range() { + result := whenwords.date_range(1705276800, 1705881600) + assert result == 'January 15–22, 2024' +} + +fn test_date_range_same_year_different_months() { + result := whenwords.date_range(1705276800, 1707955200) + assert result == 'January 15 – February 15, 2024' +} + +fn test_date_range_different_years() { + result := whenwords.date_range(1703721600, 1705276800) + assert result == 'December 28, 2023 – January 15, 2024' +} + +fn test_date_range_full_year_span() { + result := whenwords.date_range(1704067200, 1735603200) + assert result == 'January 1 – December 31, 2024' +} + +fn test_date_range_swapped_inputs_should_auto_correct() { + result := whenwords.date_range(1705881600, 1705276800) + assert result == 'January 15–22, 2024' +} + +fn test_date_range_multi_year_span() { + result := whenwords.date_range(1672531200, 1735689600) + assert result == 'January 1, 2023 – January 1, 2025' +}