Renamed two Arabic sidebar tab labels in the protocol (room-booking)
module in artifacts/tx-os/src/locales/ar.json:
- protocol.tabs.bookings: "حجوزات القاعات" → "حجز القاعات"
- protocol.tabs.external: "اللقاءات الخارجية" → "اجتماعات خارجية"
English translations left unchanged. The chart label
"اللقاءات الخارجية حسب الحالة" was left as-is per the task's out-of-scope
note. Text-only change, no logic touched.
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.
Every booking (public + internal) now gets a unique, NOT NULL, searchable
reference (RB-{year}-{pad4(id)}) shown on the success screen. Staff can
search the admin bookings list by reference OR phone.
Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
bookingReference (NOT NULL + unique) + requesterPhone index; new
enums/types. Applied via drizzle push; existing rows backfilled.
- api-server: extended public booking schema + refine (org required when
external); reserveBookingId (nextval on the serial sequence) so the
reference is written in a single INSERT and can never be null; GET
/protocol/bookings accepts a `q` param filtering by reference OR phone
(server-side, indexed, case-insensitive).
- tx-os public form rewritten; admin card shows new fields; admin search
box now debounced and wired to the API `q` param (server-side filtering)
with an empty state.
- ar/en i18n keys under protocol.bookings.
Verification:
- Public booking POST returns a reference; external-without-org rejected
(400); DB has zero null references after push.
- Pre-existing push.ts / executive-meetings.ts typecheck errors are
unrelated to this task (files untouched).
Public booking form now captures department, meeting type (internal/
external), meeting purpose, and a repeatable "key attendees" table
(name + position + side). External meetings require the entity name
(reuses requesterOrg). "عدد الحضور" relabeled to "العدد المتوقع للحضور".
Added a static red disclaimer note about possible cancellation.
Each booking now gets an auto-generated searchable reference
(RB-{year}-{pad4}) shown on the success screen; staff can search the
admin bookings list by reference OR phone.
Changes:
- lib/db schema: department, meetingType, purpose, keyAttendees (jsonb),
bookingReference (unique) + requesterPhone index; new enums/types.
Applied via drizzle push; backfilled existing 4 rows.
- api-server: extended public booking schema + refine (org required when
external), makeBookingReference, public/internal POST generate+return
bookingReference.
- tx-os public form rewritten; admin card shows new fields + client-side
search with empty state.
- ar/en i18n keys under protocol.bookings.
Notes:
- Pre-existing push.ts typecheck errors are unrelated to this task.
- Architect review: PASS, no blocking issues.
Relabeled three Protocol (العلاقات العامة والمراسم) menu items to match real usage:
- photos: "تصوير" -> "الصور" / "Photos"
- gifts (item catalog where أصناف are added): "الإهداءات" -> "المستودع" / "Warehouse"
- issues (giving gifts out): "طلبات الصرف" -> "الإهداءات" / "Dedications"
Swapped menu icons to match new meanings: gifts->Warehouse, issues->Gift,
photos->ImageIcon. Removed now-unused imports (PackageCheck, Camera).
Grouped المستودع + الإهداءات under one expandable submenu (parent label
"الهدايا"/"Gifts", new i18n key protocol.tabs.giftsGroup, HandHeart icon,
ChevronDown toggle). Group auto-expands when a child tab is active; state
giftsGroupOpen. RTL-aware (ms-auto chevron, md:ps-6 child indent).
Aligned in-screen wording with new names in both locales:
dashboard.pendingIssues, issues.new, issues.empty reworded from
issuance/صرف to dedication/إهداء.
Deviation: submenu grouping and parent-group naming ("الهدايا") were an
interpretation of the user's "sub-menu" request (user_query was unavailable
during planning). Kept scope tight: remaining secondary "issuance/صرف"
strings in reports/actions/dashboard stats (e.g. giftsIssuedThisMonth,
actions.issued, reports.byGift) were left unchanged and proposed as a
follow-up.
Unchanged (deliberately): internal tab keys (gifts/issues/photos) and URL
slugs (gifts, gift-issues, photos) so deep links keep working. No feature,
API, or data changes.
Verified: pnpm --filter @workspace/tx-os run typecheck passes; architect
code review passed after issues icon corrected to Gift.
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets both selectedDay and a new
controlled calendarMonth state to new Date(), so the month picker, day
time-grid, and red now-line all snap to today. The Calendar is now
controlled via month/onMonthChange (defaultMonth is initial-only and did
not re-navigate after mount).
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
the existing toolbar new-booking button. Opens the new-booking dialog
pre-filled with the currently selected day.
Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
Arabic RTL.
No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
Added two quick actions inside the Protocol bookings "Calendar" view's day
panel header (artifacts/tx-os/src/pages/protocol.tsx):
- "Today" (اليوم): outline button that resets selectedDay to new Date(), so
the month picker, day time-grid, and red now-line all snap to today.
- "Book" (حجز): primary button gated by the same c?.canRequest capability as
the existing toolbar new-booking button. Opens the new-booking dialog
pre-filled with the currently selected day.
Implementation:
- Extended bookingDialog state with an optional `day?: Date`.
- BookingDialog now accepts an `initialDay?: Date` prop; for new (non-edit)
bookings it pre-fills startsAt=09:00 and endsAt=10:00 on that day via a
local dayAt(hour) helper (reuses toLocalInput). Edit flow unchanged.
- Added i18n keys protocol.bookings.today / protocol.bookings.book to both
ar.json and en.json. Buttons use logical spacing (me-1) so they mirror in
Arabic RTL.
No backend/API changes. List/Calendar toggle unaffected. tx-os typecheck
passes; HMR clean, no console errors. Screenshot tool unavailable (no
artifact registered in this repo), verified via typecheck + logs.
Replaced the Protocol bookings "Calendar" view (previously an agenda list
grouped by day) with a two-panel Google-Calendar layout in
artifacts/tx-os/src/pages/protocol.tsx:
- LEFT: a mini month day-picker (reuses components/ui/calendar.tsx,
react-day-picker single mode) driving a selectedDay state. Days that
have bookings get a small sky dot via a `booked` modifier.
- RIGHT: a single-day vertical time-grid (BookingDayGrid) over working
hours 07:00–22:00 (56px/hour). Bookings render as absolutely-positioned
blocks: top = start offset, height = duration (min 22px), colored by
status (BOOKING_BLOCK_STYLE) with an inline-start accent border and a
title/room/time label.
- Overlapping bookings are laid out side-by-side via interval-partition
column assignment (layoutDayBookings), chained overlaps share a column
count.
- A live RED current-time line + red dot (useNow, 60s) shows only when the
selected day is today and within grid hours.
Details:
- View-only (no drag/create); single-day grid only. No backend changes.
- RTL/i18n: logical props (insetInlineStart, border-s) so it mirrors in
Arabic; added protocol.bookings.emptyDay to ar.json + en.json.
- Blocks with no visible intersection with the grid range are skipped
(fixes a phantom-block bug for bookings fully before 7am / after 10pm,
found in code review).
- Removed the now-unused groupBookingsByDay helper.
- List toggle preserved. tx-os typecheck passes.
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.
Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
Content-Disposition: attachment (with optional ?name base + derived ext);
default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
photos as a ZIP (guarded by requireProtocolAccess), named from the album;
unreadable objects are skipped rather than failing the archive. Empty album
returns 404.
- Added archiver@8 dependency (+@types/archiver dev).
Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header; rendered disabled
(not hidden) when the album has no photos.
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.
Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
`new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
api-server typecheck + build pass, tx-os typecheck passes, server healthy,
new routes wired (401 auth-required not 404). Pre-existing typecheck errors
in executive-meetings.ts / push.ts are unrelated and untouched.
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.
Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
Content-Disposition: attachment (with optional ?name base + derived ext);
default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
photos as a ZIP (guarded by requireProtocolAccess), named from the album;
unreadable objects are skipped rather than failing the archive. Empty album
returns 404.
- Added archiver@8 dependency (+@types/archiver dev).
Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header (hidden when empty).
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.
Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
`new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
api-server typecheck + build pass, tx-os typecheck passes, server healthy,
new routes wired (401 auth-required not 404). Pre-existing typecheck errors
in executive-meetings.ts / push.ts are unrelated and untouched.
Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums.
Task #655: staff need a one-click way to copy and share the public
(no-login) room-booking link with guests.
- Add a link bar at the top of the "حجوزات القاعات" (Room Bookings)
section showing the absolute public URL, with a "نسخ الرابط" copy
button that gives toast + a temporary "تم النسخ" confirmation state.
- URL is built from window.location.origin + BASE_URL (trailing slash
stripped) + "/protocol/request", so it works on root and subpath
deploys and when pasted into a browser/WhatsApp.
- Clipboard uses navigator.clipboard with a textarea+execCommand
fallback; fallback now checks execCommand's return and reports failure
(destructive toast) instead of falsely showing success. Textarea is
removed in a finally block. Copied-state timer stored in a ref and
cleared on repeat clicks to avoid a reset race.
- URL text forced dir="ltr" inside the RTL layout; new i18n keys
(shareLinkTitle, copyLink, linkCopied, linkCopyFailed) added under
protocol.bookings in both ar.json and en.json.
Verified: tsc typecheck passes; /protocol serves 200; module transforms
cleanly via Vite; architect review issues (fallback false-success,
timer race) fixed.
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface.
Updates API schemas and database to include attendee count for bookings, modifies the public request form to accept this new optional field, and displays the attendee count on the booking details.
Introduces a new public-facing API endpoint and UI for submitting room booking requests without authentication, including rate limiting and input validation.
New, fully separate app module "العلاقات العامة والمراسم / Public
Relations and Protocol". Does not touch executive-meetings; all new
tables are prefixed protocol_ and are additive-only, routes under
/protocol.
Includes:
- 6 protocol_ tables (rooms, room bookings, external meetings, gifts,
gift issues, audit log) in lib/db.
- API routes + role-scoped middleware (protocol_super_admin, pr_manager,
officer, requester, viewer) with requireProtocolAccess gating.
- Room-booking conflict prevention (overlap rule newStart<existingEnd
AND newEnd>existingStart vs pending/approved) with AR error message.
- Gifts/shields (دروع) registry + issuance with stock tracking.
- Bilingual AR/EN frontend page (tabs + dialogs) at /protocol, app tile,
i18n keys, and seed data (roles, permission, default rooms).
Concurrency/security hardening from code review:
- All role guards now validate users.isActive (inactive-user bypass fix).
- Per-room SELECT ... FOR UPDATE lock serializes booking check-then-write.
- Approve paths re-read inside the transaction and use state-conditional
updates (WHERE ... AND status='pending' RETURNING).
- Gift issuance claims the issue conditionally, then does an atomic
conditional stock decrement; failure rolls back the claim.
Verified: drizzle push (6 tables), seed, frontend + api-server typecheck
(only pre-existing errors in untouched files), unauth gating returns 401,
conflict predicate confirmed. Architect review PASS.
Apply 'text-center' class directly to the table data (td) elements for meeting and attendee columns to ensure consistent alignment with the time column.
Update `artifacts/tx-os/src/pages/executive-meetings.tsx` to apply horizontal centering to meeting titles, locations, attendee group headers, attendee names, and merge placeholders for improved visual consistency.
Problem: When a meeting was flagged external, the row was tinted red
(pink bg + red number box — desired) but the title cell text and the
attendee list text were also forced to red (#b91c1c), which the user
did not want.
Change (artifacts/tx-os/src/pages/executive-meetings.tsx):
- Meeting title cell: always use navy text (#0B1E3F) instead of switching
to red on meeting.isExternal.
- Attendee list <ul>: always use navy text (#0B1E3F) instead of switching
to red on externalText.
- Removed the now-dead `externalText` prop end-to-end (call-site value,
type declaration, and destructured default) to keep typecheck clean.
Unchanged: the red row tint, red number box, and all external-meeting
behavior (e.g. auto-postpone rules). Internal/virtual meetings unaffected.
Verified: tx-os typecheck passes; HMR applied with no console errors.
Enhance notification handling to preserve deep-link parameters, add relatedId and relatedType to notifications for better association, and implement Arabic pluralization for meeting reminders.
- Attendees column no longer centres its content. AttendeeFlow's <ul>
switched from justify-center to justify-start; AttendeeGroup header
and subheading rows (both committed and pending) switched from
text-center to text-start. Restores cell-start alignment (right in
AR, left in EN) the user expected on fresh inline-add and on
existing rows.
- External meetings now render the meeting title cell text and the
attendee names in red (#b91c1c), matching the row tint family, in
addition to the existing red background. Implemented by:
* Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
based on meeting.isExternal.
* New optional `externalText` prop on AttendeeFlowSharedProps,
threaded from AttendeesCell (derived from meeting.isExternal) and
applied as a colour class on the AttendeeFlow root <ul> so all
attendee names inherit red. Group headers and subheadings keep
their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
are untouched. Print/PDF path reuses the same renderer so the red
carries through automatically.
- No schema/API/migration changes. TS clean.
Files: artifacts/tx-os/src/pages/executive-meetings.tsx
- Attendees column no longer centres its content. AttendeeFlow's <ul>
switched from justify-center to justify-start; AttendeeGroup header
and subheading rows (both committed and pending) switched from
text-center to text-start. Restores cell-start alignment (right in
AR, left in EN) the user expected on fresh inline-add and on
existing rows.
- External meetings now render the meeting title cell text and the
attendee names in red (#b91c1c), matching the row tint family, in
addition to the existing red background. Implemented by:
* Conditional text-[#b91c1c] / text-[#0B1E3F] on the meeting <td>
based on meeting.isExternal.
* New optional `externalText` prop on AttendeeFlowSharedProps,
threaded from AttendeesCell (derived from meeting.isExternal) and
applied as a colour class on the AttendeeFlow root <ul> so all
attendee names inherit red. Group headers and subheadings keep
their neutral navy palette per scope.
- Non-external rows and the cancelled/highlighted white-on-red # cell
are untouched. Print/PDF path reuses the same renderer so the red
carries through automatically.
- No schema/API/migration changes. TS clean.
Files: artifacts/tx-os/src/pages/executive-meetings.tsx
Two issues reported on the new "اجتماع خارجي" toggle from #635:
1. "Doesn't save." Investigation: the server schema, PATCH/POST handlers,
GET endpoint (full-row select), and DB column are all correct, and
`docker/migrate.sh` runs `pnpm --filter @workspace/db run push-force`
on every `docker compose up`, so the production schema does receive
the `is_external` column. The likeliest real-world cause is a stale
API image (column existed but the post-review API code that persists
the flag wasn't deployed yet), or a transient stale-state render
where `state.isExternal` was undefined and silently dropped from the
PATCH body. Hardened the client so neither can happen again:
- Both save sites (Manage dialog `save()` ~6700, inline Schedule
edit save body ~3421) now send `Boolean(state.isExternal)` instead
of the raw value, so an undefined state coerces to `false` rather
than dropping the field.
2. "Overlaps other fields." The toggle row was sharing a row with the
date input in the 2-col grid and used a fixed `h-9` wrapper that
sat awkwardly next to the taller time pickers on narrow screens.
- Made the FormRow `full` so it spans both columns on its own row,
above the time pickers.
- Dropped the fixed `h-9` and switched to `py-1` + `gap-3` + `text-sm`
so it matches the form's vertical rhythm on iPhone, iPad portrait,
iPad landscape, and desktop.
Out of scope / not changed
- No server changes (the server already accepts boolean | number and
coerces to boolean; `Boolean(data.isExternal ?? false)` on POST and
`if (data.isExternal !== undefined)` on PATCH are unchanged).
- No new badge / copy — that lives under separate proposed work.
- Postpone / cascade rules unchanged.
User asked for three things in the executive-meetings module:
1. Remove the "daily number" input from the add/edit dialog — numbering
is auto-assigned by the server already, so the field was just noise.
2. Add an "اجتماع خارجي" (external meeting) toggle. When ON, the row
is force-tinted red in the schedule grid.
3. External meetings must be excluded from the auto-postpone flow:
the alert "Postpone" button is disabled, and the cascade shift
skips them as followers.
Changes
- lib/db/src/schema/executive-meetings.ts
+ `is_external boolean not null default false` column. Pushed via
drizzle-kit (no destructive migration; defaults backfill).
- artifacts/api-server/src/routes/executive-meetings.ts
+ `isExternal` added to base zod fields, POST insert, PATCH update.
+ POST/PATCH force `rowColor='red'` when `isExternal=true`.
+ POST /:id/postpone-minutes rejects external meetings with
HTTP 409 + code `external_meeting_no_auto_postpone`.
+ `computeCascadeShift` filters out external followers (preview +
writer stay in lockstep, as the comment promises).
- artifacts/tx-os/src/pages/executive-meetings.tsx
+ Meeting type + MeetingFormState gain `isExternal`; dropped the
`dailyNumber` field from the form state entirely.
+ `emptyMeetingForm` / `openEdit` updated; ManageSection.save() and
the inline ScheduleSection save body now send `isExternal` and
never send `dailyNumber`.
+ MeetingFormDialog: removed the daily-number FormRow and added a
Switch-based external-meeting FormRow with localized hint.
+ `rowColors` memo coerces to "red" whenever `m.isExternal`.
+ Audit-diff "interesting" list now includes `isExternal`.
- artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx
+ Meeting type gains optional `isExternal`.
+ "Postpone" button disabled + tooltip when meeting is external.
- locales: added `field.isExternal`, `field.isExternalHint`,
`field.isExternalOn/Off`, and `alert.externalCannotPostpone` in
both ar.json and en.json. Existing dailyNumber keys kept (still
referenced by the manage-table column header / cell display).
Post-review hardening (from architect pass)
- PATCH now coerces rowColor → 'red' for any PATCH that touches a row
that is (or becomes) external. Prevents PDF/export paths that read
raw rowColor from showing a non-red tint for externals.
- Schedule row quick-actions popover: the "Postpone" button is now
disabled with a localized tooltip when the meeting is external,
matching the upcoming-meeting alert behavior.
- PostponeDialog.handleErr maps the 409 code
`external_meeting_no_auto_postpone` to the localized
`alert.externalCannotPostpone` toast so users see a real reason
instead of the raw English error string.
Notes
- The `dailyNumber` column stays in DB (heavily used for slot
persistence and the unique index); only the form input was removed.
Server's `nextDailyNumber()` already auto-assigns when the field is
absent on POST, and PATCH leaves it untouched when absent.
- Two pre-existing TS errors remain in api-server (`isHighlighted`
type-inference quirk on `z.union(...).transform(...)` and a
font_settings comparison) — not in scope for this task.
- Tests not added; verification by typecheck on changed surfaces and
workflow-restart smoke. No e2e harness was wired.
User-reported regression from #630: opening "Send note" on iPad PWA
showed the recipient list pushed to the left side of the screen,
narrower than designed, with the dialog title + search input clipped
off the top. Dismissing the keyboard restored normal centered layout.
Root cause: the `keyboardFallback` branch (iPad PWA, where
visualViewport doesn't update) set `top: "6vh"` in *layout viewport*
coordinates. On iPad PWA Safari, opening the keyboard scrolls the
page down behind the keyboard, so layout-viewport `6vh` ends up
above the visible visual viewport — clipping the top half of the
dialog. Separately, the inline `transform` replaced Tailwind's
translate-x/y on Radix's content, and without an explicit `left:50%`
the animation/RTL combination shifted the box left.
Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):
- `keyboardFallback`: `top: calc(${vv.offsetTop || 0}px + 6vh)` so the
anchor follows the visible visual viewport when Safari scrolls the
page behind the keyboard. Add explicit `left: 50%`. Add
`overflowY: auto` so if dialog content exceeds 50dvh, the dialog
scrolls internally instead of clipping the header.
- `keyboardActive` branch: also add explicit `left: 50%` defensively
for the same animation/RTL reason (no behavior change on devices
where it was already working, but eliminates the asymmetry).
No changes to the shared `dialog.tsx`, composer, or executive-meetings
dialog — scope strictly limited to the regression site.
TypeScript: clean. Pre-existing errors in api-server/src/routes/push.ts
unrelated to this diff.
Bug: order creator (services manager) saw new orders instantly with
sound, but other users holding orders.receive — even with the app
foregrounded on the Orders screen — got nothing until they force-quit
and reopened the PWA, at which point the orders appeared. Root cause:
Socket.IO silently drops behind Tailscale/NAT on iPad PWA and the
client neither detected it nor refetched missed state on reconnect.
The creator was unaffected because their POST mutation updates their
queries locally without depending on the realtime channel.
Client (artifacts/tx-os/src/hooks/use-notifications-socket.ts):
- Tighten io() options: reconnection {Delay 500, DelayMax 3000,
Attempts Infinity, timeout 10s}.
- Track `wasDisconnected` flag; on `disconnect` flip it and log the
reason (skipping the clean "io client disconnect" path).
- On `connect`, if previously disconnected, invalidate every realtime-
driven query key: notifications, home stats, my/incoming orders,
notes, note-folders, exec meetings (list/alert-state/notifications),
apps, /me, roles, permissions. Warmup window is reset BEFORE the
refetch so any flushed notification_created events post-reconnect
don't chime.
- Add a `visibilitychange` listener: on foreground return, if the
socket is disconnected, force `socket.connect()`; if it's "connected"
but possibly half-open (silent drop), round-trip a 3s-timeout
`client_health_probe` ack — on timeout, `disconnect().connect()`.
- `connect_error` logger for field debugging.
Server (artifacts/api-server/src/index.ts):
- Tighten Socket.IO pingInterval=10s, pingTimeout=5s (was default
25s/20s) so dead-connection detection cycle drops from ~45s to ~15s.
- Add `client_health_probe` handler that acks immediately — pairs
with the client-side half-open probe.
Deviations from plan: skipped the optional UI connection indicator
(point 5) — not necessary to fix the reported bug; can ship later if
users still feel uncertain about connection state.
Architect approved with one minor caveat: severe (>3s) transient
latency on foreground could trigger a one-off socket cycle. Acceptable
tradeoff and explicitly documented in the comment.
Pre-existing TS errors in api-server/src/routes/push.ts are unrelated
to this task and not touched by this diff.
Problem: opening Send Note on iPad, tapping the search field opened the
soft keyboard which covered the recipients list and the Send button —
the dialog stayed centered on the full layout viewport.
Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):
Two-tier keyboard detection:
1. Primary — `vv.keyboardInset > 0` from useVisualViewport. Works on
desktop touch laptops, iPad Safari (regular tab), Android Chrome.
When true, override DialogContent style with
top = vv.offsetTop + vv.height/2 − 48 (QuickType inset)
transform: translate(-50%, -50%)
so the dialog recenters around the visible viewport region.
2. Fallback — `searchFocused && matchMedia('(pointer: coarse)')`.
Only used when visualViewport does NOT report an inset, which is
the iPad PWA (Add to Home Screen) case explicitly called out in
components/ui/dialog.tsx comment. Top-anchors at 6vh with
maxHeight 50dvh so the dialog never crosses the screen midpoint.
3. Neither — pass NO style at all so Radix's default centered layout
is fully restored (no leftover transition or top).
Touch-only fallback gating (matchMedia pointer:coarse) prevents the
earlier desktop-regression flagged by code review — focusing the
search on a desktop never repositions the dialog.
Other changes:
- onFocus/onBlur on the search input feed the fallback signal.
- Blur is debounced 150ms so tapping a recipient row mid-blur doesn't
recenter the dialog out from under the finger.
- useEffect cleanup clears pending blur timeout on unmount.
Verification: tsc clean. Architect approved the touch-gated fallback;
follow-up review reconciled the diff with the spec's primary
visualViewport-driven path.
Problem: opening Send Note on iPad PWA, tapping the search field popped
up the soft keyboard which covered the entire people list and the Send
button. The previous fix relied on visualViewport to shrink the list,
but iPad PWAs don't reliably resize visualViewport when the keyboard
opens (see the note in components/ui/dialog.tsx referencing the
reverted #622–#624 global attempts).
Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx):
- Track search-input focus directly — the most reliable "keyboard is
about to open" signal on touch devices, independent of visualViewport.
- Gate the override behind `matchMedia('(pointer: coarse)')` so it
only fires on touch-primary devices (iPad/iPhone/Android) and never
on desktop or trackpad laptops — addressing the architect's
cross-platform regression flag.
- When the gate is active, override DialogContent's Radix center
anchor (top:50%; translate(-50%,-50%)) with `top:6vh;
translate(-50%,0); maxHeight:50dvh`. The dialog grows downward from
a fixed top edge so it always fits in the upper half of the screen,
leaving the lower half free for the keyboard.
- Cap `listMaxHeight` to ~50% of innerHeight in the same gated
condition, as a backstop where visualViewport stays full-height.
- Debounce blur by 150ms so a tap on a recipient row doesn't lose
the row mid-tap to a center-jump animation; clear the timeout on
unmount.
- Added 120ms ease transition on top/transform so the layout swap
doesn't snap.
Deviations: original plan used vv.offsetTop+vv.height/2 to reposition,
but that depends on the same unreliable visualViewport. Switched to
a focus + coarse-pointer driven approach which works on iPad PWA.
Verification: tsc clean. Architect re-review pending after the touch
gate was added (first review flagged the missing platform guard,
which this revision addresses directly).
Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected.
- /auth/logout: delete all push_subscriptions rows for the user before
destroying the session. Best-effort with logger.warn on failure so an
operator can correlate any leaked-ring report to a real DB error.
- home.tsx handleLogout: call pushSub.disable() before the logout
mutation, raced against a 1.5s timeout so a stalled service worker
cannot block sign-out.
- executive-meeting-scheduler: switch the eligibility filter from
denylist (ne cancelled + ne completed) to whitelist
(eq status='scheduled'). Postponed / rescheduled / future statuses
can no longer trigger false 5-minute reminders.
- docker-compose.yml: pin TZ=Asia/Riyadh on postgres, api, and web
services so the scheduler's naive date/time math matches the
operator's wall clock instead of UTC.
Gitea push failed with TLS error during this session — code committed
locally, needs manual `./scripts/publish-to-gitea.sh --push` retry
when the desktop-11cj93j tunnel recovers.
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.
#622's `useScrollFocusedDialogField` still failed on Tahani's iPad
for two reasons:
1) Touch-gate too narrow: `pointer: coarse` is *primary* pointer.
An iPad connected to a Magic Keyboard / trackpad reports primary
as `fine`, so the hook returned early and never armed. Broadened
to `(pointer: coarse) OR (any-pointer: coarse)` so any device
with any touch input qualifies.
2) `scrollIntoView` doesn't work inside `position: fixed` on iOS
Safari/PWA — the scroll silently no-ops. Replaced it with a
manual walk: from the focused field, ascend to the nearest
ancestor whose computed `overflow-y` is auto/scroll AND that
actually has overflow, stopping at the dialog root. Compute the
target's offset within that container's content and set
`container.scrollTop` so the target sits centred in the visible
band, clamped to [0, scrollHeight-clientHeight].
Also tightened cleanup per #622's reviewer note — timer IDs are
now removed from the tracking set inside the timeout callback as
well as on blur.
No CSS or dialog component changes. `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.
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.
The "edit role" dialog in admin.tsx was hand-rolled as a bespoke
<div role-less wrapper> with its own close button, sticky footer and
full-width split buttons. That made it look different from every
other admin dialog (add role, add/edit group, add user), and on
iPad the action buttons floated mid-dialog because the content
exceeded max-h-[92vh]. The custom wrapper also lacked role="dialog",
so the new `:has(:focus)` CSS keyboard rule (index.css:492) never
applied to it.
Replaced the wrapper with AdminFormDialog (maxWidth="lg", KeyRound
icon, same title key). All inner content — name/desc fields, rename
warning, permissions list, removal impact, RolePermissionHistory,
RecentActivityForTarget — moved verbatim as children. Removed the
custom sticky footer; AdminFormDialog provides the unified footer
with cancel/save buttons. Preserved testIds `edit-role-dialog` and
`edit-role-submit`. Disabled logic translated 1:1 into
submitDisabled + isPending props (note: `impactError` coerced to
boolean via `!!` to satisfy the prop type).
No behavior change: same handlers (closeEditDialog, handleEdit),
same loading spinner, same disabled conditions. Bonus: the keyboard
override CSS now applies because AdminFormDialog already sets
role="dialog".
`tsc --noEmit` clean.
Add a temporary debug overlay to the DialogContent component in `dialog.tsx` to display `keyboardInset`, `height`, and `offsetTop` values, and the keyboard open state.
Adjust dialog content to enable scrolling and center focused input fields, addressing issues on iOS and Android where content was hidden by the on-screen keyboard.
Adjusts drag activation constraints for pointer and touch sensors, enabling instant drag initiation in edit mode with a lower activation distance and no delay, while maintaining a higher threshold and delay outside of edit mode.
Modify dialog component to use window.innerWidth for width and position fixed, ensuring consistent display across iOS PWA environments by anchoring to the layout viewport.
Add `min-w-0` to `FormRow` component to prevent input fields from overlapping in RTL layouts and ensure proper column spanning on various screen sizes.
Adjust dialog positioning logic to use visual viewport dimensions for precise placement and sizing on iOS, resolving display issues caused by layout viewport discrepancies with the soft keyboard.
Adjust dialog positioning on iOS Safari to render as a full-width sheet when the keyboard is open, preventing layout shifts and ensuring content remains visible.
Adjust dialog positioning on mobile to anchor to the top of the visual viewport when the keyboard is open, ensuring content remains accessible and preventing overlap.
Long-press a service tile (~500 ms) on the Services page → grid enters
edit mode: every card jiggles, a floating "تم / Done" pill appears, and
cards become draggable via @dnd-kit. Drop on another tile to reorder.
Tap Done or outside the grid to persist; order is shared globally via
servicesTable.sortOrder.
Per the task default: admin-only. Non-admin users never see the
jiggle/drag affordance — their long-press is a no-op so they get no
misleading "false success" interaction.
Backend
- PATCH /api/services/reorder (admin-gated, requireAdmin)
- Validates full-set match (no missing/dup/unknown IDs) — full
catalogue rewrite, not a partial reorder
- Transactional sortOrder rewrite — no partial writes
- Route registered before /services/:id to avoid path-param collision
- OpenAPI op + ReorderServicesBody Zod schema → regenerated client
Frontend
- artifacts/tx-os/src/pages/services.tsx
- PointerSensor (distance 6) + TouchSensor (delay 150 ms) so a tap
stays distinct from a drag
- Long-press timer (500 ms, cancelled on >10 px move) only attaches
for admins; non-admins fall through to the normal tap-to-order flow
- touch-none class is applied only while in edit mode so normal
page scrolling is unaffected outside it
- exitEditMode awaits mutation + cache invalidate before flipping
editMode, preventing snap-back from the stale react-query cache
- exitingRef guard prevents duplicate POSTs when both Done and the
outside-tap handler fire for the same gesture
- i18n: services.editMode.{done,hint,saveFailed} in ar.json + en.json