@zeyos/client 0.1.1 → 0.2.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 (28) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/agents/README.md +16 -6
  3. package/agents/shared/zeyos-agent-operating-guide.md +112 -0
  4. package/agents/shared/zeyos-query-patterns.md +12 -1
  5. package/agents/zeyos/SKILL.md +95 -0
  6. package/agents/zeyos-account-intelligence/SKILL.md +1 -1
  7. package/agents/zeyos-account-intelligence/references/workflows.md +7 -0
  8. package/agents/zeyos-billing-insights/SKILL.md +6 -1
  9. package/agents/zeyos-billing-insights/references/workflows.md +32 -3
  10. package/agents/zeyos-campaign-and-outreach/SKILL.md +1 -1
  11. package/agents/zeyos-campaign-and-outreach/references/workflows.md +8 -0
  12. package/agents/zeyos-collaboration-and-activity/SKILL.md +1 -1
  13. package/agents/zeyos-collaboration-and-activity/references/workflows.md +9 -0
  14. package/agents/zeyos-collections-and-dunning/SKILL.md +1 -1
  15. package/agents/zeyos-commerce-and-inventory/SKILL.md +1 -1
  16. package/agents/zeyos-commerce-and-inventory/references/workflows.md +7 -0
  17. package/agents/zeyos-mail-operations/SKILL.md +1 -1
  18. package/agents/zeyos-notes-and-sops/SKILL.md +1 -1
  19. package/agents/zeyos-platform-and-schema/SKILL.md +1 -1
  20. package/agents/zeyos-platform-and-schema/references/workflows.md +8 -0
  21. package/agents/zeyos-work-management/SKILL.md +1 -1
  22. package/docs/03-cli/01-getting-started.md +7 -0
  23. package/docs/03-cli/02-commands.md +28 -0
  24. package/package.json +9 -3
  25. package/samples/missioncontrol/README.md +106 -0
  26. package/samples/missioncontrol/fetch-data.mjs +341 -0
  27. package/samples/missioncontrol/index.html +419 -0
  28. package/src/runtime/client.js +27 -0
@@ -113,6 +113,7 @@ zeyos list <resource> [options]
113
113
  |--------|-------------|
114
114
  | `--fields <fields>` | Field selection — comma-separated, JSON object, or JSON array (see below) |
115
115
  | `--filter <json>` | Filter criteria — JSON object |
116
+ | `--filter-file <path>` | Read filter criteria from a JSON file |
116
117
  | `--sort <fields>` | Sort fields, comma-separated (prefix `+` asc, `-` desc) |
117
118
  | `--limit <n>` | Maximum records to return (default: `50`) |
118
119
  | `--offset <n>` | Skip the first n records |
@@ -140,6 +141,9 @@ zeyos list tickets
140
141
  # Custom filters
141
142
  zeyos list tickets --filter '{"status":1,"priority":3}'
142
143
 
144
+ # Custom filters from a file
145
+ zeyos list tickets --filter-file ./filters/open-tickets.json
146
+
143
147
  # Specify fields with aliases
144
148
  zeyos list accounts --fields '{"Name":"lastname","City":"contact.city"}'
145
149
 
@@ -179,6 +183,7 @@ zeyos count <resource> [options]
179
183
  | Option | Description |
180
184
  |--------|-------------|
181
185
  | `--filter <json>` | Filter criteria — JSON object |
186
+ | `--filter-file <path>` | Read filter criteria from a JSON file |
182
187
  | `--json` | Output as `{"count": N}` |
183
188
  | `--yaml` | YAML output |
184
189
 
@@ -193,6 +198,9 @@ zeyos count tickets
193
198
  zeyos count tickets --filter '{"status":1}'
194
199
  # → 12
195
200
 
201
+ # Filtered count using a JSON file
202
+ zeyos count tickets --filter-file ./filters/open-tickets.json
203
+
196
204
  # JSON output for scripting
197
205
  zeyos count accounts --json
198
206
  # → {"count": 156}
@@ -261,6 +269,7 @@ zeyos create <resource> [--data <json>] [--field value ...]
261
269
  | Option | Description |
262
270
  |--------|-------------|
263
271
  | `--data <json>` | Complete record as a JSON string |
272
+ | `--data-file <path>` | Read the complete record from a JSON file |
264
273
  | `--<field> <value>` | Set individual field (any unknown flag becomes a field) |
265
274
 
266
275
  **Examples:**
@@ -269,6 +278,9 @@ zeyos create <resource> [--data <json>] [--field value ...]
269
278
  # Using --data JSON
270
279
  zeyos create ticket --data '{"name":"Fix login bug","status":0,"priority":3}'
