diff --git a/artifacts/tx-os/src/__tests__/parse-time.test.mjs b/artifacts/tx-os/src/__tests__/parse-time.test.mjs
new file mode 100644
index 00000000..8ec1cad9
--- /dev/null
+++ b/artifacts/tx-os/src/__tests__/parse-time.test.mjs
@@ -0,0 +1,124 @@
+// Unit tests for parseTypedTime — the human-input time parser used by
+// the executive-meetings inline time editor. Covers every shape listed
+// in task #308's "Done looks like" plus the obvious invalid edges.
+//
+// Runs with Node 24's built-in test runner + native TypeScript support.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseTypedTime } from "../lib/parse-time.ts";
+
+test("returns null for empty / whitespace-only input", () => {
+ assert.equal(parseTypedTime(""), null);
+ assert.equal(parseTypedTime(" "), null);
+ assert.equal(parseTypedTime("\t\n"), null);
+});
+
+test("canonical 24h HH:MM passes through unchanged", () => {
+ assert.equal(parseTypedTime("09:30"), "09:30");
+ assert.equal(parseTypedTime("00:00"), "00:00");
+ assert.equal(parseTypedTime("23:59"), "23:59");
+ assert.equal(parseTypedTime("12:00"), "12:00");
+});
+
+test("zero-padding is added for single-digit hours / minutes", () => {
+ assert.equal(parseTypedTime("9:30"), "09:30");
+ assert.equal(parseTypedTime("9:5"), "09:05");
+ assert.equal(parseTypedTime("0:0"), "00:00");
+});
+
+test("compact 3-4 digit form parses as HHMM / HMM", () => {
+ assert.equal(parseTypedTime("0930"), "09:30");
+ assert.equal(parseTypedTime("930"), "09:30");
+ assert.equal(parseTypedTime("1230"), "12:30");
+ assert.equal(parseTypedTime("0000"), "00:00");
+ assert.equal(parseTypedTime("2359"), "23:59");
+ // 3-digit form: first digit is the hour, last two the minutes.
+ assert.equal(parseTypedTime("100"), "01:00");
+});
+
+test("alternate separators (., -, whitespace) are accepted", () => {
+ assert.equal(parseTypedTime("9.30"), "09:30");
+ assert.equal(parseTypedTime("9-30"), "09:30");
+ assert.equal(parseTypedTime("9 30"), "09:30");
+ assert.equal(parseTypedTime(" 9 . 30 "), "09:30");
+});
+
+test("English AM/PM markers are honoured", () => {
+ assert.equal(parseTypedTime("9:30 AM"), "09:30");
+ assert.equal(parseTypedTime("9:30am"), "09:30");
+ assert.equal(parseTypedTime("9:30 PM"), "21:30");
+ assert.equal(parseTypedTime("9:30pm"), "21:30");
+ // Dotted forms.
+ assert.equal(parseTypedTime("9:30 a.m."), "09:30");
+ assert.equal(parseTypedTime("9:30 p.m."), "21:30");
+ // 12 AM = midnight, 12 PM = noon.
+ assert.equal(parseTypedTime("12:00 AM"), "00:00");
+ assert.equal(parseTypedTime("12:30 AM"), "00:30");
+ assert.equal(parseTypedTime("12:00 PM"), "12:00");
+ assert.equal(parseTypedTime("12:30 PM"), "12:30");
+});
+
+test("Arabic AM/PM markers (ص / م) are honoured", () => {
+ assert.equal(parseTypedTime("9:30 ص"), "09:30");
+ assert.equal(parseTypedTime("9:30 م"), "21:30");
+ assert.equal(parseTypedTime("12:00 ص"), "00:00");
+ assert.equal(parseTypedTime("12:00 م"), "12:00");
+ // No space between time and marker.
+ assert.equal(parseTypedTime("9:30م"), "21:30");
+});
+
+test("Arabic-Indic digits (٠-٩) are mapped to ASCII", () => {
+ assert.equal(parseTypedTime("٠٩:٣٠"), "09:30");
+ assert.equal(parseTypedTime("٩:٣٠"), "09:30");
+ assert.equal(parseTypedTime("٩:٣٠ ص"), "09:30");
+ assert.equal(parseTypedTime("٩:٣٠ م"), "21:30");
+ assert.equal(parseTypedTime("٢٣:٥٩"), "23:59");
+});
+
+test("Persian / Eastern Arabic-Indic digits (۰-۹) are mapped to ASCII", () => {
+ assert.equal(parseTypedTime("۰۹:۳۰"), "09:30");
+ assert.equal(parseTypedTime("۱۲:۴۵"), "12:45");
+});
+
+test("invalid hours/minutes return null", () => {
+ assert.equal(parseTypedTime("24:00"), null);
+ assert.equal(parseTypedTime("25:99"), null);
+ assert.equal(parseTypedTime("9:60"), null);
+ assert.equal(parseTypedTime("99:99"), null);
+});
+
+test("12-hour-only hours out of 1..12 reject when AM/PM is given", () => {
+ assert.equal(parseTypedTime("0:30 AM"), null);
+ assert.equal(parseTypedTime("13:00 PM"), null);
+ assert.equal(parseTypedTime("13:00 ص"), null);
+});
+
+test("garbage input returns null", () => {
+ assert.equal(parseTypedTime("abc"), null);
+ // 3-segment form (HH:MM:SS) is rejected — we don't support seconds.
+ assert.equal(parseTypedTime("9:30:45"), null);
+ assert.equal(parseTypedTime(":30"), null);
+ assert.equal(parseTypedTime("9:"), null);
+ // 5-digit compact form is not a valid HHMM/HMM.
+ assert.equal(parseTypedTime("12345"), null);
+ // Mixed-with-letters input is rejected.
+ assert.equal(parseTypedTime("9h30m"), null);
+});
+
+test("collapses repeated separators (lenient typo recovery)", () => {
+ // Repeated separator characters are treated as one: this is a
+ // deliberate "be liberal in what you accept" choice so a user who
+ // double-tapped ":" or mixed "." and " " still gets a valid time
+ // rather than a useless parse error.
+ assert.equal(parseTypedTime("9::30"), "09:30");
+ assert.equal(parseTypedTime("9.. 30"), "09:30");
+});
+
+test("non-string inputs return null", () => {
+ // @ts-expect-error - intentionally testing the runtime guard
+ assert.equal(parseTypedTime(null), null);
+ // @ts-expect-error
+ assert.equal(parseTypedTime(undefined), null);
+ // @ts-expect-error
+ assert.equal(parseTypedTime(930), null);
+});
diff --git a/artifacts/tx-os/src/lib/parse-time.ts b/artifacts/tx-os/src/lib/parse-time.ts
new file mode 100644
index 00000000..da5b43d6
--- /dev/null
+++ b/artifacts/tx-os/src/lib/parse-time.ts
@@ -0,0 +1,92 @@
+// Pure, unit-testable parser for human-typed times in the executive
+// meetings inline time editor. The native only
+// surfaces a value to the controlled state when the typed text is a
+// complete, canonical "HH:MM" 24h string — partial input, "9:30"
+// without a leading zero, "0930" compact, "9:30 PM" / "9:30 ص", or
+// Arabic-Indic digits all leave the React state stale, which made
+// "type then press Enter" a silent no-op. We replace the native time
+// input with a plain text input and route every save path through
+// this parser so what the user types is what gets saved.
+//
+// Returns canonical "HH:MM" (24h, zero-padded) or null when the input
+// cannot be parsed. Empty / whitespace-only input returns null too —
+// the caller decides whether that means "clear the time" (current
+// behaviour) or "show a parse error".
+
+export function parseTypedTime(input: string): string | null {
+ if (typeof input !== "string") return null;
+ let s = input.trim();
+ if (!s) return null;
+
+ // Map Arabic-Indic (٠-٩) and Persian/Eastern Arabic-Indic (۰-۹)
+ // digits to ASCII so an Arabic user typing "٠٩:٣٠" parses identically
+ // to "09:30".
+ s = s.replace(/[\u0660-\u0669]/g, (d) =>
+ String(d.charCodeAt(0) - 0x0660),
+ );
+ s = s.replace(/[\u06F0-\u06F9]/g, (d) =>
+ String(d.charCodeAt(0) - 0x06F0),
+ );
+
+ // Detect a trailing AM/PM marker. Accepts English (am/pm, a.m./p.m.,
+ // any case) and Arabic (ص for morning/AM, م for evening/PM). The
+ // marker is stripped before numeric parsing and applied at the end.
+ let isAm = false;
+ let isPm = false;
+ const amPmMatch = /\s*(a\.?m\.?|p\.?m\.?|ص|م)\s*$/i.exec(s);
+ if (amPmMatch) {
+ const m = amPmMatch[1].toLowerCase().replace(/\./g, "");
+ if (m === "am" || m === "ص") isAm = true;
+ else if (m === "pm" || m === "م") isPm = true;
+ s = s.slice(0, amPmMatch.index).trim();
+ }
+
+ if (!s) return null;
+
+ let h: number;
+ let mm: number;
+
+ // Compact 3-4 digit form with no separator: "930" -> 9:30, "0930" ->
+ // 09:30, "1230" -> 12:30. We branch on length so "930" doesn't get
+ // read as 93:0.
+ if (/^\d{3,4}$/.test(s)) {
+ if (s.length === 3) {
+ h = Number(s[0]);
+ mm = Number(s.slice(1));
+ } else {
+ h = Number(s.slice(0, 2));
+ mm = Number(s.slice(2));
+ }
+ } else {
+ // Accept ":", ".", "-", or whitespace as the H/M separator. Collapse
+ // runs so "9 . 30" still parses cleanly.
+ const normalized = s.replace(/[.\-\s]+/g, ":").replace(/:+/g, ":");
+ const parts = normalized.split(":");
+ if (parts.length !== 2) return null;
+ if (!/^\d{1,2}$/.test(parts[0])) return null;
+ if (!/^\d{1,2}$/.test(parts[1])) return null;
+ h = Number(parts[0]);
+ mm = Number(parts[1]);
+ }
+
+ if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
+ if (mm < 0 || mm > 59) return null;
+
+ if (isAm || isPm) {
+ // 12h shapes only allow 1..12 for the hour. "13:00 PM" is rejected.
+ if (h < 1 || h > 12) return null;
+ if (isAm) {
+ // 12 AM = midnight = 00:xx.
+ if (h === 12) h = 0;
+ } else {
+ // 12 PM = noon = 12:xx; everything else gets +12.
+ if (h !== 12) h += 12;
+ }
+ } else {
+ if (h < 0 || h > 23) return null;
+ }
+
+ const hStr = String(h).padStart(2, "0");
+ const mStr = String(mm).padStart(2, "0");
+ return `${hStr}:${mStr}`;
+}
diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json
index cc82d65f..1003ecc0 100644
--- a/artifacts/tx-os/src/locales/ar.json
+++ b/artifacts/tx-os/src/locales/ar.json
@@ -1163,6 +1163,7 @@
"timeStartShort": "البدء",
"timeEndShort": "الانتهاء",
"timeOrderError": "وقت النهاية قبل وقت البداية",
+ "timeParseError": "صيغة الوقت غير صحيحة. اكتب الوقت مثل: 09:30 أو 9:30 ص",
"addAttendee": "أضف حاضر",
"addSubheading": "+ عنوان فرعي",
"addVirtualAttendee": "+ حاضر عبر الاتصال المرئي",
diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json
index 01a8f786..ad97b3a5 100644
--- a/artifacts/tx-os/src/locales/en.json
+++ b/artifacts/tx-os/src/locales/en.json
@@ -1046,6 +1046,7 @@
"timeStartShort": "Start",
"timeEndShort": "End",
"timeOrderError": "End time is before start time",
+ "timeParseError": "Time format isn't valid. Try 09:30 or 9:30 PM",
"addAttendee": "Add attendee",
"addSubheading": "+ subheading",
"addVirtualAttendee": "+ Virtual",
diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx
index 50cf5b6a..dcb4ce4a 100644
--- a/artifacts/tx-os/src/pages/executive-meetings.tsx
+++ b/artifacts/tx-os/src/pages/executive-meetings.tsx
@@ -88,6 +88,7 @@ import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
import { safeHtml } from "@/lib/safe-html";
+import { parseTypedTime } from "@/lib/parse-time";
import {
ALERT_COLOR_PRESETS,
DEFAULT_ALERT_PREFS,
@@ -3361,6 +3362,7 @@ function TimeRangeCell({
const wrapperRef = useRef(null);
const blurTimerRef = useRef | null>(null);
const startInputRef = useRef(null);
+ const endInputRef = useRef(null);
// If the saved times change while we're not editing (e.g. another tab
// updated them, or the user picked a different date), sync local state.
@@ -3374,6 +3376,10 @@ function TimeRangeCell({
useEffect(() => {
if (editing) {
startInputRef.current?.focus();
+ // Select the existing value so the user can type to overwrite
+ // immediately without having to clear the field first — matches
+ // the behaviour of the native spinner.
+ startInputRef.current?.select();
}
}, [editing]);
@@ -3401,8 +3407,42 @@ function TimeRangeCell({
}, [canMutate, editing, startSaved, endSaved]);
const save = useCallback(async () => {
- const newStart = start || null;
- const newEnd = end || null;
+ // Always read the live input values from the refs in addition to
+ // the controlled state. This is belt-and-braces against any path
+ // (Enter pressed before React's batched onChange flush, browser
+ // autofill, etc.) where the controlled state could be a frame
+ // behind what the user actually sees. parseTypedTime accepts a
+ // wide range of human-typed shapes (24h, 12h with AM/PM or ص/م,
+ // compact "HHMM", Arabic-Indic digits) and canonicalises them to
+ // "HH:MM"; empty input means "clear the time".
+ const rawStart = (startInputRef.current?.value ?? start ?? "").trim();
+ const rawEnd = (endInputRef.current?.value ?? end ?? "").trim();
+ const newStart = rawStart === "" ? null : parseTypedTime(rawStart);
+ const newEnd = rawEnd === "" ? null : parseTypedTime(rawEnd);
+
+ // If the user typed something in either field but it cannot be
+ // parsed, surface a clear destructive toast and keep the editor
+ // open with focus on the offending input — never silently discard
+ // their typing.
+ if (rawStart !== "" && newStart === null) {
+ toast({
+ title: t("executiveMeetings.schedule.timeParseError"),
+ variant: "destructive",
+ });
+ startInputRef.current?.focus();
+ startInputRef.current?.select();
+ return;
+ }
+ if (rawEnd !== "" && newEnd === null) {
+ toast({
+ title: t("executiveMeetings.schedule.timeParseError"),
+ variant: "destructive",
+ });
+ endInputRef.current?.focus();
+ endInputRef.current?.select();
+ return;
+ }
+
if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
setEditing(false);
return;
@@ -3417,6 +3457,12 @@ function TimeRangeCell({
startInputRef.current?.focus();
return;
}
+ // Snap the controlled state to the canonical "HH:MM" so the
+ // collapsed display (and any subsequent re-open of the editor)
+ // shows the same value the server now holds, even if the user
+ // typed a non-canonical shape like "9:30" or "0930".
+ setStart(newStart ?? "");
+ setEnd(newEnd ?? "");
try {
await onSaveTimes(newStart, newEnd);
setEditing(false);
@@ -3435,7 +3481,7 @@ function TimeRangeCell({
setEnd(endSaved);
setEditing(false);
}
- }, [start, end, startSaved, endSaved, onSaveTimes]);
+ }, [start, end, startSaved, endSaved, onSaveTimes, toast, t]);
const onFocusOut = (e: React.FocusEvent) => {
if (!editing) return;
@@ -3530,12 +3576,23 @@ function TimeRangeCell({
+ // only fires onChange when the value is a complete, canonical
+ // "HH:MM" string, which made keyboard typing + Enter a silent
+ // no-op for partial input, "9:30" without leading zero,
+ // "0930" compact, AM/PM shapes, and Arabic-Indic digits.
+ // parseTypedTime() handles all of those on save.
+ type="text"
+ inputMode="numeric"
+ autoComplete="off"
+ spellCheck={false}
+ placeholder="HH:MM"
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"
+ className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white text-center"
data-testid={`em-time-start-${meeting.id}`}
/>
@@ -3548,13 +3605,20 @@ function TimeRangeCell({
{t("executiveMeetings.schedule.timeEndShort")}
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"
+ className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white text-center"
data-testid={`em-time-end-${meeting.id}`}
/>
diff --git a/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs b/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs
index a1bd8e62..e18214bd 100644
--- a/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs
+++ b/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs
@@ -698,3 +698,266 @@ test("Schedule keyboard: Tab from the title cell reaches the time cell in the sa
await page.keyboard.press("Enter");
await expect(page.getByTestId(`em-time-start-${meetingId}`)).toBeFocused();
});
+
+// -------------------------------------------------------------------
+// Task #308: typing on the keyboard + Enter must save.
+//
+// The historical bug: the time cell used , which
+// only fires onChange when the value is a complete, canonical "HH:MM"
+// string. Typing "9:30" without a leading zero, "0930" compact, "9:30
+// PM", or Arabic-Indic digits left the controlled React state empty,
+// so pressing Enter ran save() with the stale value, equality with
+// the saved value short-circuited, no PATCH fired, and the cell
+// snapped back to "—" (or the prior value) — looking to the user
+// like Enter "didn't save".
+//
+// These cases exercise each typed shape through the full UI stack
+// (keystrokes → React state → save() → PATCH → DB → refetched cell
+// text) so a regression at any layer trips a test.
+// -------------------------------------------------------------------
+
+// Reusable helper: open a meeting's time cell, focus the start input,
+// clear it, type a new value, do the same for end, press Enter, and
+// wait for the PATCH + day GET refetch.
+async function typeTimesAndSave(page, meetingId, date, startText, endText) {
+ const timeCell = page.getByTestId(`em-time-${meetingId}`);
+ await timeCell.evaluate((el) => el.focus());
+ await page.keyboard.press("Enter");
+ const startInput = page.getByTestId(`em-time-start-${meetingId}`);
+ const endInput = page.getByTestId(`em-time-end-${meetingId}`);
+ await expect(startInput).toBeVisible();
+ await expect(startInput).toBeFocused();
+
+ // Clear and type into start. Use page.keyboard.type for the AR-digit
+ // case so the OS IME doesn't intercept; for ASCII we use fill() which
+ // is faster and equivalent.
+ await startInput.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.press("Delete");
+ await page.keyboard.type(startText, { delay: 10 });
+
+ await endInput.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.press("Delete");
+ await page.keyboard.type(endText, { delay: 10 });
+
+ const savePromise = page.waitForResponse(
+ (resp) => {
+ const u = new URL(resp.url());
+ return (
+ u.pathname === `/api/executive-meetings/${meetingId}` &&
+ resp.request().method() === "PATCH" &&
+ resp.ok()
+ );
+ },
+ { timeout: 15_000 },
+ );
+ const refetchPromise = waitForDayRefetch(page, date);
+ await page.keyboard.press("Enter");
+ await Promise.all([savePromise, refetchPromise]);
+}
+
+// Each row in this matrix is one accepted typing shape from the task's
+// "Done looks like" list. Both fields use the same shape so the
+// assertions stay simple, and the tested shapes intentionally vary in
+// hour/minute so a test that accidentally passes by reading stale
+// state (rather than the new value) would fail.
+const TYPING_SHAPES = [
+ { offset: 8, label: "canonical 24h", startText: "13:30", endText: "14:45", expectStart: "13:30:00", expectEnd: "14:45:00" },
+ { offset: 9, label: "no leading zero", startText: "9:30", endText: "10:45", expectStart: "09:30:00", expectEnd: "10:45:00" },
+ { offset: 10, label: "compact HHMM", startText: "0930", endText: "1045", expectStart: "09:30:00", expectEnd: "10:45:00" },
+ { offset: 11, label: "12h with PM", startText: "9:30 AM", endText: "9:30 PM", expectStart: "09:30:00", expectEnd: "21:30:00" },
+];
+
+for (const shape of TYPING_SHAPES) {
+ test(`Schedule keyboard: typing "${shape.label}" + Enter saves and PATCH succeeds`, async ({
+ page,
+ }) => {
+ await setLangEn(page);
+ // Each shape gets its own future date offset so the loop never
+ // collides on the (date, daily_number) unique constraint when
+ // these tests run in the same worker.
+ const date = uniqueFutureDate(shape.offset);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 1,
+ titleEn: `KbdType ${shape.label} ${Date.now().toString(36)}`,
+ titleAr: `إدخال ${shape.label} ${Date.now().toString(36)}`,
+ // Seed with an empty time cell so we exercise the
+ // "type from scratch" path the user reported broken.
+ startTime: null,
+ endTime: null,
+ });
+
+ await gotoMeetingRow(page, meetingId, date);
+
+ await typeTimesAndSave(page, meetingId, date, shape.startText, shape.endText);
+
+ const timeCell = page.getByTestId(`em-time-${meetingId}`);
+ await expect(page.getByTestId(`em-time-start-${meetingId}`)).toHaveCount(0);
+ await expect(page.getByTestId(`em-time-end-${meetingId}`)).toHaveCount(0);
+ // Don't pin the displayed text format — formatTime() picks 12h/24h
+ // per locale and the dash glyph varies. The DB row is the source
+ // of truth and is asserted below.
+ await expect(timeCell).not.toHaveText("—", { timeout: 10_000 });
+
+ const { rows } = await pool.query(
+ `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
+ [meetingId],
+ );
+ expect(String(rows[0].start_time)).toBe(shape.expectStart);
+ expect(String(rows[0].end_time)).toBe(shape.expectEnd);
+ });
+}
+
+test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical HH:MM", async ({
+ page,
+}) => {
+ // Default lang is Arabic — don't set EN. This mirrors how an Arabic
+ // user types times from their keyboard layout.
+ const date = uniqueFutureDate(13);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 1,
+ titleEn: `KbdAr ${Date.now().toString(36)}`,
+ titleAr: `أرقام عربية ${Date.now().toString(36)}`,
+ startTime: null,
+ endTime: null,
+ });
+
+ await gotoMeetingRow(page, meetingId, date);
+
+ // ٠٩:٣٠ -> 09:30, ١٠:٤٥ -> 10:45
+ await typeTimesAndSave(page, meetingId, date, "٠٩:٣٠", "١٠:٤٥");
+
+ const { rows } = await pool.query(
+ `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
+ [meetingId],
+ );
+ expect(String(rows[0].start_time)).toBe("09:30:00");
+ expect(String(rows[0].end_time)).toBe("10:45:00");
+});
+
+test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({
+ page,
+}) => {
+ await setLangEn(page);
+ const date = uniqueFutureDate(14);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 1,
+ titleEn: `KbdBad ${Date.now().toString(36)}`,
+ titleAr: `غير صالح ${Date.now().toString(36)}`,
+ startTime: "08:00:00",
+ endTime: "09:00:00",
+ });
+
+ await gotoMeetingRow(page, meetingId, date);
+
+ // Spy on PATCH — none should fire for an unparseable typed value.
+ let patchSeen = false;
+ page.on("response", (resp) => {
+ try {
+ const u = new URL(resp.url());
+ if (
+ u.pathname === `/api/executive-meetings/${meetingId}` &&
+ resp.request().method() === "PATCH"
+ ) {
+ patchSeen = true;
+ }
+ } catch {
+ /* ignore */
+ }
+ });
+
+ const timeCell = page.getByTestId(`em-time-${meetingId}`);
+ await timeCell.evaluate((el) => el.focus());
+ await page.keyboard.press("Enter");
+ const startInput = page.getByTestId(`em-time-start-${meetingId}`);
+ await expect(startInput).toBeFocused();
+ await startInput.click();
+ await page.keyboard.press("ControlOrMeta+a");
+ await page.keyboard.press("Delete");
+ await page.keyboard.type("25:99", { delay: 10 });
+
+ await page.keyboard.press("Enter");
+
+ // Editor must STAY open so the user can fix the typo. Toast surfaces
+ // the parse error.
+ await expect(startInput).toBeVisible();
+ await expect(startInput).toBeFocused();
+ // Match the toast title specifically. The toast also surfaces an
+ // ARIA live-region copy for screen readers, so without a scope the
+ // pattern resolves to two elements and trips strict mode.
+ await expect(
+ page
+ .locator('[data-component-name="ToastTitle"]')
+ .filter({ hasText: /Time format isn't valid|اكتب الوقت مثل/ }),
+ ).toBeVisible({ timeout: 5_000 });
+
+ // No PATCH should have been sent for the bad value.
+ await page.waitForTimeout(300);
+ expect(patchSeen).toBe(false);
+
+ // DB stayed at the seeded values.
+ const { rows } = await pool.query(
+ `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
+ [meetingId],
+ );
+ expect(String(rows[0].start_time)).toBe("08:00:00");
+ expect(String(rows[0].end_time)).toBe("09:00:00");
+});
+
+test("Schedule keyboard: Tab between start and end preserves the typed start value, Enter on end saves both", async ({
+ page,
+}) => {
+ await setLangEn(page);
+ const date = uniqueFutureDate(15);
+ const meetingId = await insertMeeting({
+ meetingDate: date,
+ dailyNumber: 1,
+ titleEn: `KbdTab ${Date.now().toString(36)}`,
+ titleAr: `تابات ${Date.now().toString(36)}`,
+ startTime: null,
+ endTime: null,
+ });
+
+ await gotoMeetingRow(page, meetingId, date);
+
+ const timeCell = page.getByTestId(`em-time-${meetingId}`);
+ await timeCell.evaluate((el) => el.focus());
+ await page.keyboard.press("Enter");
+ const startInput = page.getByTestId(`em-time-start-${meetingId}`);
+ const endInput = page.getByTestId(`em-time-end-${meetingId}`);
+ await expect(startInput).toBeFocused();
+
+ // Type into start, then Tab forward and type into end. The Tab
+ // commit path is what historically lost the start value when the
+ // input was type="time" because no canonical onChange had fired yet.
+ await page.keyboard.type("7:15", { delay: 10 });
+ await page.keyboard.press("Tab");
+ await expect(endInput).toBeFocused();
+ await page.keyboard.type("8:45", { delay: 10 });
+
+ const savePromise = page.waitForResponse(
+ (resp) => {
+ const u = new URL(resp.url());
+ return (
+ u.pathname === `/api/executive-meetings/${meetingId}` &&
+ resp.request().method() === "PATCH" &&
+ resp.ok()
+ );
+ },
+ { timeout: 15_000 },
+ );
+ const refetchPromise = waitForDayRefetch(page, date);
+ await page.keyboard.press("Enter");
+ await Promise.all([savePromise, refetchPromise]);
+
+ const { rows } = await pool.query(
+ `SELECT start_time, end_time FROM executive_meetings WHERE id = $1`,
+ [meetingId],
+ );
+ expect(String(rows[0].start_time)).toBe("07:15:00");
+ expect(String(rows[0].end_time)).toBe("08:45:00");
+});
diff --git a/attached_assets/image_1777709990981.png b/attached_assets/image_1777709990981.png
new file mode 100644
index 00000000..1c280682
Binary files /dev/null and b/attached_assets/image_1777709990981.png differ