From a72c00fe19f5e4a6a98ed7ab690077bbee72142e Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Fri, 1 May 2026 11:56:14 +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 - New `executive_meeting_alert_state` table (per-user, per-meeting) tracking `dismissed`/`acknowledged`. Migration via `pnpm --filter @workspace/db run push-force`. - New API routes in artifacts/api-server/src/routes/executive-meetings.ts: GET /alert-state, POST /:id/alert-state, POST /:id/postpone-minutes, POST /:id/reschedule, POST /:id/cancel. All three mutation routes acquire a row lock with SELECT ... FOR UPDATE inside the transaction, compute oldValue from the locked snapshot, run conflict detection in the same tx, and write the audit row before commit. Cancel is idempotent. Postpone-minutes rejects ranges that would cross midnight (use reschedule for cross-day moves). - Alert-state route uses race-safe onConflictDoNothing upsert + a conditional UPDATE ... RETURNING so transition audits never duplicate. - New i18n keys `executiveMeetings.alert.*` in en.json + ar.json, including the cancel-confirm prompt. - 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 30s, 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 (gated on confirmCancel state). - Playwright spec executive-meetings-upcoming-alert.spec.mjs with 6 scenarios: appear+Done, postpone-10 shifts times, cancel-with- confirm, dismiss audit, postpone-chip+conflict-warning toast, AR/RTL render. All 6 pass. - replit.md updated with the new table, routes, and migration step. Pre-existing tsc errors at lines 489, 605, and 2039 of executive-meetings.ts are not touched by this task. --- .../src/routes/executive-meetings.ts | 466 ++++++++++ artifacts/tx-os/public/opengraph.jpg | Bin 24568 -> 25912 bytes artifacts/tx-os/src/App.tsx | 2 + .../upcoming-meeting-alert.tsx | 813 ++++++++++++++++++ artifacts/tx-os/src/locales/ar.json | 35 + artifacts/tx-os/src/locales/en.json | 35 + ...executive-meetings-upcoming-alert.spec.mjs | 309 +++++++ lib/db/src/schema/executive-meetings.ts | 29 + replit.md | 16 + 9 files changed, 1705 insertions(+) create mode 100644 artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx create mode 100644 artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 1417f0fe..81801af2 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -22,6 +22,7 @@ import { executiveMeetingAuditLogsTable, executiveMeetingPdfArchivesTable, executiveMeetingFontSettingsTable, + executiveMeetingAlertStateTable, rolesTable, usersTable, type ExecutiveMeetingAttendee, @@ -680,6 +681,471 @@ router.patch( }, ); +// #273: ---- Upcoming-meeting alert (5-minute pre-alarm) endpoints ---- + +// Parse "HH:MM" or "HH:MM:SS" -> minutes-of-day. Returns null on missing/invalid. +function parseTimeToMinutes(t: string | null | undefined): number | null { + if (!t) return null; + const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(t); + if (!m) return null; + const h = Number(m[1]); + const mm = Number(m[2]); + if (h < 0 || h > 23 || mm < 0 || mm > 59) return null; + return h * 60 + mm; +} +function formatMinutesAsTime(mins: number): string { + const safe = ((mins % (24 * 60)) + 24 * 60) % (24 * 60); + const h = Math.floor(safe / 60); + const m = safe % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`; +} +// #273: pass a transaction-like executor so the conflict scan reads inside +// the same DB snapshot as the UPDATE that just shifted the row. +async function detectMeetingConflicts( + exec: typeof db | Parameters[0]>[0], + date: string, + excludeId: number, + startTime: string, + endTime: string, +): Promise { + const newStart = parseTimeToMinutes(startTime); + const newEnd = parseTimeToMinutes(endTime); + if (newStart == null || newEnd == null) return false; + const others = await exec + .select({ + id: executiveMeetingsTable.id, + start: executiveMeetingsTable.startTime, + end: executiveMeetingsTable.endTime, + status: executiveMeetingsTable.status, + }) + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, date)); + for (const o of others) { + if (o.id === excludeId) continue; + if (o.status === "cancelled" || o.status === "completed") continue; + const s = parseTimeToMinutes(o.start); + const e = parseTimeToMinutes(o.end); + if (s == null || e == null) continue; + if (s < newEnd && e > newStart) return true; + } + return false; +} + +const alertActionSchema = z.object({ + action: z.enum(["shown", "acknowledged", "dismissed"]), +}); +const postponeMinutesSchema = z.object({ + minutes: z.number().int().min(1).max(24 * 60), +}); +const rescheduleSchema = z.object({ + meetingDate: dateSchema, + startTime: timeSchema, + endTime: timeSchema, + note: z.string().max(500).optional(), +}); + +router.get( + "/executive-meetings/alert-state", + requireExecutiveAccess, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const dateRaw = String(req.query.date ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateRaw)) { + res.status(400).json({ error: "Invalid date", code: "invalid_date" }); + return; + } + const userId = req.session.userId; + const rows = await db + .select({ + meetingId: executiveMeetingAlertStateTable.meetingId, + dismissed: executiveMeetingAlertStateTable.dismissed, + acknowledged: executiveMeetingAlertStateTable.acknowledged, + }) + .from(executiveMeetingAlertStateTable) + .innerJoin( + executiveMeetingsTable, + eq(executiveMeetingsTable.id, executiveMeetingAlertStateTable.meetingId), + ) + .where( + and( + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingsTable.meetingDate, dateRaw), + ), + ); + res.json({ states: rows }); + }, +); + +router.post( + "/executive-meetings/:id/alert-state", + requireExecutiveAccess, + async (req, res): Promise => { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, alertActionSchema, req.body); + if (!body) return; + const meeting = await fetchMeetingWithAttendees(id); + if (!meeting) { + res.status(404).json({ error: "Meeting not found", code: "not_found" }); + return; + } + const userId = req.session.userId; + await db.transaction(async (tx) => { + // Race-safe upsert: when two tabs surface the same alert at once they + // both POST {action:"shown"}; using ON CONFLICT DO NOTHING means only + // one INSERT actually creates the row, so we audit "shown" once. + const inserted = await tx + .insert(executiveMeetingAlertStateTable) + .values({ + meetingId: id, + userId, + dismissed: body.action === "dismissed", + acknowledged: body.action === "acknowledged", + }) + .onConflictDoNothing({ + target: [ + executiveMeetingAlertStateTable.meetingId, + executiveMeetingAlertStateTable.userId, + ], + }) + .returning({ id: executiveMeetingAlertStateTable.id }); + + if (inserted.length > 0) { + await logAudit(tx, { + action: "meeting_alert_shown", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + if (body.action === "acknowledged") { + await logAudit(tx, { + action: "meeting_alert_acknowledged", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } else if (body.action === "dismissed") { + await logAudit(tx, { + action: "meeting_alert_dismissed", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + return; + } + // Row exists. Use a conditional UPDATE so concurrent clicks across + // tabs only fire one audit row per real transition (false -> true). + if (body.action === "acknowledged") { + const upd = await tx + .update(executiveMeetingAlertStateTable) + .set({ acknowledged: true }) + .where( + and( + eq(executiveMeetingAlertStateTable.meetingId, id), + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingAlertStateTable.acknowledged, false), + ), + ) + .returning({ id: executiveMeetingAlertStateTable.id }); + if (upd.length > 0) { + await logAudit(tx, { + action: "meeting_alert_acknowledged", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + } else if (body.action === "dismissed") { + const upd = await tx + .update(executiveMeetingAlertStateTable) + .set({ dismissed: true }) + .where( + and( + eq(executiveMeetingAlertStateTable.meetingId, id), + eq(executiveMeetingAlertStateTable.userId, userId), + eq(executiveMeetingAlertStateTable.dismissed, false), + ), + ) + .returning({ id: executiveMeetingAlertStateTable.id }); + if (upd.length > 0) { + await logAudit(tx, { + action: "meeting_alert_dismissed", + entityType: "meeting", + entityId: id, + newValue: { userId }, + performedBy: userId, + }); + } + } + }); + res.json({ ok: true }); + }, +); + +router.post( + "/executive-meetings/:id/postpone-minutes", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, postponeMinutesSchema, req.body); + if (!body) return; + const userId = req.session.userId!; + // #273: lock-then-mutate inside a single transaction so concurrent + // postpone/reschedule/cancel requests serialize and audit logs reflect + // the actual committed transition. + type TxResult = + | { + ok: true; + meetingDate: string; + newStart: string; + newEnd: string; + conflicts: boolean; + } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + const startMin = parseTimeToMinutes(locked.startTime); + const endMin = parseTimeToMinutes(locked.endTime); + if (startMin == null || endMin == null) { + return { + ok: false, + status: 400, + error: "Meeting has no scheduled time to postpone", + code: "no_time_window", + }; + } + if ( + startMin + body.minutes >= 24 * 60 || + endMin + body.minutes > 24 * 60 + ) { + return { + ok: false, + status: 400, + error: "Postponing by that many minutes would cross midnight", + code: "crosses_midnight", + }; + } + const newStart = formatMinutesAsTime(startMin + body.minutes); + const newEnd = formatMinutesAsTime(endMin + body.minutes); + await tx + .update(executiveMeetingsTable) + .set({ startTime: newStart, endTime: newEnd, status: "postponed" }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_postponed_minutes", + entityType: "meeting", + entityId: id, + oldValue: { + startTime: locked.startTime, + endTime: locked.endTime, + status: locked.status, + }, + newValue: { + startTime: newStart, + endTime: newEnd, + status: "postponed", + minutes: body.minutes, + }, + performedBy: userId, + }); + const conflicts = await detectMeetingConflicts( + tx, + locked.meetingDate, + id, + newStart, + newEnd, + ); + return { + ok: true, + meetingDate: locked.meetingDate, + newStart, + newEnd, + conflicts, + }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]); + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated }); + }, +); + +router.post( + "/executive-meetings/:id/reschedule", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const body = parseBody(res, rescheduleSchema, req.body); + if (!body) return; + const startMin = parseTimeToMinutes(body.startTime); + const endMin = parseTimeToMinutes(body.endTime); + if (startMin == null || endMin == null || startMin >= endMin) { + res.status(400).json({ + error: "End time must be after start time", + code: "invalid_time_range", + }); + return; + } + const userId = req.session.userId!; + // #273: lock the row inside the tx so a concurrent edit cannot race us. + type TxResult = + | { ok: true; oldDate: string; conflicts: boolean } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + // If the daily number would clash on the new date, allocate a fresh one. + let dailyNumber = locked.dailyNumber; + if (body.meetingDate !== locked.meetingDate) { + dailyNumber = await nextDailyNumber(tx, body.meetingDate); + } + await tx + .update(executiveMeetingsTable) + .set({ + meetingDate: body.meetingDate, + startTime: body.startTime, + endTime: body.endTime, + status: "postponed", + dailyNumber, + }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_rescheduled", + entityType: "meeting", + entityId: id, + oldValue: { + meetingDate: locked.meetingDate, + startTime: locked.startTime, + endTime: locked.endTime, + status: locked.status, + dailyNumber: locked.dailyNumber, + }, + newValue: { + meetingDate: body.meetingDate, + startTime: body.startTime, + endTime: body.endTime, + status: "postponed", + dailyNumber, + note: body.note ?? null, + }, + performedBy: userId, + }); + const conflicts = await detectMeetingConflicts( + tx, + body.meetingDate, + id, + body.startTime, + body.endTime, + ); + return { ok: true, oldDate: locked.meetingDate, conflicts }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + void emitExecutiveMeetingsDaysChanged([ + txResult.oldDate, + body.meetingDate, + ]); + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, conflicts: txResult.conflicts, meeting: updated }); + }, +); + +router.post( + "/executive-meetings/:id/cancel", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const userId = req.session.userId!; + // #273: lock + idempotent UPDATE — only audit on the real status change. + type TxResult = + | { ok: true; meetingDate: string; changed: boolean } + | { ok: false; status: number; error: string; code: string }; + const txResult = await db.transaction(async (tx): Promise => { + const [locked] = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.id, id)) + .for("update"); + if (!locked) { + return { ok: false, status: 404, error: "Meeting not found", code: "not_found" }; + } + if (locked.status === "cancelled") { + return { ok: true, meetingDate: locked.meetingDate, changed: false }; + } + await tx + .update(executiveMeetingsTable) + .set({ status: "cancelled" }) + .where(eq(executiveMeetingsTable.id, id)); + await logAudit(tx, { + action: "meeting_cancelled", + entityType: "meeting", + entityId: id, + oldValue: { status: locked.status }, + newValue: { status: "cancelled" }, + performedBy: userId, + }); + return { ok: true, meetingDate: locked.meetingDate, changed: true }; + }); + if (!txResult.ok) { + res.status(txResult.status).json({ error: txResult.error, code: txResult.code }); + return; + } + if (txResult.changed) { + void emitExecutiveMeetingsDaysChanged([txResult.meetingDate]); + } + const updated = await fetchMeetingWithAttendees(id); + res.json({ ok: true, meeting: updated }); + }, +); + router.delete( "/executive-meetings/:id", requireExecutiveAccess, diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 7f5d35cb0e1206da45bfe25d0aefe48d80e78515..c3ff5c902956b79a5109fa295427c5dc06abb6e4 100644 GIT binary patch literal 25912 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 literal 24568 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 a+dER&>}zi^)kHR18O_n=qDlY#=l=j9bu(oE diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index 1f5c5891..b6f6afcc 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AuthProvider } from "@/contexts/AuthContext"; import { useNotificationsSocket } from "@/hooks/use-notifications-socket"; +import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert"; import NotFound from "@/pages/not-found"; import LoginPage from "@/pages/login"; import RegisterPage from "@/pages/register"; @@ -37,6 +38,7 @@ function Router() { return ( + 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 new file mode 100644 index 00000000..e6535fba --- /dev/null +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -0,0 +1,813 @@ +// #273: Floating, draggable 5-minute pre-meeting alert. +// Mounted globally from App.tsx so it stays visible while the user +// navigates around tx-os. Polls /api/executive-meetings for today and +// the per-user alert state every 30 s, and listens to React Query +// invalidations from the executive_meetings_changed socket event. + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "wouter"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; +import { Calendar, Clock, GripVertical, X } from "lucide-react"; + +const POSITION_KEY = "txos.upcomingMeetingAlert.position"; +const ALERT_LEAD_MINUTES = 5; +const POLL_MS = 30_000; + +type Meeting = { + id: number; + dailyNumber: number; + titleAr: string; + titleEn: string; + meetingDate: string; + startTime: string | null; + endTime: string | null; + location: string | null; + meetingUrl: string | null; + status: string; +}; +type DayResponse = { date: string; meetings: Meeting[] }; +type AlertState = { + meetingId: number; + dismissed: boolean; + acknowledged: boolean; +}; +type Capabilities = { + canRead: boolean; + canMutate: boolean; +}; + +function parseTimeToMinutes(t: string | null | undefined): number | null { + if (!t) return null; + const m = /^(\d{1,2}):(\d{2})/.exec(t); + if (!m) return null; + const h = Number(m[1]); + const mm = Number(m[2]); + if (h < 0 || h > 23 || mm < 0 || mm > 59) return null; + return h * 60 + mm; +} +function todayLocalDate(): string { + 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}`; +} +function nowLocalMinutes(): number { + const d = new Date(); + return d.getHours() * 60 + d.getMinutes(); +} +function formatTime(t: string | null): string { + if (!t) return ""; + const m = /^(\d{1,2}):(\d{2})/.exec(t); + if (!m) return t; + return `${m[1].padStart(2, "0")}:${m[2]}`; +} + +async function apiJson(input: string, init?: RequestInit): Promise { + const res = await fetch(input, { + credentials: "include", + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + ...init, + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + const data = text ? (JSON.parse(text) as unknown) : ({} as unknown); + if (!res.ok) { + throw new Error( + (data as { error?: string }).error || `HTTP ${res.status}`, + ); + } + return data as T; +} + +type DragPosition = { x: number; y: number }; +function loadPosition(): DragPosition | null { + try { + const raw = localStorage.getItem(POSITION_KEY); + if (!raw) return null; + const obj = JSON.parse(raw) as DragPosition; + if (typeof obj.x !== "number" || typeof obj.y !== "number") return null; + return obj; + } catch { + return null; + } +} +function savePosition(pos: DragPosition) { + try { + localStorage.setItem(POSITION_KEY, JSON.stringify(pos)); + } catch { + /* ignore storage errors */ + } +} +function defaultPosition(): DragPosition { + // Top-right corner with 16px margin; gracefully degrades on small screens. + if (typeof window === "undefined") return { x: 16, y: 16 }; + const w = Math.min(380, window.innerWidth - 32); + return { x: Math.max(16, window.innerWidth - w - 16), y: 80 }; +} + +export function UpcomingMeetingAlert() { + const { user, isLoading: authLoading } = useAuth(); + const { t, i18n } = useTranslation(); + const isRtl = i18n.language === "ar"; + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [, setLocation] = useLocation(); + const [now, setNow] = useState(() => nowLocalMinutes()); + const [postponeOpen, setPostponeOpen] = useState(false); + const [savingAction, setSavingAction] = useState(null); + + // Tick the clock every minute so the countdown stays current. Also + // tick when the tab regains focus so users coming back to it don't + // see a stale 5-minutes-ago timestamp. + useEffect(() => { + const interval = setInterval(() => setNow(nowLocalMinutes()), 30_000); + const onFocus = () => setNow(nowLocalMinutes()); + window.addEventListener("focus", onFocus); + return () => { + clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, []); + + const today = todayLocalDate(); + + const { data: caps } = useQuery({ + queryKey: ["/api/executive-meetings/me", user?.id ?? null], + enabled: !authLoading && !!user, + queryFn: async () => { + const res = await fetch("/api/executive-meetings/me", { + credentials: "include", + }); + if (res.status === 401 || res.status === 403) { + return { canRead: false, canMutate: false }; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as Capabilities; + return { canRead: json.canRead, canMutate: json.canMutate }; + }, + staleTime: 5 * 60_000, + }); + + const enabled = !!user && !!caps?.canRead; + + const { data: dayData } = useQuery({ + queryKey: ["/api/executive-meetings", today], + enabled, + queryFn: async () => { + const res = await fetch(`/api/executive-meetings?date=${today}`, { + credentials: "include", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }, + refetchInterval: POLL_MS, + refetchOnWindowFocus: true, + }); + + const { data: alertStateData, refetch: refetchAlertState } = useQuery<{ + states: AlertState[]; + }>({ + queryKey: ["/api/executive-meetings/alert-state", today, user?.id ?? null], + enabled, + queryFn: async () => { + const res = await fetch( + `/api/executive-meetings/alert-state?date=${today}`, + { credentials: "include" }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }, + refetchInterval: POLL_MS, + }); + + const stateById = useMemo(() => { + const map = new Map(); + for (const s of alertStateData?.states ?? []) map.set(s.meetingId, s); + return map; + }, [alertStateData]); + + // Pick the soonest meeting that: + // - is on today, + // - has a known startTime, + // - is not cancelled or completed, + // - hasn't already started + ended (i.e. start - now is in [-5, 5]), + // - hasn't been acknowledged or dismissed by this user. + // We allow up to 5 minutes "after" the scheduled start so the alert + // doesn't vanish the second the meeting begins. + const eligible = useMemo(() => { + const meetings = dayData?.meetings ?? []; + const candidates = meetings + .map((m) => { + const s = parseTimeToMinutes(m.startTime); + if (s == null) return null; + if (m.status === "cancelled" || m.status === "completed") return null; + const state = stateById.get(m.id); + if (state?.dismissed || state?.acknowledged) return null; + const delta = s - now; // positive = in the future + if (delta > ALERT_LEAD_MINUTES) return null; + if (delta < -ALERT_LEAD_MINUTES) return null; + return { meeting: m, delta }; + }) + .filter( + ( + x, + ): x is { + meeting: Meeting; + delta: number; + } => !!x, + ); + candidates.sort((a, b) => a.delta - b.delta); + return candidates[0] ?? null; + }, [dayData, stateById, now]); + + // Record "shown" the first time we surface a given meeting so admins + // can audit who actually saw the alert. We use a ref to dedupe. + const shownRef = useRef>(new Set()); + useEffect(() => { + if (!eligible || !enabled) return; + const id = eligible.meeting.id; + const state = stateById.get(id); + if (state) return; // already has a row -> already counted as shown + if (shownRef.current.has(id)) return; + shownRef.current.add(id); + void apiJson(`/api/executive-meetings/${id}/alert-state`, { + method: "POST", + body: JSON.stringify({ action: "shown" }), + }) + .then(() => refetchAlertState()) + .catch(() => { + shownRef.current.delete(id); + }); + }, [eligible, enabled, stateById, refetchAlertState]); + + // ----- Drag handling ----- + const [pos, setPos] = useState( + () => loadPosition() ?? defaultPosition(), + ); + const dragState = useRef<{ + pointerId: number; + offsetX: number; + offsetY: number; + } | null>(null); + const onDragStart = (e: ReactPointerEvent) => { + if (e.button !== 0 && e.pointerType === "mouse") return; + const target = e.currentTarget; + target.setPointerCapture(e.pointerId); + const rect = target.parentElement?.getBoundingClientRect(); + dragState.current = { + pointerId: e.pointerId, + offsetX: e.clientX - (rect?.left ?? 0), + offsetY: e.clientY - (rect?.top ?? 0), + }; + }; + const onDragMove = (e: ReactPointerEvent) => { + const st = dragState.current; + 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)); + setPos({ x, y }); + }; + const onDragEnd = (e: ReactPointerEvent) => { + const st = dragState.current; + if (!st || st.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + dragState.current = null; + setPos((p) => { + savePosition(p); + return p; + }); + }; + + const handleDone = useCallback(async () => { + if (!eligible) return; + const id = eligible.meeting.id; + setSavingAction("done"); + try { + await apiJson(`/api/executive-meetings/${id}/alert-state`, { + method: "POST", + body: JSON.stringify({ action: "acknowledged" }), + }); + await refetchAlertState(); + toast({ title: t("executiveMeetings.alert.doneToast") }); + } catch (err) { + toast({ + title: t("executiveMeetings.alert.saveFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setSavingAction(null); + } + }, [eligible, refetchAlertState, t, toast]); + + const handleDismiss = useCallback(async () => { + if (!eligible) return; + const id = eligible.meeting.id; + setSavingAction("dismiss"); + try { + await apiJson(`/api/executive-meetings/${id}/alert-state`, { + method: "POST", + body: JSON.stringify({ action: "dismissed" }), + }); + await refetchAlertState(); + toast({ title: t("executiveMeetings.alert.dismissToast") }); + } catch (err) { + toast({ + title: t("executiveMeetings.alert.saveFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + } finally { + setSavingAction(null); + } + }, [eligible, refetchAlertState, t, toast]); + + if (!enabled || !eligible) return null; + + const meeting = eligible.meeting; + const title = isRtl + ? meeting.titleAr || meeting.titleEn + : meeting.titleEn || meeting.titleAr; + const minutesAway = Math.max(0, eligible.delta); + const headline = + minutesAway === 0 + ? t("executiveMeetings.alert.startsNow") + : t("executiveMeetings.alert.minutesAway", { n: minutesAway }); + + return ( + <> +
+
+
+
+ +
+
+
+ {title.replace(/<[^>]+>/g, "").trim() || title} +
+
+ + {headline} + + {meeting.startTime ? ( + + + ) : null} + {meeting.location ? ( + + + ) : null} +
+
+
+ + {caps?.canMutate ? ( + + ) : null} + +
+
+ {caps?.canMutate ? ( + { + setPostponeOpen(false); + // Invalidate today and the alert-state cache so the alert + // disappears (or re-appears) immediately based on the new + // status / time window. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["/api/executive-meetings"], + }), + refetchAlertState(), + ]); + }} + /> + ) : null} + + ); +} + +// ---------- Postpone sub-modal ---------- + +type PostponeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + meeting: Meeting; + isRtl: boolean; + onSaved: () => void | Promise; +}; + +function PostponeDialog({ + open, + onOpenChange, + meeting, + isRtl, + onSaved, +}: PostponeDialogProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [minutes, setMinutes] = useState("10"); + const [resDate, setResDate] = useState(meeting.meetingDate); + const [resStart, setResStart] = useState( + meeting.startTime ? meeting.startTime.slice(0, 5) : "", + ); + const [resEnd, setResEnd] = useState( + meeting.endTime ? meeting.endTime.slice(0, 5) : "", + ); + const [resNote, setResNote] = useState(""); + const [busy, setBusy] = useState(null); + const [confirmCancel, setConfirmCancel] = useState(false); + + // Reset local form state whenever the dialog opens against a new meeting + // so stale values don't leak between alerts. + useEffect(() => { + if (!open) return; + setMinutes("10"); + setResDate(meeting.meetingDate); + setResStart(meeting.startTime ? meeting.startTime.slice(0, 5) : ""); + setResEnd(meeting.endTime ? meeting.endTime.slice(0, 5) : ""); + setResNote(""); + setBusy(null); + setConfirmCancel(false); + }, [open, meeting.id, meeting.meetingDate, meeting.startTime, meeting.endTime]); + + const title = isRtl + ? meeting.titleAr || meeting.titleEn + : meeting.titleEn || meeting.titleAr; + const titleText = title.replace(/<[^>]+>/g, "").trim() || title; + + const handleResult = ( + res: { conflicts?: boolean } | undefined, + successKey: string, + ) => { + toast({ title: t(successKey) }); + if (res?.conflicts) { + toast({ + title: t("executiveMeetings.alert.conflictWarning"), + variant: "destructive", + }); + } + }; + const handleErr = (err: unknown) => { + toast({ + title: t("executiveMeetings.alert.saveFailed"), + description: err instanceof Error ? err.message : String(err), + variant: "destructive", + }); + }; + + const onPostponeMinutes = async () => { + const n = Number(minutes); + if (!Number.isFinite(n) || n <= 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) }) }, + ); + handleResult(res, "executiveMeetings.alert.saved"); + await onSaved(); + } catch (err) { + handleErr(err); + } finally { + setBusy(null); + } + }; + const onReschedule = async () => { + if (!resDate || !resStart || !resEnd) return; + setBusy("reschedule"); + try { + const res = await apiJson<{ conflicts?: boolean }>( + `/api/executive-meetings/${meeting.id}/reschedule`, + { + method: "POST", + body: JSON.stringify({ + meetingDate: resDate, + startTime: `${resStart}:00`, + endTime: `${resEnd}:00`, + note: resNote || undefined, + }), + }, + ); + handleResult(res, "executiveMeetings.alert.saved"); + await onSaved(); + } catch (err) { + handleErr(err); + } finally { + setBusy(null); + } + }; + const onCancelMeeting = async () => { + setBusy("cancel"); + try { + await apiJson(`/api/executive-meetings/${meeting.id}/cancel`, { + method: "POST", + body: JSON.stringify({}), + }); + toast({ title: t("executiveMeetings.alert.saved") }); + await onSaved(); + } catch (err) { + handleErr(err); + } finally { + setBusy(null); + } + }; + + const noTimeWindow = !meeting.startTime || !meeting.endTime; + + return ( + + + + {t("executiveMeetings.alert.postponeTitle")} + + {t("executiveMeetings.alert.postponeIntro", { title: titleText })} + + + +
+ +

+ {noTimeWindow + ? t("executiveMeetings.alert.noTimeWindow") + : t("executiveMeetings.alert.postponeMinutesHint")} +

+
+ {[5, 10, 15, 30, 45, 60].map((n) => ( + + ))} +
+
+ setMinutes(e.target.value)} + placeholder={t( + "executiveMeetings.alert.postponeMinutesPlaceholder", + )} + disabled={noTimeWindow || busy !== null} + data-testid="postpone-minutes-input" + className="w-28" + /> + +
+
+ +
+ +
+
+ + setResDate(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-date" + /> +
+
+ + setResStart(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-start" + /> +
+
+ + setResEnd(e.target.value)} + disabled={busy !== null} + data-testid="reschedule-end" + /> +
+
+