Skip to content

Commit ba9e820

Browse files
feat(app-shell): extract the approval decision-progress indicator (objectui#12033) (#12039)
Fixes #12033 Clause-②: no A3 of the objectui#2763 split, dispatched by the `domain:ui` seat 3 (claim comment 6076025575). Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. ## What changes - New module-internal widget `packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.tsx`. It takes a pending request's `decision_progress` (plus, optionally, the eligible-approver count) and draws the tally for each behavior the approval-service contract declares: `unanimous`, `quorum` (M-of-N) and `per_group` (countersign). - `RecordApprovalsPanel.tsx` renders the widget in place of its inline block. The markup moved unchanged. - No export from `@object-ui/app-shell`'s entry, no SDUI component type, no new prop on any exported component, no new locale row. `useRecordApprovals.ts` is read only (its `ApprovalDecisionProgress` type). The built declarations confirm it: after `pnpm --filter @object-ui/app-shell build`, only the widget's own `.d.ts` names it; `dist/index.d.ts`, `dist/views/index.d.ts` and the panel's `.d.ts` do not. - `.changeset/12033-approval-decision-progress.md`: `patch` on `@object-ui/app-shell`. ## Premise, measured on main 8f815f4 - **The card's "ordered step list" is not the `decision_progress` rendering.** That list is the panel's `flow_steps` stepper (labelled by `approvalsInbox.stepProgress`, pinned by `RecordApprovalsPanel.stepProgressVertical.test.tsx`). `decision_progress` is drawn by a separate block below it: a segmented bar plus one badge per group. The widget is bound to `decision_progress`, as the parent's A3 text and the ruling say, so it extracts that block. The `flow_steps` stepper stays in the panel; the stepProgressVertical suite is unchanged and green. - **The panel already drew all three shapes.** `unanimous` and `quorum` share the `approvalsInbox.progressApprovals` row and draw `need` ticks, with the eligible-approver count beside them. `per_group` uses `approvalsInbox.progressGroups`, never shows the eligible count, and adds one badge per `groups` entry (a check when satisfied, a hollow circle otherwise). A tally above 12 decisions is one continuous fill. No shape is new, so nothing visible changes. What was missing were pins: the panel's suites pinned only `per_group`. - **The contract's shapes** (objectstack `packages/spec/src/contracts/approval-service.ts`, the `decision_progress` member): `behavior` is `unanimous`, `quorum` or `per_group`, with `got`, `need` and an optional `groups` list of `group` / `got` / `need` / `satisfied`; absent on `first_response`. `ApprovalDecisionProgress` in `useRecordApprovals.ts` mirrors it field for field, and the widget reads only those fields. ## The re-point is DOM-identical (one-time proof, not committed) A temporary test rendered the merge-base panel (copied verbatim from 8f815f4) and the re-pointed panel over eight fixtures: unanimous, quorum with and without eligible approvers, per_group with a three-step `flow_steps`, a 20-decision tally, `need` 0, `got` above `need`, and `first_response` (no tally). Each fixture was rendered with no i18next instance and with an `en` instance: 16 comparisons. Each required `container.innerHTML` to be byte-equal, and required the progressbar to be present in every fixture except `first_response`, so an empty render could not pass. Result: 16 of 16 equal (per_group, for example, is 5305 bytes on both sides). Both temporary files were deleted before anything else read the tree. ## i18n No new rows and no duplicates. The widget asks for the same four `approvalsInbox.*` rows the panel asked for (`progressApprovals`, `progressGroups`, `progressEligible`, `progressBar`), with the same default text and arguments. One difference: it names them by literal key, not through the panel's `tr(key: string)` forwarder. So `check:i18n-keys` now checks these call sites (the key exists, the default text matches `en`, the arguments match the holes). Positive control: changing the widget's `progressApprovals` default text made that gate exit 1 with `[default-value-drift] approvalsInbox.progressApprovals` at the widget. The restore was proven by blob hash. ## Pins `DecisionProgressIndicator.test.tsx` has one pin per shape plus the two edge branches. The pins check structure: tick count, filled ticks, the progressbar's value range, the group badges and the icon on each badge. They also check which pack row each label comes from. The expected label is produced by the same i18next instance, asked for the key, so no English prose is pinned. - unanimous: one tick per approver, filled up to `got`, eligible count shown. - quorum (M-of-N): `need` ticks (the M), not the eligible N; eligible count shown. With no eligible count, the tally shows alone. - per_group: a tick per group with the satisfied ones filled, one badge per group, no eligible count. - More than 12 decisions: one continuous fill (25% for 5 of 20). - `got` above `need`: the progressbar value is capped at `need`. ## Reverse verification (`ablation-replace` on the committed tree, each restore proven by blob hash) - **M-of-N count broken.** The bar draws `max(need, eligible)` ticks, so it counts N instead of M. Result: red, 2 failed / 4 passed. The quorum pin got 5 ticks where it expects 2, and the per_group pin got 3 where it expects 2. The unanimous pin stayed green because its eligible count is below `need`. - **Per-group branch dropped** (`{false && dp.groups && (`). Result: red, 2 failed / 14 passed across the widget suite and `RecordApprovalsPanel.test.tsx`. The widget's per_group pin and the panel's own per_group test both went red. - After each restore, the widget's blob equals HEAD's (`0ec44e53a26c`) and `git diff HEAD` is empty. ## Gates, all on `0e0fd992b` | command | exit | the gate's own verdict | |---|---|---| | `pnpm exec vitest run` over `approval-progress/`, every `RecordApprovalsPanel*` suite, both `RecordDetailView.approval*` suites, and the temporary proof | 0 | `Test Files 9 passed (9)`, `Tests 74 passed (74)` | | `pnpm --filter @object-ui/app-shell type-check` (after `turbo run build --filter=@object-ui/app-shell^...`, 28 of 28) | 0 | both `tsc --noEmit` and `tsc -p tsconfig.test.json` clean | | `pnpm --filter @object-ui/app-shell build` | 0 | `dist completeness: 1 package(s) complete (1040 emitted files verified)` | | `pnpm exec eslint` on the three touched source files | 0 | `0 errors, 6 warnings`, all six in the panel's untouched code; the merge-base panel has the same six | | `pnpm check:i18n-keys` | 0 | every in-scope call-site key resolves, every inline default matches | | `pnpm check:control-bytes` · `check:test-path-roots` · `check:changeset-claims` · `check:pending-changeset-literals` · `check:unreferenced-sources` · `check:i18n-dead-keys` · `check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` | 0 each | OK | | `pnpm check:new-line-citations` | 0 | `0 new citation(s)` | | `node scripts/check-changeset-presence.mjs` · `-no-major` · `-fixed` · `-overwrite` | 0 each | one changeset declared, no major, none overwritten | | `CI=true pnpm exec vite build` in `apps/console`, then `node scripts/check-eager-closure-budget.mjs` | 0 | `aggregate closure 3162.6 KB measured / 3204.6 KB ceiling (headroom 42.0 KB = 0.47x the 89.0 KB regression)` | | `pnpm exec vitest run packages/app-shell/` (the full package suite) | running | still running under the verify lock when this PR was opened; its verdict is in the `os-dev-report` comment on the card | **First-load bytes:** +74 B gzip over the eager closure (3,238,404 to 3,238,478, 289 eager chunks on both builds). The control build was this tree with the panel checked out at 8f815f4, which leaves the widget unreferenced. The panel's chunk `record-approvals-renderer` grew 61 B gzip. The other changed chunks moved by 1 to 6 bytes because their import specifiers carry the renamed chunk's hash. **Declared narrowing:** the targeted vitest run, the DOM proof and the two ablations ran outside the verify lock, with one worker. That was after about 30 minutes queued behind another seat's full app-shell suite. The type-check, build, both console builds and the full suite ran under the lock. ## Acceptance notes - Two more renderings of the same tally sit outside this claim's surface and are not touched. One is the console Approval Center drawer in `ApprovalsInboxPage.tsx`, a near line-for-line twin that objectui#2763 B3 deletes. The other is the `DetailView` approval band in `plugin-detail`, a different, compact rendering in a package that app-shell depends on, so it cannot import this widget. - `eligibleApprovers` is the caller's pending-approver count (`pending_approvers.length`), as the panel always showed it. It is not a `decision_progress` field: the contract carries no N for a quorum, and the widget adds none. - Observation only: a comment in `RecordApprovalsPanel.test.tsx` says the counts are not interpolated without an i18next instance. That stopped being true when the provider-less fallback began interpolating (objectui#6219). The comment is unchanged here. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8de8ba2 commit ba9e820

4 files changed

Lines changed: 292 additions & 63 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally is an approval request's `decision_progress` as the approval-service contract declares it: approvals of every approver on a `unanimous` node, approvals of the threshold on a `quorum` (M-of-N) node, and satisfied groups, with one badge per group, on a `per_group` countersign node. The panel renders the same markup as before and reads the same `approvalsInbox.*` copy rows.
6+
7+
The indicator is not exported from the package entry and is not registered as an SDUI component type; registering it is the approval detail page's step (objectui#2763 B1).

‎packages/app-shell/src/views/RecordApprovalsPanel.tsx‎

Lines changed: 6 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as React from 'react';
1010
import { useDisplayLocale } from '@object-ui/i18n';
1111
import { cn, Badge, Button } from '@object-ui/components';
12-
import { Stamp, Check, Circle, Paperclip, Loader2, Send, ShieldAlert } from 'lucide-react';
12+
import { Stamp, Check, Paperclip, Loader2, Send, ShieldAlert } from 'lucide-react';
1313
import { toast } from 'sonner';
1414
import { createAuthenticatedFetch } from '@object-ui/auth';
1515
import { useObjectTranslation } from '@object-ui/react';
@@ -35,6 +35,7 @@ import {
3535
useApproverDirectory,
3636
type ApproverDirectory,
3737
} from '../hooks/useApproverDirectory.js';
38+
import { DecisionProgressIndicator } from './approval-progress/DecisionProgressIndicator.js';
3839

3940
/**
4041
* RecordApprovalsPanel — the record page's read-only approval surface
@@ -439,68 +440,10 @@ export const RecordApprovalsPanel: React.FC<RecordApprovalsPanelProps> = ({
439440
</ol>
440441
)}
441442

442-
{/* Aggregation progress — server-computed tally (quorum/unanimous
443-
approvals, per-group 会签 groups), never re-derived client-side. */}
444-
{dp && (
445-
<div>
446-
<div className="flex items-baseline justify-between gap-2 mb-1.5">
447-
<span className="text-[11px] text-muted-foreground">
448-
{dp.behavior === 'per_group'
449-
? tr('progressGroups', 'Sign-off progress — {{got}} of {{need}} groups', { got: dp.got, need: dp.need })
450-
: tr('progressApprovals', 'Approvals — {{got}} of {{need}}', { got: dp.got, need: dp.need })}
451-
</span>
452-
{dp.behavior !== 'per_group' && eligible > 0 && (
453-
<span className="text-[11px] text-muted-foreground">
454-
{tr('progressEligible', '{{count}} eligible approver(s)', { count: eligible })}
455-
</span>
456-
)}
457-
</div>
458-
<div
459-
role="progressbar"
460-
aria-valuemin={0}
461-
aria-valuemax={dp.need}
462-
aria-valuenow={Math.min(dp.got, dp.need)}
463-
aria-label={tr('progressBar', 'Decision progress')}
464-
className="flex gap-1"
465-
>
466-
{dp.need > 0 && dp.need <= 12 ? (
467-
Array.from({ length: dp.need }).map((_, i) => (
468-
<div
469-
key={i}
470-
className={cn('h-1.5 flex-1 rounded-full', i < dp.got ? 'bg-emerald-500' : 'bg-muted')}
471-
/>
472-
))
473-
) : (
474-
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
475-
<div
476-
className="h-full rounded-full bg-emerald-500"
477-
style={{ width: `${dp.need > 0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }}
478-
/>
479-
</div>
480-
)}
481-
</div>
482-
{dp.groups && (
483-
<div className="flex flex-wrap gap-1 mt-2">
484-
{dp.groups.map((g) => (
485-
<Badge
486-
key={g.group}
487-
variant="outline"
488-
className={cn(
489-
'text-[11px] gap-1',
490-
g.satisfied
491-
? 'border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400'
492-
: 'text-muted-foreground',
493-
)}
494-
title={`${g.got}/${g.need}`}
495-
>
496-
{g.satisfied ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
497-
{g.group} {g.got}/{g.need}
498-
</Badge>
499-
))}
500-
</div>
501-
)}
502-
</div>
503-
)}
443+
{/* Aggregation progress — the pending node's server-computed tally
444+
(unanimous / quorum approvals, per-group countersign), drawn by
445+
the shared indicator and never re-derived client-side. */}
446+
{dp && <DecisionProgressIndicator progress={dp} eligibleApprovers={eligible} />}
504447

505448
{/* Who the pending step waits on — THE read this panel exists for:
506449
server-resolved names (group approvers labeled with their group),
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* DecisionProgressIndicator — one pin per `decision_progress` shape
6+
* (objectui#12033, the A3 primitive of objectui#2763).
7+
*
8+
* The approval-service contract declares three behaviors for the tally:
9+
* `unanimous` (approvals of every approver), `quorum` (approvals of the M
10+
* threshold) and `per_group` (satisfied groups, plus per-group detail). Each
11+
* gets its own pin below, plus the continuous-bar branch a long tally takes.
12+
*
13+
* What is asserted is the indicator's structure — tick count, filled ticks,
14+
* the progressbar's value range, the group ticks — and WHICH pack row each
15+
* label is read from. The expected strings are produced by the same i18next
16+
* instance the render uses, asked for the key, so a pin names the row and its
17+
* interpolation arguments rather than restating English prose.
18+
*/
19+
20+
import * as React from 'react';
21+
import { describe, it, expect, afterEach } from 'vitest';
22+
import { render, screen, cleanup } from '@testing-library/react';
23+
import { createI18n, I18nProvider } from '@object-ui/i18n';
24+
import type { ApprovalDecisionProgress } from '../../hooks/useRecordApprovals';
25+
import { DecisionProgressIndicator } from './DecisionProgressIndicator';
26+
27+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
28+
29+
/** The pack row a label must come from, rendered by the instance under test. */
30+
function row(key: string, args?: Record<string, unknown>): string {
31+
return String(i18n.t(`approvalsInbox.${key}`, args));
32+
}
33+
34+
async function renderIndicator(progress: ApprovalDecisionProgress, eligibleApprovers?: number) {
35+
render(
36+
<I18nProvider instance={i18n}>
37+
<DecisionProgressIndicator progress={progress} eligibleApprovers={eligibleApprovers} />
38+
</I18nProvider>,
39+
);
40+
return screen.findByRole('progressbar');
41+
}
42+
43+
/** The bar's own children: one per tick, or the single continuous track. */
44+
function ticks(bar: HTMLElement): Element[] {
45+
return Array.from(bar.children);
46+
}
47+
48+
function filled(bar: HTMLElement): Element[] {
49+
return ticks(bar).filter((el) => (el.getAttribute('class') ?? '').split(/\s+/).includes('bg-emerald-500'));
50+
}
51+
52+
afterEach(() => {
53+
cleanup();
54+
});
55+
56+
describe('DecisionProgressIndicator — the decision_progress shapes (objectui#12033)', () => {
57+
it('unanimous: one tick per approver, filled up to the approvals recorded', async () => {
58+
const bar = await renderIndicator({ behavior: 'unanimous', got: 1, need: 3 }, 2);
59+
60+
expect(screen.getByText(row('progressApprovals', { got: 1, need: 3 }))).toBeTruthy();
61+
expect(screen.getByText(row('progressEligible', { count: 2 }))).toBeTruthy();
62+
expect(bar.getAttribute('aria-label')).toBe(row('progressBar'));
63+
expect(bar.getAttribute('aria-valuemin')).toBe('0');
64+
expect(bar.getAttribute('aria-valuemax')).toBe('3');
65+
expect(bar.getAttribute('aria-valuenow')).toBe('1');
66+
expect(ticks(bar)).toHaveLength(3);
67+
expect(filled(bar)).toHaveLength(1);
68+
// Group ticks belong to `per_group` alone.
69+
expect(bar.nextElementSibling).toBeNull();
70+
});
71+
72+
it('quorum (M-of-N): the ticks count the M threshold, the eligible N rides beside it', async () => {
73+
const bar = await renderIndicator({ behavior: 'quorum', got: 1, need: 2 }, 5);
74+
75+
expect(screen.getByText(row('progressApprovals', { got: 1, need: 2 }))).toBeTruthy();
76+
expect(screen.getByText(row('progressEligible', { count: 5 }))).toBeTruthy();
77+
expect(bar.getAttribute('aria-valuemax')).toBe('2');
78+
expect(bar.getAttribute('aria-valuenow')).toBe('1');
79+
// M ticks, not N: the bar is the threshold the engine finalizes on.
80+
expect(ticks(bar)).toHaveLength(2);
81+
expect(filled(bar)).toHaveLength(1);
82+
expect(bar.nextElementSibling).toBeNull();
83+
});
84+
85+
it('quorum with no eligible count shows the tally alone', async () => {
86+
await renderIndicator({ behavior: 'quorum', got: 0, need: 2 });
87+
88+
expect(screen.getByText(row('progressApprovals', { got: 0, need: 2 }))).toBeTruthy();
89+
expect(screen.queryByText(row('progressEligible', { count: 0 }))).toBeNull();
90+
});
91+
92+
it('per_group (countersign): a tick per group, the satisfied ones filled, a badge per group, no eligible count', async () => {
93+
const bar = await renderIndicator(
94+
{
95+
behavior: 'per_group',
96+
got: 1,
97+
need: 2,
98+
groups: [
99+
{ group: 'quality', got: 1, need: 1, satisfied: true },
100+
{ group: 'production', got: 0, need: 2, satisfied: false },
101+
],
102+
},
103+
3,
104+
);
105+
106+
expect(screen.getByText(row('progressGroups', { got: 1, need: 2 }))).toBeTruthy();
107+
// The approver count describes approvals, not groups — never shown here.
108+
expect(screen.queryByText(row('progressEligible', { count: 3 }))).toBeNull();
109+
expect(ticks(bar)).toHaveLength(2);
110+
expect(filled(bar)).toHaveLength(1);
111+
112+
const quality = screen.getByText('quality 1/1');
113+
const production = screen.getByText('production 0/2');
114+
expect(quality.getAttribute('title')).toBe('1/1');
115+
expect(production.getAttribute('title')).toBe('0/2');
116+
// Satisfied groups carry the check, open ones the hollow circle.
117+
expect(quality.querySelector('svg')?.getAttribute('class')).toMatch(/\blucide-check\b/);
118+
expect(production.querySelector('svg')?.getAttribute('class')).toMatch(/\blucide-circle\b/);
119+
// Both badges sit in the one group row that follows the bar.
120+
expect(quality.parentElement).toBe(bar.nextElementSibling);
121+
expect(production.parentElement).toBe(bar.nextElementSibling);
122+
});
123+
124+
it('a tally past a dozen decisions is one continuous fill, not hairline ticks', async () => {
125+
const bar = await renderIndicator({ behavior: 'unanimous', got: 5, need: 20 });
126+
127+
expect(bar.getAttribute('aria-valuemax')).toBe('20');
128+
expect(ticks(bar)).toHaveLength(1);
129+
const fill = ticks(bar)[0].firstElementChild as HTMLElement;
130+
expect(fill.style.width).toBe('25%');
131+
});
132+
133+
it('caps the progressbar value at the requirement', async () => {
134+
const bar = await renderIndicator({ behavior: 'quorum', got: 3, need: 2 });
135+
136+
expect(bar.getAttribute('aria-valuenow')).toBe('2');
137+
expect(filled(bar)).toHaveLength(2);
138+
});
139+
});
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from 'react';
10+
import { cn, Badge } from '@object-ui/components';
11+
import { Check, Circle } from 'lucide-react';
12+
import { useObjectTranslation } from '@object-ui/react';
13+
import type { ApprovalDecisionProgress } from '../../hooks/useRecordApprovals.js';
14+
15+
/**
16+
* DecisionProgressIndicator — the segmented progress/quorum indicator bound to
17+
* a pending approval request's `decision_progress` (objectui#12033, the A3
18+
* primitive of objectui#2763).
19+
*
20+
* It draws the three aggregation behaviors the approval-service contract
21+
* declares for `decision_progress`, from the server's tally and nothing else:
22+
*
23+
* - `unanimous` — `got` approvals of `need`, where `need` is every approver;
24+
* - `quorum` (M-of-N) — `got` approvals of the `need` threshold. The N it is
25+
* drawn from is not part of the tally, so the caller may pass the eligible
26+
* approver count to show beside it;
27+
* - `per_group` (countersign) — `got` satisfied groups of `need`, plus one
28+
* tick per entry of `groups`.
29+
*
30+
* `first_response` nodes carry no `decision_progress` (one decision finalizes
31+
* them), so there is no fourth shape: a caller renders this only when the
32+
* tally is present. The counts are never re-derived client-side — the engine's
33+
* finalization tally stays authoritative and this is display only.
34+
*
35+
* Extracted from `RecordApprovalsPanel` so the approval detail page
36+
* (objectui#2763 B1) can draw the same indicator; the panel renders it with no
37+
* change to its DOM. Module-internal: no export from the package entry and no
38+
* SDUI component type — registering it is B1's step.
39+
*
40+
* The copy is the Approval Center's `approvalsInbox.*` rows, the same ones the
41+
* panel always read, asked for by literal key so the call-site i18n gate checks
42+
* each one rather than seeing a forwarded `key: string`.
43+
*/
44+
45+
/**
46+
* Above this many required decisions the ticks would shrink to hairlines, so
47+
* the bar becomes one continuous fill and the count in the label carries it.
48+
*/
49+
const MAX_SEGMENTS = 12;
50+
51+
export interface DecisionProgressIndicatorProps {
52+
/** The pending request's server-computed tally, as the contract declares it. */
53+
progress: ApprovalDecisionProgress;
54+
/**
55+
* How many approvers may still decide the pending node, shown beside an
56+
* approvals tally (`unanimous` / `quorum`) and never beside a group tally.
57+
* Absent or zero shows nothing.
58+
*/
59+
eligibleApprovers?: number;
60+
}
61+
62+
export const DecisionProgressIndicator: React.FC<DecisionProgressIndicatorProps> = ({
63+
progress: dp,
64+
eligibleApprovers = 0,
65+
}) => {
66+
const { t } = useObjectTranslation();
67+
const perGroup = dp.behavior === 'per_group';
68+
69+
return (
70+
<div>
71+
<div className="flex items-baseline justify-between gap-2 mb-1.5">
72+
<span className="text-[11px] text-muted-foreground">
73+
{perGroup
74+
? String(t('approvalsInbox.progressGroups', {
75+
defaultValue: 'Sign-off progress — {{got}} of {{need}} groups',
76+
got: dp.got,
77+
need: dp.need,
78+
}))
79+
: String(t('approvalsInbox.progressApprovals', {
80+
defaultValue: 'Approvals — {{got}} of {{need}}',
81+
got: dp.got,
82+
need: dp.need,
83+
}))}
84+
</span>
85+
{!perGroup && eligibleApprovers > 0 && (
86+
<span className="text-[11px] text-muted-foreground">
87+
{String(t('approvalsInbox.progressEligible', {
88+
defaultValue: '{{count}} eligible approver(s)',
89+
count: eligibleApprovers,
90+
}))}
91+
</span>
92+
)}
93+
</div>
94+
<div
95+
role="progressbar"
96+
aria-valuemin={0}
97+
aria-valuemax={dp.need}
98+
aria-valuenow={Math.min(dp.got, dp.need)}
99+
aria-label={String(t('approvalsInbox.progressBar', { defaultValue: 'Decision progress' }))}
100+
className="flex gap-1"
101+
>
102+
{dp.need > 0 && dp.need <= MAX_SEGMENTS ? (
103+
Array.from({ length: dp.need }).map((_, i) => (
104+
<div
105+
key={i}
106+
className={cn('h-1.5 flex-1 rounded-full', i < dp.got ? 'bg-emerald-500' : 'bg-muted')}
107+
/>
108+
))
109+
) : (
110+
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
111+
<div
112+
className="h-full rounded-full bg-emerald-500"
113+
style={{ width: `${dp.need > 0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }}
114+
/>
115+
</div>
116+
)}
117+
</div>
118+
{dp.groups && (
119+
<div className="flex flex-wrap gap-1 mt-2">
120+
{dp.groups.map((g) => (
121+
<Badge
122+
key={g.group}
123+
variant="outline"
124+
className={cn(
125+
'text-[11px] gap-1',
126+
g.satisfied
127+
? 'border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400'
128+
: 'text-muted-foreground',
129+
)}
130+
title={`${g.got}/${g.need}`}
131+
>
132+
{g.satisfied ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
133+
{g.group} {g.got}/{g.need}
134+
</Badge>
135+
))}
136+
</div>
137+
)}
138+
</div>
139+
);
140+
};

0 commit comments

Comments
 (0)