271
280
 
281
+ # Using a JSON file
282
+ zeyos create ticket --data-file ./ticket.json
283
+
272
284
  # Using individual field flags
273
285
  zeyos create ticket --name "Fix login bug" --status 0 --priority 3
274
286
 
@@ -301,6 +313,9 @@ zeyos update <resource> <id> [--data <json>] [--field value ...]
301
313
  # Using --data JSON
302
314
  zeyos update ticket 42 --data '{"status":4}'
303
315
 
316
+ # Using a JSON file
317
+ zeyos update ticket 42 --data-file ./ticket-update.json
318
+
304
319
  # Using field flags
305
320
  zeyos update ticket 42 --status 4 --priority 2
306
321
 
@@ -368,6 +383,19 @@ Foreign keys are shown as `→ <table>`, and enum fields list their valid values
368
383
 
369
384
  ---
370
385
 
386
+ ## doctor
387
+
388
+ Check local CLI readiness for coding agents. This runs offline and never prints tokens or client secrets.
389
+
390
+ ```bash
391
+ zeyos doctor agent
392
+ zeyos doctor agent --json
393
+ ```
394
+
395
+ The report includes the CLI version, configured base URL and instance, whether auth values are present through environment/local/global config, and whether the curated resource registry can be loaded.
396
+
397
+ ---
398
+
371
399
  ## skills
372
400
 
373
401
  Discover and install the bundled ZeyOS agent skill packs into any coding agent, so the agent (Claude Code, Codex, opencode, Factory Droid, pi, …) operates against ZeyOS with the right conventions out of the box.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/client",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Dependency-light JavaScript client for ZeyOS OpenAPI services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,12 @@
31
31
  "scripts/generate-client.mjs",
32
32
  "openapi",
33
33
  "docs",
34
- "samples",
34
+ "samples/crm",
35
+ "samples/dashboard",
36
+ "samples/kanban",
37
+ "samples/missioncontrol/README.md",
38
+ "samples/missioncontrol/fetch-data.mjs",
39
+ "samples/missioncontrol/index.html",
35
40
  "agents",
36
41
  "README.md",
37
42
  "CHANGELOG.md",
@@ -44,6 +49,7 @@
44
49
  "generate": "node scripts/generate-client.mjs",
45
50
  "test": "node scripts/test.mjs",
46
51
  "test:cli-integration": "node --test cli/test/integration.mjs",
47
- "test:agent-protocol": "node test/agent-protocol/harness/run.mjs"
52
+ "test:agent-protocol": "node test/agent-protocol/harness/run.mjs",
53
+ "test:agent-loop": "node test/agent-protocol/harness/loop.mjs"
48
54
  }
49
55
  }
