thatcher 1.0.7 → 1.0.34

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 (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +5 -41
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +1 -1
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. package/src/ui/validation-ui.js +0 -147
@@ -1,12 +1,14 @@
1
+ import { STAGE_COLORS, STATUS_COLORS } from './render-helpers.js';
2
+
1
3
  export function formatDate(ts, opts) {
2
- if (!ts) return '—';
4
+ if (!ts) return '-';
3
5
  const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
4
- if (isNaN(d)) return '—';
6
+ if (isNaN(d)) return '-';
5
7
  return d.toLocaleDateString('en-ZA', opts || { day: '2-digit', month: 'short', year: 'numeric' });
6
8
  }
7
9
 
8
10
  export function timeAgo(ts) {
9
- if (!ts) return '—';
11
+ if (!ts) return '-';
10
12
  const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
11
13
  const diff = Date.now() - d;
12
14
  if (diff < 60000) return 'just now';
@@ -17,7 +19,7 @@ export function timeAgo(ts) {
17
19
  }
18
20
 
19
21
  export function formatCurrency(amount, currency) {
20
- if (amount == null || amount === '') return '—';
22
+ if (amount == null || amount === '') return '-';
21
23
  const n = Number(amount);
22
24
  if (isNaN(n)) return String(amount);
23
25
  const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : 'R';
@@ -25,35 +27,28 @@ export function formatCurrency(amount, currency) {
25
27
  }
26
28
 
27
29
  export function formatPercent(val) {
28
- if (val == null || val === '') return '—';
30
+ if (val == null || val === '') return '-';
29
31
  const n = Number(val);
30
32
  if (isNaN(n)) return val;
31
33
  return (n > 1 ? n : Math.round(n * 100)) + '%';
32
34
  }
33
35
 
34
36
  export function stagePill(stage, stageConfig) {
35
- const STAGES = stageConfig || {
36
- info_gathering: { label: 'Info Gathering', color: '#e53935', bg: '#ffebee' },
37
- commencement: { label: 'Commencement', color: '#e65100', bg: '#fff3e0' },
38
- team_execution: { label: 'Team Execution', color: '#1565c0', bg: '#e3f2fd' },
39
- partner_review: { label: 'Partner Review', color: '#283593', bg: '#e8eaf6' },
40
- finalization: { label: 'Finalization', color: '#2e7d32', bg: '#e8f5e9' },
41
- closeout: { label: 'Close Out', color: '#33691e', bg: '#f1f8e9' },
42
- };
43
- const cfg = STAGES[stage];
44
- if (!cfg) return stage ? `<span style="background:var(--color-bg,#f5f5f5);color:var(--color-text-muted,#555);padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700">${stage}</span>` : '—';
37
+ const STAGES = stageConfig || STAGE_COLORS;
38
+ const raw = STAGES[stage];
39
+ // Normalize both the local {color,bg,label} shape and the shared
40
+ // STAGE_COLORS {text,bg,label} shape into {color,bg,label}.
41
+ const cfg = raw ? { label: raw.label, bg: raw.bg, color: raw.color || raw.text } : null;
42
+ if (!cfg) return stage ? `<span style="background:var(--color-bg,#f5f5f5);color:var(--color-text-muted,#555);padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700">${stage}</span>` : '-';
45
43
  return `<span style="background:${cfg.bg};color:${cfg.color};padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700;white-space:nowrap;border:1px solid ${cfg.color}44">${cfg.label}</span>`;
46
44
  }
47
45
 
48
- const STATUS_MAP = {
49
- active: ['#2e7d32', '#e8f5e9'],
46
+ // Role/extra status keys not present in the shared STATUS_COLORS table.
47
+ // Shared statuses (active, pending, draft, closed, overdue, responded, …)
48
+ // are sourced from STATUS_COLORS so the token set stays single-source.
49
+ const EXTRA_STATUS_MAP = {
50
50
  inactive: ['#555', '#f5f5f5'],
51
- pending: ['#e65100', '#fff3e0'],
52
- draft: ['#888', '#f5f5f5'],
53
51
  sent: ['#e65100', '#fff3e0'],
54
- responded: ['#2e7d32', '#e8f5e9'],
55
- closed: ['#555', '#eeeeee'],
56
- overdue: ['#c62828', '#ffebee'],
57
52
  deleted: ['#c62828', '#fdecea'],
58
53
  admin: ['#6a1b9a', '#f3e5f5'],
59
54
  partner: ['#1565c0', '#e3f2fd'],
@@ -65,26 +60,29 @@ const STATUS_MAP = {
65
60
 
66
61
  export function statusBadge(status, labelOverride) {
67
62
  const s = (status || '').toLowerCase();
68
- const [color, bg] = STATUS_MAP[s] || ['#888', '#f5f5f5'];
69
- const label = labelOverride || (s ? s.charAt(0).toUpperCase() + s.slice(1) : '—');
63
+ const shared = STATUS_COLORS[s];
64
+ const [color, bg] = shared
65
+ ? [shared.text, shared.bg]
66
+ : (EXTRA_STATUS_MAP[s] || ['#888', '#f5f5f5']);
67
+ const label = labelOverride || (s ? s.charAt(0).toUpperCase() + s.slice(1) : '-');
70
68
  return `<span style="background:${bg};color:${color};padding:2px 9px;border-radius:10px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.3px;white-space:nowrap">${label}</span>`;
71
69
  }
72
70
 
73
71
  export function roleBadge(role) {
74
72
  const known = ['admin','partner','manager','clerk','user','auditor'];
75
- if (!role) return statusBadge('', '—');
73
+ if (!role) return statusBadge('', '-');
76
74
  if (!known.includes(role.toLowerCase())) return statusBadge('', 'Unknown');
77
75
  return statusBadge(role);
78
76
  }
79
77
 
80
78
  export function formatName(user) {
81
- if (!user) return '—';
82
- return user.name || user.display_name || user.email || '—';
79
+ if (!user) return '-';
80
+ return user.name || user.display_name || user.email || '-';
83
81
  }
84
82
 
85
83
  export function truncate(str, n) {
86
84
  const max = n || 50;
87
- if (!str) return '—';
85
+ if (!str) return '-';
88
86
  const s = String(str);
89
87
  return s.length > max ? s.slice(0, max) + '…' : s;
90
88
  }
@@ -115,6 +113,7 @@ const ICON_PATHS = {
115
113
  lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
116
114
  undo: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
117
115
  external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
116
+ close: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
118
117
  };
119
118
 
120
119
  export function icon(name, size = 18) {
@@ -7,16 +7,16 @@ import { SPACING, renderStatsRow, renderPageHeader, renderButton, renderEmptySta
7
7
  function fmtDate(ts) {
8
8
  if (!ts) return '';
9
9
  const n = Number(ts);
10
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
10
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString('en-ZA');
11
11
  return String(ts);
12
12
  }
13
13
 
14
14
  const RESOLUTION_STATES = {
15
- unresolved: { label: 'Unresolved', color: '#ef4444', pill: 'pill pill-danger' },
16
- partial_resolved: { label: 'Partial', color: '#f59e0b', pill: 'pill pill-warning' },
17
- manager_resolved: { label: 'Manager', color: '#3b82f6', pill: 'pill pill-info' },
18
- partner_resolved: { label: 'Partner', color: '#8b5cf6', pill: 'pill pill-primary' },
19
- resolved: { label: 'Resolved', color: '#22c55e', pill: 'pill pill-success' },
15
+ unresolved: { label: 'Unresolved', color: 'var(--color-danger,#ef4444)', pill: 'pill pill-danger' },
16
+ partial_resolved: { label: 'Partial', color: 'var(--color-warning,#f59e0b)', pill: 'pill pill-warning' },
17
+ manager_resolved: { label: 'Manager', color: 'var(--color-info,#3b82f6)', pill: 'pill pill-info' },
18
+ partner_resolved: { label: 'Partner', color: 'var(--color-primary,#8b5cf6)', pill: 'pill pill-primary' },
19
+ resolved: { label: 'Resolved', color: 'var(--color-success,#22c55e)', pill: 'pill pill-success' },
20
20
  };
21
21
 
22
22
  function resolutionBadge(status) {
@@ -47,14 +47,14 @@ function responseItem(response) {
47
47
  function highlightThread(highlight, responses, canResolve, idx) {
48
48
  const status = highlight.status || 'unresolved';
49
49
  const badge = resolutionBadge(status);
50
- const color = RESOLUTION_STATES[status]?.color || '#ef4444';
50
+ const color = RESOLUTION_STATES[status]?.color || 'var(--color-danger,#ef4444)';
51
51
 
52
52
  const threadItems = responses.map(r => responseItem(r)).join('');
53
53
  const sectionTag = highlight.section_name ? `<span class="review-meta-tag">${esc(highlight.section_name)}</span>` : '';
54
54
 
55
55
  const resolveSelect = canResolve ? `<div class="thread-card-actions">
56
56
  <label style="font-size:12px;color:var(--color-text-muted)">Mark as:</label>
57
- <select id="resolve-status-${highlight.id}" class="form-input" style="width:auto;padding:4px 8px;font-size:13px;min-height:auto" aria-label="Resolution status" onchange="updateResolution('${highlight.id}')">
57
+ <select id="resolve-status-${highlight.id}" class="form-input" style="width:auto;padding:${SPACING.xs} ${SPACING.sm};font-size:13px;min-height:auto" aria-label="Resolution status" onchange="updateResolution('${highlight.id}')">
58
58
  <option value="unresolved" ${status === 'unresolved' ? 'selected' : ''}>Unresolved</option>
59
59
  <option value="partial_resolved" ${status === 'partial_resolved' ? 'selected' : ''}>Partial</option>
60
60
  <option value="manager_resolved" ${status === 'manager_resolved' ? 'selected' : ''}>Manager</option>
@@ -65,7 +65,7 @@ function highlightThread(highlight, responses, canResolve, idx) {
65
65
 
66
66
  const replyForm = `<div class="thread-reply">
67
67
  <textarea id="reply-${highlight.id}" class="form-input" style="resize:vertical;min-height:60px" rows="2" placeholder="Add a response..."></textarea>
68
- <button class="btn-primary-clean" style="align-self:flex-start;padding:8px 16px" data-action="submitResponse" data-args='["${highlight.id}"]'>Reply</button>
68
+ <button class="btn-primary-clean" style="align-self:flex-start;padding:${SPACING.sm} ${SPACING.md}" data-action="submitResponse" data-args='["${highlight.id}"]'>Reply</button>
69
69
  </div>`;
70
70
 
71
71
  return `<div class="thread-card" id="thread-${highlight.id}" data-thread-status="${status}">
@@ -1,21 +1,22 @@
1
1
  import { statusLabel } from '@/ui/renderer.js';
2
2
  import { page } from '@/ui/layout.js';
3
3
  import { isPartner } from '@/ui/permissions-ui.js';
4
+ import { STATUS_COLORS, esc } from '@/ui/render-helpers.js';
4
5
 
5
6
  function fmtDate(ts) {
6
7
  if (!ts) return '-';
7
8
  const n = Number(ts);
8
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
9
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString('en-ZA');
9
10
  return String(ts);
10
11
  }
11
12
 
12
13
  function jobStatusBadge(status) {
13
14
  const colors = {
14
- running: { bg: '#dbeafe', text: '#1e40af', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>` },
15
- success: { bg: '#d1fae5', text: '#065f46', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>' },
16
- failed: { bg: '#fee2e2', text: '#991b1b', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>` },
17
- scheduled: { bg: '#fef3c7', text: '#92400e', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
18
- disabled: { bg: '#f3f4f6', text: '#6b7280', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>` },
15
+ running: { ...STATUS_COLORS.active, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>` },
16
+ success: { ...STATUS_COLORS.completed, icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>' },
17
+ failed: { ...STATUS_COLORS.rejected, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>` },
18
+ scheduled: { ...STATUS_COLORS.pending, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
19
+ disabled: { ...STATUS_COLORS.draft, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>` },
19
20
  };
20
21
  const c = colors[status] || colors.scheduled;
21
22
  return `<span style="background:${c.bg};color:${c.text};padding:2px 10px;border-radius:9999px;font-size:0.75rem;font-weight:500">${c.icon} ${(status || 'unknown').replace(/_/g, ' ')}</span>`;
@@ -25,9 +26,10 @@ function jobRow(job, canManage) {
25
26
  const lastRun = fmtDate(job.last_run_at);
26
27
  const nextRun = fmtDate(job.next_run_at);
27
28
  const duration = job.last_duration ? (job.last_duration < 1000 ? job.last_duration + 'ms' : Math.round(job.last_duration / 1000) + 's') : '-';
28
- const triggerBtn = canManage ? `<button class="btn btn-xs btn-primary" data-action="triggerJob" data-args='["${job.name}"]'>Run Now</button>` : '';
29
- const toggleBtn = canManage ? `<button class="btn btn-xs ${job.enabled !== false ? 'btn-warning' : 'btn-success'}" data-action="toggleJob" data-args='["${job.name}",${!job.enabled}]'>${job.enabled !== false ? 'Disable' : 'Enable'}</button>` : '';
30
- return `<tr><td class="font-medium">${job.label || job.name}</td><td>${job.schedule || job.cron || '-'}</td><td>${jobStatusBadge(job.status || (job.enabled === false ? 'disabled' : 'scheduled'))}</td><td class="text-sm">${lastRun}</td><td class="text-sm">${nextRun}</td><td class="text-sm">${duration}</td><td class="text-sm">${job.last_result || '-'}</td><td class="flex gap-1">${triggerBtn}${toggleBtn}</td></tr>`;
29
+ const jobNameArg = esc(JSON.stringify(String(job.name)));
30
+ const triggerBtn = canManage ? `<button class="btn btn-xs btn-primary" data-action="triggerJob" data-args='[${jobNameArg}]'>Run Now</button>` : '';
31
+ const toggleBtn = canManage ? `<button class="btn btn-xs ${job.enabled !== false ? 'btn-warning' : 'btn-success'}" data-action="toggleJob" data-args='[${jobNameArg},${!job.enabled}]'>${job.enabled !== false ? 'Disable' : 'Enable'}</button>` : '';
32
+ return `<tr><td class="font-medium">${esc(job.label || job.name)}</td><td>${esc(job.schedule || job.cron || '-')}</td><td>${jobStatusBadge(job.status || (job.enabled === false ? 'disabled' : 'scheduled'))}</td><td class="text-sm">${lastRun}</td><td class="text-sm">${nextRun}</td><td class="text-sm">${duration}</td><td class="text-sm">${esc(job.last_result || '-')}</td><td class="flex gap-1">${triggerBtn}${toggleBtn}</td></tr>`;
31
33
  }
32
34
 
33
35
  export function renderJobManagement(user, jobs, recentLogs = []) {
@@ -41,21 +43,21 @@ export function renderJobManagement(user, jobs, recentLogs = []) {
41
43
  { label: 'Running', value: runningJobs, color: runningJobs > 0 ? 'blue' : '' },
42
44
  { label: 'Failed', value: failedJobs, color: failedJobs > 0 ? 'red' : '' },
43
45
  { label: 'Healthy', value: totalJobs - failedJobs, color: 'green' },
44
- ].map(s => `<div class="card-clean"><div class="card-clean-body" style="padding:0.75rem 0"><h3 class="text-gray-500 text-sm">${s.label}</h3><p class="text-xl font-bold${s.color ? ` text-${s.color}-600` : ''}">${s.value}</p></div></div>`).join('')}</div>`;
46
+ ].map(s => `<div class="card-clean"><div class="card-clean-body" style="padding:0.75rem 0"><h3 class="text-gray-600 dark:text-gray-500 text-sm">${s.label}</h3><p class="text-xl font-bold${s.color ? ` text-${s.color}-600 dark:text-${s.color}-400` : ''}">${s.value}</p></div></div>`).join('')}</div>`;
45
47
 
46
48
  const headers = '<th>Job</th><th>Schedule</th><th>Status</th><th>Last Run</th><th>Next Run</th><th>Duration</th><th>Result</th><th>Actions</th>';
47
49
  const rows = jobs.map(j => jobRow(j, canManage)).join('');
48
- const table = `<div class="card-clean" style="margin-bottom:1.5rem" style="overflow-x:auto"><div class="card-clean-body"><h3 class="font-semibold mb-3">Scheduled Jobs</h3><table class="data-table"><thead><tr>${headers}</tr></thead><tbody>${rows || '<tr><td colspan="8" class="text-center py-8 text-gray-500">No jobs configured</td></tr>'}</tbody></table></div></div>`;
50
+ const table = `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><h3 class="font-semibold mb-3">Scheduled Jobs</h3><div style="overflow-x:auto"><table class="data-table"><thead><tr>${headers}</tr></thead><tbody>${rows || '<tr><td colspan="8" class="text-center py-8 text-gray-600 dark:text-gray-500">No jobs configured</td></tr>'}</tbody></table></div></div></div>`;
49
51
 
50
52
  const logRows = recentLogs.slice(0, 20).map(l => {
51
- const sts = l.success ? '<span class="text-green-600">OK</span>' : '<span class="text-red-600">FAIL</span>';
52
- return `<tr><td class="text-sm">${l.job_name || '-'}</td><td>${sts}</td><td class="text-xs text-gray-500">${fmtDate(l.started_at)}</td><td class="text-xs">${l.duration ? l.duration + 'ms' : '-'}</td><td class="text-xs text-gray-500 max-w-xs truncate">${l.message || '-'}</td></tr>`;
53
+ const sts = l.success ? '<span class="text-green-600 dark:text-green-400">OK</span>' : '<span class="text-red-600 dark:text-red-400">FAIL</span>';
54
+ return `<tr><td class="text-sm">${esc(l.job_name || '-')}</td><td>${sts}</td><td class="text-xs text-gray-600 dark:text-gray-500">${fmtDate(l.started_at)}</td><td class="text-xs">${l.duration ? l.duration + 'ms' : '-'}</td><td class="text-xs text-gray-600 dark:text-gray-500 max-w-xs truncate">${esc(l.message || '-')}</td></tr>`;
53
55
  }).join('');
54
- const logTable = `<div class="card-clean"><div class="card-clean-body"><h3 class="font-semibold mb-3">Recent Execution Logs</h3><table class="data-table"><thead><tr><th>Job</th><th>Result</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${logRows || '<tr><td colspan="5" class="text-center py-4 text-gray-400">No recent logs</td></tr>'}</tbody></table></div></div>`;
56
+ const logTable = `<div class="card-clean"><div class="card-clean-body"><h3 class="font-semibold mb-3">Recent Execution Logs</h3><div style="overflow-x:auto"><table class="data-table"><thead><tr><th>Job</th><th>Result</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${logRows || '<tr><td colspan="5" class="text-center py-4 text-gray-600 dark:text-gray-500">No recent logs</td></tr>'}</tbody></table></div></div></div>`;
55
57
 
56
- const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}`;
58
+ const content = `<main><div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}</main>`;
57
59
 
58
60
  const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{showToast('Trigger failed: '+(await r.text()),'error')}}catch(e){showToast(e.message,'error')}};window.toggleJob=async function(name,enabled){try{const r=await fetch('/api/admin/jobs/'+name,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});if(r.ok)location.reload();else showToast('Toggle failed','error')}catch(e){showToast(e.message,'error')}};window.triggerAll=async function(){if(!(await window.gmConfirm({title:'Please confirm',message:'Run all jobs now?',danger:false,confirmLabel:'OK'})))return;try{const r=await fetch('/api/admin/jobs/trigger-all',{method:'POST'});if(r.ok)location.reload();else showToast('Failed','error')}catch(e){showToast(e.message,'error')}}`;
59
61
 
60
- return page(user, 'Scheduled Jobs | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
62
+ return page(user, 'Scheduled Jobs | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
61
63
  }
package/src/ui/layout.js CHANGED
@@ -1,10 +1,23 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
2
  import { getNavItems, getAdminItems, isPartner, isClerk } from '@/ui/permissions-ui.js'
3
- import { TOAST_SCRIPT } from '@/ui/render-helpers.js'
3
+ import { TOAST_SCRIPT, AVATAR_COLORS, esc } from '@/ui/render-helpers.js'
4
4
  import { FETCH_JSON_SCRIPT } from '@/ui/fetch-json.js'
5
5
  import { aria, role, skipLink, liveRegion } from '@/lib/accessibility'
6
6
 
7
+ // Normalize a page title so the app-name suffix is config-driven, not hardcoded
8
+ // per call site. A caller may pass either a bare title ("Engagements") or one
9
+ // with a legacy suffix ("Engagements | Thatcher"); both resolve to
10
+ // "Engagements | <APP_NAME>" so a consuming app sets APP_NAME once instead of
11
+ // editing every renderer. APP_NAME is read at call time (not module load) so it
12
+ // reflects the env after the consuming app's loadEnv() runs.
13
+ function withAppName(title) {
14
+ const appName = process.env.APP_NAME || 'Thatcher App'
15
+ const base = String(title ?? '').replace(/\s*\|\s*[^|]*$/, '').trim() || appName
16
+ return base === appName ? base : `${base} | ${appName}`
17
+ }
18
+
7
19
  export function generateHtml(title, bodyContent, scripts = [], pathname = '/') {
20
+ title = withAppName(title)
8
21
  const scriptTags = scripts.map(s =>
9
22
  typeof s === 'string' ? `<script>${s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--')}</script>` : `<script type="module" src="${s.src}"></script>`
10
23
  ).join('\n')
@@ -17,9 +30,10 @@ export function generateHtml(title, bodyContent, scripts = [], pathname = '/') {
17
30
  }`
18
31
 
19
32
  return `<!DOCTYPE html>
20
- <html lang="en" data-theme="light">
33
+ <html lang="en">
21
34
  <head>
22
35
  <meta charset="UTF-8">
36
+ <script>(function(){try{var t=localStorage.getItem('thatcher-theme');if(!t){t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light'}document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script>
23
37
  <link rel="preconnect" href="https://fonts.googleapis.com">
24
38
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
25
39
  <link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
@@ -75,8 +89,8 @@ export function breadcrumb(items) {
75
89
  if (!items?.length) return ''
76
90
  return `<nav ${aria.label('Breadcrumb')} class="breadcrumb-clean">${items.map((item, i) =>
77
91
  i === items.length - 1
78
- ? `<span ${aria.current('page')}>${item.label}</span>`
79
- : `<a href="${item.href}">${item.label}</a><span class="breadcrumb-sep" aria-hidden="true">/</span>`
92
+ ? `<span ${aria.current('page')}>${esc(item.label)}</span>`
93
+ : `<a href="${esc(item.href)}">${esc(item.label)}</a><span class="breadcrumb-sep" aria-hidden="true">/</span>`
80
94
  ).join('')}</nav>`
81
95
  }
82
96
 
@@ -93,39 +107,38 @@ export function nav(user, pathname = '') {
93
107
  const reviewLink = `<a href="/review" class="${linkClass('/review')}">My Review</a>`
94
108
 
95
109
  const avatarInitial = user?.name?.charAt(0)?.toUpperCase() || 'U'
96
- const avatarColors = ['#1565c0','#2e7d32','#6a1b9a','#c62828','#e65100','#00695c']
97
- const avatarBg = avatarColors[(user?.name?.charCodeAt(0) || 0) % avatarColors.length]
110
+ const avatarBg = AVATAR_COLORS[(user?.name?.charCodeAt(0) || 0) % AVATAR_COLORS.length]
98
111
 
99
112
  return `<nav id="main-nav" class="main-nav" ${role.navigation} ${aria.label('Main navigation')}>
100
113
  <div class="main-nav-brand">
101
114
  <a href="/" class="main-nav-brand-link" ${aria.label('Home')}>
102
115
  ${logoSvg}
103
- <span class="main-nav-brand-text">Moonlanding</span>
116
+ <span class="main-nav-brand-text">Thatcher</span>
104
117
  </a>
105
118
  </div>
106
119
  <div class="nav-links-desktop" style="align-items:center;gap:1.25rem;flex-shrink:0">
107
120
  ${engLink}${clientLink}${settingsLink}${reviewLink}
108
121
  </div>
109
122
  <div class="main-nav-actions">
110
- <button id="theme-toggle" type="button" data-action="toggleTheme" aria-label="Toggle dark mode" title="Toggle theme" style="background:none;border:none;color:#ced4da;cursor:pointer;padding:6px;border-radius:4px;display:flex;align-items:center;min-width:32px;min-height:32px">
123
+ <button id="theme-toggle" type="button" data-action="toggleTheme" aria-label="Toggle dark mode" title="Toggle theme" class="nav-icon-btn">
111
124
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
112
125
  </button>
113
- <a href="/notifications" id="notif-bell" aria-label="Notifications" title="Notifications" style="position:relative;color:#ced4da;display:flex;align-items:center;padding:6px;border-radius:4px;text-decoration:none">
126
+ <a href="/notifications" id="notif-bell" aria-label="Notifications" title="Notifications" class="nav-icon-btn nav-notif-bell">
114
127
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
115
- <span id="notif-count" style="display:none;position:absolute;top:2px;right:2px;background:#ef4444;color:#fff;border-radius:9999px;font-size:10px;font-weight:700;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px"></span>
128
+ <span id="notif-count" style="display:none;position:absolute;top:2px;right:2px;background:var(--color-danger,#ef4444);color:#fff;border-radius:9999px;font-size:10px;font-weight:700;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px"></span>
116
129
  </a>
117
- <div class="nav-avatar" style="background:${avatarBg}" id="user-avatar" data-action="toggleUserMenu" data-pass-event title="${user?.name || 'User'}" aria-label="User menu" role="button" tabindex="0">
130
+ <div class="nav-avatar" style="background:${avatarBg}" id="user-avatar" data-action="toggleUserMenu" data-pass-event title="${esc(user?.name || 'User')}" aria-label="User menu" role="button" tabindex="0">
118
131
  ${avatarInitial}
119
132
  </div>
120
- <button class="nav-hamburger" data-action="toggleMobileNav" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="mobile-nav-drawer" style="background:none;border:none;color:#ced4da;cursor:pointer;padding:8px;border-radius:4px;align-items:center;justify-content:center;min-width:44px;min-height:44px">
133
+ <button class="nav-hamburger" data-action="toggleMobileNav" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="mobile-nav-drawer">
121
134
  ${hamburgerSvg}
122
135
  </button>
123
136
  </div>
124
137
  <div id="user-dropdown" class="user-dropdown" ${role.menu}>
125
138
  <div class="user-dropdown-header">
126
- <div class="user-dropdown-name">${user?.name || ''}</div>
127
- <div class="user-dropdown-email">${user?.email || ''}</div>
128
- <div class="user-dropdown-role" style="text-transform:capitalize">${user?.role || ''}</div>
139
+ <div class="user-dropdown-name">${esc(user?.name || '')}</div>
140
+ <div class="user-dropdown-email">${esc(user?.email || '')}</div>
141
+ <div class="user-dropdown-role" style="text-transform:capitalize">${esc(user?.role || '')}</div>
129
142
  </div>
130
143
  <a href="/api/auth/logout" class="user-dropdown-item">
131
144
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
@@ -135,9 +148,9 @@ export function nav(user, pathname = '') {
135
148
  </nav>
136
149
  <div id="mobile-nav-drawer" style="display:none;position:fixed;inset:0;z-index:200;pointer-events:none" aria-hidden="true">
137
150
  <div data-action="closeMobileNav" style="position:absolute;inset:0;background:rgba(0,0,0,0.5);pointer-events:auto"></div>
138
- <div style="position:absolute;left:0;top:0;bottom:0;width:280px;max-width:80vw;background:#04141f;padding:0;display:flex;flex-direction:column;transform:translateX(-100%);transition:transform 0.25s ease" id="mobile-nav-panel">
151
+ <div style="position:absolute;left:0;top:0;bottom:0;width:min(280px,70vw);max-width:80vw;background:#04141f;padding:0;display:flex;flex-direction:column;transform:translateX(-100%);transition:transform 0.25s ease" id="mobile-nav-panel">
139
152
  <div class="mobile-nav-header-bar">
140
- <span class="mobile-nav-header-title">Moonlanding</span>
153
+ <span class="mobile-nav-header-title">Thatcher</span>
141
154
  <button data-action="closeMobileNav" class="mobile-nav-close-btn" aria-label="Close menu">&times;</button>
142
155
  </div>
143
156
  <nav style="flex:1;padding:0.5rem 0;overflow-y:auto">
@@ -148,8 +161,8 @@ export function nav(user, pathname = '') {
148
161
  ${isPartner(user) ? `<a href="/admin/settings" class="mobile-nav-link">Settings</a>` : ''}
149
162
  </nav>
150
163
  <div class="mobile-nav-footer">
151
- <div class="mobile-nav-user-name">${user?.name || ''}</div>
152
- <div class="mobile-nav-user-email">${user?.email || ''}</div>
164
+ <div class="mobile-nav-user-name">${esc(user?.name || '')}</div>
165
+ <div class="mobile-nav-user-email">${esc(user?.email || '')}</div>
153
166
  <a href="/api/auth/logout" class="mobile-nav-logout">Sign out</a>
154
167
  </div>
155
168
  </div>
@@ -162,10 +175,19 @@ export function nav(user, pathname = '') {
162
175
  </div>
163
176
  </div>
164
177
  <script>
165
- window.loadingBtn=function(btn,loading,label){if(!btn)return;btn.disabled=loading;btn.style.opacity=loading?'0.7':'1';if(loading){btn._orig=btn.innerHTML;btn.innerHTML='<span class="btn-spinner"></span>'+(label||'Loading...');}else{btn.innerHTML=btn._orig||label||btn.innerHTML;btn.disabled=false}}
178
+ window.loadingBtn=function(btn,loading,label){if(!btn)return;btn.disabled=loading;btn.style.opacity=loading?'0.7':'1';if(loading){btn._orig=btn.innerHTML;btn.innerHTML='<span class="btn-spinner"></span>'+(label||'Loading...');}else{btn.innerHTML=btn._orig||label||btn.innerHTML;btn.disabled=false}};
179
+ // Canonical client-side date/currency formatters — mirror render-helpers fmtDate so
180
+ // client-rendered template strings format identically to server-rendered ones (fixed
181
+ // en-ZA locale, sec/ms/ISO scale-detect, '-' fallback). Use window.fmtDate(ts) in any
182
+ // client script string instead of inline new Date(...).toLocaleDateString().
183
+ (function(){function toDate(ts){if(ts==null||ts==='')return null;if(ts instanceof Date)return isNaN(ts)?null:ts;var s=String(ts).trim();var n=Number(ts);var d=(!isNaN(n)&&s!==''&&/^\d+$/.test(s))?new Date(n<1e12?n*1000:n):new Date(ts);return isNaN(d)?null:d}
184
+ window.fmtDate=function(ts){var d=toDate(ts);return d?d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'};
185
+ window.fmtDateTime=function(ts){var d=toDate(ts);return d?d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'})+', '+d.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}):'-'};
186
+ window.fmtDateInput=function(ts){var d=toDate(ts);if(!d)return '';var p=function(x){return String(x).padStart(2,'0')};return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())};
187
+ window.dateToUnixTimestamp=function(v){var d=toDate(v);return d?Math.floor(d.getTime()/1000):null};
188
+ window.fmtCurrency=function(amount,currency){if(amount==null||amount==='')return '-';var n=Number(amount);if(isNaN(n))return String(amount);var sym=currency==='USD'?'$':currency==='EUR'?'€':'R';return sym+' '+n.toLocaleString('en-ZA',{minimumFractionDigits:0,maximumFractionDigits:2})};})();
166
189
  window.showToast=window.showToast||function(m,t){var c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';document.body.appendChild(c)}var d=document.createElement('div');d.className='toast toast-'+(t||'info');d.textContent=m;c.appendChild(d);setTimeout(function(){d.style.opacity='0';setTimeout(function(){d.remove()},300)},3000)}
167
- window.toggleTheme=function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var next=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('moonlanding-theme',next)}catch(_){}}
168
- ;(function(){try{var saved=localStorage.getItem('moonlanding-theme');if(saved){document.documentElement.setAttribute('data-theme',saved)}else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.setAttribute('data-theme','dark')}}catch(_){}})();
190
+ window.toggleTheme=function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var next=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('thatcher-theme',next)}catch(_){}}
169
191
  function toggleUserMenu(e){e.stopPropagation();var d=document.getElementById('user-dropdown');var visible=d.style.display==='block';d.style.display=visible?'none':'block'}
170
192
  document.addEventListener('click',function(e){var d=document.getElementById('user-dropdown');if(d&&!d.contains(e.target)&&e.target.id!=='user-avatar'){d.style.display='none'}})
171
193
  function toggleMobileNav(){var btn=document.querySelector('.nav-hamburger');var open=btn.getAttribute('aria-expanded')==='true';btn.setAttribute('aria-expanded',String(!open));var drawer=document.getElementById('mobile-nav-drawer');var panel=document.getElementById('mobile-nav-panel');drawer.style.display=open?'none':'block';drawer.setAttribute('aria-hidden',String(open));panel.style.transform=open?'translateX(-100%)':'translateX(0)'}
@@ -202,18 +224,22 @@ export function fullPage(user, title, content, scripts = []) {
202
224
 
203
225
  export function statCards(cards) {
204
226
  return `<div class="stats shadow w-full mb-6 flex-wrap">` +
205
- cards.map(c => `<div class="stat"><div class="stat-title">${c.label}</div><div class="stat-value text-2xl${c.textClass || ''}">${c.value}</div>${c.sub ? `<div class="stat-desc">${c.sub}</div>` : ''}</div>`).join('') +
227
+ cards.map(c => `<div class="stat"><div class="stat-title">${esc(c.label)}</div><div class="stat-value text-2xl${c.textClass ? ' ' + esc(c.textClass) : ''}">${esc(c.value)}</div>${c.sub ? `<div class="stat-desc">${esc(c.sub)}</div>` : ''}</div>`).join('') +
206
228
  `</div>`
207
229
  }
208
230
 
209
231
  export function confirmDialog(entityName) {
232
+ const noun = entityName ? `this ${esc(String(entityName).replace(/_/g, ' '))}` : 'this item'
210
233
  return `<div id="confirm-dialog" class="confirm-overlay" style="display:none" ${role.alertdialog} ${aria.labelledBy('confirm-title')} ${aria.describedBy('confirm-msg')} ${aria.hidden('true')}>
211
234
  <div class="confirm-dialog"><h2 id="confirm-title" class="confirm-title">Confirm Delete</h2>
212
- <div id="confirm-msg" class="confirm-message">Are you sure you want to delete this item? This action cannot be undone.</div>
235
+ <div id="confirm-msg" class="confirm-message">Are you sure you want to delete ${noun}? This action cannot be undone.</div>
213
236
  <div class="confirm-actions"><button type="button" data-action="cancelDelete" class="btn btn-ghost" ${aria.label('Cancel deletion')}>Cancel</button>
214
237
  <button type="button" id="confirm-delete-btn" data-action="executeDelete" class="btn btn-error" ${aria.label('Confirm deletion')}>Delete</button></div></div></div>`
215
238
  }
216
239
 
240
+ // headers and rows are pre-rendered HTML fragments composed by the caller; emptyMsg
241
+ // may carry a caller-provided HTML link (e.g. "Create your first ..."), so it is
242
+ // trusted-by-contract here. Callers MUST esc() any user/db data they fold into these.
217
243
  export function dataTable(headers, rows, emptyMsg) {
218
- return `<div class="card-clean"><div class="table-wrap" ${role.region} ${aria.label('Data table')}><table class="data-table"><thead><tr>${headers}</tr></thead><tbody id="table-body">${rows || `<tr><td colspan="100" class="text-center py-8 text-base-content/50">${emptyMsg}</td></tr>`}</tbody></table></div></div>`
244
+ return `<div class="card-clean"><div class="table-wrap" ${role.region} ${aria.label('Data table')}><table class="data-table"><thead><tr>${headers}</tr></thead><tbody id="table-body">${rows || `<tr><td colspan="100" class="text-center py-8 text-base-content/50">${emptyMsg || 'No items found'}</td></tr>`}</tbody></table></div></div>`
219
245
  }
@@ -1,7 +1,3 @@
1
- // ML / AI query console — lists highlight comments flagged as ML-candidate queries.
2
- // MVP: read-only list of highlights with comment text + link back to the source review.
3
- // Full AI integration (model invocation, response streaming) is out of scope — stub.
4
-
5
1
  import { page } from '@/ui/layout.js';
6
2
  import { esc } from '@/ui/render-helpers.js';
7
3
  import { SPACING } from '@/ui/spacing-system.js';
@@ -17,13 +13,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
17
13
  const rows = candidates.map((h) => {
18
14
  const reviewName = reviewMap[h.review_id] || '-';
19
15
  const comment = h.comment || h.text || '';
20
- const truncated = comment.length > 240 ? comment.slice(0, 240) + '…' : comment;
16
+ const truncated = comment.length > 240 ? comment.slice(0, 240) + '...' : comment;
21
17
  return `<tr>
22
18
  <td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px">
23
19
  <div style="font-weight:500">${esc(reviewName)}</div>
24
20
  <div style="font-size:11px;color:var(--color-text-muted);margin-top:2px">p.${h.page_number || '?'}</div>
25
21
  </td>
26
- <td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px;max-width:560px">${esc(truncated)}</td>
22
+ <td title="${esc(comment)}" style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px;max-width:560px">${esc(truncated)}</td>
27
23
  <td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:12px;color:var(--color-text-muted);white-space:nowrap">${fmtDate(h.created_at)}</td>
28
24
  <td style="padding:8px 10px;border-bottom:1px solid var(--color-border);text-align:right">
29
25
  <a href="/review/${esc(h.review_id)}/pdf#h-${esc(h.id)}" class="btn-ghost-clean" style="padding:4px 10px;font-size:12px">Open</a>
@@ -57,19 +53,14 @@ export function renderMlConsole(user, candidates, reviewMap) {
57
53
  <tbody>${rows}</tbody>
58
54
  </table>
59
55
  </div>
60
- <div style="margin-top:${SPACING.md};padding:${SPACING.md};background:var(--color-surface-muted,#f8fafc);border-radius:8px;font-size:13px;color:var(--color-text-muted)">
61
- <strong>Note:</strong> Full AI integration requires an external model service.
62
- Clicking "Ask AI" currently records an intent; plumbing to a model endpoint is
63
- the next implementation step.
64
- </div>
65
56
  </div>
66
- <div id="ml-ask-dialog" class="modal" style="display:none" data-dialog-close-overlay="true">
57
+ <div id="ml-ask-dialog" class="modal" style="display:none">
67
58
  <div class="modal-overlay" data-dialog-close="ml-ask-dialog"></div>
68
59
  <div class="modal-content rounded-box max-w-lg p-6">
69
60
  <h3 style="font-size:16px;font-weight:600;margin:0 0 12px 0">AI Suggestion</h3>
70
61
  <div id="ml-ask-query" style="font-size:13px;color:var(--color-text-muted);margin-bottom:12px;padding:8px 10px;background:var(--color-surface-muted,#f8fafc);border-radius:6px"></div>
71
- <div id="ml-ask-result" style="font-size:14px;white-space:pre-wrap;min-height:60px;padding:10px;background:white;border:1px solid var(--color-border);border-radius:6px">
72
- <div style="color:var(--color-text-muted);font-style:italic">Generating suggestion…</div>
62
+ <div id="ml-ask-result" style="font-size:14px;white-space:pre-wrap;min-height:60px;padding:10px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:6px">
63
+ <div style="color:var(--color-text-muted);font-style:italic">Generating suggestion...</div>
73
64
  </div>
74
65
  <div id="ml-ask-meta" style="font-size:11px;color:var(--color-text-muted);margin-top:8px"></div>
75
66
  <div class="modal-action" style="margin-top:16px">
@@ -84,7 +75,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
84
75
  var qText = row ? row.querySelector('td:nth-child(2)').textContent.trim() : '';
85
76
  var dlg = document.getElementById('ml-ask-dialog');
86
77
  document.getElementById('ml-ask-query').textContent = qText;
87
- document.getElementById('ml-ask-result').innerHTML = '<div style="color:#64748b;font-style:italic">Generating suggestion…</div>';
78
+ var resultEl = document.getElementById('ml-ask-result');
79
+ resultEl.textContent = '';
80
+ var loadingEl = document.createElement('div');
81
+ loadingEl.style.color = '#64748b';
82
+ loadingEl.style.fontStyle = 'italic';
83
+ loadingEl.textContent = 'Generating suggestion...';
84
+ resultEl.appendChild(loadingEl);
88
85
  document.getElementById('ml-ask-meta').textContent = '';
89
86
  dlg.style.display = 'flex';
90
87
  try {
@@ -98,11 +95,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
98
95
  }
99
96
  } catch (e) {
100
97
  document.getElementById('ml-ask-result').textContent = 'Network error: ' + e.message;
98
+ if (window.showToast) window.showToast(e.message || 'Request failed', 'error');
99
+ console.error(e);
101
100
  }
102
101
  };
103
102
  })();
104
103
  </script>`;
105
104
 
106
105
  const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { label: 'ML Console' }];
107
- return page(user, 'AI / ML Query Console | Moonlanding', bc, content);
106
+ return page(user, 'AI / ML Query Console | Thatcher', bc, content);
108
107
  }
@@ -2,6 +2,7 @@ export function monitoringDashboardScript() {
2
2
  return `
3
3
  <script>
4
4
  let currentTab = 'requests'
5
+ function mdEsc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
5
6
 
6
7
  function showTab(name) {
7
8
  document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'))
@@ -69,7 +70,7 @@ export function monitoringDashboardScript() {
69
70
  function updateRequests(requests) {
70
71
  const html = Object.entries(requests).map(([endpoint, stats]) => \`
71
72
  <div class="mb-4 p-3 bg-base-100 rounded">
72
- <div class="font-bold">\${endpoint}</div>
73
+ <div class="font-bold">\${mdEsc(endpoint)}</div>
73
74
  <div class="grid grid-cols-7 gap-2 text-sm mt-2">
74
75
  <div>Count: \${stats.count}</div>
75
76
  <div>Min: \${stats.min.toFixed(1)}ms</div>
@@ -86,9 +87,9 @@ export function monitoringDashboardScript() {
86
87
 
87
88
  function updateAlerts(alerts) {
88
89
  const html = alerts.map(alert => \`
89
- <div class="p-3 rounded alert-\${alert.severity}">
90
- <div class="font-bold">[\${alert.severity.toUpperCase()}] \${alert.type}</div>
91
- <div>\${alert.message}</div>
90
+ <div class="p-3 rounded alert-\${mdEsc(alert.severity)}">
91
+ <div class="font-bold">[\${mdEsc(String(alert.severity).toUpperCase())}] \${mdEsc(alert.type)}</div>
92
+ <div>\${mdEsc(alert.message)}</div>
92
93
  <div class="text-sm text-gray-600">\${new Date(alert.timestamp).toLocaleString()}</div>
93
94
  </div>
94
95
  \`).join('')
@@ -104,7 +105,7 @@ export function monitoringDashboardScript() {
104
105
  <div class="mt-4 font-bold">Slow Queries:</div>
105
106
  \${(db.slowQueries || []).map(q => \`
106
107
  <div class="p-2 bg-base-100 rounded text-sm">
107
- <div class="font-mono">\${q.sql}</div>
108
+ <div class="font-mono">\${mdEsc(q.sql)}</div>
108
109
  <div>Duration: \${q.duration.toFixed(2)}ms</div>
109
110
  </div>
110
111
  \`).join('') || '<p>None</p>'}
@@ -136,9 +137,9 @@ export function monitoringDashboardScript() {
136
137
 
137
138
  function updateLogs(logs) {
138
139
  const html = logs.map(log => \`
139
- <div class="log-\${log.level} mb-1">
140
- [\${log.iso}] [\${log.level.toUpperCase()}] \${log.message}
141
- \${Object.keys(log.metadata).length ? ' ' + JSON.stringify(log.metadata) : ''}
140
+ <div class="log-\${mdEsc(log.level)} mb-1">
141
+ [\${mdEsc(log.iso)}] [\${mdEsc(String(log.level).toUpperCase())}] \${mdEsc(log.message)}
142
+ \${Object.keys(log.metadata).length ? ' ' + mdEsc(JSON.stringify(log.metadata)) : ''}
142
143
  </div>
143
144
  \`).join('')
144
145
  document.getElementById('log-list').innerHTML = html || '<p>No logs</p>'