feat(statistics): Closed Days are not business days (#103) #110
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!110
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/statistics-closed-days"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #103
Summary
A Closed Day (a business day the weekday schedule or a dated exception declares closed) is no longer a business day for statistics. A closure never makes a week or month partial, and passages counted on it (staff, guards) stay out of the deck's figures. Step 1 of the pre-deck work agreed on 2026-09-25.
Problem
The admin can already declare closures (weekday "open" checkbox, dated entry marked closed), but statistics ignored
is_open. A closed day with no counted data made its period partial, like an outage, and a closed day with staff passages counted as a normal business day.Architectural impact
OccupancyManager.get_closed_business_days_asyncresolves closures with the same precedence as the live schedule: a dated exception overrides the weekday schedule; open by default.OccupancyRepository.get_counted_day_flow_async/get_counted_camera_flow_asynctake optionalskip_windows(half-open cycle spans) so Closed Day passages are left out in SQL.PeriodQuality.closed_days,DailyStatistics.is_closed,StatisticsSummary.closed, comparison reasonCLOSED_PERIOD.business_daysexcludes Closed Days; period visitors, egress, daily average, busiest/best day, peak, weekend share, entrance totals/shares, comparison references and usual-weekday samples exclude them. A fully closed period returnsclosed: truewith zero figures (summary) or an empty list (entrances) instead of 404, and is listed in the picker withbusiness_days: 0.CONTEXT.md. API docs updated.Known limitation: schedules have no history, so a change to the weekly schedule also re-classifies past days. Same limitation already applies to opening hours (dwell, dayparts).
Verification
tests/test_statistics_closed_days.py(4 tests): exception-over-weekday precedence; a week with a closed Wednesday is complete (6 business days), its staff passages stay out of visitors, daily average, entrances and the previous-week reference; a closed day returnsclosed: true,CLOSED_PERIODcomparisons and is listed in the picker; a closed weekday is never a usual-weekday sample. All 4 fail without the service changes.pytest: 332 passed. Ruff lint/format and pre-commit passed.scripts/check_docs.py: 33 files, 72 operations.Checklist
business_days; never partial.🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed
git diff origin/master...origin/feat/statistics-closed-days(b62694c) against #103. Standards and Spec ran independently and are reported separately, not reranked. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all fixed on the branch.Standards
Ruff check/format clean;
pytest331 passed, 1 skipped. No hard violations of AGENTS.md or the code standards: SQL stays in the repository, functions are typed, I/O is async.Bug
app/services/analytics_service.py:543: the picker lists a period whencovered_days or closed_days. A week with one Closed Day and no counted data (an outage) is listed withbusiness_days: 6, covered_days: 0; selecting it fails becausevalidate_statistics_period_async(:160) raisesLookupError(404). Condition should bequality.covered_days or quality.business_days == 0.Judgement calls (smells)
occupancy_service.py:221-238vsget_active_schedule_info_async: "a dated exception beats the weekday schedule, open by default" now exists twice (the docstring says so: "Uses the same precedence…"). The daily series takesis_closedfrom the old copy (analytics_service.py:662) while quality and figures use the new one (:498). If they drift, the summary'snot row.is_closedfilter (:246) andbusiness_daysdisagree. Make the old path call the new resolver.[start, end)day list[start + timedelta(days=offset) for offset in range((end - start).days)]is built twice (analytics_service.py:169,:496). Extract a helper.skip_windows: Sequence[tuple[float, float]](occupancy_repository.py:38, 1233, 1256) is a bare tuple for a cycle span; a bounds type withstart/next_startalready exists.get_closed_business_days_asyncreads the wholeoccupancy_holidaysand schedule tables. One summary resolves closures 3–4 times (quality, reference quality,_closed_windows_async); the picker once per period. The baseline (:122) resolves closures and then still callsget_holiday_by_date_asyncper candidate (:128). Resolve once per request and pass the set along.close_weekday(6)(test:72) is an unnamed Sunday;date(2026, 9, 7)(:70) andrange(7)(:77, :98) are bare literals. Name them (calendar.SUNDAY).declare_exceptionandclose_weekday(test:42-58) write raw SQL, though the repository hasupsert_holiday/update_daily_schedule_async(occupancy_repository.py:597, ~644). AGENTS.md §3: test real flows.CONTEXT.md
CONTEXT.md:101: the definition contradicts itself — "A business day on which…" then "It is not a business day for statistics". Start with "A calendar day…". Otherwise domain language only, meeting the glossary-only rule.SQL correctness (checked, OK)
The skip-window filter is correct:
AND NOT (ts >= ? AND ts < ?)sits in theLEFT JOIN … ONclause in the camera query (zero-flow cameras still appear) and inWHEREin the day-flow query. Spans are half-open and aligned with cycle bounds; the range predicate can still use the index; a month has at most 31 terms, well under SQLite's parameter limit.Spec
Verdict: one P2 to fix before merge; the rest is P3. Full suite passes (331 passed, 1 skipped) and the 4 new tests pass in a throwaway worktree.
(c) Implemented, but wrong in one case
app/services/analytics_service.py:~544. Spec: "Picker quality and daily rows expose it:closed_daysonPeriodQuality…". The picker filter is nowquality.covered_days or quality.closed_days, which lists an outage period (no data on any open day) as soon as it contains one Closed Day. Selecting it then fails, becausevalidate_statistics_period_async(:160) raises whenbusiness_days and covered_days == 0. Confirmed with a probe: a week with no open-day data and one closed Wednesday is listed asbusiness_days=6, covered_days=0, and its summary returns404 NOT_FOUND. Any site with a closed weekday in its weekly schedule would show every outage week/month in the picker. Fix:if quality.covered_days or quality.business_days == 0.(a) Missing or partial
VisitorComparison). Spec: "Comparisons keep comparing totals; the badge explains fewer business days."quality.closed_daysonly describes the current period; the response doesn't say the previous/last-year period had fewer business days, so the badge would need a separate picker call. Acceptable if the deck does that, but decide explicitly.previousreportsNO_CAMERA_DATA_PREVIOUS_PERIOD, notCLOSED_PERIOD(:~425);previous_month_daily_average_reasonfalls back toNO_DATA_PREVIOUS_PERIOD(:~352); the closed summary'sprevioushas noreference_start/reference_end(:236).(b) Scope creep
None.
CLOSED_PERIODandStatisticsSummary.closedare what "the summary returns a closed status" needs.Surfaces checked and found correct
covered), peak, busiest/best day,daily_average,previous_month_daily_average, and the entrance current/previous/last-year rows (viaskip_windows).:791,:1057).DailyStatistics.is_closedandget_closed_business_days_asyncagree on precedence ("a dated entry overrides the weekday schedule").first_event_epoch(occupancy_repository.py:1271) still counts Closed Day events; a camera whose first events fell only on a previous period's Closed Day is labelledNO_REFERENCE_DATA, notNEW. Arguably correct.Test coverage
busiest_day/peak exclusion not really tested (tests/test_statistics_closed_days.py:~130): the closed Wednesday has 3 passages vs 10, sobusiest_day != wednesdaypasses even without the exclusion. Give the Closed Day the highest count.best_day/previous_month_daily_average, the last-year reference, the pickerlimitcounting fully closed days.Summary: Standards 9 (2×P2, 7×P3), worst: picker lists outage periods that 404 (also flagged by Spec). Spec 7 (1×P2, 6×P3), worst: the same picker bug.
🤖 Generated with Claude Code
Pass-1 findings addressed in
7f1f7a6. Full suite: 334 passed; ruff and docs check clean.Standards
covered_days or business_days == 0. Newtest_an_outage_week_with_a_closed_day_is_not_selectablefails with the old filter.is_open_by_schedule(exception, weekday)inoccupancy_service.pyis the single rule;get_active_schedule_info_async(daily-rowis_closed) andScheduleCalendar.is_closedboth call it.business_dates(start, end)module helper.(float, float)skip spansskip_cycles: Sequence[CycleBounds]; SQL built by_skip_cycles_sql.ScheduleCalendaris loaded once per summary / entrances / picker request and passed down (quality, comparisons, closed cycles, baseline). The baseline usescalendar.exception_forinstead of one holiday query per candidate.calendar.WEDNESDAY/SATURDAY/SUNDAY,DAYS_PER_WEEK, named count constants.add_holiday_asyncandupdate_daily_schedule_async.Spec
VisitorComparison.reference_business_dayson every summary and entrance comparison (maintainer decision: carry it in the comparison). Documented in the API README.previous→CLOSED_PERIOD;previous_month_daily_average_reason→CLOSED_PERIOD; the closed summary'spreviouscarries its reference dates.best_day/ daily average /previous_month_daily_average_reason, last-year reference, closed-day picker listing — all asserted.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at
7f1f7a6. Each axis verified every pass-1 finding against the code, then reviewed the fix commit for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per policy, P1/P2 block merge; P3s go to one follow-up issue.Standards
Head
7f1f7a6. Ruff check/format clean;pytest333 passed, 1 skipped (throwaway worktree).Pass-1 findings
analytics_service.py:595filters oncovered_days or business_days == 0; regression test covers it.is_open_by_schedule(occupancy_service.py:100) holds the open-by-default rule, but "a dated exception beats the weekday schedule" is still written twice: the live path keeps its ownif holiday: … return(:272-274) and calls the helper only as(holiday, None)/(None, sched)(:274,:306), never using its precedence branch. The daily series still readsis_closedfrom that live path, once per day (analytics_service.py:651,714); quality figures useScheduleCalendar. Live behaviour unchanged.business_dates(analytics_service.py:67)(float, float)skip spansskip_cycles: Sequence[CycleBounds],_skip_cycles_sql(occupancy_repository.py:38)get_schedule_calendar_asyncper request (:235,:393,:587), passed down; baseline no longer queries holidays one by one (:131-136).calendar.*andDAYS_PER_WEEKused. Still bare:date(2026, 9, 7)(test:105),4 * … + 2 * …(:133),timedelta(days=2)(:221),(1, 2, 3)(:299).add_holiday_async,update_daily_schedule_async(test:62-73).New findings
analytics_service.py:537):get_statistics_period_quality_async(..., calendar: ScheduleCalendar | None = None)— every caller passes it, so the load fallback is dead code, whilevalidate_…,statistics_visitor_comparison_asyncand_closed_cyclesrequire it. Make it required. (The baseline's optional parameter at:123is justified::843,:1109call without it.)occupancy_service.py:100-124):ScheduleCalendarandis_open_by_scheduleare pure schedule logic in the largeOccupancyManagermodule (Divergent Change risk). Precedent:CycleBoundsinapp/facility_time.py; a small sibling module fits. ANamedTuplewith behaviour carries tuple semantics — thecalendar or await …fallbacks (:126,:545) only work because a 2-tuple is truthy.@dataclass(frozen=True)(used in the CLI) is clearer.occupancy_service.py:112-113):exceptions: dict[str, dict[str, Any]]keyed by ISO strings holding raw DB rows. Key bydate, keep onlyis_open.INSERT INTO occupancy_calibration_logs, thoughrecord_calibration_log_asyncexists (occupancy_repository.py:1686). Same pattern already on master (test_statistics_baselines.py:40).occupancy_service.py:247): return annotation"ScheduleCalendar"quoted though the class is defined above.Merge readiness (this axis): Reviewer rates it ready (the partial P2 paths currently agree). Per policy, the partial P2 is fixed before merge; P3s → follow-up issue.
Spec
Pass-1 findings
7f1f7a6analytics_service.py:596:covered_days or business_days == 0, matching the validator at:173.test_an_outage_week_with_a_closed_day_is_not_selectablefails when the oldor closed_daysfilter is restored (mutation run).VisitorComparison.reference_business_days(schemas/statistics.py:121) set instatistics_visitor_comparison_async(:200-206) and both entrance comparisons (:431-446); missing on the closed summary'sprevious(N1).previous→CLOSED_PERIODwhenprevious_quality.business_days == 0(:475-480);previous_month_daily_average_reason→CLOSED_PERIOD(:380-385); closed summaryprevioushasreference_start/reference_end(:248-252). Month test fails when the branch is mutated out.CLOSED_DAY_PASSAGES = 50exceeds any open day; asserts busiest day = Saturday and peak =OPEN_WEEKEND_VISITORS(test:160-162).best_day, daily average,previous_month_daily_average_reason(:287-292), closed-day picker row (:214-215). Pickerlimitcounting of closed periods not asserted alone (negligible).New findings
previoushas noreference_business_days(analytics_service.py:248-252). Maintainer decision: "every comparison carries the reference period's business-day count (reference_business_days)";docs/api/README.md:132says "Every comparison carriesreference_business_days". This one comparison is built by hand, so the field isnull. Low impact, but contradicts the documented contract; test :237-238 doesn't check it.usual_weekdaycomparisons have noreference_business_days(:334-349). Arguably fine (a sample mean withsource_dates), but "every comparison" in the README is too broad: say "every period comparison" or set it tolen(source_dates).Re-hunt found no other P1/P2: summary figures all come from
covered(dropsis_closedrows; daily-rowis_closeduses the sameis_open_by_schedulerule asScheduleCalendar); entrance current/previous/last-year rows andhas_year_datapass_closed_cycles; usual-weekday samples (summary, presenter hourly, dayparts) exclude closed and exception days; picker and validator agree.Full suite 333 passed, 1 skipped (throwaway worktree). Two mutation checks confirm the new tests catch regressions.
Merge readiness (this axis): Ready. No P1/P2; N1 is a one-line fix or a follow-up.
Summary: Standards: 1 partial P2 (live schedule path still writes the exception-over-weekday precedence itself) + 6 P3. Spec: no P1/P2; worst P3 is the closed summary's
previouslackingreference_business_days.🤖 Generated with Claude Code
Pass-2 blockers fixed in the latest commit: the live schedule path now decides open/closed only through
is_open_by_schedule(partial P2 → fixed), and the closed summary'spreviouscarriesreference_business_days(N1). Full suite 334 passed. Remaining P3s filed as #111. Merging.🤖 Generated with Claude Code