pgkiln 0.43.0 → 0.44.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/public/app.css CHANGED
@@ -478,6 +478,8 @@ details.btn-menu[open] > summary .btn-caret { transform: rotate(-90deg); }
478
478
  .report-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; align-items: flex-end; margin-bottom: 0.75rem; }
479
479
  .report-toolbar .field { flex: 0 1 240px; }
480
480
  .search { display: flex; gap: 0.4rem; flex: 1 1 320px; max-width: 560px; margin-left: auto; }
481
+ /* (sprint 78) a report whose search is the facets': the Actions menu at the start of the toolbar */
482
+ .report-toolbar .search:not(:has(input[type=search])) { flex: 0 1 auto; min-width: 0; margin-left: 0; }
481
483
  .chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
482
484
  .chip { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.83rem; padding: 0.15rem 0.3rem 0.15rem 0.7rem; background: var(--accent-soft); border-radius: 99px; }
483
485
  .chip a { text-decoration: none; font-size: 1rem; line-height: 1; padding: 0 0.3rem; border-radius: 99px; }
@@ -1144,6 +1146,10 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
1144
1146
  .facet-chart .donut-figure { max-width: 9rem; margin: 0 auto; }
1145
1147
  .facet-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1146
1148
  .facet-count { font-size: 0.75rem; color: var(--muted); background: var(--surface-3); border-radius: 99px; padding: 0 0.5rem; font-variant-numeric: tabular-nums; }
1149
+ /* (sprint 78) values after the seventh, and the rows the filters leave */
1150
+ .facet-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--accent, var(--link)); padding: 0.15rem 0; }
1151
+ .facet-more[open] > summary { margin-bottom: 0.2rem; }
1152
+ .facet-total { margin: 0 0 0.6rem; font-size: 0.85rem; color: var(--muted); font-variant-numeric: tabular-nums; }
1147
1153
  .js .facet-actions .btn-hot { display: none; }
1148
1154
  .facet-exclude { font-size: 0.85rem; color: var(--muted); margin-top: 0.15rem; }
1149
1155
  .facet-excluded .facet-value .facet-label { text-decoration: line-through; }
@@ -1811,6 +1817,14 @@ dialog.crop-dialog::backdrop { background: rgb(15 20 30 / 55%); }
1811
1817
  .devbar-tool::-webkit-details-marker { display: none; }
