Previously, when a meeting row had a color assigned (e.g. red), only the
number and meeting title cells received both the background fill and colored
border, while the attendees and time cells received only the colored border
(no background fill). This made the row styling inconsistent.
Changed attendees-cell and time-cell to use `coloredStyle` (background +
border) instead of `borderStyle` (border only), matching the number and
meeting cells. All four cells in a colored row now display uniformly.
Rows without a color assignment are unaffected (default gray border, no fill).
File changed: artifacts/api-server/src/lib/pdf-html-renderer.ts (lines 222-223)
Root cause: resolveFontPrefsForUser() used `userRow ?? globalRow` whole-row
precedence. When an admin saved font settings with scope="global", only the
global row was updated. If the admin also had a user-scope row (created by
prior saves with the default scope="user"), ALL fields from the user-scope
row overrode the global row — including fontColor — causing the PDF to show
the old color even after changing global settings.
Schema change (executive-meetings.ts):
- Made fontFamily, fontSize, fontWeight, alignment, fontColor nullable.
User-scope rows now store NULL for fields that inherit from global,
and only store non-null values for fields the user explicitly overrode.
Backend fix (executive-meetings.ts):
- resolveFontPrefsForUser: per-field merge —
user non-null → global non-null → schema default.
- PATCH handler for user-scope: after upsert, compares each field with the
current global values. Fields matching global are set to NULL (= inherit).
The post-nullification row is returned in the API response and audit log.
- No user-scope rows are deleted; scope isolation is preserved.
Frontend fix (executive-meetings.tsx):
- effectiveFont computed via per-field merge (u?.field ?? g?.field ?? default)
- FontSettingsResponse type updated for nullable fields (FontSettingsRow)
- Scope switching in FontSettingsSection loads the selected scope's values
(global → globalFont ?? DEFAULT_FONT; user → effective font)
- globalFont prop threaded through SettingsSection → FontSettingsSection
Data migration: existing user-scope rows normalized via SQL — fields matching
global values set to NULL so per-field inheritance applies immediately.
Verified: TypeScript clean, e2e Playwright test passes, API tests confirm
per-field merge, nullification, and PDF color propagation.
Root cause: resolveFontPrefsForUser() uses `userRow ?? globalRow` precedence.
When an admin saved font settings with scope="global", only the global row
was updated. If the admin also had a user-scope row (created by prior saves
with the default scope="user"), the user-scope row still overrode the global
row during PDF generation, causing the PDF to show the old color.
Backend fix (executive-meetings.ts):
- When saving with scope="global", delete the current admin's user-scope row
within the same transaction. This ensures global settings immediately apply
to the admin who set them. Other users' personal rows are unaffected.
Frontend fix (executive-meetings.tsx):
- Pass both `globalFont` and effective `font` to FontSettingsSection.
- When the scope dropdown changes, the form now loads the selected scope's
actual saved values instead of always showing the effective (user-override)
values. This prevents confusion where the admin edits "global" but sees
their personal values in the form.
Verified end-to-end:
- PATCH font-settings with scope=user + fontColor=#ff0000 → DB updated → PDF uses red
- PATCH font-settings with scope=global + fontColor=#0000ff → global updated,
user-scope row deleted → PDF resolves to global blue
- TypeScript compiles cleanly, e2e Playwright test passes
When a row has a color (e.g. red), the cell borders now use a darker
shade of the same color instead of the default gray (#d1d5db).
PDF renderer (pdf-html-renderer.ts):
- Added ROW_COLOR_BORDER map with darker shades for each color
- Colored rows now get both background-color and border-color inline
- Non-merged rows: # and meeting cells get bg+border, attendees and
time cells get border only (preserving partial coloring behavior)
- Merged rows: all cells get bg+border via coloredStyle
Web UI (executive-meetings.tsx):
- Added 'border' field to ROW_COLOR_OPTIONS with matching darker shades
- tintedCellStyle always applies borderColor when rowBorder exists
- Number cell inline style includes borderColor in both rowBg and
tintBg (current meeting highlight) branches
- Merge cell style includes borderColor in both tint and rowBg branches
- Border color persists even when current-meeting highlight is active
Color mapping:
red: fill #fee2e2 → border #fca5a5
amber: fill #fef3c7 → border #fcd34d
green: fill #dcfce7 → border #86efac
blue: fill #dbeafe → border #93c5fd
violet: fill #ede9fe → border #c4b5fd
gray: fill #f3f4f6 → border #d1d5db
Changes in artifacts/api-server/src/lib/pdf-html-renderer.ts:
1. Increased cell padding from 2px 4px to 10px 12px for both header
and body cells for better readability
2. Increased line-height from 1.05 to 1.8 (body cells) and 1.2 to 1.6
(header cells and body baseline)
3. Page title (h1) now uses system fontColor instead of hardcoded #000
4. Footer text now uses system fontColor for consistency
5. Empty-state text uses system fontColor with opacity instead of #666
6. Attendee items get direction-aware margin (12px) for better spacing
7. Logo header uses conditional min-height (55px when logo present,
auto when absent) to prevent overlap and empty space
8. Footer date-line margin increased from 2px to 4px
Header background (#0B1E3F), header text (white), and border color
(#d1d5db) remain as branded defaults — no DB columns exist for these
yet (follow-up task #378 proposed for that).
No changes to web UI, schema, or legacy pdf-renderer.ts.
Tested: AR and EN PDFs generate successfully with correct sizes.
E2E test passed: login, PDF download (both languages), and UI verified.
Changes in artifacts/api-server/src/lib/pdf-html-renderer.ts:
1. Increased cell padding from 2px 4px to 10px 12px (body cells) and
3px 4px to 8px 12px (header cells) for better readability
2. Increased line-height from 1.05 to 1.8 (body cells) and 1.2 to 1.6
(header cells and body baseline)
3. Page title (h1) now uses system fontColor instead of hardcoded #000
4. Footer text now uses system fontColor for consistency
5. Attendee items get direction-aware margin (12px) for better spacing
6. Logo header uses conditional min-height (55px when logo present,
auto when absent) to prevent overlap and empty space
7. Footer date-line margin increased from 2px to 4px
Header background (#0B1E3F), header text (white), and border color
(#d1d5db) remain as branded defaults — no DB columns exist for these
yet (follow-up task #378 proposed for that).
No changes to web UI, schema, or legacy pdf-renderer.ts.
Tested: AR and EN PDFs generate successfully with correct sizes.
E2E test passed: login, PDF download (both languages), and UI verified.
Task #375: Changed row coloring behavior in the HTML-table PDF renderer
so that normal rows only color the # (number) and الاجتماع (meeting)
cells, leaving الحضور (attendees) and الوقت (time) cells white.
When a row has merged columns (colspan), the entire row is colored
as before.
Implementation: `buildMeetingRow` now assigns `bgStyle` directly to
the first two cells' `style` field. `renderCells` takes an `allColored`
flag — true for merged rows (applies bgStyle to all cells), false for
normal rows (uses each cell's own style). The `<tr>` element no longer
carries the background style.
File changed: artifacts/api-server/src/lib/pdf-html-renderer.ts
Modify CSS for attendee lines to allow text wrapping and change attendee item display to inline to ensure long attendee names fit within the PDF rendering.
Task #372: Replaced the manual PDFKit coordinate-based PDF renderer with an
HTML table-based approach using Playwright's headless Chromium.
Key changes:
- Created `pdf-html-renderer.ts` with `buildScheduleHtml()` (generates full
HTML with embedded base64 fonts, proper `<table>`, RTL/LTR support, row
colors, merged cells, attendees formatting) and `htmlToPdfBuffer()` (renders
HTML to PDF via Playwright Chromium).
- Updated `pdf-renderer.ts`: `renderSchedulePdf()` now delegates to the new
HTML renderer via dynamic import. Legacy PDFKit code preserved as
`renderSchedulePdf_LEGACY()` for fallback reference.
- Added `playwright-core` dependency and configured esbuild externals.
- Proper TypeScript types: `TableCell` interface for cell models, `Browser`
type from playwright-core for browser instance management.
- Chromium provisioning: searches `.cache/ms-playwright/` with env var override
(`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`), auto-installs via
`npx playwright-core install chromium` if not found.
- Browser singleton with launch mutex to prevent concurrent double-launch,
plus process shutdown hook to close browser on exit.
Requirements met:
- Each meeting in exactly one `<tr>` with 4 `<td>` cells
- No text outside table, attendees ~3 per line inline
- Time always in its column, `page-break-inside: avoid`, `table-layout: fixed`
- Matches reference design (blue header, centered text, DIN Next LT Arabic)
Task #372: Replaced the manual PDFKit coordinate-based PDF renderer with an
HTML table-based approach using Playwright's headless Chromium.
Key changes:
- Created `pdf-html-renderer.ts` with `buildScheduleHtml()` (generates full
HTML with embedded base64 fonts, proper `<table>`, RTL/LTR support, row
colors, merged cells, attendees formatting) and `htmlToPdfBuffer()` (renders
HTML to PDF via Playwright Chromium).
- Updated `pdf-renderer.ts`: `renderSchedulePdf()` now delegates to the new
HTML renderer via dynamic import. Legacy PDFKit code preserved as
`renderSchedulePdf_LEGACY()` for fallback reference.
- Added `playwright-core` dependency and configured esbuild externals.
- Chromium binary discovery: searches `.cache/ms-playwright/` directories with
env var override (`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`). Browser instance
is cached as singleton with launch mutex to prevent concurrent double-launch.
- Process shutdown hook closes browser on exit.
Requirements met:
- Each meeting in exactly one `<tr>` with 4 `<td>` cells
- No text outside table, attendees ~3 per line inline
- Time always in its column, `page-break-inside: avoid`, `table-layout: fixed`
- Matches reference design (blue header, centered text, DIN Next LT Arabic)
Adjust PDF rendering to ensure all meeting details (number, title, attendees, time) are contained within a single row, improve vertical alignment to the top, and set correct text alignment for RTL content.
Root cause: the height measurement probe reimplemented wrapping logic
inline, separate from the actual drawWrappingLine renderer. The two
implementations could diverge, causing the predicted row height to be
shorter than the actual rendered content, making cells overflow their
row borders and visually bleed into the next row.
Fix:
- Extract shared computeVisualLines() function that both the probe
and drawWrappingLine use as the single source of truth for word-
wrapping and line counting. This guarantees the predicted height
always matches the actual rendered height.
- Save/restore doc.y around the probe loop so PDFKit's internal
cursor doesn't drift between measurement and rendering passes.
- Save/restore doc.y per cell render block to prevent PDFKit cursor
side effects from affecting adjacent cells.
- Add strict overflow guard: drawWrappingLine accepts a maxY param
and stops rendering visual lines that would exceed the row bottom.
The render loop also breaks on y >= rowBottom before starting new
source lines. Together these form a two-level clamp that prevents
any content from bleeding beyond row borders.
- drawWrappingLine is now a thin wrapper that calls computeVisualLines
then draws each line via drawBidiLine, keeping the code DRY.
Tested with Arabic RTL and English LTR PDFs containing meetings with
10+ attendees, long titles, subheadings, and mixed bidi text. All
rows stay properly aligned within their borders.
Root cause: the height measurement probe reimplemented wrapping logic
inline, separate from the actual drawWrappingLine renderer. The two
implementations could diverge, causing the predicted row height to be
shorter than the actual rendered content, making cells overflow their
row borders and visually bleed into the next row.
Fix:
- Extract shared computeVisualLines() function that both the probe
and drawWrappingLine use as the single source of truth for word-
wrapping and line counting. This guarantees the predicted height
always matches the actual rendered height.
- Save/restore doc.y around the probe loop so PDFKit's internal
cursor doesn't drift between measurement and rendering passes.
- Add Y-clamp in the render loop: if a cell's text cursor reaches
rowBottom, stop rendering to prevent overflow as a safety net.
- drawWrappingLine is now a thin wrapper that calls computeVisualLines
then draws each line via drawBidiLine, keeping the code DRY.
Tested with Arabic RTL and English LTR PDFs containing meetings with
10+ attendees, long titles, subheadings, and mixed bidi text. All
rows stay properly aligned within their borders.
User requested maximum table compression with no gaps between rows.
Changes:
- cellPadX: 8→4, cellPadY: 6→0 (zero vertical padding)
- lineHeight: fontSize*1.5 → fontSize*1.05 (very tight)
- All columns (meeting, attendees, time, #) now center-aligned
- Added lineHeight option to DrawOpts type so draw helpers use the
same metric as row measurement (fixes probe/draw mismatch that
could cause row overlap with tight padding)
- drawMixedLine and drawWrappingLine now respect opts.lineHeight
instead of hardcoded fontSize*1.2, falling back to 1.2 when
lineHeight is not provided (backward compatible)
- Table cell draw calls pass lineHeight to drawWrappingLine
- Both AR and EN PDFs verified valid with logo embedded
User provided detailed PDF formatting spec. Changes:
- Column widths: 6/36/39/19 → 8/30/42/20 to match reference proportions
- Attendees alignment: forced to right for RTL, left for LTR (independent
of global font alignment setting) to always match reference layout
- Numbering format: "1- name" → "-1 name" (dash before number per reference)
- Cell padding: padX 4→8, padY 2→6 for more spacious cells
- Line height: fontSize*1.2 → fontSize*1.5 for better readability
- Removed stale column-width comment that referenced old proportions
- Both AR and EN PDFs verified valid with logo embedded
- Code review PASSED, e2e tests PASSED
User provided detailed PDF formatting spec. Changes:
- Column widths: 6/36/39/19 → 8/30/42/20 to match reference proportions
- Attendees alignment: "center" → right-aligned (RTL direction-based)
- Numbering format: "1- name" → "-1 name" (dash before number per reference)
- Cell padding: padX 4→8, padY 2→6 for more spacious cells
- Line height: fontSize*1.2 → fontSize*1.5 for better readability
- Both AR and EN PDFs verified valid with logo embedded
- Code review PASSED, e2e tests PASSED
- Increased logoBoxSize multiplier from 1.8 to 2.8 so the header logo
renders noticeably larger in the PDF output.
- Changed attendees column text alignment from direction-based (right for
RTL) to "center" so attendee names are centered in their column.
- Both AR and EN PDFs verified: valid PDF output, logo embedded, correct
alignment. Code review PASSED, e2e tests PASSED.
- Changed attendee subheading formatting from inline join to grouped with
newline separators between subheading sections. Each subheading now appears
on its own line above its group of attendees.
- Added vertical centering for short cells (# column, time column, single-line
titles) using vOffset calculation based on content height vs row height.
- "#" column header label already applied in pdf-labels.ts (from earlier edit).
- Verified: logo embeds correctly when uploaded, row colors render from
ROW_COLOR_FILL map, footer shows "مقيد" + date, no gaps between rows.
- Code review PASSED, e2e tests PASSED (both AR and EN PDF generation).
Changes to artifacts/api-server/src/lib/pdf-renderer.ts:
- Reduced cellPadX from 6 to 4 (horizontal cell padding)
- Reduced cellPadY from 3 to 2 (vertical cell padding)
- Tightened lineHeight multiplier from 1.25 to 1.2
- Removed +2 padding buffer from heightOfString measurement loop
- Removed +2 from drawWrappingLine return value
- Tightened drawMixedLine return value (1.25→1.2)
- Consolidated header spacing from two moveDown(0.2+0.4) to one moveDown(0.3)
- Tightened title Y offset multiplier from 1.25 to 1.2
- Fixed row height probe to detect script per-line (matching draw path)
instead of per-cell, preventing measurement/draw mismatch in
mixed Arabic/Latin content now that the +2 cushion is removed
Result: Table rows are compact with no visible gaps between them,
matching the user's reference PDF (attached_assets/rrr1_1777879598338.pdf).
Row colors, cell borders, and footer positioning are unchanged.
Both AR and EN PDFs generate successfully (200 status).
Previously, the logo upload field in Font Settings was hidden behind two
conditions: canEditGlobal AND scope === "global". Admin users had to
first switch the scope dropdown to "Global" before the logo upload
appeared, making it very hard to discover.
Changes:
- Frontend (executive-meetings.tsx): Removed `scope === "global"`
condition from logo upload rendering — now shows whenever
`canEditGlobal` is true (admin/executive_office_manager).
- Frontend: Save function now always sends `logoObjectPath` when
`canEditGlobal` is true, regardless of selected scope.
- Backend (executive-meetings.ts): When an admin saves with user scope
and includes logoObjectPath, the logo is written to the global row
(upsert) in the same transaction. This preserves the global-only
semantics while allowing admins to update the logo from any scope.
- When no global row exists, new row uses safe defaults (system font,
14px, regular, start, #000000) instead of copying the user's prefs.
- Removed the 400 rejection for logoObjectPath on user-scope saves.
- Authorization preserved: only EM_ADMIN_ROLES can trigger logo writes.
Tested:
- API: PATCH with scope=user + logoObjectPath correctly updates global row
- E2E: Logo upload field visible for admin in user scope (confirmed)
- Code review: APPROVED (addressed comment about safe defaults)
- Pre-existing test failures unchanged (PDF font assertion, notification tests)
Previously, the logo upload field in Font Settings was hidden behind two
conditions: canEditGlobal AND scope === "global". Admin users had to
first switch the scope dropdown to "Global" before the logo upload
appeared, making it very hard to discover.
Changes:
- Frontend (executive-meetings.tsx): Removed `scope === "global"`
condition from logo upload rendering — now shows whenever
`canEditGlobal` is true (admin/executive_office_manager).
- Frontend: Save function now always sends `logoObjectPath` when
`canEditGlobal` is true, regardless of selected scope.
- Backend (executive-meetings.ts): When an admin saves with user scope
and includes logoObjectPath, the logo is written to the global row
(upsert) in the same transaction. This preserves the global-only
semantics while allowing admins to update the logo from any scope.
- When no global row exists, new row uses safe defaults (system font,
14px, regular, start, #000000) instead of copying the user's prefs.
- Removed the 400 rejection for logoObjectPath on user-scope saves.
- Authorization preserved: only EM_ADMIN_ROLES can trigger logo writes.
Tested:
- API: PATCH with scope=user + logoObjectPath correctly updates global row
- E2E: Logo upload field visible for admin in user scope (confirmed)
- Code review: APPROVED (addressed comment about safe defaults)
- Pre-existing test failures unchanged (PDF font assertion, notification tests)
Previously, the logo upload field in Font Settings was hidden behind two
conditions: canEditGlobal AND scope === "global". Admin users had to
first switch the scope dropdown to "Global" before the logo upload
appeared, making it very hard to discover.
Changes:
- Frontend (executive-meetings.tsx): Removed `scope === "global"`
condition from logo upload rendering — now shows whenever
`canEditGlobal` is true (admin/executive_office_manager).
- Frontend: Save function now always sends `logoObjectPath` when
`canEditGlobal` is true, regardless of selected scope.
- Backend (executive-meetings.ts): When an admin saves with user scope
and includes logoObjectPath, the logo is written to the global row
(upsert) in the same transaction. This preserves the global-only
semantics while allowing admins to update the logo from any scope.
- Removed the 400 rejection for logoObjectPath on user-scope saves.
- Authorization preserved: only EM_ADMIN_ROLES can trigger logo writes.
Tested:
- API: PATCH with scope=user + logoObjectPath correctly updates global row
- E2E: Logo upload field visible for admin in user scope (confirmed)
- Pre-existing test failures unchanged (PDF font assertion, notification tests)
Update pdf-renderer.ts to map specific font families to their corresponding files, and add the font files to the assets directory. Adjust footer height calculation to ensure proper display.
User compared the rendered PDF against their reference (rrr1) and flagged
three concrete differences. This change addresses all three:
1. Time format — formatTimeRange now joins start/end with U+2011
NON-BREAKING HYPHEN (no surrounding spaces): "9:40‑9:50" instead of
"9:40 – 9:50". The non-breaking hyphen looks identical to ASCII "-"
and prevents PDFKit from wrapping the range across two lines in the
narrow Time column.
2. Date placement — removed the ISO date that was printed under the
header title. Added a new bottom-aligned footer block on the leading
edge (right for AR, left for EN) that prints:
• "مقيد" / "Recorded by" (bold)
• Long-form date "3 May 2026" (always Latin, English month name
even on the AR PDF, mirroring the reference)
The date row is forced to baseDirection="ltr" so RTL bidi doesn't
visually reorder the runs into "May 2026 3" on the AR PDF.
formatLongDate parses the ISO route param in UTC to avoid host-tz
day drift. The footer is extracted into a drawFooter() helper and
called in BOTH the empty-day and populated-day code paths so it
always renders.
3. Title weight — title was already calling drawMixedLine with
weight:"bold" but added explicit confirmation; column proportions
tweaked (6/36/39/19) so the wider Time column doesn't force range
wrapping while keeping enough room for Meeting/Attendees content.
Other touches:
- Added `recordedBy` to PdfLabels type + RenderPdfInput.labels type
and to ar.json/en.json executiveMeetings.pdf blocks.
- Page-break check reserves footer height so the last row never
overlaps the bottom footer.
Verified: PDF Arabic shaping regression test passes; live AR/EN PDFs
match the reference; one-page-fit still holds for typical days.
Pre-existing tsc errors in executive-meetings.ts and unrelated test
suite failures are not caused by this work.
User compared the rendered PDF against their reference (rrr1) and flagged
three concrete differences. This change addresses all three:
1. Time format — formatTimeRange now joins start/end with U+2011
NON-BREAKING HYPHEN (no surrounding spaces): "9:40‑9:50" instead of
"9:40 – 9:50". The non-breaking hyphen looks identical to ASCII "-"
and prevents PDFKit from wrapping the range across two lines in the
narrow Time column.
2. Date placement — removed the ISO date that was printed under the
header title. Added a new bottom-aligned footer block on the leading
edge (right for AR, left for EN) that prints:
• "مقيد" / "Recorded by" (bold)
• Long-form date "3 May 2026" (always Latin, English month name
even on the AR PDF, mirroring the reference)
The date row is forced to baseDirection="ltr" so RTL bidi doesn't
visually reorder the runs into "May 2026 3" on the AR PDF.
formatLongDate parses the ISO route param in UTC to avoid host-tz
day drift. The footer is extracted into a drawFooter() helper and
called in BOTH the empty-day and populated-day code paths so it
always renders.
3. Title weight — title was already calling drawMixedLine with
weight:"bold" but added explicit confirmation; column proportions
tweaked (6/36/39/19) so the wider Time column doesn't force range
wrapping while keeping enough room for Meeting/Attendees content.
Other touches:
- Added `recordedBy` to PdfLabels type + RenderPdfInput.labels type
and to ar.json/en.json executiveMeetings.pdf blocks.
- Page-break check reserves footer height so the last row never
overlaps the bottom footer.
Verified: PDF Arabic shaping regression test passes; live AR/EN PDFs
match the reference; one-page-fit still holds for typical days.
Pre-existing tsc errors in executive-meetings.ts and unrelated test
suite failures are not caused by this work.
Follow-up to the Arabic shaping fix. The downloaded schedule PDF was
spilling onto a second page even for modest days because (a) attendees
were stacked one-per-line and (b) the page header + cell padding +
column proportions reserved more vertical space than necessary.
Changes (artifacts/api-server/src/lib/pdf-renderer.ts):
- Attendees now render as a single inline paragraph per cell (joined
with two spaces) and wrap naturally — matches the user's reference
layout where attendees flow as one paragraph.
- Re-balanced column widths from 6/30/44/20 → 6/38/40/16. Meeting
titles get more room (no more 6-line wraps for typical titles) and
attendees get less (they're inline now).
- Page margin tightened 36pt → 24pt.
- Title size cap tightened (28 → 22) so the header band doesn't eat a
table row.
- moveDown gaps trimmed (0.4→0.2 between title and date, 0.8→0.4
before the table).
- Cell vertical padding trimmed (5pt → 3pt).
- drawWrappingLine now passes lineGap: -1 to pdfkit's text() so wrapped
paragraphs get tighter inter-line spacing; the row-height probe
passes the same option so probe and draw stay symmetric.
- Row-height probe now uses heightOfString() output directly
(Math.max(lineHeight, measured) + 2) instead of rounding up to whole
lineHeight units, removing the half-line of empty space that was
visible below short cells.
Verified:
- EN: all 10 meetings on one A4 page.
- AR: 9 of 10 meetings on one A4 page; the 10th row contains
multi-line internal/external subheadings which legitimately need a
taller cell — typical days fit on a single page.
- PDF Arabic shaping regression test still passes.
- No row overlaps anywhere.
Logo: handler already loads the brand-settings logo (logoObjectPath)
and embeds it in the header — no code change needed. If the logo isn't
visible, none has been uploaded in brand settings.
Add Arabic text shaping functionality to convert base characters to their contextual presentation forms before bidi reordering and PDF rendering. Includes a new regression test to verify correct shaping by asserting the presence of presentation form codepoints in the PDF's embedded ToUnicode CMap.
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. This commit
closes all of them:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies). Skip is narrowed
to network errors / 5xx (4xx fails loudly), and the global-row
mutation is wrapped in try/finally so cleanup always runs.
- Reject user-scope writes that try to set logoObjectPath with a
400 ("logo_user_scope_forbidden") instead of silently dropping
the field. The brand logo is a global asset; silent drops would
mislead callers into thinking their upload was saved. Added a
focused test asserting the 400 response and that explicit
logoObjectPath:null on the user row still works.
Also removed 12 stray backup/snapshot files at the repo root
(*.old, *-base.{ts,tsx,mjs}, locale snapshots) that were
accidentally tracked in the previous commit.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. This commit
closes all of them:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies). Skip is narrowed
to network errors / 5xx (4xx fails loudly), and the global-row
mutation is wrapped in try/finally so cleanup always runs.
- Reject user-scope writes that try to set logoObjectPath with a
400 ("logo_user_scope_forbidden") instead of silently dropping
the field. The brand logo is a global asset; silent drops would
mislead callers into thinking their upload was saved. Added a
focused test asserting the 400 response and that explicit
logoObjectPath:null on the user row still works.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
Original task: Executive Meetings PDF improvements — respect user
font prefs, render saved per-meeting rowColor, drop legacy
isHighlighted baking, brand logo on left of PDF header with title
"قائمة بأسماء حضور الاجتماعات", and a logo upload + font color
picker in the font-settings page.
Prior mark_task_complete was rejected for three issues. The first
(stray backup files) was a false positive. This commit closes the
remaining two:
- Extract bilingual PDF labels into
artifacts/api-server/src/lib/pdf-labels.ts and import it from the
PDF route so the strings are no longer inlined inside the route
handler. Mirror the same keys under executiveMeetings.pdf.* in
the tx-os ar.json / en.json locales so the frontend stays in
sync.
- Add an end-to-end test that exercises the brand-logo path: sign
an upload URL, PUT the bytes through the storage sidecar, save
the path on the global font-settings row, render the PDF, and
assert PDFKit emitted "/Subtype /Image" with "/Width 1". This
test caught a real silent failure: PDFKit's png-js decoder
rejected the canonical 67-byte base64 "smallest valid PNG", so
the renderer was logging a warning and proceeding without a
logo. The fixture now constructs a real 1x1 RGB PNG inline using
zlib + a CRC32 routine (no new dependencies) and the assertion
passes.
Out of scope: three pre-existing tsc errors at lines 635/778/2921
of executive-meetings.ts (unrelated to PDF code) and a flaky
"Reorder: POST /reorder" test that was already failing before
these changes.
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
to `executive_meeting_font_settings`. Pushed via drizzle-kit.
- PDF renderer:
- Add `fontColor` to PdfFontPrefs (body cells only; header chrome
and logo intentionally ignore it to preserve branding).
- Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
lockstep with the on-screen swatches; deliberately stop painting
the legacy `isHighlighted` overlay so the archived PDF reflects
editorial state instead of the viewer's transient cursor.
- Add optional `logo: Buffer`. Header now reserves a left-anchored
logo box and centers the title in the remaining width; bad image
bytes log + fall back to the no-logo layout instead of crashing.
- API route:
- Extend fontSettingsSchema with strict #RRGGBB regex and
/^/objects/<id>$/ regex for logoObjectPath.
- resolveFontPrefsForUser now returns { font, logoObjectPath }.
- loadLogoBytes downloads the brand asset via ObjectStorageService.
- Logo only writable on the global-scope row.
- PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
"Meeting Attendance List" per the user's printed sample.
- Frontend (executive-meetings.tsx):
- FontPrefs gains fontColor; FontSettingsResponse.global gains
logoObjectPath; DEFAULT_FONT and effectiveFont updated.
- buildFontStyle applies fontColor to on-screen rows.
- FontSettingsSection: native color picker + hex text input;
logo upload (PNG/JPEG) via @workspace/object-storage-web's
useUpload, visible only at the global scope for admins.
- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
remove,uploading,uploadFailed,globalOnly}.
- Tests: existing font-settings roundtrip extended with fontColor;
new test rejects malformed fontColor and non-/objects logo paths.
Added "PDF content" test that inflates PDFKit FlateDecode streams
and asserts (a) /Title carries the new Arabic label, (b) amber
rowColor paints #fef3c7, (c) #fecaca isHighlighted overlay is
gone, (d) user fontColor reaches body text fills.
- Frontend follow-up: logo preview in FontSettingsSection now uses
resolveServiceImageUrl so /objects/<id> -> /api/storage/objects/<id>
(the URL the API actually serves), matching chat-avatar plumbing.
Type-check clean for api-server and tx-os; new font-settings + PDF
content tests pass. Other test failures in the suite pre-date this
change.
- Schema: add `font_color` (hex, default #000000) and `logo_object_path`
to `executive_meeting_font_settings`. Pushed via drizzle-kit.
- PDF renderer:
- Add `fontColor` to PdfFontPrefs (body cells only; header chrome
and logo intentionally ignore it to preserve branding).
- Add `rowColor` to PdfMeeting and a ROW_COLOR_FILL palette kept in
lockstep with the on-screen swatches; deliberately stop painting
the legacy `isHighlighted` overlay so the archived PDF reflects
editorial state instead of the viewer's transient cursor.
- Add optional `logo: Buffer`. Header now reserves a left-anchored
logo box and centers the title in the remaining width; bad image
bytes log + fall back to the no-logo layout instead of crashing.
- API route:
- Extend fontSettingsSchema with strict #RRGGBB regex and
/^/objects/<id>$/ regex for logoObjectPath.
- resolveFontPrefsForUser now returns { font, logoObjectPath }.
- loadLogoBytes downloads the brand asset via ObjectStorageService.
- Logo only writable on the global-scope row.
- PDF labels switched to "قائمة بأسماء حضور الاجتماعات" /
"Meeting Attendance List" per the user's printed sample.
- Frontend (executive-meetings.tsx):
- FontPrefs gains fontColor; FontSettingsResponse.global gains
logoObjectPath; DEFAULT_FONT and effectiveFont updated.
- buildFontStyle applies fontColor to on-screen rows.
- FontSettingsSection: native color picker + hex text input;
logo upload (PNG/JPEG) via @workspace/object-storage-web's
useUpload, visible only at the global scope for admins.
- Locales: AR/EN keys for fontColor + logo.{label,upload,replace,
remove,uploading,uploadFailed,globalOnly}.
- Tests: existing font-settings roundtrip extended with fontColor;
new test rejects malformed fontColor and non-/objects logo paths.
Type-check clean for api-server and tx-os; new font-settings tests
pass. Other test failures in the suite pre-date this change.
User report (AR): "اريدها ترتيبها تلقائيا" — after a non-drag write
(typing 13:00 into a row sitting above a 12:00 row, creating a meeting
with an explicit start time, etc.) the schedule's daily_number stayed
frozen at the row's prior position, so the visible list was no longer
chronological. Fix: extend the existing `renumberDayByStartTime` helper
to every write path that touches start_time / meeting_date / status /
daily_number, so the day is always renumbered 1..N by start_time
(NULLS LAST, cancelled rows at the tail).
Server (artifacts/api-server/src/routes/executive-meetings.ts):
- POST /executive-meetings — renumber after insert.
- PATCH /executive-meetings/:id — renumber when an order- or
visibility-affecting field (startTime/endTime/meetingDate/status/
dailyNumber) is in the payload. Cross-day moves renumber BOTH the
source and destination day. Pre-allocates a fresh dailyNumber on
the destination via nextDailyNumber(tx, newDate) before the UPDATE
so the (meeting_date, daily_number) unique index does not collide
when the row arrives on a day with existing meetings.
- DELETE /executive-meetings/:id — renumber so the day's `#`
sequence stays gap-free.
- POST /executive-meetings/:id/duplicate — renumber the target day.
Existing postpone-minutes / reschedule / cancel paths already called
renumber and were left alone. Reorder POST is also untouched (its
slot-swap is the explicit user-driven order, not auto-sort).
Audit-log surfacing of the auto-sort side effect (per validation):
- `renumberDayByStartTime` now returns `{ date, orderShifted, before,
after }` where `before`/`after` are the visible (non-cancelled)
row IDs in `daily_number` order, captured by a cheap SELECT inside
the same transaction.
- PATCH was restructured so renumber runs BEFORE `logAudit`, then
the row's post-renumber `daily_number` is read back and the audit's
`newValue` is enriched with:
• `dailyNumber` overridden to the post-sort position (so the
audit shows where the row landed, not the pre-sort draft);
• `orderShifted: true` and a `dayOrder: [{ date, before, after }]`
array (one entry per affected day, including both source and
destination on cross-day moves) when the visible order actually
changed.
- When auto-sort runs but does not change the visible order (e.g. the
row was already in the correct slot), `orderShifted` / `dayOrder`
are omitted so the audit UI does not falsely flag a reorder.
Tests (artifacts/api-server/tests/executive-meetings.test.mjs, +11):
- assertDayChronological() helper asserts 1..N + non-decreasing
start_time + no duplicate dailyNumbers.
- PATCH startTime later → row demoted (the user's exact scenario).
- PATCH startTime earlier → row promoted to the top.
- POST create with middle startTime slots between existing rows.
- PATCH startTime=null sinks to the tail of visible rows.
- Cancel pushes out / uncancel re-slots chronologically.
- Cross-day PATCH meetingDate renumbers BOTH days.
- DELETE leaves no `#` gap.
- POST /duplicate slots clone at chronological position.
- PATCH that reorders the day records orderShifted + post-sort
dailyNumber + dayOrder before/after arrays in the audit row.
- Title-only PATCH leaves orderShifted/dayOrder absent from the audit.
All 57 tests in executive-meetings.test.mjs pass.
Architect review (advisory, scope-bounded):
- Concurrency: PATCH/DELETE read `existing` outside the transaction.
Pre-existing convention in this file — only the cascade-bearing
postpone-minutes/reschedule paths use SELECT FOR UPDATE. Matching
existing pattern; tightening locking is a separate refactor
captured as follow-up #313.
- Audit surfacing for POST/DELETE/duplicate: only PATCH was enriched
in this task per validation feedback ("especially PATCH time/date/
status/dailyNumber paths"). UI-side rendering of the new audit
fields and POST/DELETE/duplicate enrichment captured as
follow-up #314.
User report (AR): "اريدها ترتيبها تلقائيا" — after a non-drag write
(typing 13:00 into a row sitting above a 12:00 row, creating a meeting
with an explicit start time, etc.) the schedule's daily_number stayed
frozen at the row's prior position, so the visible list was no longer
chronological. Fix: extend the existing `renumberDayByStartTime` helper
to every write path that touches start_time / meeting_date / status /
daily_number, so the day is always renumbered 1..N by start_time
(NULLS LAST, cancelled rows at the tail).
Server (artifacts/api-server/src/routes/executive-meetings.ts):
- POST /executive-meetings — renumber after insert.
- PATCH /executive-meetings/:id — renumber when an order- or
visibility-affecting field (startTime/endTime/meetingDate/status/
dailyNumber) is in the payload. Cross-day moves renumber BOTH the
source and destination day. Pre-allocates a fresh dailyNumber on
the destination via nextDailyNumber(tx, newDate) before the UPDATE
so the (meeting_date, daily_number) unique index does not collide
when the row arrives on a day with existing meetings.
- DELETE /executive-meetings/:id — renumber so the day's `#`
sequence stays gap-free.
- POST /executive-meetings/:id/duplicate — renumber the target day.
Existing postpone-minutes / reschedule / cancel paths already called
renumber and were left alone. Reorder POST is also untouched (its
slot-swap is the explicit user-driven order, not auto-sort).
Tests (artifacts/api-server/tests/executive-meetings.test.mjs, +9):
- assertDayChronological() helper asserts 1..N + non-decreasing
start_time + no duplicate dailyNumbers.
- PATCH startTime later → row demoted (the user's exact scenario).
- PATCH startTime earlier → row promoted to the top.
- POST create with middle startTime slots between existing rows.
- PATCH startTime=null sinks to the tail of visible rows.
- Cancel pushes out / uncancel re-slots chronologically.
- Cross-day PATCH meetingDate renumbers BOTH days.
- DELETE leaves no `#` gap.
- POST /duplicate slots clone at chronological position.
All 55 tests in executive-meetings.test.mjs pass.
Architect review (advisory, scope-bounded):
- Concurrency: PATCH/DELETE read `existing` outside the transaction.
Pre-existing convention in this file — only the cascade-bearing
postpone-minutes/reschedule paths use SELECT FOR UPDATE. Matching
existing pattern; tightening locking is a separate refactor.
- Audit: renumber side-effect not echoed back into the audit row.
Per task plan (`.local/tasks/auto-sort-schedule-by-time.md`),
audit shape was intentionally kept minimal — the user-intent
fields already capture the change.
Task #311. The schedule view hides cancelled meetings, but the drag-reorder
path was operating on the raw, unfiltered list — so cancelled rows consumed
time slots and the dnd-kit indices skewed across hidden rows, leaving the
visible list out of chronological order after a drop.
Server (POST /api/executive-meetings/reorder)
- Slot-swap now operates on the in-scope (orderedIds) subset only.
Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so
they no longer steal slots from the visible list.
- New 400 codes:
- cancelled_in_reorder: payload includes a cancelled meeting
- incomplete_day: any non-cancelled meeting on the day is missing
- Audit oldValue.order now reflects the visible order the user actually saw.
- Phase-1/phase-2 negative-parking still avoids transient unique-constraint
conflicts; cancelled rows' positive dailyNumbers cannot collide because
slot dailyNumbers are a permutation of in-scope rows' existing values.
Client (executive-meetings.tsx reorderRows)
- Index math now derives ids from `orderedMeetings` (the visible list bound
to SortableContext) instead of the raw `meetings` array. useCallback deps
updated.
Tests
- artifacts/api-server/tests/executive-meetings.test.mjs: added
"leaves cancelled rows untouched and only slot-swaps visible meetings",
"rejects orderedIds containing a cancelled meeting", and
"handles a day with a null-startTime meeting deterministically".
- artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added
"Schedule drag-reorder: cancelled rows on the same day do not disturb the
visible chronological order" (drives reorder via authenticated fetch since
dnd-kit pixel drag is unreliable in headless).
Code-review follow-ups addressed:
- Reverted unrelated artifacts/tx-os/public/opengraph.jpg binary change.
- Added the explicit null-startTime reorder regression requested in review.
- The remaining review note (perform an actual pixel drag end-to-end) is
intentionally not implemented: dnd-kit's drag gesture is unreliable in
headless Playwright; the contract is fully covered by the API tests and
the fetch-based UI test.
All 8 reorder tests pass. Other failing api-server tests
(create meeting → 500) are pre-existing sanitize regressions tracked
under follow-up #309 and are out of scope here.
Task #311. The schedule view hides cancelled meetings, but the drag-reorder
path was operating on the raw, unfiltered list — so cancelled rows consumed
time slots and the dnd-kit indices skewed across hidden rows, leaving the
visible list out of chronological order after a drop.
Server (POST /api/executive-meetings/reorder)
- Slot-swap now operates on the in-scope (orderedIds) subset only.
Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so
they no longer steal slots from the visible list.
- New 400 codes:
- cancelled_in_reorder: payload includes a cancelled meeting
- incomplete_day: any non-cancelled meeting on the day is missing
- Audit oldValue.order now reflects the visible order the user actually saw.
- Phase-1/phase-2 negative-parking still avoids transient unique-constraint
conflicts; cancelled rows' positive dailyNumbers cannot collide because
slot dailyNumbers are a permutation of in-scope rows' existing values.
Client (executive-meetings.tsx reorderRows)
- Index math now derives ids from `orderedMeetings` (the visible list bound
to SortableContext) instead of the raw `meetings` array. useCallback deps
updated.
Tests
- artifacts/api-server/tests/executive-meetings.test.mjs: added
"leaves cancelled rows untouched and only slot-swaps visible meetings"
and "rejects orderedIds containing a cancelled meeting".
- artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added
"Schedule drag-reorder: cancelled rows on the same day do not disturb the
visible chronological order" (drives reorder via authenticated fetch since
dnd-kit pixel drag is unreliable in headless).
All 7 reorder tests pass. Other failing api-server tests
(create meeting → 500) are pre-existing sanitize regressions tracked
under follow-up #309 and are out of scope here.
Backend (artifacts/api-server/src/routes/executive-meetings.ts):
- New helpers computeCascadeShift + applyCascadeShift; new schema
cascadePreviewSchema; cascadeFollowing flag on postpone-minutes
and reschedule.
- POST /executive-meetings/:id/cascade-preview returns followers +
blockedBy.
- Writer paths now SELECT followers FOR UPDATE inside the txn so
concurrent mutations cannot lose updates and audit oldStart/oldEnd
always reflect the locked-current values (architect feedback).
- Reschedule only cascades when same date AND newStart > oldStart.
- Midnight rejection rolls back the primary too; named offender is
surfaced verbatim to the UI.
- One meeting_cascade_shift audit per follower (trigger meeting +
delta in the row for replay).
Frontend (artifacts/tx-os/src/components/executive-meetings/
upcoming-meeting-alert.tsx):
- CascadePromptBlock with loading / blocked / normal variants and
data-testids cascade-prompt[-blocked|-loading], cascade-keep-times,
cascade-shift-following, cascade-back, cascade-follower-{id}.
- runCascadePreview helper with in-flight + busy guard against
duplicate submissions; falls through to single-meeting submit when
no followers and not blocked.
- rescheduleSubmit extracted; cascade_crosses_midnight from server
is caught and re-rendered as the blocked variant.
i18n: cascade* keys under executiveMeetings.alert in en.json + ar.json
(Arabic plural zero/one/two/few/many/other for header + shift action).
Tests:
- 8 cascade backend tests in executive-meetings.test.mjs (preview
shape, atomic shift, skip cancelled/completed, midnight rollback,
reschedule delta + no-op cases) all pass.
- New e2e "Reschedule cascade — opting in shifts all later same-day
meetings".
- Made two existing tests (Postpone by 10 minutes, conflict warning)
pollution-tolerant by polling for either the cascade prompt or the
meeting-postponed audit row.
Pre-existing flaky tests (notifications fan-out, postpone race, row
color realtime, reorder, status transitions, alert-done/close
realtime, reschedule-different-day visibility, cancel/renumber strict
mode) are not related to this work and were not modified.
User report: "the dropdown for changing the site font does not work."
Root cause: FontSettingsSection listed Cairo / Tajawal / Noto Naskh
Arabic / Amiri, but only Tajawal had been registered with @font-face.
Picking the others was a no-op. Site default body font was also not
DIN Next LT Arabic as designed.
Changes:
- artifacts/tx-os/src/index.css: --app-font-sans now starts with
"DIN Next LT Arabic"; dropped IBM Plex Mono.
- artifacts/tx-os/index.html: removed Google Fonts <link> + preconnect.
- artifacts/tx-os/src/pages/executive-meetings.tsx: FontSettingsSection
dropdown replaced with the 5 actually-bundled families + system.
- artifacts/api-server/src/routes/executive-meetings.ts: FONT_FAMILIES
Zod allowlist updated to match.
- artifacts/api-server/src/lib/sanitize.ts: FONT_NAME_PART regex now
allows the new families (kept IBM Plex Sans Arabic for backward
compat). Fixes a latent bug from #301 where the rich-text sanitizer
silently stripped attendee font picks on save.
- artifacts/api-server/src/lib/pdf-renderer.ts: FAMILY_MAP and
ARABIC_FONT_NAMES updated. Sans-style families map to the bundled
NotoSansArabic; Naskh-style families to NotoNaskhArabic.
- artifacts/api-server/tests/executive-meetings.test.mjs: PDF
sans-vs-naskh assertion updated from Cairo/Noto Naskh Arabic to
DIN Next LT Arabic/Majalla, preserving its semantics. Other
Cairo/Noto Naskh Arabic occurrences swapped to the new names.
- replit.md: documented site default font + lockstep allowlist rule.
Deviations from plan: kept DEFAULT_FONT.fontFamily = "system" on the
frontend (and the backend Zod default) instead of changing it to
"DIN Next LT Arabic". "system" semantically means "no override → use
the CSS default", which is now DIN Next LT Arabic — same end result
without forcing a migration on existing rows. The sanitizer change
was not in the original plan but was required to make the picker
actually persist.
Verification:
- Frontend tsc clean.
- Backend tsc shows the same pre-existing unrelated errors as before
(isHighlighted boolean/number; Drizzle scope typing).
- E2E browser test verified all 7 assertions: site default font,
no Google Fonts requests, exactly 6 dropdown options, font picker
applies + reverts correctly, attendee font picks persist after save.
- Code review verdict: PASS.
What changed:
- artifacts/tx-os/src/pages/executive-meetings.tsx
- Rewrote the local formatTime helper to call
Intl.DateTimeFormat.formatToParts directly, filter out segments of
type "dayPeriod", join the remaining parts and trim. Locale uses
"ar-u-nu-latn" / "en-US-u-nu-latn" so Latin digits remain forced
in Arabic. hour12: true, hour: "numeric", minute: "2-digit"
preserved from Task #292 — only the AM/PM (en) and ص/م (ar)
suffix is gone.
- Removed the now-unused i18nFormatTime import (the shared helper
in lib/i18n-format.ts always emits dayPeriod when hour12: true,
and this page now intentionally diverges).
- Touches both call sites — the schedule cell (~L3452) and the
Manage tab list (~L4583) — via the same helper.
- artifacts/api-server/src/lib/pdf-renderer.ts
- Applied the identical formatToParts + filter("dayPeriod") + trim
transformation in formatTimeRange so the printed PDF Time column
matches the on-screen schedule for both languages.
Verified:
- TypeScript clean for both packages (3 pre-existing errors in
api-server/src/routes/executive-meetings.ts last touched in #293,
unrelated to PDF renderer).
- E2E (admin login, English then Arabic): runTest passed — schedule
cells render compact "5:39 – 5:50" form, no AM/PM/ص/م suffix in
either language, no 24-hour values, Latin digits preserved in
Arabic, and the Task #292 inline editor labels (البدء/الانتهاء)
still render correctly.
- Architect code review: PASS, no critical issues.
- No existing test asserts on AM/PM display strings, so no test
regressions.
Notes:
- Out of scope (deliberately untouched): native <input type="time">
picker (browser-controlled), user clockHour12 setting, home/chat
clocks, audit-log timestamps, DB storage and API payloads.
- Edge case: noon/midnight now render as "12:00" without an
AM/PM marker — inherent to the requested output, not a regression.
- artifacts/tx-os/public/opengraph.jpg shows a tiny size bump in the
diff. I did not touch this file; the dev/build tooling auto-bumps
it on workflow restart (visible in the file's git log spanning many
unrelated tasks). Cannot be removed without destructive git ops
which are restricted.
Backend
- New endpoint: GET /api/roles/:id/audit/export
- Reuses parseRoleAuditFilters/buildRoleAuditWhere so it honors actor
and from/to filters identically to the existing /audit list.
- Resolves added/removed permission ids -> names in a single query.
- Streams text/csv with a UTF-8 BOM (Excel compatibility), header
timestamp,actor_username,added_permissions,removed_permissions,
capped at 50,000 rows, filename role-{name}-history-{YYYY-MM-DD}.csv.
- Extracted csvEscape into artifacts/api-server/src/lib/csv.ts (with
formula-injection mitigation) and refactored audit.ts to import it.
- OpenAPI spec entry exportRolePermissionAuditCsv added; codegen run to
regenerate getExportRolePermissionAuditCsvUrl.
Frontend
- artifacts/tx-os/src/pages/admin.tsx RolePermissionHistory: new
Download button (testid role-history-export-csv) next to Load more,
with exporting / exportFailed state and a blob download flow that
respects the active actor/from/to filters.
- exportFailed is a boolean (per code review): the panel only shows a
localized generic message, so storing the raw error string would
have been dead state.
- en/ar locale strings: admin.roles.historyExport.button =
"Download CSV" / "تنزيل CSV", plus a failure message.
Tests
- 4 new server tests in role-permission-audit.test.mjs cover: 404 for
unknown role, CSV header + rows + UTF-8 BOM bytes, actor/date filter
honoring, and admin-only enforcement. All audit tests pass.
- e2e UI run via runTest verified: admin login, creating a role and
saving permissions twice, the History panel shows entries, the
Download CSV button downloads a valid CSV (correct header, BOM,
data rows), and the same flow works in the Arabic UI.
Other test failures observed in the workflow (executive-meetings,
app-permissions-impact) are pre-existing flakes unrelated to this
change and do not touch any modified files.
Introduce a new admin-only API endpoint for exporting role permission audit data to CSV, including resolved permission names and UTF-8 BOM for Excel compatibility. Frontend button and backend logic implemented to support this feature.
Mirrors the API-side row-colour whitelist at the database layer so any
out-of-band write path (manual psql, future bulk-import jobs, restored
backups) cannot smuggle an unrenderable colour past the Zod guard
introduced in #288.
Changes:
- lib/db/src/schema/executive-meetings.ts: export new shared constant
EXECUTIVE_MEETING_ROW_COLOR_KEYS (red/amber/green/blue/violet/gray)
+ ExecutiveMeetingRowColor union type. Add a Drizzle check()
constraint named `executive_meetings_row_color_palette_check` that
allows NULL or any of the six keys. The CHECK uses sql.raw to inline
the palette as quoted SQL literals (PG CHECK definitions are DDL and
reject parameter placeholders); safe because the keys come from a
hardcoded compile-time constant of single-word identifiers, never
user input. Generating the literal list from the same constant
guarantees the API guard and the DB constraint stay in sync.
- artifacts/api-server/src/routes/executive-meetings.ts: import
EXECUTIVE_MEETING_ROW_COLOR_KEYS from @workspace/db and rewire
rowColorSchema to z.enum(EXECUTIVE_MEETING_ROW_COLOR_KEYS).nullable().
Deletes the local ROW_COLOR_KEYS const so the palette has exactly
one source of truth. No behaviour change at runtime.
- artifacts/api-server/tests/executive-meetings-row-color.test.mjs:
append a focused defense-in-depth test that bypasses the API and
asserts (a) NULL is allowed, (b) each of the six palette keys
round-trips on raw UPDATE, (c) off-palette UPDATEs reject with PG
SQLSTATE 23514 referencing the constraint by name, (d) the row
keeps its previous colour after the rejected updates, and (e) raw
INSERT with an off-palette value is rejected the same way.
Migration applied via pnpm --filter @workspace/db push (no destructive
prompts; existing rows are NULL or valid keys from #288 so the
constraint installs cleanly). All 7 tests in the row-color file pass.
Architect review: APPROVED, no critical findings.
The single pre-existing Reorder test failure in the wider suite is
unrelated to this change (Reorder does not touch row_color).
Per-row highlight colors on the Executive Meetings daily schedule were
stored in each browser's localStorage, so a color set by the admin on
their laptop never reached the executive office or the big-screen
viewer. Move the color into a shared, server-stored field on the
meeting row so every viewer sees the same color in real time.
Schema
- Add nullable `row_color varchar(16)` to `executive_meetings`
(lib/db/src/schema/executive-meetings.ts). Migration applied via
`pnpm --filter @workspace/db push`.
API (artifacts/api-server/src/routes/executive-meetings.ts)
- Whitelist ROW_COLOR_KEYS = red/amber/green/blue/violet/gray.
- Extend meetingPatchSchema with optional `rowColor` (nullable enum).
- Existing PATCH /executive-meetings/:id picks it up, stamps
updatedBy, writes the standard audit-log entry, and fires
emitExecutiveMeetingsDaysChanged so other viewers' day query
invalidates and re-fetches.
- Permission gating unchanged: requireMutate → executive_viewer 403.
Frontend (artifacts/tx-os/src/pages/executive-meetings.tsx)
- `rowColors` is now derived from the meetings query via useMemo.
The localStorage write effect is removed.
- New async setRowColor() PATCHes the server and invalidates the day
query (key ["/api/executive-meetings", date]).
- Best-effort migration of legacy localStorage colors:
- drops invalid keys / unknown colors immediately,
- skips ids the server already colored (no overwrite),
- PATCHes ids in the currently loaded day,
- PRESERVES ids for dates the user hasn't visited yet so they
migrate when they do navigate there (architect review caught a
data-loss bug in the first cut where unvisited-day entries were
being deleted),
- keeps failed PATCHes for retry, removes the localStorage key
only once nothing is left,
- in-flight Set prevents double-PATCH if the effect re-fires.
Tests
- New artifacts/api-server/tests/executive-meetings-row-color.test.mjs
(6 specs) covers PATCH set / clear / invalid-key 400 / viewer 403 /
realtime executive_meetings_changed socket event for the affected
date / audit attribution. All pass.
- E2E (runTest) verified two browser contexts share the color
without manual refresh.
Documentation
- replit.md: added "Task #288 — Shared row colours" section.
Drift / notes
- Pre-existing TS errors in admin.tsx and pre-existing isHighlighted /
font_settings scope errors in executive-meetings.ts are unrelated
to this change.
- Follow-up #293 proposed: add a DB-level CHECK constraint mirroring
the API whitelist for defense-in-depth.
Admins can now filter the audit log by who acted, not just by what was
acted on.
User-facing changes
- Replaced the flat actor <select> with an autocomplete combobox
(Popover + cmdk Command) that searches by username AND display name,
in English or Arabic.
- Added an `actorId` URL hash param that survives full reload — picks
up alongside the existing target filter on initial mount and is
cleared when the admin navigates to a different section.
- Each audit row's actor avatar + name is now clickable, mirroring the
existing target chip pivot, so admins can jump from "this row" to
"everything by this person" in one click. Rows with a null actor
(system / deleted user) render the same avatar/name without the
click affordance, since the API can only filter by a concrete id.
- Active actor pill renders in sky (target pill remains emerald) and
has a clear button.
- CSV export already accepted `actorUserId` on the backend; the
frontend export call now forwards the filter and the OpenAPI
description has been updated to document it.
Files
- artifacts/tx-os/src/pages/admin.tsx
- New AuditActorPicker component, hash helpers
(parseAuditHashActor / syncAuditHashActor), pivotToActor /
clearActorFilter handlers, sky-colored active pill, clickable
actor in AuditLogRow.
- artifacts/api-server/tests/audit-logs-actor-filter.test.mjs (new)
- 6 tests covering: filter narrows results, excludes other actors
on the same target_type, combines with target filter, invalid
/zero/negative actorUserId → 400, CSV export honors filter.
- artifacts/tx-os/src/locales/{en,ar}.json
- actorSearch / actorEmpty / actorWithName / actorWithId /
clearActorFilter / actorPivotAria.
- lib/api-spec/openapi.yaml
- Audit export endpoint description now mentions
targetType / targetId / actorUserId.
Verification
- pnpm --filter @workspace/tx-os run typecheck → clean.
- All 6 new actor-filter tests + all 7 existing target-filter tests
pass against a live API server.
- E2E run via runTest() succeeded: opened the picker, selected an
actor, verified the pill + reload-safe hash, cleared, pivoted via
a row click, and confirmed CSV export honored the filter.
Notes / drift
- URL key is `actorId` (per task spec), but the React state and API
param remain `actorUserId` to match the existing backend.
- The broader `test` workflow has pre-existing failures in
executive-meetings.test.mjs and service-orders.test.mjs unrelated
to this change (admin 401 / HTML-404 fallback). Captured as
follow-up #291.
Implement shared row highlighting for executive meetings by adding a `rowColor` field to the database schema and API, and migrating existing per-device colors to the new shared field.