diff --git a/internal/server/handlers_reports.go b/internal/server/handlers_reports.go index aeb70f2e..00abf6a7 100644 --- a/internal/server/handlers_reports.go +++ b/internal/server/handlers_reports.go @@ -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 != "" { diff --git a/internal/store/reports.go b/internal/store/reports.go index 215b24aa..b4c10c58 100644 --- a/internal/store/reports.go +++ b/internal/store/reports.go @@ -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 diff --git a/internal/store/reports_test.go b/internal/store/reports_test.go index 07e4f218..037835cf 100644 --- a/internal/store/reports_test.go +++ b/internal/store/reports_test.go @@ -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) + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index b82dbde0..9d8e7db7 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -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(`/workspaces/${ws}/report${qs ? `?${qs}` : ''}`); }, diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 5c78b009..82587caf 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -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 diff --git a/web/src/routes/[username]/[workspace]/insights/+page.svelte b/web/src/routes/[username]/[workspace]/insights/+page.svelte index 2307b5e0..7f4859b2 100644 --- a/web/src/routes/[username]/[workspace]/insights/+page.svelte +++ b/web/src/routes/[username]/[workspace]/insights/+page.svelte @@ -21,6 +21,9 @@ let selectedWindow = $state('week'); // Empty set === no filter (show all collections). let selectedCollections = $state([]); + // 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 @@ {fmtDate(report.range_start)} – {fmtDate(report.range_end)} + {periodLabel} {/if}
+
+ + +
+
{#each WINDOW_OPTIONS as opt (opt.value)}