@commandgarden/cli 2.9.0 → 2.11.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.
Files changed (63) hide show
  1. package/dist/main.js +0 -0
  2. package/node_modules/@commandgarden/app/dist/client/assets/{Slides-D3xcxd9t.js → Slides-BvMlkL-3.js} +5 -15
  3. package/node_modules/@commandgarden/app/dist/client/assets/index-BD8reQ8T.js +496 -0
  4. package/node_modules/@commandgarden/app/dist/client/assets/{index-DbqYLIqy.css → index-wgb9-PIF.css} +1 -1
  5. package/node_modules/@commandgarden/app/dist/client/index.html +19 -19
  6. package/node_modules/@commandgarden/app/dist/server/daemon-client.test.js +47 -0
  7. package/node_modules/@commandgarden/app/dist/server/journal/journal.service.d.ts +1 -0
  8. package/node_modules/@commandgarden/app/dist/server/journal/journal.service.js +25 -6
  9. package/node_modules/@commandgarden/app/dist/server/journal/journal.types.d.ts +26 -0
  10. package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.d.ts +10 -4
  11. package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.js +36 -29
  12. package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.d.ts +10 -1
  13. package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.js +62 -8
  14. package/node_modules/@commandgarden/app/dist/server/routes/journal.d.ts +12 -0
  15. package/node_modules/@commandgarden/app/dist/server/routes/journal.js +87 -5
  16. package/node_modules/@commandgarden/app/dist/server/routes/routes.test.js +137 -0
  17. package/node_modules/@commandgarden/app/dist/server/store.d.ts +27 -0
  18. package/node_modules/@commandgarden/app/dist/server/store.js +130 -39
  19. package/node_modules/@commandgarden/app/dist/server/store.test.js +59 -0
  20. package/node_modules/@commandgarden/app/package.json +42 -42
  21. package/node_modules/@commandgarden/chrome/dist/content-script.js.map +1 -1
  22. package/node_modules/@commandgarden/chrome/dist/fetch-interceptor.js +44 -0
  23. package/node_modules/@commandgarden/chrome/dist/manifest.json +26 -26
  24. package/node_modules/@commandgarden/chrome/dist/popup.html +218 -218
  25. package/node_modules/@commandgarden/chrome/dist/popup.js.map +1 -1
  26. package/node_modules/@commandgarden/chrome/dist/service-worker.js +14 -4
  27. package/node_modules/@commandgarden/chrome/dist/service-worker.js.map +2 -2
  28. package/node_modules/@commandgarden/chrome/package.json +26 -26
  29. package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.eval.js +100 -100
  30. package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.yaml +70 -70
  31. package/node_modules/@commandgarden/daemon/connectors/gcs-kb-content.yaml +61 -58
  32. package/node_modules/@commandgarden/daemon/connectors/gcs-kb-pages.yaml +54 -47
  33. package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.eval.js +115 -115
  34. package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.yaml +54 -54
  35. package/node_modules/@commandgarden/daemon/connectors/lib/msal-token.js +20 -20
  36. package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.eval.js +204 -198
  37. package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.yaml +42 -42
  38. package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.eval.js +94 -94
  39. package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.yaml +34 -34
  40. package/node_modules/@commandgarden/daemon/connectors/socket-security-news.yaml +63 -63
  41. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.eval.js +437 -437
  42. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.yaml +53 -53
  43. package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.eval.js +547 -547
  44. package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.yaml +48 -48
  45. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.eval.js +52 -52
  46. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.yaml +42 -42
  47. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.eval.js +81 -81
  48. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.yaml +52 -52
  49. package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.eval.js +27 -27
  50. package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.yaml +38 -38
  51. package/node_modules/@commandgarden/daemon/connectors/tokenmaster-clients-list.yaml +55 -55
  52. package/node_modules/@commandgarden/daemon/dist/audit-store.js +8 -8
  53. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts +24 -0
  54. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts.map +1 -0
  55. package/node_modules/@commandgarden/daemon/dist/cdp-client.js +150 -0
  56. package/node_modules/@commandgarden/daemon/dist/cdp-client.js.map +1 -0
  57. package/node_modules/@commandgarden/daemon/package.json +32 -32
  58. package/node_modules/@commandgarden/shared/dist/expression/filters.d.ts.map +1 -1
  59. package/node_modules/@commandgarden/shared/dist/expression/filters.js +2 -0
  60. package/node_modules/@commandgarden/shared/dist/expression/filters.js.map +1 -1
  61. package/node_modules/@commandgarden/shared/package.json +32 -32
  62. package/package.json +58 -58
  63. package/node_modules/@commandgarden/app/dist/client/assets/index-_jNYPoLF.js +0 -461
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { DaemonClient } from './daemon-client.js';
3
+ describe('DaemonClient', () => {
4
+ let client;
5
+ const mockFetch = vi.fn();
6
+ beforeEach(() => {
7
+ vi.stubGlobal('fetch', mockFetch);
8
+ client = new DaemonClient('http://127.0.0.1:19825', 'test-token');
9
+ mockFetch.mockReset();
10
+ });
11
+ it('sends auth headers on GET', async () => {
12
+ mockFetch.mockResolvedValueOnce({
13
+ ok: true,
14
+ json: () => Promise.resolve({ ok: true, data: 'test' }),
15
+ });
16
+ await client.get('/api/status');
17
+ expect(mockFetch).toHaveBeenCalledWith('http://127.0.0.1:19825/api/status', expect.objectContaining({
18
+ method: 'GET',
19
+ headers: expect.objectContaining({
20
+ Authorization: 'Bearer test-token',
21
+ 'X-CommandGarden': '1',
22
+ }),
23
+ }));
24
+ });
25
+ it('sends body on POST', async () => {
26
+ mockFetch.mockResolvedValueOnce({
27
+ ok: true,
28
+ json: () => Promise.resolve({ ok: true }),
29
+ });
30
+ await client.post('/api/run', { connector: 'test/cmd', args: {} });
31
+ const call = mockFetch.mock.calls[0];
32
+ expect(call[1].method).toBe('POST');
33
+ expect(JSON.parse(call[1].body)).toEqual({ connector: 'test/cmd', args: {} });
34
+ });
35
+ it('throws on non-ok response', async () => {
36
+ mockFetch.mockResolvedValueOnce({
37
+ ok: false,
38
+ status: 401,
39
+ json: () => Promise.resolve({ error: 'Unauthorized' }),
40
+ });
41
+ await expect(client.get('/api/status')).rejects.toThrow('Unauthorized');
42
+ });
43
+ it('throws on network error', async () => {
44
+ mockFetch.mockRejectedValueOnce(new Error('fetch failed'));
45
+ await expect(client.get('/api/status')).rejects.toThrow('Cannot connect to daemon');
46
+ });
47
+ });
@@ -18,6 +18,7 @@ export declare class JournalService {
18
18
  private readonly jira;
19
19
  constructor(git: GitSource, timetracking: TimetrackingSource, meetings: MeetingsSource, jira: JiraSource);
20
20
  generate(input: JournalInput): Promise<JournalResponse>;
21
+ private currentMonth;
21
22
  /**
22
23
  * Cross-reference data across sources to identify patterns:
23
24
  * - forgottenDays: days with activity but 0 hours logged
@@ -20,13 +20,24 @@ export class JournalService {
20
20
  async generate(input) {
21
21
  const weekStart = input.weekStart;
22
22
  const weekEnd = this.addDays(weekStart, 6); // Mon–Sun (full 7-day week)
23
+ const sources = {
24
+ timetracking: input.sources?.timetracking ?? true,
25
+ meetings: input.sources?.meetings ?? true,
26
+ jira: input.sources?.jira ?? true,
27
+ git: input.sources?.git ?? true,
28
+ };
23
29
  const errors = [];
24
- // Fetch all sources in parallel; each source handles its own errors gracefully
25
- const [ttResult, meetingsResult, jiraResult, gitResult] = await Promise.allSettled([
26
- this.timetracking.fetch(weekStart, weekEnd),
27
- this.meetings.fetch(weekStart, weekEnd),
28
- this.jira.fetch(weekStart, weekEnd),
29
- this.git.fetch(weekStart, weekEnd),
30
+ const currentMonth = this.currentMonth();
31
+ // Fetch only the enabled sources in parallel; each source handles its own errors gracefully.
32
+ // Disabled sources resolve to null immediately without calling the connector.
33
+ // The monthly timetracking status reuses TimetrackingSource's per-request cache, so if
34
+ // the selected week falls in the current month, it never drives the browser connector twice.
35
+ const [ttResult, meetingsResult, jiraResult, gitResult, monthlyTtResult] = await Promise.allSettled([
36
+ sources.timetracking ? this.timetracking.fetch(weekStart, weekEnd) : Promise.resolve(null),
37
+ sources.meetings ? this.meetings.fetch(weekStart, weekEnd) : Promise.resolve(null),
38
+ sources.jira ? this.jira.fetch(weekStart, weekEnd) : Promise.resolve(null),
39
+ sources.git ? this.git.fetch(weekStart, weekEnd) : Promise.resolve(null),
40
+ sources.timetracking ? this.timetracking.fetchMonthlyStatus(currentMonth) : Promise.resolve(null),
30
41
  ]);
31
42
  const tt = ttResult.status === 'fulfilled' ? ttResult.value : null;
32
43
  if (ttResult.status === 'rejected')
@@ -40,6 +51,9 @@ export class JournalService {
40
51
  const git = gitResult.status === 'fulfilled' ? gitResult.value : null;
41
52
  if (gitResult.status === 'rejected')
42
53
  errors.push(`Git: ${gitResult.reason}`);
54
+ const monthlyTimetracking = monthlyTtResult.status === 'fulfilled' ? monthlyTtResult.value : null;
55
+ if (monthlyTtResult.status === 'rejected')
56
+ errors.push(`Monthly TimeTracking: ${monthlyTtResult.reason}`);
43
57
  // Cross-reference available data
44
58
  const crossRef = this.crossReference(tt, mtg, jira, git, weekStart, weekEnd);
45
59
  // Determine overall status
@@ -57,8 +71,13 @@ export class JournalService {
57
71
  crossRef,
58
72
  insights,
59
73
  errors,
74
+ monthlyTimetracking,
60
75
  };
61
76
  }
77
+ currentMonth() {
78
+ const d = new Date();
79
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
80
+ }
62
81
  /**
63
82
  * Cross-reference data across sources to identify patterns:
64
83
  * - forgottenDays: days with activity but 0 hours logged
@@ -1,8 +1,15 @@
1
1
  /** Shared types for the Dev Work Journal module.
2
2
  * Ported from dashboard/api/src/journal/journal.types.ts — identical interfaces,
3
3
  * no NestJS dependencies. */
4
+ export interface JournalSources {
5
+ timetracking: boolean;
6
+ meetings: boolean;
7
+ jira: boolean;
8
+ git: boolean;
9
+ }
4
10
  export interface JournalInput {
5
11
  weekStart: string;
12
+ sources?: Partial<JournalSources>;
6
13
  }
7
14
  export interface TtDailyEntry {
8
15
  date: string;
@@ -18,6 +25,17 @@ export interface TimetrackingData {
18
25
  daily: TtDailyEntry[];
19
26
  gaps: string[];
20
27
  }
28
+ export interface MonthlyTimetrackingData {
29
+ month: string;
30
+ workingDaysTotal: number;
31
+ workingDaysElapsed: number;
32
+ releasedDates: string[];
33
+ unreleasedDates: string[];
34
+ hoursByProject: {
35
+ projectId: string;
36
+ hours: number;
37
+ }[];
38
+ }
21
39
  export interface MeetingEntry {
22
40
  date: string;
23
41
  subject: string;
@@ -78,6 +96,12 @@ export interface CrossRefData {
78
96
  codingHours: number;
79
97
  }
80
98
  export type JournalStatus = 'success' | 'partial' | 'error';
99
+ /** Cache provenance — added at the route level (not by JournalService),
100
+ * so the UI can show whether a result came from app.db and when it was fetched. */
101
+ export interface JournalCacheInfo {
102
+ hit: boolean;
103
+ fetchedAt: string;
104
+ }
81
105
  export interface JournalResponse {
82
106
  status: JournalStatus;
83
107
  week: string;
@@ -88,4 +112,6 @@ export interface JournalResponse {
88
112
  crossRef: CrossRefData | null;
89
113
  insights: string[];
90
114
  errors: string[];
115
+ monthlyTimetracking: MonthlyTimetrackingData | null;
116
+ cache?: JournalCacheInfo;
91
117
  }
@@ -6,8 +6,12 @@
6
6
  * to bypass the MCAS proxy (see docs/ado-git-commits-fix.md for the
7
7
  * Runtime.evaluate CSP bypass that also applies here).
8
8
  *
9
- * Calls the connector once per weekday in the date range, then
10
- * aggregates into MeetingsData (totalCount, totalHours, daily, entries).
9
+ * The Scheduling Assistant grid renders the whole work week around
10
+ * whichever date it's pointed at, so a single connector call (using any
11
+ * weekday in the target range as an anchor) already returns every day's
12
+ * meetings for that week — no need to call once per weekday. Rows outside
13
+ * [weekStart, weekEnd] (if the connector's week grid doesn't align exactly
14
+ * with our range) are filtered out before aggregating into MeetingsData.
11
15
  */
12
16
  import type { DaemonClient } from '@commandgarden/shared';
13
17
  import type { MeetingsData } from '../journal.types.js';
@@ -15,6 +19,8 @@ export declare class MeetingsSource {
15
19
  private readonly daemon;
16
20
  constructor(daemon: DaemonClient);
17
21
  fetch(weekStart: string, weekEnd: string): Promise<MeetingsData | null>;
18
- /** Fetch a single day's meetings via the daemon connector. */
19
- private fetchDay;
22
+ /** Fetch the whole work week's meetings via the daemon connector, anchored on one date. */
23
+ private fetchWeek;
24
+ /** First weekday (Mon–Fri) in [start, end], or null if the range has none. */
25
+ private firstWeekday;
20
26
  }
@@ -6,8 +6,12 @@
6
6
  * to bypass the MCAS proxy (see docs/ado-git-commits-fix.md for the
7
7
  * Runtime.evaluate CSP bypass that also applies here).
8
8
  *
9
- * Calls the connector once per weekday in the date range, then
10
- * aggregates into MeetingsData (totalCount, totalHours, daily, entries).
9
+ * The Scheduling Assistant grid renders the whole work week around
10
+ * whichever date it's pointed at, so a single connector call (using any
11
+ * weekday in the target range as an anchor) already returns every day's
12
+ * meetings for that week — no need to call once per weekday. Rows outside
13
+ * [weekStart, weekEnd] (if the connector's week grid doesn't align exactly
14
+ * with our range) are filtered out before aggregating into MeetingsData.
11
15
  */
12
16
  export class MeetingsSource {
13
17
  daemon;
@@ -16,30 +20,21 @@ export class MeetingsSource {
16
20
  }
17
21
  async fetch(weekStart, weekEnd) {
18
22
  try {
19
- // Fetch meetings for each weekday in the range.
20
- // The connector opens the Scheduling Assistant per call, so we
21
- // batch weekdays sequentially to avoid multiple browser tabs.
22
- const allEntries = [];
23
- const cursor = new Date(weekStart);
24
- const end = new Date(weekEnd);
25
- while (cursor <= end) {
26
- const day = cursor.getDay();
27
- // Only fetch weekdays (Mon–Fri) to avoid unnecessary calls
28
- if (day >= 1 && day <= 5) {
29
- const dateStr = cursor.toISOString().slice(0, 10);
30
- const rows = await this.fetchDay(dateStr);
31
- for (const r of rows) {
32
- allEntries.push({
33
- date: r.date,
34
- subject: r.subject,
35
- start: r.start,
36
- end: r.end,
37
- durationMin: r.durationMin,
38
- });
39
- }
40
- }
41
- cursor.setDate(cursor.getDate() + 1);
42
- }
23
+ // Anchor on the first weekday in range — the connector's single call
24
+ // returns the whole work week's meetings for whichever date it's given.
25
+ const anchorDate = this.firstWeekday(weekStart, weekEnd);
26
+ if (!anchorDate)
27
+ return null;
28
+ const rows = await this.fetchWeek(anchorDate);
29
+ const allEntries = rows
30
+ .filter((r) => r.date >= weekStart && r.date <= weekEnd)
31
+ .map((r) => ({
32
+ date: r.date,
33
+ subject: r.subject,
34
+ start: r.start,
35
+ end: r.end,
36
+ durationMin: r.durationMin,
37
+ }));
43
38
  if (allEntries.length === 0)
44
39
  return null;
45
40
  // Aggregate daily stats
@@ -62,12 +57,12 @@ export class MeetingsSource {
62
57
  return null;
63
58
  }
64
59
  }
65
- /** Fetch a single day's meetings via the daemon connector. */
66
- async fetchDay(date) {
60
+ /** Fetch the whole work week's meetings via the daemon connector, anchored on one date. */
61
+ async fetchWeek(anchorDate) {
67
62
  try {
68
63
  const result = await this.daemon.post('/api/run', {
69
64
  connector: 'outlook/my-meetings',
70
- args: { date },
65
+ args: { date: anchorDate },
71
66
  });
72
67
  return result.ok && result.data ? result.data : [];
73
68
  }
@@ -75,4 +70,16 @@ export class MeetingsSource {
75
70
  return [];
76
71
  }
77
72
  }
73
+ /** First weekday (Mon–Fri) in [start, end], or null if the range has none. */
74
+ firstWeekday(start, end) {
75
+ const cursor = new Date(start);
76
+ const endDate = new Date(end);
77
+ while (cursor <= endDate) {
78
+ const day = cursor.getDay();
79
+ if (day >= 1 && day <= 5)
80
+ return cursor.toISOString().slice(0, 10);
81
+ cursor.setDate(cursor.getDate() + 1);
82
+ }
83
+ return null;
84
+ }
78
85
  }
@@ -7,11 +7,20 @@
7
7
  * Returns null gracefully when the connector isn't available (Phase 2 dependency).
8
8
  */
9
9
  import type { DaemonClient } from '@commandgarden/shared';
10
- import type { TimetrackingData } from '../journal.types.js';
10
+ import type { TimetrackingData, MonthlyTimetrackingData } from '../journal.types.js';
11
11
  import type { JournalConfig } from '../journal.config.js';
12
12
  export declare class TimetrackingSource {
13
13
  private readonly daemon;
14
14
  private readonly config;
15
+ private readonly rawCache;
15
16
  constructor(daemon: DaemonClient, config: JournalConfig);
17
+ private fetchMonthRaw;
16
18
  fetch(weekStart: string, weekEnd: string): Promise<TimetrackingData | null>;
19
+ /**
20
+ * Compute month-to-date release status (working days released/unreleased)
21
+ * and per-project logged hours. Reuses the cached raw month fetch so this
22
+ * never triggers a second browser-driven connector run for a month that
23
+ * was already fetched by `fetch()` in the same request.
24
+ */
25
+ fetchMonthlyStatus(month: string): Promise<MonthlyTimetrackingData>;
17
26
  }
@@ -9,26 +9,33 @@
9
9
  export class TimetrackingSource {
10
10
  daemon;
11
11
  config;
12
+ // Per-instance cache so a single journal-generate request only ever drives
13
+ // the browser-based timetracking connector once per calendar month, even
14
+ // if both the weekly report and the monthly status need the same month.
15
+ rawCache = new Map();
12
16
  constructor(daemon, config) {
13
17
  this.daemon = daemon;
14
18
  this.config = config;
15
19
  }
20
+ fetchMonthRaw(month) {
21
+ if (!this.rawCache.has(month)) {
22
+ this.rawCache.set(month, this.daemon
23
+ .post('/api/run', { connector: 'timetracking/report', args: { month } })
24
+ .then((result) => (result.ok && result.data ? result.data : []))
25
+ .catch(() => []));
26
+ }
27
+ return this.rawCache.get(month);
28
+ }
16
29
  async fetch(weekStart, weekEnd) {
17
30
  try {
18
31
  // Determine which month(s) the week spans
19
32
  const startMonth = weekStart.slice(0, 7);
20
33
  const endMonth = weekEnd.slice(0, 7);
21
34
  const months = startMonth === endMonth ? [startMonth] : [startMonth, endMonth];
22
- // Fetch each month's report via daemon
35
+ // Fetch each month's report via daemon (cached per month within this request)
23
36
  const allRows = [];
24
37
  for (const month of months) {
25
- const result = await this.daemon.post('/api/run', {
26
- connector: 'timetracking/report',
27
- args: { month },
28
- });
29
- if (result.ok && result.data) {
30
- allRows.push(...result.data);
31
- }
38
+ allRows.push(...(await this.fetchMonthRaw(month)));
32
39
  }
33
40
  // Filter to rows within the week range
34
41
  const weekRows = allRows.filter((r) => {
@@ -80,4 +87,51 @@ export class TimetrackingSource {
80
87
  return null;
81
88
  }
82
89
  }
90
+ /**
91
+ * Compute month-to-date release status (working days released/unreleased)
92
+ * and per-project logged hours. Reuses the cached raw month fetch so this
93
+ * never triggers a second browser-driven connector run for a month that
94
+ * was already fetched by `fetch()` in the same request.
95
+ */
96
+ async fetchMonthlyStatus(month) {
97
+ const raw = await this.fetchMonthRaw(month);
98
+ const [y, m] = month.split('-').map(Number);
99
+ const today = new Date();
100
+ const todayIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
101
+ const daysInMonth = new Date(y, m, 0).getDate();
102
+ let workingDaysTotal = 0;
103
+ const workingDatesElapsed = [];
104
+ for (let d = 1; d <= daysInMonth; d++) {
105
+ const date = new Date(y, m - 1, d);
106
+ const day = date.getDay();
107
+ if (day === 0 || day === 6)
108
+ continue;
109
+ workingDaysTotal += 1;
110
+ const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
111
+ if (iso <= todayIso)
112
+ workingDatesElapsed.push(iso);
113
+ }
114
+ const releasedDateSet = new Set(raw.filter((r) => r.date && r.status && /released/i.test(String(r.status))).map((r) => r.date));
115
+ const releasedDates = workingDatesElapsed.filter((d) => releasedDateSet.has(d));
116
+ const unreleasedDates = workingDatesElapsed.filter((d) => !releasedDateSet.has(d));
117
+ const hoursMap = new Map();
118
+ for (const row of raw) {
119
+ if (!row.projectId)
120
+ continue;
121
+ const hrs = typeof row.hours === 'number' ? row.hours : 0;
122
+ hoursMap.set(row.projectId, (hoursMap.get(row.projectId) ?? 0) + hrs);
123
+ }
124
+ const hoursByProject = [...hoursMap.entries()].map(([projectId, hours]) => ({
125
+ projectId,
126
+ hours: Math.round(hours * 100) / 100,
127
+ }));
128
+ return {
129
+ month,
130
+ workingDaysTotal,
131
+ workingDaysElapsed: workingDatesElapsed.length,
132
+ releasedDates,
133
+ unreleasedDates,
134
+ hoursByProject,
135
+ };
136
+ }
83
137
  }
@@ -2,6 +2,18 @@
2
2
  * Journal Fastify route — POST /api/journal/generate.
3
3
  * Reads journal config from user preferences (SQLite), instantiates
4
4
  * sources with that config, and generates the weekly report.
5
+ *
6
+ * Results are cached in app.db (journal_cache table), keyed by weekStart
7
+ * only — the latest generated result for a week is cached regardless of
8
+ * which sources were selected. A cache hit skips every browser-driven
9
+ * connector call entirely; the response carries `cache: { hit, fetchedAt }`
10
+ * so the UI can always show the user whether they're looking at a cached
11
+ * result and from when. Pass `forceRefresh: true` to bypass the cache and
12
+ * drive a fresh fetch (e.g. via a "Refresh" action in the UI).
13
+ *
14
+ * The user's last-selected source toggles (Timetracking/Meetings/Jira/Git/
15
+ * Saba) are stored separately in journal_source_prefs, so the UI can
16
+ * restore the user's picks on next load.
5
17
  */
6
18
  import type { FastifyInstance } from 'fastify';
7
19
  import type { DaemonClient } from '@commandgarden/shared';
@@ -2,6 +2,18 @@
2
2
  * Journal Fastify route — POST /api/journal/generate.
3
3
  * Reads journal config from user preferences (SQLite), instantiates
4
4
  * sources with that config, and generates the weekly report.
5
+ *
6
+ * Results are cached in app.db (journal_cache table), keyed by weekStart
7
+ * only — the latest generated result for a week is cached regardless of
8
+ * which sources were selected. A cache hit skips every browser-driven
9
+ * connector call entirely; the response carries `cache: { hit, fetchedAt }`
10
+ * so the UI can always show the user whether they're looking at a cached
11
+ * result and from when. Pass `forceRefresh: true` to bypass the cache and
12
+ * drive a fresh fetch (e.g. via a "Refresh" action in the UI).
13
+ *
14
+ * The user's last-selected source toggles (Timetracking/Meetings/Jira/Git/
15
+ * Saba) are stored separately in journal_source_prefs, so the UI can
16
+ * restore the user's picks on next load.
5
17
  */
6
18
  import { getJournalConfig } from '../journal/journal.config.js';
7
19
  import { GitSource } from '../journal/sources/git.source.js';
@@ -10,6 +22,60 @@ import { TimetrackingSource } from '../journal/sources/timetracking.source.js';
10
22
  import { MeetingsSource } from '../journal/sources/meetings.source.js';
11
23
  import { JournalService } from '../journal/journal.service.js';
12
24
  export function journalRoutes(app, daemon, store) {
25
+ // Read-only cache lookup — no connector calls, no config validation. Used by the
26
+ // UI to silently restore a previously generated result (e.g. after navigating
27
+ // away and back, which remounts the page and loses its local React state).
28
+ app.get('/api/journal/cache', async (req, reply) => {
29
+ const query = req.query;
30
+ if (!query.weekStart || !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(query.weekStart)) {
31
+ return reply.code(400).send({
32
+ ok: false,
33
+ error: 'weekStart is required and must be YYYY-MM-DD format',
34
+ });
35
+ }
36
+ const cached = store.getCachedJournal(query.weekStart);
37
+ if (!cached)
38
+ return { ok: true, data: null, saba: null };
39
+ return {
40
+ ok: true,
41
+ data: { ...cached.data, cache: { hit: true, fetchedAt: cached.fetchedAt } },
42
+ saba: cached.sabaData,
43
+ };
44
+ });
45
+ // Persists the Saba "pending training" result for a week — saved separately
46
+ // from the main journal generate() flow since it's fetched client-side and
47
+ // can complete at a different time.
48
+ app.post('/api/journal/cache/saba', async (req, reply) => {
49
+ const body = req.body;
50
+ if (!body?.weekStart || !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(body.weekStart)) {
51
+ return reply.code(400).send({
52
+ ok: false,
53
+ error: 'weekStart is required and must be YYYY-MM-DD format',
54
+ });
55
+ }
56
+ store.cacheJournalSaba(body.weekStart, body.saba ?? null);
57
+ return { ok: true };
58
+ });
59
+ // Last-selected source toggles — persisted so the UI can restore the
60
+ // user's picks on next load instead of always resetting to "all enabled".
61
+ app.get('/api/journal/source-prefs', async () => {
62
+ const prefs = store.getJournalSourcePrefs();
63
+ return { ok: true, prefs };
64
+ });
65
+ app.post('/api/journal/source-prefs', async (req, reply) => {
66
+ const body = req.body;
67
+ if (!body) {
68
+ return reply.code(400).send({ ok: false, error: 'Request body is required' });
69
+ }
70
+ store.saveJournalSourcePrefs({
71
+ timetracking: body.timetracking ?? true,
72
+ meetings: body.meetings ?? true,
73
+ jira: body.jira ?? true,
74
+ git: body.git ?? true,
75
+ saba: body.saba ?? true,
76
+ });
77
+ return { ok: true };
78
+ });
13
79
  app.post('/api/journal/generate', async (req, reply) => {
14
80
  const body = req.body;
15
81
  // Validate weekStart is present and matches YYYY-MM-DD format
@@ -19,10 +85,25 @@ export function journalRoutes(app, daemon, store) {
19
85
  error: 'weekStart is required and must be YYYY-MM-DD format',
20
86
  });
21
87
  }
88
+ const sources = {
89
+ timetracking: body.sources?.timetracking ?? true,
90
+ meetings: body.sources?.meetings ?? true,
91
+ jira: body.sources?.jira ?? true,
92
+ git: body.sources?.git ?? true,
93
+ };
94
+ if (!body.forceRefresh) {
95
+ const cached = store.getCachedJournal(body.weekStart);
96
+ if (cached) {
97
+ return {
98
+ ...cached.data,
99
+ cache: { hit: true, fetchedAt: cached.fetchedAt },
100
+ };
101
+ }
102
+ }
22
103
  // Build config from user preferences (configured via GUI Config page)
23
104
  const config = getJournalConfig(store);
24
- // Check that required settings are configured
25
- if (!config.azureDevOps.org || config.azureDevOps.repos.length === 0) {
105
+ // Check that required settings are configured, only if git (ADO) is enabled
106
+ if (sources.git && (!config.azureDevOps.org || config.azureDevOps.repos.length === 0)) {
26
107
  return reply.code(400).send({
27
108
  ok: false,
28
109
  error: 'Journal not configured. Go to Configuration → Journal Settings and set your ADO org and repos.',
@@ -36,16 +117,17 @@ export function journalRoutes(app, daemon, store) {
36
117
  const timetracking = new TimetrackingSource(daemon, config);
37
118
  const meetings = new MeetingsSource(daemon);
38
119
  const service = new JournalService(git, timetracking, meetings, jira);
39
- const result = await service.generate({ weekStart: body.weekStart });
120
+ const result = await service.generate({ weekStart: body.weekStart, sources });
121
+ const fetchedAt = store.cacheJournal(body.weekStart, result);
40
122
  // Audit log (non-blocking — failure should not break the response)
41
123
  const durationMs = Date.now() - start;
42
124
  daemon.post('/api/audit/log', {
43
125
  command: 'journal generate',
44
- args: { weekStart: body.weekStart },
126
+ args: { weekStart: body.weekStart, sources },
45
127
  status: result.status,
46
128
  durationMs,
47
129
  }).catch(() => { });
48
- return result;
130
+ return { ...result, cache: { hit: false, fetchedAt } };
49
131
  }
50
132
  catch (err) {
51
133
  const message = err instanceof Error ? err.message : 'Unknown error';