taskchef 9.5.0 → 9.7.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.
@@ -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) < 5 ? data.projects.indexOf(project) : 5] }]
62
- : [...data.projects.slice(0, 5).map((item, index) => ({ ...item, color: colors[index] })),
63
- ...(data.projects.length > 5 ? [{ id: "__other", name: "Other", costUsd: 0, tokens: 0, color: colors[5] }] : [])]) : [];
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(5).reduce((sum, item) => sum + (day.projects[item.id] ?? 0), 0) : day.projects[id] ?? 0;
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 && <>
101
+ {data.refreshError && <p role="alert">{data.refreshError} <button onClick={() => setRetry(value => value + 1)}>Retry</button></p>}
94
102
  <p className="usage-period">{dateLabel(data.start)} – {dateLabel(data.end)} · {data.timeZone} · Daily · Includes archived chats and subagent usage{data.updatedAt && <> · Updated {new Date(data.updatedAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>}</p>
95
- {!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
96
- <div className="usage-totals">
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
- <article className="usage-panel" aria-label="Tokens and cost over time"><h3>Tokens and cost over time</h3>
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 ? "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>
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="Cost by project over time"><h3>Cost by project over time</h3>
114
- <p>{project ? `${project.name} · ` : "Top 5 projects + Other · "}Select a project below to isolate its cost.</p>
115
- <div className="usage-legend"><button aria-pressed={!project} onMouseEnter={() => setHighlighted(null)} onFocus={() => setHighlighted(null)} onClick={() => setSelected(null)}>All projects</button>{data.projects.slice(0, 5).map((item, index) => <button key={item.id} aria-pressed={project?.id === item.id} onMouseEnter={() => setHighlighted(item.id)} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted(item.id)} onBlur={() => setHighlighted(null)} onClick={() => setSelected(project?.id === item.id ? null : item.id)}><i style={{ background: colors[index] }} />{item.name}</button>)}{data.projects.length > 5 && <span tabIndex={0} onMouseEnter={() => setHighlighted("__other")} onMouseLeave={() => setHighlighted(null)} onFocus={() => setHighlighted("__other")} onBlur={() => setHighlighted(null)}><i style={{ background: colors[5] }} />Other</span>}</div>
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 < 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>
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
+ }
@@ -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(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
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: 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%; } }
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,3 +1,5 @@
1
+ import { taskChefStateDir } from "./taskchef-state.js";
2
+ import { readUsageHistory, writeUsageHistory } from "./usage-history-cache.js";
1
3
  import { aggregateUsage } from "./usage-analytics.js";
2
4
  import { mkdir, readFile } from "node:fs/promises";
3
5
  import { homedir } from "node:os";
