mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
focus_project: carry overtime, v0.6.0
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
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
This commit is contained in:
parent
737bf624ca
commit
88e4be5042
@ -19,5 +19,10 @@ the clock earns only once the clock reaches it); `record_activity` rejects a `ts
|
||||
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day`
|
||||
(ValueError); today or later is fine. The harvester pays every project day of the
|
||||
last 31 days, so late-synced events for older days are credited (idempotent).
|
||||
Overtime carries over: own minutes past a day's target bank (max 60) and pay on the
|
||||
next project day; the bank never crosses the project's end. `minutes_for(day)` is the
|
||||
capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`,
|
||||
`carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `<project>-<YYYYMMDD>-carry`
|
||||
(`detail.carry`), never over `daily_minutes` together with own rows.
|
||||
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
|
||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||
|
||||
31
focus_project/focus_project/_own.py
Normal file
31
focus_project/focus_project/_own.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""A project day's own worked minutes: the union of every source's intervals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from focus_project import sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
from focus_project._model import Project
|
||||
|
||||
|
||||
def union_seconds(spans: list[sources.Interval]) -> float:
|
||||
"""Total length of the union of ``spans``."""
|
||||
total = 0.0
|
||||
end = float("-inf")
|
||||
for lo, hi in sorted(spans):
|
||||
if hi <= end:
|
||||
continue
|
||||
total += hi - max(lo, end)
|
||||
end = hi
|
||||
return total
|
||||
|
||||
|
||||
def own_minutes_of(project: Project, day: date, moment: float) -> int:
|
||||
"""Whole minutes worked on ``day``, uncapped, nothing after ``moment``."""
|
||||
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
||||
return int(union_seconds(spans) // 60)
|
||||
75
focus_project/focus_project/carry.py
Normal file
75
focus_project/focus_project/carry.py
Normal file
@ -0,0 +1,75 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Overtime carries over: minutes past a day's target bank for the next day.
|
||||
|
||||
Contract (kuhy, 2026-11-02): own minutes beyond ``daily_on(day)`` are banked
|
||||
(at most :data:`BANK_CAP`) and pay on the NEXT project day. The bank never
|
||||
crosses the project's last day (what is left then expires) and a carry never
|
||||
pays on a day with no project. The balance is derived from the activity
|
||||
record, never stored, so it cannot drift; a project day's counted minutes
|
||||
are ``min(daily, own + carried_in)``. The bank is spent first, so what a day
|
||||
takes from it is fixed when the day starts and only own minutes grow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from focus_project import _api, _clock
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._own import own_minutes_of
|
||||
|
||||
BANK_CAP: Final = 60
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Balance:
|
||||
"""One project day: worked minutes, bank taken in, bank left for tomorrow."""
|
||||
|
||||
own: int
|
||||
carried_in: int
|
||||
carry_out: int
|
||||
|
||||
def paid_bank(self, daily: int) -> int:
|
||||
"""Minutes of the day paid from the bank (fixed at the day's start)."""
|
||||
return min(self.carried_in, daily)
|
||||
|
||||
def paid_own(self, daily: int) -> int:
|
||||
"""Minutes of the day paid from own work, after the bank."""
|
||||
return min(self.own, daily - self.paid_bank(daily))
|
||||
|
||||
def counted(self, daily: int) -> int:
|
||||
"""Minutes that count on the day: own plus carried, capped at ``daily``."""
|
||||
return min(daily, self.own + self.carried_in)
|
||||
|
||||
|
||||
_NONE: Final = Balance(0, 0, 0)
|
||||
|
||||
|
||||
def balance(day: date, now: float | None = None) -> Balance:
|
||||
"""The balance of ``day`` (zeros with no project or an unreadable registry)."""
|
||||
moment = _clock.now() if now is None else now
|
||||
try:
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return _NONE
|
||||
bank = 0
|
||||
cursor = project.start
|
||||
while True:
|
||||
own = own_minutes_of(project, cursor, moment)
|
||||
total = own + bank
|
||||
left = min(BANK_CAP, max(0, total - project.daily_on(cursor)))
|
||||
if cursor >= day:
|
||||
return Balance(own, bank, left)
|
||||
bank, cursor = left, cursor + timedelta(days=1)
|
||||
except (RegistryError, OSError) as exc:
|
||||
_LOG.warning("focus_project registry unreadable, nothing banked: %s", exc)
|
||||
return _NONE
|
||||
|
||||
|
||||
def own_minutes(day: date, now: float | None = None) -> int:
|
||||
"""Minutes worked on ``day``, not capped at the target (0 with no project)."""
|
||||
return balance(day, now).own
|
||||
@ -7,6 +7,9 @@ one-minute ``.timer``). Harvests every project day of the last
|
||||
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.
|
||||
@ -18,9 +21,8 @@ from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from focus_project import _api, _clock, ledger
|
||||
from focus_project import _api, _clock, carry, ledger
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project.minutes import minutes_for
|
||||
|
||||
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
||||
_LOG = logging.getLogger("focus_project.harvest")
|
||||
@ -43,11 +45,18 @@ def harvest_day(day: date, now: float) -> int:
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return 0
|
||||
total = minutes_for(day, now=now)
|
||||
added = ledger.credit_to(project, day, total, now=now)
|
||||
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)", added, project.id, day, total
|
||||
"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
|
||||
|
||||
|
||||
@ -93,10 +93,19 @@ def _write_rows(path: Path, rows: list[Any]) -> None:
|
||||
Path(handle.name).replace(path)
|
||||
|
||||
|
||||
def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int:
|
||||
def paid_minutes(
|
||||
rows: list[Any],
|
||||
key: bytes,
|
||||
project_id: str,
|
||||
day: date,
|
||||
*,
|
||||
carry: bool | None = None,
|
||||
) -> int:
|
||||
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
|
||||
|
||||
A repeated ``entry_id`` pays once, at its smallest count.
|
||||
``carry`` picks one class (True: banked-overtime rows, False: own-work
|
||||
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
|
||||
count.
|
||||
"""
|
||||
by_id: dict[str, int] = {}
|
||||
for row in rows:
|
||||
@ -112,6 +121,7 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
|
||||
or not isinstance(minutes, int)
|
||||
or isinstance(minutes, bool)
|
||||
or minutes < 1
|
||||
or (carry is not None and bool(detail.get("carry")) != carry)
|
||||
or not earned_time.verified(row, key)
|
||||
):
|
||||
continue
|
||||
@ -121,13 +131,31 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
|
||||
|
||||
|
||||
def build_row( # noqa: PLR0913
|
||||
project_id: str, day: date, total: int, minutes: int, key: bytes, *, now: float
|
||||
project_id: str,
|
||||
day: date,
|
||||
total: int,
|
||||
minutes: int,
|
||||
key: bytes,
|
||||
*,
|
||||
now: float,
|
||||
carry: bool = False,
|
||||
first_carry: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""The signed row paying ``minutes`` that bring the day's total to ``total``."""
|
||||
"""The signed row paying ``minutes`` that bring the day's total to ``total``.
|
||||
|
||||
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
|
||||
``-carry-m<total>`` when it tops up an earlier carry row (late data for
|
||||
the day before grew the bank).
|
||||
"""
|
||||
ended = min(now, day_bounds(day)[1] - 1)
|
||||
stem = f"{project_id}-{day:%Y%m%d}"
|
||||
if not carry:
|
||||
entry_id = f"{stem}-m{total}"
|
||||
else:
|
||||
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
|
||||
row: dict[str, Any] = {
|
||||
"kind": CREDIT,
|
||||
"entry_id": f"{project_id}-{day:%Y%m%d}-m{total}",
|
||||
"entry_id": entry_id,
|
||||
"day": day.isoformat(),
|
||||
"created_at": float(now),
|
||||
"detail": {
|
||||
@ -135,6 +163,7 @@ def build_row( # noqa: PLR0913
|
||||
"minutes": int(minutes),
|
||||
"total": int(total),
|
||||
"ended_at": float(ended),
|
||||
**({"carry": True} if carry else {}),
|
||||
},
|
||||
}
|
||||
row["hmac"] = str(earned_time.entry_signature(row, key))
|
||||
@ -142,10 +171,18 @@ def build_row( # noqa: PLR0913
|
||||
|
||||
|
||||
def credit_to(
|
||||
project: Project, day: date, total: int, *, now: float | None = None
|
||||
project: Project,
|
||||
day: date,
|
||||
total: int,
|
||||
*,
|
||||
now: float | None = None,
|
||||
carry: bool = False,
|
||||
) -> int:
|
||||
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
|
||||
|
||||
``carry`` pays the banked-overtime class and counts only its own rows;
|
||||
the default pays (and counts) own work.
|
||||
|
||||
Idempotent: nothing is written when the ledger already pays ``total`` or
|
||||
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
||||
write) so the harvester can fail loudly instead of crediting nothing quietly.
|
||||
@ -159,11 +196,18 @@ def credit_to(
|
||||
with path.with_suffix(".lock").open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
rows = read_rows(path)
|
||||
paid = paid_minutes(rows, key, project.id, day)
|
||||
paid = paid_minutes(rows, key, project.id, day, carry=carry)
|
||||
if total <= paid:
|
||||
return 0
|
||||
row = build_row(
|
||||
project.id, day, total, total - paid, key, now=now or time.time()
|
||||
project.id,
|
||||
day,
|
||||
total,
|
||||
total - paid,
|
||||
key,
|
||||
now=now or time.time(),
|
||||
carry=carry,
|
||||
first_carry=paid == 0,
|
||||
)
|
||||
if any(
|
||||
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
|
||||
@ -172,7 +216,7 @@ def credit_to(
|
||||
raise LedgerError(msg)
|
||||
_write_rows(path, [*rows, row])
|
||||
stored = read_rows(path)
|
||||
if paid_minutes(stored, key, project.id, day) != total:
|
||||
if paid_minutes(stored, key, project.id, day, carry=carry) != total:
|
||||
msg = f"row {row['entry_id']} not found verified on read-back"
|
||||
raise LedgerError(msg)
|
||||
return total - paid
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Credited focus minutes per day: the union of every source's intervals.
|
||||
|
||||
Contract: union of activity intervals on the local calendar day, capped at
|
||||
the active project's ``daily_minutes``; overlaps never double count; whole
|
||||
Contract: union of activity intervals on the local calendar day plus the
|
||||
overtime carried in from the day before (:mod:`focus_project.carry`), capped
|
||||
at the active project's ``daily_minutes``; overlaps never double count; whole
|
||||
minutes (floor). Time not yet lived is never credited: every interval is
|
||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||
@ -14,24 +15,13 @@ from __future__ import annotations
|
||||
from datetime import date # noqa: TC003
|
||||
import logging
|
||||
|
||||
from focus_project import _api, _clock, sources
|
||||
from focus_project import _api, _clock, carry
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._own import union_seconds
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def union_seconds(spans: list[sources.Interval]) -> float:
|
||||
"""Total length of the union of ``spans``."""
|
||||
total = 0.0
|
||||
end = float("-inf")
|
||||
for lo, hi in sorted(spans):
|
||||
if hi <= end:
|
||||
continue
|
||||
total += hi - max(lo, end)
|
||||
end = hi
|
||||
return total
|
||||
|
||||
|
||||
def minutes_for(day: date, now: float | None = None) -> int:
|
||||
"""Minutes of focus work credited on ``day`` (0 with no project active).
|
||||
|
||||
@ -45,6 +35,7 @@ def minutes_for(day: date, now: float | None = None) -> int:
|
||||
if project is None:
|
||||
return 0
|
||||
moment = _clock.now() if now is None else now
|
||||
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
||||
seconds = union_seconds(spans)
|
||||
return min(int(seconds // 60), project.daily_on(day))
|
||||
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||
|
||||
|
||||
__all__ = ["minutes_for", "union_seconds"]
|
||||
|
||||
@ -6,27 +6,44 @@ from __future__ import annotations
|
||||
from datetime import date # noqa: TC003
|
||||
from typing import Any
|
||||
|
||||
from focus_project import _api, minutes
|
||||
from focus_project import _api, carry
|
||||
|
||||
|
||||
def status(day: date) -> dict[str, Any]:
|
||||
"""Progress of the project active on ``day``; ``project`` is None if none."""
|
||||
"""Progress of the project active on ``day``; ``project`` is None if none.
|
||||
|
||||
``minutes`` is what was really worked plus what the day took from the
|
||||
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
|
||||
is the capped figure that pays.
|
||||
"""
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return {
|
||||
"project": None,
|
||||
"name": None,
|
||||
"minutes": 0,
|
||||
"own_minutes": 0,
|
||||
"counted_minutes": 0,
|
||||
"carried_in": 0,
|
||||
"carry_out": 0,
|
||||
"daily_minutes": None,
|
||||
"unlock_minutes": None,
|
||||
"ends": None,
|
||||
"replaces": [],
|
||||
}
|
||||
daily = project.daily_on(day)
|
||||
bank = carry.balance(day)
|
||||
return {
|
||||
"project": project.id,
|
||||
"name": project.name,
|
||||
"minutes": minutes.minutes_for(day),
|
||||
"daily_minutes": project.daily_on(day),
|
||||
# Real minutes behind the day (own + banked in), never capped: the
|
||||
# number to show. Pay is ``counted_minutes``; earned_time caps it.
|
||||
"minutes": bank.own + bank.carried_in,
|
||||
"own_minutes": bank.own,
|
||||
"counted_minutes": bank.counted(daily),
|
||||
"carried_in": bank.carried_in,
|
||||
"carry_out": bank.carry_out,
|
||||
"daily_minutes": daily,
|
||||
"unlock_minutes": project.unlock_on(day),
|
||||
"ends": project.last_day.isoformat(),
|
||||
"replaces": list(project.replaces),
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path # noqa: TC003
|
||||
@ -13,12 +13,16 @@ import time
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import _clock, minutes
|
||||
from focus_project import _clock
|
||||
from focus_project._paths import data_dir
|
||||
|
||||
D1 = date(2026, 11, 2)
|
||||
|
||||
|
||||
def _at(hour: int, minute: int) -> float:
|
||||
return datetime(2026, 11, 2, hour, minute).astimezone().timestamp()
|
||||
|
||||
|
||||
def _project() -> None:
|
||||
fp.create_project(
|
||||
"hr",
|
||||
@ -115,15 +119,20 @@ def test_record_activity_concurrent_appends(data: Path) -> None:
|
||||
assert all(json.loads(x)["source"] == "tutor" for x in lines)
|
||||
|
||||
|
||||
def test_status_active_uses_minutes_module(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_status_active_reports_the_worked_minutes() -> None:
|
||||
_project()
|
||||
assert fp.status(D1)["minutes"] == 0 # the stub
|
||||
monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45)
|
||||
assert fp.status(D1)["minutes"] == 0
|
||||
for k in range(1, 10): # nine heartbeats, five minutes each
|
||||
fp.record_activity("tutor", "heartbeat", ts=_at(8, 5 * k))
|
||||
st = fp.status(D1)
|
||||
assert st == {
|
||||
"project": "hr",
|
||||
"name": "HackerRank prep",
|
||||
"minutes": 45,
|
||||
"own_minutes": 45,
|
||||
"counted_minutes": 45,
|
||||
"carried_in": 0,
|
||||
"carry_out": 0,
|
||||
"daily_minutes": 120,
|
||||
"unlock_minutes": 30,
|
||||
"ends": "2026-11-05",
|
||||
@ -141,6 +150,10 @@ def test_status_inactive() -> None:
|
||||
"project",
|
||||
"name",
|
||||
"minutes",
|
||||
"own_minutes",
|
||||
"counted_minutes",
|
||||
"carried_in",
|
||||
"carry_out",
|
||||
"daily_minutes",
|
||||
"unlock_minutes",
|
||||
"ends",
|
||||
|
||||
183
focus_project/focus_project/tests/test_carry.py
Normal file
183
focus_project/focus_project/tests/test_carry.py
Normal file
@ -0,0 +1,183 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Overtime carries over: own minutes, the bank, pay and the carry ledger rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
from pathlib import Path # noqa: TC003
|
||||
from typing import Any
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import carry, harvest, ledger, minutes
|
||||
from focus_project.tests.test_minutes import D1, at, events, project
|
||||
|
||||
KEY = b"k" * 32
|
||||
D2 = D1 + timedelta(days=1)
|
||||
D3 = D2 + timedelta(days=1)
|
||||
D4 = D3 + timedelta(days=1) # the last day of project()
|
||||
D5 = D4 + timedelta(days=1) # after the end
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _key(tmp_path: Path) -> None:
|
||||
(tmp_path / "hmac.key").write_bytes(KEY)
|
||||
|
||||
|
||||
def work(day: date, mins: int) -> None:
|
||||
"""``mins`` focused minutes on ``day``: tutor heartbeats every five minutes."""
|
||||
events(
|
||||
*(
|
||||
("tutor", "heartbeat", at(8, 0, day=day) + 300 * k)
|
||||
for k in range(1, mins // 5 + 1)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def rows() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = json.loads(ledger.ledger_path().read_text())["entries"]
|
||||
return out
|
||||
|
||||
|
||||
def test_own_minutes_are_not_capped_but_pay_is() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
assert carry.own_minutes(D1) == 125
|
||||
assert minutes.minutes_for(D1) == 120
|
||||
|
||||
|
||||
def test_overtime_banks_into_the_next_day() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
assert carry.balance(D1) == carry.Balance(own=125, carried_in=0, carry_out=5)
|
||||
assert carry.balance(D2) == carry.Balance(own=0, carried_in=5, carry_out=0)
|
||||
assert minutes.minutes_for(D2) == 5
|
||||
|
||||
|
||||
def test_carry_adds_to_own_minutes_on_the_next_day() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
work(D2, 100)
|
||||
assert minutes.minutes_for(D2) == 105
|
||||
assert carry.balance(D2).carry_out == 0
|
||||
|
||||
|
||||
def test_the_bank_is_capped_at_sixty() -> None:
|
||||
project()
|
||||
work(D1, 200)
|
||||
assert carry.balance(D1).carry_out == carry.BANK_CAP == 60
|
||||
|
||||
|
||||
def test_overtime_chains_over_days() -> None:
|
||||
project()
|
||||
work(D1, 130)
|
||||
work(D2, 125)
|
||||
assert carry.balance(D2) == carry.Balance(own=125, carried_in=10, carry_out=15)
|
||||
assert minutes.minutes_for(D3) == 15
|
||||
|
||||
|
||||
def test_the_bank_never_crosses_the_project_end() -> None:
|
||||
project()
|
||||
work(D4, 150)
|
||||
assert carry.balance(D4).carry_out == 30
|
||||
assert minutes.minutes_for(D5) == 0
|
||||
assert carry.balance(D5) == carry.Balance(0, 0, 0)
|
||||
|
||||
|
||||
def test_the_first_day_has_nothing_banked_in() -> None:
|
||||
project()
|
||||
assert carry.balance(D1 - timedelta(days=1)).carried_in == 0
|
||||
assert carry.balance(D1).carried_in == 0
|
||||
|
||||
|
||||
def test_a_past_target_is_used_for_a_past_day() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
fp.update_project("hr", today=D2, daily_minutes=100, unlock_minutes=30)
|
||||
assert carry.balance(D1).carry_out == 5 # D1 still judged at 120
|
||||
assert carry.balance(D2).carried_in == 5
|
||||
|
||||
|
||||
def test_status_reports_real_minutes_and_the_bank() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
shown = fp.status(D1)
|
||||
assert shown["minutes"] == 125
|
||||
assert shown["own_minutes"] == 125
|
||||
assert shown["counted_minutes"] == 120
|
||||
assert (shown["carried_in"], shown["carry_out"]) == (0, 5)
|
||||
nxt = fp.status(D2)
|
||||
assert (nxt["minutes"], nxt["carried_in"]) == (5, 5)
|
||||
|
||||
|
||||
def test_status_without_a_project_has_zero_bank() -> None:
|
||||
shown = fp.status(D1)
|
||||
assert (shown["own_minutes"], shown["carried_in"], shown["carry_out"]) == (0, 0, 0)
|
||||
|
||||
|
||||
def test_unreadable_registry_banks_nothing(data: Path) -> None:
|
||||
data.mkdir(parents=True)
|
||||
(data / "projects.json").write_text("{")
|
||||
assert carry.balance(D2) == carry.Balance(0, 0, 0)
|
||||
assert carry.own_minutes(D2) == 0
|
||||
|
||||
|
||||
def test_harvest_pays_own_rows_then_one_carry_row() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
work(D2, 100)
|
||||
harvest.harvest(now=at(23, 0, day=D2))
|
||||
got = {r["entry_id"]: r for r in rows()}
|
||||
assert got["hr-20261102-m120"]["detail"]["minutes"] == 120
|
||||
assert got["hr-20261103-carry"]["detail"]["minutes"] == 5
|
||||
assert got["hr-20261103-carry"]["detail"]["carry"] is True
|
||||
assert got["hr-20261103-m100"]["detail"]["minutes"] == 100
|
||||
assert all(earned_time.verified(r, KEY) for r in got.values())
|
||||
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 105
|
||||
|
||||
|
||||
def test_pay_never_exceeds_the_daily_target() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
work(D2, 130)
|
||||
harvest.harvest(now=at(23, 0, day=D2))
|
||||
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
|
||||
assert minutes.minutes_for(D2) == 120
|
||||
|
||||
|
||||
def test_carry_is_paid_at_the_start_of_the_day_and_not_again() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
harvest.harvest(now=at(0, 5, day=D2))
|
||||
before = rows()
|
||||
assert [r["entry_id"] for r in before if r["day"] == "2026-11-03"] == [
|
||||
"hr-20261103-carry"
|
||||
]
|
||||
harvest.harvest(now=at(0, 6, day=D2))
|
||||
assert rows() == before
|
||||
|
||||
|
||||
def test_late_data_for_yesterday_pays_only_the_carry_delta() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
harvest.harvest(now=at(9, 0, day=D2))
|
||||
work(D1, 130) # a late phone sync: five more minutes
|
||||
harvest.harvest(now=at(9, 5, day=D2))
|
||||
paid = [r for r in rows() if r["detail"].get("carry")]
|
||||
assert [r["entry_id"] for r in paid] == [
|
||||
"hr-20261103-carry",
|
||||
"hr-20261103-carry-m10",
|
||||
]
|
||||
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 10
|
||||
|
||||
|
||||
def test_own_rows_leave_room_for_the_carry() -> None:
|
||||
project()
|
||||
work(D1, 125)
|
||||
work(D2, 120)
|
||||
harvest.harvest(now=at(23, 0, day=D2))
|
||||
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
|
||||
assert "hr-20261103-m115" in {r["entry_id"] for r in rows()}
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focus_project"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||
requires-python = ">=3.14"
|
||||
# Stdlib only on purpose: guards import it from the system python.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user