mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:27:16 +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`
|
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
|
(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).
|
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`).
|
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`).
|
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
|
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:
|
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);
|
* a source that errors: logged, contributes 0, the run continues (fail closed);
|
||||||
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
||||||
the unit shows ``failed`` instead of silently crediting nothing.
|
the unit shows ``failed`` instead of silently crediting nothing.
|
||||||
@ -18,9 +21,8 @@ from datetime import date, timedelta
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
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._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
|
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
||||||
_LOG = logging.getLogger("focus_project.harvest")
|
_LOG = logging.getLogger("focus_project.harvest")
|
||||||
@ -43,11 +45,18 @@ def harvest_day(day: date, now: float) -> int:
|
|||||||
project = _api.active(day)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
return 0
|
return 0
|
||||||
total = minutes_for(day, now=now)
|
daily = project.daily_on(day)
|
||||||
added = ledger.credit_to(project, day, total, now=now)
|
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:
|
if added:
|
||||||
_LOG.info(
|
_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
|
return added
|
||||||
|
|
||||||
|
|||||||
@ -93,10 +93,19 @@ def _write_rows(path: Path, rows: list[Any]) -> None:
|
|||||||
Path(handle.name).replace(path)
|
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.
|
"""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] = {}
|
by_id: dict[str, int] = {}
|
||||||
for row in rows:
|
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 not isinstance(minutes, int)
|
||||||
or isinstance(minutes, bool)
|
or isinstance(minutes, bool)
|
||||||
or minutes < 1
|
or minutes < 1
|
||||||
|
or (carry is not None and bool(detail.get("carry")) != carry)
|
||||||
or not earned_time.verified(row, key)
|
or not earned_time.verified(row, key)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
@ -121,13 +131,31 @@ def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int
|
|||||||
|
|
||||||
|
|
||||||
def build_row( # noqa: PLR0913
|
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]:
|
) -> 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)
|
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] = {
|
row: dict[str, Any] = {
|
||||||
"kind": CREDIT,
|
"kind": CREDIT,
|
||||||
"entry_id": f"{project_id}-{day:%Y%m%d}-m{total}",
|
"entry_id": entry_id,
|
||||||
"day": day.isoformat(),
|
"day": day.isoformat(),
|
||||||
"created_at": float(now),
|
"created_at": float(now),
|
||||||
"detail": {
|
"detail": {
|
||||||
@ -135,6 +163,7 @@ def build_row( # noqa: PLR0913
|
|||||||
"minutes": int(minutes),
|
"minutes": int(minutes),
|
||||||
"total": int(total),
|
"total": int(total),
|
||||||
"ended_at": float(ended),
|
"ended_at": float(ended),
|
||||||
|
**({"carry": True} if carry else {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
row["hmac"] = str(earned_time.entry_signature(row, key))
|
row["hmac"] = str(earned_time.entry_signature(row, key))
|
||||||
@ -142,10 +171,18 @@ def build_row( # noqa: PLR0913
|
|||||||
|
|
||||||
|
|
||||||
def credit_to(
|
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:
|
) -> int:
|
||||||
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
|
"""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
|
Idempotent: nothing is written when the ledger already pays ``total`` or
|
||||||
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
|
||||||
write) so the harvester can fail loudly instead of crediting nothing quietly.
|
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:
|
with path.with_suffix(".lock").open("w") as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
rows = read_rows(path)
|
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:
|
if total <= paid:
|
||||||
return 0
|
return 0
|
||||||
row = build_row(
|
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(
|
if any(
|
||||||
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
|
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)
|
raise LedgerError(msg)
|
||||||
_write_rows(path, [*rows, row])
|
_write_rows(path, [*rows, row])
|
||||||
stored = read_rows(path)
|
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"
|
msg = f"row {row['entry_id']} not found verified on read-back"
|
||||||
raise LedgerError(msg)
|
raise LedgerError(msg)
|
||||||
return total - paid
|
return total - paid
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
"""Credited focus minutes per day: the union of every source's intervals.
|
"""Credited focus minutes per day: the union of every source's intervals.
|
||||||
|
|
||||||
Contract: union of activity intervals on the local calendar day, capped at
|
Contract: union of activity intervals on the local calendar day plus the
|
||||||
the active project's ``daily_minutes``; overlaps never double count; whole
|
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
|
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)
|
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
|
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
|
from datetime import date # noqa: TC003
|
||||||
import logging
|
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._errors import RegistryError
|
||||||
|
from focus_project._own import union_seconds
|
||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_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:
|
def minutes_for(day: date, now: float | None = None) -> int:
|
||||||
"""Minutes of focus work credited on ``day`` (0 with no project active).
|
"""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:
|
if project is None:
|
||||||
return 0
|
return 0
|
||||||
moment = _clock.now() if now is None else now
|
moment = _clock.now() if now is None else now
|
||||||
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
return carry.balance(day, moment).counted(project.daily_on(day))
|
||||||
seconds = union_seconds(spans)
|
|
||||||
return min(int(seconds // 60), project.daily_on(day))
|
|
||||||
|
__all__ = ["minutes_for", "union_seconds"]
|
||||||
|
|||||||
@ -6,27 +6,44 @@ from __future__ import annotations
|
|||||||
from datetime import date # noqa: TC003
|
from datetime import date # noqa: TC003
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from focus_project import _api, minutes
|
from focus_project import _api, carry
|
||||||
|
|
||||||
|
|
||||||
def status(day: date) -> dict[str, Any]:
|
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)
|
project = _api.active(day)
|
||||||
if project is None:
|
if project is None:
|
||||||
return {
|
return {
|
||||||
"project": None,
|
"project": None,
|
||||||
"name": None,
|
"name": None,
|
||||||
"minutes": 0,
|
"minutes": 0,
|
||||||
|
"own_minutes": 0,
|
||||||
|
"counted_minutes": 0,
|
||||||
|
"carried_in": 0,
|
||||||
|
"carry_out": 0,
|
||||||
"daily_minutes": None,
|
"daily_minutes": None,
|
||||||
"unlock_minutes": None,
|
"unlock_minutes": None,
|
||||||
"ends": None,
|
"ends": None,
|
||||||
"replaces": [],
|
"replaces": [],
|
||||||
}
|
}
|
||||||
|
daily = project.daily_on(day)
|
||||||
|
bank = carry.balance(day)
|
||||||
return {
|
return {
|
||||||
"project": project.id,
|
"project": project.id,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"minutes": minutes.minutes_for(day),
|
# Real minutes behind the day (own + banked in), never capped: the
|
||||||
"daily_minutes": project.daily_on(day),
|
# 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),
|
"unlock_minutes": project.unlock_on(day),
|
||||||
"ends": project.last_day.isoformat(),
|
"ends": project.last_day.isoformat(),
|
||||||
"replaces": list(project.replaces),
|
"replaces": list(project.replaces),
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path # noqa: TC003
|
from pathlib import Path # noqa: TC003
|
||||||
@ -13,12 +13,16 @@ import time
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import focus_project as fp
|
import focus_project as fp
|
||||||
from focus_project import _clock, minutes
|
from focus_project import _clock
|
||||||
from focus_project._paths import data_dir
|
from focus_project._paths import data_dir
|
||||||
|
|
||||||
D1 = date(2026, 11, 2)
|
D1 = date(2026, 11, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _at(hour: int, minute: int) -> float:
|
||||||
|
return datetime(2026, 11, 2, hour, minute).astimezone().timestamp()
|
||||||
|
|
||||||
|
|
||||||
def _project() -> None:
|
def _project() -> None:
|
||||||
fp.create_project(
|
fp.create_project(
|
||||||
"hr",
|
"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)
|
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()
|
_project()
|
||||||
assert fp.status(D1)["minutes"] == 0 # the stub
|
assert fp.status(D1)["minutes"] == 0
|
||||||
monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45)
|
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)
|
st = fp.status(D1)
|
||||||
assert st == {
|
assert st == {
|
||||||
"project": "hr",
|
"project": "hr",
|
||||||
"name": "HackerRank prep",
|
"name": "HackerRank prep",
|
||||||
"minutes": 45,
|
"minutes": 45,
|
||||||
|
"own_minutes": 45,
|
||||||
|
"counted_minutes": 45,
|
||||||
|
"carried_in": 0,
|
||||||
|
"carry_out": 0,
|
||||||
"daily_minutes": 120,
|
"daily_minutes": 120,
|
||||||
"unlock_minutes": 30,
|
"unlock_minutes": 30,
|
||||||
"ends": "2026-11-05",
|
"ends": "2026-11-05",
|
||||||
@ -141,6 +150,10 @@ def test_status_inactive() -> None:
|
|||||||
"project",
|
"project",
|
||||||
"name",
|
"name",
|
||||||
"minutes",
|
"minutes",
|
||||||
|
"own_minutes",
|
||||||
|
"counted_minutes",
|
||||||
|
"carried_in",
|
||||||
|
"carry_out",
|
||||||
"daily_minutes",
|
"daily_minutes",
|
||||||
"unlock_minutes",
|
"unlock_minutes",
|
||||||
"ends",
|
"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]
|
[project]
|
||||||
name = "focus_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"
|
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# Stdlib only on purpose: guards import it from the system python.
|
# Stdlib only on purpose: guards import it from the system python.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user