#307 Polish UpcomingMeetingAlert: AR plurals, 12h time, details table

- locales/{ar,en}.json: split minutesAway / postponeMinutesApply /
  postponeConfirmPrompt / postponeConfirmYes / staleMeetingApplyAnyway
  into i18next plural variants (AR uses zero/one/two/few/many/other).
  Switched call sites to pass `count` so plural detection triggers.
- Added cascadeMinutesUnit_* AR/EN unit keys; cascadePromptHeader_* now
  interpolates a pre-formatted {{minutesText}} so the {{minutes}} unit
  inside the cascade prompt obeys the same Arabic plural rules.
- upcoming-meeting-alert.tsx: replaced the local 24h `formatTime` with
  a 12-hour formatter that delegates to lib/i18n-format.formatTime with
  `hour12: true`, keeping Latin digits. Applied to the popup time
  window, the stale-meeting current-time line, and the new details row.
- DetailsPanel rewritten as a 2-col label/value grid (time, location,
  meeting link, attendees grouped by internal/external/virtual). Empty
  rows are skipped. Existing test-ids (alert-details-panel,
  alert-details-attendees, alert-details-no-attendees, alert-details-url)
  preserved; added alert-details-time / alert-details-location.
- Removed the postponeMinutesHint paragraph (and key from both locales);
  the noTimeWindow warning still renders when a meeting has no times.
- PostponeDialog now also reads i18n.language so stale-meeting times
  render in the user's locale.

