focus_project 0.9.1: mock needs 90 min
Some checks are pending
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
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

A score row completes mock only after a contiguous >= 90 min
focus session (600 s gap rule) that ended by the score row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:14:18 +01:00
parent 21a0b5ce1c
commit 0527bc680d
6 changed files with 114 additions and 11 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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")

View File

@ -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))

View File

@ -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"

View File

@ -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.