Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/analysis/2026-09-21-metrics-read-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Metrics and forecast request ownership

Status: corrective draft for #3346 / PR #3347. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`.

## Reproduced defects

Board metrics and forecast own separate visible surfaces, but the original store committed every response, failure, toast and `finally`. Board/date changes could restore older results or clear current loading, `$reset()` did not invalidate in-flight work, and the store had no session boundary.

Review then exposed two token-refresh defects:

1. full reset on same-user refresh cleared already loaded dashboard data;
2. preservation alone stranded an empty first load because the old request was retired and the unchanged route did not refetch.

## Contract

- Metrics and forecast retain independent latest-request owners.
- A newer request retires only the previous owner in the same lane.
- User identity, authentication or demo-session replacement advances the epoch and clears both data surfaces.
- Token-only rotation preserves settled data and errors, retires old-token UI settlement, and restarts only an active lane whose visible surface is still null.
- Retried metrics/forecast reads retain the exact query captured by the active request.
- Stale requests still resolve or reject to their original callers, but cannot write results, errors, toasts, loading or final state.
- A current failure preserves the previous result and the public error/toast/rejection behavior.
- Metrics and forecast loading remain independent.
- No mutation is replayed and no endpoint, query type or public store API changes.

This is client-state integrity, not transport cancellation or a server metrics-authorization change.

## Test-first evidence

The initial real Pinia suite covered seven deferred schedules. A bounded actual-module runner changed from **1/7 passing on `main`** to **7/7 passing** after the first correction.

Review-regression head `f9f6bc9479ec7d211077b545be95a64cf63e65ae` isolated loaded-dashboard preservation. The corrected head `8f31b2b72e6941b5e77ab730aea34da8da75e9af` passed Smart CI, Extended and the complete Required CI matrix.

Issue #3352 then added test-only head `b7425560e7f3c90833dde8bc74d82543d39ff389`, covering a token rotation while both metrics and forecast are still null. A dependency-free runner transpiled and executed the actual production module:

- before the retry correction: each API was called once and both loading flags became false;
- after the correction: each API was called twice, old-token settlement was suppressed, and fresh-token results populated both lanes.

Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it.

## Remaining gates

Current production correction: `8566adbabd9abe5ddca9a5b09b79a928616db10f` before the current review fix; settled token-rotation errors are now preserved and retry failures are covered.

Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and fresh-context review remain required. Review should focus on query capture, no retry loops, and no mutation replay.

No merge, release or deployment qualification is claimed.
163 changes: 133 additions & 30 deletions frontend/taskdeck-web/src/store/metricsStore.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { metricsApi } from '../api/metricsApi'
import { useToastStore } from './toastStore'
import { useSessionStore } from './sessionStore'
import { isDemoMode } from '../utils/demoMode'
import { getErrorDisplay } from '../composables/useErrorMapper'
import type { BoardMetricsResponse, BoardForecastResponse, MetricsQuery, ForecastQuery } from '../types/metrics'

