Task #273: 5-minute pre-meeting alert for Executive Meetings

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

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.
This commit is contained in:
riyadhafraa
2026-05-01 12:22:03 +00:00
parent bfb47b7fea
commit ea62e3382f
2 changed files with 147 additions and 20 deletions
@@ -263,9 +263,41 @@ export function UpcomingMeetingAlert() {
}, [eligible, enabled, stateById, refetchAlertState]);
// ----- Drag handling -----
const panelRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<DragPosition>(
() => loadPosition() ?? defaultPosition(),
);
// #273: keep the floating panel inside the viewport whenever the
// window resizes (e.g. mobile orientation change, or the user
// dragging the browser window narrower than where they had pinned
// the alert). Runs once on mount to clamp any stale localStorage
// position from a wider viewport, then on every resize.
useEffect(() => {
const clamp = () => {
const w = window.innerWidth;
const h = window.innerHeight;
const el = panelRef.current;
const elW = el?.offsetWidth ?? 360;
const elH = el?.offsetHeight ?? 160;
setPos((p) => {
const x = Math.max(8, Math.min(Math.max(8, w - elW - 8), p.x));
const y = Math.max(8, Math.min(Math.max(8, h - elH - 8), p.y));
if (x === p.x && y === p.y) return p;
const next = { x, y };
savePosition(next);
return next;
});
};
clamp();
window.addEventListener("resize", clamp);
window.addEventListener("orientationchange", clamp);
return () => {
window.removeEventListener("resize", clamp);
window.removeEventListener("orientationchange", clamp);
};
}, []);
const dragState = useRef<{
pointerId: number;
offsetX: number;
@@ -364,6 +396,7 @@ export function UpcomingMeetingAlert() {
return (
<>
<div
ref={panelRef}
role="dialog"
aria-live="polite"
aria-label={t("executiveMeetings.alert.title")}
@@ -574,14 +607,18 @@ function PostponeDialog({
});
};
const onPostponeMinutes = async () => {
const n = Number(minutes);
if (!Number.isFinite(n) || n <= 0) return;
// #273: shared mutation used by both the manual "Apply" button and
// each minute chip click so the chips can shift start/end immediately.
const postponeBy = async (delta: number) => {
if (!Number.isFinite(delta) || delta <= 0) return;
setBusy("minutes");
try {
const res = await apiJson<{ conflicts?: boolean }>(
`/api/executive-meetings/${meeting.id}/postpone-minutes`,
{ method: "POST", body: JSON.stringify({ minutes: Math.floor(n) }) },
{
method: "POST",
body: JSON.stringify({ minutes: Math.floor(delta) }),
},
);
handleResult(res, "executiveMeetings.alert.saved");
await onSaved();
@@ -591,6 +628,7 @@ function PostponeDialog({
setBusy(null);
}
};
const onPostponeMinutes = () => postponeBy(Number(minutes));
const onReschedule = async () => {
if (!resDate || !resStart || !resEnd) return;
setBusy("reschedule");
@@ -664,7 +702,14 @@ function PostponeDialog({
size="sm"
variant={Number(minutes) === n ? "default" : "outline"}
disabled={noTimeWindow || busy !== null}
onClick={() => setMinutes(String(n))}
// #273: chip click both selects the value AND immediately
// shifts start/end by that delta — the manual input below
// is only for fractional/custom amounts the chips don't
// cover.
onClick={() => {
setMinutes(String(n));
void postponeBy(n);
}}
data-testid={`postpone-chip-${n}`}
className="h-7 px-2 text-xs"
>
@@ -234,54 +234,136 @@ test("Upcoming-meeting alert: Dismiss (X) hides the alert and writes a dismissed
.toBeGreaterThanOrEqual(1);
});
test("Upcoming-meeting alert: Postpone-by-minutes chip 5 selects the value and conflict warning fires when overlapping another meeting", async ({
test("Upcoming-meeting alert: clicking a postpone chip immediately shifts start/end and surfaces a conflict warning when overlapping another meeting", async ({
page,
}) => {
await setLang(page, "en");
// Imminent meeting at +3 mins, plus a fixed neighbour starting at +13 mins.
// A 10-minute postpone of the imminent meeting (chip "5" => no overlap;
// chip 10 would overlap the neighbour). We use chip "10" to assert the
// conflict warning toast fires.
// Imminent meeting at +3 mins, plus a neighbour starting at +13 mins.
// Clicking the "10" chip must (a) shift start and end forward by 10
// minutes immediately on a single click — no Apply step needed — and
// (b) trigger the conflict-warning toast because the new window
// overlaps the neighbour.
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} chip AR`,
titleEn: `${TEST_TAG} chip EN`,
deltaMins: 3,
});
// Manually insert a neighbour 13 minutes from now spanning ~30 min.
const neighbour = await insertImminentMeeting({
titleAr: `${TEST_TAG} neighbour AR`,
titleEn: `${TEST_TAG} neighbour EN`,
deltaMins: 13,
});
// Snapshot original times so we can assert the +10-minute shift below.
const before = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[m.id],
);
const startBefore = String(before.rows[0].start_time);
const endBefore = String(before.rows[0].end_time);
await loginViaUi(page, "admin", "admin123");
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
// Startend window is shown.
await expect(page.getByTestId("alert-time-window")).toBeVisible();
await page.getByTestId("alert-postpone").click();
await expect(page.getByTestId("postpone-dialog")).toBeVisible();
// Click the "10" chip — fills the input.
// Single chip click is the entire interaction — no second "Apply".
await page.getByTestId("postpone-chip-10").click();
await expect(page.getByTestId("postpone-minutes-input")).toHaveValue("10");
await page.getByTestId("postpone-minutes-apply").click();
// The new times overlap the neighbour, so a conflict warning toast
// appears. Match either the EN or AR translation since the admin user's
// preferredLanguage may override the test's localStorage hint.
// Conflict warning toast (EN or AR — admin preferredLanguage may flip it).
// The Radix toast renders both a visible title element and an
// assertive a11y live-region with the same text, so scope the
// assertion to the visible ToastTitle to avoid strict-mode duplicates.
await expect(
page.getByText(/overlaps another meeting|يتداخل مع اجتماع/i),
page
.getByText(/overlaps another meeting|يتداخل مع اجتماع/i)
.first(),
).toBeVisible({ timeout: 5_000 });
// And the meeting was actually postponed.
await expect
.poll(() => audits(m.id, "meeting_postponed_minutes"), { timeout: 5_000 })
.toBeGreaterThanOrEqual(1);
// Verify start/end actually shifted by exactly +10 minutes in the DB.
const toMin = (hms) => {
const [h, mi] = String(hms).split(":");
return Number(h) * 60 + Number(mi);
};
await expect
.poll(async () => {
const { rows } = await pool.query(
`SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
[m.id],
);
const ds = toMin(rows[0].start_time) - toMin(startBefore);
const de = toMin(rows[0].end_time) - toMin(endBefore);
return ds === 10 && de === 10;
}, { timeout: 5_000 })
.toBe(true);
expect(neighbour.id).toBeGreaterThan(0);
});
test("Upcoming-meeting alert: re-clamps its position back into the viewport after the window shrinks", async ({
page,
}) => {
await setLang(page, "en");
const m = await insertImminentMeeting({
titleAr: `${TEST_TAG} clamp AR`,
titleEn: `${TEST_TAG} clamp EN`,
deltaMins: 3,
});
await loginViaUi(page, "admin", "admin123");
// Pre-seed a stale localStorage position that lives at the right edge
// of a wide viewport so a later resize *must* push the panel back.
await page.setViewportSize({ width: 1400, height: 900 });
await page.addInitScript(() => {
localStorage.setItem(
"txos.upcomingMeetingAlert.position",
JSON.stringify({ x: 1300, y: 80 }),
);
});
await page.goto("/");
await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({
timeout: 15_000,
});
// Now shrink to a phone-ish viewport and assert the panel's right edge
// stays inside the new viewport (with the 8-px safety margin).
await page.setViewportSize({ width: 420, height: 720 });
await expect
.poll(async () => {
const box = await page
.getByTestId("upcoming-meeting-alert")
.boundingBox();
if (!box) return false;
return box.x >= 0 && box.x + box.width <= 420;
}, { timeout: 5_000 })
.toBe(true);
// Restore a normal viewport so subsequent tests in the same worker
// don't inherit the phone-sized layout (form inputs in later tests
// depend on a desktop-width viewport).
await page.setViewportSize({ width: 1280, height: 720 });
// Move this seeded meeting *out of the 5-minute window* so the alert
// it produced doesn't preempt the next test's freshly-inserted meeting
// (the alert always picks the nearest unacknowledged future meeting,
// and ack rows are per-user — DB-side relocation is the only reliable
// way to evict it from the global picker for any user the next test
// logs in as).
await pool.query(
`UPDATE executive_meetings
SET start_time = (start_time::time + interval '2 hours')::time,
end_time = (end_time::time + interval '2 hours')::time
WHERE id = $1`,
[m.id],
);
});
test("Upcoming-meeting alert: Reschedule to a different day moves the meeting and clears the alert", async ({
page,
}) => {