@gaia-ai/addon-gaia-ui 0.13.0 → 0.13.1

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.
@@ -39,18 +39,31 @@ export interface SeriesFigureValue {
39
39
  days: StatisticDay[];
40
40
  }
41
41
  /**
42
- * A `ratio` figure's value: its two named factors, side by side.
42
+ * A `ratio` figure's value: the quotient, and the two factors behind it.
43
43
  *
44
- * The ratio itself is computed CLIENT-SIDE from the two totals — a
45
- * server-computed copy would be a second source of truth that can disagree
46
- * (unchanged from GAIA-377's `closedPerRun`, now over server-named factors
47
- * instead of the fixed tickets/runs pair).
44
+ * The division was CLIENT-SIDE until GAIA-481 D3, on the reasoning that a
45
+ * server-computed copy would be a second source of truth. The web Stats tabs
46
+ * reversed that reasoning: a second SURFACE would have had to re-implement
47
+ * the same rule in PHP, so the division and its zero-denominator case moved
48
+ * to the server instead. `ratio` is therefore READ and rendered, never
49
+ * recomputed here — and it arrives already formatted, because the empty
50
+ * value is part of the rule rather than a presentation choice each surface
51
+ * makes for itself.
52
+ *
53
+ * `numerator` and `denominator` still travel beside it, unchanged.
48
54
  */
49
55
  export interface RatioFigureValue {
50
56
  generated: string;
57
+ ratio: RatioQuotients;
51
58
  numerator: WindowsFigureValue;
52
59
  denominator: WindowsFigureValue;
53
60
  }
61
+ /** A ratio's derived value per trailing window: two decimals, or `—`. */
62
+ export interface RatioQuotients {
63
+ day: string;
64
+ week: string;
65
+ month: string;
66
+ }
54
67
  export type FigureValue = WindowsFigureValue | SeriesFigureValue | RatioFigureValue;
55
68
  /** One figure discovery names: which blocks the caller may render (spec D5). */
