#9830·plane

[bug]: Analytics "Last 7 / 30 days" windows cover one day too many

Author: CaptainAni187Created Sep 14, 2026Updated Sep 14, 2026

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 — feeds created_at__gte / created_at__lte for the overview and work-item counts
  • get_chart_period_range()L117–L119 — feeds created_at__date__gte / created_at__date__lte for every chart in advance.py and project_analytics.py

The intended length isn't ambiguous, because the same function gets it right twice:

  • the yesterday branch is exactly 1 day
  • the previous comparison windows are exactly 7, 30 and 90 days — previous and current sit 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.