@commandgarden/cli 1.2.3 → 1.3.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 (51) hide show
  1. package/dist/main.js +8907 -8895
  2. package/node_modules/@commandgarden/app/dist/client/assets/index-B-_w54e9.css +1 -0
  3. package/node_modules/@commandgarden/app/dist/client/assets/index-Bb1BOK1E.js +252 -0
  4. package/node_modules/@commandgarden/app/dist/client/index.html +19 -19
  5. package/node_modules/@commandgarden/app/dist/server/daemon-client.test.js +47 -0
  6. package/node_modules/@commandgarden/app/dist/server/main.d.ts +1 -0
  7. package/node_modules/@commandgarden/app/dist/server/main.js +3 -3
  8. package/node_modules/@commandgarden/app/dist/server/routes/audit.d.ts +3 -0
  9. package/node_modules/@commandgarden/app/dist/server/routes/config.d.ts +3 -0
  10. package/node_modules/@commandgarden/app/dist/server/routes/connectors.d.ts +3 -0
  11. package/node_modules/@commandgarden/app/dist/server/routes/goals.d.ts +3 -0
  12. package/node_modules/@commandgarden/app/dist/server/routes/goals.js +31 -0
  13. package/node_modules/@commandgarden/app/dist/server/routes/index.d.ts +4 -0
  14. package/node_modules/@commandgarden/app/dist/server/routes/index.js +4 -0
  15. package/node_modules/@commandgarden/app/dist/server/routes/preferences.d.ts +3 -0
  16. package/node_modules/@commandgarden/app/dist/server/routes/routes.test.js +137 -0
  17. package/node_modules/@commandgarden/app/dist/server/routes/run.d.ts +3 -0
  18. package/node_modules/@commandgarden/app/dist/server/routes/status.d.ts +3 -0
  19. package/node_modules/@commandgarden/app/dist/server/routes/timetracking-cache.d.ts +3 -0
  20. package/node_modules/@commandgarden/app/dist/server/routes/timetracking-cache.js +35 -0
  21. package/node_modules/@commandgarden/app/dist/server/store.d.ts +47 -0
  22. package/node_modules/@commandgarden/app/dist/server/store.js +112 -10
  23. package/node_modules/@commandgarden/app/dist/server/store.test.js +59 -0
  24. package/node_modules/@commandgarden/app/package.json +42 -41
  25. package/node_modules/@commandgarden/daemon/connectors/lib/msal-token.js +20 -0
  26. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.eval.js +341 -338
  27. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.yaml +46 -45
  28. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.eval.js +52 -0
  29. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.yaml +42 -0
  30. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.eval.js +81 -60
  31. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.yaml +52 -48
  32. package/node_modules/@commandgarden/daemon/dist/audit-store.js +8 -8
  33. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts +24 -0
  34. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts.map +1 -0
  35. package/node_modules/@commandgarden/daemon/dist/cdp-client.js +150 -0
  36. package/node_modules/@commandgarden/daemon/dist/cdp-client.js.map +1 -0
  37. package/node_modules/@commandgarden/daemon/dist/config.js +1 -1
  38. package/node_modules/@commandgarden/daemon/dist/config.js.map +1 -1
  39. package/node_modules/@commandgarden/daemon/package.json +30 -30
  40. package/node_modules/@commandgarden/shared/dist/daemon-client.d.ts +19 -0
  41. package/node_modules/@commandgarden/shared/dist/daemon-client.d.ts.map +1 -0
  42. package/node_modules/@commandgarden/shared/dist/daemon-client.js +129 -0
  43. package/node_modules/@commandgarden/shared/dist/daemon-client.js.map +1 -0
  44. package/node_modules/@commandgarden/shared/dist/index.d.ts +1 -0
  45. package/node_modules/@commandgarden/shared/dist/index.d.ts.map +1 -1
  46. package/node_modules/@commandgarden/shared/dist/index.js +2 -0
  47. package/node_modules/@commandgarden/shared/dist/index.js.map +1 -1
  48. package/node_modules/@commandgarden/shared/package.json +32 -32
  49. package/package.json +55 -55
  50. package/node_modules/@commandgarden/app/dist/client/assets/index-58UrF1aG.js +0 -197
  51. package/node_modules/@commandgarden/app/dist/client/assets/index-DErcb2Kp.css +0 -1
