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.
@@ -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 && <>
94
- <p className="usage-period">{dateLabel(data.start)} – {dateLabel(data.end)} · {data.timeZone} · Daily · Includes archived chats and subagent usage{data.updatedAt && <> · Updated {new Date(data.updatedAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>}</p>
95
- {!data.samples ? <p role="status">No timestamped usage is available for this period.</p> : <>
96
- <div className="usage-totals">
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
- <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
+ }
@@ -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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } });
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: {
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: {
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: {
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: {
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-next?marketplacePath=%2Fexample", settings: visibility, snapshot: { tasks, projects: registeredProjects, healthy: true } } };
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 key={`${showCli}:${showExec}`} request={readUsage} refreshKey={usageRefreshKey} /> : <>
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(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,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
- const key = JSON.stringify([settings.showCli, settings.showExec]);
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 ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
237
- if (!job || (!job.pending && (force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000)))) {
238
- job = { families: job?.families ?? null, error: null, finishedAt: job?.finishedAt ?? 0,
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({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
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: [] };
@@ -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
+ }