Build a status grader that never gets confused by today

Local Fitness · No. 021

Shipped

My training-plan tracker had two grading bugs. A run I’d already finished and synced from my watch still showed “pending.” And a recovery walk I took instead of a scheduled run, on a day where that’s exactly what the plan called for, showed as a miss. v0.8.0 fixes both: a finished day grades immediately regardless of when you check, and an easy day counts a walk the same as a run. Both bugs came from the same design mistake, and it’s a mistake that shows up in any system that grades a “day” or a “status” from data that arrives on its own schedule: deployment dashboards, attendance trackers, SLA reports, anything with a state that’s supposed to reflect what happened, not when the system noticed.

The fix is to grade a day by its outcome and keep the calendar out of the decision entirely. Here’s the full build: the state model, the grading function, scoring a stretch of days without letting uncertainty skew the total, and the rule that keeps two parts of a system from disagreeing about the same verdict.

Model what happened, not when you’re asking

The “pending” bug came from grading against a clock. The code held any day at or after the most recent data sync as pending, on the reasonable theory that you shouldn’t call a day missed before its data has arrived. The catch is that the most recent synced day is always today, so today was permanently pending, even with a finished, synced run sitting in the database. That’s the standard split between when something happened and when your system got around to noticing: streaming systems distinguish “event time,” which depends only on the data itself, from “processing time,” which tracks the machine’s system clock and is not deterministic, since it’s subject to network delays, queueing, and outages (Apache Flink: Timely Stream Processing). A grader that reads processing time gives a different answer depending on when you ask, and “today” is exactly the boundary where that difference is most visible.

So model the thing you care about instead of the clock. A day has a plan (what was expected of it) and an actual (what happened), and the verdict is a pure function of those two fields, never of the date:

# model.py
from dataclasses import dataclass
from datetime import date
from enum import Enum

class Plan(Enum):
    EXPECTED = "expected"      # a specific thing was due today
    LENIENT = "lenient"        # a flexible day; a substitute still counts
    NONE = "none"              # nothing was due, on purpose

class Actual(Enum):
    DONE = "done"               # the expected thing happened
    SUBSTITUTE = "substitute"   # something else happened that counts on a lenient day
    STARTED = "started"         # begun, not finished
    UNKNOWN = "unknown"         # nothing recorded yet

@dataclass(frozen=True)
class Day:
    on: date
    plan: Plan
    actual: Actual
    data_arrived: bool   # has this day's data reached the system?

This is a compact input-to-output mapping with no transitions and no memory carried between days, the same shape as a decision table: a fixed set of conditions maps to a fixed set of actions (Wikipedia: Decision table). Nothing here treats on as special. It’s a field the model happens to carry, not the axis the whole decision turns on.

Grade the outcome first, hold judgment only where it’s real

The grading function has two jobs, and keeping them separate is what makes the boundary case tractable. First, decide what happened, purely from plan and actual. Second, decide whether that decision is final yet, which is the only place data_arrived comes in.

# grade.py
from enum import Enum
from model import Day, Plan, Actual

class Verdict(Enum):
    DONE = "done"
    OK = "ok"                    # the day asked for nothing, or asked for rest, and got it
    MISSED = "missed"
    IN_PROGRESS = "in_progress"  # internal only: evaluate() emits it, grade() folds it to PENDING
    PENDING = "pending"          # can't judge yet, the day isn't settled

SETTLED_SUCCESS = {Verdict.DONE, Verdict.OK}

def evaluate(day: Day) -> Verdict:
    """Map (plan, actual) to a verdict by outcome alone. Never looks at day.on."""
    if day.plan is Plan.NONE:
        return Verdict.OK
    if day.plan is Plan.LENIENT and day.actual in (Actual.DONE, Actual.SUBSTITUTE):
        return Verdict.DONE
    if day.actual is Actual.DONE:
        return Verdict.DONE
    if day.actual is Actual.STARTED:
        return Verdict.IN_PROGRESS
    return Verdict.MISSED

def grade(day: Day) -> Verdict:
    verdict = evaluate(day)
    if verdict in SETTLED_SUCCESS:
        return verdict
    # verdict is in-progress or missed: only final once the day is settled.
    settled = day.data_arrived and day.actual is not Actual.STARTED
    return verdict if settled else Verdict.PENDING

A finished day returns DONE straight out of evaluate, so it never touches the pending gate, which is what fixes “today.” A day with nothing expected returns OK the same way, regardless of whether its data has arrived. Everything else, an in-progress day or a day with nothing recorded, is only allowed to become final once data_arrived is true and the day isn’t still in progress; otherwise it holds at PENDING. IN_PROGRESS is deliberately internal: evaluate produces it, but grade always folds it into PENDING, so a caller only ever sees DONE, OK, MISSED, or PENDING.

