mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:27:14 +01:00
earners_for swaps a focus project's replaced gates for one focus earner paid per minute from the focus ledger (floor of summed minutes). The gaming cut equals what the replaced penalised gates cost, so a project day is neutral; maturity never delays it. An unusable focus_project means no project (logged once). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
228 lines
9.0 KiB
Python
228 lines
9.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""Which HMAC-verified ``credit`` rows count for a window, per gate.
|
|
|
|
Each ledger-backed :class:`~earned_time._policy.Earner` names one of these as
|
|
its ``match``. They see only rows that already passed the signature check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import logging
|
|
from types import MappingProxyType
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable, Mapping
|
|
|
|
Window = tuple[float, float]
|
|
CreditMatch = Callable[[dict[str, object], Window], bool]
|
|
|
|
_logger: Final = logging.getLogger(__name__)
|
|
|
|
|
|
def _within(raw: object, window: Window) -> bool | None:
|
|
"""Whether a unix-seconds stamp falls in ``window``; ``None`` if unusable."""
|
|
try:
|
|
stamp = float(str(raw))
|
|
except ValueError:
|
|
return None
|
|
start, end = window
|
|
return start <= stamp <= end
|
|
|
|
|
|
def leetcode_match(row: dict[str, object], window: Window) -> bool:
|
|
"""A solve counts on the day LeetCode accepted it (``submitted_at``).
|
|
|
|
``day`` is the *harvesting* run's date and runs late, so it is only the
|
|
fallback for a row without a usable stamp: it can miss a late solve but
|
|
never invent one.
|
|
|
|
A day paid ahead from banked credits counts too (0.17.0, kuhy 2026-10-23:
|
|
working ahead must not forfeit the bonus). leetcode-guard writes a
|
|
``charge`` with ``source == "leetcode"`` only when verified credits cover
|
|
the day; its escape hatch and outage path write other sources and pay
|
|
nothing. The charge counts for the day it names.
|
|
"""
|
|
detail = row.get("detail")
|
|
if row.get("kind") == "charge":
|
|
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone().date()
|
|
return (
|
|
isinstance(detail, dict)
|
|
and detail.get("source") == "leetcode"
|
|
and row.get("day") == local.isoformat()
|
|
)
|
|
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
|
if raw is not None:
|
|
landed = _within(raw, window)
|
|
if landed is not None:
|
|
return landed
|
|
_logger.warning(
|
|
"LeetCode credit %r has an unparsable submitted_at (%r); "
|
|
"falling back to its day key",
|
|
row.get("entry_id"),
|
|
raw,
|
|
)
|
|
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone()
|
|
return row.get("day") == local.date().isoformat()
|
|
|
|
|
|
def reading_match(row: dict[str, object], window: Window) -> bool:
|
|
"""A reading credit counts if it earned the bonus and ended today."""
|
|
detail = row.get("detail")
|
|
if not isinstance(detail, dict) or detail.get("bonus") != "1":
|
|
return False
|
|
landed = _within(detail.get("ended_at"), window)
|
|
if landed is None:
|
|
_logger.warning("reading credit %r has no usable ended_at", row.get("entry_id"))
|
|
return False
|
|
return landed
|
|
|
|
|
|
def anki_match(row: dict[str, object], window: Window) -> bool:
|
|
"""An Anki credit counts on its Anki day (``detail.anki_day``).
|
|
|
|
anki-guard writes one row per Anki day, keyed on the collection's own day
|
|
boundary, so the day it names is the day it pays -- no stamp to parse.
|
|
"""
|
|
detail = row.get("detail")
|
|
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone().date()
|
|
return isinstance(detail, dict) and detail.get("anki_day") == local.isoformat()
|
|
|
|
|
|
def workout_match(row: dict[str, object], window: Window) -> bool:
|
|
"""A workout credit counts when it was completed in the window.
|
|
|
|
screen-locker writes one row per credited unit. A ``rest_day`` row is a
|
|
credit for the day it names (``day``), not for when it was written, and
|
|
only if it was declared (``declared_at``) before that day began: a rest
|
|
day declared on the day itself is a skipped workout, not a rest day.
|
|
"""
|
|
detail = row.get("detail")
|
|
if not isinstance(detail, dict):
|
|
return False
|
|
if detail.get("source") == "rest_day":
|
|
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone().date()
|
|
if row.get("day") != local.isoformat():
|
|
return False
|
|
try:
|
|
declared = float(str(detail.get("declared_at")))
|
|
except ValueError:
|
|
declared = None
|
|
if declared is None or declared >= window[0]:
|
|
_logger.warning(
|
|
"rest-day credit %r was not declared before its day (declared_at "
|
|
"%r); it does not count",
|
|
row.get("entry_id"),
|
|
detail.get("declared_at"),
|
|
)
|
|
return False
|
|
return True
|
|
landed = _within(detail.get("completed_at"), window)
|
|
if landed is None:
|
|
_logger.warning(
|
|
"workout credit %r has no usable completed_at", row.get("entry_id")
|
|
)
|
|
return False
|
|
return landed
|
|
|
|
|
|
# A tutor row without ``detail.minutes`` is a 0.7.0 15-minute block.
|
|
LEGACY_TUTOR_MINUTES: Final = 15
|
|
|
|
|
|
def tutor_minutes(row: dict[str, object]) -> int | None:
|
|
"""Active minutes a tutor row pays (0.8.0); ``None`` if ``minutes`` is bad.
|
|
|
|
``detail.minutes`` is a positive int (never a bool, float or string). A
|
|
row without it is a legacy 15-minute block (:data:`LEGACY_TUTOR_MINUTES`).
|
|
"""
|
|
detail = row.get("detail")
|
|
if not isinstance(detail, dict) or "minutes" not in detail:
|
|
return LEGACY_TUTOR_MINUTES
|
|
raw = detail["minutes"]
|
|
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
|
|
return None
|
|
return raw
|
|
|
|
|
|
def tutor_match(row: dict[str, object], window: Window) -> bool:
|
|
"""A tutor credit counts on the day it ended (``detail.ended_at``).
|
|
|
|
The Automation tutor pays per active minute (0.8.0): each row carries the
|
|
minutes it pays in ``detail.minutes`` (:func:`tutor_minutes`); a 0.7.0
|
|
15-minute block row has none and pays 15. A row whose ``minutes`` is
|
|
present but not a positive int counts for nothing, logged. A session
|
|
that runs past midnight pays the day each row ended on.
|
|
"""
|
|
if tutor_minutes(row) is None:
|
|
_logger.warning("tutor credit %r has bad minutes", row.get("entry_id"))
|
|
return False
|
|
detail = row.get("detail")
|
|
raw = detail.get("ended_at") if isinstance(detail, dict) else None
|
|
landed = _within(raw, window)
|
|
if landed is None:
|
|
_logger.warning("tutor credit %r has no usable ended_at", row.get("entry_id"))
|
|
return False
|
|
return landed
|
|
|
|
|
|
def focus_minutes(row: dict[str, object]) -> int | None:
|
|
"""Minutes a focus row pays: ``detail.minutes``, a positive int, else ``None``."""
|
|
detail = row.get("detail")
|
|
raw = detail.get("minutes") if isinstance(detail, dict) else None
|
|
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
|
|
return None
|
|
return raw
|
|
|
|
|
|
def focus_match(row: dict[str, object], window: Window) -> bool:
|
|
"""A focus credit counts on the day of ``detail.ended_at``, else its ``day``."""
|
|
if focus_minutes(row) is None:
|
|
_logger.warning("focus credit %r has bad minutes", row.get("entry_id"))
|
|
return False
|
|
detail = row.get("detail")
|
|
landed = _within(
|
|
detail.get("ended_at") if isinstance(detail, dict) else None, window
|
|
)
|
|
if landed is not None:
|
|
return landed
|
|
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone()
|
|
return row.get("day") == local.date().isoformat()
|
|
|
|
|
|
# The ``detail`` field each matcher decides on: the unix second the work
|
|
# happened, which :func:`earned_time._credits.credit_time` reports. A matcher
|
|
# without one (Anki's day key) falls back to the row's ``created_at``.
|
|
CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
|
{
|
|
leetcode_match: "submitted_at",
|
|
reading_match: "ended_at",
|
|
workout_match: "completed_at",
|
|
tutor_match: "ended_at",
|
|
focus_match: "ended_at",
|
|
}
|
|
)
|
|
# How many units one counting row pays, per matcher; a matcher without an
|
|
# entry pays 1 per row (the workout). Only the tutor's rows carry their own
|
|
# count (``detail.minutes``): book-guard's ``detail.minutes`` is a string
|
|
# and means something else, so this is never read generically.
|
|
ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = (
|
|
MappingProxyType({tutor_match: tutor_minutes, focus_match: focus_minutes})
|
|
)
|
|
# Which ledger row kinds each matcher is shown; a matcher without an entry
|
|
# sees only ``credit`` rows. LeetCode also sees ``charge`` rows, so a day
|
|
# settled from banked credits counts (``leetcode_match``).
|
|
ROW_KINDS: Final[Mapping[CreditMatch, frozenset[str]]] = MappingProxyType(
|
|
{leetcode_match: frozenset({"credit", "charge"})}
|
|
)
|
|
# The narrower kinds a matcher is shown while its gate is on (0.18.0). A
|
|
# LeetCode credit is one unit of a price that may be 5, so while the gate
|
|
# gates only the charge it writes once the price is covered earns (kuhy,
|
|
# 2026-10-24: one solve on a 5-credit day paid the whole bonus). The
|
|
# consumer passes ``gate_off`` live on a free or stood-down sick day, when
|
|
# there is no price, and the ``ROW_KINDS`` view applies again.
|
|
PAID_DAY_KINDS: Final[Mapping[CreditMatch, frozenset[str]]] = MappingProxyType(
|
|
{leetcode_match: frozenset({"charge"})}
|
|
)
|