Skip to content

Commit 1abb045

Browse files
arhxamclaude
andauthored
fix(backend): weekly score window is 7 days, not 8 (off-by-one) (#10015)
get_scores() documents its weekly window as "tasks created in the 7 days ending on that date", but builds it as: day_end = day + timedelta(days=1) week_start = day - timedelta(days=7) # created_at in [day-7, day+1) [day-7, day+1) spans 8 calendar days (day-7 .. day inclusive), so every task created on the day-7 date is counted in the weekly totals, inflating weekly.total_tasks and skewing weekly.score. The adjacent daily window is built as [day, day+1) — exactly one day — which fixes the intended construction: an N-day window ending on `day` is [day-(N-1), day+1). For the 7-day weekly window that is [day-6, day+1). Fix: week_start = day - timedelta(days=6). This preserves the created_at field choice (the "matches Rust backend which uses created_at" comment is about the field, not the window; the Rust backend has no weekly-score window) and only corrects the span from 8 to 7 days. Test: the existing get_scores coverage only asserted the query uses the created_at field, never the window bounds. Added test_weekly_window_spans_seven_days_ending_on_date, which captures the FieldFilter calls and asserts the created_at lower bound for date=2026-07-19 is 2026-07-13 (day-6), not 2026-07-12 (day-7). Verification: exercised the real get_scores with a mocked db + FieldFilter tracker -> created_at >= 2026-07-13 (7-day span); reverting the one-line fix yields 2026-07-12 (8-day span) and the new assertion fails. black --line-length 120 --check: clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 177117a commit 1abb045

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

backend/database/action_items.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1058,7 +1058,10 @@ def get_scores(uid: str, date: Optional[str] = None) -> Dict[str, Any]:
10581058

10591059
day_start = day
10601060
day_end = day + timedelta(days=1)
1061-
week_start = day - timedelta(days=7)
1061+
# "7 days ending on that date" is inclusive of `day`, so the window is
1062+
# [day-6, day+1) — mirroring the one-day daily window [day, day+1). Using
1063+
# day-7 spanned 8 calendar days and over-counted the weekly totals.
1064+
week_start = day - timedelta(days=6)
10621065

10631066
col = db.collection('users').document(uid).collection(action_items_collection)
10641067

backend/tests/unit/test_desktop_migration.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,41 @@ def col_where(**kwargs):
16341634
# Verify created_at was used in filter calls (for weekly query)
16351635
assert 'created_at' in captured_filters, f"Expected created_at in filters, got: {captured_filters}"
16361636

1637+
def test_weekly_window_spans_seven_days_ending_on_date(self):
1638+
"""The weekly window is the 7 days ending on `date`, i.e. [date-6, date+1).
1639+
1640+
Regression: it was [date-7, date+1) — 8 calendar days — which over-counted
1641+
every task created on the date-7 day, inconsistent with the docstring and
1642+
with the one-day daily window [date, date+1).
1643+
"""
1644+
from datetime import timedelta
1645+
1646+
mock_col = MagicMock()
1647+
empty = MagicMock()
1648+
empty.where.return_value = empty
1649+
empty.stream.return_value = []
1650+
mock_col.where.return_value = empty
1651+
mock_col.stream.return_value = []
1652+
1653+
captured = [] # (field, op, value)
1654+
original_ff = action_items_db.FieldFilter
1655+
1656+
def tracking_filter(field, op, value):
1657+
captured.append((field, op, value))
1658+
return original_ff(field, op, value)
1659+
1660+
with patch.object(action_items_db, 'db') as patched_db, patch.object(
1661+
action_items_db, 'FieldFilter', side_effect=tracking_filter
1662+
):
1663+
patched_db.collection.return_value.document.return_value.collection.return_value = mock_col
1664+
action_items_db.get_scores('test-uid', date='2026-07-19')
1665+
1666+
day = datetime(2026, 7, 19, tzinfo=timezone.utc)
1667+
created_at_lower = [v for (f, op, v) in captured if f == 'created_at' and op == '>=']
1668+
assert created_at_lower, f"no created_at >= filter captured: {captured}"
1669+
# 7 days ending on 2026-07-19 -> lower bound is 2026-07-13 (day-6), not 2026-07-12 (day-7).
1670+
assert created_at_lower[0] == day - timedelta(days=6), created_at_lower[0]
1671+
16371672
def test_default_tab_daily_when_highest(self):
16381673
"""default_tab is 'daily' when daily has tasks and highest score."""
16391674
mock_col = MagicMock()

0 commit comments

Comments
 (0)