taskchef 9.6.0 → 9.7.1
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 +57 -22
- package/package.json +1 -1
- package/scripts/migrate-taskchef-state.js +1 -1
- package/src/codex-session-scanner.js +5 -5
- package/src/codex-session-usage.js +21 -8
- package/src/mcp-app/dist/index.html +75 -75
- package/src/mcp-app/react/UsageView.test.tsx +67 -2
- package/src/mcp-app/react/UsageView.tsx +89 -17
- package/src/mcp-app/react/main.test.tsx +27 -8
- package/src/mcp-app/react/main.tsx +1 -1
- package/src/mcp-app/react/styles.css +7 -2
- package/src/mcp-app.js +23 -8
- package/src/usage-analytics.js +17 -2
- package/src/usage-history-cache.js +38 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
2
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
3
3
|
import { UsageView, type UsageData } from './UsageView';
|
|
4
4
|
beforeEach(() => window.localStorage.clear());
|
|
5
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}]};
|
|
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},projectTokens:{a:120,b:80}}],projects:[{id:'a',name:'Alpha',tokens:100,costUsd:2},{id:'b',name:'Beta',tokens:100,costUsd:1}]};
|
|
7
7
|
test('defaults to 30 days, changes range, isolates projects, and exposes exact day values',async()=>{
|
|
8
8
|
const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
|
|
9
9
|
await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
|
|
@@ -122,3 +122,68 @@ test('invalid saved Usage ranges fall back to 30 days', async () => {
|
|
|
122
122
|
await screen.findByRole('table');
|
|
123
123
|
expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
test('project tokens share selection and highlight with cost and expose exact values',async()=>{
|
|
127
|
+
const request=vi.fn().mockResolvedValue(data);const view=render(<UsageView request={request}/>);await screen.findByRole('table');
|
|
128
|
+
expect(screen.getByRole('heading',{name:'Tokens and cost by project'})).toBeVisible();expect(screen.queryByText(/over time/)).toBeNull();
|
|
129
|
+
fireEvent.focus(screen.getByRole('button',{name:'Oct 15 project usage'}));expect(screen.getByText(/Alpha: 120 tokens, \$2.00 · Beta: 80 tokens, \$1.00/)).toBeVisible();
|
|
130
|
+
fireEvent.mouseEnter(screen.getByRole('button',{name:'Alpha'}));
|
|
131
|
+
expect(screen.getByRole('img',{name:'Daily project token stack: all projects'}).querySelector('[data-project="b"]')).toHaveAttribute('opacity','0.2');
|
|
132
|
+
fireEvent.click(screen.getByRole('button',{name:'Alpha'}));expect(screen.getByRole('img',{name:'Daily project token stack: Alpha'}).querySelectorAll('polygon')).toHaveLength(1);
|
|
133
|
+
expect(view.container.querySelectorAll('h3')).not.toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
test('weekly summary connects gaps and resets, hides model buckets and shows current allowance in the view timezone',async()=>{
|
|
136
|
+
vi.useFakeTimers({toFake:['Date']});vi.setSystemTime(new Date('2026-10-17T00:00:00Z'));
|
|
137
|
+
try {
|
|
138
|
+
const days=Array.from({length:5},(_,i)=>({...data.days[0],date:`2026-10-${11+i}`}));
|
|
139
|
+
const reading=(date:string,remainingPercent:number,resetsAt:number,id='codex')=>({id,name:null,date,time:Date.parse(`${date}T00:00:00Z`),remainingPercent,resetsAt});
|
|
140
|
+
const reset=Date.parse('2026-10-13T00:00:00Z'), nextReset=Date.parse('2026-10-20T01:00:00Z');
|
|
141
|
+
const current=reading('2026-10-17',89,nextReset);
|
|
142
|
+
const request=vi.fn().mockResolvedValue({...data,samples:0,days,currentWeeklyLimit:current,weeklyLimits:[reading('2026-10-11',80,reset),reading('2026-10-12',70,reset),reading('2026-10-13',100,nextReset),reading('2026-10-15',90,nextReset),reading('2026-10-15',50,nextReset,'gpt-5.3')]});
|
|
143
|
+
render(<UsageView request={request}/>);const chart=await screen.findByRole('img',{name:'Recorded weekly allowance remaining by day'});
|
|
144
|
+
expect(chart.querySelectorAll('circle')).toHaveLength(4);
|
|
145
|
+
expect(chart.querySelector('polyline')?.getAttribute('points')?.split(' ')).toHaveLength(6);
|
|
146
|
+
expect(screen.getByRole('heading',{name:'Weekly limits · 89% left'})).toBeVisible();
|
|
147
|
+
const expected=new Date(nextReset).toLocaleString(undefined,{timeZone:data.timeZone,month:'short',day:'numeric',hour:'numeric',minute:'2-digit',timeZoneName:'short'});
|
|
148
|
+
expect(screen.getByText(`Resets ${expected}`)).toBeVisible();
|
|
149
|
+
expect(screen.queryByText('codex')).toBeNull();expect(screen.queryByText(/GPT-5.3/i)).toBeNull();expect(screen.queryByText(/Last observation/)).toBeNull();expect(screen.queryByRole('table')).toBeNull();
|
|
150
|
+
expect(screen.getByRole('heading',{name:'Recorded tokens'}).closest('.usage-totals')).toContainElement(chart);
|
|
151
|
+
} finally {vi.useRealTimers();}
|
|
152
|
+
});
|
|
153
|
+
test('project charts expose ten distinct projects and only put lower ranks in Other',async()=>{
|
|
154
|
+
const projects=Array.from({length:12},(_,i)=>({id:String(i),name:`Project ${i+1}`,tokens:12-i,costUsd:12-i}));
|
|
155
|
+
const day={...data.days[0],projects:Object.fromEntries(projects.map(p=>[p.id,p.costUsd])),projectTokens:Object.fromEntries(projects.map(p=>[p.id,p.tokens]))};
|
|
156
|
+
render(<UsageView request={vi.fn().mockResolvedValue({...data,projects,days:[day]})}/>);await screen.findByRole('table');
|
|
157
|
+
const chart=screen.getByRole('img',{name:'Daily project token stack: all projects'});expect(chart.querySelectorAll('polygon')).toHaveLength(11);
|
|
158
|
+
expect(new Set([...chart.querySelectorAll('polygon')].map(p=>p.getAttribute('fill'))).size).toBe(11);
|
|
159
|
+
fireEvent.focus(screen.getByRole('button',{name:'Oct 15 project usage'}));expect(screen.getByText(/Other: 3 tokens, \$3.00/)).toBeVisible();
|
|
160
|
+
fireEvent.click(screen.getByRole('button',{name:'Project 10'}));expect(screen.getByRole('img',{name:'Daily project token stack: Project 10'}).querySelectorAll('polygon')).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
test('expired current allowance is unavailable while history remains visible',async()=>{
|
|
163
|
+
const sample={id:'codex',name:null,time:1,resetsAt:2,remainingPercent:80};
|
|
164
|
+
render(<UsageView request={vi.fn().mockResolvedValue({...data,currentWeeklyLimit:sample})}/>);
|
|
165
|
+
expect(await screen.findByText('Current allowance unavailable')).toBeVisible();expect(screen.queryByText(/80% left/)).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a failed background refresh keeps saved charts visible and Retry forces a fresh read',async()=>{
|
|
169
|
+
const request=vi.fn().mockResolvedValueOnce({...data,refreshing:true}).mockResolvedValueOnce({...data,refreshError:'Saved usage is shown. Background refresh failed.'}).mockResolvedValue(data);
|
|
170
|
+
render(<UsageView request={request}/>);
|
|
171
|
+
await screen.findByRole('table');expect(screen.getByText(/Updating saved usage/)).toBeVisible();
|
|
172
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Saved usage is shown');expect(screen.getByRole('table')).toBeVisible();
|
|
173
|
+
fireEvent.click(screen.getByRole('button',{name:'Retry'}));
|
|
174
|
+
await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),force:true}));
|
|
175
|
+
await waitFor(()=>expect(screen.queryByRole('alert')).toBeNull());expect(screen.getByRole('table')).toBeVisible();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('an idle weekly card expires at its reset without another request',async()=>{
|
|
179
|
+
vi.useFakeTimers();vi.setSystemTime(new Date('2026-10-17T00:00:00Z'));
|
|
180
|
+
try {
|
|
181
|
+
const currentWeeklyLimit={id:'codex',name:null,time:Date.now()-1000,resetsAt:Date.now()+2000,remainingPercent:89};
|
|
182
|
+
const request=vi.fn().mockResolvedValue({...data,currentWeeklyLimit});
|
|
183
|
+
await act(async()=>{render(<UsageView request={request}/>);});
|
|
184
|
+
expect(screen.getByRole('heading',{name:'Weekly limits · 89% left'})).toBeVisible();
|
|
185
|
+
await act(async()=>{vi.advanceTimersByTime(2000);});
|
|
186
|
+
expect(screen.getByText('Current allowance unavailable')).toBeVisible();
|
|
187
|
+
expect(screen.queryByRole('heading',{name:/89% left/})).toBeNull();expect(request).toHaveBeenCalledTimes(1);
|
|
188
|
+
}finally{vi.useRealTimers();}
|
|
189
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { usePreference } from "./preferences";
|
|
2
2
|
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
|
+
interface WeeklyLimit { id: string; name: string | null; date?: string; time: number; remainingPercent: number; resetsAt: number }
|
|
4
5
|
export interface UsageData {
|
|
5
|
-
updatedAt?: string; refreshing?: boolean; progress?: ScanProgress;
|
|
6
|
+
updatedAt?: string; refreshing?: boolean; refreshError?: string; progress?: ScanProgress;
|
|
6
7
|
start: string; end: string; timeZone: string; samples: number; incomplete: boolean;
|
|
7
8
|
totals: { tokens: number; costUsd: number; inputTokens: number; cachedInputTokens: number; outputTokens: number };
|
|
8
|
-
days: { date: string; tokens: number; costUsd: number; projects: Record<string, number> }[];
|
|
9
|
+
days: { date: string; tokens: number; costUsd: number; projects: Record<string, number>; projectTokens: Record<string, number> }[];
|
|
10
|
+
weeklyLimits?: WeeklyLimit[];
|
|
11
|
+
currentWeeklyLimit?: WeeklyLimit | null;
|
|
9
12
|
projects: { id: string; name: string; costUsd: number; tokens: number }[];
|
|
10
13
|
}
|
|
11
14
|
interface ScanProgress { completed: number; total: number; }
|
|
@@ -15,7 +18,7 @@ export type UsageResponse = UsageData | { loading: true; progress?: ScanProgress
|
|
|
15
18
|
const money = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: n > 0 && n < .01 ? 4 : 2 }).format(n);
|
|
16
19
|
const count = (n: number) => new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
|
17
20
|
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"];
|
|
21
|
+
const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#7caeb5", "#a9af78", "#bb8caa", "#b28b6f", "#8e97c5", "#92979c"];
|
|
19
22
|
const left = 64, right = 794, top = 16, bottom = 166;
|
|
20
23
|
|
|
21
24
|
export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnType<typeof usageRequest>) => Promise<UsageResponse>; refreshKey?: number }) {
|
|
@@ -28,6 +31,7 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
28
31
|
const lastRequest = useRef({ refreshKey, retry: 0, rescan: 0 });
|
|
29
32
|
const [selected, setSelected] = useState<string | null>(null);
|
|
30
33
|
const [activeDay, setActiveDay] = useState<number | null>(null);
|
|
34
|
+
const [activeProjectDay, setActiveProjectDay] = useState<number | null>(null);
|
|
31
35
|
const [retry, setRetry] = useState(0);
|
|
32
36
|
const [rescan, setRescan] = useState(0);
|
|
33
37
|
useEffect(() => {
|
|
@@ -35,7 +39,7 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
35
39
|
const force = refreshKey !== lastRequest.current.refreshKey || retry !== lastRequest.current.retry;
|
|
36
40
|
const rebuild = rescan !== lastRequest.current.rescan;
|
|
37
41
|
lastRequest.current = { refreshKey, retry, rescan };
|
|
38
|
-
setLoading(true); setError(null); setProgress(null); setActiveDay(null); setHighlighted(null);
|
|
42
|
+
setLoading(true); setError(null); setProgress(null); setActiveDay(null); setActiveProjectDay(null); setHighlighted(null);
|
|
39
43
|
if (!force && !rebuild) setData(null);
|
|
40
44
|
let timer: ReturnType<typeof setTimeout>;
|
|
41
45
|
async function load(force: boolean, rebuild = false) {
|
|
@@ -58,15 +62,18 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
58
62
|
const unfinished = (date: string) => date === today;
|
|
59
63
|
const barStyle = (date: string, color: string) => unfinished(date) ? { fillOpacity: .35, stroke: color, strokeWidth: 1.5, strokeDasharray: "4 3" } : {};
|
|
60
64
|
const project = data?.projects.find(item => item.id === selected);
|
|
61
|
-
const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) <
|
|
62
|
-
: [...data.projects.slice(0,
|
|
63
|
-
...(data.projects.length >
|
|
65
|
+
const series = data ? (project ? [{ ...project, color: colors[data.projects.indexOf(project) < 10 ? data.projects.indexOf(project) : 10] }]
|
|
66
|
+
: [...data.projects.slice(0, 10).map((item, index) => ({ ...item, color: colors[index] })),
|
|
67
|
+
...(data.projects.length > 10 ? [{ id: "__other", name: "Other", costUsd: 0, tokens: 0, color: colors[10] }] : [])]) : [];
|
|
64
68
|
const activeHighlight = series.some(item => item.id === highlighted) ? highlighted : null;
|
|
65
69
|
const dayWidth = data ? (right - left) / data.days.length : 1;
|
|
66
70
|
const maxCost = Math.max(...(data?.days.map(day => day.costUsd) ?? []), .01);
|
|
67
71
|
const maxTokens = Math.max(...(data?.days.map(day => day.tokens) ?? []), 1);
|
|
68
72
|
const projectCost = (day: UsageData["days"][number], id: string) => id === "__other"
|
|
69
|
-
? data!.projects.slice(
|
|
73
|
+
? data!.projects.slice(10).reduce((sum, item) => sum + (day.projects[item.id] ?? 0), 0) : day.projects[id] ?? 0;
|
|
74
|
+
const projectTokens = (day: UsageData["days"][number], id: string) => id === "__other"
|
|
75
|
+
? data!.projects.slice(10).reduce((sum, item) => sum + (day.projectTokens?.[item.id] ?? 0), 0) : day.projectTokens?.[id] ?? 0;
|
|
76
|
+
const maxProjectTokens = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectTokens(day, item.id), 0)) ?? []), 1);
|
|
70
77
|
const maxProjectCost = Math.max(...(data?.days.map(day => series.reduce((sum, item) => sum + projectCost(day, item.id), 0)) ?? []), .01);
|
|
71
78
|
const x = (index: number) => left + (index + .5) * dayWidth;
|
|
72
79
|
const y = (value: number, max: number) => bottom - value / max * (bottom - top);
|
|
@@ -91,13 +98,15 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
91
98
|
</div>}
|
|
92
99
|
{error && <div role="alert"><p>Usage could not be loaded: {error}</p><button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
|
|
93
100
|
{data && <>
|
|
94
|
-
<p
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
{data.refreshError && <p role="alert">{data.refreshError} <button onClick={() => setRetry(value => value + 1)}>Retry</button></p>}
|
|
102
|
+
<p className="usage-period">{dateLabel(data.start)} – {dateLabel(data.end)} · {data.timeZone} · Daily · All chat sources · Includes archived chats and subagent usage{data.updatedAt && <> · Updated {new Date(data.updatedAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>}</p>
|
|
103
|
+
<div className="usage-totals">
|
|
97
104
|
<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
105
|
<article><h3>Estimated cost</h3><strong>{money(data.totals.costUsd)}</strong><p>API-equivalent prices · USD</p></article>
|
|
106
|
+
<WeeklyLimits data={data} />
|
|
99
107
|
</div>
|
|
100
|
-
|
|
108
|
+
{!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
|
|
109
|
+
<article className="usage-panel" aria-label="Tokens and cost"><h3>Tokens and cost</h3>
|
|
101
110
|
<div className="usage-legend"><span className="usage-token-key">━ Tokens</span><span><i style={{ background: colors[0] }} /> Estimated cost</span></div>
|
|
102
111
|
<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
112
|
{grid(maxCost)}
|
|
@@ -108,22 +117,85 @@ export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnT
|
|
|
108
117
|
{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
118
|
{dates()}
|
|
110
119
|
</svg></div>
|
|
111
|
-
<p className="usage-chart-detail" aria-live="polite">{activeDay === null ? "
|
|
120
|
+
<p className="usage-chart-detail" aria-live="polite">{activeDay === null ? "" : `${dateLabel(data.days[activeDay].date)} · ${data.days[activeDay].tokens.toLocaleString()} tokens · ${money(data.days[activeDay].costUsd)}`}</p>
|
|
112
121
|
</article>
|
|
113
|
-
<article className="usage-panel" aria-label="
|
|
114
|
-
<
|
|
115
|
-
<
|
|
122
|
+
<article className="usage-panel" aria-label="Tokens and cost by project"><h3>Tokens and cost by project</h3>
|
|
123
|
+
<div className="usage-legend"><button aria-pressed={!project} onMouseEnter={() => setHighlighted(null)} onFocus={() => setHighlighted(null)} onClick={() => setSelected(null)}>All projects</button>{data.projects.slice(0, 10).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 > 10 && <span tabIndex={0} onMouseEnter={() => setHighlighted("__other")} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted("__other")} onBlur={() => setHighlighted(null)}><i style={{ background: colors[10] }} />Other</span>}</div>
|
|
124
|
+
<h4>Estimated cost</h4>
|
|
116
125
|
<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
126
|
{grid(maxProjectCost)}
|
|
118
127
|
{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
128
|
{dates()}
|
|
120
129
|
</svg></div>
|
|
130
|
+
<h4>Tokens</h4>
|
|
131
|
+
<div className="usage-chart-scroll"><svg viewBox="0 0 860 210" role="img" aria-label={`Daily project token stack: ${project?.name ?? "all projects"}`} className="usage-chart">
|
|
132
|
+
{grid(maxProjectTokens, true)}
|
|
133
|
+
{series.map((item, seriesIndex) => {
|
|
134
|
+
const lower = data.days.map(day => series.slice(0, seriesIndex).reduce((sum, preceding) => sum + projectTokens(day, preceding.id), 0));
|
|
135
|
+
const upper = data.days.map((day, index) => lower[index] + projectTokens(day, item.id));
|
|
136
|
+
const finished = data.days.map((day, index) => ({ day, index })).filter(({ day }) => !unfinished(day.date));
|
|
137
|
+
return <g key={item.id} data-project={item.id} opacity={activeHighlight && activeHighlight !== item.id ? .2 : 1}>
|
|
138
|
+
<polygon fill={item.color} fillOpacity=".3" points={[...data.days.map((_, index) => `${x(index)},${y(upper[index], maxProjectTokens)}`), ...data.days.map((_, index) => `${x(index)},${y(lower[index], maxProjectTokens)}`).reverse()].join(" ")} />
|
|
139
|
+
<polyline fill="none" stroke={item.color} strokeWidth="2" points={finished.map(({ index }) => `${x(index)},${y(upper[index], maxProjectTokens)}`).join(" ")} />
|
|
140
|
+
{data.days.map((day, index) => unfinished(day.date) && index > 0 ? <polyline key={day.date} fill="none" stroke={item.color} strokeWidth="2" strokeDasharray="5 4" points={`${x(index - 1)},${y(upper[index - 1], maxProjectTokens)} ${x(index)},${y(upper[index], maxProjectTokens)}`} /> : null)}
|
|
141
|
+
{data.days.map((day, index) => <circle key={day.date} cx={x(index)} cy={y(upper[index], maxProjectTokens)} r="2.5" fill={item.color}><title>{dateLabel(day.date)} · {item.name}: {projectTokens(day, item.id).toLocaleString()} tokens</title></circle>)}
|
|
142
|
+
</g>;
|
|
143
|
+
})}
|
|
144
|
+
{data.days.map((day, index) => <g key={day.date} tabIndex={0} role="button" aria-label={`${dateLabel(day.date)} project usage`} onMouseEnter={() => setActiveProjectDay(index)} onFocus={() => setActiveProjectDay(index)} onClick={() => setActiveProjectDay(index)} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setActiveProjectDay(index); } }}><rect x={left + index * dayWidth} y={top} width={dayWidth} height={bottom - top} fill="transparent" /></g>)}
|
|
145
|
+
{dates()}
|
|
146
|
+
</svg></div>
|
|
147
|
+
<p className="usage-chart-detail" aria-live="polite">{activeProjectDay === null ? "" : `${dateLabel(data.days[activeProjectDay].date)} · ${series.map(item => `${item.name}: ${projectTokens(data.days[activeProjectDay], item.id).toLocaleString()} tokens, ${money(projectCost(data.days[activeProjectDay], item.id))}`).join(" · ")}`}</p>
|
|
121
148
|
</article>
|
|
122
149
|
<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 <
|
|
150
|
+
<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 < 10 ? index : 10] }} /></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
151
|
</article>
|
|
125
152
|
</>}
|
|
126
153
|
<p className="usage-footnote">Cost is estimated at API-equivalent prices, not your subscription bill or remaining allowance.</p>
|
|
127
154
|
</>}
|
|
128
155
|
</section>;
|
|
129
156
|
}
|
|
157
|
+
|
|
158
|
+
function WeeklyLimits({ data }: { data: UsageData }) {
|
|
159
|
+
const [now, setNow] = useState(Date.now);
|
|
160
|
+
const resetsAt = data.currentWeeklyLimit?.resetsAt;
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
163
|
+
function update() {
|
|
164
|
+
const time = Date.now();
|
|
165
|
+
setNow(time);
|
|
166
|
+
if (resetsAt && resetsAt > time) timer = setTimeout(update, Math.min(resetsAt - time, 2_147_483_647));
|
|
167
|
+
}
|
|
168
|
+
update();
|
|
169
|
+
return () => clearTimeout(timer);
|
|
170
|
+
}, [resetsAt]);
|
|
171
|
+
const samples = (data.weeklyLimits ?? []).filter(sample => sample.id === "codex").sort((a, b) => a.time - b.time);
|
|
172
|
+
const current = data.currentWeeklyLimit && data.currentWeeklyLimit.resetsAt > now ? data.currentWeeklyLimit : null;
|
|
173
|
+
const dateTime = (time: number) => new Date(time).toLocaleString(undefined, { timeZone: data.timeZone, month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" });
|
|
174
|
+
const dayIndex = new Map(data.days.map((day, index) => [day.date, index]));
|
|
175
|
+
const position = (time: number) => {
|
|
176
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: data.timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(time).map(part => [part.type, part.value]));
|
|
177
|
+
const index = dayIndex.get(`${parts.year}-${parts.month}-${parts.day}`) ?? 0;
|
|
178
|
+
return 30 + (index + (Number(parts.hour) + Number(parts.minute) / 60) / 24) / data.days.length * 264;
|
|
179
|
+
};
|
|
180
|
+
const y = (value: number) => 72 - value / 100 * 60;
|
|
181
|
+
const points: string[] = [];
|
|
182
|
+
samples.forEach((sample, index) => {
|
|
183
|
+
const previous = samples[index - 1];
|
|
184
|
+
if (previous && previous.resetsAt <= sample.time && previous.resetsAt > previous.time) {
|
|
185
|
+
// Preserve the observed allowance until its known reset, then refill.
|
|
186
|
+
points.push(`${position(previous.resetsAt)},${y(previous.remainingPercent)}`, `${position(previous.resetsAt)},${y(100)}`);
|
|
187
|
+
}
|
|
188
|
+
points.push(`${position(sample.time)},${y(sample.remainingPercent)}`);
|
|
189
|
+
});
|
|
190
|
+
return <article className="usage-weekly" aria-label="Weekly limits">
|
|
191
|
+
<h3>Weekly limits{current && ` · ${current.remainingPercent.toFixed(0)}% left`}</h3>
|
|
192
|
+
<p className="usage-weekly-reset">{current ? `Resets ${dateTime(current.resetsAt)}` : "Current allowance unavailable"}</p>
|
|
193
|
+
{!samples.length ? <p>No weekly limit readings for this period.</p> : <svg viewBox="0 0 300 96" role="img" aria-label="Recorded weekly allowance remaining by day" className="usage-weekly-chart">
|
|
194
|
+
<title>Recorded allowance snapshots from Codex session logs; lines connect observations and show known resets.</title>
|
|
195
|
+
{[0, 50, 100].map(value => <g key={value}><line x1="30" x2="294" y1={y(value)} y2={y(value)} className="usage-grid" /><text x="24" y={y(value) + 3} textAnchor="end">{value}%</text></g>)}
|
|
196
|
+
<polyline fill="none" stroke={colors[0]} strokeWidth="2" points={points.join(" ")} />
|
|
197
|
+
{samples.map(sample => <circle key={sample.time} cx={position(sample.time)} cy={y(sample.remainingPercent)} r="2.5" fill={colors[0]}><title>{dateTime(sample.time)} · {sample.remainingPercent.toFixed(1)}% left · Resets {dateTime(sample.resetsAt)}</title></circle>)}
|
|
198
|
+
<text x="30" y="90">{dateLabel(data.start)}</text><text x="294" y="90" textAnchor="end">{dateLabel(data.end)}</text>
|
|
199
|
+
</svg>}
|
|
200
|
+
</article>;
|
|
201
|
+
}
|
|
@@ -77,7 +77,7 @@ beforeEach(() => {
|
|
|
77
77
|
transition = async () => ({ structuredContent: { task: task("one", "completed") } });
|
|
78
78
|
detailFailure = null;
|
|
79
79
|
server.call.mockImplementation(({ name, arguments: args }) => {
|
|
80
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
80
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
|
|
81
81
|
if (name === "taskchef_app_task" && detailFailure) return Promise.resolve({ isError: true, content: [{ type: "text", text: detailFailure }] });
|
|
82
82
|
if (name === "taskchef_app_task") return Promise.resolve({ structuredContent: { task: details.get(args.taskId as string) } });
|
|
83
83
|
if (name === "taskchef_app_notifications") {
|
|
@@ -242,7 +242,7 @@ test("an older selection detail cannot replace a newer refresh detail", async ()
|
|
|
242
242
|
const first = new Promise((resolve) => { releaseFirst = resolve; });
|
|
243
243
|
let detailCalls = 0;
|
|
244
244
|
server.call.mockImplementation(({ name }) => {
|
|
245
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
245
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
|
|
246
246
|
if (name === "taskchef_app_task") return ++detailCalls === 1
|
|
247
247
|
? first
|
|
248
248
|
: Promise.resolve({ structuredContent: { task: task("one", "needs_input") } });
|
|
@@ -272,7 +272,7 @@ test("an older selection response cannot replace the current task", async () =>
|
|
|
272
272
|
let release!: (value: unknown) => void;
|
|
273
273
|
const first = new Promise((resolve) => { release = resolve; });
|
|
274
274
|
server.call.mockImplementation(({ name, arguments: args }) => {
|
|
275
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
275
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
|
|
276
276
|
if (name === "taskchef_app_task") return args.taskId === "one" ? first : Promise.resolve({ structuredContent: { task: task("two") } });
|
|
277
277
|
throw new Error(`Unexpected tool: ${name}`);
|
|
278
278
|
});
|
|
@@ -286,7 +286,7 @@ test("an older selection response cannot replace the current task", async () =>
|
|
|
286
286
|
|
|
287
287
|
test("keeps scan diagnostics out of the board", async () => {
|
|
288
288
|
server.call.mockImplementation(({ name }) => {
|
|
289
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
289
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: {
|
|
290
290
|
tasks: [], healthy: true, revision: 1,
|
|
291
291
|
scan: { source: "database", mode: "database", checkedAt: "2026-10-05T00:00:00Z", intervalSeconds: 5,
|
|
292
292
|
indexedFiles: 8620, visibleFiles: 300, unreadFiles: 8320, errors: 0 },
|
|
@@ -303,7 +303,7 @@ test("keeps scan diagnostics out of the board", async () => {
|
|
|
303
303
|
|
|
304
304
|
test("shows an initial inventory failure without undefined scan counts", async () => {
|
|
305
305
|
server.call.mockImplementation(({ name }) => {
|
|
306
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
306
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: {
|
|
307
307
|
tasks: [], healthy: false, revision: 0,
|
|
308
308
|
scan: { mode: "error", checkedAt: "2026-10-05T00:00:00Z", error: "EACCES" },
|
|
309
309
|
} } });
|
|
@@ -351,7 +351,7 @@ test("inline mode loads once and begins polling when expanded", async () => {
|
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
test("shows the Node requirement when the scanner rejects its runtime", async () => {
|
|
354
|
-
server.call.mockResolvedValue({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
354
|
+
server.call.mockResolvedValue({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: {
|
|
355
355
|
tasks: [], healthy: false, revision: 0,
|
|
356
356
|
scan: { mode: "error", checkedAt: "2026-10-05T00:00:00Z", error: "TaskChef requires Node.js 22.18.0 or later for read-only SQLite (current: 22.17.9)." },
|
|
357
357
|
} } });
|
|
@@ -363,7 +363,7 @@ test("a database failure after a healthy snapshot removes stale tasks", async ()
|
|
|
363
363
|
mount();
|
|
364
364
|
expect(await screen.findByRole("button", { name: "Task one" })).toBeVisible();
|
|
365
365
|
server.call.mockImplementation(({ name }) => {
|
|
366
|
-
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
366
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: {
|
|
367
367
|
tasks: [], healthy: false, revision: 2,
|
|
368
368
|
scan: { source: "database", mode: "error", checkedAt: new Date().toISOString() },
|
|
369
369
|
} } });
|
|
@@ -384,7 +384,7 @@ test("Done control calls the local mark tool with the displayed turn and refresh
|
|
|
384
384
|
tasks = [next]; details.set(current.id, next);
|
|
385
385
|
return { structuredContent: { task: next } };
|
|
386
386
|
}
|
|
387
|
-
if (name === "taskchef_app_snapshot") return { structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-
|
|
387
|
+
if (name === "taskchef_app_snapshot") return { structuredContent: { notifications: notificationState, settingsUrl: "codex://plugins/taskchef-dev?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } };
|
|
388
388
|
if (name === "taskchef_app_task") return { structuredContent: { task: details.get(current.id) } };
|
|
389
389
|
throw new Error(name);
|
|
390
390
|
});
|
|
@@ -682,6 +682,25 @@ test("Usage opens on demand and returns to Board without keeping its filters vis
|
|
|
682
682
|
expect(screen.getByRole("region",{name:"Task board"})).toBeVisible();
|
|
683
683
|
});
|
|
684
684
|
|
|
685
|
+
test('Usage keeps its project selection when Board source visibility changes', async () => {
|
|
686
|
+
const original = server.call.getMockImplementation()!;
|
|
687
|
+
const usage = { start:'2026-10-15', end:'2026-10-15', timeZone:'UTC', samples:1, incomplete:false,
|
|
688
|
+
totals:{tokens:100,costUsd:2,inputTokens:80,cachedInputTokens:20,outputTokens:20},
|
|
689
|
+
days:[{date:'2026-10-15',tokens:100,costUsd:2,projects:{alpha:2},projectTokens:{alpha:100}}],
|
|
690
|
+
projects:[{id:'alpha',name:'Alpha',tokens:100,costUsd:2}] };
|
|
691
|
+
server.call.mockImplementation(input => input.name === 'taskchef_app_usage' ? Promise.resolve({structuredContent:usage}) : original(input));
|
|
692
|
+
mount(); await screen.findByRole('button',{name:'Task one'});
|
|
693
|
+
fireEvent.click(screen.getByRole('radio',{name:'Usage'}));
|
|
694
|
+
const alpha = await screen.findByRole('button',{name:'Alpha'});
|
|
695
|
+
fireEvent.click(alpha);
|
|
696
|
+
expect(alpha).toHaveAttribute('aria-pressed','true');
|
|
697
|
+
visibility = {...visibility,showCli:true,showExec:true};
|
|
698
|
+
fireEvent.click(screen.getByRole('button',{name:'Refresh'}));
|
|
699
|
+
await waitFor(()=>expect(server.call.mock.calls.filter(([input])=>input.name==='taskchef_app_usage').length).toBeGreaterThan(1));
|
|
700
|
+
expect(screen.getByRole('button',{name:'Alpha'})).toHaveAttribute('aria-pressed','true');
|
|
701
|
+
expect(screen.getByText(/All chat sources/)).toBeVisible();
|
|
702
|
+
});
|
|
703
|
+
|
|
685
704
|
test("Calendar replaces the update window and opens matching chat details", async () => {
|
|
686
705
|
tasks = [task('one', 'needs_input', '2020-01-01T00:00:00Z')];
|
|
687
706
|
details = new Map(tasks.map(item => [item.id, item]));
|
|
@@ -461,7 +461,7 @@ export function TaskChefApp() {
|
|
|
461
461
|
{view === "list" && <Paper className="taskchef-toolbar" radius={0}>
|
|
462
462
|
<Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>
|
|
463
463
|
</Paper>}
|
|
464
|
-
{view === "calendar" ? <CalendarView request={readCalendar} showProject={!project} tasks={boardTasks} refreshKey={`${revisionRef.current}:${calendarHistoryRevision.current}:${new Date(now).toDateString()}:${usageRefreshKey}:${showCli}:${showExec}:${showArchived}`} onOpenDetail={task => void select(task)} /> : view === "usage" ? <UsageView
|
|
464
|
+
{view === "calendar" ? <CalendarView request={readCalendar} showProject={!project} tasks={boardTasks} refreshKey={`${revisionRef.current}:${calendarHistoryRevision.current}:${new Date(now).toDateString()}:${usageRefreshKey}:${showCli}:${showExec}:${showArchived}`} onOpenDetail={task => void select(task)} /> : view === "usage" ? <UsageView request={readUsage} refreshKey={usageRefreshKey} /> : <>
|
|
465
465
|
{!!scan?.scheduleErrors && <Alert color="yellow" role="alert">{scan.scheduleErrors} schedule files could not be read; schedule placement may be incomplete.</Alert>}
|
|
466
466
|
{view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {eligibleTasks.length}</Text>}
|
|
467
467
|
{error && <Alert color="red" role="alert" mt="sm">{error}</Alert>}
|
|
@@ -230,16 +230,20 @@ body { background: var(--taskchef-page); }
|
|
|
230
230
|
.usage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
231
231
|
.usage-view button:disabled { opacity: .5; cursor: default; }
|
|
232
232
|
.usage-view h2 { font-size: 24px; margin: 4px 0 8px; }
|
|
233
|
+
.usage-view h4 { font-size: 12px; font-weight: 500; margin: 16px 0 4px; color: var(--taskchef-muted); }
|
|
233
234
|
.usage-view h3 { font-size: 14px; margin: 0 0 12px; }
|
|
234
235
|
.usage-view p, .usage-view small { color: var(--taskchef-muted); font-size: 12px; line-height: 1.5; margin: 8px 0; }
|
|
235
236
|
.usage-ranges, .usage-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
|
236
237
|
.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
238
|
.usage-view button:hover, .usage-view button[aria-pressed="true"] { background: var(--taskchef-hover); border-color: var(--taskchef-muted); }
|
|
238
239
|
.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(
|
|
240
|
+
.usage-totals { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
|
|
240
241
|
.usage-totals article, .usage-panel { padding: 18px; border: 1px solid var(--taskchef-border); border-radius: 10px; background: var(--taskchef-surface); min-width: 0; }
|
|
241
242
|
.usage-totals strong { font-size: 28px; font-weight: 600; }
|
|
242
243
|
.usage-panel { margin: 16px 0; }
|
|
244
|
+
.usage-weekly-chart { display: block; width: 100%; font: 9px var(--taskchef-font, sans-serif); fill: var(--taskchef-muted); }
|
|
245
|
+
.usage-weekly h3 { margin-bottom: 8px; }
|
|
246
|
+
.usage-weekly-reset { min-height: 18px; }
|
|
243
247
|
.usage-legend { font-size: 12px; gap: 12px; }
|
|
244
248
|
.usage-legend span, .usage-legend button { display: inline-flex; align-items: center; gap: 6px; }
|
|
245
249
|
.usage-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
|
@@ -262,7 +266,8 @@ body { background: var(--taskchef-page); }
|
|
|
262
266
|
.usage-rank-track { flex: 1; height: 8px; border-radius: 2px; background: var(--taskchef-border); overflow: hidden; }
|
|
263
267
|
.usage-rank-track > span { display: block; height: 100%; }
|
|
264
268
|
.usage-warning { padding: 10px; border: 1px solid var(--taskchef-border); border-radius: 6px; }
|
|
265
|
-
@media (max-width:
|
|
269
|
+
@media (max-width: 700px) { .usage-totals { grid-template-columns: 1fr; gap: 10px; } }
|
|
270
|
+
@media (max-width: 500px) { .usage-panel { padding: 12px; } .usage-rank-track { display: none; } .usage-ranking button > span:first-child { width: 100%; } }
|
|
266
271
|
|
|
267
272
|
.usage-progress { margin: 16px 0; }
|
|
268
273
|
.usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
|
package/src/mcp-app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { taskChefStateDir } from "./taskchef-state.js";
|
|
2
|
+
import { readUsageHistory, writeUsageHistory } from "./usage-history-cache.js";
|
|
2
3
|
import { aggregateUsage } from "./usage-analytics.js";
|
|
3
4
|
import { mkdir, readFile } from "node:fs/promises";
|
|
4
5
|
import { homedir } from "node:os";
|
|
@@ -228,31 +229,45 @@ export function registerTaskChefApp(server, {
|
|
|
228
229
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
229
230
|
}, async ({ range, timeZone, force, rescan }) => {
|
|
230
231
|
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
231
|
-
const settings = await readSettings();
|
|
232
232
|
// History is independent of chart range and timezone. Range changes query
|
|
233
233
|
// this shared snapshot; only expiry or explicit Refresh starts another read.
|
|
234
|
-
|
|
234
|
+
// The new scope cannot hydrate an older, visibility-filtered snapshot.
|
|
235
|
+
const key = JSON.stringify([scanner.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"), "all-sources"]);
|
|
236
|
+
const cacheDirectory = join(dirname(settingsPath), "cache", "usage-history");
|
|
235
237
|
let job = analyticsJobs.get(key);
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } };
|
|
238
|
+
if (!job) {
|
|
239
|
+
job = { families: null, error: null, finishedAt: 0, pending: false, needsRefresh: true,
|
|
240
|
+
progress: { completed: 0, total: 0 } };
|
|
240
241
|
analyticsJobs.set(key, job);
|
|
241
242
|
const current = job;
|
|
243
|
+
// Share hydration between concurrent views before starting one background scan.
|
|
244
|
+
job.hydrating = readUsageHistory(cacheDirectory, key).then(saved => {
|
|
245
|
+
if (saved) { current.families = saved.families; current.finishedAt = saved.savedAt; }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
await job.hydrating;
|
|
249
|
+
if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
|
|
250
|
+
if (!job.pending && (job.needsRefresh || force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000))) {
|
|
251
|
+
Object.assign(job, { error: null, needsRefresh: false,
|
|
252
|
+
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } });
|
|
253
|
+
const current = job;
|
|
242
254
|
Promise.resolve().then(async () => {
|
|
243
255
|
do {
|
|
244
256
|
current.refreshAgain = false;
|
|
245
257
|
const rebuild = current.rescan;
|
|
246
258
|
current.rescan = false;
|
|
247
|
-
const families = await scanner.usageHistory({
|
|
259
|
+
const families = await scanner.usageHistory({ rescan: rebuild, onProgress: progress => { current.progress = progress; } });
|
|
248
260
|
current.families = families;
|
|
249
261
|
current.finishedAt = Date.now();
|
|
262
|
+
// Cache failures never hide readable usage or replace a last good snapshot.
|
|
263
|
+
await writeUsageHistory(cacheDirectory, key, families, current.finishedAt).catch(() => {});
|
|
250
264
|
} while (current.refreshAgain);
|
|
251
265
|
}).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
|
|
252
266
|
}
|
|
253
|
-
if (job.error) throw job.error;
|
|
267
|
+
if (job.error && !job.families) throw job.error;
|
|
254
268
|
const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
|
|
255
269
|
updatedAt: new Date(job.finishedAt).toISOString(),
|
|
270
|
+
...(job.error ? { refreshError: "Saved usage is shown. Background refresh failed." } : {}),
|
|
256
271
|
...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
|
|
257
272
|
: { loading: true, progress: job.progress };
|
|
258
273
|
return { structuredContent: data, content: [] };
|
package/src/usage-analytics.js
CHANGED
|
@@ -16,13 +16,25 @@ export function aggregateUsage(families, { range, timeZone, now = Date.now() })
|
|
|
16
16
|
const start = startDate.toISOString().slice(0, 10);
|
|
17
17
|
const days = [];
|
|
18
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: {} });
|
|
19
|
+
days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {}, projectTokens: {} });
|
|
20
20
|
}
|
|
21
21
|
const byDate = new Map(days.map(day => [day.date, day]));
|
|
22
22
|
const projects = new Map();
|
|
23
|
-
|
|
23
|
+
const limitDays = new Map();
|
|
24
|
+
let incomplete = false, samples = 0, latestWeeklyLimit = null;
|
|
24
25
|
const totals = { tokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
25
26
|
for (const { project, usage } of families) {
|
|
27
|
+
// Account allowance snapshots are observations, never summed across chats.
|
|
28
|
+
for (const sample of usage.weeklyLimits ?? []) {
|
|
29
|
+
if (!Number.isFinite(sample.time) || sample.time > now || !Number.isFinite(sample.remainingPercent)
|
|
30
|
+
|| sample.remainingPercent < 0 || sample.remainingPercent > 100 || !Number.isFinite(sample.resetsAt) || sample.resetsAt <= sample.time) continue;
|
|
31
|
+
if (sample.id !== "codex") continue;
|
|
32
|
+
if (!latestWeeklyLimit || sample.time > latestWeeklyLimit.time) latestWeeklyLimit = sample;
|
|
33
|
+
const date = dateKey(sample.time);
|
|
34
|
+
if (!byDate.has(date)) continue;
|
|
35
|
+
const key = JSON.stringify([sample.id, date]);
|
|
36
|
+
if (!limitDays.has(key) || sample.time > limitDays.get(key).time) limitDays.set(key, { ...sample, date });
|
|
37
|
+
}
|
|
26
38
|
// Coverage can be incomplete even when no timestamped records survive.
|
|
27
39
|
incomplete ||= usage.analyticsPartial || usage.partial || false;
|
|
28
40
|
const key = project.id === 'projectless' ? 'unassigned' : project.id || project.path || 'unassigned';
|
|
@@ -38,10 +50,13 @@ export function aggregateUsage(families, { range, timeZone, now = Date.now() })
|
|
|
38
50
|
totals.costUsd += sample.costUsd; samples += sample.samples;
|
|
39
51
|
day.tokens += tokens; day.costUsd += sample.costUsd;
|
|
40
52
|
day.projects[key] = (day.projects[key] ?? 0) + sample.costUsd;
|
|
53
|
+
day.projectTokens[key] = (day.projectTokens[key] ?? 0) + tokens;
|
|
41
54
|
item.tokens += tokens; item.costUsd += sample.costUsd;
|
|
42
55
|
incomplete ||= sample.partial || sample.costPartial;
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
58
|
return { start, end, timeZone, days, totals, samples, incomplete,
|
|
59
|
+
currentWeeklyLimit: latestWeeklyLimit?.resetsAt > now ? latestWeeklyLimit : null,
|
|
60
|
+
weeklyLimits: [...limitDays.values()].sort((a, b) => a.time - b.time || a.id.localeCompare(b.id)),
|
|
46
61
|
projects: [...projects.values()].sort((a, b) => b.costUsd - a.costUsd || a.name.localeCompare(b.name)) };
|
|
47
62
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { writeDurableAtomic } from "./state-store.js";
|
|
6
|
+
|
|
7
|
+
const nonnegative = z.number().finite().nonnegative();
|
|
8
|
+
const timestamp = nonnegative.max(8.64e15);
|
|
9
|
+
// Keep only fields needed by aggregateUsage, not message content or per-file records.
|
|
10
|
+
const family = z.object({
|
|
11
|
+
project: z.object({ id: z.string().nullable().optional(), name: z.string(), path: z.string().optional() }),
|
|
12
|
+
usage: z.object({
|
|
13
|
+
analyticsPartial: z.boolean().optional(), partial: z.boolean().optional(),
|
|
14
|
+
timeline: z.array(z.object({
|
|
15
|
+
time: timestamp, costUsd: nonnegative, samples: nonnegative.int(),
|
|
16
|
+
partial: z.boolean(), costPartial: z.boolean(),
|
|
17
|
+
tokens: z.object({ total_tokens: nonnegative.int(), input_tokens: nonnegative.int(), cached_input_tokens: nonnegative.int(), output_tokens: nonnegative.int() }),
|
|
18
|
+
})),
|
|
19
|
+
weeklyLimits: z.array(z.object({
|
|
20
|
+
id: z.string(), name: z.string().nullable(), time: timestamp,
|
|
21
|
+
remainingPercent: nonnegative.max(100), resetsAt: timestamp,
|
|
22
|
+
})).optional(),
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
const snapshot = z.object({ version: z.literal(1), scope: z.string(), savedAt: timestamp, families: z.array(family) });
|
|
26
|
+
const cachePath = (directory, scope) => join(directory, `${createHash("sha256").update(scope).digest("hex")}.json`);
|
|
27
|
+
|
|
28
|
+
export async function readUsageHistory(directory, scope) {
|
|
29
|
+
try {
|
|
30
|
+
const value = snapshot.parse(JSON.parse(await readFile(cachePath(directory, scope), "utf8")));
|
|
31
|
+
return value.scope === scope && value.savedAt <= Date.now() ? value : null;
|
|
32
|
+
} catch { return null; } // Missing, incompatible, or damaged caches rebuild from source.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeUsageHistory(directory, scope, families, savedAt) {
|
|
36
|
+
const value = snapshot.parse({ version: 1, scope, savedAt, families });
|
|
37
|
+
await writeDurableAtomic(cachePath(directory, scope), `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
38
|
+
}
|