dates.add-days
Shift an ISO date by a whole number of days, forwards or backwards, with exact calendar arithmetic.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 28 tests, run in TypeScript, Python and Rust.
What it does
This is the civil-date kernel for the whole dates family: days-from-civil and civil-from-days, converting a calendar date to a day number counted from 1970-01-01 and back. Every other dates capability imports it rather than re-deriving the arithmetic.
No date library is used in any of the three languages, and that is a decision, not an omission. JavaScript's Date parses "2026-09-16" as UTC midnight but new Date(2026, 8, 16) as local midnight, so the same calendar day can come back a day out depending on the machine's timezone, and setMonth rolls 31 January into 3 March. Python's datetime and Rust's chrono are each correct on their own but would mean three different libraries answering the same question, which is exactly what the parity vectors exist to rule out.
For example
add_days(2026-09-16, 1)→ 2026-09-17 one day forward mid-monthadd_days(2026-09-16, 0)→ 2026-09-16 zero days is the same date backadd_days(2024-02-28, 1)→ 2024-02-29 2024 is a leap year so 28 February is followed by the 29th
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.
def add_days(iso: str, days: int) -> str
| iso | date | ISO date, YYYY-MM-DD |
| days | int | whole days to add; negative moves backwards |
| returns | date |
The type it declares, generated into your project
@dataclass(frozen=True)
class CivilDate:
"""A calendar date as numbers, for the arithmetic other date capabilities do."""
year: int
#: 1 to 12
month: int
#: 1 to 31
day: int
Your code names it in one line, in the file that uses it
from fune.dates.add_days import add_days # dates.add-days@^1
"""Civil-date arithmetic on ISO "YYYY-MM-DD" strings.
This deliberately does not use the ``datetime`` module. The point of the
registry is that one algorithm runs in three languages and the vectors prove
they agree; handing the work to each language's own date library would prove
only that three different libraries were consulted. Doing the arithmetic
explicitly also keeps the Python readable next to the TypeScript and the Rust,
which is how a reviewer checks that they are the same function.
The kernel is the days-from-civil / civil-from-days pair: a calendar date is
converted to a day number counted from 1970-01-01, shifted, and converted back.
Every division below has non-negative operands inside the supported year range,
so Python's floor division agrees exactly with Rust's truncating division and
with Math.floor in TypeScript.
"""
from .dates_add_days_types import CivilDate
# 0001-01-01 and 9999-12-31 as epoch days: the range a 4-digit ISO year can express.
MIN_EPOCH_DAY = -719162
MAX_EPOCH_DAY = 2932896
MONTH_LENGTHS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
def is_leap_year(year: int) -> bool:
"""The Gregorian rule in full: every 4th year, except every 100th, except
every 400th. 1900 was not a leap year and 2000 was, and code that only
tests ``year % 4`` gets one of those two wrong.
"""
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def days_in_month(year: int, month: int) -> int:
if month < 1 or month > 12:
raise ValueError("month must be 1-12, received %s" % (month,))
if month == 2 and is_leap_year(year):
return 29
return MONTH_LENGTHS[month - 1]
def _is_digits(value: str, start: int, stop: int) -> bool:
for i in range(start, stop):
if not ("0" <= value[i] <= "9"):
return False
return True
def parse_iso_date(iso: str) -> CivilDate:
"""Parse and validate an ISO date.
The two shapes of bad input are rejected separately: a string that is not
an ISO date at all, and a well-formed string naming a day that never
existed (2026-02-30). The second is the dangerous one, because a permissive
parser turns it into 2026-03-02 and nobody notices.
"""
if (
not isinstance(iso, str)
or len(iso) != 10
or iso[4] != "-"
or iso[7] != "-"
or not _is_digits(iso, 0, 4)
or not _is_digits(iso, 5, 7)
or not _is_digits(iso, 8, 10)
):
raise ValueError('"%s" is not an ISO date (YYYY-MM-DD)' % (iso,))
year = int(iso[0:4])
month = int(iso[5:7])
day = int(iso[8:10])
if year < 1:
raise ValueError('"%s" is outside the supported range 0001-01-01 to 9999-12-31' % (iso,))
if month < 1 or month > 12 or day < 1 or day > days_in_month(year, month):
raise ValueError('"%s" is not a real calendar date' % (iso,))
return CivilDate(year=year, month=month, day=day)
def format_iso_date(date: CivilDate) -> str:
return "%04d-%02d-%02d" % (date.year, date.month, date.day)
def days_from_civil(year: int, month: int, day: int) -> int:
"""Days from 1970-01-01 to a civil date. Assumes the date has been validated."""
# March-based years put the leap day last, so the month-length pattern
# becomes a simple linear formula and no special case for February is needed.
y = year - (1 if month <= 2 else 0)
era = y // 400
year_of_era = y - era * 400
day_of_year = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
day_of_era = year_of_era * 365 + year_of_era // 4 - year_of_era // 100 + day_of_year
return era * 146097 + day_of_era - 719468
def civil_from_days(epoch_day: int) -> CivilDate:
"""The exact inverse of days_from_civil."""
# 146097 days is exactly 400 years, which is why the Gregorian calendar
# repeats on that cycle and why this conversion needs no lookup table.
z = epoch_day + 719468
era = z // 146097
day_of_era = z - era * 146097
year_of_era = (day_of_era - day_of_era // 1460 + day_of_era // 36524 - day_of_era // 146096) // 365
y = year_of_era + era * 400
day_of_year = day_of_era - (365 * year_of_era + year_of_era // 4 - year_of_era // 100)
month_prime = (5 * day_of_year + 2) // 153
day = day_of_year - (153 * month_prime + 2) // 5 + 1
month = month_prime + (3 if month_prime < 10 else -9)
return CivilDate(year=y + (1 if month <= 2 else 0), month=month, day=day)
def epoch_day_from_iso(iso: str) -> int:
"""An ISO date as a day number counted from 1970-01-01. Negative before then."""
date = parse_iso_date(iso)
return days_from_civil(date.year, date.month, date.day)
def iso_from_epoch_day(epoch_day: int) -> str:
"""The inverse: a day number back to an ISO date, refusing years outside 0001-9999."""
if isinstance(epoch_day, bool) or not isinstance(epoch_day, int) or epoch_day < MIN_EPOCH_DAY or epoch_day > MAX_EPOCH_DAY:
raise ValueError("day %s is outside the supported range 0001-01-01 to 9999-12-31" % (epoch_day,))
return format_iso_date(civil_from_days(epoch_day))
def add_days(iso: str, days: int) -> str:
"""Shift an ISO date by a whole number of days, forwards or backwards.
Month ends and leap days need no special handling: the shift happens on the
day number, so 2024-02-28 + 1 is 2024-02-29 and 2026-02-28 + 1 is
2026-03-01 for the same reason, without a branch for either.
"""
if isinstance(days, bool) or not isinstance(days, int):
raise TypeError("days must be an integer, received %r" % (days,))
return iso_from_epoch_day(epoch_day_from_iso(iso) + days)
def days_between(start_iso: str, end_iso: str) -> int:
"""Whole days from one date to another, negative when the second is earlier."""
return epoch_day_from_iso(end_iso) - epoch_day_from_iso(start_iso)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python 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 dates.add-days
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./dates.add-days-1.0.0-python.fune, or fetch it from a terminal with fune pull dates.add-days@1.0.0:python.
The whole function, every language, is one file too: dates.add-days-1.0.0.fune, 26,107 bytes, sha256 c8546db9870fee4bc128650b0c3801cf6b3a0bc4daa6338c53a5f3b6d15a79e5. 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 dates.add-days
after — your function gets the result and the arguments, and returns the final result.
# fune: after dates.add-days
replace — it requires no other capability, so there is no dependency to replace.
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 dates.add-days --steps.
# fune: step dates.add-days 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 | |
|---|---|---|---|
| one day forward mid-month | 2026-09-16, 1 | → | 2026-09-17 |
| zero days is the same date back | 2026-09-16, 0 | → | 2026-09-16 |
| 2024 is a leap year so 28 February is followed by the 29th | 2024-02-28, 1 | → | 2024-02-29 |
| 2026 is not a leap year so 28 February rolls into March | 2026-02-28, 1 | → | 2026-03-01 |
| 1900 was a century year and not a leap year | 1900-02-28, 1 | → | 1900-03-01 |
| 2000 was divisible by 400 and was a leap year | 2000-02-28, 1 | → | 2000-02-29 |
| 2100 is a century year and will not be a leap year | 2100-02-28, 1 | → | 2100-03-01 |
| 31 January rolls to 1 February, never to 3 March | 2026-01-31, 1 | → | 2026-02-01 |
| 31 March rolls to 1 April | 2026-03-31, 1 | → | 2026-04-01 |
| year rollover | 2026-12-31, 1 | → | 2027-01-01 |
Show the other 18 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| year rollover into a leap year | 1999-12-31, 1 | → | 2000-01-01 |
| negative offset crosses back over new year | 2027-01-01, -1 | → | 2026-12-31 |
| negative offset lands on the leap day | 2024-03-01, -1 | → | 2024-02-29 |
| negative offset skips the leap day that does not exist | 1900-03-01, -1 | → | 1900-02-28 |
| 365 days in a common year lands on the same date | 2025-03-01, 365 | → | 2026-03-01 |
| 365 days across a leap day lands a day short | 2024-01-01, 365 | → | 2024-12-31 |
| a large negative offset | 2026-09-16, -1,000 | → | 2023-12-21 |
| a large positive offset | 2026-09-16, 10,000 | → | 2054-02-01 |
| dates before the 1970 epoch work the same way | 1969-12-31, 1 | → | 1970-01-01 |
| a slash-separated date is an error, not a guess | 16/09/2026, 1 | → | error: is not an ISO date |
| an unpadded month is an error | 2026-9-16, 1 | → | error: is not an ISO date |
| a date with a time attached is an error | 2026-09-16T00:00:00Z, 1 | → | error: is not an ISO date |
| 30 February is not a real date and is not silently rolled forward | 2026-02-30, 1 | → | error: is not a real calendar date |
| 29 February in a non-leap year is rejected | 2026-02-29, 1 | → | error: is not a real calendar date |
| month 13 is rejected | 2026-13-01, 1 | → | error: is not a real calendar date |
| 31 April is rejected | 2026-04-31, 1 | → | error: is not a real calendar date |
| a fractional day count is an error | 2026-09-16, 1.5 | → | error: days must be an integer |
| running off the end of the supported range is an error | 9999-12-31, 1 | → | error: outside the supported range |
More from the author
Impossible dates are rejected rather than normalised. 2026-02-30 is an error, not 2026-03-02: a parser that silently rolls a bad date forward turns a data-entry mistake into a plausible wrong answer.
Supported range is 0001-01-01 to 9999-12-31, the dates a four-digit ISO year can express. Inside that range every division in the kernel has non-negative operands, so truncating division in Rust, floor division in Python and Math.floor in TypeScript all agree.
Files
| Path | Bytes |
|---|---|
| README.md | 1,255 |
| impl/python.py | 5,846 |
| impl/rust.rs | 6,800 |
| impl/typescript.ts | 6,218 |
| vectors.json | 3,217 |