taskchef 9.4.0 → 9.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/docs/mcp-app.md +43 -3
- package/package.json +1 -1
- package/src/calendar-history.js +30 -0
- package/src/codex-session-scanner.js +22 -0
- package/src/mcp-app/dist/index.html +139 -132
- package/src/mcp-app/react/CalendarView.test.tsx +59 -0
- package/src/mcp-app/react/CalendarView.tsx +91 -0
- package/src/mcp-app/react/UsageView.test.tsx +27 -6
- package/src/mcp-app/react/UsageView.tsx +2 -1
- package/src/mcp-app/react/main.test.tsx +121 -0
- package/src/mcp-app/react/main.tsx +21 -14
- package/src/mcp-app/react/preferences.ts +34 -0
- package/src/mcp-app/react/styles.css +32 -4
- package/src/mcp-app.js +9 -0
|
@@ -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
|
|
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:'
|
|
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:'
|
|
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:'
|
|
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:'
|
|
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] =
|
|
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] =
|
|
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] =
|
|
162
|
-
const [search, setSearch] =
|
|
163
|
-
const [
|
|
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"
|
|
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-
|
|
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-
|
|
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-
|
|
198
|
-
:root[data-taskchef-palette] .taskchef-app-
|
|
199
|
-
:root[data-taskchef-palette] .taskchef-app-
|
|
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
|
@@ -212,6 +212,15 @@ export function registerTaskChefApp(server, {
|
|
|
212
212
|
}
|
|
213
213
|
return { structuredContent: { snapshot, settings: publicSettings(settings), settingsUrl, notifications, github: githubAuth, version: TASKCHEF_VERSION }, content: [] };
|
|
214
214
|
});
|
|
215
|
+
server.registerTool("taskchef_app_calendar", {
|
|
216
|
+
title: "Read chat work calendar", description: "Read recorded turn dates for the selected month. Full idle days split chat bars.",
|
|
217
|
+
inputSchema: { month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/), timeZone: z.string().min(1).max(100) }, _meta: appOnly,
|
|
218
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
219
|
+
}, async ({ month, timeZone }) => {
|
|
220
|
+
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
221
|
+
const data = await scanner.calendarHistory({ month, timeZone, settings: await readSettings() });
|
|
222
|
+
return { structuredContent: data, content: [] };
|
|
223
|
+
});
|
|
215
224
|
server.registerTool("taskchef_app_usage", {
|
|
216
225
|
title: "Read usage history", description: "Aggregate timestamped local token usage and API-equivalent cost by day and project.",
|
|
217
226
|
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,
|