Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
340 changes: 297 additions & 43 deletions app/src/api/billFeed.ts

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions app/src/components/flagship/ProvisionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ export interface ProvisionListItem {
* quiet context underneath, and states the change as current law → new
* value. When the provision matches current law (enacted bills), the
* arrow would be meaningless, so the row shows the single value with a
* "current law" note instead.
* "current law" note instead — or, for a bill scored against the law
* before it, the value alone (an empty note).
*/
export default function ProvisionList({ provisions }: { provisions: ProvisionListItem[] }) {
export default function ProvisionList({
provisions,
sameValueNote = 'current law',
}: {
provisions: ProvisionListItem[];
sameValueNote?: string;
}) {
const variables = useSelector((state: RootState) => state.metadata.variables);
const variableLabel = (name: string) => variables?.[name]?.label ?? name;
return (
Expand Down Expand Up @@ -109,7 +116,9 @@ export default function ProvisionList({ provisions }: { provisions: ProvisionLis
>
{proposed}
</span>{' '}
<span style={{ fontSize: typography.fontSize.xs }}>current law</span>
{sameValueNote && (
<span style={{ fontSize: typography.fontSize.xs }}>{sameValueNote}</span>
)}
</>
) : (
<>
Expand Down
34 changes: 34 additions & 0 deletions app/src/components/flagship/ProvisionSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Skeleton } from '@/components/ui';
import { colors, spacing } from '@/designTokens';

/**
* Placeholder rows for provisions whose names and units are still
* loading, in place of raw parameter paths and unformatted values
* ("gov.states.ga.tax.income.main.flat_rate: 0.0499").
*/
export default function ProvisionSkeleton({ count }: { count: number }) {
const rows = Math.max(1, Math.min(count, 5));
return (
<div role="status" aria-busy="true" aria-label="Loading parameter names">
{Array.from({ length: rows }, (_, index) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.lg,
padding: `${spacing.md} 0`,
borderTop: index > 0 ? `1px solid ${colors.border.light}` : 'none',
}}
>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: spacing.xs }}>
<Skeleton style={{ height: 14, width: '40%' }} />
<Skeleton style={{ height: 12, width: '75%' }} />
</div>
<Skeleton style={{ height: 14, width: 72 }} />
</div>
))}
</div>
);
}
224 changes: 125 additions & 99 deletions app/src/components/flagship/ReportAdjustPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import { CURRENT_YEAR, FOREVER, MOCK_USER_ID } from '@/constants';
import { colors, spacing, typography } from '@/designTokens';
import { useCurrentCountry } from '@/hooks/useCurrentCountry';
import { useRunFlagshipReport } from '@/hooks/useRunFlagshipReport';
import { sameParameterValue } from '@/libs/draftReform';
import { reportProvisionName } from '@/libs/flagship/draftLabels';
import { RunReportProvision } from '@/libs/flagship/runReport';
import { Reform } from '@/types/ingredients/Reform';
import { formatCompactBreadcrumb } from '@/utils/parameterLabels';
import { ParameterMetadata } from '@/types/metadata/parameterMetadata';
import { formatValue } from '@/utils/parameterValues';
import ProvisionSkeleton from './ProvisionSkeleton';
import NumberInput from './reform/ValueInput';
import SidePanel from './SidePanel';
import ValueInput from './ValueInput';

Expand All @@ -19,6 +23,8 @@ interface ReportAdjustPanelProps {
title: string;
sourceNote: string;
provisions: RunReportProvision[];
/** The parameters' names and units are still loading: placeholders, not raw paths. */
loading?: boolean;
}

