Functional Weave
Code in TypeScript

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

  • parseDatePhrase(today, 2026-09-28) → 2026-09-28 today is today
  • parseDatePhrase(tod, 2026-09-28) → 2026-09-28 tod is short for today
  • parseDatePhrase( 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.

export function parseDatePhrase(text: string, today: string): string | null
textstringwhat was typed, e.g. "due friday", "in 3 days", "next month"
todaydatethe person's local date, which relative phrases count from
returnsdate?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

import { parseDatePhrase } from "#fune/todo.parse-date-phrase@^1";
impl/typescript.ts · 107 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { addDays, daysInMonth } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts";  ← from dates.day-of-week ^1.0.0 · built alongside by fune

// A Map, not an object literal, so "constructor" or "__proto__" is not a weekday.
const WEEKDAYS = new Map<string, number>([
  ["monday", 1], ["mon", 1],
  ["tuesday", 2], ["tue", 2], ["tues", 2],
  ["wednesday", 3], ["wed", 3],
  ["thursday", 4], ["thu", 4], ["thur", 4], ["thurs", 4],
  ["friday", 5], ["fri", 5],
  ["saturday", 6], ["sat", 6],
  ["sunday", 7], ["sun", 7],
]);

const NUMBER_WORDS = new Map<string, number>([
  ["a", 1], ["an", 1], ["one", 1], ["two", 2], ["three", 3], ["four", 4], ["five", 5],
  ["six", 6], ["seven", 7], ["eight", 8], ["nine", 9], ["ten", 10],
]);

const UNITS = new Map<string, string>([
  ["day", "day"], ["days", "day"], ["week", "week"], ["weeks", "week"],
  ["month", "month"], ["months", "month"], ["year", "year"], ["years", "year"],
]);

/** Upper-case ASCII letters only: "É" stays "É", so no other script can spell a keyword. */
function lowerAscii(text: string): string {
  return text.replace(/[A-Z]/g, (c) => c.toLowerCase());
}

function splitWords(text: string): string[] {
  return text.split(/[ \t\n\r\v\f]+/).filter((w) => w !== "");
}

function isDigits(text: string): boolean {
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i);
    if (code < 48 || code > 57) return false;
  }
  return text.length > 0;
}

/** YYYY-MM-DD naming a day that exists; anything else is simply not a date phrase. */
function isRealDate(word: string): boolean {
  if (word.length !== 10 || word[4] !== "-" || word[7] !== "-") return false;
  const y = word.slice(0, 4), m = word.slice(5, 7), d = word.slice(8, 10);
  if (!isDigits(y) || !isDigits(m) || !isDigits(d)) return false;
  const year = Number(y), month = Number(m), day = Number(d);
  return year >= 1 && month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month);
}

/** 1 to 999 in ASCII digits without a leading zero, or a/an/one...ten. */
function count(word: string): number | null {
  if (isDigits(word) && word.length <= 3 && word[0] !== "0") return Number(word);
  return NUMBER_WORDS.get(word) ?? null;
}

/** Days from today to the next given weekday, 1 to 7: never today itself. */
function daysUntil(weekday: number, today: number): number {
  return ((weekday - today + 6) % 7) + 1;
}

/**
 * The date a typed phrase names, counted from `today`, or null when the
 * whole text is not one of the phrases in the README. A weekday name is the
 * next one strictly after today; "next <weekday>" is that day in next ISO
 * week (Monday to Sunday). Months and years clamp to the month end.
 */
export function parseDatePhrase(text: string, today: string): string | null {
  // addDays(x, 0) is the date check: it throws dates.add-days's message.
  addDays(today, 0);
  let words = splitWords(lowerAscii(text));
  if (words.length > 1 && (words[0] === "on" || words[0] === "due")) words = words.slice(1);
  const dow = dayOfWeek(today);

  if (words.length === 1) {
    const word = words[0];
    if (word === "today" || word === "tod") return today;
    if (word === "tomorrow" || word === "tmr" || word === "tmrw") return addDays(today, 1);
    if (word === "yesterday") return addDays(today, -1);
    if (word === "weekend") return dow >= 6 ? today : addDays(today, 6 - dow);
    const weekday = WEEKDAYS.get(word);
    if (weekday !== undefined) return addDays(today, daysUntil(weekday, dow));
    return isRealDate(word) ? word : null;
  }
  if (words.length === 2) {
    const [first, second] = words;
    if (first === "this" && second === "weekend") return dow >= 6 ? today : addDays(today, 6 - dow);
    if (first !== "next") return null;
    const nextMonday = 8 - dow;
    if (second === "week") return addDays(today, nextMonday);
    if (second === "month") return addMonths(today.slice(0, 8) + "01", 1);
    if (second === "year") return addMonths(today.slice(0, 5) + "01-01", 12);
    const weekday = WEEKDAYS.get(second);
    return weekday === undefined ? null : addDays(today, nextMonday + weekday - 1);
  }
  if (words.length === 3 && words[0] === "in") {
    const n = count(words[1]);
    const unit = UNITS.get(words[2]);
    if (n === null || unit === undefined) return null;
    if (unit === "day") return addDays(today, n);
    if (unit === "week") return addDays(today, 7 * n);
    if (unit === "month") return addMonths(today, n);
    return addMonths(today, 12 * n);
  }
  return null;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:

fune add todo.parse-date-phrase
Download for TypeScript todo.parse-date-phrase-1.0.0-typescript.fune · 18,339 bytes sha256 49d9c2d14d2e8cdfc3a33281043690dd0d7f4024d444933d888a6b6e117aaaf1

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./todo.parse-date-phrase-1.0.0-typescript.fune, or fetch it from a terminal with fune pull todo.parse-date-phrase@1.0.0:typescript.

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md3,777
impl/python.py3,747
impl/rust.rs5,064
impl/typescript.ts4,527
vectors.json6,903