✅ fresh
last synced 2026-10-24T20:09:49.281395+00:00 · coverage 100% (
absence-service)Validation by Beadloom
doc_sync— same source assync-check.
Absence service
The owner's absences on command: add, list, delete and edit over the absence store, each answering with a Reply of catalogue messages that the bot sends in the owner's language.
Source
src/horizon_tracker/application/absence_service.py
Specification
Purpose
The owner enters a vacation, a sick leave or a day off by writing to the bot, and later lists, moves or deletes it. The bot loop parses the text; this service carries the command out against the store and says what happened. It decides what the store may hold (no two absences share a day, an id is never issued twice) and how an absence reads back to the owner (its id, its kind and its days with the year).
How it works
Each call loads the book from the store afresh and saves it only when the call changes it, so a refusal leaves the file as it was.
add(kind, first_day, last_day)gives the new absencebook.next_id(), one above every id held or ever issued. When another absence shares a day with it, the call answersbot.error.overlapwith the lowest such id and saves nothing. Otherwise it saves the book with the absence appended andlast_issuedraised to its id, and answersbot.added.list()answersbot.list.emptyfor an empty book. Otherwise it names the absences whose last day is today or later (not_over):bot.list.header(their count,Current and upcoming absences: 2) followed by onebot.list.itemper absence, ordered by first day and then by id, orbot.list.none_aheadwhen every absence is finished. When the book holds finished absences, a last line counts them:bot.list.finished(Finished absences, not listed: 3). The store keeps every absence. Only the reply leaves the finished ones out, because Telegram refuses asendMessageover 4096 characters and a list that grew with every absence ever entered would one day be answered with nothing.delete(id)answersbot.error.not_foundfor an id the book does not hold. Otherwise it saves the book without the absence, withlast_issuedunchanged, and answersbot.deletednaming what was deleted.edit(id, kind, first_day, last_day)answersbot.error.not_foundfor an unknown id. Otherwise the absence keeps its id and position, takes the new days and takeskindunless it isNone. An overlap with another absence is refused as inadd. The days the absence itself held do not count as an overlap. The answer isbot.edited.
A reply names an absence by bot.kind.<kind> and bot.days.single (one day) or bot.days.span, with the dates as dd.mm.yyyy, so a date typed without a year is read back in full: Absence 1 entered: vacation from 03.11.2026 to 14.11.2026.
An absence for today once the check-in range has begun
From the start of today's check-in range (HT_CHECKIN_RANGE, its start included) the morning run may already have checked in, so an add or edit whose days cover today starts the absence tomorrow: first_day becomes tomorrow, the overlap is judged on the days from tomorrow, and the reply gets a second line, bot.note.after_checkin_range: Today's check-in range began at 07:30, so an absence starts tomorrow at the earliest; this evening's check-out runs as usual. Days that would end today (a one-day absence for today, or yesterday and today) leave nothing to enter: the call is refused with that line alone, saves nothing and issues no id.
An edit of an absence that already covers today keeps today: the morning run was skipped for it, so the evening run stays a silent skip, and moving its start to tomorrow would send the evening run to a desktop never opened. Before the range begins, an absence for today is entered as written and the morning run skips for it.
With this rule the run loop needs no state for the evening: an absence covering today was there before the morning run, so a morning logon belongs to a day no absence covers (CONTEXT, the coordinator's ruling of 2026-10-24).
Invariants
- Two absences in the store never share a day.
- An id is never issued twice:
deletekeepslast_issued, andaddallocates above it. editkeeps the absence's id.- A refused call (overlap, not found, an absence that would end today once the range began) saves nothing.
- From the start of today's check-in range, no
addoreditmakes an absence cover today that did not already. - Every call loads the store once; a call that changes nothing saves nothing.
- The service picks no language: a
Replyis messages, rendered by the caller. - A store that cannot be read or written raises
AbsenceStoreErrorto the caller. listnames no absence whose last day is before today, and saves nothing.
API
Module src/horizon_tracker/application/absence_service.py:
Reply(lines: tuple[Message, ...])— frozen, at least one line (ValueErrorotherwise);Reply.of(*lines);in_language(language) -> str, the lines joined by a line break.AbsenceService(store: AbsenceStore, clock: Clock, checkin_range: TimeRange):add(kind: AbsenceKind, first_day: date, last_day: date) -> Reply;list() -> Reply;delete(absence_id: int) -> Reply;edit(absence_id: int, kind: AbsenceKind | None, first_day: date, last_day: date) -> Reply.
not_over(absences, today) -> tuple[Absence, ...]— the absences whose last day istodayor later, in their order;listand the probe'sabsencesline count by it.
The CLI builds it in horizon bot as AbsenceService(adapters.absences, adapters.clock, settings.checkin_range).
Testing
Tests: tests/unit/application/test_absence_service.py (on FakeAbsenceStore and FakeClock), tests/unit/test_cli.py (the file under HT_STATE_DIR).
Scenarios (tests/acceptance/features/absences.feature):
An absence entered through the bot is confirmed with its dates and an idThe list names every absence with its id, kind and datesThe list leaves out the finished absences and counts themA deleted absence no longer stops a runAn edited absence keeps its id and takes the new dates
Dependencies
Derived from the imports on reindex.
- Depends on:
absence,messages,ports,schedule - Used by:
bot-loop,cli,probe(not_over)
Parent
application — application README