taskchef 9.4.0 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ import { afterEach, expect, test, vi } from "vitest";
2
+ import "@testing-library/jest-dom/vitest";
3
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { CalendarView, type CalendarData } from "./CalendarView";
5
+ import type { Task } from "../../ui/types";
6
+ afterEach(() => { cleanup(); vi.useRealTimers(); });
7
+ const month = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; };
8
+ const task = (id: string): Task => ({ id, title: `Chat ${id}`, project: { id: 'project', name: 'Project', path: '/repo', githubRepos: [] }, status: 'needs_input', instruction: '', createdAt: '', updatedAt: '', summary: '', threadId: null, turnRef: null, turnId: null, lastResult: null, latestTurn: null });
9
+ function data(count = 1): CalendarData { return { month: month(), timeZone: 'UTC', chats: Array.from({ length: count }, (_, i) => ({ id: String(i), incomplete: false, segments: [{ start: `${month()}-05`, end: `${month()}-05`, resumed: false }, { start: `${month()}-26`, end: `${month()}-26`, resumed: true }] })) }; }
10
+ test('paused and resumed segments open the same chat; navigation has no Today control', async () => {
11
+ const onOpenDetail = vi.fn(), request = vi.fn(async () => data());
12
+ render(<CalendarView request={request} tasks={[task('0')]} refreshKey="1" onOpenDetail={onOpenDetail} />);
13
+ const bars = await screen.findAllByRole('button', { name: /Project · Chat 0/ });
14
+ expect(bars).toHaveLength(2);
15
+ expect(bars[1]).toHaveTextContent('↩');
16
+ fireEvent.click(bars[0]); fireEvent.click(bars[1]);
17
+ expect(onOpenDetail).toHaveBeenNthCalledWith(1, task('0'));
18
+ expect(onOpenDetail).toHaveBeenNthCalledWith(2, task('0'));
19
+ expect(screen.queryByRole('button', { name: 'Today' })).not.toBeInTheDocument();
20
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeDisabled();
21
+ fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
22
+ expect(request).toHaveBeenCalledTimes(1);
23
+ fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
24
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
25
+ expect(request.mock.calls[1]).not.toEqual(request.mock.calls[0]);
26
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeEnabled();
27
+ fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
28
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(3));
29
+ expect(request.mock.calls[2]).toEqual(request.mock.calls[0]);
30
+ expect(screen.getByRole('button', { name: 'Next month' })).toBeDisabled();
31
+ });
32
+ test('crowded weeks expand and filters remove nonmatching chats', async () => {
33
+ const props = { request: vi.fn(async () => data(5)), tasks: Array.from({ length: 5 }, (_, i) => task(String(i))), refreshKey: '1', onOpenDetail: vi.fn() };
34
+ const { rerender } = render(<CalendarView {...props} />);
35
+ const more = await screen.findAllByRole('button', { name: /more this week/ });
36
+ fireEvent.click(more[0]);
37
+ expect(screen.getByRole('button', { name: 'Show fewer' })).toBeInTheDocument();
38
+ expect(screen.getAllByRole('button', { name: /Project · Chat 4/ }).length).toBeGreaterThan(0);
39
+ rerender(<CalendarView {...props} tasks={[]} />);
40
+ expect(screen.queryByRole('button', { name: /Project · Chat/ })).not.toBeInTheDocument();
41
+ expect(screen.getByText(/No recorded work matches/)).toBeInTheDocument();
42
+ });
43
+ test('failed requests show retry without adding a missing-history notice', async () => {
44
+ const result = data(); result.chats[0].incomplete = true;
45
+ const request = vi.fn().mockRejectedValueOnce(new Error('Unavailable')).mockResolvedValue(result);
46
+ render(<CalendarView request={request} tasks={[task('0')]} refreshKey="1" onOpenDetail={vi.fn()} />);
47
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unavailable');
48
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
49
+ expect(await screen.findAllByRole('button', { name: /Project · Chat 0/ })).toHaveLength(2);
50
+ expect(screen.queryByText(/Some turn dates or durations are missing/)).not.toBeInTheDocument();
51
+ });
52
+
53
+ test('a selected project hides bar prefixes and persistent helper captions are absent', async () => {
54
+ render(<CalendarView request={async () => data()} tasks={[task('0')]} refreshKey="1" showProject={false} onOpenDetail={vi.fn()} />);
55
+ expect(await screen.findByRole('button', { name: 'Chat 0' })).toBeInTheDocument();
56
+ expect(screen.getByRole('button', { name: '↩ Chat 0' })).toBeInTheDocument();
57
+ expect(screen.queryByRole('button', { name: /Project ·/ })).not.toBeInTheDocument();
58
+ expect(screen.queryByText(/Recorded work ·|Selected Codex history only/)).not.toBeInTheDocument();
59
+ });
@@ -0,0 +1,91 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Task } from "../../ui/types";
3
+
4
+ export interface CalendarSegment { start: string; end: string; resumed: boolean; }
5
+ export interface CalendarData { month: string; timeZone: string; chats: { id: string; incomplete: boolean; segments: CalendarSegment[] }[]; }
6
+ export type CalendarRequest = (args: { month: string; timeZone: string }) => Promise<CalendarData>;
7
+ const dateKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
8
+ const addDays = (key: string, days: number) => new Date(Date.parse(`${key}T12:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
9
+ const statusLabels = { working: "Running", needs_input: "Waiting for review", completed: "Done", failed: "Failed", scheduled: "Scheduled", interrupted: "Interrupted", archived: "Archived" };
10
+ const projectHue = (task: Task) => [...(task.project.id || task.project.path || task.project.name)].reduce((hash, c) => (hash * 31 + c.charCodeAt(0)) % 360, 0);
11
+
12
+ export function CalendarView({ request, tasks, refreshKey, onOpenDetail, showProject = true }: { request: CalendarRequest; tasks: Task[]; refreshKey: string; showProject?: boolean; onOpenDetail: (task: Task) => void }) {
13
+ const [month, setMonth] = useState(() => dateKey(new Date()).slice(0, 7));
14
+ const [data, setData] = useState<CalendarData | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [retry, setRetry] = useState(0);
17
+ const [expanded, setExpanded] = useState<string[]>([]);
18
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
19
+ useEffect(() => {
20
+ let current = true;
21
+ setError(null);
22
+ void request({ month, timeZone }).then(value => { if (current) setData(value); })
23
+ .catch(cause => { if (current) { setData(null); setError(String(cause)); } });
24
+ return () => { current = false; };
25
+ }, [month, timeZone, request, refreshKey, retry]);
26
+ const currentMonth = dateKey(new Date()).slice(0, 7);
27
+ function navigate(delta: number) {
28
+ if (delta > 0 && month >= currentMonth) return;
29
+ const date = new Date(`${month}-01T12:00:00Z`);
30
+ date.setUTCMonth(date.getUTCMonth() + delta);
31
+ setMonth(date.toISOString().slice(0, 7)); setData(null); setExpanded([]);
32
+ }
33
+ const first = `${month}-01`;
34
+ const offset = (new Date(`${first}T12:00:00Z`).getUTCDay() + 6) % 7;
35
+ const gridStart = addDays(first, -offset);
36
+ const daysInMonth = new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0).getDate();
37
+ const weeks = Math.ceil((offset + daysInMonth) / 7);
38
+ const label = new Date(`${first}T12:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
39
+ const byId = new Map(tasks.map(task => [task.id, task]));
40
+ const chats = data?.month === month ? data.chats.filter(chat => byId.has(chat.id)) : [];
41
+ const bars = chats.flatMap(chat => chat.segments.map(segment => ({ ...segment, task: byId.get(chat.id)! })));
42
+ const today = dateKey(new Date());
43
+ return <section className="calendar-view" aria-label="Chat work calendar">
44
+ <nav className="calendar-navigation" aria-label="Month navigation">
45
+ <button aria-label="Previous month" onClick={() => navigate(-1)}>‹</button>
46
+ <h2 aria-live="polite">{label}</h2>
47
+ <button aria-label="Next month" disabled={month >= currentMonth} onClick={() => navigate(1)}>›</button>
48
+ </nav>
49
+ {error && <div role="alert">Calendar could not be loaded: {error} <button onClick={() => setRetry(value => value + 1)}>Retry</button></div>}
50
+ {!data && !error && <p role="status">Reading recorded work…</p>}
51
+ <div className="calendar-scroll" tabIndex={0} aria-label="Month calendar; scroll horizontally on narrow screens">
52
+ <div className="calendar-month">
53
+ <div className="calendar-weekdays">{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(day => <div key={day}>{day}</div>)}</div>
54
+ {Array.from({ length: weeks }, (_, week) => {
55
+ const start = addDays(gridStart, week * 7), end = addDays(start, 6);
56
+ const weekBars = bars.filter(bar => bar.start <= end && bar.end >= start).sort((a, b) => a.start.localeCompare(b.start) || b.end.localeCompare(a.end) || a.task.title.localeCompare(b.task.title) || a.task.id.localeCompare(b.task.id));
57
+ const lanes: string[] = [];
58
+ const placed = weekBars.map(bar => {
59
+ const left = bar.start < start ? start : bar.start, right = bar.end > end ? end : bar.end;
60
+ let lane = lanes.findIndex(last => last < left);
61
+ if (lane < 0) lane = lanes.length;
62
+ lanes[lane] = right;
63
+ return { ...bar, left, right, lane };
64
+ });
65
+ const open = expanded.includes(start);
66
+ const visible = placed.filter(bar => open || bar.lane < 3);
67
+ const hidden = placed.length - visible.length;
68
+ return <div className="calendar-week" key={start}>
69
+ <div className="calendar-week-grid" style={{ gridTemplateRows: `30px repeat(${Math.max(3, open ? lanes.length : Math.min(3, lanes.length))}, 28px)` }}>
70
+ <div className="calendar-day-backgrounds" aria-hidden>{Array.from({ length: 7 }, (_, col) => <div className={col > 4 ? "calendar-weekend" : ""} key={col} />)}</div>
71
+ {Array.from({ length: 7 }, (_, col) => {
72
+ const day = addDays(start, col);
73
+ return <div key={day} className={`calendar-day${day.slice(0, 7) !== month ? " calendar-outside" : ""}${day === today ? " calendar-today" : ""}`} style={{ gridColumn: col + 1, gridRow: 1 }}><time dateTime={day}>{Number(day.slice(-2))}</time></div>;
74
+ })}
75
+ {visible.map(bar => {
76
+ const col = Math.round((Date.parse(bar.left) - Date.parse(start)) / 86400000);
77
+ const span = Math.round((Date.parse(bar.right) - Date.parse(bar.left)) / 86400000) + 1;
78
+ const title = showProject ? `${bar.task.project.name} · ${bar.task.title}` : bar.task.title;
79
+ return <button key={`${bar.task.id}:${bar.start}`} className="calendar-bar" style={{ gridColumn: `${col + 1} / span ${span}`, gridRow: bar.lane + 2, borderColor: `hsl(${projectHue(bar.task)} 45% 55%)`, backgroundColor: `hsl(${projectHue(bar.task)} 45% 55% / .17)` }} title={`${title}\n${bar.start} – ${bar.end}${bar.resumed ? ' · Resumed' : ''}\nCurrent status: ${bar.task.status ? statusLabels[bar.task.status] : 'Unverified'}`} onClick={() => onOpenDetail(bar.task)}>
80
+ {bar.start < start ? "← " : bar.resumed ? "↩ " : ""}{title}{bar.end > end ? " →" : ""}
81
+ </button>;
82
+ })}
83
+ </div>
84
+ {(hidden > 0 || open) && <button className="calendar-more" onClick={() => setExpanded(value => open ? value.filter(item => item !== start) : [...value, start])}>{open ? "Show fewer" : `+${hidden} more this week`}</button>}
85
+ </div>;
86
+ })}
87
+ </div>
88
+ </div>
89
+ {data && !error && !bars.length && <p role="status">No recorded work matches this month and these filters.</p>}
90
+ </section>;
91
+ }
@@ -1,12 +1,13 @@
1
1
  import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
2
- import { afterEach, expect, test, vi } from 'vitest';
2
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
3
3
  import { UsageView, type UsageData } from './UsageView';
4
+ beforeEach(() => window.localStorage.clear());
4
5
  afterEach(cleanup);
5
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
- test('defaults to This month, changes range, isolates projects, and exposes exact day values',async()=>{
7
+ test('defaults to 30 days, changes range, isolates projects, and exposes exact day values',async()=>{
7
8
  const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
8
9
  await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
9
- expect(request).toHaveBeenCalledWith({range:'mtd',timeZone:expect.any(String)});
10
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
10
11
  expect(screen.queryByText(/Custom/)).toBeNull();
11
12
  fireEvent.click(screen.getByRole('button',{name:'7 days'}));
12
13
  await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'7d',timeZone:expect.any(String)}));
@@ -39,7 +40,7 @@ test('polls a pending history read and stops polling when unmounted',async()=>{
39
40
  const request=vi.fn().mockResolvedValueOnce({loading:true}).mockResolvedValue(data);
40
41
  const view=render(<UsageView request={request}/>);expect(await screen.findByRole('status')).toHaveTextContent('Reading saved usage');
41
42
  await waitFor(()=>expect(screen.getByRole('table')).toBeVisible(),{timeout:2000});
42
- expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String)});
43
+ expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String)});
43
44
  view.unmount();