1812
1818
  .devbar .devbar-tool:hover, .devbar-menu[open] > .devbar-tool, .devbar-quick[aria-pressed="true"] { background: #2f3b4f; color: #fff; }
1813
1819
  .devbar-debug-menu.is-on > .devbar-tool { color: #ffd166; }
1820
+ /* (sprint 79, UI-4) a text next to each icon (App 12, Page 3, Debug off); icons only when chosen or docked at a side */
1821
+ .devbar .devbar-tool:has(.devbar-text) { width: auto; min-width: 2.25rem; padding: 0 0.55rem; grid-auto-flow: column; gap: 0.35rem; white-space: nowrap; }
1822
+ .devbar[data-compact] .devbar-text, .devbar[data-dock="left"] .devbar-text, .devbar[data-dock="right"] .devbar-text { display: none; }
1823
+ .devbar[data-compact] .devbar-tool:has(.devbar-text), .devbar[data-dock="left"] .devbar-tool:has(.devbar-text), .devbar[data-dock="right"] .devbar-tool:has(.devbar-text) { width: 2.25rem; padding: 0; }
1824
+ @media (max-width: 900px) {
1825
+ .devbar .devbar-text { display: none; }
1826
+ .devbar .devbar-tool:has(.devbar-text) { width: 2.25rem; padding: 0; }
1827
+ }
1814
1828
  .devbar a, .devbar-pop a { color: #9cc8ff; }
1815
1829
  .devbar a:focus-visible, .devbar button:focus-visible, .devbar select:focus-visible, .devbar summary:focus-visible, .devbar input:focus-visible { outline: 2px solid #9cc8ff; outline-offset: 2px; }
1816
1830
  .devbar-menu { position: relative; }
@@ -1887,8 +1901,9 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1887
1901
  .devbar-quick[aria-pressed="true"] { background: var(--accent); color: var(--accent-text); }
1888
1902
 
1889
1903
  /* (sprint 76) the pager: rows per page; a Go button only without JavaScript */
1890
- .pager-size { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; font-size: 0.85rem; }
1904
+ .pager .pager-size { margin-right: 0; display: inline-flex; align-items: center; gap: 0.4rem; margin-left: auto; font-size: 0.85rem; }
1891
1905
  .pager-size select { min-height: 0; padding: 0.15rem 0.4rem; }
1906
+ .pager-size label { white-space: nowrap; }
1892
1907
  .js .no-js-only { display: none; }
1893
1908
 
1894
1909
  /* (sprint 76) the interactive report's Actions menu: one line per entry, submenus, forms in dialogs */
@@ -1914,6 +1929,13 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
1914
1929
  .search-field { display: flex; flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
1915
1930
  .search-field:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
1916
1931
  .search-field .search-scope { border: 0; border-right: 1px solid var(--border); border-radius: var(--radius) 0 0 var(--radius); max-width: 9rem; background: var(--surface-2); }
1932
+ /* (sprint 79, IR-3; APEX: the column scope in the magnifier of the search field) all columns: only the magnifier;
1933
+ a column: the magnifier and its name */
1934
+ .search-scope-wrap { position: relative; display: flex; flex: none; }
1935
+ .search-scope-wrap > .icon { position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--muted); }
1936
+ .search-field .search-scope-wrap .search-scope { padding-left: 2rem; }
1937
+ .search-field .search-scope-wrap .search-scope:has(option[value=""]:checked) { width: 2.75rem; padding-right: 0.5rem; color: transparent; }
1938
+ .search-field .search-scope option { color: var(--text); }
1917
1939
  .search-field input[type=search] { border: 0; flex: 1; min-width: 0; background: transparent; }
1918
1940
  .search-field input[type=search]:focus { outline: none; }
1919
1941
  /* a column heading's menu */
package/public/app.js CHANGED
@@ -3642,7 +3642,7 @@ function areaFilter(map, f) {
3642
3642
  const bar = document.querySelector('.devbar');
3643
3643
  if (!bar) return;
3644
3644
  const KEY = 'pgkiln.devbar';
3645
- let prefs = { dock: 'bottom', autohide: true };
3645
+ let prefs = { dock: 'bottom', autohide: true, compact: false };
3646
3646
  try { prefs = { ...prefs, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; } catch {}
3647
3647
  if (!['bottom', 'top', 'left', 'right'].includes(prefs.dock)) prefs.dock = 'bottom';
3648
3648
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch {} };
@@ -3650,6 +3650,10 @@ function areaFilter(map, f) {
3650
3650
  settings?.closest('li')?.removeAttribute('hidden');
3651
3651
  const apply = () => {
3652
3652
  bar.dataset.dock = prefs.dock;
3653
+ // (sprint 79) "Icons only": the tools without their texts
3654
+ bar.toggleAttribute('data-compact', !!prefs.compact);
3655
+ const compact = bar.querySelector('input[name="devbar-compact"]');
3656
+ if (compact) compact.checked = !!prefs.compact;
3653
3657
  bar.querySelectorAll('input[name="devbar-dock"]').forEach((r) => { r.checked = r.value === prefs.dock; });
3654
3658
  const auto = bar.querySelector('input[name="devbar-autohide"]');
3655
3659
  if (auto) auto.checked = !!prefs.autohide;
@@ -3658,6 +3662,7 @@ function areaFilter(map, f) {
3658
3662
  bar.addEventListener('change', (e) => {
3659
3663
  if (e.target.name === 'devbar-dock') prefs.dock = e.target.value;
3660
3664
  else if (e.target.name === 'devbar-autohide') prefs.autohide = e.target.checked;
3665
+ else if (e.target.name === 'devbar-compact') prefs.compact = e.target.checked;
3661
3666
  else return;
3662
3667
  save();
3663
3668
  apply();
@@ -274,6 +274,45 @@ interface Catalog {
274
274
  fk_table: string | null;
275
275
  }
276
276
 
277
+ /** (sprint 79) A number column that holds an amount: a dashboard sums it (Salary by Department). */
278
+ const AMOUNT = /(^|_)(sal|salary|salaries|amount|amt|total|price|cost|costs|revenue|value|budget|balance|fee|fees|pay|wage|wages)(_|$)/i;
279
+
280
+ interface ChartPlan {
281
+ table: string;
282
+ group: string;
283
+ /** count, or sum of `value` */
284
+ fn: 'count' | 'sum';
285
+ value: string | null;
286
+ kind: 'donut' | 'line' | 'bar';
287
+ }
288
+
289
+ /**
290
+ * (sprint 79, GEN-6) How a table is charted: by its first foreign key that says something, else a text,
291
+ * yes/no or date column whose values repeat; the sum of an amount column when there is one, else the
292
+ * number of rows; a donut for six groups or fewer, a line for dates, else a sorted bar. A grouping that
293
+ * says nothing (one group, or every group the same) is skipped.
294
+ */
295
+ async function chartPlan(c: Client, table: string, catalog: Catalog[], rows: number): Promise<ChartPlan | null> {
296
+ const amount = catalog.find((x) => x.kind === 'number' && !x.is_pk && !x.fk_table && AMOUNT.test(x.column_name))?.column_name ?? null;
297
+ const groups = [...catalog.filter((x) => x.fk_table && !x.is_pk).map((x) => x.column_name), groupColumn(catalog, rows)].filter(
298
+ (g, i, all): g is string => !!g && all.indexOf(g) === i,
299
+ );
300
+ for (const group of groups) {
301
+ for (const value of amount ? [amount, null] : [null]) {
302
+ const measure = value ? `sum(${pg.escapeIdentifier(value)})` : 'count(*)';
303
+ const spread = (
304
+ await c.query<{ groups: number; lo: string | null; hi: string | null }>(
305
+ `select count(*)::int as groups, min(v)::text as lo, max(v)::text as hi from (select ${pg.escapeIdentifier(group)} as g, ${measure} as v from ${table} group by 1) x`,
306
+ )
307
+ ).rows[0];
308
+ if (spread.groups < 2 || spread.lo === spread.hi) continue;
309
+ const kind = catalog.find((x) => x.column_name === group)?.kind === 'date' ? 'line' : spread.groups <= 6 ? 'donut' : 'bar';
310
+ return { table, group, fn: value ? 'sum' : 'count', value, kind };
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
277
316
  /**
278
317
  * A dashboard page: a chart per table (at most six) with the number of rows per
279
318
  * parent row (the first foreign key), or per value of a text, yes/no or date
@@ -283,7 +322,7 @@ interface Catalog {
283
322
  * Manager", and the Dashboard comes right after Home in the navigation menu.
284
323
  */
285
324
  export async function addDashboard(c: Client, alias: string, tables: string[], page: number, skipped: string[] = []): Promise<{ page: number; label: string } | null> {
286
- const picks: { table: string; group: string }[] = [];
325
+ const picks: ChartPlan[] = [];
287
326
  for (const t of tables) {
288
327
  if (picks.length >= MAX_CHARTS) break;
289
328
  const rel = (await c.query('select relrowsecurity as rls, greatest(reltuples, 0) as n from pg_class where oid = $1::regclass', [t])).rows[0];
@@ -292,24 +331,25 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
292
331
  continue;
293
332
  }
294
333
  const catalog = (await c.query<Catalog>('select column_name, kind, is_pk, distinct_values, fk_table::text from meta.wizard_catalog($1::regclass)', [t])).rows;
295
- const group = catalog.find((x) => x.fk_table && !x.is_pk)?.column_name ?? groupColumn(catalog, Number(rel.n));
296
- if (group) picks.push({ table: t, group });
334
+ const plan = await chartPlan(c, t, catalog, Number(rel.n));
335
+ if (plan) picks.push(plan);
297
336
  }
298
337
  if (!picks.length) return null;
299
- const generate = (table: string, group: string, no: number, nav: boolean) =>
338
+ const generate = (p: ChartPlan, no: number, nav: boolean) =>
300
339
  c.query<{ id: number }>('select meta.generate_page($1, \'chart\', $2::regclass, $3, $4::jsonb) as id', [
301
- alias, table, no,
302
- JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: group, function: 'count', value_column: null }),
340
+ alias, p.table, no,
341
+ JSON.stringify({ label: 'Dashboard', icon: 'chart', nav, label_column: p.group, function: p.fn, value_column: p.value, chart: p.kind }),
303
342
  ]);
304
- const title = async (table: string, group: string) =>
305
- (await c.query(`select meta.wizard_table_label($1::regclass) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [table, group])).rows[0].t as string;
306
- const dash = (await generate(picks[0].table, picks[0].group, page, true)).rows[0].id;
307
- await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0].table, picks[0].group)]);
343
+ // Employees by Manager; a sum: Salary by Department
344
+ const title = async (p: ChartPlan) =>
345
+ (await c.query(`select coalesce(meta.wizard_column_label($1::regclass, $3), meta.wizard_table_label($1::regclass)) || ' by ' || meta.wizard_column_label($1::regclass, $2) as t`, [p.table, p.group, p.value])).rows[0].t as string;
346
+ const dash = (await generate(picks[0], page, true)).rows[0].id;
347
+ await c.query('update meta.region set title = $2 where page_id = $1', [dash, await title(picks[0])]);
308
348
  for (const [n, p] of picks.slice(1).entries()) {
309
349
  // generated on a page of its own, then moved onto the dashboard
310
350
  const spare = (await c.query('select max(page_no) + 1000 as n from meta.page where app_id = (select id from meta.app where alias = $1)', [alias])).rows[0].n as number;
311
- const tmp = (await generate(p.table, p.group, spare, false)).rows[0].id;
312
- await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p.table, p.group)]);
351
+ const tmp = (await generate(p, spare, false)).rows[0].id;
352
+ await c.query('update meta.region set page_id = $2, seq = $3, title = $4 where page_id = $1', [tmp, dash, (n + 2) * 10, await title(p)]);
313
353
  await c.query('delete from meta.page where id = $1', [tmp]);
314
354
  }
315
355
  if (picks.length > 1) await c.query('update meta.region set columns = 6 where page_id = $1', [dash]);
@@ -319,7 +359,7 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
319
359
  from meta.app a where a.alias = $1 and e.app_id = a.id and e.parent_id is null and e.target_page = $2`,
320
360
  [alias, page],
321
361
  );
322
- return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p.table, p.group)))).join(', ')})` };
362
+ return { page, label: `Dashboard (${(await Promise.all(picks.map((p) => title(p)))).join(', ')})` };
323
363
  }
324
364
 
325
365
  /** Create the application, the tables, the rows, the foreign keys and the pages, in one transaction. */
@@ -40,6 +40,8 @@ export const pastedName = (title: string) =>
40
40
 
41
41
  interface Relation {
42
42
  relname: string;
43
+ /** (sprint 78) the friendly name of its pages: Employees */
44
+ label: string;
43
45
  kind: 'table' | 'view';
44
46
  pk: string | null;
45
47
  /** the planner's estimate (null: never analyzed) */
@@ -54,7 +56,7 @@ interface Relation {
54
56
  export async function schemaRelations(schema: string): Promise<Relation[]> {
55
57
  return (
56
58
  await owner.query<Relation>(
57
- `select c.relname, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
59
+ `select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
58
60
  meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
59
61
  meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
60
62
  meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
@@ -350,9 +352,9 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
350
352
  ${known
351
353
  ? relations.length
352
354
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
353
- <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.relname}</label>
355
+ <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
354
356
  <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
355
- <small class="muted">${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
357
+ <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small></li>`)
356
358
  : html`<li class="muted">${schema} has no tables or views.</li>`
357
359
  : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
358
360
  </ul>
package/src/i18n/ar.ts CHANGED
@@ -390,6 +390,8 @@ export const ar: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} نجوم فأكثر",
391
391
  'facets.star_up': "نجمة واحدة فأكثر",
392
392
  'facets.clear_short': "مسح",
393
+ 'facets.show_all': "عرض الكل ({n})",
394
+ 'facets.total': "إجمالي عدد الصفوف {n}",
393
395
  'smart.placeholder': "بحث أو تصفية…",
394
396
  'smart.active': "عوامل التصفية المستخدمة",
395
397
  'smart.remove': "إزالة عامل التصفية {filter}",
package/src/i18n/cs.ts CHANGED
@@ -390,6 +390,8 @@ export const cs: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} hvězdičky a více",
391
391
  'facets.star_up': "1 hvězdička a více",
392
392
  'facets.clear_short': "Vymazat",
393
+ 'facets.show_all': "Zobrazit vše ({n})",
394
+ 'facets.total': "Celkový počet řádků {n}",
393
395
  'smart.placeholder': "Hledat nebo filtrovat…",
394
396
  'smart.active': "Použité filtry",
395
397
  'smart.remove': "Odebrat filtr {filter}",
package/src/i18n/da.ts CHANGED
@@ -390,6 +390,8 @@ export const da: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stjerner og op",
391
391
  'facets.star_up': "1 stjerne og op",
392
392
  'facets.clear_short': "Ryd",
393
+ 'facets.show_all': "Vis alle ({n})",
394
+ 'facets.total': "Antal rækker i alt {n}",
393
395
  'smart.placeholder': "Søg eller filtrér…",
394
396
  'smart.active': "Filtre i brug",
395
397
  'smart.remove': "Fjern filteret {filter}",
package/src/i18n/de.ts CHANGED
@@ -390,6 +390,8 @@ export const de: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} Sterne und mehr',
391
391
  'facets.star_up': '1 Stern und mehr',
392
392
  'facets.clear_short': 'Löschen',
393
+ 'facets.show_all': 'Alle anzeigen ({n})',
394
+ 'facets.total': 'Zeilen insgesamt {n}',
393
395
  'smart.placeholder': 'Suchen oder filtern…',
394
396
  'smart.active': 'Aktive Filter',
395
397
  'smart.remove': 'Filter {filter} entfernen',
package/src/i18n/el.ts CHANGED
@@ -390,6 +390,8 @@ export const el: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} αστέρια και πάνω",
391
391
  'facets.star_up': "1 αστέρι και πάνω",
392
392
  'facets.clear_short': "Απαλοιφή",
393
+ 'facets.show_all': "Εμφάνιση όλων ({n})",
394
+ 'facets.total': "Συνολικός αριθμός γραμμών {n}",
393
395
  'smart.placeholder': "Αναζήτηση ή φιλτράρισμα…",
394
396
  'smart.active': "Φίλτρα σε χρήση",
395
397
  'smart.remove': "Αφαίρεση του φίλτρου {filter}",
package/src/i18n/es.ts CHANGED
@@ -390,6 +390,8 @@ export const es: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} estrellas o más',
391
391
  'facets.star_up': '1 estrella o más',