56
69
  export interface StatisticListing {
@@ -132,11 +145,3 @@ export declare function loadStatisticValue(client: DropshClient, statisticId: st
132
145
  * its own (spec D5). Figures already arrive weight-ordered from discovery.
133
146
  */
134
147
  export declare function loadGroupStatistics(client: DropshClient, scopeId: string): Promise<GroupStatisticsResult>;
135
- /** Closed-per-run-shaped ratio, for each trailing window, from two totals. */
136
- export declare function ratioOf(numerator: StatisticsWindows, denominator: StatisticsWindows): {
137
- day: number | null;
138
- week: number | null;
139
- month: number | null;
140
- };
141
- /** Format a ratio value for the screen: two decimals, or `—`. */
142
- export declare const formatRatio: (value: number | null) => string;
@@ -1,4 +1,12 @@
1
1
  import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
2
+ /**
3
+ * What a ratio window reads as when there is nothing to divide by.
4
+ *
5
+ * The server owns this rule and sends the character; this constant exists
6
+ * only so a response that carries no quotient at all degrades to the same
7
+ * thing rather than to an empty cell.
8
+ */
9
+ const RATIO_EMPTY = '—';
2
10
  /** How an `id: null` breakdown row is named on screen. */
3
11
  export const UNATTRIBUTED_LABEL = '— unattributed';
4
12
  const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
@@ -43,6 +51,23 @@ function windowsFigureOf(attributes) {
43
51
  ...(rows ? { rows } : {}),
44
52
  };
45
53
  }
54
+ /**
55
+ * A ratio figure's quotients, as the server formatted them (GAIA-481 D3).
56
+ *
57
+ * A control plane that predates the move sends no `ratio` at all, and this
58
+ * reads the empty value for every window rather than throwing or dividing a
59
+ * replacement: an older server is a missing figure, not a wrong one, and the
60
+ * one thing this must not do is quietly reintroduce the division it replaced.
61
+ */
62
+ function quotientsOf(value) {
63
+ const source = (value ?? {});
64
+ const windowOf = (key) => typeof source[key] === 'string' ? source[key] : RATIO_EMPTY;
65
+ return {
66
+ day: windowOf('day'),
67
+ week: windowOf('week'),
68
+ month: windowOf('month'),
69
+ };
70
+ }
46
71
  function figureValueOf(attributes) {
47
72
  if (Array.isArray(attributes.days)) {
48
73
  return {
@@ -56,6 +81,7 @@ function figureValueOf(attributes) {
56
81
  attributes.denominator !== null) {
57
82
  return {
58
83
  generated: typeof attributes.generated === 'string' ? attributes.generated : '',
84
+ ratio: quotientsOf(attributes.ratio),
59
85
  numerator: windowsFigureOf(attributes.numerator),
60
86
  denominator: windowsFigureOf(attributes.denominator),
61
87
  };
@@ -198,14 +224,12 @@ export async function loadGroupStatistics(client, scopeId) {
198
224
  }
199
225
  return { kind: 'ok', figures };
200
226
  }
201
- /** Closed-per-run-shaped ratio, for each trailing window, from two totals. */
202
- export function ratioOf(numerator, denominator) {
203
- const ratio = (n, d) => (d > 0 ? n / d : null);
204
- return {
205
- day: ratio(numerator.day, denominator.day),
206
- week: ratio(numerator.week, denominator.week),
207
- month: ratio(numerator.month, denominator.month),
208
- };
209
- }
210
- /** Format a ratio value for the screen: two decimals, or `—`. */
211
- export const formatRatio = (value) => value === null ? '—' : value.toFixed(2);
227
+ // GAIA-481 D3 — `ratioOf` and `formatRatio` were exported from here, and are
228
+ // gone. A `ratio` figure's quotient is the server's now
229
+ // (`RatioFigureValue.ratio`), so this module deliberately offers no ratio
230
+ // arithmetic for a caller to reach for: a shared helper sitting beside the
231
+ // server's own value is exactly how the second implementation would come
232
+ // back. The team Users screen still divides, because it derives a PER-MEMBER
233
+ // quotient out of two breakdown figures' rows and no configured figure
234
+ // produces that one; it owns that arithmetic privately rather than sharing
235
+ // it from here.
@@ -1,5 +1,4 @@
1
1
  import { statsPanelRows, } from '../../Component/stats-panel/index.js';
2
- import { formatRatio, ratioOf } from './Data/statistics.js';
3
2
  // GAIA-456 spec D2/D7 — mapping the four configured display kinds onto the
4
3
  // renderers the TUI already had: `windows` is the totals row GAIA-256 drew,
5
4
  // `breakdown` is the label-by-window table, `series` is the seven-day strip
@@ -24,13 +23,19 @@ function seriesLines(days) {
24
23
  const counts = ` ${'Count'.padEnd(LABEL - 2, ' ')}${days.map((row) => cell(String(row.count))).join('')}`;
25
24
  return [weekdays, dates, counts];
26
25
  }
27
- /** A ratio figure's two factor totals, divided per trailing window. */
28
- function ratioLines(numerator, denominator) {
29
- const ratios = ratioOf(numerator, denominator);
26
+ /**
27
+ * A ratio figure's quotient per trailing window, as the SERVER divided it.
28
+ *
29
+ * The values arrive already formatted (GAIA-481 D3), so this lays them out
30
+ * and does nothing else — no division and no em-dash rule, both of which are
31
+ * the server's now and would otherwise be a terminal-only second opinion on
32
+ * the same figure the browser renders.
33
+ */
34
+ function ratioLines(ratios) {
30
35
  return [
31
- ` ${WINDOWS[0]} ${formatRatio(ratios.day)}`,
32
- ` ${WINDOWS[1]} ${formatRatio(ratios.week)}`,
33
- ` ${WINDOWS[2]} ${formatRatio(ratios.month)}`,
36
+ ` ${WINDOWS[0]} ${ratios.day}`,
37
+ ` ${WINDOWS[1]} ${ratios.week}`,
38
+ ` ${WINDOWS[2]} ${ratios.month}`,
34
39
  ];
35
40
  }
36
41
  /** One figure's own section: its label as the heading, its display kind's lines. */
@@ -41,10 +46,7 @@ export function figureSection({ listing, value }) {
41
46
  return { heading, lines: seriesLines(value.days) };
42
47
  case 'ratio': {
43
48
  const ratio = value;
44
- return {
45
- heading,
46
- lines: ratioLines(ratio.numerator.total, ratio.denominator.total),
47
- };
49
+ return { heading, lines: ratioLines(ratio.ratio) };
48
50
  }
49
51
  case 'breakdown': {
50
52
  const windows = value;
@@ -4,7 +4,7 @@ import { ListCursor } from '../../../lib/cursor.js';
4
4
  import { columnNode } from '../../../lib/vnode.js';
5
5
  import { AsyncScreen } from '../../../Screen/async-screen.js';
6
6
  import { listRowIds, listTableTree } from '../../collection.js';
7
- import { formatRatio, loadGroupStatistics, ratioOf, } from '../../statistics/Data/statistics.js';
7
+ import { loadGroupStatistics, } from '../../statistics/Data/statistics.js';
8
8
  import { listUsers, TEAM_MEMBER_TABLE, } from '../../user/Data/users.js';
9
9
  import { listTeamMemberIds } from '../Data/members.js';
10
10
  import { TeamsScreen } from './teams-screen.js';
@@ -48,6 +48,20 @@ function rowsById(statistics, statisticId) {
48
48
  }
49
49
  return map;
50
50
  }
51
+ /**
52
+ * This screen's own closed-per-run, for ONE member over the 30-day window.
53
+ *
54
+ * Deliberately local and unexported (GAIA-481 D3). A `ratio` FIGURE is
55
+ * divided by the server, and this is not one: it is derived per member from
56
+ * two breakdown figures' rows, and no configured figure produces it. Keeping
57
+ * it here rather than in the shared statistics module is what stops it being
58
+ * mistaken for the general "how a ratio is computed" helper and reached for
59
+ * by a surface that should be reading the server's value instead.
60
+ *
61
+ * The rule matches the server's so the two columns agree on screen: two
62
+ * decimals, and an em-dash when there is nothing to divide by.
63
+ */
64
+ const perMemberRatio = (numerator, denominator) => denominator > 0 ? (numerator / denominator).toFixed(2) : '—';
51
65
  /** One member's row: identity from the collection, figures from the two per-user breakdowns. */
52
66
  function rowOf(user, ticketsById, runsById) {
53
67
  const tickets30d = ticketsById.get(user.id)?.month ?? 0;
@@ -57,7 +71,7 @@ function rowOf(user, ticketsById, runsById) {
57
71
  label: user.label,
58
72
  tickets30d,
59
73
  runs30d,
60
- closedPerRun30d: formatRatio(ratioOf({ day: 0, week: 0, month: tickets30d }, { day: 0, week: 0, month: runs30d }).month),
74
+ closedPerRun30d: perMemberRatio(tickets30d, runs30d),
61
75
  };
62
76
  }
63
77
  /**
@@ -251,6 +251,15 @@ export declare class TicketDetailScreen extends AsyncScreen<TicketDetailDocument
251
251
  * operator saw; a table whose `rows()` throws resolves to the error branch and
252
252
  * simply has no row to activate.
253
253
  */
254
+ /**
255
+ * GAIA-500 — `l`/`t`'s shared plumbing: resolve the active table's selected
256
+ * row and ask its `copyLocal`/`copyTunnel`, returning `undefined` when there
257
+ * is no active table, the table does not implement that side, or the row has
258
+ * nothing to offer — in every one of which cases the key is left UNHANDLED
259
+ * (the caller falls through) rather than reporting a copy that did not
260
+ * happen, exactly as `l`/`t` do nothing on every tab that is not a table.
261
+ */
262
+ private copyActiveTableRow;
254
263
  private activateTableRow;
255
264
  /** Tests and the busy indicator: the row ids whose activation is in flight. */
256
265
  activatingRowIds(): string[];
@@ -428,24 +428,49 @@ export class TicketDetailScreen extends AsyncScreen {
428
428
  // `selectTopTab` seam would be dead code on the key path. The wrap comes from
429
429
  // `TAB_LEVELS.ticket` now, the same wrap every other level gets.
430
430
  //
431
- // GAIA-326 — the same copy ladder as the ticket LIST (`y` the identifier,
432
- // `Y` the link), acting on the ticket that is open. This screen has no text
433
- // field, so the keys need no guard beyond the `ready`/`full` gate above.
434
- if (input === 'y' || input === 'Y') {
435
- this.status = this.copyOpenTicket(input === 'Y', context);
436
- context.requestDraw();
437
- return true;
438
- }
439
431
  const view = this.currentView(context);
440
432
  // GAIA-475 UX — `s` STARTS and `x` STOPS the selected row's action on a
441
433
  // TABLE tab, two distinct keys because Enter reads as "expand" on a detail
442
434
  // screen and one key doing both is a confusing toggle. Only when a table is
443
435
  // the active contribution, so `s`/`x` are untouched everywhere else.
444
436
  const onTable = this.activeContribution(context)?.type === 'table';
437
+ // GAIA-326 — the same copy ladder as the ticket LIST (`y` the identifier,
438
+ // `Y` the link), acting on the ticket that is open. NOT on a table tab
439
+ // (GAIA-500): a table's own rows have their own copy (`l`/`t`), a
440
+ // different target entirely, and `y`/`Y` copying the open ticket instead —
441
+ // a fact about the SCREEN, unrelated to whatever row is selected — read as
442
+ // two unrelated "copy" meanings live at once, confusing enough to report.
443
+ // This screen has no text field, so the keys need no guard beyond the
444
+ // `ready`/`full` gate above.
445
+ if (!onTable && (input === 'y' || input === 'Y')) {
446
+ this.status = this.copyOpenTicket(input === 'Y', context);
447
+ context.requestDraw();
448
+ return true;
449
+ }
445
450
  if (onTable && input === 's' && view.selectable > 0)
446
451
  return this.activate(this.selectedEntry, context, 'activate');
447
452
  if (onTable && input === 'x' && view.selectable > 0)
448
453
  return this.activate(this.selectedEntry, context, 'deactivate');
454
+ // GAIA-500 — `l` copies the selected row's LOCAL address, `t` the address
455
+ // reached through the TUNNEL: the same two-distinct-keys shape as `s`/`x`,
456
+ // for the same reason — one key meaning "copy whichever of two addresses
457
+ // happens to apply" is the confusing toggle this avoids.
458
+ if (onTable && input === 'l' && view.selectable > 0) {
459
+ const copied = this.copyActiveTableRow(context, 'local');
460
+ if (copied !== undefined) {
461
+ this.status = copied;
462
+ context.requestDraw();
463
+ return true;
464
+ }
465
+ }
466
+ if (onTable && input === 't' && view.selectable > 0) {
467
+ const copied = this.copyActiveTableRow(context, 'tunnel');
468
+ if (copied !== undefined) {
469
+ this.status = copied;
470
+ context.requestDraw();
471
+ return true;
472
+ }
473
+ }
449
474
  if ((input === 'enter' || input === ' ') && view.selectable > 0)
450
475
  return this.activate(this.selectedEntry, context);
451
476
  // GAIA-369 AC-7 — "HANDLED" MEANS THE SELECTION MOVED, and that is the whole
@@ -614,20 +639,38 @@ export class TicketDetailScreen extends AsyncScreen {
614
639
  const view = this.currentView(context);
615
640
  const arrows = view.selectable > 1 ? 'select' : 'scroll';
616
641
  // GAIA-346 — `Tab section`; kernel owns Esc/q. GAIA-373 — section via registry.
642
+ const contribution = this.activeContribution(context);
643
+ const onTable = contribution?.type === 'table';
617
644
  const actions = [
618
645
  actionItem('detail-tab', 'Tab', 'section'),
619
646
  actionItem('detail-edit', 'e', 'edit'),
620
- actionItem('detail-copy', 'y', 'copy'),
621
- actionItem('detail-link', 'Y', 'link'),
622
- actionItem('detail-arrows', '↑↓', arrows),
623
647
  ];
648
+ // GAIA-500 — NOT on a table tab: a table's own rows carry their own copy
649
+ // (`l`/`t` below), a different target than the open ticket, and having
650
+ // both live on the same screen at once is what read as confusing enough
651
+ // to report — named `copy id`/`copy link` here rather than the bare
652
+ // `copy`/`link` this carried before, so it cannot misread as this row's
653
+ // copy on the one tab where it is not.
654
+ if (!onTable) {
655
+ actions.push(actionItem('detail-copy', 'y', 'copy id'));
656
+ actions.push(actionItem('detail-link', 'Y', 'copy link'));
657
+ }
658
+ actions.push(actionItem('detail-arrows', '↑↓', arrows));
624
659
  if (view.selectable > 0) {
625
660
  // GAIA-475 — a table tab offers its action under a dedicated key `s`, so
626
661
  // the footer names it rather than leaving the operator to guess Enter does
627
662
  // something other than expand.
628
- if (this.activeContribution(context)?.type === 'table') {
663
+ if (onTable && contribution) {
629
664
  actions.push(actionItem('detail-share', 's', 'start'));
630
665
  actions.push(actionItem('detail-unshare', 'x', 'stop'));
666
+ // GAIA-500 — the row's own copy, distinct from `y`/`Y` above (which a
667
+ // table tab no longer even offers) for the reason `s`/`x` already read
668
+ // "start"/"stop" rather than "share" twice — which of two things a key
669
+ // copies is the part worth saying.
670
+ if (contribution.copyLocal)
671
+ actions.push(actionItem('detail-copy-local', 'l', 'copy local'));
672
+ if (contribution.copyTunnel)
673
+ actions.push(actionItem('detail-copy-tunnel', 't', 'copy tunnel'));
631
674
  }
632
675
  else {
633
676
  actions.push(this.activeSectionId(context) === 'runs' &&
@@ -740,6 +783,36 @@ export class TicketDetailScreen extends AsyncScreen {
740
783
  * operator saw; a table whose `rows()` throws resolves to the error branch and
741
784
  * simply has no row to activate.
742
785
  */
786
+ /**
787
+ * GAIA-500 — `l`/`t`'s shared plumbing: resolve the active table's selected
788
+ * row and ask its `copyLocal`/`copyTunnel`, returning `undefined` when there
789
+ * is no active table, the table does not implement that side, or the row has
790
+ * nothing to offer — in every one of which cases the key is left UNHANDLED
791
+ * (the caller falls through) rather than reporting a copy that did not
792
+ * happen, exactly as `l`/`t` do nothing on every tab that is not a table.
793
+ */
794
+ copyActiveTableRow(context, side) {
795
+ const contribution = this.activeContribution(context);
796
+ if (contribution?.type !== 'table')
797
+ return;
798
+ const copyField = side === 'local' ? contribution.copyLocal : contribution.copyTunnel;
799
+ if (!copyField)
800
+ return;
801
+ const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
802
+ const resolved = this.tableView(contribution, context, {
803
+ expanded: new Set(),
804
+ nowSeconds,
805
+ });
806
+ if ('panelSections' in resolved)
807
+ return;
808
+ const row = resolved.panelTable.rows[this.selectedEntry];
809
+ if (!row)
810
+ return;
811
+ const value = copyField(row);
812
+ if (value === undefined)
813
+ return;
814
+ return copyThrough(context.copyToClipboard, value, value, 'nothing to copy');
815
+ }
743
816
  async activateTableRow(table, index, context, intent) {
744
817
  const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
745
818
  const resolved = this.tableView(table, context, {
@@ -7,10 +7,10 @@
7
7
  //
8
8
  // The switch does three things, in order, on Enter: (1) refuse silently when
9
9
  // the choice is already active — a write and a restart over "the same" would
10
- // be a no-op with side effects; (2) write the selection through the exact
11
- // helpers `gaia ui`'s startup recovery uses (`@gaia-ai/core`'s `workspaceOwner`
12
- // + `writeWorkspaceIdSelection`), so a manual edit and a cockpit switch commit
13
- // through the identical path and the identical ownership rule; (3) re-execute
10
+ // be a no-op with side effects; (2) write the selection through `@gaia-ai/core`'s
11
+ // `workspaceOwner` + `writeWorkspaceIdSelection`, the single ownership-and-write
12
+ // path, so a manual edit and a cockpit switch commit through the identical path
13
+ // and the identical ownership rule; (3) re-execute
14
14
  // this process via the seam `ui/src/bun-runtime.ts` exposes, so any conductor
15
15
  // reading the SAME config file re-binds on its next read.
16
16
  import { workspaceOwner, writeWorkspaceIdSelection } from '@gaia-ai/core';
@@ -158,6 +158,22 @@ export interface UiTable<Row extends {
158
158
  onActivate?(row: Row, ctx: PanelRender): Promise<ActivationResult>;
159
159
  /** Shown when `rows` comes back empty. */
160
160
  readonly emptyMessage?: string;
161
+ /**
162
+ * GAIA-500 — what `l` copies for the selected row, when this table is the
163
+ * active tab: the LOCAL address, mirroring `s`/`x` (GAIA-475 UX), two
164
+ * distinct keys rather than one that means something different depending on
165
+ * state. Optional: a table with nothing local-shaped to copy leaves `l`
166
+ * unhandled here, and the key falls through inert — never a copy of
167
+ * something the row does not own.
168
+ */
169
+ copyLocal?(row: Row): string | undefined;
170
+ /**
171
+ * GAIA-500 — what `t` copies for the selected row: the address reached
172
+ * through the TUNNEL, when the row has one shared. `undefined` when nothing
173
+ * is shared — `t` on a `local only` row does nothing rather than copying an
174
+ * empty string or a stale value.
175
+ */
176
+ copyTunnel?(row: Row): string | undefined;
161
177
  }
162
178
  export type UiContribution = UiPanel | UiTable | UiScreen;
163
179
  /**
@@ -148,8 +148,8 @@ export interface RendererServices {
148
148
  workspace?: string;
149
149
  /**
150
150
  * GAIA-472 — the absolute path of the loaded connection config, so the
151
- * workspace-picker screen can find `workspace_id`'s owning file the same way
152
- * `gaia ui`'s startup recovery does (`@gaia-ai/core`'s `workspaceOwner`).
151
+ * workspace-picker screen can find `workspace_id`'s owning file through the
152
+ * shared ownership rule (`@gaia-ai/core`'s `workspaceOwner`).
153
153
  */
154
154
  configPath?: string;
155
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-gaia-ui",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,6 @@
26
26
  "drupal-jsonapi-params": "^3.0.1"
27
27
  },
28
28
  "peerDependencies": {
29
- "@gaia-ai/core": "^0.13.0"
29
+ "@gaia-ai/core": "^0.13.1"
30
30
  }
31
31
  }