todo.parse-date-phrase
Read a typed due date like "tomorrow", "next friday", "in 2 weeks" or "2026-10-01" as a date, relative to today.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 53 tests, run in TypeScript, Python and Rust.
What it does
Reads what a person types into a due-date box, such as `tomorrow`, `next friday`, `in 2 weeks` or `2026-10-01`, and returns the date it names, counted from `today`. Returns null when the text is not a date phrase, so a form can say "I didn't understand that" without catching anything. It only throws when `today` itself is not a real ISO date (dates.add-days's message, e.g. `"28/09/2026" is not an ISO date (YYYY-MM-DD)`), or when the answer would fall outside 0001-9999.
`today` is an argument, not the clock: the caller passes the person's local date, so the answer is the same on every server and in every test.
For example
parse_date_phrase(today, 2026-09-28)→ 2026-09-28 today is todayparse_date_phrase(tod, 2026-09-28)→ 2026-09-28 tod is short for todayparse_date_phrase( TOMORROW , 2026-09-28)→ 2026-09-29 capitals and surrounding spaces are ignored
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn parse_date_phrase(text: &str, today: &str) -> Option<String>
| text | string | what was typed, e.g. "due friday", "in 3 days", "next month" |
| today | date | the person's local date, which relative phrases count from |
| returns | date? | the date the whole phrase names, or null when it is not a date phrase |
Your code names it in one line, in the file that uses it
fune!(todo.parse-date-phrase@^1); // then call parse_date_phrase(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{add_days, days_in_month}; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months; ← from dates.add-months ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week; ← from dates.day-of-week ^1.0.0 · built alongside by fune
fn weekday_number(word: &str) -> Option<i64> {
match word {
"monday" | "mon" => Some(1),
"tuesday" | "tue" | "tues" => Some(2),
"wednesday" | "wed" => Some(3),
"thursday" | "thu" | "thur" | "thurs" => Some(4),
"friday" | "fri" => Some(5),
"saturday" | "sat" => Some(6),
"sunday" | "sun" => Some(7),
_ => None,
}
}
fn number_word(word: &str) -> Option<i64> {
match word {
"a" | "an" | "one" => Some(1),
"two" => Some(2),
"three" => Some(3),
"four" => Some(4),
"five" => Some(5),
"six" => Some(6),
"seven" => Some(7),
"eight" => Some(8),
"nine" => Some(9),
"ten" => Some(10),
_ => None,
}
}
fn unit(word: &str) -> Option<&'static str> {
match word {
"day" | "days" => Some("day"),
"week" | "weeks" => Some("week"),
"month" | "months" => Some("month"),
"year" | "years" => Some("year"),
_ => None,
}
}
fn is_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}')
}
fn is_digits(text: &str) -> bool {
!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
}
/// YYYY-MM-DD naming a day that exists; anything else is simply not a date phrase.
fn is_real_date(word: &str) -> bool {
let b = word.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
if !is_digits(&word[0..4]) || !is_digits(&word[5..7]) || !is_digits(&word[8..10]) {
return false;
}
let year: i64 = word[0..4].parse().unwrap();
let month: i64 = word[5..7].parse().unwrap();
let day: i64 = word[8..10].parse().unwrap();
year >= 1 && (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
}
/// 1 to 999 in ASCII digits without a leading zero, or a/an/one...ten.
fn count(word: &str) -> Option<i64> {
if is_digits(word) && word.len() <= 3 && !word.starts_with('0') {
return Some(word.parse().unwrap());
}
number_word(word)
}
/// Days from today to the next given weekday, 1 to 7: never today itself.
fn days_until(weekday: i64, today: i64) -> i64 {
(weekday - today + 6) % 7 + 1
}
/// The date a typed phrase names, counted from `today`, or None when the whole
/// text is not one of the phrases in the README.
///
/// # Panics
/// Panics when `today` is not a real ISO date, or the answer would fall
/// outside 0001-9999.
pub fn parse_date_phrase(text: &str, today: &str) -> Option<String> {
// add_days(x, 0) is the date check: it panics with dates.add-days's message.
add_days(today, 0);
let lowered = text.to_ascii_lowercase();
let mut words: Vec<&str> = lowered.split(is_space).filter(|w| !w.is_empty()).collect();
if words.len() > 1 && (words[0] == "on" || words[0] == "due") {
words.remove(0);
}
let dow = day_of_week(today);
match words.len() {
1 => {
let word = words[0];
match word {
"today" | "tod" => Some(today.to_string()),
"tomorrow" | "tmr" | "tmrw" => Some(add_days(today, 1)),
"yesterday" => Some(add_days(today, -1)),
"weekend" => Some(if dow >= 6 { today.to_string() } else { add_days(today, 6 - dow) }),
_ => match weekday_number(word) {
Some(weekday) => Some(add_days(today, days_until(weekday, dow))),
None if is_real_date(word) => Some(word.to_string()),
None => None,
},
}
}
2 => {
let (first, second) = (words[0], words[1]);
if first == "this" && second == "weekend" {
return Some(if dow >= 6 { today.to_string() } else { add_days(today, 6 - dow) });
}
if first != "next" {
return None;
}
let next_monday = 8 - dow;
match second {
"week" => Some(add_days(today, next_monday)),
"month" => Some(add_months(&format!("{}01", &today[0..8]), 1)),
"year" => Some(add_months(&format!("{}01-01", &today[0..5]), 12)),
_ => weekday_number(second).map(|weekday| add_days(today, next_monday + weekday - 1)),
}
}
3 if words[0] == "in" => {
let n = count(words[1])?;
match unit(words[2])? {
"day" => Some(add_days(today, n)),
"week" => Some(add_days(today, 7 * n)),
"month" => Some(add_months(today, n)),
_ => Some(add_months(today, 12 * n)),
}
}
_ => None,
}
}
pub fn fune_vector(args: &[Value]) -> Value {
match parse_date_phrase(args[0].as_str(), args[1].as_str()) {
Some(date) => Value::str(&date),
None => Value::Null,
}
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add todo.parse-date-phrase
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./todo.parse-date-phrase-1.0.0-rust.fune, or fetch it from a terminal with fune pull todo.parse-date-phrase@1.0.0:rust.
The whole function, every language, is one file too: todo.parse-date-phrase-1.0.0.fune, 27,727 bytes, sha256 d450114d42ed52f5273b9e892f470087e1157482e503316c84b52862283d32bd. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before todo.parse-date-phrase
after — your function gets the result and the arguments, and returns the final result.
// fune: after todo.parse-date-phrase
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.add-days in todo.parse-date-phrase
// fune: replace dates.add-months in todo.parse-date-phrase
// fune: replace dates.day-of-week in todo.parse-date-phrase
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show todo.parse-date-phrase --steps.
// fune: step todo.parse-date-phrase after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| today is today | today, 2026-09-28 | → | 2026-09-28 |
| tod is short for today | tod, 2026-09-28 | → | 2026-09-28 |
| capitals and surrounding spaces are ignored | TOMORROW , 2026-09-28 | → | 2026-09-29 |
| tmrw is tomorrow | tmrw, 2026-09-28 | → | 2026-09-29 |
| tmr is tomorrow | Tmr, 2026-09-28 | → | 2026-09-29 |
| yesterday crosses back over a month end | yesterday, 2026-03-01 | → | 2026-02-28 |
| a weekday name is the next one: friday on a Monday | friday, 2026-09-28 | → | 2026-10-02 |
| the same weekday is a week away, never today: friday on a Friday | Friday, 2026-10-02 | → | 2026-10-09 |
| a 3-letter weekday on that same weekday: mon on a Monday | mon, 2026-09-28 | → | 2026-10-05 |
| thurs is Thursday | thurs, 2026-09-28 | → | 2026-10-01 |
Show the other 43 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| tues is Tuesday, and on a Monday that is tomorrow | tues, 2026-09-28 | → | 2026-09-29 |
| sunday from a Saturday is the next day | sun, 2026-10-03 | → | 2026-10-04 |
| next week is Monday of next ISO week | next week, 2026-09-28 | → | 2026-10-05 |
| next week on a Sunday is the very next day | next week, 2026-10-04 | → | 2026-10-05 |
| next friday on a Monday is in next week, not this week's Friday | next friday, 2026-09-28 | → | 2026-10-09 |
| next sunday on a Sunday is 7 days on | next sunday, 2026-10-04 | → | 2026-10-11 |
| next month is the 1st of next month | next month, 2026-09-28 | → | 2026-10-01 |
| next month from New Year's Eve is the 1st of January | next month, 2026-12-31 | → | 2027-01-01 |
| next year is 1 January of next year | next year, 2026-09-28 | → | 2027-01-01 |
| weekend on a Monday is the coming Saturday | weekend, 2026-09-28 | → | 2026-10-03 |
| this weekend on a Saturday is today | this weekend, 2026-10-03 | → | 2026-10-03 |
| weekend on a Sunday is today, not next Saturday | weekend, 2026-10-04 | → | 2026-10-04 |
| in 3 days | in 3 days, 2026-09-28 | → | 2026-10-01 |
| in a week | in a week, 2026-09-28 | → | 2026-10-05 |
| in two weeks | in two weeks, 2026-09-28 | → | 2026-10-12 |
| in 1 day, singular | in 1 day, 2026-09-28 | → | 2026-09-29 |
| in 1 month from 31 January clamps to 28 February | in 1 month, 2026-01-31 | → | 2026-02-28 |
| in 1 month from 31 January 2028 is 29 February (leap year) | in 1 month, 2028-01-31 | → | 2028-02-29 |
| in a year from 29 February clamps to 28 February | in a year, 2028-02-29 | → | 2029-02-28 |
| in 2 months from 31 December is 28 February | in 2 months, 2026-12-31 | → | 2027-02-28 |
| in 999 days is the largest count | in 999 days, 2026-09-28 | → | 2029-06-23 |
| in 1000 days is too many: not a date phrase | in 1000 days, 2026-09-28 | → | — |
| in 0 days is not a date phrase | in 0 days, 2026-09-28 | → | — |
| a leading zero is not a count | in 03 days, 2026-09-28 | → | — |
| an unknown unit is not a date phrase | in 3 lightyears, 2026-09-28 | → | — |
| an ISO date is itself | 2026-10-01, 2026-09-28 | → | 2026-10-01 |
| 29 February in a leap year is a real date | 2028-02-29, 2026-09-28 | → | 2028-02-29 |
| 30 February is not a date, and not an error | 2026-02-30, 2026-09-28 | → | — |
| an ISO date in the past is allowed | 2020-01-01, 2026-09-28 | → | 2020-01-01 |
| due before a weekday | due friday, 2026-09-28 | → | 2026-10-02 |
| on before an ISO date | on 2026-10-01, 2026-09-28 | → | 2026-10-01 |
| due before in N days, with tabs and runs of spaces | Due in 2 days, 2026-09-28 | → | 2026-09-30 |
| on on its own is not a date phrase | on, 2026-09-28 | → | — |
| the whole text must be the phrase | friday week, 2026-09-28 | → | — |
| words around a phrase are not ignored | pay rent tomorrow, 2026-09-28 | → | — |
| next on its own is not a date phrase | next, 2026-09-28 | → | — |
| empty text is not a date phrase | , 2026-09-28 | → | — |
| whitespace only is not a date phrase | , 2026-09-28 | → | — |
| full-width letters are not ASCII and not a keyword | today, 2026-09-28 | → | — |
| an object key is not a weekday | constructor, 2026-09-28 | → | — |
| today in day/month/year order is refused, even for a phrase that ignores it | 2026-10-01, 28/09/2026 | → | error: is not an ISO date |
| today that never existed is refused | today, 2026-02-30 | → | error: is not a real calendar date |
| tomorrow after 9999-12-31 is out of range | tomorrow, 9999-12-31 | → | error: outside the supported range |
More from the author
todo.parse-quick-add uses it to find the due date inside a whole quick-add line.
## Grammar
The whole text must be one phrase. Letters are matched case-insensitively (ASCII only: `TODAY` in full-width letters is not a keyword). Words are separated by one or more ASCII whitespace characters (space, tab, CR, LF, VT, FF), and whitespace at either end is ignored.
| Phrase | Means | Example, today Monday 2026-09-28 | | --- | --- | --- | | `today`, `tod` | today | 2026-09-28 | | `tomorrow`, `tmr`, `tmrw` | today + 1 day | 2026-09-29 | | `yesterday` | today - 1 day (a due date may be overdue) | 2026-09-27 | | a weekday: `monday`/`mon`, `tuesday`/`tue`/`tues`, `wednesday`/`wed`, `thursday`/`thu`/`thur`/`thurs`, `friday`/`fri`, `saturday`/`sat`, `sunday`/`sun` | the next such day strictly after today (1 to 7 days on) | `friday` = 2026-10-02; `monday` = 2026-10-05 | | `next week` | Monday of next ISO week | 2026-10-05 | | `next <weekday>` | that weekday in next ISO week (Monday to Sunday) | `next friday` = 2026-10-09 | | `next month` | the 1st of next month | 2026-10-01 | | `next year` | 1 January next year | 2027-01-01 | | `weekend`, `this weekend` | the coming Saturday; today if today is Saturday or Sunday | 2026-10-03 | | `in N day(s)` | today + N days | `in 3 days` = 2026-10-01 | | `in N week(s)` | today + 7N days | `in a week` = 2026-10-05 | | `in N month(s)` | today + N months, clamped to the month end (dates.add-months) | `in 1 month` from 2026-01-31 = 2026-02-28 | | `in N year(s)` | today + 12N months, clamped (29 Feb becomes 28 Feb) | `in a year` from 2028-02-29 = 2029-02-28 | | `YYYY-MM-DD` | that date, if it is a real date (years 0001-9999) | `2026-10-01` | | `on <phrase>`, `due <phrase>` | the same as the phrase | `due friday`, `on 2026-10-01` |
N is 1 to 999 in ASCII digits with no leading zero (`in 03 days` is not a phrase), or one of the words `a`, `an`, `one` ... `ten`. Units may be singular or plural whatever N is. Only one `on` or `due` is allowed, and only in front.
Anything else is null: `friday week`, `in 3 lightyears`, `in 0 days`, `in 1000 days`, `2026-02-30`, `next`, an empty string, or a phrase with other words around it (`pay rent tomorrow`; finding a phrase inside a line is todo.parse-quick-add's job).
## Decisions
- **A weekday name never means today.** On a Friday, `friday` is a week away: someone typing a weekday means a day to come, and `today` is the word for today. - **`next <weekday>` means next week's.** On Monday 28 September, `friday` is 2 October and `next friday` is 9 October. People disagree about this one; the ISO-week reading at least gives the two phrases different answers, and a UI can show the date it picked. - **`weekend` on a Sunday is today.** It is still the weekend, and jumping to next Saturday would push the task six days out. - **`in 1 month` clamps**, as dates.add-months does: 31 January + 1 month is 28 February (29 in a leap year), never 3 March. - **An impossible ISO date is null, not an error.** It is something typed, like any other unrecognised text; only `today` is the caller's responsibility.
Files
| Path | Bytes |
|---|---|
| README.md | 3,777 |
| impl/python.py | 3,747 |
| impl/rust.rs | 5,064 |
| impl/typescript.ts | 4,527 |
| vectors.json | 6,903 |