392
392
  'facets.clear_short': 'Borrar',
393
+ 'facets.show_all': 'Mostrar todo ({n})',
394
+ 'facets.total': 'Total de filas {n}',
393
395
  'smart.placeholder': 'Buscar o filtrar…',
394
396
  'smart.active': 'Filtros en uso',
395
397
  'smart.remove': 'Quitar el filtro {filter}',
package/src/i18n/fi.ts CHANGED
@@ -390,6 +390,8 @@ export const fi: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} tähteä tai enemmän",
391
391
  'facets.star_up': "1 tähti tai enemmän",
392
392
  'facets.clear_short': "Tyhjennä",
393
+ 'facets.show_all': "Näytä kaikki ({n})",
394
+ 'facets.total': "Rivejä yhteensä {n}",
393
395
  'smart.placeholder': "Hae tai suodata…",
394
396
  'smart.active': "Käytössä olevat suodattimet",
395
397
  'smart.remove': "Poista suodatin {filter}",
package/src/i18n/fr.ts CHANGED
@@ -390,6 +390,8 @@ export const fr: Record<MessageKey, string> = {
390
390
  'facets.stars_up': '{n} étoiles et plus',
391
391
  'facets.star_up': '1 étoile et plus',
392
392
  'facets.clear_short': 'Effacer',
393
+ 'facets.show_all': 'Tout afficher ({n})',
394
+ 'facets.total': 'Nombre total de lignes {n}',
393
395
  'smart.placeholder': 'Rechercher ou filtrer…',
394
396
  'smart.active': 'Filtres actifs',
395
397
  'smart.remove': 'Supprimer le filtre {filter}',
package/src/i18n/he.ts CHANGED
@@ -390,6 +390,8 @@ export const he: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} כוכבים ומעלה",
391
391
  'facets.star_up': "כוכב אחד ומעלה",
