[bug]: Analytics "Last 7 / 30 days" windows cover one day too many
Is there an existing issue for this?
Yes, I searched — nothing open or closed covers this (date_utils, get_analytics_date_range, last_7_days), and no open PR touches apps/api/plane/utils/date_utils.py.
Current behavior
Every "last N" window in Advance Analytics covers N + 1 days. Last 7 days returns 8 days of data, Last 30 days returns 31, Last 3 months returns 91.
Both helpers in apps/api/plane/utils/date_utils.py build the window as today - timedelta(days=N) through today, and both bounds are inclusive:
get_analytics_date_range()— L45, L56, L67 — feedscreated_at__gte/created_at__ltefor the overview and work-item countsget_chart_period_range()— L117–L119 — feedscreated_at__date__gte/created_at__date__ltefor every chart inadvance.pyandproject_analytics.py
The intended length isn't ambiguous, because the same function gets it right twice:
- the
yesterdaybranch is exactly 1 day - the
previouscomparison windows are exactly 7, 30 and 90 days —previousandcurrentsit in the same dict literal and come out different lengths
And the get_chart_period_range docstring documents "last_3_months": Last 90 days (L102) while the code returns 91.
last_30_days is the default selectedDuration (apps/web/core/store/analytics.store.ts:38), so the default view of Advance Analytics is the one that's off.
Expected behavior
A window labelled Last 7 days covers 7 days. With both bounds inclusive that's today - timedelta(days=N - 1) through today, matching what the previous windows already do.
Steps to reproduce
This is pure date arithmetic, so it reproduces without a database. The script loads the shipped date_utils.py as-is and only stubs plane.db.models.User, which the module imports for a type annotation.
# plane_repro.py — python plane_repro.py apps/api/plane/utils/date_utils.py
import importlib.util, sys, types
import django
from django.conf import settings
settings.configure(USE_TZ=True, TIME_ZONE="UTC", INSTALLED_APPS=[])
django.setup()
stub = types.ModuleType("plane.db.models")
stub.User = object
sys.modules["plane"] = types.ModuleType("plane")
sys.modules["plane.db"] = types.ModuleType("plane.db")
sys.modules["plane.db.models"] = stub
spec = importlib.util.spec_from_file_location("date_utils", sys.argv[1])
date_utils = importlib.util.module_from_spec(spec)
spec.loader.exec_module(date_utils)
EXPECTED = {"yesterday": 1, "last_7_days": 7, "last_30_days": 30, "last_3_months": 90}
HEAD = f"{'date_filter':<16}{'window':<10}{'first day':<13}{'last day':<13}{'days':>5}{'expected':>10}"
def row(name, window, lo, hi, want):
got = (hi - lo).days + 1
flag = "" if got == want else f" <-- {got - want:+d}"
print(f"{name:<16}{window:<10}{str(lo):<13}{str(hi):<13}{got:>5}{want:>10}{flag}")
print("get_analytics_date_range()\n" + HEAD)
for f, want in EXPECTED.items():
ranges = date_utils.get_analytics_date_range(f)
for window in ("current", "previous"):
if window in ranges:
r = ranges[window]
row(f, window, r["gte"].date(), r["lte"].date(), want)
print(f"\nget_chart_period_range()\n{HEAD}")
for f, want in EXPECTED.items():
lo, hi = date_utils.get_chart_period_range(f)
row(f, "-", lo, hi, want)
Output on 2f895b8 (run 2026-09-14):
get_analytics_date_range()
date_filter window first day last day days expected
yesterday current 2026-09-13 2026-09-13 1 1
last_7_days current 2026-09-07 2026-09-14 8 7 <-- +1
last_7_days previous 2026-08-31 2026-09-06 7 7
last_30_days current 2026-08-15 2026-09-14 31 30 <-- +1
last_30_days previous 2026-07-16 2026-08-14 30 30
last_3_months current 2026-06-16 2026-09-14 91 90 <-- +1
last_3_months previous 2026-03-18 2026-06-15 90 90
get_chart_period_range()
date_filter window first day last day days expected
yesterday - 2026-09-13 2026-09-13 1 1
last_7_days - 2026-09-07 2026-09-14 8 7 <-- +1
last_30_days - 2026-08-15 2026-09-14 31 30 <-- +1
last_3_months - 2026-06-16 2026-09-14 91 90 <-- +1
In the UI: open Analytics → Overview on a workspace with work items created 31 days ago, leave the duration on the default Last 30 days, and those items are counted.
Scope
Only the current windows are user-visible today — advance.py has "filter_count": get_previous_count() commented out, so the period-over-period figure isn't returned yet. Worth noting because it means fixing current to N days also makes the two windows the same length, which is what a comparison needs when that line is uncommented.
Environment
Community, self-hosted from source at 2f895b8 (preview).
Notes
Happy to open a PR with the one-line change in each helper plus unit tests asserting the span of each window, if the 7-means-7 reading is the one you want. The alternative reading — "last 7 days" meaning today plus the 7 before it — is defensible too, but then previous and the last_3_months docstring need to move instead, which is why I'm raising it rather than sending a patch.
Source: makeplane/plane