@@ -1,19 +1,19 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="dark">
3
- <head>
4
- <script>
5
- (function(){var t=localStorage.getItem('theme');if(t){document.documentElement.setAttribute('data-theme',t)}else if(matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.setAttribute('data-theme','dark')}})();
6
- </script>
7
- <meta charset="UTF-8" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9
- <link rel="preconnect" href="https://fonts.googleapis.com" />
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
12
- <title>commandGarden</title>
13
- <script type="module" crossorigin src="/assets/index-58UrF1aG.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-DErcb2Kp.css">
15
- </head>
16
- <body>
17
- <div id="root"></div>
18
- </body>
19
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <script>
5
+ (function(){var t=localStorage.getItem('theme');if(t){document.documentElement.setAttribute('data-theme',t)}else if(matchMedia('(prefers-color-scheme:dark)').matches){document.documentElement.setAttribute('data-theme','dark')}})();
6
+ </script>
7
+ <meta charset="UTF-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
12
+ <title>commandGarden</title>
13
+ <script type="module" crossorigin src="/assets/index-Bb1BOK1E.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-B-_w54e9.css">
15
+ </head>
16
+ <body>
17
+ <div id="root"></div>
18
+ </body>
19
+ </html>
@@ -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
+ });
@@ -6,7 +6,7 @@ import { existsSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { parse as parseYaml } from 'yaml';
8
8
  import { readFileSync } from 'node:fs';
9
- import { DaemonClient, readToken } from './daemon-client.js';
9
+ import { DaemonClient, readToken } from '@commandgarden/shared';
10
10
  import { AppStore } from './store.js';
11
11
  import { registerRoutes } from './routes/index.js';
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -23,7 +23,7 @@ function readAppPort() {
23
23
  }
24
24
  }
25
25
  catch { /* use default */ }
26
- return 19826;
26
+ return 9092;
27
27
  }
28
28
  async function start() {
29
29
  const token = readToken(TOKEN_PATH);
@@ -32,7 +32,7 @@ async function start() {
32
32
  process.exit(1);
33
33
  }
34
34
  const port = readAppPort();
35
- const daemonUrl = 'http://127.0.0.1:19825';
35
+ const daemonUrl = 'http://127.0.0.1:9091';
36
36
  const daemon = new DaemonClient(daemonUrl, token);
37
37
  const store = await AppStore.create(join(CG_HOME, 'app.db'));
38
38
  const app = Fastify();
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ export declare function auditRoutes(app: FastifyInstance, daemon: DaemonClient): void;
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ export declare function configRoutes(app: FastifyInstance, daemon: DaemonClient): void;
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ export declare function connectorRoutes(app: FastifyInstance, daemon: DaemonClient): void;
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppStore } from '../store.js';
3
+ export declare function goalsRoutes(app: FastifyInstance, store: AppStore): void;
@@ -0,0 +1,31 @@
1
+ export function goalsRoutes(app, store) {
2
+ app.get('/api/goals', async (req) => {
3
+ const { month } = req.query;
4
+ if (!month)
5
+ return { ok: true, goals: [] };
6
+ return { ok: true, goals: store.getGoalsByMonth(month) };
7
+ });
8
+ app.post('/api/goals', async (req, reply) => {
9
+ const { month, projectId, activity, targetDays } = req.body;
10
+ if (!month || !projectId || !activity || typeof targetDays !== 'number' || targetDays < 0.5 || targetDays > 31) {
11
+ reply.code(400);
12
+ return { ok: false, error: 'Invalid month, projectId, activity, or targetDays (0.5–31)' };
13
+ }
14
+ const goal = store.upsertGoal(month, projectId, activity, targetDays);
15
+ return { ok: true, goal };
16
+ });
17
+ app.delete('/api/goals/:id', async (req, reply) => {
18
+ const { id } = req.params;
19
+ const numId = parseInt(id, 10);
20
+ if (isNaN(numId)) {
21
+ reply.code(400);
22
+ return { ok: false, error: 'Invalid goal ID' };
23
+ }
24
+ const deleted = store.deleteGoal(numId);
25
+ if (!deleted) {
26
+ reply.code(404);
27
+ return { ok: false, error: 'Goal not found' };
28
+ }
29
+ return { ok: true };
30
+ });
31
+ }
@@ -0,0 +1,4 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ import type { AppStore } from '../store.js';
4
+ export declare function registerRoutes(app: FastifyInstance, daemon: DaemonClient, store: AppStore): void;
@@ -4,6 +4,8 @@ import { runRoutes } from './run.js';
4
4
  import { auditRoutes } from './audit.js';
