syndes 0.1.0 → 0.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.
- package/README.md +53 -0
- package/analytics/metrics/rework.mjs +8 -2
- package/analytics/metrics/volume.mjs +19 -6
- package/analytics/team.mjs +245 -0
- package/bin/cli.mjs +413 -1
- package/collect/projects.mjs +18 -2
- package/dashboard/api/team.mjs +248 -0
- package/dashboard/router.mjs +7 -0
- package/dashboard/server.mjs +11 -2
- package/dashboard/web/api.js +8 -0
- package/dashboard/web/app.css +45 -1
- package/dashboard/web/app.js +1 -0
- package/dashboard/web/charts.js +191 -0
- package/dashboard/web/ui.js +3 -1
- package/dashboard/web/views/team.js +496 -0
- package/package.json +8 -4
- package/runtime/config.mjs +17 -0
- package/runtime/identity.mjs +96 -0
- package/runtime/paths.mjs +12 -0
- package/runtime/worker.mjs +35 -1
- package/src/briefing.mjs +14 -1
- package/src/doctor.mjs +30 -0
- package/src/preflight.mjs +174 -0
- package/src/term.mjs +42 -3
- package/sync/index.mjs +336 -0
- package/sync/invite.mjs +70 -0
- package/sync/run-sync.mjs +21 -0
- package/sync/share.mjs +143 -0
- package/sync/transport.mjs +203 -0
package/README.md
CHANGED
|
@@ -29,6 +29,13 @@ surface at most one nudge, at a moment that does not interrupt you, always with
|
|
|
29
29
|
the evidence attached: *"you edited `src/app.ts` six times in twelve minutes
|
|
30
30
|
without running tests."*
|
|
31
31
|
|
|
32
|
+
**Splits one shared account between the people on it.** Two people on one Claude
|
|
33
|
+
plan look like one user to Anthropic — there is no per-person breakdown to ask
|
|
34
|
+
for. SynDes measures on each machine and pools the results through a private git
|
|
35
|
+
repo or a synced folder, so you get the actual split: who used what, when, and
|
|
36
|
+
which hours you spent competing for the same rate-limit window. Counts and
|
|
37
|
+
tokens are shared; prompts, paths and commands never leave the machine.
|
|
38
|
+
|
|
32
39
|
**Shows you the whole picture.** `syndes dashboard` opens a local
|
|
33
40
|
dashboard: your efficiency score and its five pillars, token and cost
|
|
34
41
|
breakdowns, cache hit rate, active time versus wall-clock, permission friction,
|
|
@@ -50,6 +57,8 @@ machine already has — `syndes lock system` asks Touch ID — or set a PIN.
|
|
|
50
57
|
| `syndes sessions` | list and drill down |
|
|
51
58
|
| `syndes export --format=csv` | all of it, back out |
|
|
52
59
|
| `syndes lock system` | require Touch ID to open the dashboard |
|
|
60
|
+
| `syndes team join <repo>` | share one account, see the split |
|
|
61
|
+
| `syndes team status` | who used how much |
|
|
53
62
|
| `syndes sources` | which coding agents SynDes can see |
|
|
54
63
|
| `syndes watch` | follow agents that have no hook API |
|
|
55
64
|
| `syndes doctor` | what is wired and what actually works |
|
|
@@ -66,6 +75,50 @@ request whose `Host` is not a literal loopback address — without that, any web
|
|
|
66
75
|
page you have open could read it. The lock is the second layer, for machines
|
|
67
76
|
other people also use.
|
|
68
77
|
|
|
78
|
+
## Sharing an account
|
|
79
|
+
|
|
80
|
+
`syndes install` asks. Or set it up later:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
syndes team join git@github.com:you/usage.git # a private repo you both push to
|
|
84
|
+
syndes team join ~/Dropbox/usage --folder # or no server at all
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Adding someone is one line.** `syndes team invite` — or the Invite button on
|
|
88
|
+
the Team page — prints a command you send them:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
npm install -g syndes && syndes join syndes1_eyJ2Ijox…
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
That installs SynDes, works out why git cannot reach the repo, offers to sign
|
|
95
|
+
them in, joins them and starts publishing. The invite carries an address, never
|
|
96
|
+
a credential: who may join is the repository's collaborator list, and only that.
|
|
97
|
+
|
|
98
|
+
| | |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `syndes team invite` | a line to send someone |
|
|
101
|
+
| `syndes team status` | who used how much |
|
|
102
|
+
| `syndes team remove <name>` | take a machine out of the pool |
|
|
103
|
+
| `syndes team doctor` | what is stopping git reaching the repo |
|
|
104
|
+
|
|
105
|
+
Each machine writes only its own folder and reads everyone's, so there is nothing
|
|
106
|
+
to merge and nothing to run. Open the dashboard and the **Team** page is live
|
|
107
|
+
while you watch it.
|
|
108
|
+
|
|
109
|
+
What leaves your machine: daily counts, tokens, and the hours you worked. What
|
|
110
|
+
never does: your prompts, your file paths, your commands — at any setting.
|
|
111
|
+
|
|
112
|
+
**A pool is one room.** Everyone in it sees everyone else — names, hours and
|
|
113
|
+
volumes — and can read the raw files on GitHub without SynDes. Add only people
|
|
114
|
+
who should all see each other. Removing someone takes them out of the view;
|
|
115
|
+
git keeps the old commits, so it is not an erasure.
|
|
116
|
+
|
|
117
|
+
This is cooperative measurement, not enforcement. It sees Claude Code on machines
|
|
118
|
+
where SynDes is installed, and anyone can stop publishing. Note that consumer
|
|
119
|
+
plans are licensed to one person; per-person logins are both the supported path
|
|
120
|
+
and the only way a split can be enforced rather than agreed.
|
|
121
|
+
|
|
69
122
|
## Honest limits
|
|
70
123
|
|
|
71
124
|
The ledger is **tamper-evident, not tamper-proof**. Anything running as your
|
|
@@ -85,11 +85,17 @@ export function merge(parts) {
|
|
|
85
85
|
let verifiedRuns = 0;
|
|
86
86
|
let unverifiedRuns = 0;
|
|
87
87
|
|
|
88
|
+
// A storm's key names the command that failed, so it is never shared — but
|
|
89
|
+
// the COUNT is what the score reads, and without it a shared rollup scored a
|
|
90
|
+
// free hundred on the one part meant to catch a loop that is not converging.
|
|
91
|
+
let stormsCounted = 0;
|
|
92
|
+
|
|
88
93
|
for (const part of parts) {
|
|
89
94
|
for (const [path, count] of Object.entries(part.churn ?? {})) {
|
|
90
95
|
churn[path] = Math.max(churn[path] ?? 0, count);
|
|
91
96
|
}
|
|
92
|
-
storms.push(...
|
|
97
|
+
if (Array.isArray(part.storms)) storms.push(...part.storms);
|
|
98
|
+
else stormsCounted += part.stormCount ?? 0;
|
|
93
99
|
errors += part.errors ?? 0;
|
|
94
100
|
calls += part.calls ?? 0;
|
|
95
101
|
verifiedRuns += part.verifiedRuns ?? 0;
|
|
@@ -102,7 +108,7 @@ export function merge(parts) {
|
|
|
102
108
|
churn: Object.fromEntries(ranked.slice(0, 25)),
|
|
103
109
|
worstChurn: ranked[0] ? { path: ranked[0][0], edits: ranked[0][1] } : null,
|
|
104
110
|
storms: storms.slice(0, 25),
|
|
105
|
-
stormCount: storms.length,
|
|
111
|
+
stormCount: storms.length + stormsCounted,
|
|
106
112
|
errors,
|
|
107
113
|
calls,
|
|
108
114
|
errorRate: calls ? errors / calls : 0,
|
|
@@ -68,11 +68,24 @@ export function merge(parts) {
|
|
|
68
68
|
const files = new Set();
|
|
69
69
|
const totals = { prompts: 0, toolCalls: 0, writes: 0, commits: 0, insertions: 0, deletions: 0, filesChanged: 0 };
|
|
70
70
|
|
|
71
|
+
// A shared rollup has had its ids removed and carries only a count (see
|
|
72
|
+
// sync/share.mjs). Deduplication is impossible without the ids, so those
|
|
73
|
+
// counts are summed instead — which over-counts a session spanning midnight
|
|
74
|
+
// and is far better than the alternative, which was reporting zero sessions
|
|
75
|
+
// and silently dropping the largest part of the context score.
|
|
76
|
+
const counted = { sessions: 0, projects: 0, files: 0 };
|
|
77
|
+
|
|
71
78
|
const bySource = {};
|
|
72
79
|
for (const part of parts) {
|
|
73
|
-
for (const id of part.sessions
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
if (Array.isArray(part.sessions)) for (const id of part.sessions) sessions.add(id);
|
|
81
|
+
else counted.sessions += part.sessionCount ?? 0;
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(part.projects)) for (const id of part.projects) projects.add(id);
|
|
84
|
+
else counted.projects += part.projectCount ?? 0;
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(part.files)) for (const file of part.files) files.add(file);
|
|
87
|
+
else counted.files += part.fileCount ?? 0;
|
|
88
|
+
|
|
76
89
|
for (const key of Object.keys(totals)) totals[key] += part[key] ?? 0;
|
|
77
90
|
|
|
78
91
|
for (const [id, bucket] of Object.entries(part.bySource ?? {})) {
|
|
@@ -86,9 +99,9 @@ export function merge(parts) {
|
|
|
86
99
|
|
|
87
100
|
return {
|
|
88
101
|
...totals,
|
|
89
|
-
sessionCount: sessions.size,
|
|
90
|
-
projectCount: projects.size,
|
|
91
|
-
fileCount: files.size,
|
|
102
|
+
sessionCount: sessions.size + counted.sessions,
|
|
103
|
+
projectCount: projects.size + counted.projects,
|
|
104
|
+
fileCount: files.size + counted.files,
|
|
92
105
|
sessions: [...sessions],
|
|
93
106
|
projects: [...projects],
|
|
94
107
|
bySource: Object.fromEntries(Object.entries(bySource).map(([id, b]) => [id, {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared-account view: who used how much, and when you were in each
|
|
3
|
+
* other's way.
|
|
4
|
+
*
|
|
5
|
+
* The headline number here is deliberately NOT dollars. On a shared plan there
|
|
6
|
+
* is no per-token bill to split — the scarce thing is the usage window, and two
|
|
7
|
+
* people working the same hour throttle each other in a way neither of them can
|
|
8
|
+
* see from their own machine. So this module computes share, and it computes
|
|
9
|
+
* contention, and it treats cost as the secondary figure it actually is.
|
|
10
|
+
*
|
|
11
|
+
* Everything is derived from sanitised peer rollups (sync/share.mjs), which
|
|
12
|
+
* carry counts and no content. Nothing here reads a peer's records, because
|
|
13
|
+
* nothing ever sends them.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { rangeFor, previousRange } from './ranges.mjs';
|
|
17
|
+
import { mergeRollups, METRICS } from './rollup.mjs';
|
|
18
|
+
import { costOf } from './metrics/cost.mjs';
|
|
19
|
+
import { score } from './score.mjs';
|
|
20
|
+
import { readPool, loadState, teamConfig, isEnabled, poolRoot } from '../sync/index.mjs';
|
|
21
|
+
import { identity, initialsOf } from '../runtime/identity.mjs';
|
|
22
|
+
import { loadConfig } from '../runtime/config.mjs';
|
|
23
|
+
|
|
24
|
+
/** Three-hour bands: eight rows is a grid you can read, twenty-four is a wall. */
|
|
25
|
+
const BAND_HOURS = 3;
|
|
26
|
+
const BANDS = 24 / BAND_HOURS;
|
|
27
|
+
/** Beyond this the grid stops being a picture and starts being a spreadsheet. */
|
|
28
|
+
const MAX_GRID_DAYS = 14;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} spec a range spec, as analytics/ranges.mjs understands it
|
|
32
|
+
* @returns {Promise<object>} everything the Team page renders
|
|
33
|
+
*/
|
|
34
|
+
export async function team(spec = '7d') {
|
|
35
|
+
const range = rangeFor(spec);
|
|
36
|
+
const before = previousRange(range);
|
|
37
|
+
const prices = loadConfig().prices ?? {};
|
|
38
|
+
const me = identity();
|
|
39
|
+
|
|
40
|
+
const enabled = isEnabled();
|
|
41
|
+
const pool = enabled ? readPool() : [];
|
|
42
|
+
|
|
43
|
+
const peers = pool.map((peer) => {
|
|
44
|
+
const current = rollupsIn(peer, range.days);
|
|
45
|
+
const previous = rollupsIn(peer, before.days);
|
|
46
|
+
|
|
47
|
+
const merged = mergeRollups(current);
|
|
48
|
+
const mergedBefore = mergeRollups(previous);
|
|
49
|
+
const tokens = totalTokens(merged.tokens);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
deviceId: peer.deviceId,
|
|
53
|
+
name: peer.name,
|
|
54
|
+
initials: initialsOf(peer.name),
|
|
55
|
+
host: peer.host,
|
|
56
|
+
os: peer.os,
|
|
57
|
+
scope: peer.scope,
|
|
58
|
+
isMe: peer.isMe,
|
|
59
|
+
lastSeen: peer.lastSeen,
|
|
60
|
+
// A shelf nobody has updated in a day is stale, not quiet. The UI must be
|
|
61
|
+
// able to tell those apart or it will report someone as idle who has
|
|
62
|
+
// simply stopped syncing.
|
|
63
|
+
stale: peer.lastSeen ? Date.now() - peer.lastSeen > 6 * 3_600_000 : true,
|
|
64
|
+
days: current.length,
|
|
65
|
+
totals: {
|
|
66
|
+
tokens,
|
|
67
|
+
input: merged.tokens.input ?? 0,
|
|
68
|
+
output: merged.tokens.output ?? 0,
|
|
69
|
+
cacheRead: merged.tokens.cacheRead ?? 0,
|
|
70
|
+
cacheWrite: merged.tokens.cacheWrite ?? 0,
|
|
71
|
+
cacheHitRate: merged.tokens.cacheHitRate ?? 0,
|
|
72
|
+
prompts: merged.prompts.count ?? 0,
|
|
73
|
+
toolCalls: merged.tools.calls ?? 0,
|
|
74
|
+
sessions: sessionCountOf(merged, current),
|
|
75
|
+
activeMs: merged.time.activeMs ?? 0,
|
|
76
|
+
wallMs: merged.time.wallMs ?? 0,
|
|
77
|
+
compacts: merged.context.compacts ?? 0,
|
|
78
|
+
blocks: merged.friction.blocks ?? 0,
|
|
79
|
+
usd: costOf(merged.tokens, prices).usd,
|
|
80
|
+
},
|
|
81
|
+
previousTokens: totalTokens(mergedBefore.tokens),
|
|
82
|
+
// A peer sharing at `summary` still has every pillar the score reads,
|
|
83
|
+
// because the score never needed a path or a session id.
|
|
84
|
+
score: score(merged).total,
|
|
85
|
+
hours: merged.time.hours ?? new Array(24).fill(0),
|
|
86
|
+
series: range.days.map((day) => {
|
|
87
|
+
const rollup = peer.rollups.get(day);
|
|
88
|
+
return {
|
|
89
|
+
day,
|
|
90
|
+
tokens: rollup ? totalTokens(mergeRollups([rollup]).tokens) : 0,
|
|
91
|
+
activeMs: rollup?.metrics?.time?.activeMs ?? 0,
|
|
92
|
+
prompts: rollup?.metrics?.prompts?.count ?? 0,
|
|
93
|
+
toolCalls: rollup?.metrics?.tools?.calls ?? 0,
|
|
94
|
+
hours: rollup?.metrics?.time?.hours ?? new Array(24).fill(0),
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const poolTokens = peers.reduce((sum, peer) => sum + peer.totals.tokens, 0);
|
|
101
|
+
const poolBefore = peers.reduce((sum, peer) => sum + peer.previousTokens, 0);
|
|
102
|
+
|
|
103
|
+
for (const peer of peers) {
|
|
104
|
+
peer.share = poolTokens ? peer.totals.tokens / poolTokens : 0;
|
|
105
|
+
const shareBefore = poolBefore ? peer.previousTokens / poolBefore : null;
|
|
106
|
+
peer.shareDelta = shareBefore === null ? null : peer.share - shareBefore;
|
|
107
|
+
}
|
|
108
|
+
peers.sort((a, b) => b.totals.tokens - a.totals.tokens);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
enabled,
|
|
112
|
+
config: {
|
|
113
|
+
transport: teamConfig().transport ?? null,
|
|
114
|
+
scope: teamConfig().scope ?? 'summary',
|
|
115
|
+
pollSeconds: teamConfig().pollSeconds ?? 30,
|
|
116
|
+
pool: enabled ? poolRoot() : null,
|
|
117
|
+
},
|
|
118
|
+
sync: loadState(),
|
|
119
|
+
me: { deviceId: me.deviceId, name: me.name, initials: initialsOf(me.name) },
|
|
120
|
+
range: { ...range, label: range.label },
|
|
121
|
+
peers,
|
|
122
|
+
totals: {
|
|
123
|
+
tokens: poolTokens,
|
|
124
|
+
usd: peers.reduce((sum, peer) => sum + peer.totals.usd, 0),
|
|
125
|
+
prompts: peers.reduce((sum, peer) => sum + peer.totals.prompts, 0),
|
|
126
|
+
toolCalls: peers.reduce((sum, peer) => sum + peer.totals.toolCalls, 0),
|
|
127
|
+
activeMs: peers.reduce((sum, peer) => sum + peer.totals.activeMs, 0),
|
|
128
|
+
sessions: peers.reduce((sum, peer) => sum + peer.totals.sessions, 0),
|
|
129
|
+
people: peers.length,
|
|
130
|
+
deltaTokens: poolBefore ? poolTokens - poolBefore : null,
|
|
131
|
+
},
|
|
132
|
+
grid: contentionGrid(peers, range.days),
|
|
133
|
+
lanes: laneRows(peers, range.days),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The contention grid — the one picture that answers "were we in each other's
|
|
139
|
+
* way".
|
|
140
|
+
*
|
|
141
|
+
* Each cell is a three-hour band of one day. Size carries volume; colour carries
|
|
142
|
+
* how many people were working in it, which on a shared plan is the thing that
|
|
143
|
+
* actually costs you: one person alone gets the whole window, two people split
|
|
144
|
+
* it and both get throttled. Colour is therefore load-bearing here in exactly
|
|
145
|
+
* the way the palette intends — orange means look at this, and it means it
|
|
146
|
+
* because that band is where your limits were being spent twice.
|
|
147
|
+
*/
|
|
148
|
+
function contentionGrid(peers, days) {
|
|
149
|
+
const window = days.slice(-MAX_GRID_DAYS);
|
|
150
|
+
const cells = [];
|
|
151
|
+
let peak = 0;
|
|
152
|
+
|
|
153
|
+
for (const day of window) {
|
|
154
|
+
for (let band = 0; band < BANDS; band += 1) {
|
|
155
|
+
const byDevice = {};
|
|
156
|
+
let total = 0;
|
|
157
|
+
|
|
158
|
+
for (const peer of peers) {
|
|
159
|
+
const entry = peer.series.find((point) => point.day === day);
|
|
160
|
+
if (!entry) continue;
|
|
161
|
+
let count = 0;
|
|
162
|
+
for (let hour = band * BAND_HOURS; hour < (band + 1) * BAND_HOURS; hour += 1) {
|
|
163
|
+
count += entry.hours[hour] ?? 0;
|
|
164
|
+
}
|
|
165
|
+
if (count > 0) byDevice[peer.deviceId] = count;
|
|
166
|
+
total += count;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
peak = Math.max(peak, total);
|
|
170
|
+
cells.push({
|
|
171
|
+
day,
|
|
172
|
+
band,
|
|
173
|
+
hour: band * BAND_HOURS,
|
|
174
|
+
total,
|
|
175
|
+
people: Object.keys(byDevice).length,
|
|
176
|
+
byDevice,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const overlapping = cells.filter((cell) => cell.people > 1);
|
|
182
|
+
return {
|
|
183
|
+
days: window,
|
|
184
|
+
bands: BANDS,
|
|
185
|
+
bandHours: BAND_HOURS,
|
|
186
|
+
cells,
|
|
187
|
+
peak,
|
|
188
|
+
overlapBands: overlapping.length,
|
|
189
|
+
// Reported as a share of the bands that had ANY work in them. A share of all
|
|
190
|
+
// bands would be dominated by everyone being asleep, which flatters nobody.
|
|
191
|
+
overlapRate: cells.filter((cell) => cell.total > 0).length
|
|
192
|
+
? overlapping.length / cells.filter((cell) => cell.total > 0).length
|
|
193
|
+
: 0,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One row per day, split into each person's segment — the split, day by day. */
|
|
198
|
+
function laneRows(peers, days) {
|
|
199
|
+
return [...days].reverse().slice(0, 12).map((day) => {
|
|
200
|
+
const segments = peers
|
|
201
|
+
.map((peer) => {
|
|
202
|
+
const point = peer.series.find((entry) => entry.day === day);
|
|
203
|
+
return {
|
|
204
|
+
deviceId: peer.deviceId,
|
|
205
|
+
name: peer.name,
|
|
206
|
+
initials: peer.initials,
|
|
207
|
+
isMe: peer.isMe,
|
|
208
|
+
tokens: point?.tokens ?? 0,
|
|
209
|
+
activeMs: point?.activeMs ?? 0,
|
|
210
|
+
};
|
|
211
|
+
})
|
|
212
|
+
.filter((segment) => segment.tokens > 0)
|
|
213
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
214
|
+
|
|
215
|
+
return { day, total: segments.reduce((sum, s) => sum + s.tokens, 0), segments };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rollupsIn(peer, days) {
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const day of days) {
|
|
222
|
+
const rollup = peer.rollups.get(day);
|
|
223
|
+
if (rollup) out.push(rollup);
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function totalTokens(summary) {
|
|
229
|
+
if (!summary) return 0;
|
|
230
|
+
return (summary.input ?? 0) + (summary.output ?? 0) + (summary.cacheRead ?? 0) + (summary.cacheWrite ?? 0);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Sessions, from whichever field the peer's scope left us.
|
|
235
|
+
*
|
|
236
|
+
* At `summary` the ids are gone and only a count survives, so summing the
|
|
237
|
+
* per-day counts is the honest answer — it over-counts a session that spans
|
|
238
|
+
* midnight, which is a smaller error than reporting zero.
|
|
239
|
+
*/
|
|
240
|
+
function sessionCountOf(merged, rollups) {
|
|
241
|
+
if (merged.volume?.sessionCount) return merged.volume.sessionCount;
|
|
242
|
+
return rollups.reduce((sum, rollup) => sum + (rollup.metrics?.volume?.sessionCount ?? 0), 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export { contentionGrid, BAND_HOURS, BANDS };
|