mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +01:00
Some checks are pending
code-runner pre-commit / pre-commit (push) Waiting to run
code-runner tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
leetcode-coach pre-commit / pre-commit (push) Waiting to run
leetcode-coach tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sql-drills pre-commit / pre-commit (push) Waiting to run
sql-drills tests / test (push) Waiting to run
Minutes past a day's target bank (max 60) and pay on the next project day; status shows real minutes (121/120) plus carried_in/carry_out; carry ledger rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
91 lines
3.1 KiB
Python
91 lines
3.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""The harvester: turn credited focus minutes into signed ledger rows.
|
|
|
|
Run by ``focus-project-harvest.service`` (``.path`` on activity.jsonl plus a
|
|
one-minute ``.timer``). Harvests every project day of the last
|
|
:data:`BACKFILL_DAYS` days, so work recorded late (a phone synced days
|
|
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
|
|
settled days cost one read and write nothing. Failure modes, by path:
|
|
|
|
Overtime past a day's target banks for the next day (``carry``): that day's
|
|
carry row pays it from the day's start, own-work rows pay the rest.
|
|
|
|
* a source that errors: logged, contributes 0, the run continues (fail closed);
|
|
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
|
the unit shows ``failed`` instead of silently crediting nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
import logging
|
|
import sys
|
|
|
|
from focus_project import _api, _clock, carry, ledger
|
|
from focus_project._errors import RegistryError
|
|
|
|
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
|
_LOG = logging.getLogger("focus_project.harvest")
|
|
|
|
|
|
def project_days(today: date) -> list[date]:
|
|
"""Every project day in the backfill window up to ``today``, oldest first."""
|
|
oldest = today - timedelta(days=BACKFILL_DAYS - 1)
|
|
days: set[date] = set()
|
|
for project in _api.list_projects():
|
|
day = max(project.start, oldest)
|
|
while day <= min(project.last_day, today):
|
|
days.add(day)
|
|
day += timedelta(days=1)
|
|
return sorted(days)
|
|
|
|
|
|
def harvest_day(day: date, now: float) -> int:
|
|
"""Credit ``day``; return the minutes added (0 with no project)."""
|
|
project = _api.active(day)
|
|
if project is None:
|
|
return 0
|
|
daily = project.daily_on(day)
|
|
bank = carry.balance(day, now)
|
|
added = ledger.credit_to(project, day, bank.paid_bank(daily), now=now, carry=True)
|
|
added += ledger.credit_to(project, day, bank.paid_own(daily), now=now)
|
|
if added:
|
|
_LOG.info(
|
|
"credited %d min to %s on %s (total %d, %d from the bank)",
|
|
added,
|
|
project.id,
|
|
day,
|
|
bank.counted(daily),
|
|
bank.paid_bank(daily),
|
|
)
|
|
return added
|
|
|
|
|
|
def harvest(now: float | None = None) -> int:
|
|
"""Harvest the backfill window; 0 on success, 1 if any path failed."""
|
|
moment = _clock.now() if now is None else now
|
|
today = date.fromtimestamp(moment) # noqa: DTZ012 (local day)
|
|
try:
|
|
days = project_days(today)
|
|
except (RegistryError, OSError) as exc:
|
|
_LOG.error("harvest registry unreadable: %s", exc)
|
|
return 1
|
|
status = 0
|
|
for day in days:
|
|
try:
|
|
harvest_day(day, moment)
|
|
except (RegistryError, ledger.LedgerError, OSError) as exc:
|
|
_LOG.error("harvest %s failed: %s", day, exc)
|
|
status = 1
|
|
return status
|
|
|
|
|
|
def main() -> None:
|
|
"""Entry point of ``python -m focus_project.harvest``."""
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
sys.exit(harvest())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|