44
45
  const pending=vi.fn().mockResolvedValue({loading:true});const second=render(<UsageView request={pending}/>);await waitFor(()=>expect(pending).toHaveBeenCalledTimes(1));second.unmount();
45
46
  });
@@ -63,7 +64,7 @@ test('Refresh forces one new read and shows measured progress while cached data
63
64
  const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
64
65
  view.rerender(<UsageView request={request} refreshKey={1}/>);
65
66
  await screen.findByText('Updating saved usage… 2 of 10 chats');expect(screen.getByRole('progressbar',{name:'Usage scan progress'})).toHaveAttribute('value','2');expect(screen.getByRole('table')).toBeVisible();
66
- expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String),force:true});await waitFor(()=>expect(screen.queryByRole('progressbar')).toBeNull());
67
+ expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),force:true});await waitFor(()=>expect(screen.queryByRole('progressbar')).toBeNull());
67
68
  });
68
69
  test('legend hover and focus highlight only matching project bars and reset on leave',async()=>{
69
70
  const request=vi.fn().mockResolvedValue(data);const view=render(<UsageView request={request}/>);await waitFor(()=>expect(screen.getByRole('table')).toBeVisible());
@@ -78,7 +79,7 @@ test('legend hover and focus highlight only matching project bars and reset on l
78
79
  await screen.findByRole('table');
79
80
  for(const text of [/Incomplete coverage/,/Known subtotal/,/Cached input is included/,/right axis/,/left axis/,/By estimated cost/,/Missing usage is not zero/]) expect(screen.queryByText(text)).toBeNull();
80
81
  fireEvent.click(screen.getByRole('button',{name:'Rescan'}));
81
- await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'mtd',timeZone:expect.any(String),rescan:true}));
82
+ await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'30d',timeZone:expect.any(String),rescan:true}));
82
83
  });