The LENIENT branch is the other rule this release shipped: on a lenient day, a substitute counts the same as the expected thing. On a day where something specific was expected, that same substitute falls through and grades MISSED, because it’s the plan type, not just the fact that something happened, that decides whether it counts.

Score a stretch of days without letting uncertainty count against you

Because grade is a pure function of one Day, scoring a range is a plain map, and the interesting decision moves to what you do with the pending ones.

# score.py
from datetime import date
from model import Day, Plan, Actual
from grade import grade, Verdict

week = [
    Day(date(2026, 6, 20), Plan.EXPECTED, Actual.DONE,       data_arrived=True),   # target hit
    Day(date(2026, 6, 21), Plan.NONE,     Actual.UNKNOWN,    data_arrived=True),   # nothing was due
    Day(date(2026, 6, 22), Plan.LENIENT,  Actual.SUBSTITUTE, data_arrived=True),   # lenient day, a substitute counts
    Day(date(2026, 6, 23), Plan.EXPECTED, Actual.STARTED,    data_arrived=True),   # in progress
    Day(date(2026, 6, 24), Plan.EXPECTED, Actual.UNKNOWN,    data_arrived=False),  # today, data not in yet
    Day(date(2026, 6, 25), Plan.EXPECTED, Actual.UNKNOWN,    data_arrived=True),   # due, nothing recorded
]

for d in week:
    print(d.on, grade(d).value)

def adherence(days) -> float | None:
    verdicts = [grade(d) for d in days]
    graded = [v for v in verdicts if v is not Verdict.PENDING]  # pending isn't a judgment yet
    if not graded:
        return None
    good = sum(v in {Verdict.DONE, Verdict.OK} for v in graded)
    return good / len(graded)

print("adherence:", adherence(week))

Running it prints exactly this:

2026-06-20 done
2026-06-21 ok
2026-06-22 done
2026-06-23 pending
2026-06-24 pending
2026-06-25 missed
adherence: 0.75

Six days in, and only four are settled: two done, one ok, one missed. adherence drops the two pending days from the denominator entirely instead of guessing, so the rate is 3/4, not 3/6. That matters more than it looks: if pending days counted as failures, the rate would silently improve every time a background sync catches up, which means the number was never really measuring adherence, it was measuring how stale your data happened to be at the moment you asked.

(The float | None return uses the X | None union syntax from PEP 604, available in Python 3.10 and later; on an older runtime, write Optional[float] instead.)

One verdict, read everywhere it’s rendered

Getting the grading function right is necessary but not sufficient. The bug that shipped a second time, inside this same release, was a UI component that recomputed its own idea of “did this day succeed” from raw fields instead of reading the verdict the grader had already produced. Once a lenient day started counting a substitute as done, a substitute’s pace or distance still looked nothing like the original target, so a component that judged success by comparing raw numbers against the target kept painting that same done day as a miss. Two parts of the system were answering the same question independently, and they disagreed.

That’s the single-source-of-truth problem: each fact should have exactly one place it’s computed, and everything else should read the result instead of keeping its own copy (Wikipedia: Single source of truth). It’s also the specific case React’s own docs warn about: if a value can be derived from state you already have, computing it again in a second place is redundant state, and redundant state is what goes stale (React: Choosing the State Structure). The fix is for the second component to read the verdict and stop computing its own opinion:

# ui.py
from grade import Verdict

# The verdict is the single source of truth for how a day renders.
# Nothing downstream re-derives "did this day succeed" from raw fields.
BADGE = {
    Verdict.DONE: "badge-green",
    Verdict.OK: "badge-green",
    Verdict.PENDING: "badge-gray",
    Verdict.MISSED: "badge-red",
}

def badge_class(verdict: Verdict) -> str:
    return BADGE[verdict]

badge_class takes a Verdict and nothing else. It can’t drift from the grader’s decision because it has no other data to disagree with.

Verify the boundaries

The cases worth pinning down are the boundary cases, so each one becomes a test: a finished day grades regardless of its date, a day with nothing expected reads OK regardless of sync state, an in-progress day holds pending rather than booking partial credit, an unsettled day holds pending rather than a false miss, and a settled, expected, empty day is a genuine miss.

# test_grade.py
from datetime import date
from model import Day, Plan, Actual
from grade import grade, Verdict

