mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-15 09:30:59 +01:00
Project.kind (focus default; old rows load unchanged) plus interview fields test_at, invite_url, trips, weekend_answers. Interview budget 30 min weekdays / 120 Sat-Sun, exempt from daily_floor, unlock clamped after the 0.7.0 history ratchet. Milestones are generated (rules, trip_ask, phone, mock, slot), never stored. Append-only evidence store behind the _paths seam. Calendar dropped: slot = slot entry + slot_confirm. Failure paths: - unanswered trip question: fail closed (reads as a trip, phone due) - late no-trip answer cannot erase an overdue phone milestone: closed - phone source missing/stale/crashed: unknown, locks: fail closed - trip_ask answer: evidence row first, then registry; a crash between leaves the question open: fail closed - add_trip/answer_weekend loosening: ValueError, nothing written - owed_lock with unreadable registry or evidence: fail open, logged (focus-guard's unreadable-registry rule); test day never locks - status() milestones with unreadable evidence: empty list, logged - torn or non-utf8 evidence lines: skipped and logged Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
62 lines
2.0 KiB
Python
62 lines
2.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Trips: days away from the PC. They can only grow (ratchet)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Trip:
|
|
"""An inclusive away range; ``entered_ms`` is when it was announced."""
|
|
|
|
start: date
|
|
end: date
|
|
entered_ms: int = 0
|
|
|
|
def covers(self, day: date) -> bool:
|
|
"""Whether ``day`` is inside the trip."""
|
|
return self.start <= day <= self.end
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
"""The projects.json row."""
|
|
return {
|
|
"start": self.start.isoformat(),
|
|
"end": self.end.isoformat(),
|
|
"entered_ms": self.entered_ms,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, row: dict[str, Any]) -> Trip:
|
|
"""Parse a trip row (errors surface as RegistryError upstream)."""
|
|
return cls(
|
|
start=date.fromisoformat(row["start"]),
|
|
end=date.fromisoformat(row["end"]),
|
|
entered_ms=int(row.get("entered_ms", 0)),
|
|
)
|
|
|
|
|
|
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
|
|
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
|
|
|
|
A new range touching an old trip must cover it: it replaces the old one
|
|
(keeping its entry time when the start did not move earlier). Anything
|
|
that starts later or ends earlier than what is on file is refused.
|
|
"""
|
|
kept: list[Trip] = []
|
|
for old in trips:
|
|
if old.start > new.end or new.start > old.end:
|
|
kept.append(old)
|
|
elif new.start <= old.start and new.end >= old.end:
|
|
if new.start == old.start:
|
|
new = replace(new, entered_ms=old.entered_ms)
|
|
else:
|
|
msg = (
|
|
f"a trip may only get stricter: pass a range that covers "
|
|
f"{old.start}..{old.end}"
|
|
)
|
|
raise ValueError(msg)
|
|
return tuple(sorted([*kept, new], key=lambda t: t.start))
|