diff --git a/app/src/api/societyWideCalculation.ts b/app/src/api/societyWideCalculation.ts index d09587c12..4b138b47a 100644 --- a/app/src/api/societyWideCalculation.ts +++ b/app/src/api/societyWideCalculation.ts @@ -29,6 +29,12 @@ export interface SocietyWideCalculationResponse { result: SocietyWideReportOutput | null; error?: string; policyengine_bundle?: PolicyEngineBundle | null; + /** Optional stage fields while computing (policyengine-sim-api#781 via the API). */ + stage?: string; + progress?: number; + progress_max?: number; + stage_started_at?: string; + segments?: { completed: number; total: number }; } function mergePolicyEngineBundle( diff --git a/app/src/components/flagship/report/RunProgress.tsx b/app/src/components/flagship/report/RunProgress.tsx index aec6ed8c2..a018feb8f 100644 --- a/app/src/components/flagship/report/RunProgress.tsx +++ b/app/src/components/flagship/report/RunProgress.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Progress, Spinner, Stack, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { formatElapsed } from '@/libs/flagship/reportStages'; +import type { RunStage } from '@/types/calculation'; /** Milliseconds since the component mounted, ticking each second. */ function useElapsed(): number { @@ -53,16 +54,52 @@ export function RunLoading({ label }: { label: string }) { ); } +/** One line per stage the API reports; an unknown stage gets no line. */ +export function runStageCaption(runStage?: RunStage): string | undefined { + switch (runStage?.stage) { + case 'queued': + return 'Waiting for a worker'; + case 'loading_data': + return 'Loading the data'; + case 'baseline': + return 'Running current law'; + case 'reform': + return 'Running the reform'; + case 'simulating': + return runStage.segments + ? `Running the regions, ${runStage.segments.completed} of ${runStage.segments.total} done` + : 'Running the regions'; + case 'outputs': + return 'Calculating the results'; + default: + return undefined; + } +} + +/** + * The bar's value: the time estimate, held inside the stage the API reports + * (from where the stage starts to just before the next one), else the + * estimate alone. + */ +export function runProgressValue(estimate: number, runStage?: RunStage): number { + if (typeof runStage?.floor !== 'number' || typeof runStage.max !== 'number') { + return estimate; + } + return Math.min(Math.max(estimate, runStage.floor), runStage.max); +} + /** * A society-wide run in progress, as a bar: what is running, how long it - * has taken, and how far along a typical run of this size would be. The - * API reports no stages for a single-year run, so the bar follows the - * run's typical duration (the calculation strategy's estimate, held short - * of the end until the results arrive). + * has taken, and how far along it is. The bar follows the run's typical + * duration (the calculation strategy's estimate, held short of the end + * until the results arrive). When the API reports the run's stage, the + * estimate is held inside that stage, and the stage names the line under + * the title. The bar never moves backwards. */ export default function RunProgress({ title, progress, + runStage, detail, expectedMs, note, @@ -71,6 +108,8 @@ export default function RunProgress({ title: string; /** 0–100, from the calculation status; absent, the bar follows the clock. */ progress?: number; + /** The run's stage from the API, when it reports one. */ + runStage?: RunStage; /** One line under the title, e.g. a queue position. */ detail?: string; /** How long a run like this one typically takes. */ @@ -79,9 +118,15 @@ export default function RunProgress({ note?: string; }) { const elapsed = useElapsed(); - const value = + const estimate = typeof progress === 'number' ? progress : Math.min((elapsed / expectedMs) * 100, 95); + // The highest value shown so far: a stage's range can start lower than an + // earlier estimate (a queued run's path is not known yet). + const shown = useRef(0); + shown.current = Math.max(shown.current, runProgressValue(estimate, runStage)); + const value = shown.current; const percent = Math.max(0, Math.min(Math.round(value), 99)); + const line = detail ?? runStageCaption(runStage); return ( @@ -110,9 +155,9 @@ export default function RunProgress({ - {detail && ( + {line && ( - {detail} + {line} )} {note && ( diff --git a/app/src/hooks/useBillEconomy.ts b/app/src/hooks/useBillEconomy.ts index 014c969fd..0ed2e8993 100644 --- a/app/src/hooks/useBillEconomy.ts +++ b/app/src/hooks/useBillEconomy.ts @@ -7,7 +7,7 @@ import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { SocietyWideCalcStrategy } from '@/libs/calculations/economy/SocietyWideCalcStrategy'; import { createReformPolicy } from '@/libs/flagship/runReport'; import type { RootState } from '@/store'; -import type { CalcStatus } from '@/types/calculation'; +import type { CalcStatus, RunStage } from '@/types/calculation'; const strategy = new SocietyWideCalcStrategy(); @@ -18,6 +18,8 @@ export interface BillEconomy { queuePosition?: number; /** 0–100 while pending: the calculation strategy's estimate from typical run times. */ progress?: number; + /** The run's stage from the API while pending, when it reports one. */ + runStage?: RunStage; reformPolicyId?: string; baselinePolicyId?: string; year: string; @@ -118,6 +120,7 @@ export function useBillEconomy( : economy.data?.message, queuePosition: economy.data?.queuePosition, progress: economy.data?.progress, + runStage: economy.data?.runStage, reformPolicyId, baselinePolicyId, year, diff --git a/app/src/hooks/useCalculationStatus.ts b/app/src/hooks/useCalculationStatus.ts index f24b6aadb..9ec7c5cac 100644 --- a/app/src/hooks/useCalculationStatus.ts +++ b/app/src/hooks/useCalculationStatus.ts @@ -121,6 +121,7 @@ function useSingleCalculationStatus(calcId: string, targetType: 'report' | 'simu // Keep server data available queuePosition: status?.queuePosition, + runStage: status?.runStage, result: status?.result, error: status?.error, metadata: status?.metadata, diff --git a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts index 324fd88e1..5eec6a9b0 100644 --- a/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts +++ b/app/src/libs/calculations/economy/SocietyWideCalcStrategy.ts @@ -5,7 +5,7 @@ import { SocietyWideCalculationResponse, } from '@/api/societyWideCalculation'; import { getDurationForCountry, getDurationForRegion } from '@/constants/calculationDurations'; -import { CalcMetadata, CalcParams, CalcStatus } from '@/types/calculation'; +import { CalcMetadata, CalcParams, CalcStatus, RunStage } from '@/types/calculation'; import { CalcExecutionStrategy, RefetchConfig } from '../strategies/types'; /** How a caller wants the run's duration estimated. */ @@ -105,10 +105,13 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { const estimatedDuration = this.getEstimatedDuration(countryId, estimate); const progress = Math.min((elapsed / estimatedDuration) * 100, 95); // Cap at 95% + const runStage = estimate.estimateByRegion ? this.getRunStage(response) : undefined; + return { status: 'pending', progress, ...(estimate.estimateByRegion && { estimatedDurationMs: estimatedDuration }), + ...(runStage && { runStage }), queuePosition: response.queue_position, message: this.getComputingMessage(response.queue_position), metadata, @@ -149,6 +152,24 @@ export class SocietyWideCalcStrategy implements CalcExecutionStrategy { return this.transformResponseWithMetadata(apiResponse, metadata); } + /** + * The API's stage fields, when it reports any + */ + private getRunStage(response: SocietyWideCalculationResponse): RunStage | undefined { + const runStage: RunStage = {}; + if (typeof response.stage === 'string') { + runStage.stage = response.stage; + } + if (typeof response.progress === 'number' && typeof response.progress_max === 'number') { + runStage.floor = response.progress; + runStage.max = Math.max(response.progress, response.progress_max); + } + if (response.segments) { + runStage.segments = response.segments; + } + return Object.keys(runStage).length > 0 ? runStage : undefined; + } + /** * Get human-readable message for computing state */ diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index 29fe2674c..e2a050000 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -15,7 +15,7 @@ import StoredImpactCharts from '@/components/flagship/report/StoredImpactCharts' import { ErrorState } from '@/components/flagship/states'; import { BillExternalEstimates, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Button, Stack, Text } from '@/components/ui'; -import { getDurationForCountry } from '@/constants/calculationDurations'; +import { getDurationForRegion } from '@/constants/calculationDurations'; import { CongressionalDistrictDataProvider } from '@/contexts/CongressionalDistrictDataContext'; import { colors, spacing, typography } from '@/designTokens'; import { useBillEconomy, type BillEconomy } from '@/hooks/useBillEconomy'; @@ -203,8 +203,9 @@ function FullResultsStatus({ ); diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 024c281a0..6edd06f32 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -13,7 +13,7 @@ import { ReportUnresolvable } from '@/components/flagship/ReportComputing'; import { ErrorState } from '@/components/flagship/states'; import { trackRecordPrograms, useModelTrackRecord } from '@/components/flagship/ValidationPanel'; import { Stack, Title } from '@/components/ui'; -import { getDurationForCountry } from '@/constants/calculationDurations'; +import { getDurationForRegion } from '@/constants/calculationDurations'; import { colors, spacing } from '@/designTokens'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; @@ -266,12 +266,13 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep : 'Calculating the nationwide results' } progress={calcStatus.progress} + runStage={'runStage' in calcStatus ? calcStatus.runStage : undefined} detail={ typeof calcStatus.queuePosition === 'number' && calcStatus.queuePosition > 0 ? `In queue, position ${calcStatus.queuePosition}` : undefined } - expectedMs={getDurationForCountry(report?.countryId ?? 'us')} + expectedMs={getDurationForRegion(report?.countryId ?? 'us', region)} /> ) : ( diff --git a/app/src/tests/unit/components/flagship/report/RunProgress.test.tsx b/app/src/tests/unit/components/flagship/report/RunProgress.test.tsx new file mode 100644 index 000000000..8a1f30504 --- /dev/null +++ b/app/src/tests/unit/components/flagship/report/RunProgress.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import RunProgress, { + runProgressValue, + runStageCaption, +} from '@/components/flagship/report/RunProgress'; + +describe('runProgressValue', () => { + test('given no run stage then the time estimate is the value', () => { + expect(runProgressValue(40)).toBe(40); + }); + + test('given an estimate behind the stage then the stage start holds the bar', () => { + expect(runProgressValue(30, { stage: 'reform', floor: 67, max: 96 })).toBe(67); + }); + + test('given an estimate past the stage then the bar waits below the next stage', () => { + expect(runProgressValue(99, { stage: 'reform', floor: 67, max: 96 })).toBe(96); + }); + + test('given an estimate inside the stage then the estimate is the value', () => { + expect(runProgressValue(80, { stage: 'reform', floor: 67, max: 96 })).toBe(80); + }); +}); + +describe('runStageCaption', () => { + test('given a segmented run then the caption counts the regions', () => { + expect(runStageCaption({ stage: 'simulating', segments: { completed: 19, total: 20 } })).toBe( + 'Running the regions, 19 of 20 done' + ); + }); + + test('given a stage the app does not know then there is no caption', () => { + expect(runStageCaption({ stage: 'a_future_stage' })).toBeUndefined(); + }); +}); + +describe('RunProgress', () => { + test('given a run stage then the stage names the line under the title', () => { + render( + + ); + + expect(screen.getByText('Running the reform')).toBeInTheDocument(); + expect(screen.getByText(/67%/)).toBeInTheDocument(); + }); + + test('given a queue detail then it is shown in place of the stage', () => { + render( + + ); + + expect(screen.getByText('In queue, position 2')).toBeInTheDocument(); + expect(screen.queryByText('Waiting for a worker')).not.toBeInTheDocument(); + }); + + test('given a later stage range that starts lower then the bar does not move backwards', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect(screen.getByText(/· 20%/)).toBeInTheDocument(); + }); +}); diff --git a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts index e12c5cd1b..5582b131c 100644 --- a/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts +++ b/app/src/tests/unit/libs/calculations/strategies/SocietyWideCalcStrategy.test.ts @@ -319,6 +319,55 @@ describe('SocietyWideCalcStrategy', () => { expect(result.progress).toBeCloseTo(50, 0); expect(result).not.toHaveProperty('estimatedDurationMs'); }); + + it('given estimateByRegion and API stage fields then passes the run stage on', () => { + // When + const result = strategy.transformResponseWithMetadata( + { + ...mockSocietyWideComputingResponse(), + stage: 'simulating', + progress: 5, + progress_max: 96, + segments: { completed: 19, total: 20 }, + }, + startedAgo(1000), + 'us', + { estimateByRegion: true } + ); + + // Then + expect(result.runStage).toEqual({ + stage: 'simulating', + floor: 5, + max: 96, + segments: { completed: 19, total: 20 }, + }); + }); + + it('given API stage fields without estimateByRegion then the v2 status is unchanged', () => { + // When + const result = strategy.transformResponseWithMetadata( + { ...mockSocietyWideComputingResponse(), stage: 'reform', progress: 67, progress_max: 96 }, + startedAgo(1000), + 'us' + ); + + // Then + expect(result).not.toHaveProperty('runStage'); + }); + + it('given estimateByRegion and no API stage fields then there is no run stage', () => { + // When + const result = strategy.transformResponseWithMetadata( + mockSocietyWideComputingResponse(), + startedAgo(1000), + 'us', + { estimateByRegion: true } + ); + + // Then + expect(result).not.toHaveProperty('runStage'); + }); }); describe('getRefetchConfig', () => { diff --git a/app/src/types/calculation/CalcStatus.ts b/app/src/types/calculation/CalcStatus.ts index c675fc1b6..70cb7104f 100644 --- a/app/src/types/calculation/CalcStatus.ts +++ b/app/src/types/calculation/CalcStatus.ts @@ -60,6 +60,19 @@ export type CalcStatusValue = 'initializing' | 'idle' | 'pending' | 'complete' | * Unified calculation status interface * Works for both economy and household calculations */ +/** + * A computing run's stage from the API. `floor` and `max` bound its + * progress: the percent at which the stage starts, and the highest + * percent before the next stage starts. + */ +export interface RunStage { + /** e.g. 'queued', 'loading_data', 'reform'; later API versions may add stages */ + stage?: string; + floor?: number; + max?: number; + segments?: { completed: number; total: number }; +} + export interface CalcStatus { /** * Current status of the calculation @@ -94,6 +107,12 @@ export interface CalcStatus { */ estimatedDurationMs?: number; + /** + * The run's stage as the API reports it while computing, for v3 bars + * (CalcParams.estimateByRegion). Absent when the API reports no stages. + */ + runStage?: RunStage; + /** * The calculation result (only present when status is 'complete') */ diff --git a/app/src/types/calculation/index.ts b/app/src/types/calculation/index.ts index 376f43f2b..99316911b 100644 --- a/app/src/types/calculation/index.ts +++ b/app/src/types/calculation/index.ts @@ -6,5 +6,5 @@ export type { CalcError } from './CalcError'; export type { CalcMetadata } from './CalcMetadata'; export type { CalcParams } from './CalcParams'; -export type { CalcStatus, CalcResult } from './CalcStatus'; +export type { CalcStatus, CalcResult, RunStage } from './CalcStatus'; export type { CalcStartConfig } from './CalcStartConfig';