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
This commit is contained in:
Replit Agent
2026-06-01 14:48:26 +00:00
parent 7651a23372
commit ceadff6944
9 changed files with 264 additions and 13 deletions
@@ -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", {
@@ -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<void> {
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<void> {
bodyAr,
bodyEn,
tag: `em-reminder-${meeting.id}`,
url: "/meetings",
url: `/meetings?meeting=${meeting.id}`,
type: "executive_meeting",
relatedId: meeting.id,
},
+6 -2
View File
@@ -1130,6 +1130,8 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
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<void> => {
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<void> =>
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<void> =>
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
@@ -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<void> => {
service.nameEn,
order.id,
userId,
"incoming",
);
}
await broadcastIncomingChanged(receivers);
+6 -1
View File
@@ -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", () => {
@@ -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=<id> (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<string | null>(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
+58 -2
View File
@@ -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 (
<div className="glass-panel rounded-xl p-4 flex flex-col gap-3">
<div
data-order-id={order.id}
className={cn(
"glass-panel rounded-xl p-4 flex flex-col gap-3 transition-shadow",
highlighted && "ring-2 ring-primary ring-offset-2",
)}
>
<div className="flex items-start gap-3">
<OrderImage src={img} alt={name} />
<div className="flex-1 min-w-0">
@@ -433,6 +441,53 @@ export default function MyOrdersPage() {
)
: [];
// Deep-link from a notification: /my-orders?order=<id> 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<number | null>(null);
const highlightHandledRef = useRef<string | null>(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 (
<div className="min-h-screen os-bg flex flex-col">
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
@@ -488,6 +543,7 @@ export default function MyOrdersPage() {
<OrderCard
key={o.id}
order={o}
highlighted={o.id === highlightOrderId}
onDelete={(id) => scheduleDelete([id])}
onReorder={(order) =>
setReorderTarget({
@@ -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;
+56 -2
View File
@@ -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=<id> 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<number | null>(null);
const highlightHandledRef = useRef<string | null>(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<Set<number>>(new Set());
// confirmIds === null → no dialog; otherwise the ids that confirming will delete.
const [confirmIds, setConfirmIds] = useState<number[] | null>(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}