392
392
  'facets.clear_short': "ניקוי",
393
+ 'facets.show_all': "הצג הכול ({n})",
394
+ 'facets.total': "סך כל השורות {n}",
393
395
  'smart.placeholder': "חיפוש או סינון…",
394
396
  'smart.active': "מסננים בשימוש",
395
397
  'smart.remove': "הסרת המסנן {filter}",
package/src/i18n/it.ts CHANGED
@@ -390,6 +390,8 @@ export const it: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stelle e più",
391
391
  'facets.star_up': "1 stella e più",
392
392
  'facets.clear_short': "Cancella",
393
+ 'facets.show_all': "Mostra tutto ({n})",
394
+ 'facets.total': "Numero totale di righe {n}",
393
395
  'smart.placeholder': "Cerca o filtra…",
394
396
  'smart.active': "Filtri attivi",
395
397
  'smart.remove': "Rimuovi il filtro {filter}",
package/src/i18n/ja.ts CHANGED
@@ -390,6 +390,8 @@ export const ja: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "星 {n} つ以上",
391
391
  'facets.star_up': "星 1 つ以上",
392
392
  'facets.clear_short': "クリア",
393
+ 'facets.show_all': "すべて表示 ({n})",
394
+ 'facets.total': "合計行数 {n}",
393
395
  'smart.placeholder': "検索またはフィルター…",