@@ -65,7 +67,7 @@ export function registerTaskChefApp(server, {
65
67
  openThread = openThreadInCodex,
66
68
  getSettingsUrl = pluginSettingsUrl,
67
69
  openSettings = openPluginSettingsInCodex,
68
- settingsPath = join(homedir(), ".agents", "taskchef-next", "settings.json"),
70
+ settingsPath = join(taskChefStateDir(), "settings.json"),
69
71
  notificationsPath = join(dirname(settingsPath), "notifications.json"),
70
72
  createGitHub = (options) => new NextGitHub(options),
71
73
  } = {}) {
@@ -230,14 +232,25 @@ export function registerTaskChefApp(server, {
230
232
  const settings = await readSettings();
231
233
  // History is independent of chart range and timezone. Range changes query
232
234
  // this shared snapshot; only expiry or explicit Refresh starts another read.
233
- const key = JSON.stringify([settings.showCli, settings.showExec]);
235
+ const key = JSON.stringify([scanner.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"), settings.showCli, settings.showExec]);
236
+ const cacheDirectory = join(dirname(settingsPath), "cache", "usage-history");
234
237
  let job = analyticsJobs.get(key);
235
- if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
236
- if (!job || (!job.pending && (force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000)))) {
237
- job = { families: job?.families ?? null, error: null, finishedAt: job?.finishedAt ?? 0,
238
- 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 } };
239
241
  analyticsJobs.set(key, job);
240
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;
241
254
  Promise.resolve().then(async () => {
242
255
  do {
243
256
  current.refreshAgain = false;
@@ -246,12 +259,15 @@ export function registerTaskChefApp(server, {
246
259
  const families = await scanner.usageHistory({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
247
260
  current.families = families;
248
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(() => {});
249
264
  } while (current.refreshAgain);
250
265
  }).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
251
266
  }
252
- if (job.error) throw job.error;
267
+ if (job.error && !job.families) throw job.error;
253
268
  const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
254
269
  updatedAt: new Date(job.finishedAt).toISOString(),
270
+ ...(job.error ? { refreshError: "Saved usage is shown. Background refresh failed." } : {}),
255
271
  ...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
256
272
  : { loading: true, progress: job.progress };
257
273
  return { structuredContent: data, content: [] };
@@ -0,0 +1,47 @@
1
+ import { existsSync, lstatSync, realpathSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const dispatcherFiles = ["tasks.jsonl", "taskchef.json", ".taskchef-usage.json"];
6
+ const nativeEntries = ["settings.json", "done.json", "github-auth", "cache", "notifications.json", "notifications.json.errors.json", "delegation-memory.md", "usage-cache", "pricing.json"];
7
+
8
+ export function isNativeStateDir(directory) {
9
+ const entries = readdirSync(directory);
10
+ return !hasDispatcherState(directory) && (!entries.length || entries.some(name => nativeEntries.includes(name)));
11
+ }
12
+
13
+ export function taskChefStatePaths(home = homedir()) {
14
+ const agents = join(home, ".agents");
15
+ return { agents, current: join(agents, "taskchef"), previous: join(agents, "taskchef-next"), archive: join(agents, "taskchef-old"), registry: join(home, ".codex", "taskchef"), lock: join(agents, ".taskchef-storage-migration") };
16
+ }
17
+
18
+ export function hasDispatcherState(directory) {
19
+ return dispatcherFiles.some(name => existsSync(join(directory, name)));
20
+ }
21
+
22
+ // Resolve paths only. Moving user data requires the explicit offline migration.
23
+ export function taskChefStateDir(home = homedir()) {
24
+ const paths = taskChefStatePaths(home);
25
+ const fail = () => { throw new Error("TaskChef storage needs migration. Quit Codex and other TaskChef clients, then run node scripts/migrate-taskchef-state.js from the installed TaskChef package. See docs/taskchef-next-storage.md. No state was moved."); };
26
+ if (existsSync(paths.lock)) fail();
27
+ const retired = hasDispatcherState(paths.current);
28
+ let previousDirectory = false;
29
+ for (const directory of [paths.current, paths.previous]) {
30
+ let info;
31
+ try { info = lstatSync(directory); } catch (error) { if (error.code === "ENOENT") continue; throw error; }
32
+ if (directory === paths.previous && info.isSymbolicLink()) {
33
+ try { if (realpathSync(directory) === realpathSync(paths.current)) continue; } catch { /* Fail closed for dangling links. */ }
34
+ }
35
+ if (!info.isDirectory() || info.isSymbolicLink()) fail();
36
+ if (directory === paths.previous) previousDirectory = true;
37
+ }
38
+ if (previousDirectory) {
39
+ // Upgraded packages remain usable before the offline move. Never read the
40
+ // retired dispatcher directory as native state, and never choose between
41
+ // two independent native stores.
42
+ if ((!retired && existsSync(paths.current)) || !isNativeStateDir(paths.previous)) fail();
43
+ return paths.previous;
44
+ }
45
+ if (retired) fail();
46
+ return paths.current;
47
+ }
@@ -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
- let incomplete = false, samples = 0;
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
+ }