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 +0 -0
- holiplan/engine/__init__.py +0 -0
- holiplan/engine/bridges.py +116 -0
- holiplan/engine/checks.py +231 -0
- holiplan/engine/ics.py +91 -0
- holiplan/engine/leave.py +174 -0
- holiplan/engine/models.py +250 -0
- holiplan/engine/summary.py +115 -0
- holiplan/holidays.py +244 -0
- holiplan/schemas/ledger.schema.json +120 -0
- holiplan/server.py +515 -0
- holiplan-0.1.0.dist-info/METADATA +152 -0
- holiplan-0.1.0.dist-info/RECORD +16 -0
- holiplan-0.1.0.dist-info/WHEEL +4 -0
- holiplan-0.1.0.dist-info/entry_points.txt +2 -0
- holiplan-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|
holiplan/engine/leave.py
ADDED
|
@@ -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
|
+
)
|