From b54993827841aaf2c8c950bc58ff6bf440d397f9 Mon Sep 17 00:00:00 2001 From: Charles-Meldhine Madi Mnemoi Date: Sat, 7 Feb 2026 22:38:02 +0100 Subject: [PATCH] Add Haxe implementation --- haxe/TestRunner.hx | 11 + haxe/TestWhenwords.hx | 518 ++++++++++++++++++++++++++++++++++++++++++ haxe/Whenwords.hx | 466 +++++++++++++++++++++++++++++++++++++ haxe/haxelib.json | 12 + haxe/test.hxml | 4 + haxe/usage.md | 247 ++++++++++++++++++++ 6 files changed, 1258 insertions(+) create mode 100644 haxe/TestRunner.hx create mode 100644 haxe/TestWhenwords.hx create mode 100644 haxe/Whenwords.hx create mode 100644 haxe/haxelib.json create mode 100644 haxe/test.hxml create mode 100644 haxe/usage.md diff --git a/haxe/TestRunner.hx b/haxe/TestRunner.hx new file mode 100644 index 0000000..f90d8c7 --- /dev/null +++ b/haxe/TestRunner.hx @@ -0,0 +1,11 @@ +import utest.Runner; +import utest.ui.Report; + +class TestRunner { + static function main() { + var runner = new Runner(); + runner.addCase(new TestWhenwords()); + Report.create(runner); + runner.run(); + } +} diff --git a/haxe/TestWhenwords.hx b/haxe/TestWhenwords.hx new file mode 100644 index 0000000..39f16f7 --- /dev/null +++ b/haxe/TestWhenwords.hx @@ -0,0 +1,518 @@ +import utest.Test; +import utest.Assert; +import Whenwords; + +class TestWhenwords extends Test { + + // TIMEAGO TESTS + + function testTimeago_justNow_identicalTimestamps() { + Assert.equals("just now", Whenwords.timeago(1704067200, 1704067200)); + } + + function testTimeago_justNow_30SecondsAgo() { + Assert.equals("just now", Whenwords.timeago(1704067170, 1704067200)); + } + + function testTimeago_justNow_44SecondsAgo() { + Assert.equals("just now", Whenwords.timeago(1704067156, 1704067200)); + } + + function testTimeago_1MinuteAgo_45Seconds() { + Assert.equals("1 minute ago", Whenwords.timeago(1704067155, 1704067200)); + } + + function testTimeago_1MinuteAgo_89Seconds() { + Assert.equals("1 minute ago", Whenwords.timeago(1704067111, 1704067200)); + } + + function testTimeago_2MinutesAgo_90Seconds() { + Assert.equals("2 minutes ago", Whenwords.timeago(1704067110, 1704067200)); + } + + function testTimeago_30MinutesAgo() { + Assert.equals("30 minutes ago", Whenwords.timeago(1704065400, 1704067200)); + } + + function testTimeago_44MinutesAgo() { + Assert.equals("44 minutes ago", Whenwords.timeago(1704064560, 1704067200)); + } + + function testTimeago_1HourAgo_45Minutes() { + Assert.equals("1 hour ago", Whenwords.timeago(1704064500, 1704067200)); + } + + function testTimeago_1HourAgo_89Minutes() { + Assert.equals("1 hour ago", Whenwords.timeago(1704061860, 1704067200)); + } + + function testTimeago_2HoursAgo_90Minutes() { + Assert.equals("2 hours ago", Whenwords.timeago(1704061800, 1704067200)); + } + + function testTimeago_5HoursAgo() { + Assert.equals("5 hours ago", Whenwords.timeago(1704049200, 1704067200)); + } + + function testTimeago_21HoursAgo() { + Assert.equals("21 hours ago", Whenwords.timeago(1703991600, 1704067200)); + } + + function testTimeago_1DayAgo_22Hours() { + Assert.equals("1 day ago", Whenwords.timeago(1703988000, 1704067200)); + } + + function testTimeago_1DayAgo_35Hours() { + Assert.equals("1 day ago", Whenwords.timeago(1703941200, 1704067200)); + } + + function testTimeago_2DaysAgo_36Hours() { + Assert.equals("2 days ago", Whenwords.timeago(1703937600, 1704067200)); + } + + function testTimeago_7DaysAgo() { + Assert.equals("7 days ago", Whenwords.timeago(1703462400, 1704067200)); + } + + function testTimeago_25DaysAgo() { + Assert.equals("25 days ago", Whenwords.timeago(1701907200, 1704067200)); + } + + function testTimeago_1MonthAgo_26Days() { + Assert.equals("1 month ago", Whenwords.timeago(1701820800, 1704067200)); + } + + function testTimeago_1MonthAgo_45Days() { + Assert.equals("1 month ago", Whenwords.timeago(1700179200, 1704067200)); + } + + function testTimeago_2MonthsAgo_46Days() { + Assert.equals("2 months ago", Whenwords.timeago(1700092800, 1704067200)); + } + + function testTimeago_6MonthsAgo() { + Assert.equals("6 months ago", Whenwords.timeago(1688169600, 1704067200)); + } + + function testTimeago_11MonthsAgo_319Days() { + Assert.equals("11 months ago", Whenwords.timeago(1676505600, 1704067200)); + } + + function testTimeago_1YearAgo_320Days() { + Assert.equals("1 year ago", Whenwords.timeago(1676419200, 1704067200)); + } + + function testTimeago_1YearAgo_547Days() { + Assert.equals("1 year ago", Whenwords.timeago(1656806400, 1704067200)); + } + + function testTimeago_2YearsAgo_548Days() { + Assert.equals("2 years ago", Whenwords.timeago(1656720000, 1704067200)); + } + + function testTimeago_5YearsAgo() { + Assert.equals("5 years ago", Whenwords.timeago(1546300800, 1704067200)); + } + + function testTimeago_future_inJustNow_30Seconds() { + Assert.equals("just now", Whenwords.timeago(1704067230, 1704067200)); + } + + function testTimeago_future_in1Minute() { + Assert.equals("in 1 minute", Whenwords.timeago(1704067260, 1704067200)); + } + + function testTimeago_future_in5Minutes() { + Assert.equals("in 5 minutes", Whenwords.timeago(1704067500, 1704067200)); + } + + function testTimeago_future_in1Hour() { + Assert.equals("in 1 hour", Whenwords.timeago(1704070200, 1704067200)); + } + + function testTimeago_future_in3Hours() { + Assert.equals("in 3 hours", Whenwords.timeago(1704078000, 1704067200)); + } + + function testTimeago_future_in1Day() { + Assert.equals("in 1 day", Whenwords.timeago(1704150000, 1704067200)); + } + + function testTimeago_future_in2Days() { + Assert.equals("in 2 days", Whenwords.timeago(1704240000, 1704067200)); + } + + function testTimeago_future_in1Month() { + Assert.equals("in 1 month", Whenwords.timeago(1706745600, 1704067200)); + } + + function testTimeago_future_in1Year() { + Assert.equals("in 1 year", Whenwords.timeago(1735689600, 1704067200)); + } + + // DURATION TESTS + + function testDuration_zeroSeconds() { + Assert.equals("0 seconds", Whenwords.duration(0)); + } + + function testDuration_1Second() { + Assert.equals("1 second", Whenwords.duration(1)); + } + + function testDuration_45Seconds() { + Assert.equals("45 seconds", Whenwords.duration(45)); + } + + function testDuration_1Minute() { + Assert.equals("1 minute", Whenwords.duration(60)); + } + + function testDuration_1Minute30Seconds() { + Assert.equals("1 minute, 30 seconds", Whenwords.duration(90)); + } + + function testDuration_2Minutes() { + Assert.equals("2 minutes", Whenwords.duration(120)); + } + + function testDuration_1Hour() { + Assert.equals("1 hour", Whenwords.duration(3600)); + } + + function testDuration_1Hour1Minute() { + Assert.equals("1 hour, 1 minute", Whenwords.duration(3661)); + } + + function testDuration_1Hour30Minutes() { + Assert.equals("1 hour, 30 minutes", Whenwords.duration(5400)); + } + + function testDuration_2Hours30Minutes() { + Assert.equals("2 hours, 30 minutes", Whenwords.duration(9000)); + } + + function testDuration_1Day() { + Assert.equals("1 day", Whenwords.duration(86400)); + } + + function testDuration_1Day2Hours() { + Assert.equals("1 day, 2 hours", Whenwords.duration(93600)); + } + + function testDuration_7Days() { + Assert.equals("7 days", Whenwords.duration(604800)); + } + + function testDuration_1Month_30Days() { + Assert.equals("1 month", Whenwords.duration(2592000)); + } + + function testDuration_1Year_365Days() { + Assert.equals("1 year", Whenwords.duration(31536000)); + } + + function testDuration_1Year2Months() { + Assert.equals("1 year, 2 months", Whenwords.duration(36720000)); + } + + function testDuration_compact_1h1m() { + Assert.equals("1h 1m", Whenwords.duration(3661, {compact: true})); + } + + function testDuration_compact_2h30m() { + Assert.equals("2h 30m", Whenwords.duration(9000, {compact: true})); + } + + function testDuration_compact_1d2h() { + Assert.equals("1d 2h", Whenwords.duration(93600, {compact: true})); + } + + function testDuration_compact_45s() { + Assert.equals("45s", Whenwords.duration(45, {compact: true})); + } + + function testDuration_compact_0s() { + Assert.equals("0s", Whenwords.duration(0, {compact: true})); + } + + function testDuration_maxUnits1_hoursOnly() { + Assert.equals("1 hour", Whenwords.duration(3661, {maxUnits: 1})); + } + + function testDuration_maxUnits1_daysOnly() { + Assert.equals("1 day", Whenwords.duration(93600, {maxUnits: 1})); + } + + function testDuration_maxUnits3() { + Assert.equals("1 day, 2 hours, 1 minute", Whenwords.duration(93661, {maxUnits: 3})); + } + + function testDuration_compactMaxUnits1() { + Assert.equals("3h", Whenwords.duration(9000, {compact: true, maxUnits: 1})); + } + + function testDuration_error_negativeSeconds() { + Assert.raises(function() { + Whenwords.duration(-100); + }); + } + + // PARSE_DURATION TESTS + + function testParseDuration_compactHoursMinutes() { + Assert.equals(9000, Whenwords.parseDuration("2h30m")); + } + + function testParseDuration_compactWithSpace() { + Assert.equals(9000, Whenwords.parseDuration("2h 30m")); + } + + function testParseDuration_compactWithComma() { + Assert.equals(9000, Whenwords.parseDuration("2h, 30m")); + } + + function testParseDuration_verbose() { + Assert.equals(9000, Whenwords.parseDuration("2 hours 30 minutes")); + } + + function testParseDuration_verboseWithAnd() { + Assert.equals(9000, Whenwords.parseDuration("2 hours and 30 minutes")); + } + + function testParseDuration_verboseWithCommaAnd() { + Assert.equals(9000, Whenwords.parseDuration("2 hours, and 30 minutes")); + } + + function testParseDuration_decimalHours() { + Assert.equals(9000, Whenwords.parseDuration("2.5 hours")); + } + + function testParseDuration_decimalCompact() { + Assert.equals(5400, Whenwords.parseDuration("1.5h")); + } + + function testParseDuration_singleUnitMinutesVerbose() { + Assert.equals(5400, Whenwords.parseDuration("90 minutes")); + } + + function testParseDuration_singleUnitMinutesCompact() { + Assert.equals(5400, Whenwords.parseDuration("90m")); + } + + function testParseDuration_singleUnitMin() { + Assert.equals(5400, Whenwords.parseDuration("90min")); + } + + function testParseDuration_colonNotation_hMm() { + Assert.equals(9000, Whenwords.parseDuration("2:30")); + } + + function testParseDuration_colonNotation_hMmSs() { + Assert.equals(5400, Whenwords.parseDuration("1:30:00")); + } + + function testParseDuration_colonNotationWithSeconds() { + Assert.equals(330, Whenwords.parseDuration("0:05:30")); + } + + function testParseDuration_daysVerbose() { + Assert.equals(172800, Whenwords.parseDuration("2 days")); + } + + function testParseDuration_daysCompact() { + Assert.equals(172800, Whenwords.parseDuration("2d")); + } + + function testParseDuration_weeksVerbose() { + Assert.equals(604800, Whenwords.parseDuration("1 week")); + } + + function testParseDuration_weeksCompact() { + Assert.equals(604800, Whenwords.parseDuration("1w")); + } + + function testParseDuration_mixedVerbose() { + Assert.equals(95400, Whenwords.parseDuration("1 day, 2 hours, and 30 minutes")); + } + + function testParseDuration_mixedCompact() { + Assert.equals(95400, Whenwords.parseDuration("1d 2h 30m")); + } + + function testParseDuration_secondsOnlyVerbose() { + Assert.equals(45, Whenwords.parseDuration("45 seconds")); + } + + function testParseDuration_secondsCompactS() { + Assert.equals(45, Whenwords.parseDuration("45s")); + } + + function testParseDuration_secondsCompactSec() { + Assert.equals(45, Whenwords.parseDuration("45sec")); + } + + function testParseDuration_hoursHr() { + Assert.equals(7200, Whenwords.parseDuration("2hr")); + } + + function testParseDuration_hoursHrs() { + Assert.equals(7200, Whenwords.parseDuration("2hrs")); + } + + function testParseDuration_minutesMins() { + Assert.equals(1800, Whenwords.parseDuration("30mins")); + } + + function testParseDuration_caseInsensitive() { + Assert.equals(9000, Whenwords.parseDuration("2H 30M")); + } + + function testParseDuration_whitespaceTolerance() { + Assert.equals(9000, Whenwords.parseDuration(" 2 hours 30 minutes ")); + } + + function testParseDuration_error_emptyString() { + Assert.raises(function() { + Whenwords.parseDuration(""); + }); + } + + function testParseDuration_error_noUnits() { + Assert.raises(function() { + Whenwords.parseDuration("hello world"); + }); + } + + function testParseDuration_error_negative() { + Assert.raises(function() { + Whenwords.parseDuration("-5 hours"); + }); + } + + function testParseDuration_error_justNumber() { + Assert.raises(function() { + Whenwords.parseDuration("42"); + }); + } + + // HUMAN_DATE TESTS + + function testHumanDate_today() { + Assert.equals("Today", Whenwords.humanDate(1705276800, 1705276800)); + } + + function testHumanDate_today_sameDayDifferentTime() { + Assert.equals("Today", Whenwords.humanDate(1705320000, 1705276800)); + } + + function testHumanDate_yesterday() { + Assert.equals("Yesterday", Whenwords.humanDate(1705190400, 1705276800)); + } + + function testHumanDate_tomorrow() { + Assert.equals("Tomorrow", Whenwords.humanDate(1705363200, 1705276800)); + } + + function testHumanDate_lastSunday_1DayBeforeMonday() { + Assert.equals("Yesterday", Whenwords.humanDate(1705190400, 1705276800)); + } + + function testHumanDate_lastSaturday_2DaysAgo() { + Assert.equals("Last Saturday", Whenwords.humanDate(1705104000, 1705276800)); + } + + function testHumanDate_lastFriday_3DaysAgo() { + Assert.equals("Last Friday", Whenwords.humanDate(1705017600, 1705276800)); + } + + function testHumanDate_lastThursday_4DaysAgo() { + Assert.equals("Last Thursday", Whenwords.humanDate(1704931200, 1705276800)); + } + + function testHumanDate_lastWednesday_5DaysAgo() { + Assert.equals("Last Wednesday", Whenwords.humanDate(1704844800, 1705276800)); + } + + function testHumanDate_lastTuesday_6DaysAgo() { + Assert.equals("Last Tuesday", Whenwords.humanDate(1704758400, 1705276800)); + } + + function testHumanDate_lastMonday_7DaysAgo_becomesDate() { + Assert.equals("January 8", Whenwords.humanDate(1704672000, 1705276800)); + } + + function testHumanDate_thisTuesday_1DayFuture() { + Assert.equals("Tomorrow", Whenwords.humanDate(1705363200, 1705276800)); + } + + function testHumanDate_thisWednesday_2DaysFuture() { + Assert.equals("This Wednesday", Whenwords.humanDate(1705449600, 1705276800)); + } + + function testHumanDate_thisThursday_3DaysFuture() { + Assert.equals("This Thursday", Whenwords.humanDate(1705536000, 1705276800)); + } + + function testHumanDate_thisSunday_6DaysFuture() { + Assert.equals("This Sunday", Whenwords.humanDate(1705795200, 1705276800)); + } + + function testHumanDate_nextMonday_7DaysFuture_becomesDate() { + Assert.equals("January 22", Whenwords.humanDate(1705881600, 1705276800)); + } + + function testHumanDate_sameYearDifferentMonth() { + Assert.equals("March 1", Whenwords.humanDate(1709251200, 1705276800)); + } + + function testHumanDate_sameYearEndOfYear() { + Assert.equals("December 31", Whenwords.humanDate(1735603200, 1705276800)); + } + + function testHumanDate_previousYear() { + Assert.equals("January 1, 2023", Whenwords.humanDate(1672531200, 1705276800)); + } + + function testHumanDate_nextYear() { + Assert.equals("January 6, 2025", Whenwords.humanDate(1736121600, 1705276800)); + } + + // DATE_RANGE TESTS + + function testDateRange_sameDay() { + Assert.equals("January 15, 2024", Whenwords.dateRange(1705276800, 1705276800)); + } + + function testDateRange_sameDayDifferentTimes() { + Assert.equals("January 15, 2024", Whenwords.dateRange(1705276800, 1705320000)); + } + + function testDateRange_consecutiveDaysSameMonth() { + Assert.equals("January 15–16, 2024", Whenwords.dateRange(1705276800, 1705363200)); + } + + function testDateRange_sameMonthRange() { + Assert.equals("January 15–22, 2024", Whenwords.dateRange(1705276800, 1705881600)); + } + + function testDateRange_sameYearDifferentMonths() { + Assert.equals("January 15 – February 15, 2024", Whenwords.dateRange(1705276800, 1707955200)); + } + + function testDateRange_differentYears() { + Assert.equals("December 28, 2023 – January 15, 2024", Whenwords.dateRange(1703721600, 1705276800)); + } + + function testDateRange_fullYearSpan() { + Assert.equals("January 1 – December 31, 2024", Whenwords.dateRange(1704067200, 1735603200)); + } + + function testDateRange_swappedInputs_shouldAutoCorrect() { + Assert.equals("January 15–22, 2024", Whenwords.dateRange(1705881600, 1705276800)); + } + + function testDateRange_multiYearSpan() { + Assert.equals("January 1, 2023 – January 1, 2025", Whenwords.dateRange(1672531200, 1735689600)); + } +} diff --git a/haxe/Whenwords.hx b/haxe/Whenwords.hx new file mode 100644 index 0000000..034e5d2 --- /dev/null +++ b/haxe/Whenwords.hx @@ -0,0 +1,466 @@ +package; + +import haxe.Exception; + +typedef DurationOptions = { + ?compact: Bool, + ?maxUnits: Int +} + +class Whenwords { + + /** + * Returns a human-readable relative time string. + * @param timestamp Unix timestamp (seconds) or Date object + * @param reference Optional reference timestamp (defaults to timestamp) + * @return Human-readable relative time string + */ + public static function timeago(timestamp: Dynamic, ?reference: Dynamic): String { + var ts = normalizeTimestamp(timestamp); + var ref = reference != null ? normalizeTimestamp(reference) : ts; + + var diff = ref - ts; + var absDiff = Math.abs(diff); + var isFuture = diff < 0; + + var value: Float; + var unit: String; + + if (absDiff < 45) { + return "just now"; + } else if (absDiff < 90) { + value = 1; + unit = "minute"; + } else if (absDiff < 45 * 60) { + value = Math.round(absDiff / 60); + unit = "minute"; + } else if (absDiff < 90 * 60) { + value = 1; + unit = "hour"; + } else if (absDiff < 22 * 3600) { + value = Math.round(absDiff / 3600); + unit = "hour"; + } else if (absDiff < 36 * 3600) { + value = 1; + unit = "day"; + } else if (absDiff < 26 * 86400) { + value = Math.round(absDiff / 86400); + unit = "day"; + } else if (absDiff < 46 * 86400) { + value = 1; + unit = "month"; + } else if (absDiff < 320 * 86400) { + value = Math.round(absDiff / (30 * 86400)); + unit = "month"; + } else if (absDiff < 548 * 86400) { + value = 1; + unit = "year"; + } else { + value = Math.round(absDiff / (365 * 86400)); + unit = "year"; + } + + var plural = value != 1 ? "s" : ""; + var valueStr = Std.string(Std.int(value)); + + if (isFuture) { + return 'in $valueStr $unit$plural'; + } else { + return '$valueStr $unit$plural ago'; + } + } + + /** + * Formats a duration (not relative to now). + * @param seconds Non-negative number of seconds + * @param options Optional formatting options + * @return Formatted duration string + */ + public static function duration(seconds: Float, ?options: DurationOptions): String { + if (seconds < 0) { + throw new Exception("Duration cannot be negative"); + } + if (!Math.isFinite(seconds) || Math.isNaN(seconds)) { + throw new Exception("Duration must be a finite number"); + } + + var compact = options != null && options.compact == true; + var maxUnits = options != null && options.maxUnits != null ? options.maxUnits : 2; + + var totalSeconds = Math.round(seconds); + + // Calculate all units + var years = Std.int(Math.floor(totalSeconds / (365 * 86400))); + var afterYears = totalSeconds % (365 * 86400); + + var months = Std.int(Math.floor(afterYears / (30 * 86400))); + var afterMonths = afterYears % (30 * 86400); + + var days = Std.int(Math.floor(afterMonths / 86400)); + var afterDays = afterMonths % 86400; + + var hours = Std.int(Math.floor(afterDays / 3600)); + var afterHours = afterDays % 3600; + + var minutes = Std.int(Math.floor(afterHours / 60)); + var secs = afterHours % 60; + + // Build list of all non-zero units + var allUnits = []; + if (years > 0) allUnits.push({value: years, longName: "year", shortName: "y"}); + if (months > 0) allUnits.push({value: months, longName: "month", shortName: "mo"}); + if (days > 0) allUnits.push({value: days, longName: "day", shortName: "d"}); + if (hours > 0) allUnits.push({value: hours, longName: "hour", shortName: "h"}); + if (minutes > 0) allUnits.push({value: minutes, longName: "minute", shortName: "m"}); + if (secs > 0) allUnits.push({value: secs, longName: "second", shortName: "s"}); + + if (allUnits.length == 0) { + return compact ? "0s" : "0 seconds"; + } + + // Apply rounding to the smallest displayed unit if truncated + if (allUnits.length > maxUnits) { + // Calculate divisors for each unit type + var divisors = [365 * 86400, 30 * 86400, 86400, 3600, 60, 1]; + + // Find position of last unit to display + var displayUnits = allUnits.slice(0, maxUnits); + var lastUnitName = displayUnits[displayUnits.length - 1].shortName; + + // Get the divisor for the last unit + var lastDivisor = switch (lastUnitName) { + case "y": 365 * 86400; + case "mo": 30 * 86400; + case "d": 86400; + case "h": 3600; + case "m": 60; + case "s": 1; + default: 1; + } + + // Calculate what's left after the last displayed unit + var accounted = 0.0; + var unitDivisors = [365 * 86400, 30 * 86400, 86400, 3600, 60, 1]; + + for (i in 0...maxUnits) { + if (i < allUnits.length) { + var div = switch (allUnits[i].shortName) { + case "y": 365 * 86400; + case "mo": 30 * 86400; + case "d": 86400; + case "h": 3600; + case "m": 60; + case "s": 1; + default: 1; + } + accounted += allUnits[i].value * div; + } + } + + var remaining = totalSeconds - accounted; + + // Round up the last displayed unit if remaining is significant + if (remaining >= lastDivisor / 2) { + displayUnits[displayUnits.length - 1].value++; + } + + allUnits = displayUnits; + } + + var parts: Array = []; + for (unit in allUnits) { + parts.push(formatUnit(unit.value, unit.longName, unit.shortName, compact)); + } + + return compact ? parts.join(" ") : parts.join(", "); + } + + /** + * Parses a human-written duration string into seconds. + * @param str Duration string to parse + * @return Number of seconds + */ + public static function parseDuration(str: String): Float { + if (str == null || StringTools.trim(str) == "") { + throw new Exception("Duration string cannot be empty"); + } + + str = StringTools.trim(str).toLowerCase(); + + // Try colon notation first + var colonRegex = ~/^(\d+):(\d+)(?::(\d+))?$/; + if (colonRegex.match(str)) { + var hours = Std.parseInt(colonRegex.matched(1)); + var minutes = Std.parseInt(colonRegex.matched(2)); + var seconds = colonRegex.matched(3) != null ? Std.parseInt(colonRegex.matched(3)) : 0; + return hours * 3600 + minutes * 60 + seconds; + } + + var total: Float = 0; + + // Remove commas and "and" + str = StringTools.replace(str, ",", " "); + str = StringTools.replace(str, " and ", " "); + + // Pattern for matching duration components + var pattern = ~/([-]?\d+(?:\.\d+)?)\s*([a-z]+)/g; + var matched = false; + + while (pattern.match(str)) { + matched = true; + var value = Std.parseFloat(pattern.matched(1)); + var unit = pattern.matched(2); + + if (value < 0) { + throw new Exception("Duration values cannot be negative"); + } + + var multiplier = getUnitMultiplier(unit); + if (multiplier == null) { + throw new Exception('Unknown duration unit: $unit'); + } + + total += value * multiplier; + str = pattern.matchedRight(); + } + + if (!matched) { + throw new Exception("No parseable duration found in string"); + } + + if (total < 0) { + throw new Exception("Duration cannot be negative"); + } + + return total; + } + + /** + * Returns a contextual date string. + * @param timestamp The date to format + * @param reference The "current" date for comparison + * @return Contextual date string + */ + public static function humanDate(timestamp: Dynamic, reference: Dynamic): String { + var ts = normalizeTimestamp(timestamp); + var ref = normalizeTimestamp(reference); + + var tsDate = dateFromUnixSeconds(ts); + var refDate = dateFromUnixSeconds(ref); + + var tsDayStart = getDayStartTimestamp(ts); + var refDayStart = getDayStartTimestamp(ref); + + var daysDiff = Math.round((tsDayStart - refDayStart) / 86400); + + if (daysDiff == 0) { + return "Today"; + } else if (daysDiff == -1) { + return "Yesterday"; + } else if (daysDiff == 1) { + return "Tomorrow"; + } else if (daysDiff >= -6 && daysDiff < 0) { + return "Last " + getWeekdayName(tsDate.weekday); + } else if (daysDiff > 1 && daysDiff <= 6) { + return "This " + getWeekdayName(tsDate.weekday); + } + + // Same year check + if (tsDate.year == refDate.year) { + return getMonthName(tsDate.month) + " " + tsDate.day; + } + + // Different year + return getMonthName(tsDate.month) + " " + tsDate.day + ", " + tsDate.year; + } + + /** + * Formats a date range with smart abbreviation. + * @param start Start timestamp + * @param end End timestamp + * @return Formatted date range string + */ + public static function dateRange(start: Dynamic, end: Dynamic): String { + var startTs = normalizeTimestamp(start); + var endTs = normalizeTimestamp(end); + + // Swap if needed + if (startTs > endTs) { + var temp = startTs; + startTs = endTs; + endTs = temp; + } + + var startDate = dateFromUnixSeconds(startTs); + var endDate = dateFromUnixSeconds(endTs); + + var startDayStart = getDayStartTimestamp(startTs); + var endDayStart = getDayStartTimestamp(endTs); + + // Same day + if (startDayStart == endDayStart) { + return getMonthName(startDate.month) + " " + startDate.day + ", " + startDate.year; + } + + // Same month and year + if (startDate.year == endDate.year && startDate.month == endDate.month) { + return getMonthName(startDate.month) + " " + startDate.day + "–" + endDate.day + ", " + startDate.year; + } + + // Same year, different months + if (startDate.year == endDate.year) { + return getMonthName(startDate.month) + " " + startDate.day + " – " + + getMonthName(endDate.month) + " " + endDate.day + ", " + startDate.year; + } + + // Different years + return getMonthName(startDate.month) + " " + startDate.day + ", " + startDate.year + " – " + + getMonthName(endDate.month) + " " + endDate.day + ", " + endDate.year; + } + + // Helper functions + + private static function normalizeTimestamp(timestamp: Dynamic): Float { + if (Std.isOfType(timestamp, Float) || Std.isOfType(timestamp, Int)) { + return cast(timestamp, Float); + } else if (Std.isOfType(timestamp, Date)) { + return cast(timestamp, Date).getTime() / 1000; + } else if (Std.isOfType(timestamp, String)) { + // Parse ISO 8601 string + var date = Date.fromString(cast(timestamp, String)); + return date.getTime() / 1000; + } + throw new Exception("Invalid timestamp format"); + } + + private static function dateFromUnixSeconds(timestamp: Float): {year: Int, month: Int, day: Int, weekday: Int} { + // Convert Unix timestamp to UTC date components + var days = Math.floor(timestamp / 86400); + var seconds = Std.int(timestamp % 86400); + + // Unix epoch is Thursday, January 1, 1970 + var weekday = Std.int((days + 4) % 7); + if (weekday < 0) weekday += 7; + + // Calculate year, month, day + var year = 1970; + var month = 1; + var day = 1; + + // Add days from epoch + var totalDays = Std.int(days); + + // Calculate year + while (true) { + var daysInYear = isLeapYear(year) ? 366 : 365; + if (totalDays < daysInYear) break; + totalDays -= daysInYear; + year++; + } + + // Calculate month + while (true) { + var daysInMonth = getDaysInMonth(year, month); + if (totalDays < daysInMonth) break; + totalDays -= daysInMonth; + month++; + } + + // Calculate day + day = totalDays + 1; + + return {year: year, month: month, day: day, weekday: weekday}; + } + + private static function getDayStartTimestamp(timestamp: Float): Float { + // Get start of day in UTC + var days = Math.floor(timestamp / 86400); + return days * 86400; + } + + private static function isLeapYear(year: Int): Bool { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + } + + private static function getDaysInMonth(year: Int, month: Int): Int { + return switch (month) { + case 1: 31; + case 2: isLeapYear(year) ? 29 : 28; + case 3: 31; + case 4: 30; + case 5: 31; + case 6: 30; + case 7: 31; + case 8: 31; + case 9: 30; + case 10: 31; + case 11: 30; + case 12: 31; + default: 0; + } + } + + private static function formatUnit(value: Float, longName: String, shortName: String, compact: Bool): String { + var intValue = Std.int(value); + if (compact) { + return '$intValue$shortName'; + } else { + var plural = intValue != 1 ? "s" : ""; + return '$intValue $longName$plural'; + } + } + + private static function getUnitCarryLimit(unitIndex: Int): Int { + // Not used in simple implementation but kept for potential carry-over logic + return switch (unitIndex) { + case 5: 60; // seconds -> minutes + case 4: 60; // minutes -> hours + case 3: 24; // hours -> days + case 2: 30; // days -> months (approximate) + case 1: 12; // months -> years + default: 999999; + } + } + + private static function getUnitMultiplier(unit: String): Null { + return switch (unit) { + case "s" | "sec" | "secs" | "second" | "seconds": 1; + case "m" | "min" | "mins" | "minute" | "minutes": 60; + case "h" | "hr" | "hrs" | "hour" | "hours": 3600; + case "d" | "day" | "days": 86400; + case "w" | "wk" | "wks" | "week" | "weeks": 604800; + default: null; + } + } + + private static function getWeekdayName(weekday: Int): String { + return switch (weekday) { + case 0: "Sunday"; + case 1: "Monday"; + case 2: "Tuesday"; + case 3: "Wednesday"; + case 4: "Thursday"; + case 5: "Friday"; + case 6: "Saturday"; + default: ""; + } + } + + private static function getMonthName(month: Int): String { + return switch (month) { + case 1: "January"; + case 2: "February"; + case 3: "March"; + case 4: "April"; + case 5: "May"; + case 6: "June"; + case 7: "July"; + case 8: "August"; + case 9: "September"; + case 10: "October"; + case 11: "November"; + case 12: "December"; + default: ""; + } + } +} diff --git a/haxe/haxelib.json b/haxe/haxelib.json new file mode 100644 index 0000000..cf3f773 --- /dev/null +++ b/haxe/haxelib.json @@ -0,0 +1,12 @@ +{ + "name": "whenwords", + "url": "https://github.com/dbreunig/whenwords", + "license": "MIT", + "tags": ["time", "date", "formatting", "parsing", "duration", "timeago", "humanize"], + "description": "Human-friendly time formatting and parsing library. Converts timestamps to readable strings like '3 hours ago' and parses duration strings like '2h 30m' into seconds.", + "version": "0.1.0", + "releasenote": "Initial release with timeago, duration, parseDuration, humanDate, and dateRange functions", + "contributors": ["claude","cmnemoi", "dbreunig"], + "classPath": ".", + "dependencies": {} +} diff --git a/haxe/test.hxml b/haxe/test.hxml new file mode 100644 index 0000000..dd63f0b --- /dev/null +++ b/haxe/test.hxml @@ -0,0 +1,4 @@ +-cp . +-main TestRunner +-lib utest +--interp diff --git a/haxe/usage.md b/haxe/usage.md new file mode 100644 index 0000000..f8cd2db --- /dev/null +++ b/haxe/usage.md @@ -0,0 +1,247 @@ +# whenwords for Haxe + +Human-friendly time formatting and parsing. + +## Installation + +Copy the `Whenwords.hx` file into your project's source directory. The library is a single class with static methods that can be used anywhere in your code. + +```haxe +import Whenwords; +``` + +## Quick start + +```haxe +import Whenwords; + +class Main { + static function main() { + // Relative time + var result = Whenwords.timeago(1704064500, 1704067200); + trace(result); // "1 hour ago" + + // Duration formatting + var dur = Whenwords.duration(3661); + trace(dur); // "1 hour, 1 minute" + + // Parse duration strings + var seconds = Whenwords.parseDuration("2h 30m"); + trace(seconds); // 9000 + + // Contextual dates + var date = Whenwords.humanDate(1705190400, 1705276800); + trace(date); // "Yesterday" + + // Date ranges + var range = Whenwords.dateRange(1705276800, 1705881600); + trace(range); // "January 15–22, 2024" + } +} +``` + +## Functions + +### timeago(timestamp, reference?) → String + +Returns a human-readable relative time string. + +```haxe +static function timeago(timestamp: Dynamic, ?reference: Dynamic): String +``` + +**Parameters:** +- `timestamp` - Unix timestamp (seconds) as Float or Int +- `reference` - Optional reference timestamp (defaults to `timestamp`) + +**Returns:** Human-readable relative time string + +**Examples:** +```haxe +Whenwords.timeago(1704067170, 1704067200) // "just now" +Whenwords.timeago(1704064500, 1704067200) // "1 hour ago" +Whenwords.timeago(1703462400, 1704067200) // "7 days ago" +Whenwords.timeago(1704070200, 1704067200) // "in 1 hour" +``` + +**Behavior:** +- 0-44 seconds: "just now" +- 45-89 seconds: "1 minute ago" +- 90 seconds - 44 minutes: "{n} minutes ago" +- 45-89 minutes: "1 hour ago" +- 90 minutes - 21 hours: "{n} hours ago" +- 22-35 hours: "1 day ago" +- 36 hours - 25 days: "{n} days ago" +- 26-45 days: "1 month ago" +- 46-319 days: "{n} months ago" +- 320-547 days: "1 year ago" +- 548+ days: "{n} years ago" + +Future times use "in {n} {unit}" format. + +### duration(seconds, options?) → String + +Formats a duration in seconds to a human-readable string. + +```haxe +static function duration(seconds: Float, ?options: DurationOptions): String + +typedef DurationOptions = { + ?compact: Bool, // Use compact format (default: false) + ?maxUnits: Int // Maximum units to display (default: 2) +} +``` + +**Parameters:** +- `seconds` - Non-negative number of seconds +- `options` - Optional formatting options + +**Returns:** Formatted duration string + +**Examples:** +```haxe +Whenwords.duration(3661) // "1 hour, 1 minute" +Whenwords.duration(3661, {compact: true}) // "1h 1m" +Whenwords.duration(3661, {maxUnits: 1}) // "1 hour" +Whenwords.duration(9000, {compact: true, maxUnits: 1}) // "3h" +Whenwords.duration(0) // "0 seconds" +``` + +**Units:** years (365d), months (30d), days, hours, minutes, seconds + +### parseDuration(string) → Float + +Parses a human-written duration string into seconds. + +```haxe +static function parseDuration(str: String): Float +``` + +**Parameters:** +- `str` - Duration string to parse + +**Returns:** Number of seconds + +**Throws:** `Exception` if string is empty, unparseable, or contains negative values + +**Examples:** +```haxe +Whenwords.parseDuration("2h30m") // 9000 +Whenwords.parseDuration("2 hours 30 minutes") // 9000 +Whenwords.parseDuration("2.5 hours") // 9000 +Whenwords.parseDuration("90m") // 5400 +Whenwords.parseDuration("2:30") // 9000 (h:mm format) +Whenwords.parseDuration("1:30:00") // 5400 (h:mm:ss format) +``` + +**Accepted formats:** +- Compact: `2h30m`, `2h 30m`, `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 + +### humanDate(timestamp, reference) → String + +Returns a contextual date string. + +```haxe +static function humanDate(timestamp: Dynamic, reference: Dynamic): String +``` + +**Parameters:** +- `timestamp` - The date to format (Unix seconds) +- `reference` - The "current" date for comparison (Unix seconds) + +**Returns:** Contextual date string + +**Examples:** +```haxe +Whenwords.humanDate(1705276800, 1705276800) // "Today" +Whenwords.humanDate(1705190400, 1705276800) // "Yesterday" +Whenwords.humanDate(1705363200, 1705276800) // "Tomorrow" +Whenwords.humanDate(1705104000, 1705276800) // "Last Saturday" +Whenwords.humanDate(1705449600, 1705276800) // "This Wednesday" +Whenwords.humanDate(1709251200, 1705276800) // "March 1" +Whenwords.humanDate(1672531200, 1705276800) // "January 1, 2023" +``` + +**Output formats:** +- 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}" + +### dateRange(start, end) → String + +Formats a date range with smart abbreviation. + +```haxe +static function dateRange(start: Dynamic, end: Dynamic): String +``` + +**Parameters:** +- `start` - Start timestamp (Unix seconds) +- `end` - End timestamp (Unix seconds) + +**Returns:** Formatted date range string + +**Examples:** +```haxe +Whenwords.dateRange(1705276800, 1705276800) // "January 15, 2024" +Whenwords.dateRange(1705276800, 1705363200) // "January 15–16, 2024" +Whenwords.dateRange(1705276800, 1705881600) // "January 15–22, 2024" +Whenwords.dateRange(1705276800, 1707955200) // "January 15 – February 15, 2024" +Whenwords.dateRange(1703721600, 1705276800) // "December 28, 2023 – January 15, 2024" +``` + +**Behavior:** +- Same day: "Month Day, Year" +- Same month: "Month Day–Day, Year" +- Same year: "Month Day – Month Day, Year" +- Different years: "Month Day, Year – Month Day, Year" +- If start > end, they are automatically swapped + +## Error handling + +Functions throw `haxe.Exception` with descriptive messages when: + +- `duration`: Negative seconds, NaN, or infinite values +- `parseDuration`: Empty string, unparseable input, or negative result +- `timeago`, `humanDate`, `dateRange`: Invalid timestamp format + +**Example:** +```haxe +try { + var result = Whenwords.duration(-100); +} catch (e:haxe.Exception) { + trace("Error: " + e.message); // "Duration cannot be negative" +} +``` + +## Accepted types + +All timestamp parameters accept: +- **Float/Int**: Unix seconds (e.g., `1704067200`) +- **Date**: Haxe Date objects (automatically converted to Unix seconds) + +The library works exclusively with UTC timestamps. All calendar-based functions (`humanDate`, `dateRange`) interpret timestamps in UTC. + +## Testing + +Run the included test suite: + +```bash +haxe test.hxml +``` + +All 123 test cases from `tests.yaml` pass successfully.