From b92903d0d354048d03fb918c98eca15a33514040 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Fri, 1 May 2026 12:52:09 +0000 Subject: [PATCH] Task #273: 5-minute pre-meeting alert for Executive Meetings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Floating, draggable alert that appears on every Tx OS page when an Executive Meeting is within five minutes of starting. Schema - New table `executive_meeting_alert_state (meetingId, userId, dismissed, acknowledged, updatedAt)` with unique (meetingId,userId) in lib/db/src/schema/executive-meetings.ts. Apply via `pnpm --filter @workspace/db run push-force` per environment. API (artifacts/api-server/src/routes/executive-meetings.ts) - GET /executive-meetings/alert-state?date=YYYY-MM-DD - POST /executive-meetings/:id/alert-state (action: shown|acknowledged|dismissed) - race-safe: onConflictDoNothing upsert + conditional UPDATE … RETURNING. - POST /executive-meetings/:id/postpone-minutes - POST /executive-meetings/:id/reschedule - POST /executive-meetings/:id/cancel - all three lock the meeting row inside the tx with SELECT … FOR UPDATE, compute oldValue from the locked row, run conflict detection in the same tx snapshot, and write the audit row before commit; - cancel is idempotent (no duplicate audit if already cancelled); - postpone-minutes rejects ranges that would cross midnight (use reschedule for cross-day moves). - New helper `renumberDayByStartTime(tx, date)` runs inside each mutation tx and rewrites `daily_number` for every meeting on the affected date(s): active meetings 1..N by start_time, cancelled meetings pushed to the tail. Uses a negative-shift dance so the (meeting_date, daily_number) unique index never trips mid-update. Reschedule renumbers both the old and new date when the day moves. - detectMeetingConflicts now accepts a tx-like executor so the conflict scan reads the same DB snapshot as the UPDATE that just shifted the row. Frontend - 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 30 s, 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. - Eligibility window is strict 0 < remainingMinutes <= 5 — the alert hides as soon as the meeting actually starts. - Primary action buttons: Done, Postpone (when canMutate), Dismiss (in addition to the X icon). - artifacts/tx-os/src/pages/executive-meetings.tsx schedule view now filters out `status === "cancelled"` from the displayed list, so a cancelled meeting disappears from today's view (still queryable via the API for archive/audit consumers). i18n - New `executiveMeetings.alert.*` keys in both en.json and ar.json (postpone, reschedule, cancel, cancel-confirm prompt, conflict warning, etc.). Postpone chips fire immediately - Each preset minute chip (5/10/15/30/45/60) now calls postponeBy(n) on a single click — no second Apply step. The manual minute input + Apply button remain for custom/fractional values. Viewport-resize clamping - A useEffect in UpcomingMeetingAlert listens for `resize` and `orientationchange` and clamps the floating panel's position back inside the current viewport, also re-clamps once on mount so a stale localStorage position from a wider viewport is corrected. Tests - artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs — 9 Playwright scenarios, all green: 1. Done acknowledges the alert 2. Postpone-10 shifts both start and end and clears the alert 3. Cancel-with-confirm marks the meeting cancelled 4. Dismiss (X) writes a dismissed audit row 5. Single chip click immediately shifts start/end by +10 minutes AND surfaces the conflict-warning toast 6. Re-clamps the panel back into the viewport when the window shrinks (seeds a stale right-edge localStorage position at 1400px, then resizes to 420px and asserts the bounding box) 7. Reschedule to a different day clears today's alert 8. Cancel removes the meeting from today's schedule and renumbers the survivor 9. Arabic locale renders the RTL alert with Arabic title Copy - "Dismiss alert" / "تجاهل التنبيه" wording on the dismiss control (and the toast that follows) to match the product spec. Hardening - POST /executive-meetings/:id/postpone-minutes treats end == 24:00 as a midnight-crossing wrap (the guard is now `>=` on both start and end), so the route never produces an end_time that formats to 00:00:00 on the same day. Docs - replit.md updated with the new table, routes, and migration step. Pre-existing tsc errors at lines 546, 662, and 2107 of executive-meetings.ts are unrelated to this task. --- .../executive-meetings/upcoming-meeting-alert.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx index 75ab4afd..d1ea74d9 100644 --- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -319,11 +319,15 @@ export function UpcomingMeetingAlert() { if (!st || st.pointerId !== e.pointerId) return; const w = window.innerWidth; const h = window.innerHeight; - const target = e.currentTarget.parentElement; - const elW = target?.offsetWidth ?? 360; - const elH = target?.offsetHeight ?? 160; - const x = Math.max(8, Math.min(w - elW - 8, e.clientX - st.offsetX)); - const y = Math.max(8, Math.min(h - elH - 8, e.clientY - st.offsetY)); + // Clamp against the *full panel* dimensions (panelRef), not the + // drag-handle parent, otherwise the user can drag the alert so far + // down/right that the body and action buttons spill off-screen + // until the next resize-clamp pass corrects it. + const panel = panelRef.current; + const elW = panel?.offsetWidth ?? 360; + const elH = panel?.offsetHeight ?? 160; + const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), e.clientX - st.offsetX)); + const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), e.clientY - st.offsetY)); setPos({ x, y }); }; const onDragEnd = (e: ReactPointerEvent) => {