#481 Polish cascade-affected meetings table in postpone/reschedule prompt

- Drop the leading "#" index column; dailyNumber already serves as a
  stable per-row identifier.
- Replace the hard-coded amber background/border with the user's chosen
  alert accent via hexToRgba(accent, 0.12 / 0.45). Threaded `accent`
  through PostponeDialog → CascadePromptBlock so children don't read
  prefs directly. Both the loading and main prompt blocks track the
  accent; the rose blocked-by-midnight variant is intentionally kept.
- Render times as localized 12-hour with ص/م (AR) or AM/PM (EN) by
  reusing the shared formatTime helper instead of slicing "HH:mm".
- Drop now-unused i18n key cascadeColIndex from ar.json + en.json.
- Update the e2e test to match the 3-column schema (meeting#, title,
  times); kept the dailyNumber assertion in the first cell.

tsc clean. Cascade specs pass (Postpone by 10, Reschedule cascade,
Cascade prompt UI: Shift/Keep, no-followers fallthrough).
This commit is contained in:
riyadhafraa
2026-05-10 16:07:20 +00:00
parent 4994840989
commit 7ea7744e26
4 changed files with 32 additions and 17 deletions
@@ -761,6 +761,7 @@ export function UpcomingMeetingAlert() {
meeting={meeting}
isRtl={isRtl}
meetingNumbersById={meetingNumbersById}
accent={alertPrefs.accent}
onSaved={async () => {
setPostponeOpen(false);
// Invalidate today and the alert-state cache so the alert
@@ -831,6 +832,8 @@ function CascadePromptBlock({
isRtl,
busy,
meetingNumbersById,
accent,
lang,
onShiftFollowing,
onKeepTimes,
onBack,
@@ -843,11 +846,20 @@ function CascadePromptBlock({
// Built by the parent from already-loaded dayData.meetings; missing
// ids fall back to "—" rather than blocking the prompt.
meetingNumbersById: Map<number, number>;
// #481: accent hex from the user's alert color theme, used to derive
// the prompt's background + border via hexToRgba so the cascade block
// matches the rest of the alert instead of hard-coded amber.
accent: string;
// #481: active i18n language so times render localized 12-hour with
// ص/م (AR) or AM/PM (EN).
lang: string;
onShiftFollowing: () => void;
onKeepTimes: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const promptBg = hexToRgba(accent, 0.12);
const promptBorder = hexToRgba(accent, 0.45);
// While the preview is in flight, we still render the block (so the
// user sees something happen after they click "Yes") but with empty
// content + a loading hint. The buttons are disabled until either
@@ -855,7 +867,8 @@ function CascadePromptBlock({
if (prompt.loading) {
return (
<div
className="flex flex-col gap-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950"
className="flex flex-col gap-2 rounded border p-3"
style={{ backgroundColor: promptBg, borderColor: promptBorder }}
data-testid="cascade-prompt-loading"
>
<p className="text-sm font-medium">
@@ -871,8 +884,6 @@ function CascadePromptBlock({
: m.titleEn || m.titleAr;
return raw.replace(/<[^>]+>/g, "").trim() || raw;
};
const fmtTime = (hhmm: string) =>
hhmm.length >= 5 ? hhmm.slice(0, 5) : hhmm;
if (prompt.blockedBy) {
// Midnight rejection: server (or preview) refused because shifting
@@ -888,7 +899,7 @@ function CascadePromptBlock({
<p className="text-sm font-medium">
{t("executiveMeetings.alert.cascadeMidnightError", {
title: titleOf(prompt.blockedBy),
projectedStart: fmtTime(prompt.blockedBy.projectedStart),
projectedStart: formatTime(prompt.blockedBy.projectedStart, lang),
})}
</p>
<div className="flex flex-wrap items-center gap-2">
@@ -918,7 +929,8 @@ function CascadePromptBlock({
return (
<div
className="flex flex-col gap-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950"
className="flex flex-col gap-2 rounded border p-3"
style={{ backgroundColor: promptBg, borderColor: promptBorder }}
data-testid="cascade-prompt"
>
<p className="text-sm font-medium">
@@ -942,9 +954,6 @@ function CascadePromptBlock({
>
<thead className="text-muted-foreground">
<tr>
<th scope="col" className="w-8 px-1 py-1 text-start font-medium tabular-nums">
{t("executiveMeetings.alert.cascadeColIndex")}
</th>
<th scope="col" className="w-16 px-1 py-1 text-start font-medium tabular-nums">
{t("executiveMeetings.alert.cascadeColMeetingNumber")}
</th>
@@ -957,7 +966,7 @@ function CascadePromptBlock({
</tr>
</thead>
<tbody>
{prompt.followers.map((f, i) => {
{prompt.followers.map((f) => {
const dn = meetingNumbersById.get(f.id);
const title = titleOf(f);
return (
@@ -966,7 +975,6 @@ function CascadePromptBlock({
className="border-t border-black/5 dark:border-white/10"
data-testid={`cascade-follower-${f.id}`}
>
<td className="px-1 py-0.5 tabular-nums">{i + 1}</td>
<td className="px-1 py-0.5 tabular-nums">
{dn != null ? dn : "—"}
</td>
@@ -974,7 +982,7 @@ function CascadePromptBlock({
{title}
</td>
<td className="px-1 py-0.5 text-end tabular-nums whitespace-nowrap">
{fmtTime(f.oldStart)} {isRtl ? "←" : "→"} {fmtTime(f.newStart)}
{formatTime(f.oldStart, lang)} {isRtl ? "←" : "→"} {formatTime(f.newStart, lang)}
</td>
</tr>
);
@@ -1028,6 +1036,10 @@ type PostponeDialogProps = {
// table. Provided by the parent so we don't refetch today's meetings
// inside the dialog.
meetingNumbersById: Map<number, number>;
// #481: accent hex from the parent's alert color theme; threaded
// into CascadePromptBlock so the cascade box matches the alert's
// chosen palette instead of hard-coded amber.
accent: string;
onSaved: () => void | Promise<void>;
};
@@ -1039,6 +1051,7 @@ function PostponeDialog({
meeting,
isRtl,
meetingNumbersById,
accent,
onSaved,
}: PostponeDialogProps) {
const { t, i18n } = useTranslation();
@@ -1535,6 +1548,8 @@ function PostponeDialog({
isRtl={isRtl}
busy={busy !== null}
meetingNumbersById={meetingNumbersById}
accent={accent}
lang={i18n.language}
onShiftFollowing={() =>
void postponeBy(cascadePrompt.delta, undefined, true)
}
@@ -1721,6 +1736,8 @@ function PostponeDialog({
isRtl={isRtl}
busy={busy !== null}
meetingNumbersById={meetingNumbersById}
accent={accent}
lang={i18n.language}
onShiftFollowing={() =>
void rescheduleSubmit(cascadePrompt.draft, true)
}
-1
View File
@@ -1341,7 +1341,6 @@
"cascadePromptHeader_many": "سيتأثر {{count}} اجتماعاً لاحقاً في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
"cascadePromptHeader_other": "سيتأثر {{count}} اجتماع لاحق في هذا اليوم. هل تريد تأجيلها أيضاً بـ {{minutesText}}؟",
"cascadeListTitle": "الاجتماعات المتأثرة:",
"cascadeColIndex": "#",
"cascadeColMeetingNumber": "رقم الاجتماع",
"cascadeColTitle": "العنوان",
"cascadeColTimes": "من ← إلى",
-1
View File
@@ -1224,7 +1224,6 @@
"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:",
"cascadeColIndex": "#",
"cascadeColMeetingNumber": "Meeting #",
"cascadeColTitle": "Title",
"cascadeColTimes": "From → To",
@@ -1088,11 +1088,11 @@ test("Cascade prompt UI: Shift sends cascadeFollowing=true; Keep sends false", a
// Our follower row must appear in the list with its testid.
const followerRow = page.getByTestId(`cascade-follower-${follower.id}`);
await expect(followerRow).toBeVisible();
// #480: the row is rendered as a numbered table — the first cell is
// the 1-based index, the second is the meeting's dailyNumber.
// #481: the row is rendered as a 3-column table (Meeting #, Title,
// From → To). The leading "#" index column was dropped because the
// dailyNumber already gives the user a stable identifier.
const followerCells = followerRow.locator("td");
await expect(followerCells.nth(0)).toHaveText("1");
await expect(followerCells.nth(1)).toHaveText(String(follower.dailyNumber));
await expect(followerCells.nth(0)).toHaveText(String(follower.dailyNumber));
// Intercept the mutation so we can read its JSON body without
// letting it actually fire (which would side-effect every other