394
396
  'smart.active': "使用中のフィルター",
395
397
  'smart.remove': "フィルター {filter} を削除",
package/src/i18n/ko.ts CHANGED
@@ -390,6 +390,8 @@ export const ko: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "별 {n}개 이상",
391
391
  'facets.star_up': "별 1개 이상",
392
392
  'facets.clear_short': "지우기",
393
+ 'facets.show_all': "모두 표시 ({n})",
394
+ 'facets.total': "전체 행 수 {n}",
393
395
  'smart.placeholder': "검색 또는 필터…",
394
396
  'smart.active': "사용 중인 필터",
395
397
  'smart.remove': "{filter} 필터 제거",
package/src/i18n/nb.ts CHANGED
@@ -390,6 +390,8 @@ export const nb: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stjerner og opp",
391
391
  'facets.star_up': "1 stjerne og opp",
392
392
  'facets.clear_short': "Fjern",
393
+ 'facets.show_all': "Vis alle ({n})",
394
+ 'facets.total': "Totalt antall rader {n}",
393
395
  'smart.placeholder': "Søk eller filtrer…",
394
396
  'smart.active': "Filtre i bruk",
395
397
  'smart.remove': "Fjern filteret {filter}",
package/src/i18n/pl.ts CHANGED
@@ -390,6 +390,8 @@ export const pl: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} gwiazdki i więcej",
391
391
  'facets.star_up': "1 gwiazdka i więcej",
392
392
  'facets.clear_short': "Wyczyść",
393
+ 'facets.show_all': "Pokaż wszystkie ({n})",
394
+ 'facets.total': "Łączna liczba wierszy {n}",
393
395
  'smart.placeholder': "Szukaj lub filtruj…",
394
396
  'smart.active': "Używane filtry",
395
397
  'smart.remove': "Usuń filtr {filter}",
package/src/i18n/pt.ts CHANGED
@@ -390,6 +390,8 @@ export const pt: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} estrelas ou mais",
391
391
  'facets.star_up': "1 estrela ou mais",
392
392
  'facets.clear_short': "Limpar",
393
+ 'facets.show_all': "Mostrar tudo ({n})",
394
+ 'facets.total': "Total de linhas {n}",
393
395
  'smart.placeholder': "Pesquisar ou filtrar…",