def day(plan, actual, *, arrived=True):
    return Day(date(2026, 6, 23), plan, actual, arrived)

def test_grade_ignores_the_date_field():
    today = Day(date.today(), Plan.EXPECTED, Actual.DONE, data_arrived=True)
    past = Day(date(2020, 1, 1), Plan.EXPECTED, Actual.DONE, data_arrived=True)
    assert grade(today) == grade(past) == Verdict.DONE

def test_done_grades_immediately_even_unsettled():
    assert grade(day(Plan.EXPECTED, Actual.DONE)) is Verdict.DONE
    assert grade(day(Plan.EXPECTED, Actual.DONE, arrived=False)) is Verdict.DONE

def test_substitute_counts_only_on_a_lenient_day():
    assert grade(day(Plan.LENIENT, Actual.SUBSTITUTE)) is Verdict.DONE
    assert grade(day(Plan.EXPECTED, Actual.SUBSTITUTE)) is Verdict.MISSED

def test_no_plan_is_ok_regardless_of_data_arrival():
    assert grade(day(Plan.NONE, Actual.UNKNOWN)) is Verdict.OK
    assert grade(day(Plan.NONE, Actual.UNKNOWN, arrived=False)) is Verdict.OK

def test_in_progress_is_pending_not_partial_credit():
    assert grade(day(Plan.EXPECTED, Actual.STARTED)) is Verdict.PENDING

def test_unsettled_day_holds_pending_not_a_false_miss():
    assert grade(day(Plan.EXPECTED, Actual.UNKNOWN, arrived=False)) is Verdict.PENDING

def test_settled_and_expected_but_nothing_recorded_is_a_real_miss():
    assert grade(day(Plan.EXPECTED, Actual.UNKNOWN, arrived=True)) is Verdict.MISSED

if __name__ == "__main__":
    tests = [v for k, v in list(globals().items()) if k.startswith("test_")]
    for t in tests:
        t()
        print(f"{t.__name__} ... ok")
    print(f"{len(tests)} passed")

Running python3 test_grade.py prints:

test_grade_ignores_the_date_field ... ok
test_done_grades_immediately_even_unsettled ... ok
test_substitute_counts_only_on_a_lenient_day ... ok
test_no_plan_is_ok_regardless_of_data_arrival ... ok
test_in_progress_is_pending_not_partial_credit ... ok
test_unsettled_day_holds_pending_not_a_false_miss ... ok
test_settled_and_expected_but_nothing_recorded_is_a_real_miss ... ok
7 passed

test_grade_ignores_the_date_field is the one that guards the fix: it grades two days that are identical except for on, one dated today and one years ago, and asserts they come out the same. That test fails the moment grading reads the calendar again.

Gotchas

Gating “not final yet” on the wrong verdict lets a half-done day book partial credit. The first version of this fix only held MISSED pending until the data settled; an in-progress day fell straight through and graded as its half-finished outcome. Picture a fraction-based verdict where being 40% through counts as partial credit: a day that’s only 40% done at noon would score that partial credit immediately, then silently change again once it finished later that day. The number would move without any new information arriving from outside, just the system re-grading its own prior guess. The fix is to hold both “missed” and “in-progress” pending until the day is settled, not just the missed case; a review pass caught this exact gap before it shipped, and it’s worth writing that check into the test suite rather than trusting it stays caught by a human next time.

A field you add to the model still needs to survive every projection between it and the caller. Adding a field to the day record is the easy part. If anything downstream re-shapes that record before handing it to a caller (an API serializer, a tool wrapper, a view model), and that re-shaping step lists fields explicitly instead of passing the record through, your new field gets silently dropped at that boundary. The bug doesn’t show up as an error; the field is just missing from the output, and nothing tells you why. Grep every explicit field list between the model and the final consumer whenever you add a field, don’t just check the model and the one obvious render site.

Recomputing a decision from raw data is a second definition of “correct,” and it will eventually disagree with the first. The badge-coloring bug above is the general case: any code that judges success by re-checking pace, distance, thresholds, whatever raw signal originally fed the grader, is maintaining its own copy of the grader’s logic. The two copies stay in sync only by accident, until a rule changes in one of them and not the other. Read the verdict field itself, always, and delete the code path that re-derives it.

Sources

Changelog

  • fix: outcome-based plan grading + recovery walks count on easy days (0.8.0) (20b19e0)
  • docs: revise grading-fixes design per quality-gate (4 rounds, 4->0) (f6b0732)
  • docs: design for training-plan grading fixes (today-pending + walks) (f93db60)