@commandgarden/cli 2.10.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.
- package/dist/main.js +6 -6
- package/node_modules/@commandgarden/app/dist/client/assets/{Slides-D3xcxd9t.js → Slides-BvMlkL-3.js} +5 -15
- package/node_modules/@commandgarden/app/dist/client/assets/index-BD8reQ8T.js +496 -0
- package/node_modules/@commandgarden/app/dist/client/assets/{index-DbqYLIqy.css → index-wgb9-PIF.css} +1 -1
- package/node_modules/@commandgarden/app/dist/client/index.html +19 -19
- package/node_modules/@commandgarden/app/dist/server/daemon-client.test.js +47 -0
- package/node_modules/@commandgarden/app/dist/server/journal/journal.service.d.ts +1 -0
- package/node_modules/@commandgarden/app/dist/server/journal/journal.service.js +25 -6
- package/node_modules/@commandgarden/app/dist/server/journal/journal.types.d.ts +26 -0
- package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.d.ts +10 -4
- package/node_modules/@commandgarden/app/dist/server/journal/sources/meetings.source.js +36 -29
- package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.d.ts +10 -1
- package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.js +62 -8
- package/node_modules/@commandgarden/app/dist/server/routes/journal.d.ts +12 -0
- package/node_modules/@commandgarden/app/dist/server/routes/journal.js +87 -5
- package/node_modules/@commandgarden/app/dist/server/routes/routes.test.js +137 -0
- package/node_modules/@commandgarden/app/dist/server/store.d.ts +27 -0
- package/node_modules/@commandgarden/app/dist/server/store.js +130 -39
- package/node_modules/@commandgarden/app/dist/server/store.test.js +59 -0
- package/node_modules/@commandgarden/app/package.json +42 -42
- package/node_modules/@commandgarden/chrome/dist/content-script.js.map +1 -1
- package/node_modules/@commandgarden/chrome/dist/fetch-interceptor.js +44 -0
- package/node_modules/@commandgarden/chrome/dist/manifest.json +26 -26
- package/node_modules/@commandgarden/chrome/dist/popup.html +218 -218
- package/node_modules/@commandgarden/chrome/dist/popup.js.map +1 -1
- package/node_modules/@commandgarden/chrome/dist/service-worker.js +11 -3
- package/node_modules/@commandgarden/chrome/dist/service-worker.js.map +2 -2
- package/node_modules/@commandgarden/chrome/package.json +26 -26
- package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.eval.js +100 -100
- package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.yaml +70 -70
- package/node_modules/@commandgarden/daemon/connectors/gcs-kb-content.yaml +61 -61
- package/node_modules/@commandgarden/daemon/connectors/gcs-kb-pages.yaml +54 -54
- package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.eval.js +115 -115
- package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.yaml +54 -54
- package/node_modules/@commandgarden/daemon/connectors/lib/msal-token.js +20 -20
- package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.eval.js +204 -198
- package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.yaml +42 -42
- package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.eval.js +94 -94
- package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.yaml +34 -34
- package/node_modules/@commandgarden/daemon/connectors/socket-security-news.yaml +63 -63
- package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.eval.js +437 -437
- package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.yaml +53 -53
- package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.eval.js +547 -547
- package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.yaml +48 -48
- package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.eval.js +52 -52
- package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.yaml +42 -42
- package/node_modules/@commandgarden/daemon/connectors/timetracking-report.eval.js +81 -81
- package/node_modules/@commandgarden/daemon/connectors/timetracking-report.yaml +52 -52
- package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.eval.js +27 -27
- package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.yaml +38 -38
- package/node_modules/@commandgarden/daemon/connectors/tokenmaster-clients-list.yaml +55 -55
- package/node_modules/@commandgarden/daemon/dist/audit-store.js +8 -8
- package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts +24 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts.map +1 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.js +150 -0
- package/node_modules/@commandgarden/daemon/dist/cdp-client.js.map +1 -0
- package/node_modules/@commandgarden/daemon/package.json +32 -32
- package/node_modules/@commandgarden/shared/package.json +32 -32
- package/package.json +58 -58
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
19
|
-
private
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
66
|
-
async
|
|
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
|
}
|
package/node_modules/@commandgarden/app/dist/server/journal/sources/timetracking.source.d.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|