Root causes the prior attempts missed:
1) Safari iOS/iPadOS ignores `interactive-widget=resizes-content`,
so `100dvh` never shrinks when the soft keyboard opens. Every
previous override that used `dvh` to size the dialog was sized
to the full screen and ran behind the keyboard.
2) The CSS rule from #622 (`[role="dialog"]:has(:focus)`) was
unconditional. On desktop, focusing any input inside a Radix
DialogContent instantly stretched it to width:100vw + top:0,
which collided with Radix's open-state slide-animation
transforms and rendered the dialog as a broken narrow strip
pinned in the top-left corner — visible in Tahani's laptop
screenshot when opening "Add Meeting".
3) `AdminFormDialog` puts `role="dialog"` on a NON-fixed inner
card, so the rule's `top/left/right` had no effect there
anyway; the card stayed centred in a layout-viewport-sized
wrapper which iOS does not shrink.
Fix:
- New `useVisualViewportVars` hook mounted in
NotificationsSocketBridge. Writes `--vv-height` and
`--vv-offset-top` on `<html>` from `window.visualViewport`,
updated on resize / scroll / orientationchange. Falls back
to `innerHeight`/0 if visualViewport is absent.
- Rewrote the index.css dialog rule:
* Gated inside `@media (any-pointer: coarse)` so desktop is
completely unaffected.
* Uses `var(--vv-height, 100vh)` and `var(--vv-offset-top, 0px)`
instead of `100dvh` / `0`, so dialogs actually track the
visible band on iOS.
- New `.dialog-vv-wrapper` helper class that applies the same
vv-sized top/height to bespoke full-screen overlay wrappers.
- Applied `.dialog-vv-wrapper` to AdminFormDialog and the admin
ConfirmDialog wrappers; switched their `fixed inset-0` to
`fixed inset-x-0 top-0 h-screen` and their inner cards from
`max-h-[90vh]`/`max-h-[85vh]` to `max-h-full` so the card
shrinks with the wrapper.
Kept `useScrollFocusedDialogField` unchanged — works correctly
once the dialog itself is sized to the visible band.
`tsc --noEmit` clean.
The CSS `[role="dialog"]:has(:focus)` rule from #621 promotes any
focused dialog to a full-width sheet sized to `100dvh`, but Safari
does not auto-scroll the focused input into view inside the
dialog's own overflow container — it only tries to scroll the
document, which our position:fixed dialog ignores. The field stays
where it was, often behind the keyboard.
Added a global `useScrollFocusedDialogField` hook (in
artifacts/tx-os/src/hooks/) that listens to `document` focusin. If
the target is an input/textarea/select/contenteditable inside a
`closest('[role="dialog"]')`, it calls
`target.scrollIntoView({block:'center', behavior:'smooth'})` twice
(300ms and 600ms) to cover the iPad keyboard animation window and
the dialog's `max-height:100dvh` recalc. Timers are cleared on blur
and on unmount.
Mounted the hook once in `NotificationsSocketBridge` inside App.tsx
so it covers every page and every dialog kind we use (Radix
DialogContent, the bespoke AdminFormDialog, ad-hoc role="dialog"
wrappers) without touching their JSX.
No CSS or dialog component changes. `tsc --noEmit` clean.
Task #554. Adds a full Web Push stack on top of the existing Socket.IO
notification fan-out so the iPad PWA (and any installed browser) receives
system notifications when Tx OS is closed or backgrounded.
Backend
- New `push_subscriptions` table (userId + unique endpoint + p256dh/auth
keys + ua + timestamps), exported from `@workspace/db`.
- `artifacts/api-server/src/lib/push.ts`:
- VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT
(Docker volume) with /tmp fallback for Replit dev, else ephemeral.
- `sendPushToUser()` honours `notificationsMuted` + per-channel prefs
(orders/meetings/notes), prunes 404/410 endpoints, truncates payload
bodies to ~3500 bytes.
- **De-dup gate:** skips push when the user has any active Socket.IO
connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a
connected user only gets the in-app chime, never a duplicate system
notification.
- `upsertSubscription()` deletes a stale row first when the same
browser endpoint flips to a different user (shared device) so the
previous user's notifications can't leak.
- Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`,
`POST /api/push/unsubscribe` (auth-gated, Zod-validated).
- Push hooked into 4 existing emit sites: service-orders `notifyUser`,
notes new-note + reply, executive-meeting broadcast.
Frontend
- `artifacts/tx-os/public/sw.js`: push + notificationclick only (no
asset caching). All URLs (icon, badge, navigation target) resolved
against `self.registration.scope`, so the SW works at root or under
a subpath without code changes.
- SW registration in `main.tsx` scoped to `BASE_URL`.
- `use-push-subscription` hook (enable/disable/refresh + status).
- New `PushEnablePrompt` card mounted in `App` — appears on first
launch when supported + permission still "default", one-tap enable,
dismiss persists for 14 days. Silent on unsupported devices.
- `PushToggleRow` added inside Notification Settings.
- ar/en strings: `notifSettings.push.*` and `common.later`.
Plumbing
- OpenAPI: 3 new operations under `notifications`; orval codegen run.
- `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY /
VAPID_SUBJECT through to the api service.
- `pnpm --filter @workspace/db run push` applied the schema.
- web-push + @types/web-push installed in api-server.
Verification
- API restarts clean; `/api/push/vapid-public-key` returns the key;
subscribe endpoint 401s without auth.
- New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing —
covers VAPID endpoint, auth gating on subscribe/unsubscribe, row
persistence + removal, idempotent re-subscribe with key rotation,
account-switch endpoint reassignment, and malformed-input rejection.
- Two architect rounds: first flagged race + payload size (fixed),
second flagged dedup gate + first-launch UX + SW base-path + tests
(all fixed in this commit).
- artifacts/tx-os/src/App.tsx: serve the schedule at /meetings; add an
ExecutiveMeetingsRedirect that rewrites /executive-meetings (and any
/executive-meetings/* sub-path) to /meetings while preserving the
query string and hash, so existing PDFs, emails, and bookmarks keep
working with one transparent hop.
- scripts/src/seed.ts: seed the apps row with route="/meetings" and
update the expectedBuiltinRoutes drift guard to match. Add an
idempotent UPDATE-by-slug after the apps insert so already-deployed
rows (including any drift like /executive-meetings or /mms) get
reconciled to /meetings on the next seed — safe because the slug is
in BUILTIN_APP_SLUGS, meaning the SPA owns the route.
- artifacts/tx-os/tests/meetings-route-redirect.spec.mjs: new spec
verifying that /meetings loads, /executive-meetings?date=...#... is
redirected with query+hash intact, and /api/apps now reports
route="/meetings" for the executive-meetings slug.
Out of scope (intentionally left untouched per task): API paths under
/api/executive-meetings/*, React Query keys, DB tables, the apps slug
"executive-meetings", page/component file names, the "Meetings" display
name, and the executive_meetings:* permission name.
Admin Add/Edit App now supports:
- Custom image upload (or fall back to Lucide icon) via the existing
ServiceImageUploader; rendered on the home launcher when set.
- Open mode picker: internal (default), external_tab (window.open),
external_iframe (renders inside /embedded/:id). External URL input
shown conditionally and required by the form when an external mode
is chosen.
- Route field is locked (readOnly + lock hint) when editing a built-in
app, since those slugs are hardcoded in the SPA router.
Backend:
- apps schema gains image_url, external_url, open_mode (default
'internal'); drizzle-kit push applied.
- New lib/db/src/built-in-apps.ts exports BUILTIN_APP_SLUGS +
isBuiltinAppSlug, re-exported from lib/db.
- PATCH /apps/:id rejects route changes whose previous slug is
built-in with 400 + code='builtin_route_locked'. Same-route no-op
is allowed; non-route updates on built-ins still work.
Other:
- New SPA route /embedded/:id and embedded-app page (iframe host with
back + open-in-new-tab + error/not-embeddable states).
- OpenAPI App / CreateAppBody / UpdateAppBody extended; codegen ran.
- en/ar locales: admin.appImage, appExternalUrl, appOpenMode.*,
builtinPathLocked, embeddedFrame.*.
- New tests in apps-builtin-route-lock.test.mjs (4/4 pass) covering
reject built-in route change, allow non-route built-in updates,
allow non-builtin route changes, allow built-in same-route no-op.
Notes / drift:
- BUILTIN_APP_SLUGS is duplicated inline in admin.tsx
(BUILTIN_APP_SLUGS_FE) because the browser bundle cannot import
@workspace/db (pulls pg). Comment points at the canonical source;
drift risk filed as a follow-up.
- Pre-existing failures unrelated to this task: 3 tests in
executive-meetings-* and tsc errors in
api-server/src/routes/executive-meetings.ts. Out of scope.
- Backend: extend `note_received` socket payload with note snapshot
(title, content, color, sentAt), recipientRowId, senderUserId, and
sender summary so the recipient can render the popup without an extra
fetch.
- Add IncomingNotePopupContext: FIFO queue, dedupe by note id, suppress
events for the user's own sends, auto-clear on logout.
- Add IncomingNotePopup AlertDialog that shows the note (title/content/
color) with a sender chip and Reply / Mark read / Open in Notes /
Dismiss buttons. Acknowledge actions call /notes/:id/read.
- Wire the popup globally in App.tsx (alongside the socket bridge).
- Notes page accepts a `?thread=ID` deep link so the popup's Open and
Reply buttons land on the inbox tab + thread dialog.
- Added bilingual `notes.popup.*` strings in EN + AR (RTL respected).
- New Playwright e2e (`notes-popup-on-receive.spec.mjs`) verifies the
popup appears in a second browser context within seconds, sender
doesn't see their own popup, and dismiss closes it.
DB
- Added 7 user pref columns; vibration is now per-channel
(vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
sounds, vibration on, mute off, volume 70.
API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.
Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
(notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute, volume, slot tabs (orders/meetings) with
per-channel sound + per-channel vibration + per-channel toggle, sound list
with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
meeting (deduped by meetingId, survives 30s polling refetches).
- AR/EN translations added.
Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume).
- API: PATCH /auth/me/notification-preferences with whitelisted partial update,
integer guard for volume, and hydrated AuthUser response. AuthUser now exposes
the 7 new fields.
- Frontend: Web Audio synth library (8 sounds), settings popover with global
mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons,
shift+click on bell for quick mute. Concurrency-safe optimistic updates with
monotonic seq counter. RTL-correct toggle knob transforms.
- Socket hook plays sound on notification_created (orders/meetings only) when
tab is hidden, respecting global mute and per-type toggles.
- Audio unlock hook mounted globally to satisfy autoplay restrictions.
- AR/EN translations added.
Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing
test failures in test workflow are unrelated to this task.
- New `executive_meeting_alert_state` table (per-user, per-meeting) tracking
`dismissed`/`acknowledged`. Migration via `pnpm --filter @workspace/db
run push-force`.
- New API routes in artifacts/api-server/src/routes/executive-meetings.ts:
GET /alert-state, POST /:id/alert-state, POST /:id/postpone-minutes,
POST /:id/reschedule, POST /:id/cancel. All three mutation routes
acquire a row lock with SELECT ... FOR UPDATE inside the transaction,
compute oldValue from the locked snapshot, run conflict detection in
the same tx, and write the audit row before commit. Cancel is
idempotent. Postpone-minutes rejects ranges that would cross midnight
(use reschedule for cross-day moves).
- Alert-state route uses race-safe onConflictDoNothing upsert + a
conditional UPDATE ... RETURNING so transition audits never duplicate.
- New i18n keys `executiveMeetings.alert.*` in en.json + ar.json,
including the cancel-confirm prompt.
- New component artifacts/tx-os/src/components/executive-meetings/
upcoming-meeting-alert.tsx — globally mounted in App.tsx inside
AuthProvider. Draggable with localStorage position persistence,
RTL-aware, polls every 30s, shows start–end window, postpone-by-
minutes chips [5,10,15,30,45,60], full reschedule sub-form, and a
Cancel-meeting flow that requires an explicit confirm step before
the destructive call fires (gated on confirmCancel state).
- Playwright spec executive-meetings-upcoming-alert.spec.mjs with
6 scenarios: appear+Done, postpone-10 shifts times, cancel-with-
confirm, dismiss audit, postpone-chip+conflict-warning toast,
AR/RTL render. All 6 pass.
- replit.md updated with the new table, routes, and migration step.
Pre-existing tsc errors at lines 489, 605, and 2039 of
executive-meetings.ts are not touched by this task.
Original umbrella covered #144, #145, #168, #169, #199, #200, #211, #217, #222.
Three landed here; #200 was already implemented; remaining five proposed as
follow-ups #248–#250.
#169 — Removed dead /executive-meetings/print route:
- Deleted artifacts/tx-os/src/pages/executive-meetings-print.tsx
- Removed import + Route from App.tsx
- Removed executiveMeetings.print blocks from en.json and ar.json
#222 — "+ شخص هنا" chip in inter-person gaps:
- Added addPersonHere i18n key (ar/en)
- Threaded addPersonHereLabel through AttendeeFlowSharedProps → flowProps → AttendeeFlow
- Renders chip after a person row when next item is also a person (not a subheading);
reuses existing onStartAdd("person", i+1) plumbing
- Test IDs em-add-person-after-row-${i} / em-add-person-after-${i}
#199 — Bulk delete undo via toast action:
- ToastAction wired into deleteSelectedMeetings result toast
- Snapshots captured client-side before DELETE
- Undo recreates each row via existing POST /api/executive-meetings (omitting
dailyNumber to avoid 409s on stolen slots), then PATCH if merge fields existed
- Single-fire guard prevents double-click duplicates
- Updated bulkDeleteConfirm to drop the now-untrue "cannot be undone" line
- New strings: bulkDeleteUndo / bulkDeleteUndone / bulkDeleteUndoPartial /
bulkDeleteUndoFailed in ar+en
Verification:
- tsc --noEmit clean for all touched files (admin.tsx errors are pre-existing,
unrelated to this diff)
- Playwright executive-meetings-bulk-actions.spec.mjs: 5/5 pass
- Pre-existing flake meeting_created socket fan-out test passes in isolation
(unrelated to changes)
Implement a new API endpoint and UI for managing executive meetings, including role-based access control and database schema changes for meeting data and attendees.