education.academic-year
The academic year and term a date falls in: 1 September to 31 August by default, terms from the caller.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 23 tests, run in TypeScript, Python and Rust.
What it does
Which academic year a date falls in (label, first and last day), and which of the caller's terms, if any, contains it.
## Decisions
For example
academicYear(2025-10-15, —, terms ×3)→ label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19 mid-October is autumn term of 2025/26academicYear(2025-09-01, —, terms ×3)→ label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — 1 September starts the year, but term has not begunacademicYear(2025-08-31, —, terms ×3)→ label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end — 31 August is the last day of the previous year
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 academicYear(onDate: string, yearStart: string | null, terms: readonly Term[]): AcademicYear
| onDate | date | the date to place |
| yearStart | string? | MM-DD the year begins on; null for 09-01, the academic year in England |
| terms | Term[] | the institution's term dates, in any order, not overlapping; may be empty |
| returns | AcademicYear |
The types it declares, generated into your project
/** One term or semester, first and last day inclusive. */
export interface Term {
readonly name: string;
readonly start: string;
readonly end: string;
}
/** The academic year containing a date, and the term if the date is in one. */
export interface AcademicYear {
/** 2025/26; a year starting 1 January is labelled 2026 */
readonly label: string;
/** first day of the academic year */
readonly start: string;
/** last day, inclusive: the day before the next year starts */
readonly end: string;
/** the term containing onDate; null in a holiday */
readonly term: string | null;
readonly termStart: string | null;
readonly termEnd: string | null;
}
Your code names it in one line, in the file that uses it
import { academicYear } from "#fune/education.academic-year@^1";
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, formatIsoDate, parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { type AcademicYear, type Term } from "./education_academic_year_types.ts";
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
/**
* The academic year and term for a date.
*
* The end is the day before the next year's start, worked out by date
* arithmetic, so a 1 March start ends on 29 February in a leap year.
*/
export function academicYear(onDate: string, yearStart: string | null, terms: readonly Term[]): AcademicYear {
const on = parseIsoDate(onDate);
const startText = yearStart === null ? "09-01" : yearStart;
const match = MONTH_DAY.exec(startText);
const month = match === null ? 0 : Number(match[1]);
const day = match === null ? 0 : Number(match[2]);
if (match === null || month < 1 || month > 12 || day < 1 || day > daysInMonth(2001, month)) {
throw new RangeError(`yearStart must be MM-DD on a day every year has, received "${startText}"`);
}
const sorted = [...terms].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
for (const t of sorted) {
parseIsoDate(t.start);
parseIsoDate(t.end);
if (t.end < t.start) {
throw new RangeError(`term ${t.name} ends (${t.end}) before it starts (${t.start})`);
}
}
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].start <= sorted[i - 1].end) {
throw new RangeError(`terms ${sorted[i - 1].name} and ${sorted[i].name} overlap`);
}
}
const beforeStart = on.month < month || (on.month === month && on.day < day);
const startYear = beforeStart ? on.year - 1 : on.year;
const start = formatIsoDate({ year: startYear, month, day });
const end = addDays(formatIsoDate({ year: startYear + 1, month, day }), -1);
const endYear = Number(end.slice(0, 4));
const label = endYear === startYear ? String(startYear) : `${startYear}/${String(endYear % 100).padStart(2, "0")}`;
const term = sorted.find((t) => t.start <= onDate && onDate <= t.end) ?? null;
return {
label,
start,
end,
term: term === null ? null : term.name,
termStart: term === null ? null : term.start,
termEnd: term === null ? null : term.end,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, 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 education.academic-year
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./education.academic-year-1.0.1-typescript.fune, or fetch it from a terminal with fune pull education.academic-year@1.0.1:typescript.
The whole function, every language, is one file too: education.academic-year-1.0.1.fune, 21,655 bytes, sha256 1fb1026f5bc09794198c4d38378e7004589a623292db9380b50098ecd9c976e8. 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 education.academic-year
after — your function gets the result and the arguments, and returns the final result.
// fune: after education.academic-year
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 education.academic-year
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 education.academic-year --steps.
// fune: step education.academic-year 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 | |
|---|---|---|---|
| mid-October is autumn term of 2025/26 | 2025-10-15, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19 |
| 1 September starts the year, but term has not begun | 2025-09-01, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — |
| 31 August is the last day of the previous year | 2025-08-31, —, terms ×3 | → | label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end — |
| Christmas is in the year but in no term | 2025-12-25, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — |
| a term's last day is inside it | 2025-12-19, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19 |
| a term's first day is inside it | 2026-04-13, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term summer, term start 2026-04-13, term end 2026-07-22 |
| the summer holiday after the last term | 2026-08-15, —, terms ×3 | → | label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — |
| no terms given | 2026-02-01, —, | → | label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — |
| an August start | 2025-08-01, 08-01, | → | label 2025/26, start 2025-08-01, end 2026-07-31, term —, term start —, term end — |
| the day before an August start | 2025-07-31, 08-01, | → | label 2024/25, start 2024-08-01, end 2025-07-31, term —, term start —, term end — |
Show the other 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a 1 January start is one calendar year | 2026-06-30, 01-01, | → | label 2026, start 2026-01-01, end 2026-12-31, term —, term start —, term end — |
| the century turns in the label | 1999-12-01, —, | → | label 1999/00, start 1999-09-01, end 2000-08-31, term —, term start —, term end — |
| a 1 March start ends on 29 February in a leap year | 2024-02-29, 03-01, | → | label 2023/24, start 2023-03-01, end 2024-02-29, term —, term start —, term end — |
| and on 28 February otherwise | 2025-02-28, 03-01, | → | label 2024/25, start 2024-03-01, end 2025-02-28, term —, term start —, term end — |
| a term outside the year is still found by date | 2025-09-25, 10-01, terms ×1 | → | label 2024/25, start 2024-10-01, end 2025-09-30, term welcome week, term start 2025-09-22, term end 2025-09-26 |
| 29 February is not a day every year has | 2025-10-15, 02-29, | → | error: yearStart must be MM-DD on a day every year has, received "02-29" |
| a thirteenth month is an error | 2025-10-15, 13-01, | → | error: yearStart must be MM-DD on a day every year has, received "13-01" |
| an unpadded yearStart is an error | 2025-10-15, 9-1, | → | error: yearStart must be MM-DD on a day every year has, received "9-1" |
| a term ending before it starts is an error | 2025-10-15, —, terms ×1 | → | error: term autumn ends (2025-09-03) before it starts (2025-12-19) |
| overlapping terms are an error | 2025-10-15, —, terms ×2 | → | error: terms autumn and winter overlap |
| an impossible date is an error | 2025-02-30, —, | → | error: "2025-02-30" is not a real calendar date |
| a yearStart with a trailing newline is an error | 2025-10-15, 09-01 , | → | error: yearStart must be MM-DD on a day every year has, received "09-01 " |
| a yearStart in Arabic-Indic digits is an error | 2025-10-15, ٠٩-٠١, | → | error: yearStart must be MM-DD on a day every year has, received "٠٩-٠١" |
More from the author
- **The year runs from `yearStart` to the day before the next one.** With `null` it is 1 September to 31 August, the academic year the Department for Education uses in England for school years, admissions and statistics. Pass `08-01` for a year that starts in August (Scottish schools, many universities), or whatever your institution uses. - **Term dates are arguments, not data.** Every local authority, academy trust and university sets its own, so there is nothing the registry could carry that would be right. A date between terms is in the academic year but in no term: `term` is null. - A term is found by date alone, so it need not lie inside the academic year (a university's September welcome week before a `10-01` year start still resolves). Overlapping terms are refused, since a date would be in two. - The label is `2025/26`, with the second year as two digits (`1999/00`); a year starting on 1 January is one calendar year and is labelled `2026`. - `yearStart` must be a day every year has, so `02-29` is refused. A `03-01` start ends on 29 February in a leap year: the end is the day before the next start, not a fixed date.
1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in yearStart; adds tests.
Files
| Path | Bytes |
|---|---|
| README.md | 1,421 |
| impl/python.py | 2,254 |
| impl/rust.rs | 3,608 |
| impl/typescript.ts | 2,180 |
| vectors.json | 7,638 |