83
84
 
84
85
  test('only the current local day has dashed bars and token segment; a completed period stays solid',async()=>{
@@ -101,3 +102,23 @@ test('only the current local day has dashed bars and token segment; a completed
101
102
  expect(screen.getByRole('img',{name:'Daily project cost bars: Alpha'}).querySelectorAll('[stroke-dasharray]')).toHaveLength(0);
102
103
  } finally {vi.useRealTimers();}
103
104
  });
105
+
106
+ test('remembers the selected range after leaving and reopening Usage', async () => {
107
+ const request=vi.fn().mockResolvedValue(data);
108
+ const first=render(<UsageView request={request}/>);
109
+ await screen.findByRole('table');
110
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
111
+ fireEvent.click(screen.getByRole('button',{name:'90 days'}));
112
+ await waitFor(()=>expect(request).toHaveBeenLastCalledWith({range:'90d',timeZone:expect.any(String)}));
113
+ first.unmount(); request.mockClear();
114
+ render(<UsageView request={request}/>);
115
+ await screen.findByRole('table');
116
+ expect(request).toHaveBeenCalledWith({range:'90d',timeZone:expect.any(String)});
117
+ expect(screen.getByRole('button',{name:'90 days'})).toHaveAttribute('aria-pressed','true');
118
+ });
119
+ test('invalid saved Usage ranges fall back to 30 days', async () => {
120
+ window.localStorage.setItem('taskchef.app.usage.range',JSON.stringify('invalid'));
121
+ const request=vi.fn().mockResolvedValue(data);render(<UsageView request={request}/>);
122
+ await screen.findByRole('table');
123
+ expect(request).toHaveBeenCalledWith({range:'30d',timeZone:expect.any(String)});
124
+ });
@@ -1,3 +1,4 @@
1
+ import { usePreference } from "./preferences";
1
2
  import { useEffect, useRef, useState } from "react";
