taskchef 9.3.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.
@@ -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";
@@ -14,6 +16,7 @@ import { NextNotificationCenter, type NextNotification, type NextNotificationSta
14
16
  import { GitHubConnection, type GitHubAuth } from "./GitHubConnection";
15
17
  import { AppearancePicker } from "./AppearancePicker";
16
18
  import { savedVisualTheme, themeVariables, visualTheme, VISUAL_THEME_KEY } from "./themes";
19
+ import { UsageView, type UsageResponse, type usageRequest } from "./UsageView";
17
20
  import { ProjectPicker } from "./ProjectPicker";
18
21
  import brandIcon from "../../../assets/taskchef-dark.svg";
19
22
  import "@mantine/core/styles.css";
@@ -40,6 +43,9 @@ async function call<T>(name: string, args: Record<string, unknown> = {}): Promis
40
43
  return result.structuredContent as T;
41
44
  }
42
45
 
46
+ const readCalendar: CalendarRequest = args => call<CalendarData>("taskchef_app_calendar", args);
47
+ const readUsage = (args: ReturnType<typeof usageRequest>) => call<UsageResponse>("taskchef_app_usage", args);
48
+
43
49
  const THEME_KEY = "taskchef.app.theme";
44
50
  type ThemeChoice = "dark" | "light" | "system";
45
51
  function initialTheme(): ThemeChoice {
@@ -113,7 +119,7 @@ export function TaskChefApp() {
113
119
  const [scan, setScan] = useState<ScanStats | null>(null);
114
120
  const [selected, setSelected] = useState<Task | null>(null);
115
121
  const [opened, setOpened] = useState(false);
116
- const [pinnedOnly, setPinnedOnly] = useState(false);
122
+ const [pinnedOnly, setPinnedOnly] = usePreference("filter.pinned");
117
123
  const [busy, setBusy] = useState(false);
118
124
  const [error, setError] = useState<string | null>(null);
119
125
 
@@ -155,12 +161,14 @@ export function TaskChefApp() {
155
161
  receiveNotifications(result.notifications);
156
162
  } catch (notificationError) { setError(`${String(cause)}. ${String(notificationError)}`); }
157
163
  }
158
- const [project, setProject] = useState("");
159
- const [search, setSearch] = useState("");
160
- 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");
161
168
  const [status, setStatus] = useState("");
162
- // Keep the list implementation for later, but expose only Board. Ignore old saved List choices.
163
- const [view, setView] = useState<"board" | "list">("board");
169
+ // Keep the legacy list hidden; Board and Usage share the native shell.
170
+ const [view, setView] = useState<"board" | "list" | "usage" | "calendar">("board");
171
+ const [usageRefreshKey, setUsageRefreshKey] = useState(0);
164
172
  const [completedLimit, setCompletedLimit] = useState(5);
165
173
  const [archivedLimit, setArchivedLimit] = useState(5);
166
174
  const [now, setNow] = useState(() => Date.now());
@@ -172,6 +180,7 @@ export function TaskChefApp() {
172
180
  const detailRequestVersion = useRef(0);
173
181
  const refreshVersion = useRef(0);
174
182
  const revisionRef = useRef<number | null>(null);
183
+ const calendarHistoryRevision = useRef(0);
175
184
  const displayModeRef = useRef(displayMode);
176
185
  const previousDisplayMode = useRef(displayMode);
177
186
  displayModeRef.current = displayMode;
@@ -206,6 +215,7 @@ export function TaskChefApp() {
206
215
  return;
207
216
  }
208
217
  } else { setScan(data.scan); setError(null); }
218
+ if ((data.snapshot?.scan ?? data.scan)?.cacheHit === false) calendarHistoryRevision.current += 1;
209
219
  setNow(Date.now());
210
220
  const selectedTask = selectedRef.current;
211
221
  if (!selectedTask || initialSelection !== selectionVersion.current) return;
@@ -295,7 +305,6 @@ export function TaskChefApp() {
295
305
  }, [displayMode, refresh]);