export const useMetricsStore = defineStore('metrics', () => {
const toast = useToastStore()
const session = useSessionStore()

const metrics = ref<BoardMetricsResponse | null>(null)
const loading = ref(false)
Expand All @@ -17,61 +19,162 @@ export const useMetricsStore = defineStore('metrics', () => {
const forecastLoading = ref(false)
const forecastError = ref<string | null>(null)

type ReadRetry = () => Promise<void>

interface RequestOwner {
epoch: number
token: symbol
}

let credentialEpoch = 0
let metricsOwner: RequestOwner | null = null
let forecastOwner: RequestOwner | null = null
let metricsRetry: ReadRetry | null = null
let forecastRetry: ReadRetry | null = null

function beginMetricsRequest(retry: ReadRetry): RequestOwner {
const owner = { epoch: credentialEpoch, token: Symbol('board-metrics') }
metricsOwner = owner
metricsRetry = retry
loading.value = true
error.value = null
return owner
}

function ownsMetricsRequest(owner: RequestOwner): boolean {
return owner.epoch === credentialEpoch && metricsOwner?.token === owner.token
}

function finishMetricsRequest(owner: RequestOwner): void {
if (!ownsMetricsRequest(owner)) return
metricsOwner = null
metricsRetry = null
loading.value = false
}

function beginForecastRequest(retry: ReadRetry): RequestOwner {
const owner = { epoch: credentialEpoch, token: Symbol('board-forecast') }
forecastOwner = owner
forecastRetry = retry
forecastLoading.value = true
forecastError.value = null
return owner
}

function ownsForecastRequest(owner: RequestOwner): boolean {
return owner.epoch === credentialEpoch && forecastOwner?.token === owner.token
}

function finishForecastRequest(owner: RequestOwner): void {
if (!ownsForecastRequest(owner)) return
forecastOwner = null
forecastRetry = null
forecastLoading.value = false
}

function invalidateRequests(options: { preserveErrors?: boolean } = {}): void {
credentialEpoch += 1
metricsOwner = null
forecastOwner = null
metricsRetry = null
forecastRetry = null
loading.value = false
forecastLoading.value = false
if (!options.preserveErrors) {
error.value = null
forecastError.value = null
}
}

function retryEmptyActiveRequests(): void {
const pendingMetricsRetry = metricsOwner && metrics.value === null ? metricsRetry : null
const pendingForecastRetry = forecastOwner && forecast.value === null ? forecastRetry : null

invalidateRequests({ preserveErrors: true })
if (pendingMetricsRetry) {
void pendingMetricsRetry().catch(() => {
// The retried store action owns current error/toast state.
})
}
if (pendingForecastRetry) {
void pendingForecastRetry().catch(() => {
// The retried store action owns current error/toast state.
})
}
}

function $reset(): void {
invalidateRequests()
metrics.value = null
forecast.value = null
}

watch(
() => [session.userId, session.isAuthenticated, session.isDemo],
$reset,
{ flush: 'sync' },
)

watch(
() => session.token,
retryEmptyActiveRequests,
{ flush: 'sync' },
)

async function fetchBoardMetrics(query: MetricsQuery) {
if (isDemoMode) {
loading.value = true
error.value = null
metrics.value = null
metricsOwner = null
metricsRetry = null
loading.value = false
error.value = 'Metrics are not available in demo mode.'
metrics.value = null
return
}

const owner = beginMetricsRequest(() => fetchBoardMetrics(query))
try {
loading.value = true
error.value = null
metrics.value = await metricsApi.getBoardMetrics(query)
const result = await metricsApi.getBoardMetrics(query)
if (!ownsMetricsRequest(owner)) return
metrics.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to fetch board metrics').message
error.value = msg
toast.error(msg)
if (ownsMetricsRequest(owner)) {
const msg = getErrorDisplay(e, 'Failed to fetch board metrics').message
error.value = msg
toast.error(msg)
}
throw e
} finally {
loading.value = false
finishMetricsRequest(owner)
}
}

async function fetchBoardForecast(query: ForecastQuery) {
if (isDemoMode) {
forecastLoading.value = true
forecastError.value = null
forecast.value = null
forecastOwner = null
forecastRetry = null
forecastLoading.value = false
forecastError.value = 'Forecast is not available in demo mode.'
forecast.value = null
return
}

const owner = beginForecastRequest(() => fetchBoardForecast(query))
try {
forecastLoading.value = true
forecastError.value = null
forecast.value = await metricsApi.getBoardForecast(query)
const result = await metricsApi.getBoardForecast(query)
if (!ownsForecastRequest(owner)) return
forecast.value = result
} catch (e: unknown) {
const msg = getErrorDisplay(e, 'Failed to fetch board forecast').message
forecastError.value = msg
toast.error(msg)
if (ownsForecastRequest(owner)) {
const msg = getErrorDisplay(e, 'Failed to fetch board forecast').message
forecastError.value = msg
toast.error(msg)
}
throw e
} finally {
forecastLoading.value = false
finishForecastRequest(owner)
}
}

function $reset() {
metrics.value = null
loading.value = false
error.value = null
forecast.value = null
forecastLoading.value = false
forecastError.value = null
}

return {
metrics,
loading,
Expand Down
Loading
Loading