Task #292: Show schedule meetings in 12h format and add LTR Start/End labels to inline editor

What changed:
- artifacts/tx-os/src/pages/executive-meetings.tsx
  - formatTime helper now uses hour: "numeric", minute: "2-digit", hour12: true
    (was hour12: false). All on-page time displays — schedule cells and the
    Manage tab list — flow through this single helper. Latin digits remain
    enforced via the shared i18nFormatTime helper, so Arabic renders as
    "5:39 م" rather than mixing Arabic-Indic numerals into the table.
  - Inline time editor (InlineTimeEditor):
    - Wrapper locked to dir="ltr" so start-on-left / end-on-right (and
      save/cancel after end) layout never mirrors under RTL.
    - Each <input type="time"> wrapped in a vertical mini-column with a
      small muted <label> above ("Start"/"End" or "البدء"/"الانتهاء").
    - Stable per-meeting input ids (em-time-{start,end}-input-<id>) wired
      via htmlFor so click-to-focus works and screen readers don't jump
      across rows. Existing aria-labels preserved for accessibility.
    - All existing data-testids preserved; keyboard (Enter/Escape) and
      blur-to-save behavior unchanged.
- artifacts/tx-os/src/locales/en.json + ar.json
  - Added executiveMeetings.schedule.timeStartShort ("Start"/"البدء")
    and timeEndShort ("End"/"الانتهاء"). Existing timeStart/timeEnd
    ("Start time"/"End time") were too long for the compact label slot
    above the time inputs and remain in use as aria-labels.

Out of scope (verified):
- API-side PDF renderer at artifacts/api-server/src/lib/pdf-renderer.ts
  already uses hour12: true (line 291) — no change needed.
- No tx-os print/PDF page exists; scope confined to the schedule page.
- Audit log timestamps, home/chat clocks, clockHour12 user setting:
  out of scope, untouched.

Verification:
- TypeScript clean.
- E2E (English): visual screenshot confirmed schedule shows
  "5:39 PM – 5:50 PM" / "6:20 PM – 6:25 PM"; inline editor shows Start/End
  labels above inputs in correct LTR order.
- E2E (Arabic): runTest passed — time cells use Latin digits with ص/م
  markers; inline editor shows البدء/الانتهاء labels with LTR layout
  (start-on-left, end-on-right, save/cancel on right).
- Architect code review: PASS, no critical issues.
- No existing test asserts a specific 24h display string, so no e2e
  test breakage introduced.

Notes:
- Pre-existing test workflow failures on executive-meetings*.test.mjs
  (500 errors on meeting create + Reorder 400 vs 200) are unrelated to
  this task — they pre-date both this task and Task #205.
- Re-applies and extends the 12h direction from earlier Task #160 which
  had regressed back to 24h.
