stejac / zeitangaben
Resolves colloquial German time expressions such as Herbstferien, ueber Ostern or morgen to a period or a reasoned clarification.
Requires
- php: >=8.3
- ext-json: *
- azuyalabs/yasumi: ^2.12
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 20:39:22 UTC
README
Resolves colloquial German time expressions such as "in den Herbstferien", "über Ostern" or "morgen" to a concrete period, or to a clarifying question with the reason why it could not decide.
Why this one?
"In den Herbstferien" has no single answer: the dates depend on the German state. "Über Ostern" can mean the public holidays or the school break. Most date parsers either guess or return nothing. To our knowledge, none of the common ones knows school holidays by state: Duckling knows public holidays only, and the documentation of dateparser and chrono does not mention holidays.
This library resolves what is certain and asks when it is not. Every answer is one of three results, so a chatbot, an agent or a form can handle it without guessing:
Resolved: one period, start and end inclusive.Clarification: a reason, a ready-to-ask Germanquestionand, where possible,candidatesthe user can pick from.NoExpression: no expression was recognised in the text (see whatnonemeans).
Further properties, all covered by tests:
- The library never reads the system clock. Relative terms need an explicit reference date, so the same message gives the same answer whenever it is processed.
- Public holidays come from Yasumi, school holidays from a bundled data file (see Data). No network access at runtime.
- Pure PHP, PHP 8.3 or newer.
Quickstart
composer require stejac/zeitangaben
<?php require 'vendor/autoload.php'; use Stejac\Zeitangaben\{Clarification, Context, Resolved, Resolver, State}; $context = new Context(new DateTimeImmutable('2026-09-16'), State::NI); $result = Resolver::create()->resolve('in den Herbstferien', $context); if ($result instanceof Resolved) { echo $result->period->start->format('d.m.Y'), ' bis ', $result->period->end->format('d.m.Y'), "\n"; } elseif ($result instanceof Clarification) { echo $result->question, "\n"; }
Output:
12.10.2026 bis 24.10.2026
The reference date is required for relative terms ("morgen", "nächstes Wochenende", "im Herbst") and for deciding which year is meant. It is a parameter on purpose: a message that is read a day later must still resolve to the same date. If it is missing, the result is a Clarification with the reason missing_reference_date. A state named in the text after a word such as "in", "aus" or "nach" ("Herbstferien in Bayern") takes precedence over the state in the context. A bare name ("Frau Berlin", "FC Bayern") is not read as the state: if the answer depends on the state, the library asks, and it also asks when two different states are named.
Sentences or expressions?
Pass the time expression alone wherever you can, for example one extracted by an LLM. That gives the most answers, because Resolved means certain: it stands only if everything else in the input is known to be harmless. The input may hold a greeting, a state mention and a closed list of function words and common verbs ("Wir möchten gerne im Juli anreisen"). Any other word or number turns the result into a question with reason unclear_context and the found period as its only candidate, because the library cannot know what that word does to the meaning. With the reference date 2026-10-30:
| Input | Result |
|---|---|
im Juli |
Resolved |
Wir möchten gerne im Juli anreisen |
Resolved |
Keine Termine im Juli |
unclear_context: „Meinen Sie „im Juli“, also 01.07.2027 bis 31.07.2027?“ |
Silvester 2000 Euro |
unclear_context: „Meinen Sie „Silvester“, also 31.12.2026?“ |
The list holds no negations, no numbers and no nouns that could name something else. A year next to the expression belongs to the expression. Two digits count as a year only from 00 to 69, and only directly after a numeric date, behind an apostrophe ("im Juli '27") or at the very end of the input.
Handling a clarification
<?php require 'vendor/autoload.php'; use Stejac\Zeitangaben\{Clarification, Context, Resolver, State}; $context = new Context(new DateTimeImmutable('2026-09-16'), State::NI); $result = Resolver::create()->resolve('über Ostern', $context); if ($result instanceof Clarification) { echo $result->reason->value, ': ', $result->question, "\n"; foreach ($result->candidates as $i => $candidate) { echo $i + 1, ') ', $candidate->label, ', ', $candidate->period->start->format('Y-m-d'), ' to ', $candidate->period->end->format('Y-m-d'), "\n"; } }
Output:
ambiguous: Meinen Sie bei „über Ostern“ die Feiertage (26.03. bis 29.03.2027) oder die Schulferien (22.03. bis 03.04.2027)?
1) Karfreitag bis Ostermontag, 2027-03-26 to 2027-03-29
2) Osterferien, 2027-03-22 to 2027-04-03
question is written for the end user and can be shown as it is, by a form or by an LLM that relays it. candidates are the valid answers: render them as buttons or a select, or hand them to the model as the allowed choices, then continue with the chosen period. For missing_state there are no candidates; ask the question, then call resolve() again with a Context that has the state. Every result is JSON-serialisable (json_encode($result)):
{
"type": "clarification",
"reason": "ambiguous",
"question": "Meinen Sie bei „über Ostern“ die Feiertage (26.03. bis 29.03.2027) oder die Schulferien (22.03. bis 03.04.2027)?",
"expression": "über Ostern",
"candidates": [
{"start": "2027-03-26", "end": "2027-03-29", "label": "Karfreitag bis Ostermontag"},
{"start": "2027-03-22", "end": "2027-04-03", "label": "Osterferien"}
]
}
A resolved school holiday also says which state it was read for; state is null for everything else:
{
"type": "resolved",
"start": "2026-10-12",
"end": "2026-10-24",
"expression": "Herbstferien",
"notes": [],
"state": "NI"
}
Command line
vendor/bin/zeitangaben "in den Herbstferien" --state=NI --date=2026-09-16 vendor/bin/zeitangaben "Sommerferien" --date=2026-09-16 --json
Options: --state=<code> (two-letter state code such as NI, BY, NW), --date=YYYY-MM-DD (reference date), --json, --help. Exit code 0 for every result including a clarification, 2 for invalid arguments. The human-readable output is German.
What a result can be
Reasons for a clarification
| Reason | Meaning |
|---|---|
missing_state |
The expression depends on the state ("Sommerferien") and none was given. |
missing_reference_date |
A relative expression ("morgen") and no reference date was given. |
ambiguous |
Two or more readings are plausible ("über Ostern": holidays or school break). candidates lists them. |
vague |
The expression names no exact period ("Anfang Oktober", "Frühling"). candidates offers a likely reading. |
not_a_calendar_term |
The term is jargon, not a calendar term ("Nebensaison"). |
out_of_data |
School holidays are requested for a year the bundled data does not cover. |
not_in_state |
The state has no such holidays, for example Pfingstferien in Hessen. |
invalid_date |
The date does not exist ("31.2.", a calendar week 60), the weekday contradicts it ("Mo, 12.06.2027") or a range runs backwards ("vom 19. bis 12. Oktober"). |
multiple_expressions |
The text contains several time expressions; the question names them. |
unclear_context |
One expression was found, but the input holds a word or number the library does not know to be harmless ("Keine Termine im Juli"). The only candidate is the found period; see Sentences or expressions? |
What none means
NoExpression ({"type": "none"}) means the text was not recognised, not that it contains no date. Recognition is rule-based and has gaps. Not recognised for now, for example: „erste Juliwoche“, „Ende des Monats“, „in 3 Monaten“. Treat none as "ask the user or fall back", never as proof that no time was meant.
Past tense is not modelled, so „Ostern war schön“ still asks about the next Easter.
Public API
These types are the public API and follow semantic versioning: Resolver, Context, State (its cases, germanName() and locative(); the methods marked @internal are excluded), Result with Resolved, Clarification and NoExpression, ClarificationReason, Candidate, Period, InvalidPeriodException, and the provider interfaces School\SchoolHolidayProvider and Holiday\PublicHolidayProvider with their value types. Everything marked @internal (Rule\*, Calendar\*, Text\*, Cli\*) can change in any release.
Features
| Feature | Status |
|---|---|
| Explicit dates and calendar weeks ("am 3. Oktober", "KW 42") | beta |
| Relative days, weeks and weekends ("morgen", "nächste Woche") | beta |
| Months and parts of months, seasons | beta |
| Feasts and long weekends (Ostern, Pfingsten, Weihnachten, Brückentage) | beta |
| School holidays per state, 2026 to 2029 | beta |
| Clarifications with reason, question and candidates | beta |
| MCP tool for use from LLM clients | planned |
| Stay length ("eine Woche im Herbst") | out of scope |
The rules are covered by unit tests and by a corpus test with 273 expressions (tests/Corpus/).
Data
School holidays come from the OpenHolidays API and are bundled in data/school-holidays-de.json. Public holidays are computed by Yasumi.
- Complete through 2029 for Easter (not Hamburg), summer, autumn and Christmas holidays. Pentecost and winter holidays exist only where the state has them, and gaps are possible. 2030 is partial; a gap or a later year gives
out_of_data. - Mecklenburg-Vorpommern: dates of general schools. Schleswig-Holstein: dates of the mainland (not Sylt, Föhr, Amrum, Helgoland and the Halligen).
- Hamburg has Frühjahrsferien instead of Osterferien; they are kept apart from Easter.
Details, name mapping and known gaps are in data/README.md. The data is licensed under the ODbL 1.0 (attribution: OpenHolidays API); the code is MIT. If you redistribute the data or a derived database, the ODbL applies to it.
Security
Read SECURITY.md before reporting a vulnerability. The library processes text only and performs no I/O besides reading its bundled data file.
Contributing
Issues and pull requests are welcome, see CONTRIBUTING.md. A wrongly resolved or unrecognised expression is a useful bug report: include the text, the state and the reference date.
About
Built and maintained by Stefan Jacomeit, freelance PHP/Symfony developer with 25+ years of experience, building AI integrations for businesses.
Credits
OpenHolidays API for the school holiday data and Yasumi for public holidays.
