taskchef 9.3.0 → 9.4.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.
- package/.codex-plugin/plugin.json +1 -1
- package/docs/mcp-app.md +47 -4
- package/package.json +1 -1
- package/src/codex-rollout.js +3 -3
- package/src/codex-session-scanner.js +39 -4
- package/src/codex-session-usage.js +54 -20
- package/src/mcp-app/dist/index.html +110 -110
- package/src/mcp-app/react/UsageView.test.tsx +103 -0
- package/src/mcp-app/react/UsageView.tsx +128 -0
- package/src/mcp-app/react/main.test.tsx +16 -2
- package/src/mcp-app/react/main.tsx +11 -4
- package/src/mcp-app/react/styles.css +54 -7
- package/src/mcp-app.js +37 -0
- package/src/usage-analytics.js +47 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import { afterEach, expect, test, vi } from 'vitest';
|
|
3
|
+
import { UsageView, type UsageData } from './UsageView';
|
|
4
|
+
afterEach(cleanup);
|
|
5
|
+
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}]};
|
|
6
|
+
test('defaults to This month, changes range, isolates projects, and exposes exact day values',async()=>{
|
|
7
|
+
const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
|
|
8
|
+
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
9
|
+
expect(request).toHaveBeenCalledWith({range:'mtd',timeZone:expect.any(String)});
|
|
10
|
+
expect(screen.queryByText(/Custom/)).toBeNull();
|
|
11
|
+
fireEvent.click(screen.getByRole('button',{name:'7 days'}));
|
|
12
|
+
await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'7d',timeZone:expect.any(String)}));
|
|
13
|
+
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
14
|
+
fireEvent.click(screen.getByRole('button',{name:'Alpha'}));
|
|
15
|
+
expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'})).toBeVisible();
|
|
16
|
+
fireEvent.click(screen.getByRole('button',{name:'All projects'}));
|
|
17
|
+
expect(screen.getByRole('img',{name:'Daily project cost bars: all projects'})).toBeVisible();
|
|
18
|
+
fireEvent.focus(screen.getByRole('button',{name:'Oct 15: $3.00, 200 tokens'}));
|
|
19
|
+
expect(screen.getByText('Oct 15 · 200 tokens · $3.00')).toBeVisible();
|
|
20
|
+
});
|
|
21
|
+
test('a late old response cannot replace the selected range and errors can retry',async()=>{
|
|
22
|
+
let first!:(value:UsageData)=>void;const request=vi.fn().mockImplementationOnce(()=>new Promise<UsageData>(resolve=>{first=resolve;})).mockRejectedValueOnce(new Error('Read failed')).mockResolvedValue(data);
|
|
23
|
+
render(<UsageView request={request}/>);fireEvent.click(screen.getByRole('button',{name:'90 days'}));
|
|
24
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Read failed');
|
|
25
|
+
first(data);await Promise.resolve();expect(screen.queryByRole('table')).toBeNull();
|
|
26
|
+
fireEvent.click(screen.getByRole('button',{name:'Retry'}));await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
27
|
+
});
|
|
28
|
+
test('missing coverage and empty data are distinct from a zero cost estimate',async()=>{
|
|
29
|
+
const request=vi.fn().mockResolvedValue({...data,samples:0,incomplete:true});render(<UsageView request={request}/>);
|
|
30
|
+
await screen.findByText(/No timestamped usage/);expect(screen.queryByText(/Incomplete coverage:/)).toBeNull();expect(screen.queryByRole('table')).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
test('ranks only ten projects and keeps Other out of project identity',async()=>{
|
|
33
|
+
const projects=Array.from({length:12},(_,i)=>({id:String(i),name:`Project ${i}`,tokens:1,costUsd:12-i}));
|
|
34
|
+
const request=vi.fn().mockResolvedValue({...data,projects});render(<UsageView request={request}/>);
|
|
35
|
+
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());expect(within(screen.getByRole('table')).getAllByRole('row')).toHaveLength(11);expect(screen.getByText('Other')).toBeVisible();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('polls a pending history read and stops polling when unmounted',async()=>{
|
|
39
|
+
const request=vi.fn().mockResolvedValueOnce({loading:true}).mockResolvedValue(data);
|
|
40
|
+
const view=render(<UsageView request={request}/>);expect(await screen.findByRole('status')).toHaveTextContent('Reading saved usage');
|
|
41
|
+
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible(),{timeout:2000});
|
|
42
|
+
expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String)});
|
|
43
|
+
view.unmount();
|
|
44
|
+
const pending=vi.fn().mockResolvedValue({loading:true});const second=render(<UsageView request={pending}/>);await waitFor(()=>expect(pending).toHaveBeenCalledTimes(1));second.unmount();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a hidden view does not start historical usage reads',async()=>{
|
|
48
|
+
const visibility=vi.spyOn(document,'visibilityState','get').mockReturnValue('hidden');
|
|
49
|
+
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);}
|
|
50
|
+
finally{visibility.mockRestore();}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('Last month follows This month and range changes reuse history without forcing a scan',async()=>{
|
|
54
|
+
const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
|
|
55
|
+
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
56
|
+
const buttons=within(screen.getByRole('group',{name:'Usage time range'})).getAllByRole('button');
|
|
57
|
+
expect(buttons.map(button=>button.textContent)).toEqual(['7 days','30 days','90 days','This month','Last month']);
|
|
58
|
+
fireEvent.click(screen.getByRole('button',{name:'Last month'}));await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'last-month',timeZone:expect.any(String)}));
|
|
59
|
+
fireEvent.click(screen.getByRole('button',{name:'This month'}));await waitFor(()=>expect(request).toHaveBeenCalledTimes(3));expect(request.mock.calls.every(([args])=>!args.force)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
test('Refresh forces one new read and shows measured progress while cached data stays visible',async()=>{
|
|
62
|
+
const request=vi.fn().mockResolvedValueOnce(data).mockResolvedValueOnce({...data,refreshing:true,progress:{completed:2,total:10}}).mockResolvedValue(data);
|
|
63
|
+
const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
64
|
+
view.rerender(<UsageView request={request} refreshKey={1}/>);
|
|
65
|
+
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();
|
|
66
|
+
expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String),force:true});await waitFor(()=>expect(screen.queryByRole('progressbar')).toBeNull());
|
|
67
|
+
});
|
|
68
|
+
test('legend hover and focus highlight only matching project bars and reset on leave',async()=>{
|
|
69
|
+
const request=vi.fn().mockResolvedValue(data);const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
70
|
+
const alpha=screen.getByRole('button',{name:'Alpha'});fireEvent.mouseEnter(alpha);
|
|
71
|
+
expect(view.container.querySelector('[data-project="a"]')).toHaveAttribute('opacity','1');expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','0.2');
|
|
72
|
+
fireEvent.mouseLeave(alpha);expect(view.container.querySelector('[data-project="b"]')).toHaveAttribute('opacity','1');
|
|
73
|
+
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');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('Rescan rebuilds explicitly and removes coverage and axis helper copy',async()=>{
|
|
77
|
+
const request=vi.fn().mockResolvedValue({...data,incomplete:true});render(<UsageView request={request}/>);
|
|
78
|
+
await screen.findByRole('table');
|
|
79
|
+
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();
|
|
80
|
+
fireEvent.click(screen.getByRole('button',{name:'Rescan'}));
|
|
81
|
+
await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String),rescan:true}));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('only the current local day has dashed bars and token segment; a completed period stays solid',async()=>{
|
|
85
|
+
vi.useFakeTimers({toFake:['Date']});vi.setSystemTime(new Date('2026-10-15T17:00:00Z'));
|
|
86
|
+
try {
|
|
87
|
+
const days=[{...data.days[0],date:'2026-10-15'},{...data.days[0],date:'2026-10-16'}];
|
|
88
|
+
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'}]});
|
|
89
|
+
render(<UsageView request={request}/>);await screen.findByRole('table');
|
|
90
|
+
const combined=screen.getByRole('img',{name:'Daily estimated cost bars and token line'});
|
|
91
|
+
expect(combined.querySelectorAll('rect[stroke-dasharray]')).toHaveLength(1);
|
|
92
|
+
expect(combined.querySelectorAll('polyline[stroke-dasharray]')).toHaveLength(1);
|
|
93
|
+
expect(screen.getByRole('button',{name:/Oct 16: .*in progress/})).toBeVisible();
|
|
94
|
+
const projects=screen.getByRole('img',{name:'Daily project cost bars: all projects'});
|
|
95
|
+
expect(projects.querySelectorAll('rect[stroke-dasharray]')).toHaveLength(2);
|
|
96
|
+
fireEvent.click(screen.getByRole('button',{name:'Alpha'}));
|
|
97
|
+
expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'}).querySelectorAll('rect[stroke-dasharray]')).toHaveLength(1);
|
|
98
|
+
fireEvent.click(screen.getByRole('button',{name:'Last month'}));
|
|
99
|
+
await waitFor(()=>expect(screen.getByText(/Sep 1 – Sep 30/)).toBeVisible());
|
|
100
|
+
expect(screen.getByRole('img',{name:'Daily estimated cost bars and token line'}).querySelectorAll('[stroke-dasharray]')).toHaveLength(0);
|
|
101
|
+
expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'}).querySelectorAll('[stroke-dasharray]')).toHaveLength(0);
|
|
102
|
+
} finally {vi.useRealTimers();}
|
|
103
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UsageData {
|
|
4
|
+
updatedAt?: string; refreshing?: boolean; progress?: ScanProgress;
|
|
5
|
+
start: string; end: string; timeZone: string; samples: number; incomplete: boolean;
|
|
6
|
+
totals: { tokens: number; costUsd: number; inputTokens: number; cachedInputTokens: number; outputTokens: number };
|
|
7
|
+
days: { date: string; tokens: number; costUsd: number; projects: Record<string, number> }[];
|
|
8
|
+
projects: { id: string; name: string; costUsd: number; tokens: number }[];
|
|
9
|
+
}
|
|
10
|
+
interface ScanProgress { completed: number; total: number; }
|
|
11
|
+
export type UsageRange = "7d" | "30d" | "90d" | "mtd" | "last-month";
|
|
12
|
+
export const usageRequest = (range: UsageRange, force = false, rescan = false) => ({ range, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, ...(force ? { force: true } : {}), ...(rescan ? { rescan: true } : {}) });
|
|
13
|
+
export type UsageResponse = UsageData | { loading: true; progress?: ScanProgress };
|
|
14
|
+
const money = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: n > 0 && n < .01 ? 4 : 2 }).format(n);
|
|
15
|
+
const count = (n: number) => new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
|
16
|
+
const dateLabel = (value: string) => new Date(`${value}T12:00:00Z`).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
|
|
17
|
+
const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#92979c"];
|
|
18
|
+
const left = 64, right = 794, top = 16, bottom = 166;
|
|
19
|
+
|
|
20
|
+
export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnType<typeof usageRequest>) => Promise<UsageResponse>; refreshKey?: number }) {
|
|
21
|
+
const [range, setRange] = useState<UsageRange>("mtd");
|
|
22
|
+
const [data, setData] = useState<UsageData | null>(null);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [progress, setProgress] = useState<ScanProgress | null>(null);
|
|
26
|
+
const [highlighted, setHighlighted] = useState<string | null>(null);
|
|
27
|
+
const lastRequest = useRef({ refreshKey, retry: 0, rescan: 0 });
|
|
28
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
29
|
+
const [activeDay, setActiveDay] = useState<number | null>(null);
|
|
30
|
+
const [retry, setRetry] = useState(0);
|
|
31
|
+
const [rescan, setRescan] = useState(0);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let current = true;
|
|
34
|
+
const force = refreshKey !== lastRequest.current.refreshKey || retry !== lastRequest.current.retry;
|
|
35
|
+
const rebuild = rescan !== lastRequest.current.rescan;
|
|
36
|
+
lastRequest.current = { refreshKey, retry, rescan };
|
|
37
|
+
setLoading(true); setError(null); setProgress(null); setActiveDay(null); setHighlighted(null);
|
|
38
|
+
if (!force && !rebuild) setData(null);
|
|
39
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
40
|
+
async function load(force: boolean, rebuild = false) {
|
|
41
|
+
if (document.visibilityState === "hidden") { timer = setTimeout(() => { void load(force, rebuild); }, 1000); return; }
|
|
42
|
+
try {
|
|
43
|
+
const result = await request(usageRequest(range, force, rebuild));
|
|
44
|
+
if (!current) return;
|
|
45
|
+
setProgress(result.progress ?? null);
|
|
46
|
+
if ("loading" in result) timer = setTimeout(() => { void load(false); }, 250);
|
|
47
|
+
else {
|
|
48
|
+
setData(result); setLoading(false);
|
|
49
|
+
if (result.refreshing) timer = setTimeout(() => { void load(false); }, 250);
|
|
50
|
+
}
|
|
51
|
+
} catch (cause) { if (current) { setError(String(cause)); setLoading(false); } }
|
|
52
|
+
}
|
|
53
|
+
void load(force, rebuild);
|
|
54
|
+
return () => { current = false; clearTimeout(timer); };
|
|
55
|
+
}, [range, request, refreshKey, retry, rescan]);
|
|
56
|
+
const today = data ? new Intl.DateTimeFormat("en-CA", { timeZone: data.timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()) : "";
|
|
57
|
+
const unfinished = (date: string) => date === today;
|
|
58
|
+
const barStyle = (date: string, color: string) => unfinished(date) ? { fillOpacity: .35, stroke: color, strokeWidth: 1.5, strokeDasharray: "4 3" } : {};
|
|
59
|
+
const project = data?.projects.find(item => item.id === selected);
|
|
60
|
+
const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) < 5 ? data.projects.indexOf(project) : 5] }]
|
|
61
|
+
: [...data.projects.slice(0, 5).map((item, index) => ({ ...item, color: colors[index] })),
|
|
62
|
+
...(data.projects.length > 5 ? [{ id: "__other", name: "Other", costUsd: 0, tokens: 0, color: colors[5] }] : [])]) : [];
|
|
63
|
+
const activeHighlight = series.some(item => item.id === highlighted) ? highlighted : null;
|
|
64
|
+
const dayWidth = data ? (right - left) / data.days.length : 1;
|
|
65
|
+
const maxCost = Math.max(...(data?.days.map(day => day.costUsd) ?? []), .01);
|
|
66
|
+
const maxTokens = Math.max(...(data?.days.map(day => day.tokens) ?? []), 1);
|
|
67
|
+
const projectCost = (day: UsageData["days"][number], id: string) => id === "__other"
|
|
68
|
+
? data!.projects.slice(5).reduce((sum, item) => sum + (day.projects[item.id] ?? 0), 0) : day.projects[id] ?? 0;
|
|
69
|
+
const maxProjectCost = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectCost(day, item.id), 0)) ?? []), .01);
|
|
70
|
+
const x = (index: number) => left + (index + .5) * dayWidth;
|
|
71
|
+
const y = (value: number, max: number) => bottom - value / max * (bottom - top);
|
|
72
|
+
function grid(max: number, tokens = false) {
|
|
73
|
+
return [0, .5, 1].map(fraction => <g key={fraction}>
|
|
74
|
+
<line x1={left} x2={right} y1={y(fraction, 1)} y2={y(fraction, 1)} className="usage-grid" />
|
|
75
|
+
<text x={left - 10} y={y(fraction, 1) + 4} textAnchor="end">{tokens ? count(max * fraction) : money(max * fraction)}</text>
|
|
76
|
+
</g>);
|
|
77
|
+
}
|
|
78
|
+
function dates() {
|
|
79
|
+
return data!.days.map((day, index) => index === 0 || index === data!.days.length - 1 || index % Math.ceil(data!.days.length / 5) === 0
|
|
80
|
+
? <text key={day.date} x={x(index)} y={bottom + 25} textAnchor="middle">{dateLabel(day.date)}</text> : null);
|
|
81
|
+
}
|
|
82
|
+
return <section className="usage-view" aria-label="Usage analytics">
|
|
83
|
+
<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>
|
|
84
|
+
<div className="usage-ranges" role="group" aria-label="Usage time range">
|
|
85
|
+
{([ ["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>)}
|
|
86
|
+
</div>
|
|
87
|
+
{(loading || data?.refreshing) && !error && <div className="usage-progress" role="status">
|
|
88
|
+
<p>{data ? "Updating saved usage…" : "Reading saved usage…"} {progress?.total ? `${progress.completed} of ${progress.total} chats` : "Preparing history…"}</p>
|
|
89
|
+
<progress aria-label="Usage scan progress" max={progress?.total || 1} value={progress?.total ? progress.completed : undefined} />
|
|
90
|
+
</div>}
|
|
91
|
+
{error && <div role="alert"><p>Usage could not be loaded: {error}</p><button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
|
|
92
|
+
{data && <>
|
|
93
|
+
<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>
|
|
94
|
+
{!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
|
|
95
|
+
<div className="usage-totals">
|
|
96
|
+
<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>
|
|
97
|
+
<article><h3>Estimated cost</h3><strong>{money(data.totals.costUsd)}</strong><p>API-equivalent prices · USD</p></article>
|
|
98
|
+
</div>
|
|
99
|
+
<article className="usage-panel" aria-label="Tokens and cost over time"><h3>Tokens and cost over time</h3>
|
|
100
|
+
<div className="usage-legend"><span className="usage-token-key">━ Tokens</span><span><i style={{ background: colors[0] }} /> Estimated cost</span></div>
|
|
101
|
+
<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">
|
|
102
|
+
{grid(maxCost)}
|
|
103
|
+
{[0, .5, 1].map(fraction => <text className="usage-token-axis" key={fraction} x={right + 10} y={y(fraction, 1) + 4}>{count(maxTokens * fraction)}</text>)}
|
|
104
|
+
{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>)}
|
|
105
|
+
<polyline className="usage-token-line" points={data.days.map((day, index) => unfinished(day.date) ? null : `${x(index)},${y(day.tokens, maxTokens)}`).filter(Boolean).join(" ")} />
|
|
106
|
+
{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)}
|
|
107
|
+
{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>)}
|
|
108
|
+
{dates()}
|
|
109
|
+
</svg></div>
|
|
110
|
+
<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>
|
|
111
|
+
</article>
|
|
112
|
+
<article className="usage-panel" aria-label="Cost by project over time"><h3>Cost by project over time</h3>
|
|
113
|
+
<p>{project ? `${project.name} · ` : "Top 5 projects + Other · "}Select a project below to isolate its cost.</p>
|
|
114
|
+
<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>
|
|
115
|
+
<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">
|
|
116
|
+
{grid(maxProjectCost)}
|
|
117
|
+
{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>; })}
|
|
118
|
+
{dates()}
|
|
119
|
+
</svg></div>
|
|
120
|
+
</article>
|
|
121
|
+
<article className="usage-panel" aria-label="Top 10 projects"><h3>Top 10 projects</h3>
|
|
122
|
+
<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>
|
|
123
|
+
</article>
|
|
124
|
+
</>}
|
|
125
|
+
<p className="usage-footnote">Cost is estimated at API-equivalent prices, not your subscription bill or remaining allowance.</p>
|
|
126
|
+
</>}
|
|
127
|
+
</section>;
|
|
128
|
+
}
|
|
@@ -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("
|
|
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.
|
|
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,16 @@ 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
|
+
});
|
|
@@ -14,6 +14,7 @@ import { NextNotificationCenter, type NextNotification, type NextNotificationSta
|
|
|
14
14
|
import { GitHubConnection, type GitHubAuth } from "./GitHubConnection";
|
|
15
15
|
import { AppearancePicker } from "./AppearancePicker";
|
|
16
16
|
import { savedVisualTheme, themeVariables, visualTheme, VISUAL_THEME_KEY } from "./themes";
|
|
17
|
+
import { UsageView, type UsageResponse, type usageRequest } from "./UsageView";
|
|
17
18
|
import { ProjectPicker } from "./ProjectPicker";
|
|
18
19
|
import brandIcon from "../../../assets/taskchef-dark.svg";
|
|
19
20
|
import "@mantine/core/styles.css";
|
|
@@ -40,6 +41,8 @@ async function call<T>(name: string, args: Record<string, unknown> = {}): Promis
|
|
|
40
41
|
return result.structuredContent as T;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const readUsage = (args: ReturnType<typeof usageRequest>) => call<UsageResponse>("taskchef_app_usage", args);
|
|
45
|
+
|
|
43
46
|
const THEME_KEY = "taskchef.app.theme";
|
|
44
47
|
type ThemeChoice = "dark" | "light" | "system";
|
|
45
48
|
function initialTheme(): ThemeChoice {
|
|
@@ -159,8 +162,9 @@ export function TaskChefApp() {
|
|
|
159
162
|
const [search, setSearch] = useState("");
|
|
160
163
|
const [date, setDate] = useState("all");
|
|
161
164
|
const [status, setStatus] = useState("");
|
|
162
|
-
// Keep the list
|
|
163
|
-
const [view, setView] = useState<"board" | "list">("board");
|
|
165
|
+
// Keep the legacy list hidden; Board and Usage share the native shell.
|
|
166
|
+
const [view, setView] = useState<"board" | "list" | "usage">("board");
|
|
167
|
+
const [usageRefreshKey, setUsageRefreshKey] = useState(0);
|
|
164
168
|
const [completedLimit, setCompletedLimit] = useState(5);
|
|
165
169
|
const [archivedLimit, setArchivedLimit] = useState(5);
|
|
166
170
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -420,7 +424,8 @@ export function TaskChefApp() {
|
|
|
420
424
|
<Box className={`taskchef-app-shell${displayMode === "inline" ? " taskchef-app-inline" : ""}`}>
|
|
421
425
|
<header className="taskchef-app-header">
|
|
422
426
|
<Group className="taskchef-app-brand" gap="xs" wrap="nowrap"><img alt="" aria-hidden className="taskchef-app-mark" src={brandIcon} /><Title order={1}>TaskChef</Title></Group>
|
|
423
|
-
{displayMode !== "inline" && <
|
|
427
|
+
{displayMode !== "inline" && <SegmentedControl className="taskchef-app-view" aria-label="View" data={[{ label: "Board", value: "board" }, { label: "Usage", value: "usage" }]} value={view} onChange={value => setView(value === "usage" ? "usage" : "board")} size="xs" />}
|
|
428
|
+
{displayMode !== "inline" && view !== "usage" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
|
|
424
429
|
<ProjectPicker data={projects} onChange={(value) => { setProject(value); setCompletedLimit(5); }} value={project} />
|
|
425
430
|
<Select className="taskchef-app-date" aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} value={date} size="xs" />
|
|
426
431
|
<TextInput className="taskchef-app-search" aria-label="Search Chats" placeholder="Search Chats" type="search" leftSection={<IconSearch size={14} aria-hidden />} value={search} onChange={(event) => { setSearch(event.currentTarget.value); setCompletedLimit(5); setArchivedLimit(5); }} size="xs" />
|
|
@@ -433,7 +438,7 @@ export function TaskChefApp() {
|
|
|
433
438
|
try { await call("taskchef_app_open_settings", {}); }
|
|
434
439
|
catch (cause) { await actionError("settings", cause); }
|
|
435
440
|
})()} variant="subtle"><IconSettings size={17} /></ActionIcon>
|
|
436
|
-
<ActionIcon aria-label="Refresh" onClick={() => void refresh(true).catch((cause) => setError(String(cause)))} variant="subtle"><IconRefresh size={17} /></ActionIcon>
|
|
441
|
+
<ActionIcon aria-label="Refresh" onClick={() => { setUsageRefreshKey(value => value + 1); void refresh(true).catch((cause) => setError(String(cause))); }} variant="subtle"><IconRefresh size={17} /></ActionIcon>
|
|
437
442
|
<AppearancePicker value={themeChoice} onChange={chooseTheme} visualThemeId={selectedTheme.id} appearance={colorScheme} onVisualThemeChange={chooseVisualTheme} />
|
|
438
443
|
</Group>
|
|
439
444
|
</header>
|
|
@@ -449,6 +454,7 @@ export function TaskChefApp() {
|
|
|
449
454
|
{view === "list" && <Paper className="taskchef-toolbar" radius={0}>
|
|
450
455
|
<Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>
|
|
451
456
|
</Paper>}
|
|
457
|
+
{view === "usage" ? <UsageView key={`${showCli}:${showExec}`} request={readUsage} refreshKey={usageRefreshKey} /> : <>
|
|
452
458
|
{!!scan?.scheduleErrors && <Alert color="yellow" role="alert">{scan.scheduleErrors} schedule files could not be read; schedule placement may be incomplete.</Alert>}
|
|
453
459
|
{view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {eligibleTasks.length}</Text>}
|
|
454
460
|
{error && <Alert color="red" role="alert" mt="sm">{error}</Alert>}
|
|
@@ -457,6 +463,7 @@ export function TaskChefApp() {
|
|
|
457
463
|
{visible.map((task) => <TaskCard key={task.id} onOpenCodex={(item) => void openChat(item)} onOpenDetail={(item) => void select(item)} task={task} />)}
|
|
458
464
|
{visible.length === 0 && <Paper className="taskchef-empty" p="lg" ta="center" withBorder><Title order={2} size="h5">No tasks match these filters</Title><Text c="dimmed" size="sm">Choose a different project, update window, or status.</Text></Paper>}
|
|
459
465
|
</Stack>)}
|
|
466
|
+
</>}
|
|
460
467
|
</main>
|
|
461
468
|
</>}
|
|
462
469
|
{taskchefVersion && displayMode !== "inline" && <footer className="taskchef-app-version" aria-label="TaskChef version">v{taskchefVersion}</footer>}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
html, body, #root { min-width: 0; min-height: 100%; }
|
|
16
16
|
body { background: var(--taskchef-page); }
|
|
17
17
|
.taskchef-app-shell { min-height: 100vh; background: var(--taskchef-page); }
|
|
18
|
-
.taskchef-app-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
|
|
18
|
+
.taskchef-app-header { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "brand view filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
|
|
19
19
|
.taskchef-app-brand { grid-area: brand; }
|
|
20
20
|
.taskchef-app-actions { grid-area: actions; }
|
|
21
21
|
.taskchef-app-header h1 { color: var(--taskchef-text); font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; }
|
|
@@ -26,14 +26,18 @@ body { background: var(--taskchef-page); }
|
|
|
26
26
|
.taskchef-app-main-list { max-width: 624px; margin-inline: auto; }
|
|
27
27
|
.taskchef-app-main .taskchef-toolbar { margin-bottom: 8px; padding-bottom: 10px; }
|
|
28
28
|
.taskchef-app-main .mantine-SegmentedControl-root { max-width: 100%; }
|
|
29
|
-
.taskchef-app-view { width: max-content; }
|
|
30
|
-
.taskchef-app-filters { grid-area: filters; min-width: 0; width: min(100%, 680px); }
|
|
29
|
+
.taskchef-app-view { grid-area: view; width: max-content; }
|
|
30
|
+
.taskchef-app-filters { grid-area: filters; border-inline-start: 1px solid var(--taskchef-border); padding-inline-start: 12px; min-width: 0; width: min(100%, 680px); }
|
|
31
31
|
.taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1 100px; min-width: 100px; }
|
|
32
32
|
.taskchef-app-pinned { flex: 0 0 auto; white-space: nowrap; }
|
|
33
33
|
.taskchef-app-filters .taskchef-app-date { flex: 0 0 132px; }
|
|
34
|
-
@media (max-width:
|
|
35
|
-
.taskchef-app-header { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "brand actions" "filters filters"; gap: 8px; }
|
|
36
|
-
.taskchef-app-filters { width: 100%; }
|
|
34
|
+
@media (max-width: 1000px) {
|
|
35
|
+
.taskchef-app-header { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand view actions" "filters filters filters"; gap: 8px; }
|
|
36
|
+
.taskchef-app-filters { width: 100%; border-inline-start: 0; padding-inline-start: 0; border-top: 1px solid var(--taskchef-border); padding-top: 8px; }
|
|
37
|
+
}
|
|
38
|
+
@media (max-width: 500px) {
|
|
39
|
+
.taskchef-app-header { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "brand view" "actions actions" "filters filters"; }
|
|
40
|
+
.taskchef-app-actions { justify-self: end; }
|
|
37
41
|
}
|
|
38
42
|
.taskchef-app-status { max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
|
39
43
|
.taskchef-app-status .mantine-SegmentedControl-root { width: max-content; min-width: max-content; }
|
|
@@ -107,7 +111,7 @@ body { background: var(--taskchef-page); }
|
|
|
107
111
|
.taskchef-reply-cover { background: var(--taskchef-page); }
|
|
108
112
|
|
|
109
113
|
@media (max-width: 380px) {
|
|
110
|
-
.taskchef-app-header { grid-template-areas: "brand
|
|
114
|
+
.taskchef-app-header { grid-template-areas: "brand view" "actions actions" "filters filters"; }
|
|
111
115
|
.taskchef-app-inline .taskchef-app-header { grid-template-areas: "brand brand" "actions actions"; }
|
|
112
116
|
.taskchef-app-actions { justify-self: end; }
|
|
113
117
|
}
|
|
@@ -221,3 +225,46 @@ body { background: var(--taskchef-page); }
|
|
|
221
225
|
.taskchef-turn-error-compact { gap: 6px; margin-top: 8px; padding: 0; border: 0; border-radius: 0; background: transparent; font-size: var(--mantine-font-size-xs); }
|
|
222
226
|
|
|
223
227
|
.taskchef-turn-error-compact > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
|
|
228
|
+
|
|
229
|
+
.usage-view { max-width: 1100px; margin: 0 auto; color: var(--taskchef-text); }
|
|
230
|
+
.usage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
231
|
+
.usage-view button:disabled { opacity: .5; cursor: default; }
|
|
232
|
+
.usage-view h2 { font-size: 24px; margin: 4px 0 8px; }
|
|
233
|
+
.usage-view h3 { font-size: 14px; margin: 0 0 12px; }
|
|
234
|
+
.usage-view p, .usage-view small { color: var(--taskchef-muted); font-size: 12px; line-height: 1.5; margin: 8px 0; }
|
|
235
|
+
.usage-ranges, .usage-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
|
236
|
+
.usage-view button { font: inherit; font-size: 12px; color: var(--taskchef-text); background: transparent; border: 1px solid var(--taskchef-border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
|
237
|
+
.usage-view button:hover, .usage-view button[aria-pressed="true"] { background: var(--taskchef-hover); border-color: var(--taskchef-muted); }
|
|
238
|
+
.usage-view button:focus-visible, .usage-chart g:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
|
|
239
|
+
.usage-totals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
|
|
240
|
+
.usage-totals article, .usage-panel { padding: 18px; border: 1px solid var(--taskchef-border); border-radius: 10px; background: var(--taskchef-surface); min-width: 0; }
|
|
241
|
+
.usage-totals strong { font-size: 28px; font-weight: 600; }
|
|
242
|
+
.usage-panel { margin: 16px 0; }
|
|
243
|
+
.usage-legend { font-size: 12px; gap: 12px; }
|
|
244
|
+
.usage-legend span, .usage-legend button { display: inline-flex; align-items: center; gap: 6px; }
|
|
245
|
+
.usage-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
|
246
|
+
.usage-legend button { max-width: 100%; overflow-wrap: anywhere; text-align: left; }
|
|
247
|
+
.usage-token-key, .usage-chart .usage-token-axis { color: light-dark(#a54d2e, #efb18b); fill: light-dark(#a54d2e, #efb18b); }
|
|
248
|
+
.usage-token-line { stroke: light-dark(#a54d2e, #efb18b); stroke-width: 2.5; fill: none; pointer-events: none; }
|
|
249
|
+
.usage-token-dot { fill: light-dark(#a54d2e, #efb18b); pointer-events: none; }
|
|
250
|
+
.usage-chart-scroll { overflow-x: auto; }
|
|
251
|
+
.usage-chart { width: 100%; min-width: 500px; display: block; font: 11px var(--taskchef-font, sans-serif); fill: var(--taskchef-muted); }
|
|
252
|
+
.usage-grid { stroke: var(--taskchef-border); }
|
|
253
|
+
.usage-chart-detail { min-height: 18px; }
|
|
254
|
+
.usage-ranking { overflow-x: auto; }
|
|
255
|
+
.usage-ranking table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
256
|
+
.usage-ranking th, .usage-ranking td { padding: 6px; text-align: right; font-weight: normal; }
|
|
257
|
+
.usage-ranking th:first-child { width: 70%; text-align: left; }
|
|
258
|
+
.usage-ranking td { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
259
|
+
.usage-ranking thead { color: var(--taskchef-muted); }
|
|
260
|
+
.usage-ranking button { width: 100%; display: flex; align-items: center; gap: 12px; border-color: transparent; text-align: left; }
|
|
261
|
+
.usage-ranking button > span:first-child { width: 45%; overflow-wrap: anywhere; }
|
|
262
|
+
.usage-rank-track { flex: 1; height: 8px; border-radius: 2px; background: var(--taskchef-border); overflow: hidden; }
|
|
263
|
+
.usage-rank-track > span { display: block; height: 100%; }
|
|
264
|
+
.usage-warning { padding: 10px; border: 1px solid var(--taskchef-border); border-radius: 6px; }
|
|
265
|
+
@media (max-width: 500px) { .usage-totals { grid-template-columns: 1fr; gap: 10px; } .usage-panel { padding: 12px; } .usage-rank-track { display: none; } .usage-ranking button > span:first-child { width: 100%; } }
|
|
266
|
+
|
|
267
|
+
.usage-progress { margin: 16px 0; }
|
|
268
|
+
.usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
|
|
269
|
+
.usage-chart [data-project] { transition: opacity 120ms ease; }
|
|
270
|
+
@media (prefers-reduced-motion: reduce) { .usage-chart [data-project] { transition: none; } }
|
package/src/mcp-app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { aggregateUsage } from "./usage-analytics.js";
|
|
1
2
|
import { mkdir, readFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
@@ -78,6 +79,7 @@ export function registerTaskChefApp(server, {
|
|
|
78
79
|
let ipcErrorReported = false;
|
|
79
80
|
const turnUsageCache = new Map();
|
|
80
81
|
const usagePending = new Map();
|
|
82
|
+
const analyticsJobs = new Map();
|
|
81
83
|
async function boardSnapshot(settings, force = false, scope = { date: "all", taskIds: [] }) {
|
|
82
84
|
const databaseSnapshot = await scanner.refresh({ force });
|
|
83
85
|
await liveState.refresh(databaseSnapshot.healthy === false ? [] : databaseSnapshot.tasks);
|
|
@@ -210,6 +212,41 @@ export function registerTaskChefApp(server, {
|
|
|
210
212
|
}
|
|
211
213
|
return { structuredContent: { snapshot, settings: publicSettings(settings), settingsUrl, notifications, github: githubAuth, version: TASKCHEF_VERSION }, content: [] };
|
|
212
214
|
});
|
|
215
|
+
server.registerTool("taskchef_app_usage", {
|
|
216
|
+
title: "Read usage history", description: "Aggregate timestamped local token usage and API-equivalent cost by day and project.",
|
|
217
|
+
inputSchema: { range: z.enum(["7d", "30d", "90d", "mtd", "last-month"]), timeZone: z.string().min(1).max(100), force: z.boolean().optional(), rescan: z.boolean().optional() }, _meta: appOnly,
|
|
218
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
219
|
+
}, async ({ range, timeZone, force, rescan }) => {
|
|
220
|
+
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
221
|
+
const settings = await readSettings();
|
|
222
|
+
// History is independent of chart range and timezone. Range changes query
|
|
223
|
+
// this shared snapshot; only expiry or explicit Refresh starts another read.
|
|
224
|
+
const key = JSON.stringify([settings.showCli, settings.showExec]);
|
|
225
|
+
let job = analyticsJobs.get(key);
|
|
226
|
+
if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
|
|
227
|
+
if (!job || (!job.pending && (force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000)))) {
|
|
228
|
+
job = { families: job?.families ?? null, error: null, finishedAt: job?.finishedAt ?? 0,
|
|
229
|
+
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } };
|
|
230
|
+
analyticsJobs.set(key, job);
|
|
231
|
+
const current = job;
|
|
232
|
+
Promise.resolve().then(async () => {
|
|
233
|
+
do {
|
|
234
|
+
current.refreshAgain = false;
|
|
235
|
+
const rebuild = current.rescan;
|
|
236
|
+
current.rescan = false;
|
|
237
|
+
const families = await scanner.usageHistory({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
|
|
238
|
+
current.families = families;
|
|
239
|
+
current.finishedAt = Date.now();
|
|
240
|
+
} while (current.refreshAgain);
|
|
241
|
+
}).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
|
|
242
|
+
}
|
|
243
|
+
if (job.error) throw job.error;
|
|
244
|
+
const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
|
|
245
|
+
updatedAt: new Date(job.finishedAt).toISOString(),
|
|
246
|
+
...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
|
|
247
|
+
: { loading: true, progress: job.progress };
|
|
248
|
+
return { structuredContent: data, content: [] };
|
|
249
|
+
});
|
|
213
250
|
server.registerTool("taskchef_app_task", {
|
|
214
251
|
title: "Read Codex chat metadata", description: "Read one local chat's metadata and its latest recorded request and response.",
|
|
215
252
|
inputSchema: { taskId: taskIdSchema, refreshGithub: z.boolean().optional() }, _meta: appOnly,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Calendar dates are evaluated in the view's timezone, including DST changes.
|
|
2
|
+
export function aggregateUsage(families, { range, timeZone, now = Date.now() }) {
|
|
3
|
+
const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
4
|
+
const dateKey = time => {
|
|
5
|
+
const parts = Object.fromEntries(format.formatToParts(time).map(part => [part.type, part.value]));
|
|
6
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
7
|
+
};
|
|
8
|
+
let end = dateKey(now);
|
|
9
|
+
const startDate = new Date(`${end}T00:00:00Z`);
|
|
10
|
+
if (range === 'last-month') {
|
|
11
|
+
startDate.setUTCDate(1);
|
|
12
|
+
end = new Date(startDate.getTime() - 86400000).toISOString().slice(0, 10);
|
|
13
|
+
startDate.setUTCMonth(startDate.getUTCMonth() - 1);
|
|
14
|
+
} else if (range === 'mtd') startDate.setUTCDate(1);
|
|
15
|
+
else startDate.setUTCDate(startDate.getUTCDate() - ({ '7d': 6, '30d': 29, '90d': 89 }[range] ?? 29));
|
|
16
|
+
const start = startDate.toISOString().slice(0, 10);
|
|
17
|
+
const days = [];
|
|
18
|
+
for (let cursor = startDate; cursor.toISOString().slice(0, 10) <= end; cursor = new Date(cursor.getTime() + 86400000)) {
|
|
19
|
+
days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {} });
|
|
20
|
+
}
|
|
21
|
+
const byDate = new Map(days.map(day => [day.date, day]));
|
|
22
|
+
const projects = new Map();
|
|
23
|
+
let incomplete = false, samples = 0;
|
|
24
|
+
const totals = { tokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
25
|
+
for (const { project, usage } of families) {
|
|
26
|
+
// Coverage can be incomplete even when no timestamped records survive.
|
|
27
|
+
incomplete ||= usage.analyticsPartial || usage.partial || false;
|
|
28
|
+
const key = project.id === 'projectless' ? 'unassigned' : project.id || project.path || 'unassigned';
|
|
29
|
+
for (const sample of usage.timeline) {
|
|
30
|
+
if (sample.time > now) continue;
|
|
31
|
+
const day = byDate.get(dateKey(sample.time));
|
|
32
|
+
if (!day) continue;
|
|
33
|
+
let item = projects.get(key);
|
|
34
|
+
if (!item) { item = { id: key, name: key === 'unassigned' ? 'Unassigned' : project.name, tokens: 0, costUsd: 0 }; projects.set(key, item); }
|
|
35
|
+
const tokens = sample.tokens.total_tokens;
|
|
36
|
+
totals.tokens += tokens; totals.inputTokens += sample.tokens.input_tokens;
|
|
37
|
+
totals.cachedInputTokens += sample.tokens.cached_input_tokens; totals.outputTokens += sample.tokens.output_tokens;
|
|
38
|
+
totals.costUsd += sample.costUsd; samples += sample.samples;
|
|
39
|
+
day.tokens += tokens; day.costUsd += sample.costUsd;
|
|
40
|
+
day.projects[key] = (day.projects[key] ?? 0) + sample.costUsd;
|
|
41
|
+
item.tokens += tokens; item.costUsd += sample.costUsd;
|
|
42
|
+
incomplete ||= sample.partial || sample.costPartial;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { start, end, timeZone, days, totals, samples, incomplete,
|
|
46
|
+
projects: [...projects.values()].sort((a, b) => b.costUsd - a.costUsd || a.name.localeCompare(b.name)) };
|
|
47
|
+
}
|