feat(insights): navigate to past periods (offset + prev/next) (TASK-1639) (#649)

Add an `offset` to the report (periods back; 0 = current, clamped >= 0):
window becomes [now - (offset+1)*lookback, now - offset*lookback]. Throughput
and cycle-time shift automatically; response echoes `offset` + shifted range.

Backend: ReportOptions.Offset + ReportData.offset; handler parses ?offset=.
Web: api.report.get passes offset; ReportData.offset typed.
Insights page: ◀ Previous / Next ▶ controls (Next disabled at offset 0) + a
period label; offset is session-only (not persisted to the layout); resets on
window or workspace change. Interim: WIP + status-distribution are hidden when
viewing a past period (they're as-of-now) with a note — TASK-1640 reconstructs
them historically.

Parent: PLAN-1628.
This commit is contained in:
xarmian
2026-05-29 16:59:37 -04:00
committed by GitHub
parent cd8ac9b618
commit 0d0c660565
6 changed files with 179 additions and 11 deletions
+6
View File
@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
@@ -32,6 +33,11 @@ func (s *Server) handleGetReport(w http.ResponseWriter, r *http.Request) {
ScopeToVisible: scopeToVisible,
VisibleCollectionIDs: visibleIDs,
}
// offset = periods back (0 = current). Non-numeric/negative → 0 (the store
// also clamps).
if n, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("offset"))); err == nil && n > 0 {
opts.Offset = n
}
if raw := strings.TrimSpace(r.URL.Query().Get("collections")); raw != "" {
for _, slug := range strings.Split(raw, ",") {
if s := strings.TrimSpace(slug); s != "" {
+18 -4
View File
@@ -51,6 +51,9 @@ type ReportOptions struct {
// Now is the reference end of the window; zero means time.Now().UTC().
// Injectable so tests are deterministic.
Now time.Time
// Offset steps the window back by whole window-lengths (0 = current period,
// 1 = the period immediately before, …). Negative is clamped to 0.
Offset int
// ScopeToVisible, when true, restricts the report to VisibleCollectionIDs
// (the caller's visible collection set). Aggregate counts for collections
// the caller can't see are never computed — preventing a member/guest from
@@ -128,6 +131,7 @@ type ReportWIP struct {
// Reports UI (TASK-1633), CLI/MCP (TASK-1635), and charts (TASK-1632) consume.
type ReportData struct {
Window string `json:"window"`
Offset int `json:"offset"` // periods back from now (0 = current)
Granularity string `json:"granularity"` // "hour" | "day"
RangeStart string `json:"range_start"` // RFC3339 UTC
RangeEnd string `json:"range_end"` // RFC3339 UTC
@@ -179,9 +183,18 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
now = time.Now().UTC()
}
now = now.UTC()
start := now.Add(-lookback)
// offset steps the window back by whole window-lengths (0 = current,
// 1 = the window immediately before, …). Negative is clamped to 0 (no
// future). end is the window's right edge; start its left edge.
offset := opts.Offset
if offset < 0 {
offset = 0
}
end := now.Add(-time.Duration(offset) * lookback)
start := end.Add(-lookback)
startStr := start.Format(time.RFC3339)
endStr := now.Format(time.RFC3339)
endStr := end.Format(time.RFC3339)
// Resolve the collections in scope (filtered by slug if requested, and by
// the caller's visible set when scoping is enabled).
@@ -192,6 +205,7 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
data := &ReportData{
Window: window,
Offset: offset,
Granularity: gran,
RangeStart: startStr,
RangeEnd: endStr,
@@ -216,7 +230,7 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
// No collections in scope → empty (but well-formed) report.
if len(collIDs) == 0 {
data.Buckets = s.zeroFilledBuckets(start, now, gran, nil, nil)
data.Buckets = s.zeroFilledBuckets(start, end, gran, nil, nil)
return data, nil
}
@@ -229,7 +243,7 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
return nil, err
}
data.Buckets = s.zeroFilledBuckets(start, now, gran, createdByBucket, completedByBucket)
data.Buckets = s.zeroFilledBuckets(start, end, gran, createdByBucket, completedByBucket)
for _, b := range data.Buckets {
data.Totals.Created += b.Created
data.Totals.Completed += b.Completed
+39
View File
@@ -381,3 +381,42 @@ func TestGetReport_EmptyScopeWellFormedJSON(t *testing.T) {
}
}
}
func TestGetReport_OffsetShiftsWindow(t *testing.T) {
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
createTestItem(t, s, wsID, colID, "recent", "") // created ~now
old := createTestItem(t, s, wsID, colID, "old", "")
backdateItem(t, s, old.ID, 10*24) // created ~10 days ago
now := time.Now().UTC()
// offset 0, week window [now-7d, now]: only the recent item.
r0, err := s.GetReport(wsID, ReportOptions{Window: "week", Offset: 0, Now: now})
if err != nil {
t.Fatalf("offset 0: %v", err)
}
if r0.Offset != 0 || r0.Totals.Created != 1 {
t.Fatalf("offset 0: expected offset=0 created=1 (recent), got offset=%d created=%d", r0.Offset, r0.Totals.Created)
}
// offset 1, week window [now-14d, now-7d]: only the 10-day-old item.
r1, err := s.GetReport(wsID, ReportOptions{Window: "week", Offset: 1, Now: now})
if err != nil {
t.Fatalf("offset 1: %v", err)
}
if r1.Offset != 1 || r1.Totals.Created != 1 {
t.Fatalf("offset 1: expected offset=1 created=1 (old), got offset=%d created=%d", r1.Offset, r1.Totals.Created)
}
if !(r1.RangeEnd < r0.RangeEnd && r1.RangeStart < r0.RangeStart) {
t.Fatalf("offset 1 window should be earlier: r1 [%s,%s] vs r0 [%s,%s]", r1.RangeStart, r1.RangeEnd, r0.RangeStart, r0.RangeEnd)
}
// Negative offset clamps to 0 (no future).
rNeg, err := s.GetReport(wsID, ReportOptions{Window: "week", Offset: -3, Now: now})
if err != nil {
t.Fatalf("neg offset: %v", err)
}
if rNeg.Offset != 0 || rNeg.RangeEnd != r0.RangeEnd {
t.Fatalf("negative offset should clamp to 0, got offset=%d", rNeg.Offset)
}
}
+2 -1
View File
@@ -936,10 +936,11 @@ export const api = {
* Windowed project report. window ∈ {day, week, 2wk, month} (default
* week). collections optionally restricts to the given slugs.
*/
get: (ws: string, opts?: { window?: ReportWindow; collections?: string[] }) => {
get: (ws: string, opts?: { window?: ReportWindow; collections?: string[]; offset?: number }) => {
const params = new URLSearchParams();
if (opts?.window) params.set('window', opts.window);
if (opts?.collections?.length) params.set('collections', opts.collections.join(','));
if (opts?.offset && opts.offset > 0) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<ReportData>(`/workspaces/${ws}/report${qs ? `?${qs}` : ''}`);
},
+2
View File
@@ -884,6 +884,8 @@ export interface ReportWIP {
*/
export interface ReportData {
window: ReportWindow;
/** periods back from now (0 = current); set via the period-nav controls */
offset: number;
granularity: 'hour' | 'day';
range_start: string; // RFC3339 UTC
range_end: string; // RFC3339 UTC
@@ -21,6 +21,9 @@
let selectedWindow = $state<ReportWindow>('week');
// Empty set === no filter (show all collections).
let selectedCollections = $state<string[]>([]);
// Period navigation: periods back from now (0 = current). SESSION-only — not
// part of ReportLayout, never persisted via scheduleSave.
let offset = $state(0);
// The workspace the current filter belongs to. SvelteKit reuses this route
// component across workspace param changes, so a filter selected in
// workspace A would otherwise persist into B and scope B's /report to an
@@ -96,6 +99,8 @@
if (slug !== filterWsSlug) {
filterWsSlug = slug;
selectedCollections = [];
// Reset period navigation to the current period for the new workspace.
offset = 0;
// Drop the previous workspace's data so A's totals/date-range don't
// linger under B's URL while B loads — the `loading` state covers the gap.
report = null;
@@ -107,8 +112,9 @@
hydrated = false;
}
const colls = [...selectedCollections];
const off = offset;
if (slug) {
loadReport(slug, win, colls);
loadReport(slug, win, colls, off);
}
});
@@ -142,14 +148,15 @@
}
}
async function loadReport(slug: string, win: ReportWindow, colls: string[]) {
async function loadReport(slug: string, win: ReportWindow, colls: string[], off: number) {
const seq = ++reqSeq;
loading = true;
error = '';
try {
const data = await api.report.get(slug, {
window: win,
collections: colls.length > 0 ? colls : undefined
collections: colls.length > 0 ? colls : undefined,
offset: off
});
// Only the latest in-flight request commits — discard stale responses.
if (seq !== reqSeq) return;
@@ -193,9 +200,21 @@
function selectWindow(win: ReportWindow) {
selectedWindow = win;
// Different period length, so the current offset no longer maps — snap to
// the present. offset is session-only and not persisted.
offset = 0;
scheduleSave();
}
// Period navigation (session-only; never persisted).
function prevPeriod() {
offset += 1;
}
function nextPeriod() {
offset = Math.max(0, offset - 1);
}
function toggleCollection(slug: string) {
if (selectedCollections.includes(slug)) {
selectedCollections = selectedCollections.filter((s) => s !== slug);
@@ -276,6 +295,16 @@
const netFlow = $derived(report?.totals.net_flow ?? 0);
// True when viewing a past period. WIP + status distribution are point-in-time
// snapshots that, for now, only render meaningfully for the current period;
// hide them in the past until a follow-up makes them historical.
const inPast = $derived(offset > 0);
// Short relative label for the period nav.
const periodLabel = $derived(
offset === 0 ? 'Current period' : `${offset} period${offset === 1 ? '' : 's'} ago`
);
// Status distribution grouped by collection.
const statusByCollection = $derived.by(() => {
const groups: { collection: string; total: number; rows: { status: string; count: number }[] }[] =
@@ -301,10 +330,31 @@
<span class="date-range">
{fmtDate(report.range_start)} &ndash; {fmtDate(report.range_end)}
</span>
<span class="period-label" class:past={inPast}>{periodLabel}</span>
{/if}
</div>
<div class="header-controls">
<div class="period-nav" role="group" aria-label="Period navigation">
<button
type="button"
class="period-btn"
aria-label="Previous period"
onclick={prevPeriod}
>
&#9664; Previous
</button>
<button
type="button"
class="period-btn"
aria-label="Next period"
disabled={offset === 0}
onclick={nextPeriod}
>
Next &#9654;
</button>
</div>
<div class="window-control" role="group" aria-label="Time window">
{#each WINDOW_OPTIONS as opt (opt.value)}
<button
@@ -401,6 +451,12 @@
</div>
</section>
{#if inPast}
<p class="past-note">
Work-in-progress and status distribution show the current period only.
</p>
{/if}
<!-- Throughput -->
{#if !hiddenCards.has('throughput')}
<section class="card">
@@ -422,7 +478,7 @@
</section>
{/if}
{#if !hiddenCards.has('cycle_time') || !hiddenCards.has('wip')}
{#if !hiddenCards.has('cycle_time') || (!hiddenCards.has('wip') && !inPast)}
<div class="grid">
<!-- Cycle time -->
{#if !hiddenCards.has('cycle_time')}
@@ -466,7 +522,7 @@
{/if}
<!-- Work in progress -->
{#if !hiddenCards.has('wip')}
{#if !hiddenCards.has('wip') && !inPast}
<section class="card">
<div class="card-header">
<h2>Work in progress</h2>
@@ -532,7 +588,7 @@
{/if}
<!-- Status distribution -->
{#if !hiddenCards.has('status_distribution')}
{#if !hiddenCards.has('status_distribution') && !inPast}
<section class="card">
<div class="card-header">
<h2>Status distribution</h2>
@@ -601,6 +657,20 @@
font-size: 0.9em;
color: var(--text-muted);
}
.period-label {
font-size: 0.72em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 10px;
}
.period-label.past {
color: var(--accent-amber);
background: color-mix(in srgb, var(--accent-amber) 15%, transparent);
}
.header-controls {
display: flex;
align-items: center;
@@ -608,6 +678,42 @@
flex-wrap: wrap;
}
/* ── Period navigation ────────────────────────────────────────────── */
.period-nav {
display: inline-flex;
gap: var(--space-1);
}
.period-btn {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-2) var(--space-3);
font-size: 0.8em;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.period-btn:hover:not(:disabled) {
border-color: var(--text-muted);
color: var(--text-primary);
}
.period-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Past-period note ─────────────────────────────────────────────── */
.past-note {
font-size: 0.82em;
color: var(--text-muted);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
}
/* ── Window segmented control ─────────────────────────────────────── */
.window-control {
display: inline-flex;