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