Task #590: Daily meetings — clock, inline notes, fullscreen header

Three small UX fixes on /executive-meetings (schedule tab):

1) HeaderClock: dropped the digital "HH:MM:SS م" string next to the
   weekday — the analog dial already conveys the live time, so it was
   redundant. Bumped AnalogClock size from 36 to 44 so the dial reads
   cleanly without the digital crutch. Weekday + date stay (calendar
   info, not clock info). Removed the now-unused `useState`/`useEffect`
   ticker inside HeaderClock — AnalogClock has its own.

2) "اكتب ملاحظة" yellow side tab no longer navigates away to /notes.
   Added a transient `quickNoteOpen` local state in
   ExecutiveMeetingsPageInner. The tab's onClick now toggles it on; a
   new `InlineQuickNotePanel` is rendered above <ScheduleSection> with
   an auto-focused textarea, Cancel/Send buttons, Cmd/Ctrl+Enter to
   submit, Esc to close. Submit POSTs to /api/notes with the existing
   contract ({ content, color: "yellow" }) and closes on success. The
   side tab hides while the panel is open so they don't double up.
   State auto-resets when the user leaves the schedule tab.

3) Fullscreen exit pill overlap: the floating "الخروج من ملء الشاشة"
   pill (fixed top-3 end-3) was covering the schedule table's "الوقت"
   column header. Added `pt-12 sm:pt-14` to the fullscreen branch of
   <main> so the table starts below the pill on iPad landscape
   (1024-wide). Non-fullscreen layout unchanged.

i18n: added executiveMeetings.quickNote.{placeholder,send} in EN+AR;
existing executiveMeetings.quickNote.label reused for the panel
header. All keys also have inline fallbacks so missing translations
don't break.

Drive-by: fixed a TypeScript narrowing error in admin.tsx left over
from the prior task's review-comment cleanup (data.startedAt is
already typed as string, so the typeof check produced `never` in the
else branch — replaced with a direct call).

