diff --git a/focus_project/focus_project/_milestones.py b/focus_project/focus_project/_milestones.py index 7c56a59..f51e969 100644 --- a/focus_project/focus_project/_milestones.py +++ b/focus_project/focus_project/_milestones.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Literal from focus_project import _api, _interview, evidence from focus_project._errors import RegistryError from focus_project._kind import INTERVIEW +from focus_project.sources import activity if TYPE_CHECKING: from collections.abc import Callable @@ -30,6 +31,7 @@ if TYPE_CHECKING: _LOG = logging.getLogger(__name__) State = Literal["pending", "done", "missed", "late_done", "unknown"] PHONE_LEAD = timedelta(hours=12) +MOCK_SESSION_S = 90 * 60 _RED = frozenset({"missed", "unknown"}) @@ -134,6 +136,21 @@ def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]: return judge +def _mock(mid: str, rows: Rows, project: Project) -> Completion: + """Done at the first score row that follows a >= 90 min focus session. + + The session (contiguous beats, :func:`activity.sessions`) must END at or + before the score row; a score with no such session completes nothing. + """ + ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S] + for row in rows: + if (row.milestone, row.kind) == (mid, "score") and any( + end <= row.ms / 1000 for end in ends + ): + return row.at, False + return None, False + + def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion: """Done at the answer row; an answer with no row was given at creation.""" if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers): @@ -164,7 +181,7 @@ def _phone(mid: str, rows: Rows, _project: Project) -> Completion: _JUDGES = { "rules": _by_evidence("file"), - "mock": _by_evidence("score"), + "mock": _mock, "trip_ask": _trip_ask, "slot": _slot, "phone": _phone, diff --git a/focus_project/focus_project/sources/activity.py b/focus_project/focus_project/sources/activity.py index a0dff32..7b70f65 100644 --- a/focus_project/focus_project/sources/activity.py +++ b/focus_project/focus_project/sources/activity.py @@ -84,6 +84,24 @@ def _is_beat(event: dict[str, Any]) -> bool: return event.get("source") == "tutor" and event.get("kind") == "heartbeat" +def sessions(project_id: str) -> list[Interval]: + """Contiguous focus sessions of ``project_id``, time-ordered, as (start, end). + + Built from the same timer/tutor-beat segments (and so the same + :data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one + session, which ends at its last beat. + """ + events = read_events(project_id) + timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)] + merged: list[Interval] = [] + for lo, hi in timer_intervals(timer): + if merged and lo <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(hi, merged[-1][1])) + else: + merged.append((lo, hi)) + return merged + + def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001 """Timer segments plus heartbeats of ``project`` (the registry clips).""" events = read_events(project.id) diff --git a/focus_project/focus_project/tests/helpers.py b/focus_project/focus_project/tests/helpers.py index 2848633..ae2c07f 100644 --- a/focus_project/focus_project/tests/helpers.py +++ b/focus_project/focus_project/tests/helpers.py @@ -3,10 +3,12 @@ from __future__ import annotations -from datetime import date, datetime, time +from datetime import date, datetime, time, timedelta +import json from typing import TYPE_CHECKING import focus_project as fp +from focus_project._paths import activity_path if TYPE_CHECKING: from pathlib import Path @@ -50,3 +52,14 @@ def shot(tmp_path: Path, name: str = "rules.png") -> Path: def states(project: fp.Project, now: datetime) -> dict[str, str]: """Milestone id -> state.""" return {m.id: m.state for m in fp.milestones(project, now)} + + +def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None: + """A timer session start -> check-in every ``step`` min -> stop.""" + activity_path().parent.mkdir(parents=True, exist_ok=True) + with activity_path().open("a", encoding="utf-8") as handle: + for offset in range(0, minutes + 1, step): + kind = "start" if offset == 0 else "checkin" + when = start + timedelta(minutes=offset) + row = {"ts": when.timestamp(), "source": "timer", "kind": kind} + handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n") diff --git a/focus_project/focus_project/tests/test_milestones.py b/focus_project/focus_project/tests/test_milestones.py index addeb39..2d17555 100644 --- a/focus_project/focus_project/tests/test_milestones.py +++ b/focus_project/focus_project/tests/test_milestones.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING import focus_project as fp from focus_project import evidence -from focus_project.tests.helpers import SAT1, SAT2, at, make, shot, states +from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states if TYPE_CHECKING: from pathlib import Path @@ -97,6 +97,7 @@ def _clean(tmp_path: Path) -> None: fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9)) fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9)) fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9)) + session(at(24, 7), 90) fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9)) @@ -147,13 +148,6 @@ def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None: assert states(p, at(10, 23, 59))["rules"] == "done" -def test_mock_needs_a_score(tmp_path: Path) -> None: - p = make() - assert states(p, at(26, 8))["mock"] == "missed" - fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8)) - assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done" - - def test_slot_needs_entry_and_confirm_tap() -> None: p = make() fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9)) diff --git a/focus_project/focus_project/tests/test_milestones_mock.py b/focus_project/focus_project/tests/test_milestones_mock.py new file mode 100644 index 0000000..b7470c3 --- /dev/null +++ b/focus_project/focus_project/tests/test_milestones_mock.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The mock milestone: a score counts only after a >= 90 min focus session.""" + +from __future__ import annotations + +import focus_project as fp +from focus_project.tests.helpers import at, make, session, states + + +def test_mock_needs_a_score_after_a_long_session() -> None: + p = make() + assert states(p, at(26, 8))["mock"] == "missed" + session(at(25, 20), 90) + fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8)) + assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done" + + +def test_score_without_a_session_does_not_complete_mock() -> None: + p = make() + fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9)) + assert states(p, at(24, 10))["mock"] == "pending" + assert states(p, at(26, 8))["mock"] == "missed" + assert fp.owed_lock(at(26, 8)) is not None + + +def test_short_session_does_not_complete_mock() -> None: + p = make() + session(at(24, 7), 89) + fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9)) + assert states(p, at(24, 10))["mock"] == "pending" + + +def test_session_broken_by_a_gap_does_not_complete_mock() -> None: + p = make() + session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s) + session(at(24, 7, 11), 60) + fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9)) + assert states(p, at(24, 10))["mock"] == "pending" + + +def test_session_must_end_before_the_score() -> None: + p = make() + session(at(24, 8), 90) # ends 09:30 + fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9)) + assert states(p, at(24, 10))["mock"] == "pending" + fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30)) + assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score + + +def test_other_projects_sessions_do_not_count() -> None: + p = make() + session(at(24, 7), 90, pid="other") + fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9)) + assert states(p, at(24, 10))["mock"] == "pending" + + +def test_session_on_time_but_score_late_is_late_done() -> None: + p = make() + session(at(25, 20), 100) + fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5)) + assert states(p, at(26, 1))["mock"] == "late_done" diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 698e52f..0aa5598 100644 --- a/focus_project/pyproject.toml +++ b/focus_project/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "focus_project" -version = "0.9.0" +version = "0.9.1" 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.