@@ -0,0 +1,106 @@
1
+ # Mission Control
2
+
3
+ A team-performance dashboard for an IT consultancy running on ZeyOS — built on
4
+ the [`@zeyos/client`](../../README.md) library. It answers the managing
5
+ director's question: **who is actually working, and where is there untapped
6
+ capacity?**
7
+
8
+ A dark "mission control" view: a velocity KPI row, a 13-week throughput trend,
9
+ a team-capacity strip, and a searchable/sortable/filterable grid of per-employee
10
+ activity cards (click one for a per-type digest + contribution graph).
11
+
12
+ ## What it shows
13
+
14
+ 1. **Velocity** — tickets opened vs closed in the window, net flow, average /
15
+ median / p90 **cycle time** (open → close), open backlog (with overdue), and
16
+ total **hours booked**. A 13-week **throughput trend** of opened vs closed.
17
+ 2. **Activity card per employee** — open tickets, open tasks, tickets closed,
18
+ hours booked, a weekly-hours sparkline, team/location tags, a **capacity
19
+ badge** (Overloaded / Balanced / Available / Idle / Former), and **last
20
+ activity** (the most recent time entry) — flagged red when it's **older than
21
+ 7 days**. Hover "last activity" to see the engineer's **last 10 time entries**
22
+ (date · type · customer · ticket · hours).
23
+ 3. **Per-employee digest** (click a card) — **booked hours stacked by
24
+ `extdata.type`** (Weekly / Monthly), and a **GitHub-style contribution graph**
25
+ of their time-entry activity over the last 53 weeks.
26
+
27
+ Filters: search by name, **filter by team / department / location** (group
28
+ membership), capacity chips (Engaged / Spare capacity / Inactive >7d /
29
+ Overloaded / All), and sort by activity, hours, throughput, workload, cycle,
30
+ overdue, or least-recent.
31
+
32
+ The **Team capacity** strip and the *Spare capacity* filter surface the
33
+ "untapped resources": active engineers running light or with no load at all,
34
+ contrasted with the overloaded ones — the clearest place to rebalance work.
35
+
36
+ ## Run it
37
+
38
+ ```bash
39
+ # 1. Authenticate once (if you haven't already)
40
+ zeyos login --base-url https://zeyos.cms-it.de/dev
41
+
42
+ # 2. Pull live data into data.js (read-only; reuses your CLI credentials)
43
+ node samples/missioncontrol/fetch-data.mjs # 90-day window
44
+ node samples/missioncontrol/fetch-data.mjs --days 180 # custom window
45
+
46
+ # 3. Open the dashboard
47
+ # Either open index.html directly, or serve the folder:
48
+ python3 -m http.server 8765 --directory samples/missioncontrol
49
+ # → http://localhost:8765
50
+ ```
51
+
52
+ `fetch-data.mjs` writes `data.js` (a `window.MISSION_DATA = …` assignment) so
53
+ `index.html` works straight from disk — no server, no CORS, no token pasting.
54
+ Re-run the fetcher to refresh; the page is otherwise a static, dependency-free
55
+ single file (hand-rolled CSS + inline-SVG charts).
56
+
57
+ ## How it works
58
+
59
+ `fetch-data.mjs` reads the credentials `zeyos login` stored
60
+ (`.zeyos/auth.json` or `~/.config/zeyos/credentials.json`), builds an
61
+ auto-refreshing client with `createZeyosClient`, and issues a handful of
62
+ **read-only** `list` queries (tickets, `actionsteps`, tasks, groups), then
63
+ aggregates them client-side (ZeyOS has no server-side group-by). It never writes
64
+ to ZeyOS.
65
+
66
+ ### Metric definitions (so the numbers are reproducible)
67
+
68
+ | Metric | Definition |
69
+ |--------|------------|
70
+ | **Time entry** | an `actionsteps` row with `status IN [1, 3]` (COMPLETED + BOOKED), attributed to an engineer via **`assigneduser`** (`owneruser` is unused here). `effort` is in **minutes**. |
71
+ | **Last activity** | the engineer's most recent time-entry `date`; **stale** (red ⚠) when it's >7 days before the "as of" date. |
72
+ | **Type** | `extdata.type` of each time entry (Intern / Auftrag / Consulting / Wartung / …), selected on `list` via the `extdata.type` field. Top 6 are charted; the rest roll into *Other*. |
73
+ | **Closed / done** | tickets with `status IN [9, 11]` — COMPLETED + BOOKED (BOOKED, completed & billed, is the dominant terminal state). |
74
+ | **Open backlog** | `status IN [0, 1, 2, 4, 6, 7]` — started/accepted/active but not done. |
75
+ | **Opened in window** | tickets whose indexed `date` falls in the window. |
76
+ | **Cycle time** | `lastmodified − creationdate` for tickets closed in the window (a proxy for the close timestamp, which ZeyOS does not store separately). |
77
+ | **Capacity** | relative to the engaged-and-active cohort: *overloaded* (top-quintile workload or ≥3 overdue), *available* (low workload **and** below-median throughput), *idle* (active user, zero load), *balanced* (else), *former* (deactivated user with leftover assignments). |
78
+ | **Team / location** | the engineer's **group memberships** (see below). |
79
+
80
+ ### Notes & limitations
81
+
82
+ - **Department/location = groups.** ZeyOS *defines* custom fields
83
+ `users.department`/`users.location`/`users.team`, but the API returns
84
+ *"Extension data not available for users"* — they can't be read. The org
85
+ structure instead lives in **group membership** (e.g. `Developers`,
86
+ `Services`, `Technik`, `Berlin`, `Bayern`, `Nordrhein-Westfalen`), which is
87
+ what the team/location filter uses.
88
+ - **`extdata` only lists via dot-fields.** Custom fields aren't returned by a
89
+ plain `list` (or `extdata=1`); they must be selected explicitly, e.g.
90
+ `fields: ['…','extdata.type']` (returned as `extdata_type`). On single records
91
+ `getTicket(…, { query:{ extdata:1 } })` returns them under an `extdata` object.
92
+ - **Corrupt time-entry dates.** Many `actionsteps` have far-future `date` values
93
+ (year 2099+); the fetcher bounds queries to `date ≤ now` to exclude them.
94
+ - **"As of" anchoring.** Windows are measured back from the latest activity in
95
+ the data, not wall-clock today — so a frozen/dev snapshot still produces
96
+ meaningful windows (and the 7-day staleness check is relative to it). The
97
+ anchor date is shown in the header.
98
+ - **`date`, not `creationdate`, for windows.** `creationdate`/`lastmodified` are
99
+ unindexed on `tickets`; range-scanning them can time out (HTTP 503). The
100
+ indexed `date` column is used for opened-in-window queries.
101
+ - **Roles aren't distinguished.** Every active user is included; a salesperson
102
+ with no tickets shows as *Idle*. Use the team filter / search to focus on a
103
+ delivery team.
104
+
105
+ > `data.js` / `data.json` are generated and contain real names from your
106
+ > instance — they are git-ignored. Commit only the source.
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mission Control — data fetcher
4
+ * ──────────────────────────────
5
+ * Pulls team-performance data from a live ZeyOS instance using the
6
+ * `@zeyos/client` library and the credentials you already created with
7
+ * `zeyos login` (read from .zeyos/auth.json or ~/.config/zeyos/credentials.json).
8
+ *
9
+ * It aggregates tickets (velocity) and `actionsteps` (time entries) into the
10
+ * metrics the dashboard needs and writes `data.js` (a `window.MISSION_DATA = …`
11
+ * assignment) so `index.html` can be opened straight from disk — no server, no
12
+ * CORS, no token pasting.
13
+ *
14
+ * Usage:
15
+ * node samples/missioncontrol/fetch-data.mjs # 90-day window
16
+ * node samples/missioncontrol/fetch-data.mjs --days 180
17
+ *
18
+ * Read-only: this script only issues list/count queries. It never writes to ZeyOS.
19
+ */
20
+
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import { createZeyosClient, MemoryTokenStore, normalizeListResult } from '../../src/index.js';
27
+
28
+ const __dir = dirname(fileURLToPath(import.meta.url));
29
+ const DAY = 86400;
30
+
31
+ // ── Tunables ────────────────────────────────────────────────────────────────
32
+ const WINDOW_DAYS = parseInt(argValue('--days') ?? '90', 10); // primary window
33
+ const TREND_WEEKS = 13; // velocity / digest trend
34
+ const CONTRIB_WEEKS = 53; // contribution graph span
35
+ const STALE_DAYS = 7; // "last activity" warning threshold
36
+ const TOP_TYPES = 6; // distinct time-entry types to chart; rest → "Other"
37
+
38
+ // ── Status vocabulary ─────────────────────────────────────────────────────────
39
+ const CLOSED_STATUSES = [9, 11]; // tickets: COMPLETED + BOOKED
40
+ const OPEN_STATUSES = [0, 1, 2, 4, 6, 7]; // tickets/tasks: in-flight backlog
41
+ const TIME_STATUSES = [1, 3]; // actionsteps: COMPLETED + BOOKED (booked time)
42
+
43
+ // ── Credentials ───────────────────────────────────────────────────────────────
44
+ const LOCAL_FILE = '.zeyos/auth.json';
45
+ const GLOBAL_FILE = join(homedir(), '.config', 'zeyos', 'credentials.json');
46
+
47
+ function findCredentials() {
48
+ let dir = process.cwd();
49
+ for (;;) {
50
+ const candidate = join(dir, LOCAL_FILE);
51
+ if (existsSync(candidate)) return candidate;
52
+ const parent = dirname(dir);
53
+ if (parent === dir) break;
54
+ dir = parent;
55
+ }
56
+ return existsSync(GLOBAL_FILE) ? GLOBAL_FILE : null;
57
+ }
58
+
59
+ const credPath = findCredentials();
60
+ if (!credPath) { console.error('No ZeyOS credentials found. Run `zeyos login` first.'); process.exit(1); }
61
+ const cred = JSON.parse(readFileSync(credPath, 'utf8'));
62
+ if (!cred.baseUrl || !cred.accessToken) {
63
+ console.error(`Credentials at ${credPath} look incomplete. Run \`zeyos login\` again.`); process.exit(1);
64
+ }
65
+
66
+ const tokenStore = new MemoryTokenStore({
67
+ accessToken: cred.accessToken, refreshToken: cred.refreshToken,
68
+ expiresAt: cred.expiresAt, refreshTokenExpiresAt: cred.refreshTokenExpiresAt,
69
+ });
70
+ const client = createZeyosClient({
71
+ platform: cred.baseUrl,
72
+ auth: { mode: 'oauth', oauth: { clientId: cred.clientId, clientSecret: cred.clientSecret, tokenStore, autoRefresh: true } },
73
+ });
74
+
75
+ async function persistTokens() {
76
+ try {
77
+ const ts = await tokenStore.get();
78
+ if (ts?.accessToken && ts.accessToken !== cred.accessToken) {
79
+ writeFileSync(credPath, JSON.stringify({ ...cred, ...ts }, null, 2) + '\n', { mode: 0o600 });
80
+ }
81
+ } catch { /* non-critical */ }
82
+ }
83
+
84
+ // ── Query helpers ───────────────────────────────────────────────────────────
85
+ async function listAll(op, body) {
86
+ return normalizeListResult(await client.api[op]({ limit: 10000, ...body })).data;
87
+ }
88
+ /** Page through a large collection (ZeyOS caps a single page at 10000). */
89
+ async function listPaged(op, body, cap = 250000) {
90
+ const out = []; const limit = 10000;
91
+ for (let offset = 0; ; offset += limit) {
92
+ const page = normalizeListResult(await client.api[op]({ ...body, limit, offset })).data;
93
+ out.push(...page);
94
+ if (page.length < limit || out.length >= cap) break;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // ── Aggregation utilities ─────────────────────────────────────────────────────
100
+ const sum = (xs) => xs.reduce((a, b) => a + b, 0);
101
+ const mean = (xs) => (xs.length ? sum(xs) / xs.length : 0);
102
+ const median = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
103
+ const percentile = (xs, p) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; };
104
+ const round1 = (n) => Math.round(n * 10) / 10;
105
+ const weekLabel = (ts) => { const d = new Date(ts * 1000); return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; };
106
+
107
+ // ── Main ──────────────────────────────────────────────────────────────────────
108
+ async function main() {
109
+ const REAL_NOW = Math.floor(Date.now() / 1000);
110
+ console.error(`⚡️ Mission Control — fetching from ${cred.baseUrl}`);
111
+ console.error(` window: last ${WINDOW_DAYS} days · contribution: ${CONTRIB_WEEKS} weeks\n`);
112
+
113
+ // 1) Active roster
114
+ const users = await listAll('listUsers', { fields: ['ID', 'name', 'email'], filters: { activity: 0 } });
115
+ const userById = new Map(users.map((u) => [u.ID, u]));
116
+ console.error(`· users (active): ${users.length}`);
117
+
118
+ // 2) Group membership → department/location facet (user extdata isn't API-readable).
119
+ const groups = await listAll('listGroups', { fields: ['ID', 'name'] });
120
+ const groupName = new Map(groups.map((g) => [g.ID, g.name]));
121
+ const g2u = await listPaged('listGroupsToUsers', { fields: ['group', 'user'] });
122
+ const userGroups = new Map();
123
+ for (const row of g2u) {
124
+ if (!userById.has(row.user)) continue;
125
+ if (!userGroups.has(row.user)) userGroups.set(row.user, []);
126
+ const name = groupName.get(row.group);
127
+ if (name) userGroups.get(row.user).push(name);
128
+ }
129
+ console.error(`· groups: ${groups.length}`);
130
+
131
+ // 3) Anchor "now" to the latest real activity (data may be a frozen snapshot).
132
+ const latestTicket = await listAll('listTickets', { fields: ['ID', 'date'], filters: { visibility: 0 }, sort: ['-date'], limit: 1 });
133
+ let asOf = latestTicket[0]?.date || REAL_NOW;
134
+
135
+ // 4) Tickets — velocity (indexed `date` for opened; lastmodified for closed).
136
+ const trendStart = asOf - TREND_WEEKS * 7 * DAY;
137
+ const windowStart = asOf - WINDOW_DAYS * DAY;
138
+ const recentTickets = await listAll('listTickets', {
139
+ fields: ['ID', 'assigneduser', 'status', 'date', 'creationdate', 'lastmodified'],
140
+ filters: { visibility: 0, date: { '>=': trendStart, '<=': asOf } }, sort: ['-date'],
141
+ });
142
+ const closedTickets = await listAll('listTickets', {
143
+ fields: ['ID', 'assigneduser', 'status', 'date', 'creationdate', 'lastmodified'],
144
+ filters: { visibility: 0, status: { IN: CLOSED_STATUSES }, lastmodified: { '>=': trendStart } }, sort: ['-lastmodified'],
145
+ });
146
+ const openTickets = await listAll('listTickets', {
147
+ fields: ['ID', 'assigneduser', 'status', 'duedate'],
148
+ filters: { visibility: 0, status: { IN: OPEN_STATUSES } },
149
+ });
150
+ const openTasks = await listAll('listTasks', {
151
+ fields: ['ID', 'assigneduser', 'status'],
152
+ filters: { visibility: 0, status: { IN: OPEN_STATUSES } },
153
+ });
154
+ console.error(`· tickets: ${recentTickets.length} opened / ${closedTickets.length} closed (trend) · ${openTickets.length} open · ${openTasks.length} tasks`);
155
+
156
+ // 5) Time entries — actionsteps (booked time). Bound to sane dates (≤ now) to
157
+ // skip corrupt far-future rows. Paged; extdata.type pulled via dot-field.
158
+ const contribStartRaw = asOf - (CONTRIB_WEEKS * 7 - 1) * DAY;
159
+ // snap contribution window start back to a Monday for clean week columns
160
+ const csWeekday = (new Date(contribStartRaw * 1000).getDay() + 6) % 7;
161
+ const contribStart = contribStartRaw - csWeekday * DAY;
162
+ const entries = await listPaged('listActionSteps', {
163
+ fields: ['ID', 'assigneduser', 'date', 'effort', 'ticket', 'account', 'extdata.type'],
164
+ filters: { status: { IN: TIME_STATUSES }, date: { '>=': contribStart, '<=': REAL_NOW } }, sort: ['-date'],
165
+ });
166
+ // entries arrive with `extdata_type`; normalise to `type`
167
+ for (const e of entries) { e.type = e.extdata_type || 'Untyped'; delete e.extdata_type; if (e.date > asOf) asOf = e.date; }
168
+ console.error(`· time entries: ${entries.length} (${CONTRIB_WEEKS}w)`);
169
+
170
+ await persistTokens();
171
+
172
+ // recompute windows against possibly-updated asOf
173
+ const winStart = asOf - WINDOW_DAYS * DAY;
174
+ const trStart = asOf - TREND_WEEKS * 7 * DAY;
175
+
176
+ // ── Distinct time-entry types → top N + Other (stable chart segmentation) ────
177
+ const typeTotals = new Map();
178
+ for (const e of entries) if (e.date >= winStart) typeTotals.set(e.type, (typeTotals.get(e.type) || 0) + e.effort);
179
+ const topTypes = [...typeTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_TYPES).map(([t]) => t);
180
+ const typeBucket = (t) => (topTypes.includes(t) ? t : 'Other');
181
+ const TYPE_ORDER = [...topTypes, ...(typeTotals.size > TOP_TYPES ? ['Other'] : [])];
182
+
183
+ // ── Velocity (tickets) ──────────────────────────────────────────────────────
184
+ const weeks = [];
185
+ for (let w = TREND_WEEKS - 1; w >= 0; w--) {
186
+ const s = asOf - (w + 1) * 7 * DAY, e = asOf - w * 7 * DAY;
187
+ weeks.push({ start: s, end: e, label: weekLabel(e),
188
+ opened: recentTickets.filter((t) => t.date >= s && t.date < e).length,
189
+ closed: closedTickets.filter((t) => t.lastmodified >= s && t.lastmodified < e).length });
190
+ }
191
+ const cycleDaysAll = closedTickets.filter((t) => t.lastmodified >= winStart)
192
+ .map((t) => (t.lastmodified - (t.creationdate || t.date)) / DAY).filter((d) => d >= 0 && d < 3650);
193
+ const cycle = { avgDays: round1(mean(cycleDaysAll)), medianDays: round1(median(cycleDaysAll)), p90Days: round1(percentile(cycleDaysAll, 90)), sampleSize: cycleDaysAll.length };
194
+ const openedInWindow = recentTickets.filter((t) => t.date >= winStart).length;
195
+ const closedInWindow = closedTickets.filter((t) => t.lastmodified >= winStart).length;
196
+ const overdueOpen = openTickets.filter((t) => t.duedate > 0 && t.duedate < asOf).length;
197
+
198
+ // ── Per-employee aggregation ─────────────────────────────────────────────────
199
+ const contribCols = Math.ceil((asOf - contribStart) / DAY / 7) + 1;
200
+ const dayIndex = (ts) => Math.floor((ts - contribStart) / DAY);
201
+
202
+ const byUser = new Map();
203
+ const ensure = (uid) => {
204
+ if (uid == null) return null;
205
+ if (!byUser.has(uid)) {
206
+ const u = userById.get(uid);
207
+ byUser.set(uid, {
208
+ id: uid, name: u?.name || `user#${uid}`, email: u?.email || '', active: !!u,
209
+ groups: userGroups.get(uid) || [],
210
+ openTickets: 0, overdueTickets: 0, openTasks: 0, openedInWindow: 0, closedInWindow: 0,
211
+ cycleDays: [], _entries: [], lastActivity: 0,
212
+ });
213
+ }
214
+ return byUser.get(uid);
215
+ };
216
+ for (const u of users) ensure(u.ID);
217
+
218
+ for (const t of openTickets) { const a = ensure(t.assigneduser); if (!a) continue; a.openTickets++; if (t.duedate > 0 && t.duedate < asOf) a.overdueTickets++; }
219
+ for (const t of openTasks) { const a = ensure(t.assigneduser); if (a) a.openTasks++; }
220
+ for (const t of recentTickets) { const a = ensure(t.assigneduser); if (a && t.date >= winStart) a.openedInWindow++; }
221
+ for (const t of closedTickets) {
222
+ const a = ensure(t.assigneduser); if (!a) continue;
223
+ if (t.lastmodified >= winStart) { a.closedInWindow++; const d = (t.lastmodified - (t.creationdate || t.date)) / DAY; if (d >= 0 && d < 3650) a.cycleDays.push(d); }
224
+ }
225
+ for (const e of entries) {
226
+ const a = ensure(e.assigneduser); if (!a) continue;
227
+ a._entries.push(e);
228
+ if (e.date > a.lastActivity) a.lastActivity = e.date;
229
+ }
230
+
231
+ // Resolve customer (account) + ticket numbers for the recent-entries hover.
232
+ // Many entries carry only a ticket, so backfill the customer via the ticket's account.
233
+ const accIds = new Set(), tkIds = new Set();
234
+ for (const a of byUser.values()) for (const e of a._entries.slice(0, 10)) { if (e.account) accIds.add(e.account); if (e.ticket) tkIds.add(e.ticket); }
235
+ const accName = new Map(), tkNum = new Map(), tkAccount = new Map();
236
+ if (tkIds.size) for (const t of await listAll('listTickets', { fields: ['ID', 'ticketnum', 'account'], filters: { ID: { IN: [...tkIds] } } })) {
237
+ tkNum.set(t.ID, t.ticketnum || `#${t.ID}`);
238
+ if (t.account) { tkAccount.set(t.ID, t.account); accIds.add(t.account); }
239
+ }
240
+ if (accIds.size) for (const acc of await listAll('listAccounts', { fields: ['ID', 'lastname', 'firstname'], filters: { ID: { IN: [...accIds] } } }))
241
+ accName.set(acc.ID, acc.lastname || acc.firstname || `#${acc.ID}`);
242
+ const customerOf = (e) => { const id = e.account || tkAccount.get(e.ticket); return id ? (accName.get(id) || `#${id}`) : ''; };
243
+
244
+ let employees = [...byUser.values()].map((e) => {
245
+ const entriesWin = e._entries.filter((x) => x.date >= winStart);
246
+ // weekly stacked-by-type (last TREND_WEEKS)
247
+ const weeklyByType = weeks.map((w) => {
248
+ const seg = {};
249
+ for (const x of e._entries) if (x.date >= w.start && x.date < w.end) seg[typeBucket(x.type)] = (seg[typeBucket(x.type)] || 0) + x.effort;
250
+ return { label: w.label, seg };
251
+ });
252
+ // contribution: sparse [dayIndex, count]
253
+ const contribMap = new Map();
254
+ for (const x of e._entries) { const d = dayIndex(x.date); if (d >= 0) contribMap.set(d, (contribMap.get(d) || 0) + 1); }
255
+ const contrib = [...contribMap.entries()].sort((a, b) => a[0] - b[0]);
256
+ // recent 10 entries for the hover
257
+ const recentEntries = e._entries.slice(0, 10).map((x) => ({
258
+ date: x.date, mins: x.effort, type: x.type,
259
+ customer: customerOf(x),
260
+ ticket: x.ticket ? (tkNum.get(x.ticket) || `#${x.ticket}`) : '',
261
+ }));
262
+ const throughput = e.closedInWindow, workload = e.openTickets + e.openTasks;
263
+ return {
264
+ id: e.id, name: e.name, email: e.email, active: e.active, groups: e.groups,
265
+ openTickets: e.openTickets, overdueTickets: e.overdueTickets, openTasks: e.openTasks,
266
+ openedInWindow: e.openedInWindow, closedInWindow: e.closedInWindow,
267
+ avgCycleDays: round1(mean(e.cycleDays)), throughput, workload,
268
+ lastActivity: e.lastActivity,
269
+ stale: e.lastActivity > 0 && (asOf - e.lastActivity) > STALE_DAYS * DAY,
270
+ bookedHours: round1(sum(entriesWin.map((x) => x.effort)) / 60),
271
+ entriesInWindow: entriesWin.length,
272
+ recentEntries, weeklyByType, contrib,
273
+ activityScore: workload + throughput + e.openedInWindow + entriesWin.length,
274
+ };
275
+ });
276
+
277
+ // ── Capacity classification (engaged-and-active cohort defines thresholds) ───
278
+ const engaged = employees.filter((e) => e.active && e.activityScore > 0);
279
+ const wLow = percentile(engaged.map((e) => e.workload), 33);
280
+ const wHigh = percentile(engaged.map((e) => e.workload), 80);
281
+ const tMed = median(engaged.map((e) => e.throughput));
282
+ for (const e of employees) {
283
+ if (!e.active) e.capacity = 'former';
284
+ else if (e.activityScore === 0) e.capacity = 'idle';
285
+ else if (e.workload >= wHigh || e.overdueTickets >= 3) e.capacity = 'overloaded';
286
+ else if (e.workload <= wLow && e.throughput <= tMed) e.capacity = 'available';
287
+ else e.capacity = 'balanced';
288
+ }
289
+ employees.sort((a, b) => b.activityScore - a.activityScore || b.throughput - a.throughput);
290
+
291
+ // ── Group facet for the filter (only groups engaged employees belong to) ─────
292
+ const groupCount = new Map();
293
+ for (const e of employees) if (e.capacity !== 'former') for (const g of e.groups) groupCount.set(g, (groupCount.get(g) || 0) + 1);
294
+ const groupFacet = [...groupCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, count]) => ({ name, count }));
295
+
296
+ // ── Team-level time summary ──────────────────────────────────────────────────
297
+ const winEntries = entries.filter((e) => e.date >= winStart);
298
+ const byTypeHours = TYPE_ORDER.map((t) => ({ type: t, hours: round1(sum(winEntries.filter((e) => typeBucket(e.type) === t).map((e) => e.effort)) / 60) }));
299
+
300
+ const data = {
301
+ meta: {
302
+ instance: cred.baseUrl, generatedAt: REAL_NOW, asOf,
303
+ windowDays: WINDOW_DAYS, trendWeeks: TREND_WEEKS,
304
+ contribWeeks: CONTRIB_WEEKS, contribStart, contribCols, staleDays: STALE_DAYS,
305
+ typeOrder: TYPE_ORDER, closedStatuses: CLOSED_STATUSES, openStatuses: OPEN_STATUSES, timeStatuses: TIME_STATUSES,
306
+ },
307
+ velocity: {
308
+ openedInWindow, closedInWindow, net: openedInWindow - closedInWindow,
309
+ backlogOpen: openTickets.length, backlogOverdue: overdueOpen, openTasks: openTasks.length,
310
+ weeklyOpenedAvg: round1(mean(weeks.map((w) => w.opened))), weeklyClosedAvg: round1(mean(weeks.map((w) => w.closed))),
311
+ cycle, trend: weeks.map(({ label, opened, closed }) => ({ label, opened, closed })),
312
+ },
313
+ time: {
314
+ entries: winEntries.length, bookedHours: round1(sum(winEntries.map((e) => e.effort)) / 60),
315
+ byType: byTypeHours, staleEngineers: employees.filter((e) => e.active && e.stale).length,
316
+ },
317
+ team: {
318
+ activeUsers: users.length, engaged: engaged.length,
319
+ available: employees.filter((e) => e.capacity === 'available').length,
320
+ overloaded: employees.filter((e) => e.capacity === 'overloaded').length,
321
+ balanced: employees.filter((e) => e.capacity === 'balanced').length,
322
+ idle: employees.filter((e) => e.capacity === 'idle').length,
323
+ former: employees.filter((e) => e.capacity === 'former').length,
324
+ },
325
+ groups: groupFacet,
326
+ employees: employees.map(({ activityScore, ...rest }) => rest),
327
+ };
328
+
329
+ const json = JSON.stringify(data, null, 1);
330
+ writeFileSync(join(__dir, 'data.js'), `window.MISSION_DATA = ${json};\n`);
331
+ writeFileSync(join(__dir, 'data.json'), json + '\n');
332
+
333
+ console.error(`\n✓ Wrote data.js + data.json`);
334
+ console.error(` as of ${new Date(asOf * 1000).toISOString().slice(0, 10)} · ${openedInWindow} opened / ${closedInWindow} closed · ` +
335
+ `${data.time.bookedHours}h booked · ${data.time.staleEngineers} engineers stale (>${STALE_DAYS}d) · ` +
336
+ `${employees.length} employees · ${data.team.available} available, ${data.team.overloaded} overloaded`);
337
+ }
338
+
339
+ function argValue(flag) { const i = process.argv.indexOf(flag); return i >= 0 ? process.argv[i + 1] : undefined; }
340
+
341
+ main().catch((err) => { console.error(`\n✗ ${err.message}`); if (err.status) console.error(` HTTP ${err.status}`); process.exit(1); });