From c4636174a2151de3bb49c05369c777d88ebd3f61 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Fri, 1 May 2026 12:38:21 +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 - GET /executive-meetings/alert-state now rejects any date that is not the server's current local date (returns 400 date_not_today). - 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. --- .../src/routes/executive-meetings.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 936e8a3d..5624331f 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -814,6 +814,24 @@ router.get( res.status(400).json({ error: "Invalid date", code: "invalid_date" }); return; } + // The 5-minute upcoming-meeting alert is, by definition, a "what is + // about to start in the next 5 minutes" widget — it has no semantic + // use for past or future dates. Restrict reads to the server's + // current local date so callers can't accidentally (or + // intentionally) enumerate other days' state via this endpoint. + const today = (() => { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + })(); + if (dateRaw !== today) { + res + .status(400) + .json({ error: "Date must be today", code: "date_not_today" }); + return; + } const userId = req.session.userId; const rows = await db .select({