5
5
  import { configRoutes } from './config.js';
6
6
  import { preferencesRoutes } from './preferences.js';
7
+ import { goalsRoutes } from './goals.js';
8
+ import { timetrackingCacheRoutes } from './timetracking-cache.js';
7
9
  export function registerRoutes(app, daemon, store) {
8
10
  statusRoutes(app, daemon);
9
11
  connectorRoutes(app, daemon);
@@ -11,4 +13,6 @@ export function registerRoutes(app, daemon, store) {
11
13
  auditRoutes(app, daemon);
12
14
  configRoutes(app, daemon);
13
15
  preferencesRoutes(app, store);
16
+ goalsRoutes(app, store);
17
+ timetrackingCacheRoutes(app, store);
14
18
  }
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppStore } from '../store.js';
3
+ export declare function preferencesRoutes(app: FastifyInstance, store: AppStore): void;
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import Fastify from 'fastify';
3
+ import { registerRoutes } from './index.js';
4
+ import { AppStore } from '../store.js';
5
+ function mockDaemon() {
6
+ return {
7
+ get: vi.fn(),
8
+ post: vi.fn(),
9
+ pipeRaw: vi.fn(),
10
+ };
11
+ }
12
+ describe('routes', () => {
13
+ let app;
14
+ let daemon;
15
+ let store;
16
+ beforeEach(async () => {
17
+ app = Fastify();
18
+ daemon = mockDaemon();
19
+ store = new AppStore(':memory:');
20
+ registerRoutes(app, daemon, store);
21
+ await app.ready();
22
+ });
23
+ describe('GET /api/status', () => {
24
+ it('proxies daemon status', async () => {
25
+ daemon.get.mockResolvedValueOnce({
26
+ ok: true, extensionConnected: true, connectorCount: 2,
27
+ });
28
+ const resp = await app.inject({ method: 'GET', url: '/api/status' });
29
+ expect(resp.statusCode).toBe(200);
30
+ const body = JSON.parse(resp.payload);
31
+ expect(body.ok).toBe(true);
32
+ expect(body.extensionConnected).toBe(true);
33
+ });
34
+ });
35
+ describe('GET /api/connectors', () => {
36
+ it('proxies and enriches connector list', async () => {
37
+ daemon.get
38
+ .mockImplementation((path) => {
39
+ if (path === '/api/connectors') {
40
+ return Promise.resolve({
41
+ ok: true,
42
+ connectors: [
43
+ { key: 'timetracking/report', description: 'test', access: 'read', domains: [], capabilities: [] },
44
+ ],
45
+ });
46
+ }
47
+ if (path === '/api/config') {
48
+ return Promise.resolve({
49
+ ok: true,
50
+ config: { security: { highRiskCapabilities: ['js_evaluate', 'cookie_write'], approvedHighRisk: [], autoApproveConnectors: [] } },
51
+ });
52
+ }
53
+ return Promise.resolve({ ok: true });
54
+ });
55
+ const resp = await app.inject({ method: 'GET', url: '/api/connectors' });
56
+ expect(resp.statusCode).toBe(200);
57
+ const body = JSON.parse(resp.payload);
58
+ expect(body.connectors).toHaveLength(1);
59
+ expect(body.connectors[0].hasAppPage).toBe(true);
60
+ expect(body.connectors[0].appRoute).toBe('/apps/timetracking');
61
+ });
62
+ it('enriches connectors with security flags from config', async () => {
63
+ daemon.get
64
+ .mockImplementation((path) => {
65
+ if (path === '/api/connectors') {
66
+ return Promise.resolve({
67
+ ok: true,
68
+ connectors: [
69
+ { key: 'timetracking/report', description: 'test', access: 'read', domains: [], capabilities: ['navigate', 'js_evaluate'] },
70
+ { key: 'safe/connector', description: 'safe', access: 'read', domains: [], capabilities: ['navigate'] },
71
+ ],
72
+ });
73
+ }
74
+ if (path === '/api/config') {
75
+ return Promise.resolve({
76
+ ok: true,
77
+ config: {
78
+ security: {
79
+ highRiskCapabilities: ['js_evaluate', 'cookie_write'],
80
+ approvedHighRisk: ['timetracking/report'],
81
+ autoApproveConnectors: ['timetracking/report'],
82
+ },
83
+ },
84
+ });
85
+ }
86
+ return Promise.resolve({ ok: true });
87
+ });
88
+ const resp = await app.inject({ method: 'GET', url: '/api/connectors' });
89
+ const body = JSON.parse(resp.payload);
90
+ expect(body.connectors[0].isHighRisk).toBe(true);
91
+ expect(body.connectors[0].isApproved).toBe(true);
92
+ expect(body.connectors[0].isAutoApproved).toBe(true);
93
+ expect(body.connectors[1].isHighRisk).toBe(false);
94
+ expect(body.connectors[1].isApproved).toBe(false);
95
+ expect(body.connectors[1].isAutoApproved).toBe(false);
96
+ });
97
+ });
98
+ describe('GET /api/preferences', () => {
99
+ it('returns empty preferences', async () => {
100
+ const resp = await app.inject({ method: 'GET', url: '/api/preferences' });
101
+ expect(resp.statusCode).toBe(200);
102
+ expect(JSON.parse(resp.payload)).toEqual({ ok: true, preferences: {} });
103
+ });
104
+ it('sets and gets a preference via PUT', async () => {
105
+ await app.inject({
106
+ method: 'PUT', url: '/api/preferences',
107
+ payload: { key: 'theme', value: 'dark' },
108
+ });
109
+ const resp = await app.inject({ method: 'GET', url: '/api/preferences' });
110
+ expect(JSON.parse(resp.payload).preferences).toEqual({ theme: 'dark' });
111
+ });
112
+ });
113
+ describe('POST /api/run', () => {
114
+ it('proxies run to daemon', async () => {
115
+ daemon.post.mockResolvedValueOnce({
116
+ ok: true, data: [{ a: 1 }], rowCount: 1,
117
+ });
118
+ const resp = await app.inject({
119
+ method: 'POST', url: '/api/run',
120
+ payload: { connector: 'test/cmd', args: {} },
121
+ });
122
+ expect(resp.statusCode).toBe(200);
123
+ expect(JSON.parse(resp.payload).ok).toBe(true);
124
+ });
125
+ it('returns 502 with error message when daemon throws', async () => {
126
+ daemon.post.mockRejectedValueOnce(new Error('Extension not connected'));
127
+ const resp = await app.inject({
128
+ method: 'POST', url: '/api/run',
129
+ payload: { connector: 'test/cmd', args: {} },
130
+ });
131
+ expect(resp.statusCode).toBe(502);
132
+ const body = JSON.parse(resp.payload);
133
+ expect(body.ok).toBe(false);
134
+ expect(body.error).toBe('Extension not connected');
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ export declare function runRoutes(app: FastifyInstance, daemon: DaemonClient): void;
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { DaemonClient } from '@commandgarden/shared';
3
+ export declare function statusRoutes(app: FastifyInstance, daemon: DaemonClient): void;
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { AppStore } from '../store.js';
3
+ export declare function timetrackingCacheRoutes(app: FastifyInstance, store: AppStore): void;
@@ -0,0 +1,35 @@
1
+ export function timetrackingCacheRoutes(app, store) {
2
+ app.get('/api/timetracking/cache', async (req) => {
3
+ const { month } = req.query;
4
+ if (!month)
5
+ return { ok: true, data: null, fetchedAt: null };
6
+ const cached = store.getCachedReport(month);
7
+ if (!cached)
8
+ return { ok: true, data: null, fetchedAt: null };
9
+ return { ok: true, data: cached.data, fetchedAt: cached.fetchedAt };
10
+ });
11
+ app.post('/api/timetracking/cache', async (req, reply) => {
12
+ const { month, data } = req.body;
13
+ if (!month || !Array.isArray(data)) {
14
+ reply.code(400);
15
+ return { ok: false, error: 'Missing month or data array' };
16
+ }
17
+ store.cacheReport(month, data);
18
+ return { ok: true };
19
+ });
20
+ app.get('/api/timetracking/projects', async () => {
21
+ const cached = store.getCachedProjects();
22
+ if (!cached)
23
+ return { ok: true, data: null, fetchedAt: null };
24
+ return { ok: true, data: cached.data, fetchedAt: cached.fetchedAt };
25
+ });
26
+ app.post('/api/timetracking/projects', async (req, reply) => {
27
+ const { data } = req.body;
28
+ if (!Array.isArray(data)) {
29
+ reply.code(400);
30
+ return { ok: false, error: 'Missing data array' };
31
+ }
32
+ store.cacheProjects(data);
33
+ return { ok: true };
34
+ });
35
+ }
@@ -0,0 +1,47 @@
1
+ export interface SavedView {
2
+ id: string;
3
+ app: string;
4
+ name: string;
5
+ config: string;
6
+ created_at: string;
7
+ }
8
+ export interface Goal {
9
+ id: number;
10
+ month: string;
11
+ projectId: string;
12
+ activity: string;
13
+ targetDays: number;
14
+ targetHours: number;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ }
18
+ export declare class AppStore {
19
+ private db;
20
+ private dbPath;
21
+ private constructor();
22
+ static create(dbPath: string): Promise<AppStore>;
23
+ private persist;
24
+ private query;
25
+ private migrate;
26
+ getPreference(key: string): string | undefined;
27
+ setPreference(key: string, value: string): void;
28
+ getAllPreferences(): Record<string, string>;
29
+ listViews(app: string): SavedView[];
30
+ createView(app: string, name: string, config: string): SavedView;
31
+ deleteView(id: string): boolean;
32
+ upsertGoal(month: string, projectId: string, activity: string, targetDays: number): Goal;
33
+ getGoalsByMonth(month: string): Goal[];
34
+ deleteGoal(id: number): boolean;
35
+ private rowToGoal;
36
+ cacheReport(month: string, data: Record<string, unknown>[]): void;
37
+ getCachedReport(month: string): {
38
+ data: Record<string, unknown>[];
39
+ fetchedAt: string;
40
+ } | null;
41
+ cacheProjects(data: Record<string, unknown>[]): void;
42
+ getCachedProjects(): {
43
+ data: Record<string, unknown>[];
44
+ fetchedAt: string;
45
+ } | null;
46
+ close(): void;
47
+ }
@@ -42,23 +42,60 @@ export class AppStore {
42
42
  if (results.length === 0)
43
43
  return [];
44
44
  const { columns, values } = results[0];
45
- return values.map(row => {
45
+ return values.map((row) => {
46
46
  const obj = {};
47
47
  columns.forEach((col, i) => { obj[col] = row[i]; });
48
48
  return obj;
49
49
  });
50
50
  }
51
51
  migrate() {
52
- this.db.run(`CREATE TABLE IF NOT EXISTS preferences (
53
- key TEXT PRIMARY KEY,
54
- value TEXT NOT NULL
52
+ this.db.run(`CREATE TABLE IF NOT EXISTS preferences (
53
+ key TEXT PRIMARY KEY,
54
+ value TEXT NOT NULL
55
55
  )`);
56
- this.db.run(`CREATE TABLE IF NOT EXISTS saved_views (
57
- id TEXT PRIMARY KEY,
58
- app TEXT NOT NULL,
59
- name TEXT NOT NULL,
60
- config TEXT NOT NULL,
61
- created_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ this.db.run(`CREATE TABLE IF NOT EXISTS saved_views (
57
+ id TEXT PRIMARY KEY,
58
+ app TEXT NOT NULL,
59
+ name TEXT NOT NULL,
60
+ config TEXT NOT NULL,
61
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
62
+ )`);
63
+ // Drop legacy goals table (had category column) and recreate with activity
64
+ try {
65
+ this.db.run(`DROP TABLE IF EXISTS goals_old`);
66
+ }
67
+ catch { /* ignore */ }
68
+ // Migrate: if goals table has 'category' column but not 'activity', recreate
69
+ try {
70
+ this.db.run(`SELECT activity FROM goals LIMIT 0`);
71
+ }
72
+ catch {
73
+ // 'activity' column missing — drop and recreate
74
+ try {
75
+ this.db.run(`DROP TABLE goals`);
76
+ }
77
+ catch { /* ignore */ }
78
+ }
79
+ this.db.run(`CREATE TABLE IF NOT EXISTS goals (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ month TEXT NOT NULL,
82
+ project_id TEXT NOT NULL,
83
+ activity TEXT NOT NULL DEFAULT '',
84
+ target_days REAL NOT NULL,
85
+ target_hours REAL NOT NULL,
86
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
87
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ UNIQUE(month, project_id, activity)
89
+ )`);
90
+ this.db.run(`CREATE TABLE IF NOT EXISTS report_cache (
91
+ month TEXT PRIMARY KEY,
92
+ data TEXT NOT NULL,
93
+ fetched_at TEXT NOT NULL
94
+ )`);
95
+ this.db.run(`CREATE TABLE IF NOT EXISTS projects_cache (
96
+ id INTEGER PRIMARY KEY CHECK (id = 1),
97
+ data TEXT NOT NULL,
98
+ fetched_at TEXT NOT NULL
62
99
  )`);
63
100
  }
64
101
  getPreference(key) {
@@ -93,6 +130,71 @@ export class AppStore {
93
130
  this.persist();
94
131
  return changes > 0;
95
132
  }
133
+ upsertGoal(month, projectId, activity, targetDays) {
134
+ const targetHours = targetDays * 8;
135
+ const now = new Date().toISOString();
136
+ const existing = this.query('SELECT id FROM goals WHERE month = ? AND project_id = ? AND activity = ?', [month, projectId, activity]);
137
+ if (existing.length > 0) {
138
+ this.db.run('UPDATE goals SET target_days = ?, target_hours = ?, updated_at = ? WHERE month = ? AND project_id = ? AND activity = ?', [targetDays, targetHours, now, month, projectId, activity]);
139
+ }
140
+ else {
141
+ this.db.run('INSERT INTO goals (month, project_id, activity, target_days, target_hours, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [month, projectId, activity, targetDays, targetHours, now, now]);
142
+ }
143
+ this.persist();
144
+ const rows = this.query('SELECT * FROM goals WHERE month = ? AND project_id = ? AND activity = ?', [month, projectId, activity]);
145
+ return this.rowToGoal(rows[0]);
146
+ }
147
+ getGoalsByMonth(month) {
148
+ const rows = this.query('SELECT * FROM goals WHERE month = ? ORDER BY project_id ASC', [month]);
149
+ return rows.map((r) => this.rowToGoal(r));
150
+ }
151
+ deleteGoal(id) {
152
+ this.db.run('DELETE FROM goals WHERE id = ?', [id]);
153
+ const changes = this.db.getRowsModified();
154
+ if (changes > 0)
155
+ this.persist();
156
+ return changes > 0;
157
+ }
158
+ rowToGoal(row) {
159
+ return {
160
+ id: row.id,
161
+ month: row.month,
162
+ projectId: row.project_id,
163
+ activity: row.activity ?? '',
164
+ targetDays: row.target_days,
165
+ targetHours: row.target_hours,
166
+ createdAt: row.created_at,
167
+ updatedAt: row.updated_at,
168
+ };
169
+ }
170
+ cacheReport(month, data) {
171
+ const now = new Date().toISOString();
172
+ this.db.run('INSERT OR REPLACE INTO report_cache (month, data, fetched_at) VALUES (?, ?, ?)', [month, JSON.stringify(data), now]);
173
+ this.persist();
174
+ }
175
+ getCachedReport(month) {
176
+ const rows = this.query('SELECT data, fetched_at FROM report_cache WHERE month = ?', [month]);
177
+ if (rows.length === 0)
178
+ return null;
179
+ return {
180
+ data: JSON.parse(rows[0].data),
181
+ fetchedAt: rows[0].fetched_at,
182
+ };
183
+ }
184
+ cacheProjects(data) {
185
+ const now = new Date().toISOString();
186
+ this.db.run('INSERT OR REPLACE INTO projects_cache (id, data, fetched_at) VALUES (1, ?, ?)', [JSON.stringify(data), now]);
187
+ this.persist();
188
+ }
189
+ getCachedProjects() {
190
+ const rows = this.query('SELECT data, fetched_at FROM projects_cache WHERE id = 1');
191
+ if (rows.length === 0)
192
+ return null;
193
+ return {
194
+ data: JSON.parse(rows[0].data),
195
+ fetchedAt: rows[0].fetched_at,
196
+ };
197
+ }
96
198
  close() {
97
199
  this.db.close();
98
200
  }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { AppStore } from './store.js';
3
+ describe('AppStore', () => {
4
+ let store;
5
+ beforeEach(() => {
6
+ store = new AppStore(':memory:');
7
+ });
8
+ afterEach(() => {
9
+ store.close();
10
+ });
11
+ describe('preferences', () => {
12
+ it('returns undefined for missing key', () => {
13
+ expect(store.getPreference('missing')).toBeUndefined();
14
+ });
15
+ it('sets and gets a preference', () => {
16
+ store.setPreference('theme', 'light');
17
+ expect(store.getPreference('theme')).toBe('light');
18
+ });
19
+ it('overwrites existing preference', () => {
20
+ store.setPreference('theme', 'light');
21
+ store.setPreference('theme', 'dark');
22
+ expect(store.getPreference('theme')).toBe('dark');
23
+ });
24
+ it('returns all preferences', () => {
25
+ store.setPreference('a', '1');
26
+ store.setPreference('b', '2');
27
+ expect(store.getAllPreferences()).toEqual({ a: '1', b: '2' });
28
+ });
29
+ });
30
+ describe('saved views', () => {
31
+ it('returns empty array when no views', () => {
32
+ expect(store.listViews('timetracking')).toEqual([]);
33
+ });
34
+ it('creates and lists a view', () => {
35
+ const view = store.createView('timetracking', 'June report', '{"month":"2026-06"}');
36
+ expect(view.id).toBeDefined();
37
+ expect(view.app).toBe('timetracking');
38
+ expect(view.name).toBe('June report');
39
+ expect(view.config).toBe('{"month":"2026-06"}');
40
+ const views = store.listViews('timetracking');
41
+ expect(views).toHaveLength(1);
42
+ expect(views[0].name).toBe('June report');
43
+ });
44
+ it('filters views by app', () => {
45
+ store.createView('timetracking', 'View A', '{}');
46
+ store.createView('rooms', 'View B', '{}');
47
+ expect(store.listViews('timetracking')).toHaveLength(1);
48
+ expect(store.listViews('rooms')).toHaveLength(1);
49
+ });
50
+ it('deletes a view', () => {
51
+ const view = store.createView('timetracking', 'To delete', '{}');
52
+ expect(store.deleteView(view.id)).toBe(true);
53
+ expect(store.listViews('timetracking')).toHaveLength(0);
54
+ });
55
+ it('returns false when deleting non-existent view', () => {
56
+ expect(store.deleteView('nonexistent')).toBe(false);
57
+ });
58
+ });
59
+ });