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:
@@ -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,
|
||||
});
|
||||
// Start–end 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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user