This commit is contained in:
riyadhafraa
2026-05-01 16:28:02 +00:00
parent 49bf44f0a2
commit f2c031e1fe
4 changed files with 71 additions and 30 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+2
View File
@@ -1087,6 +1087,8 @@
"newMeetingDefaultEn": "New meeting",
"timeStart": "وقت البداية",
"timeEnd": "وقت النهاية",
"timeStartShort": "البدء",
"timeEndShort": "الانتهاء",
"timeOrderError": "وقت النهاية قبل وقت البداية",
"addAttendee": "أضف حاضر",
"addPersonHere": "+ شخص هنا",
+2
View File
@@ -1005,6 +1005,8 @@
"newMeetingDefaultEn": "New meeting",
"timeStart": "Start time",
"timeEnd": "End time",
"timeStartShort": "Start",
"timeEndShort": "End",
"timeOrderError": "End time is before start time",
"addAttendee": "Add attendee",
"addPersonHere": "+ person here",
@@ -357,18 +357,24 @@ function computeCurrentMeetingId(
function formatTime(t: string | null, lang: "ar" | "en"): string {
if (!t) return "";
// DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
// Intl can format the time-of-day in the user's language. We use 24h
// (no AM/PM) so the schedule shows a clean "10:00 11:00" without
// the ص/م suffix in Arabic. Latin digits are enforced via the shared
// formatTime helper. Falls back to the raw HH:mm slice if parsing
// somehow fails (defensive — shouldn't happen for our DB shape).
// Intl can format the time-of-day in the user's language. We render
// in 12-hour format with AM/PM (English) or ص/م (Arabic) — admins
// expect 12h on this page since meeting times sit in everyday work
// hours, and the previous 24h format ("17:39") was confusing. The
// shared `i18nFormatTime` helper enforces Latin digits in both
// languages so the schedule reads "5:39 PM" / "5:39 م" rather than
// mixing Arabic-Indic numerals into the table. `hour: "numeric"`
// (vs "2-digit") drops the leading zero so single-digit hours render
// as "5:39 PM" instead of "05:39 PM". Falls back to the raw HH:mm
// slice if parsing somehow fails (defensive — shouldn't happen for
// our DB shape).
const hhmm = t.slice(0, 5);
const parsed = new Date(`1970-01-01T${hhmm}:00`);
if (Number.isNaN(parsed.getTime())) return hhmm;
return i18nFormatTime(parsed, lang, {
hour: "2-digit",
hour: "numeric",
minute: "2-digit",
hour12: false,
hour12: true,
});
}
@@ -3480,41 +3486,72 @@ function TimeRangeCell({
}
};
// Stable per-meeting input ids so the <label htmlFor=...> hookup is
// unique across meeting rows in the schedule. Without this, screen
// readers and click-to-focus on the label would jump to the first
// matching id elsewhere in the document.
const startInputId = `em-time-start-input-${meeting.id}`;
const endInputId = `em-time-end-input-${meeting.id}`;
return (
<div
ref={wrapperRef}
onFocus={onFocusIn}
onBlur={onFocusOut}
className="flex items-center justify-center gap-1 font-mono whitespace-nowrap"
// Lock the editor to LTR so start-on-the-left / end-on-the-right
// is preserved regardless of the surrounding RTL Arabic page
// layout. Without this, the two time inputs, the dash, and the
// save/cancel buttons get visually mirrored, making it impossible
// for the user to tell which input is which.
dir="ltr"
className="inline-flex items-end justify-center gap-1 font-mono whitespace-nowrap"
data-testid={`em-time-${meeting.id}`}
data-editing="true"
>
<input
ref={startInputRef}
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("executiveMeetings.schedule.timeStart")}
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
data-testid={`em-time-start-${meeting.id}`}
/>
<span className="text-muted-foreground"></span>
<input
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("executiveMeetings.schedule.timeEnd")}
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
data-testid={`em-time-end-${meeting.id}`}
/>
<div className="flex flex-col items-center">
<label
htmlFor={startInputId}
className="text-[10px] leading-none text-muted-foreground mb-0.5 select-none"
>
{t("executiveMeetings.schedule.timeStartShort")}
</label>
<input
ref={startInputRef}
id={startInputId}
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("executiveMeetings.schedule.timeStart")}
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
data-testid={`em-time-start-${meeting.id}`}
/>
</div>
<span className="text-muted-foreground pb-0.5"></span>
<div className="flex flex-col items-center">
<label
htmlFor={endInputId}
className="text-[10px] leading-none text-muted-foreground mb-0.5 select-none"
>
{t("executiveMeetings.schedule.timeEndShort")}
</label>
<input
id={endInputId}
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("executiveMeetings.schedule.timeEnd")}
className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
data-testid={`em-time-end-${meeting.id}`}
/>
</div>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => void save()}
aria-label={t("common.save")}
className="ms-0.5 p-0.5 rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none"
className="ms-0.5 p-0.5 rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none mb-0.5"
data-testid={`em-time-save-${meeting.id}`}
>
<Check className="w-3.5 h-3.5" />
@@ -3524,7 +3561,7 @@ function TimeRangeCell({
onMouseDown={(e) => e.preventDefault()}
onClick={cancel}
aria-label={t("common.cancel")}
className="p-0.5 rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none"
className="p-0.5 rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none mb-0.5"
data-testid={`em-time-cancel-${meeting.id}`}
>
<X className="w-3.5 h-3.5" />