Skip to content

Latest commit

 

History

History
316 lines (239 loc) · 11 KB

File metadata and controls

316 lines (239 loc) · 11 KB

Outstandingly Obvious Time Delta (OOTD)

Rust Python TypeScript WebAssembly
Java Kotlin Swift

OOTD supports bidirectional conversion between relative-time expressions and time ranges.

  • OOTD renders time deltas as glanceable, localized phrases for feeds, notifications, timelines, and logs.
  • OOTD parses natural phrases back into query-ready time ranges.

Quick start

import ootd

# duration/timestamps -> expression
print(ootd.between("2026-03-09T18:21:29Z", "2026-05-03T19:31:43Z"))
# 2 months ago

# expression -> range -> concrete timestamps (query-ready)
r = ootd.range_of("2 months ago")
ts = r.resolve_at("2026-04-29T12:00:00Z")
print(f"Between {ts.start} and {ts.end}.")
# Between 2026-01-29 12:00:01Z and 2026-02-28 12:00:00Z.

# query text -> parseable expression candidates
print(ootd.extract_expressions("지난 두 달 전 로그랑 어제 낮 결제", "ko"))
# [{'start': 3, 'end': 8, 'text': '두 달 전'}, {'start': 12, 'end': 15, 'text': '어제 낮'}]

# locale support
print(ootd.between("2026-03-09T18:21:29Z", "2026-05-03T19:31:43Z",
                   locale="ko", use_native_ko_number=True))
# 두 달 전

Same interval, different rendering:

Case Expression
Actual datetime.timedelta 55 days, 1:10:14
Common site display 1 month ago
OOTD 2 months ago

You know a 55-day gap is actually two months.
But no site says that until the calendar-month delta rolls over to 2.

OOTD also works in the reverse direction: parse phrases like 두 달 전, yesterday afternoon, then resolve to absolute timestamp ranges for DB queries.

See the same idea in whatever stack you ship:

  • Rust
  • Python
  • TypeScript
  • WebAssembly
  • Java
  • Kotlin
  • Swift

Behavior By Example

OOTD gives people the phrase they understand at a glance. (ootd.between(Start, End)):

Start End English Korean(locale="ko")
2023-11-03 2026-05-03 2 years and a half ago 2년 반 전
03-09 05-03 2 months ago 두 달 전
03-24 05-03 a month and a half ago 한 달 반 전
06-12 05-03 a month and a half later 한 달 반 후
04-23 05-03 a week ago 1주 전
05-10 05-03 a week later 1주 후
05-02 13:30 05-03 12:00 yesterday afternoon 어제 낮
05-04 13:30 05-03 20:30 tomorrow afternoon 내일 낮
05-04 08:00 05-03 20:00 tomorrow morning 내일 아침
20:30 23:30 earlier tonight 오늘 밤
09:07 10:42 an hour and a half ago 한 시간 반 전
10:42 09:07 an hour and a half later 한 시간 반 후

Range interpretation check (ootd.range_of(Expression).resolve_at(Anchor)):

Target Anchor Expression Resolved range Target in range?
2023-11-03 2026-05-03 2 years and a half ago 2023-05-19 ~ 2023-11-15 Yes
02-22 05-03 2 months ago 02-02 00:00:01 ~ 03-04 00:00:00 Yes
05-10 05-03 a week later 05-10 ~ 05-16 Yes
01-24 16:30 01-25 13:00 yesterday afternoon 01-24 11:00:00 ~ 16:59:59 Yes

Languages

Rust

use ootd_core::{between_rfc3339, between_rfc3339_with_options, Locale, RenderOptions};

let phrase = between_rfc3339(
    "2026-03-09T18:21:29Z",
    "2026-05-03T19:31:43Z",
    Locale::En,
)?;
assert_eq!(phrase, "2 months ago");

let ko = between_rfc3339_with_options(
    "2026-03-09T18:21:29Z",
    "2026-05-03T19:31:43Z",
    Locale::Ko,
    RenderOptions {
        ko_native_numerals: true,
    },
)?;
assert_eq!(ko, "두 달 전");

Python

The Python API accepts RFC3339 strings or timezone-aware datetime objects for between. Naive datetimes are rejected so the output cannot silently depend on the machine timezone.

import ootd

print(ootd.between(
    "2026-03-09T18:21:29Z",
    "2026-05-03T19:31:43Z",
))
# 2 months ago

print(ootd.between(
    "2026-03-09T18:21:29Z",
    "2026-05-03T19:31:43Z",
    locale="ko",
    use_native_ko_number=True,
))
# 두 달 전

r = ootd.range_of("두 달 전", "ko")
ts = r.resolve_at("2026-04-29T12:00:00+09:00")
# ts.start, ts.end are timezone-aware datetime

print(ootd.extract_expressions("지난 두 달 전 로그랑 어제 낮 결제", "ko"))
# [{'start': 3, 'end': 8, 'text': '두 달 전'}, {'start': 12, 'end': 15, 'text': '어제 낮'}]

TypeScript Node

import { between, extractExpressions, rangeOf } from '@ootd/node'

console.log(between('2026-03-09T18:21:29Z', '2026-05-03T19:31:43Z', 'en'))
// 2 months ago

console.log(between('2026-03-09T18:21:29Z', '2026-05-03T19:31:43Z', 'ko', true))
// 두 달 전

