pgkiln 0.42.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.
Files changed (60) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/db/migrations/100_generated_labels.sql +381 -0
  3. package/db/migrations/101_theme_choice.sql +15 -0
  4. package/docs/apex-feature-parity.md +36 -9
  5. package/docs/guide/03-builder.md +82 -21
  6. package/docs/guide/04-pages-and-regions.md +55 -5
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +5 -1
  9. package/docs/guide/12-development.md +2 -1
  10. package/docs/guide/14-globalization.md +5 -1
  11. package/package.json +1 -1
  12. package/public/app.css +80 -0
  13. package/public/app.js +189 -1
  14. package/public/builder.css +112 -0
  15. package/public/builder.js +280 -3
  16. package/src/builder/appsheets.ts +53 -13
  17. package/src/builder/appwizard.ts +5 -3
  18. package/src/builder/arrange.ts +133 -17
  19. package/src/builder/components.ts +28 -22
  20. package/src/builder/designer.ts +67 -18
  21. package/src/builder/forms.ts +4 -1
  22. package/src/builder/object-browser.ts +342 -0
  23. package/src/builder/pwa.ts +3 -3
  24. package/src/builder/routes.ts +85 -26
  25. package/src/builder/shared.ts +103 -1
  26. package/src/builder/sql.ts +13 -65
  27. package/src/builder/ui.ts +21 -6
  28. package/src/i18n/ar.ts +18 -0
  29. package/src/i18n/cs.ts +18 -0
  30. package/src/i18n/da.ts +18 -0
  31. package/src/i18n/de.ts +18 -0
  32. package/src/i18n/el.ts +18 -0
  33. package/src/i18n/es.ts +18 -0
  34. package/src/i18n/fi.ts +18 -0
  35. package/src/i18n/fr.ts +18 -0
  36. package/src/i18n/he.ts +18 -0
  37. package/src/i18n/it.ts +18 -0
  38. package/src/i18n/ja.ts +18 -0
  39. package/src/i18n/ko.ts +18 -0
  40. package/src/i18n/nb.ts +18 -0
  41. package/src/i18n/pl.ts +18 -0
  42. package/src/i18n/pt.ts +18 -0
  43. package/src/i18n/ru.ts +18 -0
  44. package/src/i18n/sv.ts +18 -0
  45. package/src/i18n/tr.ts +18 -0
  46. package/src/i18n/uk.ts +18 -0
  47. package/src/i18n/zh.ts +18 -0
  48. package/src/i18n.ts +36 -0
  49. package/src/metadata.ts +5 -1
  50. package/src/runtime/account.ts +5 -5
  51. package/src/runtime/devbar.ts +16 -13
  52. package/src/runtime/engine.ts +6 -0
  53. package/src/runtime/facets.ts +34 -3
  54. package/src/runtime/format.ts +52 -0
  55. package/src/runtime/items.ts +27 -0
  56. package/src/runtime/locale.ts +7 -1
  57. package/src/runtime/regions.ts +36 -2
  58. package/src/runtime/render.ts +27 -6
  59. package/src/runtime/report.ts +141 -81
  60. package/src/runtime/routes.ts +39 -7