Verified: pnpm --filter @workspace/tx-os exec tsc --noEmit passes
clean, Vite HMR updated cleanly in dev.
This commit is contained in:
riyadhafraa
2026-05-18 11:04:27 +00:00
parent 3cfa24afc9
commit bf81debebf
4 changed files with 140 additions and 22 deletions
+3 -1
View File
@@ -1174,7 +1174,9 @@
"exit": "الخروج من ملء الشاشة"
},
"quickNote": {
"label": "اكتب ملاحظة…"
"label": "اكتب ملاحظة…",
"placeholder": "اكتب ملاحظة سريعة…",
"send": "إرسال"
},
"noMeetings": "لا توجد اجتماعات في هذا اليوم",
"virtual": "اتصال مرئي",
+3 -1
View File
@@ -1082,7 +1082,9 @@
"exit": "Exit fullscreen"
},
"quickNote": {
"label": "Take a note…"
"label": "Take a note…",
"placeholder": "Write a quick note…",
"send": "Send"
},
"noMeetings": "No meetings scheduled for this day",
"virtual": "Virtual",
+1 -5
View File
@@ -2514,11 +2514,7 @@ function SystemUpdatesPanel() {
data-testid="system-updates-started-at"
>
{t("admin.systemUpdates.serverStartedAt")}:{" "}
{formatChecked(
typeof data.startedAt === "string"
? data.startedAt
: data.startedAt.toISOString(),
)}
{formatChecked(data.startedAt)}
</div>
)}
</div>
+133 -15
View File
@@ -784,25 +784,19 @@ export default function ExecutiveMeetingsPage() {
}
function HeaderClock({ lang, date }: { lang: "ar" | "en"; date: string }) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
// The analog dial already conveys the live time — the previous digital
// "HH:MM:SS" string next to it was redundant, so it was removed. We
// keep weekday + date because those are calendar info, not clock info.
return (
<div
className="flex items-center gap-1.5 sm:gap-3 print:hidden"
data-testid="em-header-clock"
aria-live="off"
>
<AnalogClock size={36} />
<AnalogClock size={44} />
<div className="leading-tight">
<div className="flex items-center gap-2 text-[#0B1E3F] font-semibold text-sm sm:text-base">
<span data-testid="em-header-weekday">{formatWeekday(date, lang, "long")}</span>
<span className="text-muted-foreground/60">·</span>
<span className="tabular-nums" data-testid="em-header-digital-time">
{formatTimeI18n(now, lang, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true })}
</span>
<div className="text-[#0B1E3F] font-semibold text-sm sm:text-base" data-testid="em-header-weekday">
{formatWeekday(date, lang, "long")}
</div>
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-header-date">
{formatDate(date, lang)}
@@ -840,6 +834,114 @@ function MeetingsQuickNoteTab({
);
}
function InlineQuickNotePanel({
lang,
onClose,
}: {
lang: "ar" | "en";
onClose: () => void;
}) {
const { t } = useTranslation();
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
const submit = async () => {
const trimmed = content.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/notes", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: trimmed, color: "yellow" }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `HTTP ${res.status}`);
}
setContent("");
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const placeholder = t(
"executiveMeetings.quickNote.placeholder",
lang === "ar" ? "اكتب ملاحظة سريعة…" : "Write a quick note…",
);
const sendLabel = t("executiveMeetings.quickNote.send", lang === "ar" ? "إرسال" : "Send");
const cancelLabel = t("common.cancel", lang === "ar" ? "إلغاء" : "Cancel");
return (
<div
data-testid="em-quick-note-panel"
className="mb-3 sm:mb-4 rounded-lg border border-amber-300 bg-amber-50 shadow-sm print:hidden"
>
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-amber-200">
<div className="text-sm font-semibold text-[#0B1E3F]">
{t("executiveMeetings.quickNote.label", lang === "ar" ? "اكتب ملاحظة…" : "Take a note…")}
</div>
<button
type="button"
onClick={onClose}
aria-label={cancelLabel}
data-testid="em-quick-note-close"
className="rounded-md p-1 text-[#0B1E3F] hover:bg-amber-200/70"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-3 space-y-2">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
placeholder={placeholder}
rows={3}
className="w-full resize-y rounded-md border border-amber-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-300"
data-testid="em-quick-note-textarea"
/>
{error && (
<div className="text-xs text-red-600" data-testid="em-quick-note-error">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose} type="button">
{cancelLabel}
</Button>
<Button
type="button"
size="sm"
onClick={() => void submit()}
disabled={busy || content.trim().length === 0}
data-testid="em-quick-note-send"
className="bg-[#0B1E3F] text-white hover:bg-[#0B1E3F]/90"
>
{sendLabel}
</Button>
</div>
</div>
</div>
);
}
function ExecutiveMeetingsPageInner() {
const { t, i18n } = useTranslation();
const [, setLocation] = useLocation();
@@ -849,6 +951,13 @@ function ExecutiveMeetingsPageInner() {
const [section, setSection] = useState<SectionKey>("schedule");
const [date, setDate] = useState<string>(todayIso());
// Inline quick-note panel toggled by the yellow side tab. Kept as
// transient local state (not sessionStorage) — it's a quick capture,
// not a view preference. Auto-closes when leaving the schedule tab.
const [quickNoteOpen, setQuickNoteOpen] = useState<boolean>(false);
useEffect(() => {
if (section !== "schedule") setQuickNoteOpen(false);
}, [section]);
// #583 fullscreen toggle for the schedule view. Hides the page header
// and toolbar chrome so the meetings table can fill the viewport.
@@ -1005,10 +1114,10 @@ function ExecutiveMeetingsPageInner() {
dir={isRtl ? "rtl" : "ltr"}
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
>
{section === "schedule" && !isFullscreen && !bulkToolbarActive && (
{section === "schedule" && !isFullscreen && !bulkToolbarActive && !quickNoteOpen && (
<MeetingsQuickNoteTab
lang={lang}
onClick={() => setLocation("/notes?new=1")}
onClick={() => setQuickNoteOpen(true)}
/>
)}
{isFullscreen && (
@@ -1112,10 +1221,19 @@ function ExecutiveMeetingsPageInner() {
<main
className={
isFullscreen
? "w-full px-2 sm:px-3 py-2"
? // Extra top padding in fullscreen so the floating "Exit
// fullscreen" pill (fixed top-3 end-3) doesn't overlap the
// schedule table's header row (especially the time column).
"w-full px-2 sm:px-3 pt-12 sm:pt-14 pb-2"
: "max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6"
}
>
{section === "schedule" && quickNoteOpen && (
<InlineQuickNotePanel
lang={lang}
onClose={() => setQuickNoteOpen(false)}
/>
)}
{section === "schedule" && (
<ScheduleSection
meetings={meetings}