From ceadff69444e9ae6d435f847aeb9689434b82ace Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Mon, 1 Jun 2026 14:48:26 +0000 Subject: [PATCH] Improve notification deep-linking and add Arabic meeting reminders Enhance notification handling to preserve deep-link parameters, add relatedId and relatedType to notifications for better association, and implement Arabic pluralization for meeting reminders. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 9abb57f5-4f2c-4eed-9fad-ab278885083e Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/fKZh36i Replit-Helium-Checkpoint-Created: true --- .../src/lib/executive-meeting-notify.ts | 2 +- .../src/lib/executive-meeting-scheduler.ts | 14 +++- artifacts/api-server/src/routes/notes.ts | 8 +- .../api-server/src/routes/service-orders.ts | 15 +++- artifacts/tx-os/public/sw.js | 7 +- .../tx-os/src/pages/executive-meetings.tsx | 80 ++++++++++++++++++- artifacts/tx-os/src/pages/my-orders.tsx | 60 +++++++++++++- artifacts/tx-os/src/pages/notifications.tsx | 33 ++++++++ artifacts/tx-os/src/pages/orders-incoming.tsx | 58 +++++++++++++- 9 files changed, 264 insertions(+), 13 deletions(-) diff --git a/artifacts/api-server/src/lib/executive-meeting-notify.ts b/artifacts/api-server/src/lib/executive-meeting-notify.ts index deddecc7..1ed6453c 100644 --- a/artifacts/api-server/src/lib/executive-meeting-notify.ts +++ b/artifacts/api-server/src/lib/executive-meeting-notify.ts @@ -222,7 +222,7 @@ export async function broadcastExecutiveMeetingNotifications( type: "executive_meeting", relatedId: meetingId, tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`, - url: "/meetings", + url: meetingId ? `/meetings?meeting=${meetingId}` : "/meetings", }); } io.emit("executive_meeting_notifications_changed", { diff --git a/artifacts/api-server/src/lib/executive-meeting-scheduler.ts b/artifacts/api-server/src/lib/executive-meeting-scheduler.ts index d877f68c..18bfd8e2 100644 --- a/artifacts/api-server/src/lib/executive-meeting-scheduler.ts +++ b/artifacts/api-server/src/lib/executive-meeting-scheduler.ts @@ -43,6 +43,16 @@ function stripHtmlToPlainText(input: string | null | undefined): string { return decoded.replace(/\s+/g, " ").trim(); } +// Arabic minute pluralisation for the "starts after N minutes" reminder +// body. Arabic uses three forms for small counts: 1 -> دقيقة, 2 -> دقيقتين +// (dual), 3+ -> دقائق. The scheduler only fires for deltas of 1-5 min, but +// the helper stays correct for the dual/plural boundary regardless. +function minutesAfterAr(n: number): string { + if (n <= 1) return "بعد دقيقة"; + if (n === 2) return "بعد دقيقتين"; + return `بعد ${n} دقائق`; +} + function dateKey(d: Date): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } @@ -161,7 +171,7 @@ async function tickOnce(): Promise { stripHtmlToPlainText(meeting.titleEn) || stripHtmlToPlainText(meeting.titleAr) || "Meeting"; - const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`; + const bodyAr = `${subjectAr} يبدأ ${minutesAfterAr(deltaMin)}`; const bodyEn = `${subjectEn} starts in ${deltaMin} min`; for (const { userId } of claimed) { @@ -175,7 +185,7 @@ async function tickOnce(): Promise { bodyAr, bodyEn, tag: `em-reminder-${meeting.id}`, - url: "/meetings", + url: `/meetings?meeting=${meeting.id}`, type: "executive_meeting", relatedId: meeting.id, }, diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 595e6342..83108d7d 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { bodyAr: `${senderNameAr} أرسل لك ملاحظة`, bodyEn: `${senderName} sent you a note`, type: "note", + relatedType: "note", + relatedId: note.id, }) .returning(); await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" }); @@ -1143,7 +1145,7 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { type: "note", relatedId: note.id, tag: `note-${note.id}`, - url: "/notes", + url: `/notes?thread=${note.id}`, }); await emitToUser(r.recipientUserId, "note_received", { noteId: note.id, @@ -1488,6 +1490,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => bodyAr: `${replierNameAr} رد على ملاحظة`, bodyEn: `${replierName} replied on a note`, type: "note", + relatedType: "note", + relatedId: id.id, }) .returning(); await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" }); @@ -1501,7 +1505,7 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => type: "note", relatedId: id.id, tag: `note-reply-${reply.id}`, - url: "/notes", + url: `/notes?thread=${id.id}`, }); // Enrich the realtime payload so the recipient's client can render the // floating reply card without an extra round-trip. We truncate the body diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 67dead47..a46b351b 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -117,9 +117,19 @@ async function notifyUser( bodyEn: string, orderId: number, actorId?: number, + // Which order surface the deep-link should open: the recipient's own + // "My orders" list (status/cancel updates) or the "Incoming orders" + // queue (a brand-new order to claim). Drives both the stored + // notification's relatedType and the Web Push deep-link URL so a tap + // lands on the right page with the order highlighted. + target: "mine" | "incoming" = "mine", ) { // Never notify users about actions they themselves initiated. if (actorId !== undefined && actorId === userId) return; + const relatedType = target === "incoming" ? "incoming_order" : "order"; + const deepLinkUrl = `${ + target === "incoming" ? "/orders/incoming" : "/my-orders" + }?order=${orderId}`; const [notification] = await db .insert(notificationsTable) .values({ @@ -130,7 +140,7 @@ async function notifyUser( bodyEn, type: "order", relatedId: orderId, - relatedType: "order", + relatedType, }) .returning(); await emitToUser(userId, "notification_created", notification); @@ -154,7 +164,7 @@ async function notifyUser( type: "order", relatedId: orderId, tag: `order-${orderId}`, - url: "/my-orders", + url: deepLinkUrl, }, { ignoreConnected: true }, ); @@ -209,6 +219,7 @@ router.post("/orders", requireAuth, async (req, res): Promise => { service.nameEn, order.id, userId, + "incoming", ); } await broadcastIncomingChanged(receivers); diff --git a/artifacts/tx-os/public/sw.js b/artifacts/tx-os/public/sw.js index 5f332e91..e35bf71b 100644 --- a/artifacts/tx-os/public/sw.js +++ b/artifacts/tx-os/public/sw.js @@ -18,7 +18,12 @@ function scopedUrl(relative) { // Strip a leading slash from `relative` so URL() treats it as a // path under scope rather than origin-relative. const cleaned = String(relative || "").replace(/^\/+/, ""); - return new URL(cleaned, self.registration.scope).pathname; + const resolved = new URL(cleaned, self.registration.scope); + // Preserve the query string and hash so notification deep-links + // (e.g. "/meetings?meeting=12" or "/notes?thread=7") survive the click + // handler — taking only .pathname would drop them and land the user on + // the generic page instead of the specific item. + return resolved.pathname + resolved.search + resolved.hash; } self.addEventListener("install", () => { diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 0bed75a6..683dc53e 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -13,7 +13,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { useLocation } from "wouter"; +import { useLocation, useSearch } from "wouter"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, @@ -2450,6 +2450,84 @@ function ScheduleSection({ }; }, [highlightEditRowId, meetings]); + // --- Deep-link: /meetings?meeting= (optionally &date=YYYY-MM-DD) --- + // Opened from a notification (in-app list or system Web Push). We switch + // the schedule to the meeting's day, then scroll + flash its row reusing + // the same accent-ring as the row "Edit" affordance. When no date is + // supplied we resolve it from the meeting id via the API so the same link + // works regardless of which day is currently shown. + const deepLinkSearch = useSearch(); + const deepLinkHandledRef = useRef(null); + const [pendingDeepLinkMeetingId, setPendingDeepLinkMeetingId] = useState< + number | null + >(null); + useEffect(() => { + const params = new URLSearchParams(deepLinkSearch); + const meetingParam = params.get("meeting"); + if (!meetingParam) return; + if (deepLinkHandledRef.current === meetingParam) return; + const id = Number(meetingParam); + if (!Number.isInteger(id) || id <= 0) return; + deepLinkHandledRef.current = meetingParam; + setPendingDeepLinkMeetingId(id); + const dateParam = params.get("date"); + if (dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam)) { + if (dateParam !== date) onDateChange(dateParam); + return; + } + let cancelled = false; + void apiJson<{ meetingDate?: string }>(`/api/executive-meetings/${id}`) + .then((m) => { + if (cancelled) return; + if (m?.meetingDate && m.meetingDate !== date) onDateChange(m.meetingDate); + }) + .catch(() => { + // Meeting may have been deleted/moved — leave the user on the + // current day rather than surfacing an error for a stale link. + }); + return () => { + cancelled = true; + }; + }, [deepLinkSearch, date, onDateChange]); + useEffect(() => { + if (pendingDeepLinkMeetingId == null) return; + if (!meetings.some((m) => m.id === pendingDeepLinkMeetingId)) return; + setHighlightEditRowId(pendingDeepLinkMeetingId); + setPendingDeepLinkMeetingId(null); + deepLinkHandledRef.current = null; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + params.delete("meeting"); + params.delete("date"); + const qs = params.toString(); + const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`; + window.history.replaceState(null, "", next); + } + }, [pendingDeepLinkMeetingId, meetings]); + useEffect(() => { + if (pendingDeepLinkMeetingId == null) return; + // Safety net for stale links: if the target meeting never materializes + // (deleted, moved to another day we can't resolve, or simply not in the + // loaded set), clear the pending state and strip the param after a grace + // period so the URL doesn't linger and a later valid link still works. + const stale = window.setTimeout(() => { + setPendingDeepLinkMeetingId(null); + deepLinkHandledRef.current = null; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + params.delete("meeting"); + params.delete("date"); + const qs = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}`, + ); + } + }, 8000); + return () => window.clearTimeout(stale); + }, [pendingDeepLinkMeetingId]); + // #489: Drag-from-anywhere on the row rotates meeting CONTENT through // the day's fixed time slots. The (startTime, endTime, dailyNumber) // tuple of each chronological slot stays anchored to its physical diff --git a/artifacts/tx-os/src/pages/my-orders.tsx b/artifacts/tx-os/src/pages/my-orders.tsx index ce6ea6de..6968dfc1 100644 --- a/artifacts/tx-os/src/pages/my-orders.tsx +++ b/artifacts/tx-os/src/pages/my-orders.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation } from "wouter"; +import { useLocation, useSearch } from "wouter"; import { useQueryClient } from "@tanstack/react-query"; import { useListMyServiceOrders, @@ -159,10 +159,12 @@ function OrderCard({ order, onDelete, onReorder, + highlighted = false, }: { order: ServiceOrder; onDelete: (id: number) => void; onReorder: (order: ServiceOrder) => void; + highlighted?: boolean; }) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); @@ -236,7 +238,13 @@ function OrderCard({ }; return ( -
+
@@ -433,6 +441,53 @@ export default function MyOrdersPage() { ) : []; + // Deep-link from a notification: /my-orders?order= scrolls to and + // flashes the matching order card, then strips the param so re-renders + // don't re-trigger the jump. + const orderSearch = useSearch(); + const [highlightOrderId, setHighlightOrderId] = useState(null); + const highlightHandledRef = useRef(null); + useEffect(() => { + const params = new URLSearchParams(orderSearch); + const orderParam = params.get("order"); + if (!orderParam) return; + if (highlightHandledRef.current === orderParam) return; + const id = Number(orderParam); + if (!Number.isInteger(id) || id <= 0) return; + highlightHandledRef.current = orderParam; + setHighlightOrderId(id); + if (typeof window !== "undefined") { + params.delete("order"); + const qs = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}`, + ); + } + }, [orderSearch]); + useEffect(() => { + if (highlightOrderId == null) return; + if (!sortedOrders.some((o) => o.id === highlightOrderId)) return; + const rafId = window.requestAnimationFrame(() => { + const el = document.querySelector(`[data-order-id="${highlightOrderId}"]`); + if (el && "scrollIntoView" in el) { + (el as HTMLElement).scrollIntoView({ + block: "center", + behavior: "smooth", + }); + } + }); + const clear = window.setTimeout(() => { + setHighlightOrderId(null); + highlightHandledRef.current = null; + }, 2000); + return () => { + window.cancelAnimationFrame(rafId); + window.clearTimeout(clear); + }; + }, [highlightOrderId, sortedOrders]); + return (
@@ -488,6 +543,7 @@ export default function MyOrdersPage() { scheduleDelete([id])} onReorder={(order) => setReorderTarget({ diff --git a/artifacts/tx-os/src/pages/notifications.tsx b/artifacts/tx-os/src/pages/notifications.tsx index 3cebef44..e9c99899 100644 --- a/artifacts/tx-os/src/pages/notifications.tsx +++ b/artifacts/tx-os/src/pages/notifications.tsx @@ -14,6 +14,34 @@ import { AppDock } from "@/components/app-dock"; import { formatDateTime } from "@/lib/i18n-format"; import { useAuth } from "@/contexts/AuthContext"; +// Map a notification to the in-app deep-link it should open when tapped. +// Mirrors the Web Push `url` built on the server so the in-app list and a +// system push notification land on the exact same item: a note opens its +// thread, a meeting opens that meeting on its day, and an order opens the +// right order surface ("Incoming orders" for a fresh order to claim, +// otherwise the recipient's "My orders"). Returns null when there is no +// specific destination (the tap then only marks the item read). +function notificationDestination(n: { + type: string; + relatedType?: string | null; + relatedId?: number | null; +}): string | null { + const id = n.relatedId; + switch (n.type) { + case "note": + return id ? `/notes?thread=${id}` : "/notes"; + case "executive_meeting": + return id ? `/meetings?meeting=${id}` : "/meetings"; + case "order": { + const base = + n.relatedType === "incoming_order" ? "/orders/incoming" : "/my-orders"; + return id ? `${base}?order=${id}` : base; + } + default: + return null; + } +} + export default function NotificationsPage() { const { t, i18n } = useTranslation(); const [, setLocation] = useLocation(); @@ -53,10 +81,15 @@ export default function NotificationsPage() { const handleNotificationClick = (n: { id: number; isRead: boolean; + type: string; + relatedType?: string | null; + relatedId?: number | null; }) => { if (!n.isRead) { handleMarkOne(n.id); } + const dest = notificationDestination(n); + if (dest) setLocation(dest); }; const unreadCount = notifications?.filter((n) => !n.isRead).length ?? 0; diff --git a/artifacts/tx-os/src/pages/orders-incoming.tsx b/artifacts/tx-os/src/pages/orders-incoming.tsx index 3a7acc1e..22ede5f2 100644 --- a/artifacts/tx-os/src/pages/orders-incoming.tsx +++ b/artifacts/tx-os/src/pages/orders-incoming.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation } from "wouter"; +import { useLocation, useSearch } from "wouter"; import { useQueryClient } from "@tanstack/react-query"; import { useListIncomingServiceOrders, @@ -105,6 +105,7 @@ function IncomingOrderCard({ selected, onToggleSelected, onRequestDelete, + highlighted = false, }: { order: ServiceOrder; meId: number; @@ -112,6 +113,7 @@ function IncomingOrderCard({ selected: boolean; onToggleSelected: (id: number) => void; onRequestDelete: (id: number) => void; + highlighted?: boolean; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -279,6 +281,7 @@ function IncomingOrderCard({ className={cn( "glass-panel rounded-xl p-3 flex flex-col gap-3 transition-colors", selected && "ring-2 ring-amber-400 bg-amber-50/40", + !selected && highlighted && "ring-2 ring-primary ring-offset-2", )} data-testid={`incoming-order-card-${order.id}`} > @@ -406,6 +409,55 @@ export default function OrdersIncomingPage() { [mineIds, unclaimedIds], ); + // Deep-link from a notification: /orders/incoming?order= scrolls to + // and flashes the matching order card, then strips the param so + // re-renders don't re-trigger the jump. + const orderSearch = useSearch(); + const [highlightOrderId, setHighlightOrderId] = useState(null); + const highlightHandledRef = useRef(null); + useEffect(() => { + const params = new URLSearchParams(orderSearch); + const orderParam = params.get("order"); + if (!orderParam) return; + if (highlightHandledRef.current === orderParam) return; + const id = Number(orderParam); + if (!Number.isInteger(id) || id <= 0) return; + highlightHandledRef.current = orderParam; + setHighlightOrderId(id); + if (typeof window !== "undefined") { + params.delete("order"); + const qs = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}`, + ); + } + }, [orderSearch]); + useEffect(() => { + if (highlightOrderId == null) return; + if (![...mine, ...unclaimed].some((o) => o.id === highlightOrderId)) return; + const rafId = window.requestAnimationFrame(() => { + const el = document.querySelector( + `[data-testid="incoming-order-card-${highlightOrderId}"]`, + ); + if (el && "scrollIntoView" in el) { + (el as HTMLElement).scrollIntoView({ + block: "center", + behavior: "smooth", + }); + } + }); + const clear = window.setTimeout(() => { + setHighlightOrderId(null); + highlightHandledRef.current = null; + }, 2000); + return () => { + window.cancelAnimationFrame(rafId); + window.clearTimeout(clear); + }; + }, [highlightOrderId, mine, unclaimed]); + const [selected, setSelected] = useState>(new Set()); // confirmIds === null → no dialog; otherwise the ids that confirming will delete. const [confirmIds, setConfirmIds] = useState(null); @@ -610,6 +662,7 @@ export default function OrdersIncomingPage() { key={o.id} order={o} meId={meId} + highlighted={o.id === highlightOrderId} onActionDone={refetch} selected={validSelected.has(o.id)} onToggleSelected={toggleOne} @@ -649,6 +702,7 @@ export default function OrdersIncomingPage() { key={o.id} order={o} meId={meId} + highlighted={o.id === highlightOrderId} onActionDone={refetch} selected={validSelected.has(o.id)} onToggleSelected={toggleOne}