Skip to content

Commit 4d33dea

Browse files
committed
feat(header): show weekly quota remaining
1 parent eceb6b4 commit 4d33dea

6 files changed

Lines changed: 98 additions & 39 deletions

File tree

src/client/App.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
6161
type PlanState,
6262
} from './features/plans/plan-controller.js';
6363
import { isRelayPlanUpdate } from './features/plans/contracts.js';
64-
import { weeklyQuotaUsed } from './features/plans/weekly-quota.js';
64+
import { weeklyQuotaRemaining } from './features/plans/weekly-quota.js';
6565
import PlanView from './features/plans/PlanView.svelte';
6666
import { reconnectDelay, turnReadiness } from './features/sessions/session-state.js';
6767
import { createSessionCache } from './features/sessions/session-cache.js';
@@ -123,8 +123,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
123123
(planState.kind === 'ready' || planState.kind === 'closing' || planState.kind === 'error') &&
124124
!!planState.plan,
125125
);
126-
let weeklyQuotaUsage = $derived(
127-
weeklyQuotaUsed(
126+
let weeklyQuotaRemainingValue = $derived(
127+
weeklyQuotaRemaining(
128128
planState.kind === 'ready' || planState.kind === 'closing' || planState.kind === 'error'
129129
? planState.plan
130130
: undefined,
@@ -939,7 +939,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
939939
)
940940
: null}
941941
sessionModel={tab === 'chat' ? sessions.find((session) => session.id === sessionId)?.model ?? defaultSessionModel : null}
942-
weeklyQuotaUsed={weeklyQuotaUsage}
942+
weeklyQuotaRemaining={weeklyQuotaRemainingValue}
943943
onthemechange={setTheme}
944944
/>
945945
{#if recoveryNotice}

src/client/components/AppHeader.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-or-later
1111
theme,
1212
sessionPath = null,
1313
sessionModel = null,
14-
weeklyQuotaUsed = null,
14+
weeklyQuotaRemaining = null,
1515
onthemechange,
1616
}: {
1717
theme: ThemePreference;
1818
sessionPath?: string | null;
1919
sessionModel?: string | null;
20-
weeklyQuotaUsed?: number | null;
20+
weeklyQuotaRemaining?: number | null;
2121
onthemechange: (theme: ThemePreference) => void;
2222
} = $props();
2323
</script>
@@ -35,8 +35,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
3535
</p>
3636
{/if}
3737
<div class="header-actions">
38-
{#if weeklyQuotaUsed !== null}
39-
<span class="weekly-quota" aria-label="Weekly quota used">{weeklyQuotaUsed}% used</span>
38+
{#if weeklyQuotaRemaining !== null}
39+
<span class="weekly-quota" aria-label="Weekly quota remaining">{weeklyQuotaRemaining}% left</span>
4040
{/if}
4141
<button
4242
class="menu-trigger"

src/client/components/AppHeader.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@ describe('AppHeader', () => {
2525
expect(container.querySelector('.session-model')?.textContent).toBe('· gpt-5.6-terra');
2626
});
2727

28-
it('places the available weekly quota usage immediately before the menu trigger', () => {
28+
it('places the available weekly quota remaining immediately before the menu trigger', () => {
2929
const { container } = render(AppHeader, {
3030
theme: 'system',
31-
weeklyQuotaUsed: 37,
31+
weeklyQuotaRemaining: 63,
3232
onthemechange: () => {},
3333
});
3434

35-
expect(container.querySelector('.weekly-quota')?.textContent).toBe('37% used');
35+
expect(container.querySelector('.weekly-quota')?.textContent).toBe('63% left');
3636
expect(container.querySelector('.weekly-quota + .menu-trigger')).toBeTruthy();
3737
});
38+
39+
it('omits the weekly quota when the relay has no current value', () => {
40+
const { container } = render(AppHeader, { theme: 'system', onthemechange: () => {} });
41+
42+
expect(container.querySelector('.weekly-quota')).toBeNull();
43+
});
3844
});

src/client/features/plans/weekly-quota.test.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,48 @@
77
import { describe, expect, it } from 'vitest';
88

99
import type { SupervisedPlan } from './contracts.js';
10-
import { weeklyQuotaUsed } from './weekly-quota.js';
10+
import { weeklyQuotaRemaining } from './weekly-quota.js';
1111

1212
const plan = (remaining?: number): SupervisedPlan => ({
1313
title: 'Weekly quota',
14-
steps: [{
15-
id: 'l1', title: 'Parent', level: 1, state: 'WIP', priority: 'A', description: {}, children: [
16-
{
17-
id: 'l2', title: 'Active', level: 2, state: 'WIP', priority: 'A', description: {},
18-
...(remaining === undefined ? {} : { measurement: { weeklyRemainingCurrent: remaining } }),
19-
children: [],
20-
},
21-
],
22-
}],
14+
steps: [
15+
{
16+
id: 'l1',
17+
title: 'Parent',
18+
level: 1,
19+
state: 'WIP',
20+
priority: 'A',
21+
description: {},
22+
children: [
23+
{
24+
id: 'l2',
25+
title: 'Active',
26+
level: 2,
27+
state: 'WIP',
28+
priority: 'A',
29+
description: {},
30+
...(remaining === undefined
31+
? {}
32+
: { measurement: { weeklyRemainingCurrent: remaining } }),
33+
children: [],
34+
},
35+
],
36+
},
37+
],
2338
totalSteps: 2,
2439
doneSteps: 0,
2540
allDone: false,
2641
currentStepId: 'l2',
2742
});
2843

29-
describe('weeklyQuotaUsed', () => {
30-
it('returns the rounded used weekly percentage on the active step', () => {
31-
expect(weeklyQuotaUsed(plan(62.7))).toBe(37);
44+
describe('weeklyQuotaRemaining', () => {
45+
it('returns the rounded remaining weekly percentage on the active step', () => {
46+
expect(weeklyQuotaRemaining(plan(62.7))).toBe(63);
3247
});
3348

3449
it('keeps unavailable, invalid, and inactive measurements hidden', () => {
35-
expect(weeklyQuotaUsed(plan())).toBeNull();
36-
expect(weeklyQuotaUsed(plan(101))).toBeNull();
37-
expect(weeklyQuotaUsed(undefined)).toBeNull();
50+
expect(weeklyQuotaRemaining(plan())).toBeNull();
51+
expect(weeklyQuotaRemaining(plan(101))).toBeNull();
52+
expect(weeklyQuotaRemaining(undefined)).toBeNull();
3853
});
3954
});

src/client/features/plans/weekly-quota.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66

77
import type { PlanStep, SupervisedPlan } from './contracts.js';
88

9-
/** Returns the active plan step's latest weekly account-wide percentage used. */
10-
export function weeklyQuotaUsed(plan: SupervisedPlan | undefined): number | null {
9+
/** Returns the active plan step's latest remaining weekly account-wide percentage. */
10+
export function weeklyQuotaRemaining(plan: SupervisedPlan | undefined): number | null {
1111
if (!plan) return null;
1212
const remaining = findStep(plan.steps, plan.currentStepId)?.measurement?.weeklyRemainingCurrent;
13-
return typeof remaining === 'number' && Number.isFinite(remaining) && remaining >= 0 && remaining <= 100
14-
? Math.round(100 - remaining)
13+
return typeof remaining === 'number' &&
14+
Number.isFinite(remaining) &&
15+
remaining >= 0 &&
16+
remaining <= 100
17+
? Math.round(remaining)
1518
: null;
1619
}
1720

test/e2e/plan-tab-navigation.spec.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const completedPlan = {
2828
priority: 'A',
2929
reviewStatus: 'UNREVIEWED',
3030
description: { goal: 'Keep every input path consistent.' },
31+
measurement: { weeklyRemainingCurrent: 63 },
3132
children: [],
3233
},
3334
],
@@ -37,15 +38,24 @@ const completedPlan = {
3738
currentStepId: 'finish',
3839
};
3940

40-
const activePlan = { ...completedPlan, allDone: false, doneSteps: 0 };
41+
const activePlan = {
42+
...completedPlan,
43+
allDone: false,
44+
doneSteps: 0,
45+
};
4146

4247
async function routeSessionHistory(page: Page, id: string): Promise<void> {
4348
await page.route(`**/api/sessions/${id}/history`, (route) =>
44-
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [], currentSequence: 0 }) }),
49+
route.fulfill({
50+
contentType: 'application/json',
51+
body: JSON.stringify({ items: [], currentSequence: 0 }),
52+
}),
4553
);
4654
}
4755

48-
test('keeps the completed Plan tab reachable and overflow-free at 320px with 200% root font', async ({ page }) => {
56+
test('keeps the completed Plan tab reachable and overflow-free at 320px with 200% root font', async ({
57+
page,
58+
}) => {
4959
const selected = session('session-1', '/projects/one');
5060
let closed = false;
5161
await page.setViewportSize({ width: 320, height: 568 });
@@ -66,12 +76,18 @@ test('keeps the completed Plan tab reachable and overflow-free at 320px with 200
6676
await page.route('**/api/sessions/recent-threads', (route) =>
6777
route.fulfill({ contentType: 'application/json', body: '[]' }),
6878
);
69-
await page.routeWebSocket(/ws:\/\/127\.0\.0\.1:4173\/api\/sessions\/session-1\/events\?after=\d+/, () => {});
79+
await page.routeWebSocket(
80+
/ws:\/\/127\.0\.0\.1:4173\/api\/sessions\/session-1\/events\?after=\d+/,
81+
() => {},
82+
);
7083

7184
await page.goto('/');
7285
await page.addStyleTag({ content: 'html { font-size: 200% !important; }' });
7386

7487
const navigation = page.getByLabel('Primary');
88+
const weeklyQuota = page.getByLabel('Weekly quota remaining');
89+
await expect(weeklyQuota).toHaveText('63% left');
90+
await expect(weeklyQuota.locator('+ .menu-trigger')).toHaveCount(1);
7591
await expect(navigation.getByRole('button')).toHaveText(['Sessions', 'Git', 'Chat', 'Plan']);
7692
const horizontalLayout = await page.evaluate(() => ({
7793
clientWidth: document.documentElement.clientWidth,
@@ -96,6 +112,10 @@ test('keeps the completed Plan tab reachable and overflow-free at 320px with 200
96112

97113
const chat = navigation.getByRole('button', { name: 'Chat' });
98114
const plan = navigation.getByRole('button', { name: 'Plan' });
115+
for (const tab of ['Sessions', 'Git', 'Chat', 'Plan']) {
116+
await navigation.getByRole('button', { name: tab }).click();
117+
await expect(weeklyQuota).toHaveText('63% left');
118+
}
99119
await chat.click();
100120
const prompt = page.getByRole('textbox', { name: 'Prompt' });
101121
await prompt.fill('Keep this chat draft');
@@ -121,8 +141,18 @@ test('keeps the completed Plan tab reachable and overflow-free at 320px with 200
121141

122142
await chat.click();
123143
const main = page.locator('main');
124-
await main.dispatchEvent('pointerdown', { pointerType: 'touch', pointerId: 4, clientX: 240, clientY: 240 });
125-
await main.dispatchEvent('pointerup', { pointerType: 'touch', pointerId: 4, clientX: 100, clientY: 242 });
144+
await main.dispatchEvent('pointerdown', {
145+
pointerType: 'touch',
146+
pointerId: 4,
147+
clientX: 240,
148+
clientY: 240,
149+
});
150+
await main.dispatchEvent('pointerup', {
151+
pointerType: 'touch',
152+
pointerId: 4,
153+
clientX: 100,
154+
clientY: 242,
155+
});
126156
await expect(plan).toHaveAttribute('aria-pressed', 'true');
127157

128158
await page.getByRole('button', { name: 'Close completed plan' }).click();
@@ -132,7 +162,9 @@ test('keeps the completed Plan tab reachable and overflow-free at 320px with 200
132162
await expect(plan).toHaveCount(0);
133163
});
134164

135-
test('adds and removes Plan from live events without stealing focus, then isolates a session without a plan', async ({ page }) => {
165+
test('adds and removes Plan from live events without stealing focus, then isolates a session without a plan', async ({
166+
page,
167+
}) => {
136168
const first = session('session-1', '/projects/one');
137169
const second = session('session-2', '/projects/two');
138170
let emitPlanEvent: ((event: object) => void) | undefined;
@@ -154,7 +186,10 @@ test('adds and removes Plan from live events without stealing focus, then isolat
154186
emitPlanEvent = (event) => socket.send(JSON.stringify({ type: 'relay.event', event }));
155187
},
156188
);
157-
await page.routeWebSocket(/ws:\/\/127\.0\.0\.1:4173\/api\/sessions\/session-2\/events\?after=\d+/, () => {});
189+
await page.routeWebSocket(
190+
/ws:\/\/127\.0\.0\.1:4173\/api\/sessions\/session-2\/events\?after=\d+/,
191+
() => {},
192+
);
158193

159194
await page.goto('/');
160195
const navigation = page.getByLabel('Primary');

0 commit comments

Comments
 (0)