394
396
  'smart.active': "Filtros em uso",
395
397
  'smart.remove': "Remover o filtro {filter}",
package/src/i18n/ru.ts CHANGED
@@ -390,6 +390,8 @@ export const ru: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} звезды и выше",
391
391
  'facets.star_up': "1 звезда и выше",
392
392
  'facets.clear_short': "Очистить",
393
+ 'facets.show_all': "Показать все ({n})",
394
+ 'facets.total': "Всего строк {n}",
393
395
  'smart.placeholder': "Поиск или фильтр…",
394
396
  'smart.active': "Действующие фильтры",
395
397
  'smart.remove': "Удалить фильтр {filter}",
package/src/i18n/sv.ts CHANGED
@@ -390,6 +390,8 @@ export const sv: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} stjärnor och uppåt",
391
391
  'facets.star_up': "1 stjärna och uppåt",
392
392
  'facets.clear_short': "Rensa",
393
+ 'facets.show_all': "Visa alla ({n})",
394
+ 'facets.total': "Totalt antal rader {n}",
393
395
  'smart.placeholder': "Sök eller filtrera…",
394
396
  'smart.active': "Filter som används",
395
397
  'smart.remove': "Ta bort filtret {filter}",
package/src/i18n/tr.ts CHANGED
@@ -390,6 +390,8 @@ export const tr: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} yıldız ve üzeri",
391
391
  'facets.star_up': "1 yıldız ve üzeri",
392
392
  'facets.clear_short': "Temizle",
393
+ 'facets.show_all': "Tümünü göster ({n})",
394
+ 'facets.total': "Toplam satır sayısı {n}",
393
395
  'smart.placeholder': "Ara veya süz…",
394
396
  'smart.active': "Kullanılan süzgeçler",
395
397
  'smart.remove': "{filter} süzgecini kaldır",
package/src/i18n/uk.ts CHANGED
@@ -390,6 +390,8 @@ export const uk: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} зірки й вище",
391
391
  'facets.star_up': "1 зірка й вище",
392
392
  'facets.clear_short': "Очистити",
393
+ 'facets.show_all': "Показати все ({n})",
394
+ 'facets.total': "Усього рядків {n}",
393
395
  'smart.placeholder': "Пошук або фільтр…",
394
396
  'smart.active': "Чинні фільтри",
395
397
  'smart.remove': "Видалити фільтр {filter}",
