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:
@@ -1174,7 +1174,9 @@
|
|||||||
"exit": "الخروج من ملء الشاشة"
|
"exit": "الخروج من ملء الشاشة"
|
||||||
},
|
},
|
||||||
"quickNote": {
|
"quickNote": {
|
||||||
"label": "اكتب ملاحظة…"
|
"label": "اكتب ملاحظة…",
|
||||||
|
"placeholder": "اكتب ملاحظة سريعة…",
|
||||||
|
"send": "إرسال"
|
||||||
},
|
},
|
||||||
"noMeetings": "لا توجد اجتماعات في هذا اليوم",
|
"noMeetings": "لا توجد اجتماعات في هذا اليوم",
|
||||||
"virtual": "اتصال مرئي",
|
"virtual": "اتصال مرئي",
|
||||||
|
|||||||
@@ -1082,7 +1082,9 @@
|
|||||||
"exit": "Exit fullscreen"
|
"exit": "Exit fullscreen"
|
||||||
},
|
},
|
||||||
"quickNote": {
|
"quickNote": {
|
||||||
"label": "Take a note…"
|
"label": "Take a note…",
|
||||||
|
"placeholder": "Write a quick note…",
|
||||||
|
"send": "Send"
|
||||||
},
|
},
|
||||||
"noMeetings": "No meetings scheduled for this day",
|
"noMeetings": "No meetings scheduled for this day",
|
||||||
"virtual": "Virtual",
|
"virtual": "Virtual",
|
||||||
|
|||||||
@@ -2514,11 +2514,7 @@ function SystemUpdatesPanel() {
|
|||||||
data-testid="system-updates-started-at"
|
data-testid="system-updates-started-at"
|
||||||
>
|
>
|
||||||
{t("admin.systemUpdates.serverStartedAt")}:{" "}
|
{t("admin.systemUpdates.serverStartedAt")}:{" "}
|
||||||
{formatChecked(
|
{formatChecked(data.startedAt)}
|
||||||
typeof data.startedAt === "string"
|
|
||||||
? data.startedAt
|
|
||||||
: data.startedAt.toISOString(),
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -784,25 +784,19 @@ export default function ExecutiveMeetingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HeaderClock({ lang, date }: { lang: "ar" | "en"; date: string }) {
|
function HeaderClock({ lang, date }: { lang: "ar" | "en"; date: string }) {
|
||||||
const [now, setNow] = useState<Date>(() => new Date());
|
// The analog dial already conveys the live time — the previous digital
|
||||||
useEffect(() => {
|
// "HH:MM:SS" string next to it was redundant, so it was removed. We
|
||||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
// keep weekday + date because those are calendar info, not clock info.
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 sm:gap-3 print:hidden"
|
className="flex items-center gap-1.5 sm:gap-3 print:hidden"
|
||||||
data-testid="em-header-clock"
|
data-testid="em-header-clock"
|
||||||
aria-live="off"
|
aria-live="off"
|
||||||
>
|
>
|
||||||
<AnalogClock size={36} />
|
<AnalogClock size={44} />
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="flex items-center gap-2 text-[#0B1E3F] font-semibold text-sm sm:text-base">
|
<div className="text-[#0B1E3F] font-semibold text-sm sm:text-base" data-testid="em-header-weekday">
|
||||||
<span data-testid="em-header-weekday">{formatWeekday(date, lang, "long")}</span>
|
{formatWeekday(date, lang, "long")}
|
||||||
<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>
|
</div>
|
||||||
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-header-date">
|
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-header-date">
|
||||||
{formatDate(date, lang)}
|
{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() {
|
function ExecutiveMeetingsPageInner() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
@@ -849,6 +951,13 @@ function ExecutiveMeetingsPageInner() {
|
|||||||
|
|
||||||
const [section, setSection] = useState<SectionKey>("schedule");
|
const [section, setSection] = useState<SectionKey>("schedule");
|
||||||
const [date, setDate] = useState<string>(todayIso());
|
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
|
// #583 fullscreen toggle for the schedule view. Hides the page header
|
||||||
// and toolbar chrome so the meetings table can fill the viewport.
|
// and toolbar chrome so the meetings table can fill the viewport.
|
||||||
@@ -1005,10 +1114,10 @@ function ExecutiveMeetingsPageInner() {
|
|||||||
dir={isRtl ? "rtl" : "ltr"}
|
dir={isRtl ? "rtl" : "ltr"}
|
||||||
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
|
style={{ ["--em-header-h" as string]: "0px", ["--em-heading-h" as string]: "0px" }}
|
||||||
>
|
>
|
||||||
{section === "schedule" && !isFullscreen && !bulkToolbarActive && (
|
{section === "schedule" && !isFullscreen && !bulkToolbarActive && !quickNoteOpen && (
|
||||||
<MeetingsQuickNoteTab
|
<MeetingsQuickNoteTab
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={() => setLocation("/notes?new=1")}
|
onClick={() => setQuickNoteOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
@@ -1112,10 +1221,19 @@ function ExecutiveMeetingsPageInner() {
|
|||||||
<main
|
<main
|
||||||
className={
|
className={
|
||||||
isFullscreen
|
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"
|
: "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" && (
|
{section === "schedule" && (
|
||||||
<ScheduleSection
|
<ScheduleSection
|
||||||
meetings={meetings}
|
meetings={meetings}
|
||||||
|
|||||||
Reference in New Issue
Block a user