/** True when the reform's parameter set is exactly the given provisions. */
Expand Down Expand Up @@ -46,6 +52,7 @@ export default function ReportAdjustPanel({
title,
sourceNote,
provisions,
loading = false,
}: ReportAdjustPanelProps) {
const runReport = useRunFlagshipReport();
const countryId = useCurrentCountry();
Expand Down Expand Up @@ -123,110 +130,129 @@ export default function ReportAdjustPanel({
meta={`${active.length} provision${active.length === 1 ? '' : 's'}`}
defaultOpen={false}
>
<Stack style={{ gap: 0 }}>
{active.map((provision) => (
<Stack
key={provision.path}
style={{
gap: spacing.xs,
padding: `${spacing.sm} ${spacing.lg}`,
borderBottom: `1px solid ${colors.border.light}`,
}}
>
<Stack
style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: spacing.sm,
}}
>
<Text
title={provision.breadcrumb || provision.path}
style={{
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{formatCompactBreadcrumb(provision.breadcrumb || provision.path)}
</Text>
<button
type="button"
onClick={() => setRemoved((current) => new Set([...current, provision.path]))}
aria-label={`Remove ${provision.breadcrumb || provision.path}`}
{loading ? (
<div style={{ padding: `0 ${spacing.lg}` }}>
<ProvisionSkeleton count={active.length} />
</div>
) : (
<Stack style={{ gap: 0 }}>
{active.map((provision) => {
const name = reportProvisionName(provision);
return (
<Stack
key={provision.path}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
color: colors.text.secondary,
padding: 2,
flexShrink: 0,
gap: spacing.sm,
padding: `${spacing.md} ${spacing.lg}`,
borderBottom: `1px solid ${colors.border.light}`,
}}
>
<IconX size={14} />
</button>
</Stack>
<Stack
<Stack
style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: spacing.sm,
}}
>
{/* The name in full, wrapping; the parameter's place in the tree on hover. */}
<Text
title={provision.breadcrumb || provision.path}
style={{
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
lineHeight: typography.lineHeight.tight,
color: colors.text.primary,
}}
>
{name}
</Text>
<button
type="button"
onClick={() => setRemoved((current) => new Set([...current, provision.path]))}
aria-label={`Remove ${name}`}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
color: colors.text.secondary,
padding: 2,
flexShrink: 0,
}}
>
<IconX size={14} />
</button>
</Stack>
{/* The value to edit; what it was, quietly beside it, as Build shows it. */}
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: spacing.sm,
}}
>
{typeof values[provision.path] === 'boolean' ? (
<ValueInput
value={values[provision.path]}
onChange={(next) =>
setValues((current) => ({ ...current, [provision.path]: next }))
}
ariaLabel={`New value for ${name}`}
/>
) : (
// Build's box: rates as percentages, amounts with their currency.
<div style={{ width: 150 }}>
<NumberInput
param={{ unit: provision.unit } as ParameterMetadata}
value={values[provision.path]}
onChange={(next) =>
setValues((current) => ({ ...current, [provision.path]: next }))
}
ariaLabel={`New value for ${name}`}
/>
</div>
)}
{!sameParameterValue(values[provision.path], provision.baselineValue) && (
<Text style={{ fontSize: typography.fontSize.xs, color: colors.text.tertiary }}>
was {formatValue(provision.baselineValue, provision.unit)}
</Text>
)}
</div>
</Stack>
);
})}
{active.length === 0 && (
<Text
style={{
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
flexWrap: 'wrap',
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
padding: `${spacing.md} ${spacing.lg}`,
}}
>
<Text
style={{
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
whiteSpace: 'nowrap',
}}
>
{formatValue(provision.baselineValue, provision.unit)} →
</Text>
<ValueInput
value={values[provision.path]}
onChange={(next) =>
setValues((current) => ({ ...current, [provision.path]: next }))
}
ariaLabel={`Adjusted value for ${provision.path}`}
/>
</Stack>
</Stack>
))}
{active.length === 0 && (
<Text
style={{
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
padding: `${spacing.md} ${spacing.lg}`,
}}
>
All provisions removed — restore some to recompute.
</Text>
)}
{removed.size > 0 && (
<button
type="button"
onClick={() => setRemoved(new Set())}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
color: colors.text.secondary,
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.primary,
padding: `${spacing.sm} ${spacing.lg}`,
textAlign: 'left',
}}
>
Restore {removed.size} removed provision{removed.size === 1 ? '' : 's'}
</button>
)}
</Stack>
All provisions removed — restore some to recompute.
</Text>
)}
{removed.size > 0 && (
<button
type="button"
onClick={() => setRemoved(new Set())}
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
color: colors.text.secondary,
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.primary,
padding: `${spacing.sm} ${spacing.lg}`,
textAlign: 'left',
}}
>
Restore {removed.size} removed provision{removed.size === 1 ? '' : 's'}
</button>
)}
</Stack>
)}

<Stack style={{ gap: spacing.sm, padding: spacing.lg }}>
{(reconcileError || runReport.error) && (
Expand Down
27 changes: 25 additions & 2 deletions app/src/components/flagship/ScopePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ interface ScopePickerProps {
states: ScopeOption[];
/** Trigger styling shared with the neighboring filter controls */
triggerStyle: React.CSSProperties;
/** How many items each option holds, keyed by option code, e.g. bills per state */
counts?: Record<string, number>;
/** The trigger's accessible name before its value, e.g. "State scope" */
label?: string;
}

const JURISDICTIONS: ScopeOption[] = [
Expand Down Expand Up @@ -57,7 +61,14 @@ export function scopeFilter(value: string, search: string, keywords?: string[]):
* The search scope as a filterable list: fifty-odd states are too many to
* scan in a native select, so typing narrows them by name or code.
*/
export default function ScopePicker({ value, onChange, states, triggerStyle }: ScopePickerProps) {
export default function ScopePicker({
value,
onChange,
states,
triggerStyle,
counts,
label = 'State scope',
}: ScopePickerProps) {
const [open, setOpen] = useState(false);
const current =
JURISDICTIONS.find((option) => option.code === value) ??
Expand All @@ -80,6 +91,18 @@ export default function ScopePicker({ value, onChange, states, triggerStyle }: S
>
<span>{option.label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: spacing.sm }}>
{counts?.[option.code] !== undefined && (
<span
style={{
minWidth: 18,
textAlign: 'right',
fontSize: typography.fontSize.xs,
color: colors.text.secondary,
}}
>
{counts[option.code]}
</span>
)}
{showCode && (
<span
style={{
Expand Down Expand Up @@ -108,7 +131,7 @@ export default function ScopePicker({ value, onChange, states, triggerStyle }: S
<PopoverTrigger asChild>
<button
type="button"
aria-label={`State scope: ${currentLabel}`}
aria-label={`${label}: ${currentLabel}`}
className="tw:hover:border-border-medium"
style={{ ...triggerStyle, cursor: 'pointer' }}
>
Expand Down
Loading
Loading