package/src/i18n/zh.ts CHANGED
@@ -390,6 +390,8 @@ export const zh: Record<MessageKey, string> = {
390
390
  'facets.stars_up': "{n} 星及以上",
391
391
  'facets.star_up': "1 星及以上",
392
392
  'facets.clear_short': "清除",
393
+ 'facets.show_all': "显示全部 ({n})",
394
+ 'facets.total': "总行数 {n}",
393
395
  'smart.placeholder': "搜索或筛选…",
394
396
  'smart.active': "正在使用的筛选器",
395
397
  'smart.remove': "删除筛选器 {filter}",
package/src/i18n.ts CHANGED
@@ -417,6 +417,8 @@ const en = {
417
417
  'facets.stars_up': '{n} stars and up',
418
418
  'facets.star_up': '1 star and up',
419
419
  'facets.clear_short': 'Clear',
420
+ 'facets.show_all': 'Show all ({n})',
421
+ 'facets.total': 'Total row count {n}',
420
422
  'smart.placeholder': 'Search or filter…',
421
423
  'smart.active': 'Filters in use',
422
424
  'smart.remove': 'Remove filter {filter}',
@@ -1121,6 +1123,8 @@ const nl: Record<MessageKey, string> = {
1121
1123
  'facets.stars_up': '{n} sterren of meer',
1122
1124
  'facets.star_up': '1 ster of meer',
1123
1125
  'facets.clear_short': 'Wissen',
1126
+ 'facets.show_all': 'Alle tonen ({n})',
1127
+ 'facets.total': 'Totaal aantal rijen {n}',
1124
1128
  'smart.placeholder': 'Zoeken of filteren…',
1125
1129
  'smart.active': 'Gebruikte filters',
1126
1130
  'smart.remove': 'Filter {filter} verwijderen',
@@ -7,7 +7,7 @@ import { forgetAllRemembered, rememberCookie, rememberedCount } from '../remembe
7
7
  import { getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
8
8
  import { loginWindowMinutes } from '../security.ts';
9
9
  import type { PageContext } from './context.ts';
10
- import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
10
+ import { databaseTimeZone, isTheme, matchLanguage, sameOffset, THEME_COOKIE, themeCookieOptions, timeZoneFor, timeZoneNames, validTimeZone } from './locale.ts';
11
11
  import { chrome, clientTexts } from './render.ts';
12
12
  import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
13
13
  import { loadApp } from '../metadata.ts';
@@ -28,8 +28,8 @@ async function saveStyle(appId: number, username: string, style: string) {
28
28
  );
29
29
  }
30
30
 
31
- const themeCookie = (reply: FastifyReply, theme: string) =>
32
- reply.setCookie(THEME_COOKIE, theme, { path: '/', sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
31
+ // (sprint 79) per application: a choice in one application doesn't change another's mode
32
+ const themeCookie = (reply: FastifyReply, theme: string, base: string) => reply.setCookie(THEME_COOKIE, theme, themeCookieOptions(base));
33
33
 
34
34
  /** My account → Time zone: automatic (the browser's) or one of PostgreSQL's time zones. */
35
35
  async function timeZoneField(ctx: PageContext) {
@@ -160,7 +160,7 @@ export async function accountRoutes(app: FastifyInstance) {
160
160
  }
161
161
  if (theme) {
162
162
  ctx.session.state.__THEME = theme;
163
- themeCookie(reply, theme);
163
+ themeCookie(reply, theme, ctx.base);
164
164
  }
165
165
  if (language) ctx.session.state.__LANG = language;
166
166
  // a style variant: only one of the app's own styles ('' = the base colours)
@@ -263,7 +263,7 @@ export async function accountRoutes(app: FastifyInstance) {
263
263
  const to = safeNext(a, b.next);
264
264
  if (b.__csrf !== session.csrf_token || a.theme?.user_choice === false || !isTheme(b.theme)) return reply.redirect(to, 303);
265
265
  session.state.__THEME = b.theme;
266
- themeCookie(reply, b.theme);
266
+ themeCookie(reply, b.theme, `/a/${a.alias}`);
267
267
  if (session.username) {
268
268
  await runtime.query('update meta.account set theme_pref = $2 where lower(username) = lower($1)', [session.username, b.theme]);
269
269
  }
@@ -86,26 +86,28 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
86
86
  const level = sessionDebugLevel(ctx.session);
87
87
  const appLevel = Number(ctx.app.debug_level ?? 0);
88
88
  const page = ctx.page.page_no;
89
- const tool = (href: string, label: string, name: string) =>
90
- html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}</a></li>`;
91
- const menu = (label: string, name: string, cls: string, body: Raw, hidden = false) =>
92
- html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}</summary>
89
+ // (sprint 79, UI-4; APEX: App 77536, Page 8, Debug ⏻) each tool with a short text; "Icons only" in Settings
90
+ const text = (s: string) => html`<span class="devbar-text" aria-hidden="true">${s}</span>`;
91
+ const tool = (href: string, label: string, name: string, short: string) =>
92
+ html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</a></li>`;
93
+ const menu = (label: string, name: string, cls: string, body: Raw, hidden = false, short = label) =>
94
+ html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}${text(short)}</summary>
93
95
  <div class="devbar-pop" role="group" aria-label="${label}">${body}</div></details></li>`;
94
96
  return html`<div class="devbar" data-dock="bottom">
95
97
  <nav aria-label="Developer toolbar"><ul class="devbar-tools">
96
- ${tool(BUILDER, 'App Builder', 'home')}
97
- ${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid')}
98
- ${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit')}
99
- ${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database')}
100
- ${menu('Debug', 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
98
+ ${tool(BUILDER, 'App Builder', 'home', 'Home')}
99
+ ${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid', `App ${ctx.app.id}`)}
100
+ ${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit', `Page ${page}`)}
101
+ ${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database', 'Session')}
102
+ ${menu(`Debug: ${level || appLevel ? 'on' : 'off'}`, 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
101
103
  <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
102
104
  <label for="devbar-debug">Debug messages</label>
103
105
  <select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
104
106
  html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
105
107
  <button>Set</button>
106
- </form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`)}
107
- <li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}</button></li>
108
- ${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders')}
108
+ </form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`, false, `Debug ${level || appLevel ? 'on' : 'off'}`)}
109
+ <li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}${text('Quick edit')}</button></li>
110
+ ${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders', 'Customize')}
109
111
  ${menu('Info', 'info', 'devbar-info', html`<dl class="devbar-facts">
110
112
  <dt>Application</dt><dd>${ctx.app.name} (${ctx.app.id})</dd>
111
113
  <dt>Page</dt><dd>${page}: ${ctx.page.name}</dd>
@@ -115,7 +117,8 @@ export function devbarHtml(ctx: PageContext): Raw | '' {
115
117
  ${menu('Settings', 'settings', 'devbar-settings', html`<fieldset class="devbar-dock"><legend>Position</legend>
116
118
  ${(['bottom', 'top', 'left', 'right'] as const).map((d) => html`<label><input type="radio" name="devbar-dock" value="${d}"${d === 'bottom' ? raw(' checked') : ''}> ${d[0].toUpperCase() + d.slice(1)}</label>`)}
117
119
  </fieldset>
118
- <label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>`, true)}
120
+ <label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>
121
+ <label class="devbar-autohide"><input type="checkbox" name="devbar-compact"> Icons only</label>`, true)}
119
122
  </ul></nav>
120
123
  </div>`;
121
124
  }
@@ -99,6 +99,20 @@ export async function rangeCounts(ctx: PageContext, fs: FacetSource, f: FacetDef
99
99
  return res.rows[0] as number[];
100
100
  }
101
101
 
102
+ /** (sprint 78) Checkbox facet values shown before "Show all" (APEX shows seven). */
103
+ export const FACET_SHOWN = 7;
104
+
105
+ /** (sprint 78) The rows the report shows: the search and every facet's filter. */
106
+ export async function totalRows(ctx: PageContext, fs: FacetSource): Promise<number> {
107
+ const p = new SqlParams();
108
+ const where = othersWhere(ctx, fs, '', p);
109
+ const c = ctx.client!;
110
+ const res = await savepoint(c, () =>
111
+ c.query<{ n: number }>({ text: `select count(*)::int as n from (\n${fs.src}\n) "__q"${where.length ? ` where ${where.join(' and ')}` : ''}`, values: queryValues(p.values) }),
112
+ );
113
+ return res.rows[0].n;
114
+ }
115
+
102
116
  export const facetLabel = (ctx: PageContext, f: FacetDef) => f.label ?? ctx.locale.tr(heading(f.column));
103
117
 
104
118
  /** A star rating drawn with stars, read as "4 stars and up". */
@@ -156,15 +170,24 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
156
170
  // keep selected values visible even when their count dropped to 0
157
171
  for (const s of selected) if (s && !values.some((x) => x.v === s)) values.push({ v: s, n: 0 });
158
172
  const excluded = f.exclude && ctx.params.get(k.exclude + f.column) === '1';
173
+ // (sprint 78) a boolean's values as Yes and No, not true and false
174
+ const bool = fs.cols.get(f.column) === 16;
175
+ const shown = (v: string) => (bool && (v === 'true' || v === 'false') ? t(v === 'true' ? 'item.yes' : 'item.no') : (toState(v) ?? ''));
159
176
  const chart = facetChart(ctx, report, f, label, values.map((x) => ({
160
- label: toState(x.v) ?? '', n: x.n,
177
+ label: shown(x.v), n: x.n,
161
178
  apply: (p) => { p.delete(k.values + f.column); p.delete(k.exclude + f.column); p.append(k.values + f.column, x.v); },
162
179
  })));
180
+ const value = (x: { v: string; n: number }) =>
181
+ html`<label class="check facet-value"><input type="checkbox" form="${formId}" name="${k.values}${f.column}" value="${x.v}"${selected.has(x.v) ? raw(' checked') : ''} data-facet>
182
+ <span class="facet-label">${shown(x.v)}</span><span class="facet-count">${x.n}</span></label>`;
163
183
  groups.push(html`<fieldset class="facet${excluded ? ' facet-excluded' : ''}">
164
184
  <legend>${label}${clearLink(f)}</legend>
165
185
  ${chart}
166
- ${values.map((x) => html`<label class="check facet-value"><input type="checkbox" form="${formId}" name="${k.values}${f.column}" value="${x.v}"${selected.has(x.v) ? raw(' checked') : ''} data-facet>
167
- <span class="facet-label">${toState(x.v)}</span><span class="facet-count">${x.n}</span></label>`)}
186
+ ${values.slice(0, FACET_SHOWN).map(value)}
187
+ ${values.length > FACET_SHOWN
188
+ ? html`<details class="facet-more"${values.slice(FACET_SHOWN).some((x) => selected.has(x.v)) ? raw(' open') : ''}><summary>${t('facets.show_all', { n: String(values.length) })}</summary>
189
+ ${values.slice(FACET_SHOWN).map(value)}</details>`
190
+ : ''}
168
191
  ${f.exclude
169
192
  ? html`<label class="check facet-exclude"><input type="checkbox" form="${formId}" name="${k.exclude}${f.column}" value="1"${excluded ? raw(' checked') : ''} data-facet> ${t('facets.exclude')}</label>`
170
193
  : ''}
@@ -225,7 +248,15 @@ export async function renderFacets(ctx: PageContext, r: Region): Promise<Raw> {
225
248
  html`<form id="${formId}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([name, v]) => html`<input type="hidden" name="${name}" value="${v}">`)}</form>`,
226
249
  );
227
250
  const any = defs.some((f) => active.has(f.column)) || (r.config.search === true && fs.search !== '');
251
+ // (sprint 78; APEX: the results' "Total Row Count") the rows the filters leave
252
+ let total: number | null = null;
253
+ try {
254
+ total = await totalRows(ctx, fs);
255
+ } catch {
256
+ total = null; // the facets' own errors are shown above
257
+ }
228
258
  return html`<div class="facets">
259
+ ${total !== null ? html`<p class="facet-total" role="status">${t('facets.total', { n: total.toLocaleString(ctx.locale.lang) })}</p>` : ''}
229
260
  ${groups}
230
261
  <div class="buttons facet-actions">
231
262
  <button class="btn btn-hot" form="${formId}">${t('report.apply')}</button>