tsc + the full executive-meetings-upcoming-alert.spec.mjs Playwright
suite (18 tests) pass after clearing leftover seeded test data.
This commit is contained in:
Riyadh
2026-05-01 22:26:52 +00:00
parent 1eab0a11be
commit 86639c7c6f
4 changed files with 237 additions and 108 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -5,12 +5,14 @@
// invalidations from the executive_meetings_changed socket event.
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -38,6 +40,7 @@ import {
X,
} from "lucide-react";
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
const ALERT_LEAD_MINUTES = 5;
@@ -103,11 +106,20 @@ function nowLocalMinutes(): number {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function formatTime(t: string | null): string {
// #307: Render scheduled HH:mm strings as a localized 12-hour clock
// (e.g. "2:30 PM" / "2:30 م"). The stored value is always a wall-clock
// "HH:mm[:ss]" so we anchor it on today's date just to feed Intl.
function formatTime(t: string | null, lang: string): 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]}`;
const d = new Date();
d.setHours(Number(m[1]), Number(m[2]), 0, 0);
return formatTimeLocale(d, lang, {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
// #283: Custom error so callers can inspect the structured response
@@ -461,7 +473,7 @@ export function UpcomingMeetingAlert() {
const headline =
minutesAway === 0
? t("executiveMeetings.alert.startsNow")
: t("executiveMeetings.alert.minutesAway", { n: minutesAway });
: t("executiveMeetings.alert.minutesAway", { count: minutesAway });
// Theme tokens derived from the user's saved preset. The accent is
// used for the divider lines and the close-X hover; the bg/fg are
@@ -563,9 +575,9 @@ export function UpcomingMeetingAlert() {
>
<Clock className="h-3 w-3" aria-hidden="true" />
{meeting.endTime
? `${formatTime(meeting.startTime)} ${formatTime(meeting.endTime)}`
? `${formatTime(meeting.startTime, i18n.language)} ${formatTime(meeting.endTime, i18n.language)}`
: t("executiveMeetings.alert.atTime", {
time: formatTime(meeting.startTime),
time: formatTime(meeting.startTime, i18n.language),
})}
</span>
) : null}
@@ -582,6 +594,7 @@ export function UpcomingMeetingAlert() {
meeting={meeting}
isRtl={isRtl}
t={t}
lang={i18n.language}
dividerColor={dividerColor}
sectionBg={detailsBg}
/>
@@ -813,6 +826,9 @@ function CascadePromptBlock({
{t("executiveMeetings.alert.cascadePromptHeader", {
count: prompt.followers.length,
minutes: prompt.delta,
minutesText: t("executiveMeetings.alert.cascadeMinutesUnit", {
count: prompt.delta,
}),
})}
</p>
<p className="text-xs text-muted-foreground">
@@ -886,7 +902,7 @@ function PostponeDialog({
isRtl,
onSaved,
}: PostponeDialogProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { toast } = useToast();
const [minutes, setMinutes] = useState<string>("10");
const [resDate, setResDate] = useState<string>(meeting.meetingDate);
@@ -1369,11 +1385,11 @@ function PostponeDialog({
className="space-y-2 focus:outline-none"
data-testid="postpone-minutes-panel"
>
<p className="text-xs text-muted-foreground">
{noTimeWindow
? t("executiveMeetings.alert.noTimeWindow")
: t("executiveMeetings.alert.postponeMinutesHint")}
</p>
{noTimeWindow ? (
<p className="text-xs text-muted-foreground">
{t("executiveMeetings.alert.noTimeWindow")}
</p>
) : null}
{cascadePrompt && cascadePrompt.kind === "postpone" ? (
<CascadePromptBlock
prompt={cascadePrompt}
@@ -1402,9 +1418,9 @@ function PostponeDialog({
{staleConflict.currentStart ? (
<p className="text-xs text-muted-foreground">
{t("executiveMeetings.alert.staleMeetingCurrentTime", {
start: formatTime(staleConflict.currentStart),
start: formatTime(staleConflict.currentStart, i18n.language),
end: staleConflict.currentEnd
? formatTime(staleConflict.currentEnd)
? formatTime(staleConflict.currentEnd, i18n.language)
: "—",
})}
</p>
@@ -1429,7 +1445,7 @@ function PostponeDialog({
data-testid="postpone-stale-apply-anyway"
>
{t("executiveMeetings.alert.staleMeetingApplyAnyway", {
n: staleConflict.minutes,
count: staleConflict.minutes,
})}
</Button>
<Button
@@ -1495,7 +1511,7 @@ function PostponeDialog({
data-testid="postpone-minutes-apply"
>
{t("executiveMeetings.alert.postponeMinutesApply", {
n: Number(minutes) || 0,
count: Number(minutes) || 0,
})}
</Button>
</div>
@@ -1507,7 +1523,7 @@ function PostponeDialog({
>
<p className="text-sm font-medium">
{t("executiveMeetings.alert.postponeConfirmPrompt", {
n: pendingMinutes,
count: pendingMinutes,
})}
</p>
<div className="flex items-center gap-2">
@@ -1528,7 +1544,7 @@ function PostponeDialog({
data-testid="postpone-confirm-yes"
>
{t("executiveMeetings.alert.postponeConfirmYes", {
n: pendingMinutes,
count: pendingMinutes,
})}
</Button>
<Button
@@ -1710,6 +1726,7 @@ type DetailsPanelProps = {
meeting: Meeting;
isRtl: boolean;
t: (k: string, opts?: Record<string, unknown>) => string;
lang: string;
dividerColor: string;
sectionBg: string;
};
@@ -1718,6 +1735,7 @@ function DetailsPanel({
meeting,
isRtl,
t,
lang,
dividerColor,
sectionBg,
}: DetailsPanelProps) {
@@ -1754,6 +1772,126 @@ function DetailsPanel({
}
})();
// #307: Pre-compute the rendered time-window string so the row is
// skipped entirely when the meeting has no scheduled times.
const timeWindow = meeting.startTime
? meeting.endTime
? `${formatTime(meeting.startTime, lang)} ${formatTime(meeting.endTime, lang)}`
: formatTime(meeting.startTime, lang)
: null;
// Each row renders a label cell + a value cell inside the parent
// CSS grid. Skipping rows whose value is missing keeps the table
// tight and avoids empty cells. The Icon is decorative — labels do
// the real work for screen readers.
type Row = {
key: string;
icon: ReactNode;
label: string;
value: ReactNode;
};
const rows: Row[] = [];
if (timeWindow) {
rows.push({
key: "time",
icon: <Clock className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
label: t("executiveMeetings.alert.detailsTime"),
value: <span data-testid="alert-details-time">{timeWindow}</span>,
});
}
if (meeting.location && meeting.location.trim()) {
rows.push({
key: "location",
icon: <Calendar className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
label: t("executiveMeetings.alert.detailsLocation"),
value: (
<span
className="break-words"
data-testid="alert-details-location"
>
{meeting.location}
</span>
),
});
}
if (safeUrl) {
rows.push({
key: "url",
icon: (
<ExternalLink
className="h-3.5 w-3.5 opacity-70"
aria-hidden="true"
/>
),
label: t("executiveMeetings.alert.detailsUrl"),
value: (
<a
href={safeUrl}
target="_blank"
rel="noreferrer noopener"
className="block truncate underline hover:no-underline"
data-testid="alert-details-url"
>
{safeUrl}
</a>
),
});
}
// Attendees row is always shown (even when empty) so the user can
// distinguish "no one yet" from "field not loaded".
rows.push({
key: "attendees",
icon: <Users className="h-3.5 w-3.5 opacity-70" aria-hidden="true" />,
label: t("executiveMeetings.alert.detailsAttendees", {
n: persons.length,
}),
value:
persons.length === 0 ? (
<div className="opacity-80" data-testid="alert-details-no-attendees">
{t("executiveMeetings.alert.detailsNoAttendees")}
</div>
) : (
<div
className="max-h-40 overflow-y-auto"
data-testid="alert-details-attendees"
>
{(["internal", "external", "virtual"] as const).map((g) => {
const list = groups[g];
if (list.length === 0) return null;
return (
<div key={g} className="mb-1.5 last:mb-0">
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60">
{t(`executiveMeetings.alert.detailsGroup.${g}`)}
</div>
<ul className="list-disc ps-4 leading-snug">
{list.map((p, idx) => {
// Attendee names are stored as sanitized rich-text
// HTML; strip every tag for the popup. If the input
// is tag-only (e.g. "<p></p>") we render nothing
// rather than fall back to the raw value, so HTML
// can never appear here.
const cleanName = p.name
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanName) return null;
return (
<li key={`${g}-${p.id ?? idx}-${p.name}`}>
{cleanName}
</li>
);
})}
</ul>
</div>
);
})}
</div>
),
});
return (
<div
id="alert-details-panel"
@@ -1762,81 +1900,35 @@ function DetailsPanel({
data-testid="alert-details-panel"
dir={isRtl ? "rtl" : "ltr"}
>
{safeUrl ? (
<div className="mb-2 flex items-start gap-2">
<ExternalLink
className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-70"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="font-semibold opacity-80">
{t("executiveMeetings.alert.detailsUrl")}
</div>
<a
href={safeUrl}
target="_blank"
rel="noreferrer noopener"
className="block truncate underline hover:no-underline"
data-testid="alert-details-url"
>
{safeUrl}
</a>
</div>
</div>
) : null}
<div className="flex items-start gap-2">
<Users
className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-70"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="font-semibold opacity-80">
{t("executiveMeetings.alert.detailsAttendees", {
n: persons.length,
})}
</div>
{persons.length === 0 ? (
<div className="opacity-80" data-testid="alert-details-no-attendees">
{t("executiveMeetings.alert.detailsNoAttendees")}
</div>
) : (
<div
className="grid items-start gap-x-3 gap-y-2"
style={{ gridTemplateColumns: "auto 1fr" }}
>
{rows.map((row, idx) => (
<Fragment key={row.key}>
<div
className="mt-1 max-h-40 overflow-y-auto pr-1"
data-testid="alert-details-attendees"
className="flex items-center gap-1.5 self-start whitespace-nowrap font-semibold opacity-80"
style={{
paddingTop: idx === 0 ? 0 : 6,
borderTop:
idx === 0 ? "none" : `1px solid ${dividerColor}`,
}}
>
{(["internal", "external", "virtual"] as const).map((g) => {
const list = groups[g];
if (list.length === 0) return null;
return (
<div key={g} className="mb-1.5 last:mb-0">
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60">
{t(`executiveMeetings.alert.detailsGroup.${g}`)}
</div>
<ul className="list-disc ps-4 leading-snug">
{list.map((p, idx) => {
// Attendee names are stored as sanitized rich-text
// HTML; strip every tag for the popup. If the input
// is tag-only (e.g. "<p></p>") we render nothing
// rather than fall back to the raw value, so HTML
// can never appear here.
const cleanName = p.name
.replace(/<[^>]+>/g, "")
.trim();
if (!cleanName) return null;
return (
<li key={`${g}-${p.id ?? idx}-${p.name}`}>
{cleanName}
</li>
);
})}
</ul>
</div>
);
})}
{row.icon}
<span>{row.label}</span>
</div>
)}
</div>
<div
className="min-w-0 self-start text-start"
style={{
paddingTop: idx === 0 ? 0 : 6,
borderTop:
idx === 0 ? "none" : `1px solid ${dividerColor}`,
}}
>
{row.value}
</div>
</Fragment>
))}
</div>
</div>
);
+43 -11
View File
@@ -1054,7 +1054,12 @@
},
"alert": {
"title": "اجتماع يبدأ قريبًا",
"minutesAway": "يبدأ خلال {{n}} دقيقة",
"minutesAway_zero": "يبدأ الآن",
"minutesAway_one": "يبدأ خلال دقيقة",
"minutesAway_two": "يبدأ خلال دقيقتين",
"minutesAway_few": "يبدأ خلال {{count}} دقائق",
"minutesAway_many": "يبدأ خلال {{count}} دقيقة",
"minutesAway_other": "يبدأ خلال {{count}} دقيقة",
"startsNow": "يبدأ الآن",
"atTime": "في {{time}}",
"done": "تم",
@@ -1067,6 +1072,8 @@
"details": "تفاصيل",
"detailsClose": "إخفاء التفاصيل",
"detailsUrl": "رابط الاجتماع",
"detailsTime": "الوقت",
"detailsLocation": "المكان",
"detailsAttendees": "الحضور ({{n}})",
"detailsNoAttendees": "لا يوجد حضور بعد.",
"detailsGroup": {
@@ -1079,11 +1086,25 @@
"tabPostpone": "تأجيل",
"tabReschedule": "إعادة الجدولة",
"tabCancel": "إلغاء",
"postponeMinutesHint": "ستتم إزاحة وقتي البداية والنهاية بنفس المقدار.",
"postponeMinutesPlaceholder": "دقائق",
"postponeMinutesApply": "تأجيل {{n}} دقيقة",
"postponeConfirmPrompt": "هل تريد تأجيل الاجتماع {{n}} دقيقة؟",
"postponeConfirmYes": "نعم، أجّل {{n}} دقيقة",
"postponeMinutesApply_zero": "تأجيل",
"postponeMinutesApply_one": "تأجيل دقيقة",
"postponeMinutesApply_two": "تأجيل دقيقتين",
"postponeMinutesApply_few": "تأجيل {{count}} دقائق",
"postponeMinutesApply_many": "تأجيل {{count}} دقيقة",
"postponeMinutesApply_other": "تأجيل {{count}} دقيقة",
"postponeConfirmPrompt_zero": "هل تريد تأجيل الاجتماع؟",
"postponeConfirmPrompt_one": "هل تريد تأجيل الاجتماع دقيقة واحدة؟",
"postponeConfirmPrompt_two": "هل تريد تأجيل الاجتماع دقيقتين؟",
"postponeConfirmPrompt_few": "هل تريد تأجيل الاجتماع {{count}} دقائق؟",
"postponeConfirmPrompt_many": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟",
"postponeConfirmPrompt_other": "هل تريد تأجيل الاجتماع {{count}} دقيقة؟",
"postponeConfirmYes_zero": "نعم، أجّل",
"postponeConfirmYes_one": "نعم، أجّل دقيقة",
"postponeConfirmYes_two": "نعم، أجّل دقيقتين",
"postponeConfirmYes_few": "نعم، أجّل {{count}} دقائق",
"postponeConfirmYes_many": "نعم، أجّل {{count}} دقيقة",
"postponeConfirmYes_other": "نعم، أجّل {{count}} دقيقة",
"rescheduleHint": "نقل الاجتماع إلى تاريخ أو وقت جديد.",
"rescheduleDate": "التاريخ",
"rescheduleStart": "البداية",
@@ -1102,14 +1123,25 @@
"staleMeetingTitle": "تم تعديل هذا الاجتماع للتو من قبل مستخدم آخر.",
"staleMeetingByUser": "{{name}} عدّل هذا الاجتماع للتو.",
"staleMeetingCurrentTime": "الوقت الحالي: {{start}} {{end}}.",
"staleMeetingApplyAnyway": "أضف {{n}} دقيقة إضافية على أي حال",
"staleMeetingApplyAnyway_zero": "أضف وقتاً إضافياً على أي حال",
"staleMeetingApplyAnyway_one": "أضف دقيقة إضافية على أي حال",
"staleMeetingApplyAnyway_two": "أضف دقيقتين إضافيتين على أي حال",
"staleMeetingApplyAnyway_few": "أضف {{count}} دقائق إضافية على أي حال",
"staleMeetingApplyAnyway_many": "أضف {{count}} دقيقة إضافية على أي حال",
"staleMeetingApplyAnyway_other": "أضف {{count}} دقيقة إضافية على أي حال",
"cascadeLoading": "جارٍ التحقق من الاجتماعات اللاحقة…",
"cascadeMinutesUnit_zero": "بدون دقائق",
"cascadeMinutesUnit_one": "دقيقة واحدة",
"cascadeMinutesUnit_two": "دقيقتين",
"cascadeMinutesUnit_few": "{{count}} دقائق",
"cascadeMinutesUnit_many": "{{count}} دقيقة",
"cascadeMinutesUnit_other": "{{count}} دقيقة",
"cascadePromptHeader_zero": "لا توجد اجتماعات لاحقة في هذا اليوم.",
"cascadePromptHeader_one": "سيتأثر اجتماع واحد لاحق في هذا اليوم. هل تريد تأجيله أيضاً بـ {{minutes}} دقيقة؟",
"cascadePromptHeader_two": "سيتأثر اجتماعان لاحقان في هذا اليوم. هل تريد تأجيلهما أيضاً بـ {{minutes}} دقيقة؟",
"cascadePromptHeader_few": "سيتأثر {{count}} اجتماعات لاحقة في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
"cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
"cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutes}} دقيقة؟",
"cascadePromptHeader_one": "سيتأثر اجتماع واحد لاحق في هذا اليوم. هل تريد تأجيله أيضاً بـ {{minutesText}}؟",
"cascadePromptHeader_two": "سيتأثر اجتماعان لاحقان في هذا اليوم. هل تريد تأجيلهما أيضاً بـ {{minutesText}}؟",
"cascadePromptHeader_few": "سيتأثر {{count}} اجتماعات لاحقة في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
"cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
"cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
"cascadeListTitle": "الاجتماعات المتأثرة:",
"cascadeListItem": "{{title}} — {{oldStart}} ← {{newStart}}",
"cascadeShift_zero": "تأجيل الاجتماعات اللاحقة",
+13 -8
View File
@@ -972,7 +972,8 @@
},
"alert": {
"title": "Meeting starting soon",
"minutesAway": "Starts in {{n}} min",
"minutesAway_one": "Starts in {{count}} min",
"minutesAway_other": "Starts in {{count}} min",
"startsNow": "Starting now",
"atTime": "at {{time}}",
"done": "Done",
@@ -985,6 +986,8 @@
"details": "Details",
"detailsClose": "Hide details",
"detailsUrl": "Meeting link",
"detailsTime": "Time",
"detailsLocation": "Location",
"detailsAttendees": "Attendees ({{n}})",
"detailsNoAttendees": "No attendees yet.",
"detailsGroup": {
@@ -997,11 +1000,10 @@
"tabPostpone": "Postpone",
"tabReschedule": "Reschedule",
"tabCancel": "Cancel",
"postponeMinutesHint": "Shift both start and end by the same amount.",
"postponeMinutesPlaceholder": "min",
"postponeMinutesApply": "Postpone {{n}} min",
"postponeConfirmPrompt": "Postpone meeting by {{n}} min?",
"postponeConfirmYes": "Yes, postpone {{n}} min",
"postponeMinutesApply": "Postpone {{count}} min",
"postponeConfirmPrompt": "Postpone meeting by {{count}} min?",
"postponeConfirmYes": "Yes, postpone {{count}} min",
"rescheduleHint": "Move the meeting to a new date or time window.",
"rescheduleDate": "Date",
"rescheduleStart": "Start",
@@ -1020,10 +1022,13 @@
"staleMeetingTitle": "This meeting was just changed by another user.",
"staleMeetingByUser": "{{name}} just changed this meeting.",
"staleMeetingCurrentTime": "Current time: {{start}} {{end}}.",
"staleMeetingApplyAnyway": "Add {{n}} more minutes anyway",
"staleMeetingApplyAnyway_one": "Add {{count}} more minute anyway",
"staleMeetingApplyAnyway_other": "Add {{count}} more minutes anyway",
"cascadeLoading": "Checking later meetings…",
"cascadePromptHeader_one": "{{count}} later meeting on this day would be affected. Also shift it by {{minutes}} minutes?",
"cascadePromptHeader_other": "{{count}} later meetings on this day would be affected. Also shift them by {{minutes}} minutes?",
"cascadeMinutesUnit_one": "{{count}} minute",
"cascadeMinutesUnit_other": "{{count}} minutes",
"cascadePromptHeader_one": "{{count}} later meeting on this day would be affected. Also shift it by {{minutesText}}?",
"cascadePromptHeader_other": "{{count}} later meetings on this day would be affected. Also shift them by {{minutesText}}?",
"cascadeListTitle": "Affected meetings:",
"cascadeListItem": "{{title}} — {{oldStart}} → {{newStart}}",
"cascadeShift_one": "Shift {{count}} following meeting",