2
3
 
3
4
  export interface UsageData {
@@ -18,7 +19,7 @@ const colors = ["#6e91ad", "#80ad99", "#b8a0ca", "#c2a06e", "#c78585", "#92979c"
18
19
  const left = 64, right = 794, top = 16, bottom = 166;
19
20
 
20
21
  export function UsageView({ request, refreshKey = 0 }: { request: (args: ReturnType<typeof usageRequest>) => Promise<UsageResponse>; refreshKey?: number }) {
21
- const [range, setRange] = useState<UsageRange>("mtd");
22
+ const [range, setRange] = usePreference("usage.range");
22
23
  const [data, setData] = useState<UsageData | null>(null);
23
24
  const [error, setError] = useState<string | null>(null);
24
25
  const [loading, setLoading] = useState(true);
@@ -681,3 +681,124 @@ test("Usage opens on demand and returns to Board without keeping its filters vis
681
681
  fireEvent.click(screen.getByRole("radio",{name:"Board"}));
682
682
  expect(screen.getByRole("region",{name:"Task board"})).toBeVisible();
683
683
  });
684
+
685
+ test("Calendar replaces the update window and opens matching chat details", async () => {
686
+ tasks = [task('one', 'needs_input', '2020-01-01T00:00:00Z')];
687
+ details = new Map(tasks.map(item => [item.id, item]));
688
+ const base = server.call.getMockImplementation()!;
689
+ server.call.mockImplementation(input => {
690
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: true }] }] } });
691
+ return base(input);
692
+ });
693
+ mount();
694
+ await screen.findByRole('radio', { name: 'Calendar' });
695
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
696
+ expect(screen.queryByLabelText('Updated')).not.toBeInTheDocument();
697
+ fireEvent.click(await screen.findByRole('button', { name: /Example · Task one/ }));
698
+ expect(await screen.findByRole('heading', { name: 'Task one' })).toBeInTheDocument();
699
+ });
700
+
701
+ test('Calendar scheduled toggle starts on, includes running schedules, and never filters Board', async () => {
702
+ tasks = [{ ...task('one', 'working'), scheduled: true }, task('two', 'needs_input')];
703
+ const base = server.call.getMockImplementation()!;
704
+ server.call.mockImplementation(input => {
705
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: tasks.map(item => ({ id: item.id, incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] })) } });
706
+ return base(input);
707
+ });
708
+ mount();
709
+ await screen.findByRole('button', { name: 'Task one' });
710
+ expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument();
711
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
712
+ expect(await screen.findByRole('button', { name: 'Example · Task one' })).toBeInTheDocument();
713
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'true');
714
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
715
+ expect(screen.queryByRole('button', { name: 'Example · Task one' })).not.toBeInTheDocument();
716
+ expect(screen.getByRole('button', { name: 'Example · Task two' })).toBeInTheDocument();
717
+ fireEvent.change(screen.getByRole('combobox', { name: 'Project' }), { target: { value: '/Example' } });
718
+ expect(screen.getByRole('button', { name: 'Task two' })).toBeInTheDocument();
719
+ expect(screen.queryByRole('button', { name: 'Example · Task two' })).not.toBeInTheDocument();
720
+ fireEvent.click(screen.getByRole('radio', { name: 'Board' }));
721
+ expect(screen.getByRole('button', { name: 'Task one' })).toBeInTheDocument();
722
+ expect(screen.queryByRole('button', { name: 'Scheduled' })).not.toBeInTheDocument();
723
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
724
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'false');
725
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
726
+ expect(await screen.findByRole('button', { name: 'Task one' })).toBeInTheDocument();
727
+ });
728
+
729
+ test('Calendar skips unchanged polls and rereads on data changes, explicit refresh, and date rollover', async () => {
730
+ const interval = vi.spyOn(window, 'setInterval');
731
+ let revision = 1, cacheHit = true;
732
+ const base = server.call.getMockImplementation()!;
733
+ server.call.mockImplementation(input => {
734
+ if (input.name === 'taskchef_app_snapshot') return Promise.resolve({ structuredContent: { snapshot: { tasks, projects: [], healthy: true, revision, scan: { cacheHit } }, settings: visibility } });
735
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] }] } });
736
+ return base(input);
737
+ });
738
+ const calendarReads = () => server.call.mock.calls.filter(([input]) => input.name === 'taskchef_app_calendar').length;
739
+ mount();
740
+ await screen.findByRole('button', { name: 'Task one' });
741
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
742
+ await screen.findByRole('button', { name: 'Example · Task one' });
743
+ expect(calendarReads()).toBe(1);
744
+ const poll = interval.mock.calls.find(([, delay]) => delay === 5000)![0] as () => void;
745
+ await act(async () => { poll(); });
746
+ expect(calendarReads()).toBe(1);
747
+ cacheHit = false;
748
+ await act(async () => { poll(); });
749
+ expect(calendarReads()).toBe(2);
750
+ cacheHit = true; revision += 1;
751
+ await act(async () => { poll(); });
752
+ expect(calendarReads()).toBe(3);
753
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); });
754
+ expect(calendarReads()).toBe(4);
755
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 86400000);
756
+ await act(async () => { poll(); });
757
+ expect(calendarReads()).toBe(5);
758
+ });
759
+
760
+ test('shared filters and the Calendar schedule preference survive reopening', async () => {
761
+ tasks = [{ ...task('one', 'working'), scheduled: true, observed: { pinned: true, archive: false, lastTurnEvent: 'inProgress', lastTurnEventAt: null, recentFileActivity: false } }];
762
+ const base = server.call.getMockImplementation()!;
763
+ server.call.mockImplementation(input => {
764
+ if (input.name === 'taskchef_app_calendar') return Promise.resolve({ structuredContent: { month: input.arguments.month, timeZone: input.arguments.timeZone, chats: [{ id: 'one', incomplete: false, segments: [{ start: `${input.arguments.month}-05`, end: `${input.arguments.month}-05`, resumed: false }] }] } });
765
+ return base(input);
766
+ });
767
+ const first = render(<TaskChefApp />);
768
+ await screen.findByRole('button', { name: 'Task one' });
769
+ fireEvent.change(screen.getByRole('combobox', { name: 'Project' }), { target: { value: '/Example' } });
770
+ fireEvent.change(screen.getByLabelText('Search Chats'), { target: { value: 'one' } });
771
+ fireEvent.change(screen.getByRole('combobox', { name: 'Updated' }), { target: { value: '7d' } });
772
+ fireEvent.click(screen.getByRole('button', { name: 'Pinned' }));
773
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
774
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
775
+ first.unmount();
776
+ // Loading must not clear the saved project before inventory arrives.
777
+ let load!: (value: unknown) => void;
778
+ const active = server.call.getMockImplementation()!;
779
+ server.call.mockImplementationOnce(input => input.name === 'taskchef_app_snapshot' ? new Promise(resolve => { load = resolve; }) : active(input));
780
+ mount();
781
+ expect(window.localStorage.getItem('taskchef.app.filter.project')).toBe(JSON.stringify('/Example'));
782
+ expect(screen.getByLabelText('Search Chats')).toHaveValue('one');
783
+ expect(screen.getByRole('combobox', { name: 'Updated' })).toHaveValue('7d');
784
+ expect(screen.getByRole('button', { name: 'Pinned' })).toHaveAttribute('aria-pressed', 'true');
785
+ await waitFor(() => expect(load).toBeTypeOf('function'));
786
+ await act(async () => { load({ structuredContent: { settings: visibility, snapshot: { tasks, projects: [], healthy: true } } }); });
787
+ expect(screen.getByRole('combobox', { name: 'Project' })).toHaveValue('/Example');
788
+ fireEvent.click(screen.getByRole('radio', { name: 'Calendar' }));
789
+ expect(screen.getByRole('button', { name: 'Scheduled' })).toHaveAttribute('aria-pressed', 'false');
790
+ expect(await screen.findByText(/No recorded work matches/)).toBeInTheDocument();
791
+ fireEvent.click(screen.getByRole('button', { name: 'Scheduled' }));
792
+ expect(await screen.findByRole('button', { name: 'Task one' })).toBeInTheDocument();
793
+ });
794
+
795
+ test('unavailable preference storage leaves filters usable', async () => {
796
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Unavailable'); });
797
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Unavailable'); });
798
+ mount();
799
+ await screen.findByRole('button', { name: 'Task one' });
800
+ fireEvent.change(screen.getByLabelText('Search Chats'), { target: { value: 'missing' } });
801
+ expect(screen.queryByRole('button', { name: 'Task one' })).not.toBeInTheDocument();
802
+ fireEvent.click(screen.getByRole('radio', { name: 'Usage' }));
803
+ expect(await screen.findByRole('button', { name: '30 days' })).toHaveAttribute('aria-pressed','true');
804
+ });
@@ -1,7 +1,9 @@
1
+ import { usePreference } from "./preferences";
2
+ import { CalendarView, type CalendarData, type CalendarRequest } from "./CalendarView";
1
3
  import { cachedImageLoader } from "../../ui/image-cache";