const r = rangeOf('두 달 전', 'ko')
const ts = r.resolveAt('2026-04-29T12:00:00+09:00')
// ts.start, ts.end are Date

console.log(extractExpressions('지난 두 달 전 로그랑 어제 낮 결제', 'ko'))
// [{ start: 3, end: 8, text: '두 달 전' }, { start: 12, end: 15, text: '어제 낮' }]

TypeScript Browser WebAssembly

import { between, extractExpressions, rangeOf } from '@ootd/wasm'

console.log(between('2026-03-09T18:21:29Z', '2026-05-03T19:31:43Z', 'en'))
// 2 months ago

console.log(between('2026-03-09T18:21:29Z', '2026-05-03T19:31:43Z', 'ko', true))
// 두 달 전

const r = rangeOf('두 달 전', 'ko')
const ts = r.resolveAt('2026-04-29T12:00:00+09:00')
// ts.start, ts.end are Date

console.log(extractExpressions('지난 두 달 전 로그랑 어제 낮 결제', 'ko'))
// [{ start: 3, end: 8, text: '두 달 전' }, { start: 12, end: 15, text: '어제 낮' }]

Java

import io.ootd.Ootd;
import io.ootd.Locale;

String phrase = Ootd.between(
        "2026-03-09T18:21:29Z",
        "2026-05-03T19:31:43Z",
        Locale.EN
);
// 2 months ago

String ko = Ootd.between(
        "2026-03-09T18:21:29Z",
        "2026-05-03T19:31:43Z",
        Locale.KO,
        true
);
// 두 달 전

var r = Ootd.rangeOf("두 달 전", Locale.KO);
var ts = r.resolveAt("2026-04-29T12:00:00+09:00");
// ts.start(), ts.end() are OffsetDateTime

var extracted = Ootd.extractExpressions("지난 두 달 전 로그랑 어제 낮 결제", Locale.KO);
// extracted.get(0).text() == "두 달 전"

Kotlin

import io.ootd.Locale
import io.ootd.kotlin.Ootd

println(Ootd.between("2026-03-09T18:21:29Z", "2026-05-03T19:31:43Z", Locale.EN))
// 2 months ago

println(Ootd.between("2026-03-09T18:21:29Z", "2026-05-03T19:31:43Z", Locale.KO, true))
// 두 달 전

val r = Ootd.rangeOf("두 달 전", Locale.KO)
val ts = r.resolveAt("2026-04-29T12:00:00+09:00")
// ts.start(), ts.end()

val extracted = Ootd.extractExpressions("지난 두 달 전 로그랑 어제 낮 결제", Locale.KO)
// extracted[0].text() == "두 달 전"

Swift

import OOTD

let phrase = try OOTD.between(
    startRFC3339: "2026-03-09T18:21:29Z",
    endRFC3339: "2026-05-03T19:31:43Z",
    locale: .en
)
// 2 months ago

let ko = try OOTD.between(
    startRFC3339: "2026-03-09T18:21:29Z",
    endRFC3339: "2026-05-03T19:31:43Z",
    locale: .ko,
    useNativeKoNumber: true
)
// 두 달 전

let r = try OOTD.rangeOf(expression: "두 달 전", locale: .ko)
let ts = try r.resolveAt("2026-04-29T12:00:00+09:00")
// ts.start, ts.end are Date

let extracted = OOTD.extractExpressions(input: "지난 두 달 전 로그랑 어제 낮 결제", locale: .ko)
// extracted[0].text == "두 달 전"

API Shape

Core operations are the same across bindings and form a bidirectional flow:

Operation Use when Direction
between(start, end, locale, options) You have two timestamp instants. end - start decides past/future.
from_duration(seconds, is_future, locale, options) You already have an elapsed duration. is_future=False renders past, True renders future.
extract_expressions(input, locale) You have free-text query input. Extracts parseable relative-time expression candidates.
range_of(expression, locale) You have a relative phrase like 두 달 전, yesterday afternoon. Returns a duration range relative to an anchor.
duration_range.resolve_at(anchor) You want concrete query timestamps. Resolves to absolute timestamp range usable directly in queries.

Supported locales:

  • en
  • ko

Korean native counters can be enabled for 시간 and units:

ootd.from_duration(90 * 60, False, "ko", True)
# 한 시간 반 전

Bindings

This is a Rust-first multi-binding repository.

Binding Location Build or test
Rust core crates/ootd-core cargo test -p ootd-core
C FFI crates/ootd-ffi-c cargo build -p ootd-ffi-c
Python bindings/python maturin develop && pytest tests
Node bindings/node npm ci && npm run build && node test/parity.test.mjs
WebAssembly bindings/wasm npm ci && npm run build && npm run test:parity
Java bindings/java gradle test --no-daemon
Kotlin bindings/kotlin gradle test --no-daemon
Swift bindings/swift swift run ootd-parity

Input Contract

  • between requires RFC3339 timestamps or timezone-aware datetime objects.
  • Naive datetime values are rejected by design.
  • Mixed offsets are allowed.
  • Delta magnitude is computed by comparing absolute instants.
  • Daypart labels are based on the start time converted to the end timezone offset.
  • from_duration accepts non-negative durations.

Tooling

  • C header generation: cbindgen with cbindgen.toml
  • Java binding generation: jextract from include/ootd.h
  • Shared parity fixtures: tests/parity_cases.json
  • CI: GitHub Actions validates Rust and all maintained bindings

License

LGPL-3.0 (see LICENSE.txt)