mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
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:
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}` : ''}`);
|
||||
},
|
||||
|
||||
@@ -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)} – {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}
|
||||
>
|
||||
◀ Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="period-btn"
|
||||
aria-label="Next period"
|
||||
disabled={offset === 0}
|
||||
onclick={nextPeriod}
|
||||
>
|
||||
Next ▶
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user