2
4
  import { App } from "@modelcontextprotocol/ext-apps";
3
5
  import { ActionIcon, Alert, Box, Button, Group, MantineProvider, Modal, createTheme, Paper, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
4
- import { IconPin, IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
6
+ import { IconCalendarEvent, IconPin, IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
5
7
  import { createRoot } from "react-dom/client";
6
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
9
  import type { DashboardSnapshot, Task, Project } from "../../ui/types";
@@ -41,6 +43,7 @@ async function call<T>(name: string, args: Record<string, unknown> = {}): Promis
41
43
  return result.structuredContent as T;
42
44
  }
43
45
 
46
+ const readCalendar: CalendarRequest = args => call<CalendarData>("taskchef_app_calendar", args);
44
47
  const readUsage = (args: ReturnType<typeof usageRequest>) => call<UsageResponse>("taskchef_app_usage", args);
45
48
 
46
49
  const THEME_KEY = "taskchef.app.theme";
@@ -116,7 +119,7 @@ export function TaskChefApp() {
116
119
  const [scan, setScan] = useState<ScanStats | null>(null);
117
120
  const [selected, setSelected] = useState<Task | null>(null);
118
121
  const [opened, setOpened] = useState(false);
119
- const [pinnedOnly, setPinnedOnly] = useState(false);
122
+ const [pinnedOnly, setPinnedOnly] = usePreference("filter.pinned");
120
123
  const [busy, setBusy] = useState(false);
121
124
  const [error, setError] = useState<string | null>(null);
122
125
 
@@ -158,12 +161,13 @@ export function TaskChefApp() {
158
161
  receiveNotifications(result.notifications);
159
162
  } catch (notificationError) { setError(`${String(cause)}. ${String(notificationError)}`); }
160
163
  }
161
- const [project, setProject] = useState("");
162
- const [search, setSearch] = useState("");
163
- const [date, setDate] = useState("all");
164
+ const [project, setProject] = usePreference("filter.project");
165
+ const [search, setSearch] = usePreference("filter.search");
166
+ const [showScheduled, setShowScheduled] = usePreference("calendar.scheduled");
167
+ const [date, setDate] = usePreference("filter.updated");
164
168
  const [status, setStatus] = useState("");
165
169
  // Keep the legacy list hidden; Board and Usage share the native shell.
166
- const [view, setView] = useState<"board" | "list" | "usage">("board");
170
+ const [view, setView] = useState<"board" | "list" | "usage" | "calendar">("board");
167
171
  const [usageRefreshKey, setUsageRefreshKey] = useState(0);
168
172
  const [completedLimit, setCompletedLimit] = useState(5);
169
173
  const [archivedLimit, setArchivedLimit] = useState(5);
@@ -176,6 +180,7 @@ export function TaskChefApp() {
176
180
  const detailRequestVersion = useRef(0);
177
181
  const refreshVersion = useRef(0);
178
182
  const revisionRef = useRef<number | null>(null);
183
+ const calendarHistoryRevision = useRef(0);
179
184
  const displayModeRef = useRef(displayMode);
180
185
  const previousDisplayMode = useRef(displayMode);
181
186
  displayModeRef.current = displayMode;
@@ -210,6 +215,7 @@ export function TaskChefApp() {
210
215
  return;
211
216
  }
212
217
  } else { setScan(data.scan); setError(null); }
218
+ if ((data.snapshot?.scan ?? data.scan)?.cacheHit === false) calendarHistoryRevision.current += 1;
213
219
  setNow(Date.now());
214
220
  const selectedTask = selectedRef.current;
215
221
  if (!selectedTask || initialSelection !== selectionVersion.current) return;
@@ -299,7 +305,6 @@ export function TaskChefApp() {
299
305
  }, [displayMode, refresh]);
300
306
  const eligibleTasks = useMemo(() => tasks.filter((task) => eligibleForView(task, showCli, showArchived, showExec)), [tasks, showCli, showArchived, showExec]);
301
307
  useEffect(() => {
302
- setProject("");
303
308
  setCompletedLimit(5);
304
309
  setArchivedLimit(5);
305
310
  if (!showArchived) setStatus((current) => current === "archived" ? "" : current);
@@ -330,12 +335,13 @@ export function TaskChefApp() {
330
335
  { label: "No project", value: "projectless" },
331
336
  ];
332
337
  }, [eligibleTasks, registeredProjects]);
333
- useEffect(() => { if (project && !projects.some((item) => item.value === project)) setProject(""); }, [project, projects]);
338
+ useEffect(() => { if (!loading && !error && project && !projects.some((item) => item.value === project)) setProject(""); }, [project, projects, loading, error]);
334
339
  const projectTasks = useMemo(() => eligibleTasks.filter((task) => !project || (task.project.id || task.project.path || task.project.name) === project), [eligibleTasks, project]);
335
340
  const boardTasks: Task[] = useMemo(() => {
336
341
  const terms = search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
337
- return filterTasks(projectTasks, { date, now }).filter((task: Task) => {
342
+ return filterTasks(projectTasks, { date: view === "calendar" ? "all" : date, now }).filter((task: Task) => {
338
343
  if (pinnedOnly && !task.observed?.pinned) return false;
344
+ if (view === "calendar" && !showScheduled && task.scheduled) return false;
339
345
  const content = [
340
346
  task.title, task.promptExcerpt ?? "", task.replyExcerpt ?? "", task.project.name,
341
347
  ...(task.project.githubRepos ?? []),
@@ -343,7 +349,7 @@ export function TaskChefApp() {
343
349
  ].join("\n").toLocaleLowerCase();
344
350
  return terms.every((term) => content.includes(term));
345
351
  });
346
- }, [projectTasks, date, now, search, pinnedOnly]);
352
+ }, [projectTasks, date, now, search, pinnedOnly, view, showScheduled]);
347
353
  const visible = useMemo(() => boardTasks.filter((task) => !status || (task.status ?? "unverified") === status), [boardTasks, status]);
348
354
  const statusOptions = [{ label: `All ${boardTasks.length}`, value: "" }, ...lanes.map(({ label, status: value }) => ({
349
355
  label: `${label} ${boardTasks.filter((task) => task.status === value).length}`, value: value ?? "unverified",
@@ -424,12 +430,13 @@ export function TaskChefApp() {
424
430
  <Box className={`taskchef-app-shell${displayMode === "inline" ? " taskchef-app-inline" : ""}`}>
425
431
  <header className="taskchef-app-header">
426
432
  <Group className="taskchef-app-brand" gap="xs" wrap="nowrap"><img alt="" aria-hidden className="taskchef-app-mark" src={brandIcon} /><Title order={1}>TaskChef</Title></Group>
427
- {displayMode !== "inline" && <SegmentedControl className="taskchef-app-view" aria-label="View" data={[{ label: "Board", value: "board" }, { label: "Usage", value: "usage" }]} value={view} onChange={value => setView(value === "usage" ? "usage" : "board")} size="xs" />}
433
+ {displayMode !== "inline" && <SegmentedControl className="taskchef-app-view" aria-label="View" withItemsBorders={false} data={[{ label: "Board", value: "board" }, { label: "Calendar", value: "calendar" }, { label: "Usage", value: "usage" }]} value={view} onChange={value => setView(value === "usage" || value === "calendar" ? value : "board")} size="xs" />}
428
434
  {displayMode !== "inline" && view !== "usage" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
429
435
  <ProjectPicker data={projects} onChange={(value) => { setProject(value); setCompletedLimit(5); }} value={project} />
430
- <Select className="taskchef-app-date" aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} value={date} size="xs" />
436
+ {view !== "calendar" && <Select className="taskchef-app-date" aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} value={date} size="xs" />}
431
437
  <TextInput className="taskchef-app-search" aria-label="Search Chats" placeholder="Search Chats" type="search" leftSection={<IconSearch size={14} aria-hidden />} value={search} onChange={(event) => { setSearch(event.currentTarget.value); setCompletedLimit(5); setArchivedLimit(5); }} size="xs" />
432
- <Button className="taskchef-app-pinned" aria-pressed={pinnedOnly} title="Show only pinned chats" size="xs" variant={pinnedOnly ? "filled" : "outline"} leftSection={<IconPin size={14} aria-hidden />} onClick={() => { setPinnedOnly(value => !value); setCompletedLimit(5); setArchivedLimit(5); }}>Pinned</Button>
438
+ <Button className="taskchef-app-toggle" aria-pressed={pinnedOnly} title="Show only pinned chats" size="xs" variant={pinnedOnly ? "filled" : "outline"} leftSection={<IconPin size={14} aria-hidden />} onClick={() => { setPinnedOnly(value => !value); setCompletedLimit(5); setArchivedLimit(5); }}>Pinned</Button>
439
+ {view === "calendar" && <Button className="taskchef-app-toggle" leftSection={<IconCalendarEvent size={14} aria-hidden />} aria-pressed={showScheduled} title="Show scheduled chats" size="xs" variant={showScheduled ? "filled" : "outline"} onClick={() => setShowScheduled(value => !value)}>Scheduled</Button>}
433
440
  </Group>}
434
441
  <Group className="taskchef-app-actions" gap="xs" wrap="nowrap">
435
442
  <GitHubConnection hideTrigger openSignal={githubOpenSignal} auth={githubAuth} request={githubRequest} openLink={githubOpenLink} refresh={githubRefresh} openSettings={githubOpenSettings} />
@@ -454,7 +461,7 @@ export function TaskChefApp() {
454
461
  {view === "list" && <Paper className="taskchef-toolbar" radius={0}>
455
462
  <Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>
456
463
  </Paper>}
457
- {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 key={`${showCli}:${showExec}`} request={readUsage} refreshKey={usageRefreshKey} /> : <>
458
465
  {!!scan?.scheduleErrors && <Alert color="yellow" role="alert">{scan.scheduleErrors} schedule files could not be read; schedule placement may be incomplete.</Alert>}
459
466
  {view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {eligibleTasks.length}</Text>}
460
467
  {error && <Alert color="red" role="alert" mt="sm">{error}</Alert>}
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ type Preferences = {
4
+ "filter.project": string;
5
+ "filter.search": string;
6
+ "filter.updated": string;
7
+ "filter.pinned": boolean;
8
+ "calendar.scheduled": boolean;
9
+ "usage.range": "7d" | "30d" | "90d" | "mtd" | "last-month";
10
+ };
11
+ const defaults: Preferences = {
12
+ "filter.project": "", "filter.search": "", "filter.updated": "all",
13
+ "filter.pinned": false, "calendar.scheduled": true, "usage.range": "30d",
14
+ };
15
+ function readPreference<K extends keyof Preferences>(key: K): Preferences[K] {
16
+ try {
17
+ const value: unknown = JSON.parse(window.localStorage.getItem(`taskchef.app.${key}`) ?? "null");
18
+ if (typeof value !== typeof defaults[key]) return defaults[key];
19
+ if (key === "filter.updated" && !["all", "24h", "7d"].includes(value as string)) return defaults[key];
20
+ if (key === "usage.range" && !["7d", "30d", "90d", "mtd", "last-month"].includes(value as string)) return defaults[key];
21
+ return value as Preferences[K];
22
+ } catch { return defaults[key]; }
23
+ }
24
+
25
+ // The six view preferences share the same local storage behavior as Appearance.
26
+ // Unavailable storage leaves controls usable for the current mounted view.
27
+ export function usePreference<K extends keyof Preferences>(key: K) {
28
+ const [value, setValue] = useState<Preferences[K]>(() => readPreference(key));
29
+ useEffect(() => {
30
+ try { window.localStorage.setItem(`taskchef.app.${key}`, JSON.stringify(value)); }
31
+ catch { /* Keep the user's current choice in memory. */ }
32
+ }, [key, value]);
33
+ return [value, setValue] as const;
34
+ }
@@ -29,7 +29,7 @@ body { background: var(--taskchef-page); }
29
29
  .taskchef-app-view { grid-area: view; width: max-content; }
30
30
  .taskchef-app-filters { grid-area: filters; border-inline-start: 1px solid var(--taskchef-border); padding-inline-start: 12px; min-width: 0; width: min(100%, 680px); }
31
31
  .taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1 100px; min-width: 100px; }
32
- .taskchef-app-pinned { flex: 0 0 auto; white-space: nowrap; }
32
+ .taskchef-app-toggle { flex: 0 0 auto; white-space: nowrap; }
33
33
  .taskchef-app-filters .taskchef-app-date { flex: 0 0 132px; }
34
34
  @media (max-width: 1000px) {
35
35
  .taskchef-app-header { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand view actions" "filters filters filters"; gap: 8px; }
@@ -194,9 +194,9 @@ body { background: var(--taskchef-page); }
194
194
  .next-appearance-panel { background: var(--taskchef-elevated); }
195
195
 
196
196
  :root[data-taskchef-palette] .mantine-Button-root[data-variant="filled"] { color: var(--taskchef-surface); }
197
- :root[data-taskchef-palette] .taskchef-app-pinned { background: transparent; color: var(--taskchef-text); border-color: var(--taskchef-border); }
198
- :root[data-taskchef-palette] .taskchef-app-pinned:hover { background: var(--taskchef-hover); }
199
- :root[data-taskchef-palette] .taskchef-app-pinned[aria-pressed="true"] { background: var(--taskchef-accent); color: var(--taskchef-surface); border-color: var(--taskchef-accent); }
197
+ :root[data-taskchef-palette] .taskchef-app-toggle { background: transparent; color: var(--taskchef-text); border-color: var(--taskchef-border); }
198
+ :root[data-taskchef-palette] .taskchef-app-toggle:hover { background: var(--taskchef-hover); }
199
+ :root[data-taskchef-palette] .taskchef-app-toggle[aria-pressed="true"] { background: var(--taskchef-accent); color: var(--taskchef-surface); border-color: var(--taskchef-accent); }
200
200
  :root[data-taskchef-palette] .taskchef-board-card:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
201
201
 
202
202
  :root[data-taskchef-palette] body { font-family: var(--taskchef-font); }
@@ -268,3 +268,31 @@ body { background: var(--taskchef-page); }
268
268
  .usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
269
269
  .usage-chart [data-project] { transition: opacity 120ms ease; }
270
270
  @media (prefers-reduced-motion: reduce) { .usage-chart [data-project] { transition: none; } }
271
+
272
+ .calendar-view { padding: 16px; color: var(--mantine-color-text); }
273
+ .calendar-navigation { display: flex; justify-content: center; align-items: center; gap: 14px; }
274
+ .calendar-navigation h2 { margin: 0; font-size: 1.15rem; min-width: 170px; text-align: center; }
275
+ .calendar-navigation button { font-size: 26px; padding: 0 12px; }
276
+ .calendar-view button { color: inherit; cursor: pointer; background: transparent; border: 1px solid var(--mantine-color-default-border); border-radius: 5px; }
277
+ .calendar-view button:disabled { opacity: .35; cursor: default; }
278
+ .calendar-view button:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 2px; }
279
+ .calendar-scroll { overflow-x: auto; border: 1px solid var(--mantine-color-default-border); border-radius: 6px; margin-top: 14px; }
280
+ .calendar-month { min-width: 700px; }
281
+ .calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: var(--mantine-color-default); font-size: 12px; font-weight: 600; }
282
+ .calendar-weekdays > div { padding: 10px; }
283
+ .calendar-week { border-top: 1px solid var(--mantine-color-default-border); }
284
+ .calendar-week-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); position: relative; padding-bottom: 8px; row-gap: 4px; }
285
+ .calendar-day-backgrounds { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); pointer-events: none; }
286
+ .calendar-day-backgrounds > div + div { border-left: 1px solid var(--mantine-color-default-border); }
287
+ .calendar-weekend { background: color-mix(in srgb, var(--mantine-color-dimmed) 5%, transparent); }
288
+ .calendar-day { position: relative; padding: 5px 9px; font-size: 12px; }
289
+ .calendar-outside { opacity: .45; }
290
+ .calendar-today time { border-bottom: 2px solid var(--mantine-primary-color-filled); font-weight: 700; }
291
+ .calendar-view .calendar-bar { position: relative; margin: 0 4px; padding: 3px 7px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; font: inherit; font-size: 12px; border-left-width: 3px; }
292
+ .calendar-view .calendar-bar:hover { filter: brightness(1.15); }
293
+ .calendar-view .calendar-more { margin: 3px 8px 8px; padding: 3px 8px; font-size: 12px; }
294
+ @media (max-width: 600px) { .calendar-view { padding: 10px; } }
295
+
296
+ .calendar-navigation button, .calendar-navigation button:disabled { opacity: 0; }
297
+ .calendar-navigation:hover button, .calendar-navigation:focus-within button { opacity: 1; }
298
+ .calendar-navigation:hover button:disabled, .calendar-navigation:focus-within button:disabled { opacity: .35; }
package/src/mcp-app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { taskChefStateDir } from "./taskchef-state.js";
1
2
  import { aggregateUsage } from "./usage-analytics.js";
2
3
  import { mkdir, readFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -65,7 +66,7 @@ export function registerTaskChefApp(server, {
65
66
  openThread = openThreadInCodex,
66
67
  getSettingsUrl = pluginSettingsUrl,
67
68
  openSettings = openPluginSettingsInCodex,
68
- settingsPath = join(homedir(), ".agents", "taskchef-next", "settings.json"),
69
+ settingsPath = join(taskChefStateDir(), "settings.json"),
69
70
  notificationsPath = join(dirname(settingsPath), "notifications.json"),
70
71
  createGitHub = (options) => new NextGitHub(options),
71
72
  } = {}) {
@@ -212,6 +213,15 @@ export function registerTaskChefApp(server, {
212
213
  }
213
214
  return { structuredContent: { snapshot, settings: publicSettings(settings), settingsUrl, notifications, github: githubAuth, version: TASKCHEF_VERSION }, content: [] };
214
215
  });
216
+ server.registerTool("taskchef_app_calendar", {
217
+ title: "Read chat work calendar", description: "Read recorded turn dates for the selected month. Full idle days split chat bars.",
218
+ inputSchema: { month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/), timeZone: z.string().min(1).max(100) }, _meta: appOnly,
219
+ annotations: { readOnlyHint: true, openWorldHint: false },
220
+ }, async ({ month, timeZone }) => {
221
+ new Intl.DateTimeFormat("en", { timeZone }).format();
222
+ const data = await scanner.calendarHistory({ month, timeZone, settings: await readSettings() });
223
+ return { structuredContent: data, content: [] };
224
+ });
215
225
  server.registerTool("taskchef_app_usage", {
216
226
  title: "Read usage history", description: "Aggregate timestamped local token usage and API-equivalent cost by day and project.",
217
227
  inputSchema: { range: z.enum(["7d", "30d", "90d", "mtd", "last-month"]), timeZone: z.string().min(1).max(100), force: z.boolean().optional(), rescan: z.boolean().optional() }, _meta: appOnly,