taskchef 9.3.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect, test, vi } from "vitest";
2
+ import "@testing-library/jest-dom/vitest";
3
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { CalendarView, type CalendarData } from "./CalendarView";
5
+ import type { Task } from "../../ui/types";
6
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
7
+ const month = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; };
8
+ const task = (id: string): Task => ({ id, title: `Chat ${id}`, project: { id: 'project', name: 'Project', path: '/repo', githubRepos: [] }, status: 'needs_input', instruction: '', createdAt: '', updatedAt: '', summary: '', threadId: null, turnRef: null, turnId: null, lastResult: null, latestTurn: null });
9
+ function data(count = 1): CalendarData { return { month: month(), timeZone: 'UTC', chats: Array.from({ length: count }, (_, i) => ({ id: String(i), incomplete: false, segments: [{ start: `${month()}-05`, end: `${month()}-05`, resumed: false }, { start: `${month()}-26`, end: `${month()}-26`, resumed: true }] })) }; }
10
+ test('paused and resumed segments open the same chat; navigation has no Today control', async () => {
11
+ const onOpenDetail = vi.fn(), request = vi.fn(async () => data());
12
+ render(<CalendarView request={request} tasks={[task('0')]} refreshKey="1" onOpenDetail={onOpenDetail} />);
13
+ const bars = await screen.findAllByRole('button', { name: /Project · Chat 0/ });
14
+ expect(bars).toHaveLength(2);
15
+ expect(bars[1]).toHaveTextContent('↩');
16
+ fireEvent.click(bars[0]); fireEvent.click(bars[1]);
17
+ expect(onOpenDetail).toHaveBeenNthCalledWith(1, task('0'));
18
+ expect(onOpenDetail).toHaveBeenNthCalledWith(2, task('0'));
19
+ expect(screen.queryByRole('button', { name: 'Today' })).not.toBeInTheDocument();
20
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeDisabled();
21
+ fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
22
+ expect(request).toHaveBeenCalledTimes(1);
23
+ fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
24
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
25
+ expect(request.mock.calls[1]).not.toEqual(request.mock.calls[0]);
26
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeEnabled();
27
+ fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
28
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(3));
29
+ expect(request.mock.calls[2]).toEqual(request.mock.calls[0]);
30
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeDisabled();
31
+ });
32
+ test('crowded weeks expand and filters remove nonmatching chats', async () => {
33
+ const props = { request: vi.fn(async () => data(5)), tasks: Array.from({ length: 5 }, (_, i) => task(String(i))), refreshKey: '1', onOpenDetail: vi.fn() };
34
+ const { rerender } = render(<CalendarView {...props} />);
35
+ const more = await screen.findAllByRole('button', { name: /more this week/ });
36
+ fireEvent.click(more[0]);
37
+ expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument();
38
+ expect(screen.getAllByRole('button', { name: /Project · Chat 4/ }).length).toBeGreaterThan(0);
39
+ rerender(<CalendarView {...props} tasks={[]} />);
40
+ expect(screen.queryByRole('button', { name: /Project · Chat/ })).not.toBeInTheDocument();
41
+ expect(screen.getByText(/No recorded work matches/)).toBeInTheDocument();
42
+ });
43
+ test('failed requests show retry without adding a missing-history notice', async () => {
44
+ const result = data(); result.chats[0].incomplete = true;
45
+ const request = vi.fn().mockRejectedValueOnce(new Error('Unavailable')).mockResolvedValue(result);
46
+ render(<CalendarView request={request} tasks={[task('0')]} refreshKey="1" onOpenDetail={vi.fn()} />);
47
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unavailable');
48
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
49
+ expect(await screen.findAllByRole('button', { name: /Project · Chat 0/ })).toHaveLength(2);
50
+ expect(screen.queryByText(/Some turn dates or durations are missing/)).not.toBeInTheDocument();
51
+ });
52
+
53
+ test('a selected project hides bar prefixes and persistent helper captions are absent', async () => {
54
+ render(<CalendarView request={async () => data()} tasks={[task('0')]} refreshKey="1" showProject={false} onOpenDetail={vi.fn()} />);
55
+ expect(await screen.findByRole('button', { name: 'Chat 0' })).toBeInTheDocument();
56
+ expect(screen.getByRole('button', { name: '↩ Chat 0' })).toBeInTheDocument();
57
+ expect(screen.queryByRole('button', { name: /Project ·/ })).not.toBeInTheDocument();
58
+ expect(screen.queryByText(/Recorded work ·|Selected Codex history only/)).not.toBeInTheDocument();
59
+ });
@@ -0,0 +1,91 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Task } from "../../ui/types";
3
+
4
+ export interface CalendarSegment { start: string; end: string; resumed: boolean; }
5
+ export interface CalendarData { month: string; timeZone: string; chats: { id: string; incomplete: boolean; segments: CalendarSegment[] }[]; }
6
+ export type CalendarRequest = (args: { month: string; timeZone: string }) => Promise<CalendarData>;
7
+ const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
8
+ const addDays = (key: string, days: number) => new Date(Date.parse(`${key}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
9
+ const statusLabels = { working: "Running", needs_input: "Waiting for review", completed: "Done", failed: "Failed", scheduled: "Scheduled", interrupted: "Interrupted", archived: "Archived" };
10
+ const projectHue = (task: Task) => [...(task.project.id || task.project.path || task.project.name)].reduce((hash, c) => (hash * 31 + c.charCodeAt(0)) % 360, 0);
11
+
12
+ export function CalendarView({ request, tasks, refreshKey, onOpenDetail, showProject = true }: { request: CalendarRequest; tasks: Task[]; refreshKey: string; showProject?: boolean; onOpenDetail: (task: Task) => void }) {
13
+ const [month, setMonth] = useState(() => dateKey(new Date()).slice(0, 7));
14
+ const [data, setData] = useState<CalendarData | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [retry, setRetry] = useState(0);
17
+ const [expanded, setExpanded] = useState<string[]>([]);
18
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
19
+ useEffect(() => {
20
+ let current = true;
21
+ setError(null);
22
+ void request({ month, timeZone }).then(value => { if (current) setData(value); })
23
+ .catch(cause => { if (current) { setData(null); setError(String(cause)); } });
24
+ return () => { current = false; };
25
+ }, [month, timeZone, request, refreshKey, retry]);
26
+ const currentMonth = dateKey(new Date()).slice(0, 7);
27
+ function navigate(delta: number) {
28
+ if (delta > 0 && month >= currentMonth) return;
29
+ const date = new Date(`${month}-01T12:00:00Z`);
30
+ date.setUTCMonth(date.getUTCMonth() + delta);
31
+ setMonth(date.toISOString().slice(0, 7)); setData(null); setExpanded([]);
32
+ }
33
+ const first = `${month}-01`;
34
+ const offset = (new Date(`${first}T12:00:00Z`).getUTCDay() + 6) % 7;
35
+ const gridStart = addDays(first, -offset);
36
+ const daysInMonth = new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0).getDate();
37
+ const weeks = Math.ceil((offset + daysInMonth) / 7);
38
+ const label = new Date(`${first}T12:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
39
+ const byId = new Map(tasks.map(task => [task.id, task]));
40
+ const chats = data?.month === month ? data.chats.filter(chat => byId.has(chat.id)) : [];
41
+ const bars = chats.flatMap(chat => chat.segments.map(segment => ({ ...segment, task: byId.get(chat.id)! })));
42
+ const today = dateKey(new Date());
43
+ return <section className="calendar-view" aria-label="Chat work calendar">
44
+ <nav className="calendar-navigation" aria-label="Month navigation">
45
+ <button aria-label="Previous month" onClick={() => navigate(-1)}>‹</button>
46
+ <h2 aria-live="polite">{label}</h2>
47
+ <button aria-label="Next month" disabled={month >= currentMonth} onClick={() => navigate(1)}>›</button>
48
+ </nav>
49
+ {error && <div role="alert">Calendar could not be loaded: {error} <button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
50
+ {!data && !error && <p role="status">Reading recorded work…</p>}
51
+ <div className="calendar-scroll" tabIndex={0} aria-label="Month calendar; scroll horizontally on narrow screens">
52
+ <div className="calendar-month">
53
+ <div className="calendar-weekdays">{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(day => <div key={day}>{day}</div>)}</div>
54
+ {Array.from({ length: weeks }, (_, week) => {
55
+ const start = addDays(gridStart, week * 7), end = addDays(start, 6);
56
+ const weekBars = bars.filter(bar => bar.start <= end && bar.end >= start).sort((a, b) => a.start.localeCompare(b.start) || b.end.localeCompare(a.end) || a.task.title.localeCompare(b.task.title) || a.task.id.localeCompare(b.task.id));
57
+ const lanes: string[] = [];
58
+ const placed = weekBars.map(bar => {
59
+ const left = bar.start < start ? start : bar.start, right = bar.end > end ? end : bar.end;
60
+ let lane = lanes.findIndex(last => last < left);
61
+ if (lane < 0) lane = lanes.length;
62
+ lanes[lane] = right;
63
+ return { ...bar, left, right, lane };
64
+ });
65
+ const open = expanded.includes(start);
66
+ const visible = placed.filter(bar => open || bar.lane < 3);
67
+ const hidden = placed.length - visible.length;
68
+ return <div className="calendar-week" key={start}>
69
+ <div className="calendar-week-grid" style={{ gridTemplateRows: `30px repeat(${Math.max(3, open ? lanes.length : Math.min(3, lanes.length))}, 28px)` }}>
70
+ <div className="calendar-day-backgrounds" aria-hidden>{Array.from({ length: 7 }, (_, col) => <div className={col > 4 ? "calendar-weekend" : ""} key={col} />)}</div>
71
+ {Array.from({ length: 7 }, (_, col) => {
72
+ const day = addDays(start, col);
73
+ return <div key={day} className={`calendar-day${day.slice(0, 7) !== month ? " calendar-outside" : ""}${day === today ? " calendar-today" : ""}`} style={{ gridColumn: col + 1, gridRow: 1 }}><time dateTime={day}>{Number(day.slice(-2))}</time></div>;
74
+ })}
75
+ {visible.map(bar => {
76
+ const col = Math.round((Date.parse(bar.left) - Date.parse(start)) / 86400000);
77
+ const span = Math.round((Date.parse(bar.right) - Date.parse(bar.left)) / 86400000) + 1;
78
+ const title = showProject ? `${bar.task.project.name} · ${bar.task.title}` : bar.task.title;
79
+ return <button key={`${bar.task.id}:${bar.start}`} className="calendar-bar" style={{ gridColumn: `${col + 1} / span ${span}`, gridRow: bar.lane + 2, borderColor: `hsl(${projectHue(bar.task)} 45% 55%)`, backgroundColor: `hsl(${projectHue(bar.task)} 45% 55% / .17)` }} title={`${title}\n${bar.start} – ${bar.end}${bar.resumed ? ' · Resumed' : ''}\nCurrent status: ${bar.task.status ? statusLabels[bar.task.status] : 'Unverified'}`} onClick={() => onOpenDetail(bar.task)}>
80
+ {bar.start < start ? "← " : bar.resumed ? "↩ " : ""}{title}{bar.end > end ? " →" : ""}
81
+ </button>;
82
+ })}
83
+ </div>
84
+ {(hidden > 0 || open) && <button className="calendar-more" onClick={() => setExpanded(value => open ? value.filter(item => item !== start) : [...value, start])}>{open ? "Show fewer" : `+${hidden} more this week`}</button>}
85
+ </div>;
86
+ })}
87
+ </div>
88
+ </div>
89
+ {data && !error && !bars.length && <p role="status">No recorded work matches this month and these filters.</p>}
90
+ </section>;
91
+ }
@@ -0,0 +1,124 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
3
+ import { UsageView, type UsageData } from './UsageView';
4
+ beforeEach(() => window.localStorage.clear());
5
+ afterEach(cleanup);
6
+ const data:UsageData={start:'2026-10-01',end:'2026-10-15',timeZone:'Asia/Singapore',samples:2,incomplete:false,totals:{tokens:200,costUsd:3,inputTokens:160,cachedInputTokens:40,outputTokens:40},days:[{date:'2026-10-15',tokens:200,costUsd:3,projects:{a:2,b:1}}],projects:[{id:'a',name:'Alpha',tokens:100,costUsd:2},{id:'b',name:'Beta',tokens:100,costUsd:1}]};
7
+ test('defaults to 30 days, changes range, isolates projects, and exposes exact day values',async()=>{
8
+ const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
9
+ await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
10
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
11
+ expect(screen.queryByText(/Custom/)).toBeNull();
12
+ fireEvent.click(screen.getByRole('button',{name:'7 days'}));
13
+ await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'7d',timeZone:expect.any(String)}));
14
+ await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
15
+ fireEvent.click(screen.getByRole('button',{name:'Alpha'}));
16
+ expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'})).toBeVisible();
17
+ fireEvent.click(screen.getByRole('button',{name:'All projects'}));
18
+ expect(screen.getByRole('img',{name:'Daily project cost bars: all projects'})).toBeVisible();
19
+ fireEvent.focus(screen.getByRole('button',{name:'Oct 15: $3.00, 200 tokens'}));
20
+ expect(screen.getByText('Oct 15 · 200 tokens · $3.00')).toBeVisible();
21
+ });
22
+ test('a late old response cannot replace the selected range and errors can retry',async()=>{
23
+ let first!:(value:UsageData)=>void;const request=vi.fn().mockImplementationOnce(()=>new Promise<UsageData>(resolve=>{first=resolve;})).mockRejectedValueOnce(new Error('Read failed')).mockResolvedValue(data);
24
+ render(<UsageView request={request}/>);fireEvent.click(screen.getByRole('button',{name:'90 days'}));
25
+ expect(await screen.findByRole('alert')).toHaveTextContent('Read failed');
26
+ first(data);await Promise.resolve();expect(screen.queryByRole('table')).toBeNull();
27
+ fireEvent.click(screen.getByRole('button',{name:'Retry'}));await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
28
+ });
29
+ test('missing coverage and empty data are distinct from a zero cost estimate',async()=>{
30
+ const request=vi.fn().mockResolvedValue({...data,samples:0,incomplete:true});render(<UsageView request={request}/>);
31
+ await screen.findByText(/No timestamped usage/);expect(screen.queryByText(/Incomplete coverage:/)).toBeNull();expect(screen.queryByRole('table')).toBeNull();
32
+ });
33
+ test('ranks only ten projects and keeps Other out of project identity',async()=>{
34
+ const projects=Array.from({length:12},(_,i)=>({id:String(i),name:`Project ${i}`,tokens:1,costUsd:12-i}));
35
+ const request=vi.fn().mockResolvedValue({...data,projects});render(<UsageView request={request}/>);
36
+ await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());expect(within(screen.getByRole('table')).getAllByRole('row')).toHaveLength(11);expect(screen.getByText('Other')).toBeVisible();
37
+ });
38
+
39
+ test('polls a pending history read and stops polling when unmounted',async()=>{
40
+ const request=vi.fn().mockResolvedValueOnce({loading:true}).mockResolvedValue(data);
41
+ const view=render(<UsageView request={request}/>);expect(await screen.findByRole('status')).toHaveTextContent('Reading saved usage');
42
+ await waitFor(()=>expect(screen.getByRole('table')).toBeVisible(),{timeout:2000});
43
+ expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String)});
44
+ view.unmount();
45
+ const pending=vi.fn().mockResolvedValue({loading:true});const second=render(<UsageView request={pending}/>);await waitFor(()=>expect(pending).toHaveBeenCalledTimes(1));second.unmount();
46
+ });
47
+
48
+ test('a hidden view does not start historical usage reads',async()=>{
49
+ const visibility=vi.spyOn(document,'visibilityState','get').mockReturnValue('hidden');
50
+ try {const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);expect(request).not.toHaveBeenCalled();visibility.mockReturnValue('visible');await waitFor(()=>expect(screen.getByRole('table')).toBeVisible(),{timeout:2000});expect(request).toHaveBeenCalledTimes(1);}
51
+ finally{visibility.mockRestore();}
52
+ });
53
+
54
+ test('Last month follows This month and range changes reuse history without forcing a scan',async()=>{
55
+ const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
56
+ await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
57
+ const buttons=within(screen.getByRole('group',{name:'Usage time range'})).getAllByRole('button');
58
+ expect(buttons.map(button=>button.textContent)).toEqual(['7 days','30 days','90 days','This month','Last month']);
59
+ fireEvent.click(screen.getByRole('button',{name:'Last month'}));await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'last-month',timeZone:expect.any(String)}));
60
+ fireEvent.click(screen.getByRole('button',{name:'This month'}));await waitFor(()=>expect(request).toHaveBeenCalledTimes(3));expect(request.mock.calls.every(([args])=>!args.force)).toBe(true);
61
+ });
62
+ test('Refresh forces one new read and shows measured progress while cached data stays visible',async()=>{
63
+ const request=vi.fn().mockResolvedValueOnce(data).mockResolvedValueOnce({...data,refreshing:true,progress:{completed:2,total:10}}).mockResolvedValue(data);
64
+ const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
65
+ view.rerender(<UsageView request={request} refreshKey={1}/>);
66
+ await screen.findByText('Updating saved usage… 2 of 10 chats');expect(screen.getByRole('progressbar',{name:'Usage scan progress'})).toHaveAttribute('value','2');expect(screen.getByRole('table')).toBeVisible();
67
+ expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),force:true});await waitFor(()=>expect(screen.queryByRole('progressbar')).toBeNull());
68
+ });
69
+ test('legend hover and focus highlight only matching project bars and reset on leave',async()=>{
70
+ const request=vi.fn().mockResolvedValue(data);const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
71
+ const alpha=screen.getByRole('button',{name:'Alpha'});fireEvent.mouseEnter(alpha);
72
+ expect(view.container.querySelector('[data-project="a"]')).toHaveAttribute('opacity','1');expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','0.2');
73
+ fireEvent.mouseLeave(alpha);expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','1');
74
+ fireEvent.focus(alpha);expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','0.2');fireEvent.blur(alpha);expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','1');
75
+ });
76
+
77
+ test('Rescan rebuilds explicitly and removes coverage and axis helper copy',async()=>{
78
+ const request=vi.fn().mockResolvedValue({...data,incomplete:true});render(<UsageView request={request}/>);
79
+ await screen.findByRole('table');
80
+ for(const text of [/Incomplete coverage/,/Known subtotal/,/Cached input is included/,/right axis/,/left axis/,/By estimated cost/,/Missing usage is not zero/]) expect(screen.queryByText(text)).toBeNull();
81
+ fireEvent.click(screen.getByRole('button',{name:'Rescan'}));
82
+ await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),rescan:true}));
83
+ });
84
+
85
+ test('only the current local day has dashed bars and token segment; a completed period stays solid',async()=>{
86
+ vi.useFakeTimers({toFake:['Date']});vi.setSystemTime(new Date('2026-10-15T17:00:00Z'));
87
+ try {
88
+ const days=[{...data.days[0],date:'2026-10-15'},{...data.days[0],date:'2026-10-16'}];
89
+ const request=vi.fn().mockResolvedValueOnce({...data,end:'2026-10-16',days}).mockResolvedValue({...data,start:'2026-09-01',end:'2026-09-30',days:[{...data.days[0],date:'2026-09-30'}]});
90
+ render(<UsageView request={request}/>);await screen.findByRole('table');
91
+ const combined=screen.getByRole('img',{name:'Daily estimated cost bars and token line'});
92
+ expect(combined.querySelectorAll('rect[stroke-dasharray]')).toHaveLength(1);
93
+ expect(combined.querySelectorAll('polyline[stroke-dasharray]')).toHaveLength(1);
94
+ expect(screen.getByRole('button',{name:/Oct 16: .*in progress/})).toBeVisible();
95
+ const projects=screen.getByRole('img',{name:'Daily project cost bars: all projects'});
96
+ expect(projects.querySelectorAll('rect[stroke-dasharray]')).toHaveLength(2);
97
+ fireEvent.click(screen.getByRole('button',{name:'Alpha'}));
98
+ expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'}).querySelectorAll('rect[stroke-dasharray]')).toHaveLength(1);
99
+ fireEvent.click(screen.getByRole('button',{name:'Last month'}));
100
+ await waitFor(()=>expect(screen.getByText(/Sep 1 – Sep 30/)).toBeVisible());
101
+ expect(screen.getByRole('img',{name:'Daily estimated cost bars and token line'}).querySelectorAll('[stroke-dasharray]')).toHaveLength(0);
102
+ expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'}).querySelectorAll('[stroke-dasharray]')).toHaveLength(0);
103
+ } finally {vi.useRealTimers();}
104
+ });
105
+
106
+ test('remembers the selected range after leaving and reopening Usage', async () => {
107
+ const request=vi.fn().mockResolvedValue(data);
108
+ const first=render(<UsageView request={request}/>);
109
+ await screen.findByRole('table');
110
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
111
+ fireEvent.click(screen.getByRole('button',{name:'90 days'}));
112
+ await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'90d',timeZone:expect.any(String)}));
113
+ first.unmount(); request.mockClear();
114
+ render(<UsageView request={request}/>);
115
+ await screen.findByRole('table');
116
+ expect(request).toHaveBeenCalledWith({range:'90d',timeZone:expect.any(String)});
117
+ expect(screen.getByRole('button',{name:'90 days'})).toHaveAttribute('aria-pressed','true');
118
+ });
119
+ test('invalid saved Usage ranges fall back to 30 days', async () => {
120
+ window.localStorage.setItem('taskchef.app.usage.range',JSON.stringify('invalid'));
121
+ const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
122
+ await screen.findByRole('table');
123
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
124
+ });
@@ -0,0 +1,129 @@
1
+ import { usePreference } from "./preferences";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ export interface UsageData {
5
+ updatedAt?: string; refreshing?: boolean; progress?: ScanProgress;
6
+ start: string; end: string; timeZone: string; samples: number; incomplete: boolean;
7
+ totals: { tokens: number; costUsd: number; inputTokens: number; cachedInputTokens: number; outputTokens: number };
8
+ days: { date: string; tokens: number; costUsd: number; projects: Record<string, number> }[];
9
+ projects: { id: string; name: string; costUsd: number; tokens: number }[];
10
+ }
11
+ interface ScanProgress { completed: number; total: number; }
12
+ export type UsageRange = "7d" | "30d" | "90d" | "mtd" | "last-month";
13
+ export const usageRequest = (range: UsageRange, force = false, rescan = false) => ({ range, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, ...(force ? { force: true } : {}), ...(rescan ? { rescan: true } : {}) });
14
+ export type UsageResponse = UsageData | { loading: true; progress?: ScanProgress };
15
+ const money = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: n > 0 && n < .01 ? 4 : 2 }).format(n);
16
+ const count = (n: number) => new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
17
+ const dateLabel = (value: string) => new Date(`${value}T12:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
18
+ const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#92979c"];
19
+ const left = 64, right = 794, top = 16, bottom = 166;
20
+
21
+ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnType<typeof usageRequest>) => Promise<UsageResponse>; refreshKey?: number }) {
22
+ const [range, setRange] = usePreference("usage.range");
23
+ const [data, setData] = useState<UsageData | null>(null);
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [loading, setLoading] = useState(true);
26
+ const [progress, setProgress] = useState<ScanProgress | null>(null);
27
+ const [highlighted, setHighlighted] = useState<string | null>(null);
28
+ const lastRequest = useRef({ refreshKey, retry: 0, rescan: 0 });
29
+ const [selected, setSelected] = useState<string | null>(null);
30
+ const [activeDay, setActiveDay] = useState<number | null>(null);
31
+ const [retry, setRetry] = useState(0);
32
+ const [rescan, setRescan] = useState(0);
33
+ useEffect(() => {
34
+ let current = true;
35
+ const force = refreshKey !== lastRequest.current.refreshKey || retry !== lastRequest.current.retry;
36
+ const rebuild = rescan !== lastRequest.current.rescan;
37
+ lastRequest.current = { refreshKey, retry, rescan };
38
+ setLoading(true); setError(null); setProgress(null); setActiveDay(null); setHighlighted(null);
39
+ if (!force && !rebuild) setData(null);
40
+ let timer: ReturnType<typeof setTimeout>;
41
+ async function load(force: boolean, rebuild = false) {
42
+ if (document.visibilityState === "hidden") { timer = setTimeout(() => { void load(force, rebuild); }, 1000); return; }
43
+ try {
44
+ const result = await request(usageRequest(range, force, rebuild));
45
+ if (!current) return;
46
+ setProgress(result.progress ?? null);
47
+ if ("loading" in result) timer = setTimeout(() => { void load(false); }, 250);
48
+ else {
49
+ setData(result); setLoading(false);
50
+ if (result.refreshing) timer = setTimeout(() => { void load(false); }, 250);
51
+ }
52
+ } catch (cause) { if (current) { setError(String(cause)); setLoading(false); } }
53
+ }
54
+ void load(force, rebuild);
55
+ return () => { current = false; clearTimeout(timer); };
56
+ }, [range, request, refreshKey, retry, rescan]);
57
+ const today = data ? new Intl.DateTimeFormat("en-CA", { timeZone: data.timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()) : "";
58
+ const unfinished = (date: string) => date === today;
59
+ const barStyle = (date: string, color: string) => unfinished(date) ? { fillOpacity: .35, stroke: color, strokeWidth: 1.5, strokeDasharray: "4 3" } : {};
60
+ const project = data?.projects.find(item => item.id === selected);
61
+ const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) < 5 ? data.projects.indexOf(project) : 5] }]
62
+ : [...data.projects.slice(0, 5).map((item, index) => ({ ...item, color: colors[index] })),
63
+ ...(data.projects.length > 5 ? [{ id: "__other", name: "Other", costUsd: 0, tokens: 0, color: colors[5] }] : [])]) : [];
64
+ const activeHighlight = series.some(item => item.id === highlighted) ? highlighted : null;
65
+ const dayWidth = data ? (right - left) / data.days.length : 1;
66
+ const maxCost = Math.max(...(data?.days.map(day => day.costUsd) ?? []), .01);
67
+ const maxTokens = Math.max(...(data?.days.map(day => day.tokens) ?? []), 1);
68
+ const projectCost = (day: UsageData["days"][number], id: string) => id === "__other"
69
+ ? data!.projects.slice(5).reduce((sum, item) => sum + (day.projects[item.id] ?? 0), 0) : day.projects[id] ?? 0;
70
+ const maxProjectCost = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectCost(day, item.id), 0)) ?? []), .01);
71
+ const x = (index: number) => left + (index + .5) * dayWidth;
72
+ const y = (value: number, max: number) => bottom - value / max * (bottom - top);
73
+ function grid(max: number, tokens = false) {
74
+ return [0, .5, 1].map(fraction => <g key={fraction}>
75
+ <line x1={left} x2={right} y1={y(fraction, 1)} y2={y(fraction, 1)} className="usage-grid" />
76
+ <text x={left - 10} y={y(fraction, 1) + 4} textAnchor="end">{tokens ? count(max * fraction) : money(max * fraction)}</text>
77
+ </g>);
78
+ }
79
+ function dates() {
80
+ return data!.days.map((day, index) => index === 0 || index === data!.days.length - 1 || index % Math.ceil(data!.days.length / 5) === 0
81
+ ? <text key={day.date} x={x(index)} y={bottom + 25} textAnchor="middle">{dateLabel(day.date)}</text> : null);
82
+ }
83
+ return <section className="usage-view" aria-label="Usage analytics">
84
+ <div className="usage-heading"><div><h2>Usage</h2><p>Tokens and estimated cost across your Codex work.</p></div><button disabled={loading || data?.refreshing} onClick={() => setRescan(value => value + 1)}>Rescan</button></div>
85
+ <div className="usage-ranges" role="group" aria-label="Usage time range">
86
+ {([ ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["mtd", "This month"], ["last-month", "Last month"] ] as const).map(([value, label]) => <button key={value} aria-pressed={range === value} onClick={() => setRange(value)}>{label}</button>)}
87
+ </div>
88
+ {(loading || data?.refreshing) && !error && <div className="usage-progress" role="status">
89
+ <p>{data ? "Updating saved usage…" : "Reading saved usage…"} {progress?.total ? `${progress.completed} of ${progress.total} chats` : "Preparing history…"}</p>
90
+ <progress aria-label="Usage scan progress" max={progress?.total || 1} value={progress?.total ? progress.completed : undefined} />
91
+ </div>}
92
+ {error && <div role="alert"><p>Usage could not be loaded: {error}</p><button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
93
+ {data && <>
94
+ <p className="usage-period">{dateLabel(data.start)} – {dateLabel(data.end)} · {data.timeZone} · Daily · Includes archived chats and subagent usage{data.updatedAt && <> · Updated {new Date(data.updatedAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>}</p>
95
+ {!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
96
+ <div className="usage-totals">
97
+ <article><h3>Recorded tokens</h3><strong>{count(data.totals.tokens)}</strong><p>Input {count(data.totals.inputTokens)} · Cached input {count(data.totals.cachedInputTokens)} · Output {count(data.totals.outputTokens)}</p></article>
98
+ <article><h3>Estimated cost</h3><strong>{money(data.totals.costUsd)}</strong><p>API-equivalent prices · USD</p></article>
99
+ </div>
100
+ <article className="usage-panel" aria-label="Tokens and cost over time"><h3>Tokens and cost over time</h3>
101
+ <div className="usage-legend"><span className="usage-token-key">━ Tokens</span><span><i style={{ background: colors[0] }} /> Estimated cost</span></div>
102
+ <div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label="Daily estimated cost bars and token line" className="usage-chart">
103
+ {grid(maxCost)}
104
+ {[0, .5, 1].map(fraction => <text className="usage-token-axis" key={fraction} x={right + 10} y={y(fraction, 1) + 4}>{count(maxTokens * fraction)}</text>)}
105
+ {data.days.map((day, index) => <rect key={day.date} x={x(index) - dayWidth * .3} y={y(day.costUsd, maxCost)} width={dayWidth * .6} height={bottom - y(day.costUsd, maxCost)} fill={colors[0]} {...barStyle(day.date, colors[0])}><title>{dateLabel(day.date)}{unfinished(day.date) ? " (in progress)" : ""}: {money(day.costUsd)}, {day.tokens.toLocaleString()} tokens</title></rect>)}
106
+ <polyline className="usage-token-line" points={data.days.map((day, index) => unfinished(day.date) ? null : `${x(index)},${y(day.tokens, maxTokens)}`).filter(Boolean).join(" ")} />
107
+ {data.days.map((day, index) => unfinished(day.date) && index > 0 ? <polyline key={day.date} className="usage-token-line" strokeDasharray="5 4" points={`${x(index - 1)},${y(data.days[index - 1].tokens, maxTokens)} ${x(index)},${y(day.tokens, maxTokens)}`} /> : null)}
108
+ {data.days.map((day, index) => <g key={day.date} tabIndex={0} role="button" aria-label={`${dateLabel(day.date)}: ${money(day.costUsd)}, ${day.tokens.toLocaleString()} tokens${unfinished(day.date) ? " (in progress)" : ""}`} onMouseEnter={() => setActiveDay(index)} onFocus={() => setActiveDay(index)} onClick={() => setActiveDay(index)} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setActiveDay(index); } }}><rect x={left + index * dayWidth} y={top} width={dayWidth} height={bottom - top} fill="transparent" /><circle cx={x(index)} cy={y(day.tokens, maxTokens)} r={data.days.length > 30 ? 0 : 2.5} className="usage-token-dot" /></g>)}
109
+ {dates()}
110
+ </svg></div>
111
+ <p className="usage-chart-detail" aria-live="polite">{activeDay === null ? "Hover or focus a day to inspect its usage." : `${dateLabel(data.days[activeDay].date)} · ${data.days[activeDay].tokens.toLocaleString()} tokens · ${money(data.days[activeDay].costUsd)}`}</p>
112
+ </article>
113
+ <article className="usage-panel" aria-label="Cost by project over time"><h3>Cost by project over time</h3>
114
+ <p>{project ? `${project.name} · ` : "Top 5 projects + Other · "}Select a project below to isolate its cost.</p>
115
+ <div className="usage-legend"><button aria-pressed={!project} onMouseEnter={() => setHighlighted(null)} onFocus={() => setHighlighted(null)} onClick={() => setSelected(null)}>All projects</button>{data.projects.slice(0, 5).map((item, index) => <button key={item.id} aria-pressed={project?.id === item.id} onMouseEnter={() => setHighlighted(item.id)} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted(item.id)} onBlur={() => setHighlighted(null)} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><i style={{ background: colors[index] }} />{item.name}</button>)}{data.projects.length > 5 && <span tabIndex={0} onMouseEnter={() => setHighlighted("__other")} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted("__other")} onBlur={() => setHighlighted(null)}><i style={{ background: colors[5] }} />Other</span>}</div>
116
+ <div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label={`Daily project cost bars: ${project?.name ?? "all projects"}`} className="usage-chart">
117
+ {grid(maxProjectCost)}
118
+ {data.days.map((day, index) => { let accumulated = 0; return <g key={day.date}>{series.map(item => { const cost = projectCost(day, item.id); const start = accumulated; accumulated += cost; return <rect key={item.id} x={x(index) - dayWidth * .3} y={y(accumulated, maxProjectCost)} width={dayWidth * .6} height={(cost / maxProjectCost) * (bottom - top)} fill={item.color} {...barStyle(day.date, item.color)} data-project={item.id} opacity={activeHighlight && activeHighlight !== item.id ? .2 : 1}><title>{dateLabel(day.date)}{unfinished(day.date) ? " (in progress)" : ""} · {item.name}: {money(accumulated - start)}</title></rect>; })}</g>; })}
119
+ {dates()}
120
+ </svg></div>
121
+ </article>
122
+ <article className="usage-panel" aria-label="Top 10 projects"><h3>Top 10 projects</h3>
123
+ <div className="usage-ranking"><table><thead><tr><th scope="col">Project</th><th scope="col">Estimated cost</th><th scope="col">Share</th></tr></thead><tbody>{data.projects.slice(0, 10).map((item, index) => <tr key={item.id}><th scope="row"><button aria-pressed={project?.id === item.id} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><span>{index + 1}. {item.name}</span><span className="usage-rank-track" aria-hidden><span style={{ width: `${data.projects[0].costUsd ? item.costUsd / data.projects[0].costUsd * 100 : 0}%`, background: colors[index < 5 ? index : 5] }} /></span></button></th><td>{money(item.costUsd)}</td><td>{data.totals.costUsd ? (item.costUsd / data.totals.costUsd * 100).toFixed(1) : "0.0"}%</td></tr>)}</tbody></table></div>
124
+ </article>
125
+ </>}
126
+ <p className="usage-footnote">Cost is estimated at API-equivalent prices, not your subscription bill or remaining allowance.</p>
127
+ </>}
128
+ </section>;
129
+ }
@@ -118,13 +118,14 @@ test("lifecycle events log readable single strings without suppressing repeats",
118
118
  expect(warn).not.toHaveBeenCalled();
119
119
  });
120
120
 
121
- test("shows only Board, ignores a saved List choice, and filters project and date", async () => {
121
+ test("defaults to Board, ignores a saved List choice, and filters project and date", async () => {
122
122
  window.localStorage.setItem("taskchef.app.view", "list");
123
123
  tasks = [task("one", "working", new Date().toISOString(), "Alpha"), task("two", "completed", "2026-01-01T00:00:00Z", "Alpha"), task("three", "interrupted", new Date().toISOString(), "Beta")];
124
124
  mount();
125
125
  await screen.findByRole("button", { name: "Task one" });
126
126
  expect(screen.getByRole("region", { name: "Task board" })).toBeVisible();
127
- expect(screen.queryByRole("radiogroup", { name: "View" })).not.toBeInTheDocument();
127
+ expect(screen.getByRole("radio", { name: "Board" })).toHaveAttribute("aria-checked", "true");
128
+ expect(screen.queryByRole("radio", { name: "List" })).not.toBeInTheDocument();
128
129
  expect(screen.queryByRole("radiogroup", { name: "Status" })).not.toBeInTheDocument();
129
130
  fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "/Alpha" } });
130
131
  expect(screen.queryByRole("button", { name: "Task three" })).not.toBeInTheDocument();
@@ -667,3 +668,137 @@ test("Pinned toggle combines with filters and updates after pin changes on refre
667
668
  expect(toggle).toHaveAttribute("aria-pressed", "false");
668
669
  expect(screen.getByRole("button", { name: "Task two" })).toBeVisible();
669
670
  });
671
+
672
+
673
+ test("Usage opens on demand and returns to Board without keeping its filters visible", async () => {
674
+ const original = server.call.getMockImplementation()!;
675
+ server.call.mockImplementation((input) => input.name === "taskchef_app_usage" ? Promise.resolve({structuredContent:{start:"2026-10-01",end:"2026-10-15",timeZone:"UTC",days:[],projects:[],totals:{},samples:0,incomplete:false}}) : original(input));
676
+ mount();await screen.findByRole("button",{name:"Task one"});
677
+ expect(server.call.mock.calls.some(([input])=>input.name === "taskchef_app_usage")).toBe(false);
678
+ fireEvent.click(screen.getByRole("radio",{name:"Usage"}));
679
+ await screen.findByText("No timestamped usage is available for this period.");
680
+ expect(screen.queryByRole("combobox",{name:"Project"})).toBeNull();
681
+ fireEvent.click(screen.getByRole("radio",{name:"Board"}));
682
+ expect(screen.getByRole("region",{name:"Task board"})).toBeVisible();
683
+ });
684
+
685
+ test("Calendar replaces the update window and opens matching chat details", async () => {
686
+ tasks = [task('one', 'needs_input', '2020-01-01T00:00:00Z')];
687
+ details = new Map(tasks.map(item => [item.id, item]));
688
+ const base = server.call.getMockImplementation()!;
689
+ server.call.mockImplementation(input => {
690
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: true }] }] } });
691
+ return base(input);
692
+ });
693
+ mount();
694
+ await screen.findByRole('radio', { name: 'Calendar' });
695
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
696
+ expect(screen.queryByLabelText('Updated')).not.toBeInTheDocument();
697
+ fireEvent.click(await screen.findByRole('button', { name: /Example · Task one/ }));
698
+ expect(await screen.findByRole('heading', { name: 'Task one' })).toBeInTheDocument();
699
+ });
700
+
701
+ test('Calendar scheduled toggle starts on, includes running schedules, and never filters Board', async () => {
702
+ tasks = [{ ...task('one', 'working'), scheduled: true }, task('two', 'needs_input')];
703
+ const base = server.call.getMockImplementation()!;
704
+ server.call.mockImplementation(input => {
705
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: tasks.map(item => ({ id: item.id, incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] })) } });
706
+ return base(input);
707
+ });
708
+ mount();
709
+ await screen.findByRole('button', { name: 'Task one' });
710
+ expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument();
711
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
712
+ expect(await screen.findByRole('button', { name: 'Example · Task one' })).toBeInTheDocument();
713
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'true');
714
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
715
+ expect(screen.queryByRole('button', { name: 'Example · Task one' })).not.toBeInTheDocument();
716
+ expect(screen.getByRole('button', { name: 'Example · Task two' })).toBeInTheDocument();
717
+ fireEvent.change(screen.getByRole('combobox', { name: 'Project' }), { target: { value: '/Example' } });
718
+ expect(screen.getByRole('button', { name: 'Task two' })).toBeInTheDocument();
719
+ expect(screen.queryByRole('button', { name: 'Example · Task two' })).not.toBeInTheDocument();
720
+ fireEvent.click(screen.getByRole('radio', { name: 'Board' }));
721
+ expect(screen.getByRole('button', { name: 'Task one' })).toBeInTheDocument();
722
+ expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument();
723
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
724
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'false');
725
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
726
+ expect(await screen.findByRole('button', { name: 'Task one' })).toBeInTheDocument();
727
+ });
728
+
729
+ test('Calendar skips unchanged polls and rereads on data changes, explicit refresh, and date rollover', async () => {
730
+ const interval = vi.spyOn(window, 'setInterval');
731
+ let revision = 1, cacheHit = true;
732
+ const base = server.call.getMockImplementation()!;
733
+ server.call.mockImplementation(input => {
734
+ if (input.name === 'taskchef_app_snapshot') return Promise.resolve({ structuredContent: { snapshot: { tasks, projects: [], healthy: true, revision, scan: { cacheHit } }, settings: visibility } });
735
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] }] } });
736
+ return base(input);
737
+ });
738
+ const calendarReads = () => server.call.mock.calls.filter(([input]) => input.name === 'taskchef_app_calendar').length;
739
+ mount();
740
+ await screen.findByRole('button', { name: 'Task one' });
741
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
742
+ await screen.findByRole('button', { name: 'Example · Task one' });
743
+ expect(calendarReads()).toBe(1);
744
+ const poll = interval.mock.calls.find(([, delay]) => delay === 5000)![0] as () => void;
745
+ await act(async () => { poll(); });
746
+ expect(calendarReads()).toBe(1);
747
+ cacheHit = false;
748
+ await act(async () => { poll(); });
749
+ expect(calendarReads()).toBe(2);
750
+ cacheHit = true; revision += 1;
751
+ await act(async () => { poll(); });
752
+ expect(calendarReads()).toBe(3);
753
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); });
754
+ expect(calendarReads()).toBe(4);
755
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 86400000);
756
+ await act(async () => { poll(); });
757
+ expect(calendarReads()).toBe(5);
758
+ });
759
+
760
+ test('shared filters and the Calendar schedule preference survive reopening', async () => {
761
+ tasks = [{ ...task('one', 'working'), scheduled: true, observed: { pinned: true, archive: false, lastTurnEvent: 'inProgress', lastTurnEventAt: null, recentFileActivity: false } }];
762
+ const base = server.call.getMockImplementation()!;
763
+ server.call.mockImplementation(input => {
764
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] }] } });
765
+ return base(input);
766
+ });
767
+ const first = render(<TaskChefApp />);
768
+ await screen.findByRole('button', { name: 'Task one' });
769
+ fireEvent.change(screen.getByRole('combobox', { name: 'Project' }), { target: { value: '/Example' } });
770
+ fireEvent.change(screen.getByLabelText('Search Chats'), { target: { value: 'one' } });
771
+ fireEvent.change(screen.getByRole('combobox', { name: 'Updated' }), { target: { value: '7d' } });
772
+ fireEvent.click(screen.getByRole('button', { name: 'Pinned' }));
773
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
774
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
775
+ first.unmount();
776
+ // Loading must not clear the saved project before inventory arrives.
777
+ let load!: (value: unknown) => void;
778
+ const active = server.call.getMockImplementation()!;
779
+ server.call.mockImplementationOnce(input => input.name === 'taskchef_app_snapshot' ? new Promise(resolve => { load = resolve; }) : active(input));
780
+ mount();
781
+ expect(window.localStorage.getItem('taskchef.app.filter.project')).toBe(JSON.stringify('/Example'));
782
+ expect(screen.getByLabelText('Search Chats')).toHaveValue('one');
783
+ expect(screen.getByRole('combobox', { name: 'Updated' })).toHaveValue('7d');
784
+ expect(screen.getByRole('button', { name: 'Pinned' })).toHaveAttribute('aria-pressed', 'true');
785
+ await waitFor(() => expect(load).toBeTypeOf('function'));
786
+ await act(async () => { load({ structuredContent: { settings: visibility, snapshot: { tasks, projects: [], healthy: true } } }); });
787
+ expect(screen.getByRole('combobox', { name: 'Project' })).toHaveValue('/Example');
788
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
789
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'false');
790
+ expect(await screen.findByText(/No recorded work matches/)).toBeInTheDocument();
791
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
792
+ expect(await screen.findByRole('button', { name: 'Task one' })).toBeInTheDocument();
793
+ });
794
+
795
+ test('unavailable preference storage leaves filters usable', async () => {
796
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Unavailable'); });
797
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Unavailable'); });
798
+ mount();
799
+ await screen.findByRole('button', { name: 'Task one' });
800
+ fireEvent.change(screen.getByLabelText('Search Chats'), { target: { value: 'missing' } });
801
+ expect(screen.queryByRole('button', { name: 'Task one' })).not.toBeInTheDocument();
802
+ fireEvent.click(screen.getByRole('radio', { name: 'Usage' }));
803
+ expect(await screen.findByRole('button', { name: '30 days' })).toHaveAttribute('aria-pressed','true');
804
+ });