package/src/i18n/tr.ts CHANGED
@@ -111,6 +111,21 @@ export const tr: Record<MessageKey, string> = {
111
111
  'color.blue': "Mavi",
112
112
  'color.gray': "Gri",
113
113
  'report.saved_reports': "Kayıtlı raporlar",
114
+ 'report.first': "İlk sayfa",
115
+ 'report.last': "Son sayfa",
116
+ 'report.hidden_column': "Gizli",
117
+ 'report.show_column': "Sütunu yeniden göster",
118
+ 'report.search_column': "Şurada ara",
119
+ 'report.all_columns': "Tüm sütunlar",
120
+ 'report.columns': "Sütunlar",
121
+ 'report.hide_columns': "Gizlenecek sütunlar",
122
+ 'report.data': "Veri",
123
+ 'report.format': "Biçim",
124
+ 'report.report': "Rapor",
125
+ 'report.download_menu': "İndir",
126
+ 'report.hide': "Sütunu gizle",
127
+ 'report.filter_values': "Değerleri filtrele",
128
+ 'report.no_values': "Değer yok",
114
129
  'report.save_as': "Farklı kaydet…",
115
130
  'report.name': "Ad",
116
131
  'report.save': "Kaydet",
@@ -154,6 +169,7 @@ export const tr: Record<MessageKey, string> = {
154
169
  'pwa.status.invalid': "reddedildi: sayfayı açıp düzeltin",
155
170
  'pwa.status.error': "gönderilmedi",
156
171
  'item.locate': "Konumumu kullan",
172
+ 'item.choose_date': "Bir tarih seçin",
157
173
  'item.show_map': "harita",
158
174
  'item.scan': "Tara",
159
175
  'item.locate_error': "Konum kullanılamıyor.",
@@ -374,6 +390,8 @@ export const tr: Record<MessageKey, string> = {
374
390
  'facets.stars_up': "{n} yıldız ve üzeri",
375
391
  'facets.star_up': "1 yıldız ve üzeri",
376
392
  'facets.clear_short': "Temizle",
393
+ 'facets.show_all': "Tümünü göster ({n})",
394
+ 'facets.total': "Toplam satır sayısı {n}",
377
395
  'smart.placeholder': "Ara veya süz…",
378
396
  'smart.active': "Kullanılan süzgeçler",
379
397
  'smart.remove': "{filter} süzgecini kaldır",
package/src/i18n/uk.ts CHANGED
@@ -111,6 +111,21 @@ export const uk: Record<MessageKey, string> = {
111
111
  'color.blue': "Синій",
112
112
  'color.gray': "Сірий",
113
113
  'report.saved_reports': "Збережені звіти",
114
+ 'report.first': "Перша сторінка",
115
+ 'report.last': "Остання сторінка",
116
+ 'report.hidden_column': "Прихований",
117
+ 'report.show_column': "Знову показати стовпець",
118
+ 'report.search_column': "Шукати в",
119
+ 'report.all_columns': "Усі стовпці",
120
+ 'report.columns': "Стовпці",
121
+ 'report.hide_columns': "Приховати стовпці",
122
+ 'report.data': "Дані",
123
+ 'report.format': "Формат",
124
+ 'report.report': "Звіт",
125
+ 'report.download_menu': "Завантажити",
126
+ 'report.hide': "Приховати стовпець",
127
+ 'report.filter_values': "Фільтр значень",
128
+ 'report.no_values': "Немає значень",
114
129
  'report.save_as': "Зберегти як…",
115
130
  'report.name': "Назва",
116
131
  'report.save': "Зберегти",
@@ -154,6 +169,7 @@ export const uk: Record<MessageKey, string> = {
154
169
  'pwa.status.invalid': "відхилено: відкрийте сторінку й виправте",
155
170
  'pwa.status.error': "не надіслано",
156
171
  'item.locate': "Моє розташування",
172
+ 'item.choose_date': "Вибрати дату",
157
173
  'item.show_map': "карта",
158
174
  'item.scan': "Сканувати",
159
175
  'item.locate_error': "Розташування недоступне.",
@@ -374,6 +390,8 @@ export const uk: Record<MessageKey, string> = {
374
390
  'facets.stars_up': "{n} зірки й вище",
375
391
  'facets.star_up': "1 зірка й вище",
376
392
  'facets.clear_short': "Очистити",
393
+ 'facets.show_all': "Показати все ({n})",
394
+ 'facets.total': "Усього рядків {n}",
377
395
  'smart.placeholder': "Пошук або фільтр…",
378
396
  'smart.active': "Чинні фільтри",
379
397
  'smart.remove': "Видалити фільтр {filter}",
package/src/i18n/zh.ts CHANGED
@@ -111,6 +111,21 @@ export const zh: Record<MessageKey, string> = {
111
111
  'color.blue': "蓝色",
112
112
  'color.gray': "灰色",
113
113
  'report.saved_reports': "已保存的报表",
114
+ 'report.first': "第一页",
115
+ 'report.last': "最后一页",
116
+ 'report.hidden_column': "已隐藏",
117
+ 'report.show_column': "重新显示该列",
118
+ 'report.search_column': "搜索范围",
119
+ 'report.all_columns': "所有列",
120
+ 'report.columns': "列",
121
+ 'report.hide_columns': "要隐藏的列",
122
+ 'report.data': "数据",
123
+ 'report.format': "格式",
124
+ 'report.report': "报表",
125
+ 'report.download_menu': "下载",
126
+ 'report.hide': "隐藏列",
127
+ 'report.filter_values': "筛选值",
128
+ 'report.no_values': "没有值",
114
129
  'report.save_as': "另存为…",
115
130
  'report.name': "名称",
116
131
  'report.save': "保存",
@@ -154,6 +169,7 @@ export const zh: Record<MessageKey, string> = {
154
169
  'pwa.status.invalid': "被拒绝:请打开页面并更正",
155
170
  'pwa.status.error': "未发送",
156
171
  'item.locate': "使用我的位置",
172
+ 'item.choose_date': "选择日期",
157
173
  'item.show_map': "地图",
158
174
  'item.scan': "扫描",
159
175
  'item.locate_error': "无法获取位置。",
@@ -374,6 +390,8 @@ export const zh: Record<MessageKey, string> = {
374
390
  'facets.stars_up': "{n} 星及以上",
375
391
  'facets.star_up': "1 星及以上",
376
392
  'facets.clear_short': "清除",
393
+ 'facets.show_all': "显示全部 ({n})",
394
+ 'facets.total': "总行数 {n}",
377
395
  'smart.placeholder': "搜索或筛选…",
378
396
  'smart.active': "正在使用的筛选器",
379
397
  'smart.remove': "删除筛选器 {filter}",
package/src/i18n.ts CHANGED
@@ -138,6 +138,21 @@ const en = {
138
138
  'color.blue': 'Blue',
139
139
  'color.gray': 'Gray',
140
140
  'report.saved_reports': 'Saved reports',
141
+ 'report.first': "First page",
142
+ 'report.last': "Last page",
143
+ 'report.hidden_column': "Hidden",
144
+ 'report.show_column': "Show the column again",
145
+ 'report.search_column': "Search in",
146
+ 'report.all_columns': "All columns",
147
+ 'report.columns': "Columns",
148
+ 'report.hide_columns': "Columns to hide",
149
+ 'report.data': "Data",
150
+ 'report.format': "Format",
151
+ 'report.report': "Report",
152
+ 'report.download_menu': "Download",
153
+ 'report.hide': "Hide column",
154
+ 'report.filter_values': "Filter the values",
155
+ 'report.no_values': "No values",
141
156
  'report.save_as': 'Save as…',
142
157
  'report.name': 'Name',
143
158
  'report.save': 'Save',
@@ -181,6 +196,7 @@ const en = {
181
196
  'pwa.status.invalid': 'rejected: open the page and correct it',
182
197
  'pwa.status.error': 'not sent',
183
198
  'item.locate': 'Use my location',
199
+ 'item.choose_date': "Choose a date",
184
200
  'item.show_map': 'map',
185
201
  'item.scan': 'Scan',
186
202
  'item.locate_error': 'The location is not available.',
@@ -401,6 +417,8 @@ const en = {
401
417
  'facets.stars_up': '{n} stars and up',
402
418
  'facets.star_up': '1 star and up',
403
419
  'facets.clear_short': 'Clear',
420
+ 'facets.show_all': 'Show all ({n})',
421
+ 'facets.total': 'Total row count {n}',
404
422
  'smart.placeholder': 'Search or filter…',
405
423
  'smart.active': 'Filters in use',
406
424
  'smart.remove': 'Remove filter {filter}',
@@ -827,6 +845,21 @@ const nl: Record<MessageKey, string> = {
827
845
  'color.blue': 'Blauw',
828
846
  'color.gray': 'Grijs',
829
847
  'report.saved_reports': 'Opgeslagen rapporten',
848
+ 'report.first': "Eerste pagina",
849
+ 'report.last': "Laatste pagina",
850
+ 'report.hidden_column': "Verborgen",
851
+ 'report.show_column': "Kolom weer tonen",
852
+ 'report.search_column': "Zoeken in",
853
+ 'report.all_columns': "Alle kolommen",
854
+ 'report.columns': "Kolommen",
855
+ 'report.hide_columns': "Kolommen verbergen",
856
+ 'report.data': "Gegevens",
857
+ 'report.format': "Opmaak",
858
+ 'report.report': "Rapport",
859
+ 'report.download_menu': "Downloaden",
860
+ 'report.hide': "Kolom verbergen",
861
+ 'report.filter_values': "Waarden filteren",
862
+ 'report.no_values': "Geen waarden",
830
863
  'report.save_as': 'Opslaan als…',
831
864
  'report.name': 'Naam',
832
865
  'report.save': 'Opslaan',
@@ -870,6 +903,7 @@ const nl: Record<MessageKey, string> = {
870
903
  'pwa.status.invalid': 'geweigerd: open de pagina en verbeter het',
871
904
  'pwa.status.error': 'niet verzonden',
872
905
  'item.locate': 'Mijn locatie',
906
+ 'item.choose_date': "Kies een datum",
873
907
  'item.show_map': 'kaart',
874
908
  'item.scan': 'Scannen',
875
909
  'item.locate_error': 'De locatie is niet beschikbaar.',
@@ -1089,6 +1123,8 @@ const nl: Record<MessageKey, string> = {
1089
1123
  'facets.stars_up': '{n} sterren of meer',
1090
1124
  'facets.star_up': '1 ster of meer',
1091
1125
  'facets.clear_short': 'Wissen',
1126
+ 'facets.show_all': 'Alle tonen ({n})',
1127
+ 'facets.total': 'Totaal aantal rijen {n}',
1092
1128
  'smart.placeholder': 'Zoeken of filteren…',
1093
1129
  'smart.active': 'Gebruikte filters',
1094
1130
  'smart.remove': 'Filter {filter} verwijderen',
package/src/metadata.ts CHANGED
@@ -154,6 +154,8 @@ export interface Region {
154
154
  rest_source?: string | null;
155
155
  /** (053) template options: CSS classes from REGION_OPTIONS (others are ignored) */
156
156
  template_options?: string[];
157
+ /** (099) where the region shows: banner, breadcrumb, left, body (null), right or footer */
158
+ position?: 'banner' | 'breadcrumb' | 'left' | 'body' | 'right' | 'footer' | null;
157
159
  }
158
160
 
159
161
  export type ItemType =
@@ -205,6 +207,8 @@ export interface Button {
205
207
  badge_query?: string | null;
206
208
  /** (053) template options: CSS classes from BUTTON_OPTIONS (others are ignored) */
207
209
  template_options?: string[];
210
+ /** (099) a region button's position: create, edit, change, copy, help, up, previous, close, delete, next or region (null) */
211
+ slot?: string | null;
208
212
  }
209
213
 
210
214
  export interface MenuEntry {
@@ -328,7 +332,7 @@ export interface Page extends PageSummary {
328
332
  /** (077) ask before leaving the page with unsaved changes */
329
333
  warn_unsaved: boolean;
330
334
  /** (087) 'minimal': no header and navigation */
331
- page_template: 'standard' | 'minimal';
335
+ page_template: 'standard' | 'minimal' | 'left_side_column' | 'right_side_column';
332
336
  }
333
337
 
334
338
  /** The static files a list of names refers to, in that order, with their version. */
@@ -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
  }
@@ -198,6 +198,12 @@ export async function validate(ctx: PageContext) {
198
198
  const example = mask ? formatNumber('1234.5', mask, ctx.locale.numbers) : undefined;
199
199
  fail(i.name, example && !example.startsWith('#') ? ctx.locale.t('error.number_format', { label, example }) : ctx.locale.t('error.not_number', { label }));
200
200
  }
201
+ // (sprint 76) a date item holds a date (one in the application's date format was read back on submit)
202
+ for (const i of deleting ? [] : ctx.page.items) {
203
+ const v = state[i.name];
204
+ if (i.type !== 'date' || !v || !vis.editable.has(i.name) || errors.items[i.name] || /^\d{4}-\d{2}-\d{2}$/.test(v)) continue;
205
+ fail(i.name, ctx.locale.t('error.not_date', { label: i.label ?? i.name }));
206
+ }
201
207
  for (const i of deleting ? [] : ctx.page.items) {
202
208
  const v = state[i.name];
203
209
  if (i.type !== 'location' || !v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
@@ -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>
@@ -74,6 +74,58 @@ export function applyMask(value: string, mask: string, lang: string) {
74
74
  });
75
75
  }
76
76
 
77
+ /** (sprint 76) The month names of a language (long and short), for the date picker. */
78
+ export function monthNames(lang: string) {
79
+ const n = localeNames(lang);
80
+ return { long: n.months, short: n.monthsShort };
81
+ }
82
+
83
+ /**
84
+ * (sprint 76) Read a date typed in a date mask back as "YYYY-MM-DD": YYYY, YY (2000–2099), MM, DD, MON and
85
+ * MONTH (the language's names, any case), DY and DAY (names, not checked), quoted text and other characters as
86
+ * they are. An ISO date is read too. Null when the text doesn't fit the mask or isn't a real date.
87
+ */
88
+ export function parseDateMask(value: string, mask: string, lang: string): string | null {
89
+ const v = value.trim();
90
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
91
+ const n = localeNames(lang);
92
+ let y: number | undefined, mo: number | undefined, d: number | undefined;
93
+ if (iso) [y, mo, d] = iso.slice(1).map(Number);
94
+ else {
95
+ const parts: string[] = [];
96
+ const groups: string[] = [];
97
+ let last = 0;
98
+ const esc = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ for (const m of mask.matchAll(TOKEN)) {
100
+ parts.push(esc(mask.slice(last, m.index)));
101
+ last = m.index! + m[0].length;
102
+ const tok = m[0];
103
+ if (tok.startsWith('"')) { parts.push(esc(tok.slice(1, -1))); continue; }
104
+ const up = tok.toUpperCase();
105
+ const names = (list: string[]) => `(${list.map(esc).join('|')})`;
106
+ const re: Record<string, string> = { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(n.months), MON: names(n.monthsShort), DAY: names(n.days), DY: names(n.daysShort) };
107
+ if (!re[up]) return null; // a time in a date mask: not a date item's
108
+ parts.push(re[up]);
109
+ groups.push(up);
110
+ }
111
+ parts.push(esc(mask.slice(last)));
112
+ const m = new RegExp(`^${parts.join('')}$`, 'i').exec(v);
113
+ if (!m) return null;
114
+ groups.forEach((g, i) => {
115
+ const x = m[i + 1];
116
+ if (g === 'YYYY') y = Number(x);
117
+ else if (g === 'YY') y = 2000 + Number(x);
118
+ else if (g === 'MM') mo = Number(x);
119
+ else if (g === 'DD') d = Number(x);
120
+ else if (g === 'MONTH' || g === 'MON') mo = (g === 'MONTH' ? n.months : n.monthsShort).findIndex((name) => name.toLowerCase() === x.toLowerCase()) + 1;
121
+ });
122
+ }
123
+ if (!y || !mo || !d) return null;
124
+ const date = new Date(Date.UTC(y, mo - 1, d));
125
+ if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) return null;
126
+ return `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
127
+ }
128
+
77
129
  /** A formatter for dates and timestamps with the given masks (null: ISO, as sent by Postgres). */
78
130
  export function dateFormatter(lang: string, dateMask: string | null, timestampMask: string | null): Formatter {
79
131
  return (v, oid) => {
@@ -12,6 +12,7 @@ import { markdownHtml, sanitizeHtml } from '../richtext.ts';
12
12
  import { qrSvg, type Ecc } from '../qrcode.ts';
13
13
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
14
14
  import { addressControl, imageControl } from './data-items.ts';
15
+ import { applyMask, formatMaskError, monthNames } from './format.ts';
15
16
 
16
17
  const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
17
18
  export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
@@ -180,6 +181,17 @@ export const splitRange = (v: string | null | undefined): [string, string] => {
180
181
  return [from, to];
181
182
  };
182
183
 
184
+ /**
185
+ * (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask), else the
186
+ * application's date format; null: the browser's date field (in the browser's notation).
187
+ */
188
+ export function itemDateMask(ctx: PageContext, item: Item): string | null {
189
+ if (item.type !== 'date') return null;
190
+ const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
191
+ if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
192
+ return ctx.locale.dateMask?.trim() || null;
193
+ }
194
+
183
195
  /** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
184
196
  export function itemMask(item: Item): string | null {
185
197
  if (item.type !== 'number' && item.type !== 'display') return null;
@@ -383,6 +395,21 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
383
395
  control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
384
396
  <button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
385
397
  break;
398
+ case 'date': {
399
+ // (sprint 76) in the application's date format: a text field and a calendar button (app.js), read back on submit
400
+ const mask = itemDateMask(ctx, item);
401
+ if (!mask) {
402
+ control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
403
+ break;
404
+ }
405
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
406
+ const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
407
+ const months = monthNames(ctx.locale.lang);
408
+ control = html`<div class="input-with-button date-field"><input type="text" id="${id}" name="${id}" value="${shown}" placeholder="${mask}" autocomplete="off" data-date-mask="${mask}" data-months="${JSON.stringify(months)}"${aria}${editable ? '' : raw(' readonly')}>
409
+ ${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
410
+ <button type="button" class="btn" data-date-pick="${id}" aria-label="${ctx.locale.t('item.choose_date')}" title="${ctx.locale.t('item.choose_date')}">${icon('calendar')}</button>` : ''}</div>`;
411
+ break;
412
+ }
386
413
  default: {
387
414
  const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
388
415
  // a number with a format mask is text in the language's notation (1.234,50), read back on submit
@@ -31,6 +31,8 @@ export interface Locale {
31
31
  themeChoice: boolean;
32
32
  /** dates and timestamps for display (undefined: not a date) */
33
33
  format: Formatter;
34
+ /** (sprint 76) the application's own date format (or its FORMAT.DATE text message): date items show it with a picker; null: the browser's date field */
35
+ dateMask: string | null;
34
36
  number: Intl.NumberFormat;
35
37
  /** separators and currency for number format masks */
36
38
  numbers: NumberSymbols;
@@ -42,7 +44,10 @@ export interface Locale {
42
44
  timeZoneFrom: 'user' | 'browser' | 'app' | 'database';
43
45
  }
44
46
 
45
- export const THEME_COOKIE = 'pgkiln_theme';
47
+ /** (sprint 79) The light/dark choice of a user who is not signed in, per application (path /a/<alias>). */
48
+ export const THEME_COOKIE = 'pgkiln_mode';
49
+ /** The cookie's options: the application's path, a year. */
50
+ export const themeCookieOptions = (base: string) => ({ path: base, sameSite: 'lax' as const, secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
46
51
  const THEMES: ThemeMode[] = ['auto', 'light', 'dark'];
47
52
  export const isTheme = (v: unknown): v is ThemeMode => THEMES.includes(v as ThemeMode);
48
53
 
@@ -202,6 +207,7 @@ export async function resolveLocale(req: FastifyRequest, app: App, session?: Ses
202
207
  style: baseStyleOf(app.theme) === 'standard' ? undefined : (baseStyleOf(app.theme) as 'iris' | 'lavender'),
203
208
  themeChoice: app.theme?.user_choice !== false,
204
209
  format,
210
+ dateMask: messages['FORMAT.DATE'] ?? app.date_format ?? null,
205
211
  number,
206
212
  numbers,
207
213
  masked: (m) => maskedFormatter(format, lang, numbers, m),
@@ -100,9 +100,9 @@ export async function renderButton(ctx: PageContext, b: Button) {
100
100
  return html`<button type="submit" class="${cls}" name="__request" value="${b.name}" data-button="${b.name}"${confirm}>${b.label}${badge}</button>`;
101
101
  }
102
102
 
103
- export async function buttonsFor(ctx: PageContext, regionId: number | null) {
103
+ export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true) {
104
104
  const out = [];
105
- for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0) out.push(await renderButton(ctx, b));
105
+ for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0 && only(b)) out.push(await renderButton(ctx, b));
106
106
  return out.some(Boolean) ? html`<div class="buttons">${out}</div>` : '';
107
107
  }
108
108
 
@@ -408,7 +408,13 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
408
408
  return body!;
409
409
  }
410
410
 
411
+ /** (099) Button positions in a region: its top bar, the bottom bar's left end, the bottom bar's right end. */
412
+ const TOP_SLOTS = new Set(['create', 'edit', 'change', 'copy', 'help', 'up']);
413
+ const LEFT_SLOTS = new Set(['previous', 'close', 'delete']);
414
+
411
415
  async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
416
+ const slotted = [...ctx.vis!.buttons.values()].some((b) => b.region_id === r.id && b.slot && b.slot !== 'region');
417
+ if (slotted && (r.template === 'standard' || r.template === 'collapsible' || r.template === 'plain')) return slottedShell(ctx, r, hidden, body);
412
418
  const buttons = await buttonsFor(ctx, r.id);
413
419
  const buttonsOnTop = r.type !== 'form' && r.type !== 'static';
414
420
  // (a grid renders its own Save button in its toolbar)
@@ -469,3 +475,31 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
469
475
  </section>`;
470
476
  }
471
477
 
478
+ /**
479
+ * (099) A region whose buttons have positions (APEX: Button Position): create, edit, change, copy, help and up in
480
+ * the header; previous, close and delete at the left end of the bottom bar, next at its right end; the others
481
+ * where they always go (the header for reports and charts, the bottom bar for forms and static content).
482
+ */
483
+ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
484
+ const onTop = r.type !== 'form' && r.type !== 'static';
485
+ const plain = (b: Button) => !b.slot || b.slot === 'region';
486
+ const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
487
+ const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
488
+ const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)));
489
+ const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
490
+ const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
491
+ const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
492
+ const titleId = `R${r.id}_title`;
493
+ const footer = left || right ? html`<footer class="region-footer region-footer-split"><div class="region-footer-start">${left}</div><div class="region-footer-end">${right}</div></footer>` : '';
494
+ if (r.template === 'plain')
495
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
496
+ ${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
497
+ const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
498
+ if (r.template === 'collapsible')
499
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
500
+ <details open><summary class="region-header">${header}</summary><div class="region-body">${body}</div>${footer}</details></section>`;
501
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
502
+ ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
503
+ <div class="region-body">${body}</div>${footer}
504
+ </section>`;
505
+ }