@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.
- package/CHANGELOG.md +22 -0
- package/agents/README.md +16 -6
- package/agents/shared/zeyos-agent-operating-guide.md +112 -0
- package/agents/shared/zeyos-query-patterns.md +12 -1
- package/agents/zeyos/SKILL.md +95 -0
- package/agents/zeyos-account-intelligence/SKILL.md +1 -1
- package/agents/zeyos-account-intelligence/references/workflows.md +7 -0
- package/agents/zeyos-billing-insights/SKILL.md +6 -1
- package/agents/zeyos-billing-insights/references/workflows.md +32 -3
- package/agents/zeyos-campaign-and-outreach/SKILL.md +1 -1
- package/agents/zeyos-campaign-and-outreach/references/workflows.md +8 -0
- package/agents/zeyos-collaboration-and-activity/SKILL.md +1 -1
- package/agents/zeyos-collaboration-and-activity/references/workflows.md +9 -0
- package/agents/zeyos-collections-and-dunning/SKILL.md +1 -1
- package/agents/zeyos-commerce-and-inventory/SKILL.md +1 -1
- package/agents/zeyos-commerce-and-inventory/references/workflows.md +7 -0
- package/agents/zeyos-mail-operations/SKILL.md +1 -1
- package/agents/zeyos-notes-and-sops/SKILL.md +1 -1
- package/agents/zeyos-platform-and-schema/SKILL.md +1 -1
- package/agents/zeyos-platform-and-schema/references/workflows.md +8 -0
- package/agents/zeyos-work-management/SKILL.md +1 -1
- package/docs/03-cli/01-getting-started.md +7 -0
- package/docs/03-cli/02-commands.md +28 -0
- package/package.json +9 -3
- package/samples/missioncontrol/README.md +106 -0
- package/samples/missioncontrol/fetch-data.mjs +341 -0
- package/samples/missioncontrol/index.html +419 -0
- 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.
|
|
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); });
|