Functional Weave
Code in TypeScript

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.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 21 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/26
  • academicYear(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 begun
  • academicYear(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
onDatedatethe date to place
yearStartstring?MM-DD the year begins on; null for 09-01, the academic year in England
termsTerm[]the institution's term dates, in any order, not overlapping; may be empty
returnsAcademicYear

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";
impl/typescript.ts · 52 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, 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
Download for TypeScript education.academic-year-1.0.0-typescript.fune · 15,049 bytes sha256 40359926d68a913c6677edf2274247ee9feaec449489a4eeeb4134620ef70b91

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

The whole function, every language, is one file too: education.academic-year-1.0.0.fune, 21,127 bytes, sha256 0d7011288ad1a4b1e0b788524b38b00b591e3b84038b55996cc81045f09a3a2a. 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.

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

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.

Files

PathBytes
README.md1,326
impl/python.py2,246
impl/rust.rs3,608
impl/typescript.ts2,180
vectors.json7,254