296
306
  const eligibleTasks = useMemo(() => tasks.filter((task) => eligibleForView(task, showCli, showArchived, showExec)), [tasks, showCli, showArchived, showExec]);
297
307
  useEffect(() => {
298
- setProject("");
299
308
  setCompletedLimit(5);
300
309
  setArchivedLimit(5);
301
310
  if (!showArchived) setStatus((current) => current === "archived" ? "" : current);
@@ -326,12 +335,13 @@ export function TaskChefApp() {
326
335
  { label: "No project", value: "projectless" },
327
336
  ];
328
337
  }, [eligibleTasks, registeredProjects]);
329
- 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]);
330
339
  const projectTasks = useMemo(() => eligibleTasks.filter((task) => !project || (task.project.id || task.project.path || task.project.name) === project), [eligibleTasks, project]);
331
340
  const boardTasks: Task[] = useMemo(() => {
332
341
  const terms = search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
333
- return filterTasks(projectTasks, { date, now }).filter((task: Task) => {
342
+ return filterTasks(projectTasks, { date: view === "calendar" ? "all" : date, now }).filter((task: Task) => {
334
343
  if (pinnedOnly && !task.observed?.pinned) return false;
344
+ if (view === "calendar" && !showScheduled && task.scheduled) return false;
335
345
  const content = [
336
346
  task.title, task.promptExcerpt ?? "", task.replyExcerpt ?? "", task.project.name,
337
347
  ...(task.project.githubRepos ?? []),
@@ -339,7 +349,7 @@ export function TaskChefApp() {
339
349
  ].join("\n").toLocaleLowerCase();
340
350
  return terms.every((term) => content.includes(term));
341
351
  });
342
- }, [projectTasks, date, now, search, pinnedOnly]);
352
+ }, [projectTasks, date, now, search, pinnedOnly, view, showScheduled]);
343
353
  const visible = useMemo(() => boardTasks.filter((task) => !status || (task.status ?? "unverified") === status), [boardTasks, status]);
344
354
  const statusOptions = [{ label: `All ${boardTasks.length}`, value: "" }, ...lanes.map(({ label, status: value }) => ({
345
355
  label: `${label} ${boardTasks.filter((task) => task.status === value).length}`, value: value ?? "unverified",
@@ -420,11 +430,13 @@ export function TaskChefApp() {
420
430
  <Box className={`taskchef-app-shell${displayMode === "inline" ? " taskchef-app-inline" : ""}`}>
421
431
  <header className="taskchef-app-header">
422
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>
423
- {displayMode !== "inline" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
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" />}
434
+ {displayMode !== "inline" && view !== "usage" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
424
435
  <ProjectPicker data={projects} onChange={(value) => { setProject(value); setCompletedLimit(5); }} value={project} />
425
- <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" />}
426
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" />
427
- <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>}
428
440
  </Group>}
429
441
  <Group className="taskchef-app-actions" gap="xs" wrap="nowrap">
430
442
  <GitHubConnection hideTrigger openSignal={githubOpenSignal} auth={githubAuth} request={githubRequest} openLink={githubOpenLink} refresh={githubRefresh} openSettings={githubOpenSettings} />
@@ -433,7 +445,7 @@ export function TaskChefApp() {
433
445
  try { await call("taskchef_app_open_settings", {}); }
434
446
  catch (cause) { await actionError("settings", cause); }
435
447
  })()} variant="subtle"><IconSettings size={17} /></ActionIcon>
436
- <ActionIcon aria-label="Refresh" onClick={() => void refresh(true).catch((cause) => setError(String(cause)))} variant="subtle"><IconRefresh size={17} /></ActionIcon>
448
+ <ActionIcon aria-label="Refresh" onClick={() => { setUsageRefreshKey(value => value + 1); void refresh(true).catch((cause) => setError(String(cause))); }} variant="subtle"><IconRefresh size={17} /></ActionIcon>
437
449
  <AppearancePicker value={themeChoice} onChange={chooseTheme} visualThemeId={selectedTheme.id} appearance={colorScheme} onVisualThemeChange={chooseVisualTheme} />
438
450
  </Group>
439
451
  </header>
@@ -449,6 +461,7 @@ export function TaskChefApp() {
449
461
  {view === "list" && <Paper className="taskchef-toolbar" radius={0}>
450
462
  <Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>
451
463
  </Paper>}
464
+ {view === "calendar" ? <CalendarView request={readCalendar} showProject={!project} tasks={boardTasks} refreshKey={`${revisionRef.current}:${calendarHistoryRevision.current}:${new Date(now).toDateString()}:${usageRefreshKey}:${showCli}:${showExec}:${showArchived}`} onOpenDetail={task => void select(task)} /> : view === "usage" ? <UsageView key={`${showCli}:${showExec}`} request={readUsage} refreshKey={usageRefreshKey} /> : <>
452
465
  {!!scan?.scheduleErrors && <Alert color="yellow" role="alert">{scan.scheduleErrors} schedule files could not be read; schedule placement may be incomplete.</Alert>}
453
466
  {view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {eligibleTasks.length}</Text>}
454
467
  {error && <Alert color="red" role="alert" mt="sm">{error}</Alert>}
@@ -457,6 +470,7 @@ export function TaskChefApp() {
457
470
  {visible.map((task) => <TaskCard key={task.id} onOpenCodex={(item) => void openChat(item)} onOpenDetail={(item) => void select(item)} task={task} />)}
458
471
  {visible.length === 0 && <Paper className="taskchef-empty" p="lg" ta="center" withBorder><Title order={2} size="h5">No tasks match these filters</Title><Text c="dimmed" size="sm">Choose a different project, update window, or status.</Text></Paper>}
459
472
  </Stack>)}
473
+ </>}
460
474
  </main>
461
475
  </>}
462
476
  {taskchefVersion && displayMode !== "inline" && <footer className="taskchef-app-version" aria-label="TaskChef version">v{taskchefVersion}</footer>}
@@ -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
+ }
@@ -15,7 +15,7 @@
15
15
  html, body, #root { min-width: 0; min-height: 100%; }
16
16
  body { background: var(--taskchef-page); }
17
17
  .taskchef-app-shell { min-height: 100vh; background: var(--taskchef-page); }
18
- .taskchef-app-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
18
+ .taskchef-app-header { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "brand view filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
19
19
  .taskchef-app-brand { grid-area: brand; }
20
20
  .taskchef-app-actions { grid-area: actions; }
21
21
  .taskchef-app-header h1 { color: var(--taskchef-text); font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; }
@@ -26,14 +26,18 @@ body { background: var(--taskchef-page); }
26
26
  .taskchef-app-main-list { max-width: 624px; margin-inline: auto; }
27
27
  .taskchef-app-main .taskchef-toolbar { margin-bottom: 8px; padding-bottom: 10px; }
28
28
  .taskchef-app-main .mantine-SegmentedControl-root { max-width: 100%; }
29
- .taskchef-app-view { width: max-content; }
30
- .taskchef-app-filters { grid-area: filters; min-width: 0; width: min(100%, 680px); }
29
+ .taskchef-app-view { grid-area: view; width: max-content; }
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
- @media (max-width: 650px) {
35
- .taskchef-app-header { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "brand actions" "filters filters"; gap: 8px; }
36
- .taskchef-app-filters { width: 100%; }
34
+ @media (max-width: 1000px) {
35
+ .taskchef-app-header { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand view actions" "filters filters filters"; gap: 8px; }
36
+ .taskchef-app-filters { width: 100%; border-inline-start: 0; padding-inline-start: 0; border-top: 1px solid var(--taskchef-border); padding-top: 8px; }
37
+ }
38
+ @media (max-width: 500px) {
39
+ .taskchef-app-header { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "brand view" "actions actions" "filters filters"; }
40
+ .taskchef-app-actions { justify-self: end; }
37
41
  }
38
42
  .taskchef-app-status { max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
39
43
  .taskchef-app-status .mantine-SegmentedControl-root { width: max-content; min-width: max-content; }
@@ -107,7 +111,7 @@ body { background: var(--taskchef-page); }
107
111
  .taskchef-reply-cover { background: var(--taskchef-page); }
108
112
 
109
113
  @media (max-width: 380px) {
110
- .taskchef-app-header { grid-template-areas: "brand brand" "actions actions" "filters filters"; }
114
+ .taskchef-app-header { grid-template-areas: "brand view" "actions actions" "filters filters"; }
111
115
  .taskchef-app-inline .taskchef-app-header { grid-template-areas: "brand brand" "actions actions"; }
112
116
  .taskchef-app-actions { justify-self: end; }
113
117
  }
@@ -190,9 +194,9 @@ body { background: var(--taskchef-page); }
190
194
  .next-appearance-panel { background: var(--taskchef-elevated); }
191
195
 
192
196
  :root[data-taskchef-palette] .mantine-Button-root[data-variant="filled"] { color: var(--taskchef-surface); }
193
- :root[data-taskchef-palette] .taskchef-app-pinned { background: transparent; color: var(--taskchef-text); border-color: var(--taskchef-border); }
194
- :root[data-taskchef-palette] .taskchef-app-pinned:hover { background: var(--taskchef-hover); }
195
- :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); }
196
200
  :root[data-taskchef-palette] .taskchef-board-card:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
197
201
 
198
202
  :root[data-taskchef-palette] body { font-family: var(--taskchef-font); }
@@ -221,3 +225,74 @@ body { background: var(--taskchef-page); }
221
225
  .taskchef-turn-error-compact { gap: 6px; margin-top: 8px; padding: 0; border: 0; border-radius: 0; background: transparent; font-size: var(--mantine-font-size-xs); }
222
226
 
223
227
  .taskchef-turn-error-compact > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
228
+
229
+ .usage-view { max-width: 1100px; margin: 0 auto; color: var(--taskchef-text); }
230
+ .usage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
231
+ .usage-view button:disabled { opacity: .5; cursor: default; }
232
+ .usage-view h2 { font-size: 24px; margin: 4px 0 8px; }
233
+ .usage-view h3 { font-size: 14px; margin: 0 0 12px; }
234
+ .usage-view p, .usage-view small { color: var(--taskchef-muted); font-size: 12px; line-height: 1.5; margin: 8px 0; }
235
+ .usage-ranges, .usage-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
236
+ .usage-view button { font: inherit; font-size: 12px; color: var(--taskchef-text); background: transparent; border: 1px solid var(--taskchef-border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
237
+ .usage-view button:hover, .usage-view button[aria-pressed="true"] { background: var(--taskchef-hover); border-color: var(--taskchef-muted); }
238
+ .usage-view button:focus-visible, .usage-chart g:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
239
+ .usage-totals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
240
+ .usage-totals article, .usage-panel { padding: 18px; border: 1px solid var(--taskchef-border); border-radius: 10px; background: var(--taskchef-surface); min-width: 0; }
241
+ .usage-totals strong { font-size: 28px; font-weight: 600; }
242
+ .usage-panel { margin: 16px 0; }
243
+ .usage-legend { font-size: 12px; gap: 12px; }
244
+ .usage-legend span, .usage-legend button { display: inline-flex; align-items: center; gap: 6px; }
245
+ .usage-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
246
+ .usage-legend button { max-width: 100%; overflow-wrap: anywhere; text-align: left; }
247
+ .usage-token-key, .usage-chart .usage-token-axis { color: light-dark(#a54d2e, #efb18b); fill: light-dark(#a54d2e, #efb18b); }
248
+ .usage-token-line { stroke: light-dark(#a54d2e, #efb18b); stroke-width: 2.5; fill: none; pointer-events: none; }
249
+ .usage-token-dot { fill: light-dark(#a54d2e, #efb18b); pointer-events: none; }
250
+ .usage-chart-scroll { overflow-x: auto; }
251
+ .usage-chart { width: 100%; min-width: 500px; display: block; font: 11px var(--taskchef-font, sans-serif); fill: var(--taskchef-muted); }
252
+ .usage-grid { stroke: var(--taskchef-border); }
253
+ .usage-chart-detail { min-height: 18px; }
254
+ .usage-ranking { overflow-x: auto; }
255
+ .usage-ranking table { width: 100%; border-collapse: collapse; font-size: 12px; }
256
+ .usage-ranking th, .usage-ranking td { padding: 6px; text-align: right; font-weight: normal; }
257
+ .usage-ranking th:first-child { width: 70%; text-align: left; }
258
+ .usage-ranking td { white-space: nowrap; font-variant-numeric: tabular-nums; }
259
+ .usage-ranking thead { color: var(--taskchef-muted); }
260
+ .usage-ranking button { width: 100%; display: flex; align-items: center; gap: 12px; border-color: transparent; text-align: left; }
261
+ .usage-ranking button > span:first-child { width: 45%; overflow-wrap: anywhere; }
262
+ .usage-rank-track { flex: 1; height: 8px; border-radius: 2px; background: var(--taskchef-border); overflow: hidden; }
263
+ .usage-rank-track > span { display: block; height: 100%; }
264
+ .usage-warning { padding: 10px; border: 1px solid var(--taskchef-border); border-radius: 6px; }
265
+ @media (max-width: 500px) { .usage-totals { grid-template-columns: 1fr; gap: 10px; } .usage-panel { padding: 12px; } .usage-rank-track { display: none; } .usage-ranking button > span:first-child { width: 100%; } }
266
+
267
+ .usage-progress { margin: 16px 0; }
268
+ .usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
269
+ .usage-chart [data-project] { transition: opacity 120ms ease; }
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 { aggregateUsage } from "./usage-analytics.js";
1
2
  import { mkdir, readFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join } from "node:path";
@@ -78,6 +79,7 @@ export function registerTaskChefApp(server, {
78
79
  let ipcErrorReported = false;
79
80
  const turnUsageCache = new Map();
80
81
  const usagePending = new Map();
82
+ const analyticsJobs = new Map();
81
83
  async function boardSnapshot(settings, force = false, scope = { date: "all", taskIds: [] }) {
82
84
  const databaseSnapshot = await scanner.refresh({ force });
83
85
  await liveState.refresh(databaseSnapshot.healthy === false ? [] : databaseSnapshot.tasks);
@@ -210,6 +212,50 @@ export function registerTaskChefApp(server, {
210
212
  }
211
213
  return { structuredContent: { snapshot, settings: publicSettings(settings), settingsUrl, notifications, github: githubAuth, version: TASKCHEF_VERSION }, content: [] };
212
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
+ });
224
+ server.registerTool("taskchef_app_usage", {
225
+ title: "Read usage history", description: "Aggregate timestamped local token usage and API-equivalent cost by day and project.",
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,
227
+ annotations: { readOnlyHint: true, openWorldHint: false },
228
+ }, async ({ range, timeZone, force, rescan }) => {
229
+ new Intl.DateTimeFormat("en", { timeZone }).format();
230
+ const settings = await readSettings();
231
+ // History is independent of chart range and timezone. Range changes query
232
+ // this shared snapshot; only expiry or explicit Refresh starts another read.
233
+ const key = JSON.stringify([settings.showCli, settings.showExec]);
234
+ let job = analyticsJobs.get(key);
235
+ if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
236
+ if (!job || (!job.pending && (force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000)))) {
237
+ job = { families: job?.families ?? null, error: null, finishedAt: job?.finishedAt ?? 0,
238
+ pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } };
239
+ analyticsJobs.set(key, job);
240
+ const current = job;
241
+ Promise.resolve().then(async () => {
242
+ do {
243
+ current.refreshAgain = false;
244
+ const rebuild = current.rescan;
245
+ current.rescan = false;
246
+ const families = await scanner.usageHistory({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
247
+ current.families = families;
248
+ current.finishedAt = Date.now();
249
+ } while (current.refreshAgain);
250
+ }).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
251
+ }
252
+ if (job.error) throw job.error;
253
+ const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
254
+ updatedAt: new Date(job.finishedAt).toISOString(),
255
+ ...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
256
+ : { loading: true, progress: job.progress };
257
+ return { structuredContent: data, content: [] };
258
+ });
213
259
  server.registerTool("taskchef_app_task", {
214
260
  title: "Read Codex chat metadata", description: "Read one local chat's metadata and its latest recorded request and response.",
215
261
  inputSchema: { taskId: taskIdSchema, refreshGithub: z.boolean().optional() }, _meta: appOnly,
@@ -0,0 +1,47 @@
1
+ // Calendar dates are evaluated in the view's timezone, including DST changes.
2
+ export function aggregateUsage(families, { range, timeZone, now = Date.now() }) {
3
+ const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
4
+ const dateKey = time => {
5
+ const parts = Object.fromEntries(format.formatToParts(time).map(part => [part.type, part.value]));
6
+ return `${parts.year}-${parts.month}-${parts.day}`;
7
+ };
8
+ let end = dateKey(now);
9
+ const startDate = new Date(`${end}T00:00:00Z`);
10
+ if (range === 'last-month') {
11
+ startDate.setUTCDate(1);
12
+ end = new Date(startDate.getTime() - 86400000).toISOString().slice(0, 10);
13
+ startDate.setUTCMonth(startDate.getUTCMonth() - 1);
14
+ } else if (range === 'mtd') startDate.setUTCDate(1);
15
+ else startDate.setUTCDate(startDate.getUTCDate() - ({ '7d': 6, '30d': 29, '90d': 89 }[range] ?? 29));
16
+ const start = startDate.toISOString().slice(0, 10);
17
+ const days = [];
18
+ for (let cursor = startDate; cursor.toISOString().slice(0, 10) <= end; cursor = new Date(cursor.getTime() + 86400000)) {
19
+ days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {} });
20
+ }
21
+ const byDate = new Map(days.map(day => [day.date, day]));
22
+ const projects = new Map();
23
+ let incomplete = false, samples = 0;
24
+ const totals = { tokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0 };
25
+ for (const { project, usage } of families) {
26
+ // Coverage can be incomplete even when no timestamped records survive.
27
+ incomplete ||= usage.analyticsPartial || usage.partial || false;
28
+ const key = project.id === 'projectless' ? 'unassigned' : project.id || project.path || 'unassigned';
29
+ for (const sample of usage.timeline) {
30
+ if (sample.time > now) continue;
31
+ const day = byDate.get(dateKey(sample.time));
32
+ if (!day) continue;
33
+ let item = projects.get(key);
34
+ if (!item) { item = { id: key, name: key === 'unassigned' ? 'Unassigned' : project.name, tokens: 0, costUsd: 0 }; projects.set(key, item); }
35
+ const tokens = sample.tokens.total_tokens;
36
+ totals.tokens += tokens; totals.inputTokens += sample.tokens.input_tokens;
37
+ totals.cachedInputTokens += sample.tokens.cached_input_tokens; totals.outputTokens += sample.tokens.output_tokens;
38
+ totals.costUsd += sample.costUsd; samples += sample.samples;
39
+ day.tokens += tokens; day.costUsd += sample.costUsd;
40
+ day.projects[key] = (day.projects[key] ?? 0) + sample.costUsd;
41
+ item.tokens += tokens; item.costUsd += sample.costUsd;
42
+ incomplete ||= sample.partial || sample.costPartial;
43
+ }
44
+ }
45
+ return { start, end, timeZone, days, totals, samples, incomplete,
46
+ projects: [...projects.values()].sort((a, b) => b.costUsd - a.costUsd || a.name.localeCompare(b.name)) };
47
+ }