holiplan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
holiplan/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,116 @@
1
+ """Bridge days: the leave days that buy the most time off.
2
+
3
+ The calendar splits into alternating runs of free days (weekends, public
4
+ holidays) and gaps of workdays. Taking leave on one gap -- or on a few
5
+ neighbouring gaps -- joins the free runs either side into one stretch off.
6
+ Every such choice within the leave limit is an option, scored by days off per
7
+ leave day. Only stretches with a public holiday on a workday count: without
8
+ one, it's an ordinary long weekend, not a bridge.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Set as AbstractSet
14
+ from dataclasses import dataclass
15
+ from datetime import date, timedelta
16
+
17
+ from .leave import daterange
18
+ from .models import DayPattern
19
+
20
+ # How far past the year's edges to look for free days to join onto.
21
+ _MARGIN = timedelta(days=14)
22
+
23
+
24
+ @dataclass
25
+ class BridgeOption:
26
+ leave_dates: list[str]
27
+ leave_days: float
28
+ start: str # first day off
29
+ end: str # last day off
30
+ days_off: int
31
+ days_off_per_leave_day: float
32
+ public_holidays: list[str] # the workday holidays that make it a bridge
33
+ school: str # "holiday", "partly" or "term": whether the kids are off too
34
+
35
+ def as_dict(self) -> dict:
36
+ return self.__dict__.copy()
37
+
38
+
39
+ def bridge_days(
40
+ year: int,
41
+ public_holidays: AbstractSet[str],
42
+ pattern: DayPattern | None = None,
43
+ half_holidays: AbstractSet[str] = frozenset(),
44
+ max_leave: float = 4,
45
+ school_windows: list[tuple[date, date]] | None = None,
46
+ ) -> list[BridgeOption]:
47
+ """Every bridge in `year` costing at most `max_leave`, best value first.
48
+
49
+ `public_holidays` should reach a couple of weeks past the year's edges so
50
+ stretches around New Year are measured correctly. `school_windows` are
51
+ effective (start, end) school holidays, used to say whether the children
52
+ are off during each option. Options may overlap; they are alternatives.
53
+ """
54
+ pattern = pattern or DayPattern()
55
+ first, last = date(year, 1, 1), date(year, 12, 31)
56
+
57
+ def free(day: date) -> bool:
58
+ return not pattern.is_workday(day) or day.isoformat() in public_holidays
59
+
60
+ def weight(day: date) -> float:
61
+ w = pattern.leave_weight(day)
62
+ return min(w, 0.5) if day.isoformat() in half_holidays else w
63
+
64
+ # Alternating runs: (is_free, [days]).
65
+ runs: list[tuple[bool, list[date]]] = []
66
+ for day in daterange(first - _MARGIN, last + _MARGIN):
67
+ if runs and runs[-1][0] == free(day):
68
+ runs[-1][1].append(day)
69
+ else:
70
+ runs.append((free(day), [day]))
71
+
72
+ options: list[BridgeOption] = []
73
+ for i, (is_free, _) in enumerate(runs):
74
+ if is_free or i == 0:
75
+ continue
76
+ leave: list[date] = []
77
+ for j in range(i, len(runs) - 1, 2): # gaps i, i+2, ... with free runs between
78
+ leave += runs[j][1]
79
+ cost = sum(weight(d) for d in leave)
80
+ if cost > max_leave:
81
+ break
82
+ if leave[0] < first or leave[-1] > last:
83
+ continue
84
+ span = runs[i - 1][1][0], runs[j + 1][1][-1]
85
+ holidays = [
86
+ d.isoformat()
87
+ for d in daterange(*span)
88
+ if pattern.is_workday(d) and d.isoformat() in public_holidays
89
+ ]
90
+ if not holidays:
91
+ continue
92
+ days_off = (span[1] - span[0]).days + 1
93
+ options.append(
94
+ BridgeOption(
95
+ leave_dates=[d.isoformat() for d in leave],
96
+ leave_days=cost,
97
+ start=span[0].isoformat(),
98
+ end=span[1].isoformat(),
99
+ days_off=days_off,
100
+ days_off_per_leave_day=round(days_off / cost, 2),
101
+ public_holidays=holidays,
102
+ school=_school(span, school_windows),
103
+ )
104
+ )
105
+
106
+ options.sort(key=lambda o: (-o.days_off_per_leave_day, -o.days_off, o.start))
107
+ return options
108
+
109
+
110
+ def _school(span: tuple[date, date], windows: list[tuple[date, date]] | None) -> str:
111
+ windows = windows or []
112
+ if any(start <= span[0] and span[1] <= end for start, end in windows):
113
+ return "holiday"
114
+ if any(start <= span[1] and span[0] <= end for start, end in windows):
115
+ return "partly"
116
+ return "term"
@@ -0,0 +1,231 @@
1
+ """Constraint checks over a ledger, plus the deadlines it implies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Set as AbstractSet
6
+ from dataclasses import dataclass
7
+ from datetime import date, timedelta
8
+
9
+ from .leave import year_budget
10
+ from .models import DayPattern, Ledger
11
+
12
+ _MAX_STRETCH = 14 # days; real stretches are a weekend plus a holiday or two
13
+
14
+
15
+ def effective_window(
16
+ start: date,
17
+ end: date,
18
+ public_holidays: set[str],
19
+ pattern: DayPattern | None = None,
20
+ ) -> tuple[date, date]:
21
+ """Stretch a school-holiday window over the free days that touch it.
22
+
23
+ Official windows usually run Monday to Friday, but families leave on the
24
+ Saturday before and come back on the Sunday after, and a public holiday on
25
+ either edge (Whit Monday before Bavaria's Pentecost window) adds a day more.
26
+ Walk outwards from each edge while the neighbouring day is a weekend or a
27
+ public holiday, at most `_MAX_STRETCH` days, so a pattern with no workdays
28
+ cannot walk forever.
29
+ """
30
+ pattern = pattern or DayPattern()
31
+
32
+ def free(day: date) -> bool:
33
+ return not pattern.is_workday(day) or day.isoformat() in public_holidays
34
+
35
+ for _ in range(_MAX_STRETCH):
36
+ if not free(start - timedelta(days=1)):
37
+ break
38
+ start -= timedelta(days=1)
39
+ for _ in range(_MAX_STRETCH):
40
+ if not free(end + timedelta(days=1)):
41
+ break
42
+ end += timedelta(days=1)
43
+ return start, end
44
+
45
+
46
+ # A school holiday with this many school days or fewer is a closure (All Souls'
47
+ # Day in Austria, Zürich's Ascension bridge), not a window to plan a trip around.
48
+ CLOSURE_MAX_SCHOOL_DAYS = 2
49
+
50
+
51
+ def describe_window(
52
+ name: str | None,
53
+ start: date,
54
+ end: date,
55
+ public_holidays: set[str],
56
+ groups: list[str] | None = None,
57
+ ) -> dict:
58
+ """A school holiday as the family sees it: raw dates, stretched dates, size.
59
+
60
+ `school_days` counts the Monday-to-Friday days inside the raw window that
61
+ aren't public holidays -- the days the holiday actually takes off school.
62
+ `groups` lists the school types it applies to where the data says (Zürich
63
+ splits primary, secondary and vocational); empty means every school.
64
+ """
65
+ eff_start, eff_end = effective_window(start, end, public_holidays)
66
+ school_days = sum(
67
+ 1
68
+ for offset in range((end - start).days + 1)
69
+ if (day := start + timedelta(days=offset)).weekday() < 5
70
+ and day.isoformat() not in public_holidays
71
+ )
72
+ return {
73
+ "name": name,
74
+ "start": start.isoformat(),
75
+ "end": end.isoformat(),
76
+ "effective_start": eff_start.isoformat(),
77
+ "effective_end": eff_end.isoformat(),
78
+ "days_off": (eff_end - eff_start).days + 1,
79
+ "school_days": school_days,
80
+ "closure": school_days <= CLOSURE_MAX_SCHOOL_DAYS,
81
+ "groups": groups or [],
82
+ }
83
+
84
+
85
+ @dataclass
86
+ class Issue:
87
+ level: str # "error" | "warning"
88
+ code: str
89
+ message: str
90
+ trip_ids: list[str]
91
+
92
+ def as_dict(self) -> dict:
93
+ return self.__dict__.copy()
94
+
95
+
96
+ def validate_ledger(
97
+ ledger: Ledger,
98
+ public_holidays: set[str],
99
+ windows: list[dict] | None = None,
100
+ half_holidays: AbstractSet[str] = frozenset(),
101
+ ) -> list[dict]:
102
+ """Overlaps, budget breaches, bad dates, and trips outside school windows.
103
+
104
+ `windows` is an optional list of {"name", "start", "end"} school-holiday
105
+ windows; trips with intent "trip" that fall outside all of them are flagged
106
+ as a warning, since that usually means taking the kids out of school.
107
+ """
108
+ issues: list[Issue] = []
109
+ trips = [t for t in ledger.trips if t.status != "cancelled"]
110
+
111
+ for trip in trips:
112
+ if trip.end_date < trip.start_date:
113
+ issues.append(
114
+ Issue("error", "bad_dates", f"{trip.label}: end is before start.", [trip.id])
115
+ )
116
+
117
+ ordered = sorted(trips, key=lambda t: t.start_date)
118
+ for earlier, later in zip(ordered, ordered[1:]):
119
+ if later.start_date <= earlier.end_date:
120
+ issues.append(
121
+ Issue(
122
+ "error",
123
+ "overlap",
124
+ f"{earlier.label} overlaps {later.label}.",
125
+ [earlier.id, later.id],
126
+ )
127
+ )
128
+
129
+ if ledger.profile:
130
+ account_years = {a.year for a in ledger.profile.leave}
131
+ for trip in trips:
132
+ if not trip.consumes_leave or trip.end_date < trip.start_date:
133
+ continue
134
+ missing = sorted(
135
+ y for y in range(trip.start_date.year, trip.end_date.year + 1)
136
+ if y not in account_years
137
+ )
138
+ if missing:
139
+ years = ", ".join(str(y) for y in missing)
140
+ issues.append(
141
+ Issue(
142
+ "warning",
143
+ "no_leave_account",
144
+ f"{trip.label} takes leave in {years}, which has no leave account.",
145
+ [trip.id],
146
+ )
147
+ )
148
+ for account in ledger.profile.leave:
149
+ budget = year_budget(
150
+ ledger, account.year, public_holidays, half_holidays=half_holidays
151
+ )
152
+ for warning in budget.warnings:
153
+ level = "error" if warning.startswith("Over budget") else "warning"
154
+ code = "over_budget" if level == "error" else "carryover_at_risk"
155
+ issues.append(Issue(level, code, warning, []))
156
+
157
+ if windows:
158
+ pattern = ledger.profile.day_pattern if ledger.profile else None
159
+ spans = []
160
+ for w in windows:
161
+ start, end = effective_window(
162
+ date.fromisoformat(w["start"]),
163
+ date.fromisoformat(w["end"]),
164
+ public_holidays,
165
+ pattern,
166
+ )
167
+ spans.append((start, end, w.get("name", "window")))
168
+ for trip in trips:
169
+ if trip.intent != "trip":
170
+ continue
171
+ inside = any(
172
+ trip.start_date >= start and trip.end_date <= end for start, end, _ in spans
173
+ )
174
+ if not inside:
175
+ issues.append(
176
+ Issue(
177
+ "warning",
178
+ "outside_school_window",
179
+ f"{trip.label} is not fully inside a school holiday window.",
180
+ [trip.id],
181
+ )
182
+ )
183
+
184
+ return [i.as_dict() for i in issues]
185
+
186
+
187
+ def deadlines(ledger: Ledger, today: date | None = None) -> list[dict]:
188
+ """Every dated action the ledger implies, soonest first."""
189
+ today = today or date.today()
190
+ out: list[dict] = []
191
+
192
+ for trip in ledger.trips:
193
+ if trip.status == "cancelled":
194
+ continue
195
+ for booking in trip.bookings:
196
+ if booking.book_by and booking.status == "todo":
197
+ out.append(
198
+ {
199
+ "due": booking.book_by,
200
+ "kind": "book_by",
201
+ "trip_id": trip.id,
202
+ "message": f"Book {booking.what} for {trip.label}.",
203
+ "overdue": date.fromisoformat(booking.book_by) < today,
204
+ }
205
+ )
206
+ if booking.cancel_by and booking.status == "booked":
207
+ out.append(
208
+ {
209
+ "due": booking.cancel_by,
210
+ "kind": "cancel_by",
211
+ "trip_id": trip.id,
212
+ "message": (
213
+ f"Free cancellation for {booking.what} "
214
+ f"({trip.label}) ends {booking.cancel_by}."
215
+ ),
216
+ "overdue": date.fromisoformat(booking.cancel_by) < today,
217
+ }
218
+ )
219
+ # A week at home needs nothing booked; every other plan does.
220
+ if trip.status in ("idea", "locked") and not trip.bookings and trip.intent != "home":
221
+ out.append(
222
+ {
223
+ "due": trip.start,
224
+ "kind": "unbooked",
225
+ "trip_id": trip.id,
226
+ "message": f"{trip.label} has no bookings recorded.",
227
+ "overdue": trip.start_date < today,
228
+ }
229
+ )
230
+
231
+ return sorted(out, key=lambda d: d["due"])
holiplan/engine/ics.py ADDED
@@ -0,0 +1,91 @@
1
+ """Minimal .ics export: all-day events for trips, plus VTODOs for deadlines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import date, datetime, timedelta, timezone
7
+
8
+ from .models import Ledger
9
+
10
+ PRODID = "-//holiplan//family holiday planner//EN"
11
+
12
+
13
+ def _stamp() -> str:
14
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
15
+
16
+
17
+ def _esc(text: str) -> str:
18
+ return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
19
+
20
+
21
+ def _fold(line: str) -> str:
22
+ """RFC 5545: lines of at most 75 octets, continuation lines start with a space.
23
+
24
+ Counted in UTF-8 bytes, never splitting a character, so umlauts stay intact.
25
+ """
26
+ lines: list[str] = []
27
+ current, size = "", 0
28
+ for char in line:
29
+ width = len(char.encode("utf-8"))
30
+ if size + width > 75:
31
+ lines.append(current)
32
+ current, size = " ", 1
33
+ current += char
34
+ size += width
35
+ lines.append(current)
36
+ return "\r\n".join(lines)
37
+
38
+
39
+ def _deadline_uid(item: dict) -> str:
40
+ """Stable across exports, so re-importing updates a VTODO instead of duplicating it."""
41
+ key = f"{item['kind']}|{item['trip_id']}|{item['message']}".encode("utf-8")
42
+ return f"deadline-{hashlib.sha1(key).hexdigest()[:16]}@holiplan"
43
+
44
+
45
+ def to_ics(ledger: Ledger, include_deadlines: bool = True) -> str:
46
+ from .checks import deadlines as compute_deadlines
47
+
48
+ lines = [
49
+ "BEGIN:VCALENDAR",
50
+ "VERSION:2.0",
51
+ f"PRODID:{PRODID}",
52
+ "CALSCALE:GREGORIAN",
53
+ ]
54
+
55
+ for trip in ledger.trips:
56
+ if trip.status == "cancelled":
57
+ continue
58
+ # DTEND is exclusive for all-day events
59
+ end_exclusive = (date.fromisoformat(trip.end) + timedelta(days=1)).strftime("%Y%m%d")
60
+ summary = trip.label if not trip.destination else f"{trip.label} ({trip.destination})"
61
+ lines += [
62
+ "BEGIN:VEVENT",
63
+ f"UID:{trip.id}@holiplan",
64
+ f"DTSTAMP:{_stamp()}",
65
+ f"DTSTART;VALUE=DATE:{date.fromisoformat(trip.start).strftime('%Y%m%d')}",
66
+ f"DTEND;VALUE=DATE:{end_exclusive}",
67
+ _fold(f"SUMMARY:{_esc(summary)}"),
68
+ f"STATUS:{'CONFIRMED' if trip.status == 'booked' else 'TENTATIVE'}",
69
+ "TRANSP:TRANSPARENT" if trip.intent == "home" else "TRANSP:OPAQUE",
70
+ ]
71
+ if trip.notes:
72
+ lines.append(_fold(f"DESCRIPTION:{_esc(trip.notes)}"))
73
+ if trip.destination:
74
+ lines.append(_fold(f"LOCATION:{_esc(trip.destination)}"))
75
+ lines.append("END:VEVENT")
76
+
77
+ if include_deadlines:
78
+ for item in compute_deadlines(ledger):
79
+ if item["kind"] == "unbooked":
80
+ continue
81
+ lines += [
82
+ "BEGIN:VTODO",
83
+ f"UID:{_deadline_uid(item)}",
84
+ f"DTSTAMP:{_stamp()}",
85
+ f"DUE;VALUE=DATE:{date.fromisoformat(item['due']).strftime('%Y%m%d')}",
86
+ _fold(f"SUMMARY:{_esc(item['message'])}"),
87
+ "END:VTODO",
88
+ ]
89
+
90
+ lines.append("END:VCALENDAR")
91
+ return "\r\n".join(lines) + "\r\n"
@@ -0,0 +1,174 @@
1
+ """Leave arithmetic: what a trip costs, and what's left in the year.
2
+
3
+ Holiday dates are passed in (a set of ISO date strings) so this module stays
4
+ offline and deterministic. The OpenHolidays client supplies them at runtime.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Set as AbstractSet
10
+ from dataclasses import dataclass, field
11
+ from datetime import date, timedelta
12
+ from typing import Iterable
13
+
14
+ from .models import DayPattern, FamilyProfile, Ledger, Trip
15
+
16
+
17
+ def daterange(start: date, end: date) -> Iterable[date]:
18
+ day = start
19
+ while day <= end:
20
+ yield day
21
+ day += timedelta(days=1)
22
+
23
+
24
+ @dataclass
25
+ class LeaveCost:
26
+ trip_id: str | None
27
+ start: str
28
+ end: str
29
+ days_away: int
30
+ leave_days: float
31
+ public_holidays_used: list[str] # ISO dates that fell on a workday
32
+ weekend_days: int
33
+ half_day_holidays_used: list[str] = field(default_factory=list) # afternoon off only
34
+
35
+ def as_dict(self) -> dict:
36
+ return {
37
+ "trip_id": self.trip_id,
38
+ "start": self.start,
39
+ "end": self.end,
40
+ "days_away": self.days_away,
41
+ "leave_days": self.leave_days,
42
+ "public_holidays_used": self.public_holidays_used,
43
+ "weekend_days": self.weekend_days,
44
+ "half_day_holidays_used": self.half_day_holidays_used,
45
+ }
46
+
47
+
48
+ def leave_cost(
49
+ start: date,
50
+ end: date,
51
+ public_holidays: set[str],
52
+ pattern: DayPattern | None = None,
53
+ trip_id: str | None = None,
54
+ half_holidays: AbstractSet[str] = frozenset(),
55
+ ) -> LeaveCost:
56
+ """Leave days needed to be away from `start` to `end` inclusive.
57
+
58
+ `half_holidays` are public holidays that free only half the day (Zürich's
59
+ Sechseläuten), so a workday on one costs at most half a day of leave.
60
+ """
61
+ pattern = pattern or DayPattern()
62
+ total = 0.0
63
+ used: list[str] = []
64
+ half_used: list[str] = []
65
+ weekend = 0
66
+ days = 0
67
+
68
+ for day in daterange(start, end):
69
+ days += 1
70
+ iso = day.isoformat()
71
+ if not pattern.is_workday(day):
72
+ weekend += 1
73
+ continue
74
+ if iso in public_holidays:
75
+ used.append(iso)
76
+ continue
77
+ weight = pattern.leave_weight(day)
78
+ if iso in half_holidays:
79
+ half_used.append(iso)
80
+ weight = min(weight, 0.5)
81
+ total += weight
82
+
83
+ return LeaveCost(
84
+ trip_id=trip_id,
85
+ start=start.isoformat(),
86
+ end=end.isoformat(),
87
+ days_away=days,
88
+ leave_days=total,
89
+ public_holidays_used=used,
90
+ weekend_days=weekend,
91
+ half_day_holidays_used=half_used,
92
+ )
93
+
94
+
95
+ @dataclass
96
+ class YearBudget:
97
+ year: int
98
+ allowance: float
99
+ carryover: float
100
+ carryover_expires: str | None
101
+ planned: float
102
+ carryover_used_before_expiry: float
103
+ remaining: float
104
+ carryover_at_risk: float
105
+ warnings: list[str]
106
+
107
+ def as_dict(self) -> dict:
108
+ return self.__dict__.copy()
109
+
110
+
111
+ def year_budget(
112
+ ledger: Ledger,
113
+ year: int,
114
+ public_holidays: set[str],
115
+ profile: FamilyProfile | None = None,
116
+ half_holidays: AbstractSet[str] = frozenset(),
117
+ ) -> YearBudget:
118
+ """Leave planned versus available for one year, including carryover expiry.
119
+
120
+ Carryover is treated as spent first, but only by trips that finish on or
121
+ before the expiry date -- that is what makes "use it before June" visible.
122
+ """
123
+ profile = profile or ledger.profile
124
+ if profile is None:
125
+ raise ValueError("ledger has no profile; pass one explicitly")
126
+
127
+ account = next((a for a in profile.leave if a.year == year), None)
128
+ if account is None:
129
+ raise ValueError(f"no leave account for {year}")
130
+
131
+ expiry = date.fromisoformat(account.carryover_expires) if account.carryover_expires else None
132
+
133
+ planned = 0.0
134
+ before_expiry = 0.0
135
+ warnings: list[str] = []
136
+
137
+ first_day, last_day = date(year, 1, 1), date(year, 12, 31)
138
+ for trip in ledger.trips:
139
+ if trip.status == "cancelled" or not trip.consumes_leave:
140
+ continue
141
+ # A trip over New Year spends each year's leave only on that year's days.
142
+ start, end = max(trip.start_date, first_day), min(trip.end_date, last_day)
143
+ if start > end:
144
+ continue
145
+ cost = leave_cost(
146
+ start, end, public_holidays, profile.day_pattern, trip.id, half_holidays
147
+ )
148
+ planned += cost.leave_days
149
+ if expiry and trip.end_date <= expiry:
150
+ before_expiry += cost.leave_days
151
+
152
+ carryover_used = min(account.carryover, before_expiry)
153
+ at_risk = round(account.carryover - carryover_used, 2)
154
+ remaining = round(account.allowance + account.carryover - planned, 2)
155
+
156
+ if remaining < 0:
157
+ warnings.append(f"Over budget by {abs(remaining)} days.")
158
+ if at_risk > 0 and expiry:
159
+ warnings.append(
160
+ f"{at_risk} carried-over day(s) expire on {expiry.isoformat()} "
161
+ "and no planned trip uses them."
162
+ )
163
+
164
+ return YearBudget(
165
+ year=year,
166
+ allowance=account.allowance,
167
+ carryover=account.carryover,
168
+ carryover_expires=account.carryover_expires,
169
+ planned=round(planned, 2),
170
+ carryover_used_before_expiry=round(carryover_used, 2),
171
+ remaining=remaining,
172
+ carryover_at_risk=at_risk,
173
+ warnings=warnings,
174
+ )