#572 president-only focused view for executive meetings

User asked that when the President (`executive_ceo` role) logs into
the Meetings page he sees a clean "presentation" layout: just the
schedule table, an analog clock plus the weekday/date of the
schedule, and nothing else. The Manage / Notifications / Audit /
PDF tabs and the PDF-export shortcut button are noisy for him and
must disappear — but his backend permissions stay intact.

Changes (frontend only — backend role gates untouched):
- New component `components/executive-meetings/analog-clock.tsx`:
  small SVG analog clock (hour/minute/second hands, navy + gold),
  ticks every second, ~44px default.
- `pages/executive-meetings.tsx`:
  - Derived `isPresidentView` from the `/me` roles: true iff
    `executive_ceo` is present AND the user is NOT also `admin`
    or `executive_office_manager` (admins keep full UI).
  - Effect forces `section = "schedule"` whenever
    `isPresidentView` becomes true, so the president can never
    land on a now-hidden tab.
  - Hides the header PDF-shortcut button and the entire SECTIONS
    tab list when `isPresidentView`. The `#em-toolbar-portal` is
    kept so the date picker still mounts.
  - `ScheduleSection` accepts two new optional props:
    `isPresidentView` and `lang`. When `isPresidentView` is on,
    the toolbar renders the AnalogClock next to a label showing
    `formatWeekday(date, lang, "long")` and `formatDate(date, lang)`
    (e.g. "Sunday · May 17, 2026" / "الأحد · 17 مايو 2026"). The
    label updates immediately when the date input changes.
  - Edit-mode toggle stays gated on `canMutate`, so the president
    (who has canMutate) still gets the inline-edit pencil if he
    wants it.

Out of scope: backend role changes, applying the focused view to
other roles, manual presentation-mode toggle. Verified via tsc
(only the pre-existing `use-push-subscription` error remains).
This commit is contained in:
riyadhafraa
2026-05-17 14:32:19 +00:00
parent d002670e4e
commit 27384d019a
2 changed files with 128 additions and 11 deletions
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
type AnalogClockProps = {
size?: number;
className?: string;
};
export function AnalogClock({ size = 44, className = "" }: AnalogClockProps) {
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
const hours = now.getHours() % 12;
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const hourAngle = (hours + minutes / 60) * 30;
const minuteAngle = (minutes + seconds / 60) * 6;
const secondAngle = seconds * 6;
const cx = 50;
const cy = 50;
const navy = "#0B1E3F";
const gold = "#C9A04C";
return (
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className={className}
role="img"
aria-label="analog clock"
data-testid="em-analog-clock"
>
<circle cx={cx} cy={cy} r="46" fill="#ffffff" stroke={navy} strokeWidth="3" />
{Array.from({ length: 12 }).map((_, i) => {
const angle = (i * 30 * Math.PI) / 180;
const x1 = cx + Math.sin(angle) * 40;
const y1 = cy - Math.cos(angle) * 40;
const x2 = cx + Math.sin(angle) * 44;
const y2 = cy - Math.cos(angle) * 44;
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={navy}
strokeWidth={i % 3 === 0 ? 2.5 : 1.5}
strokeLinecap="round"
/>
);
})}
<g transform={`rotate(${hourAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 6} x2={cx} y2={cy - 24} stroke={navy} strokeWidth="4" strokeLinecap="round" />
</g>
<g transform={`rotate(${minuteAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 8} x2={cx} y2={cy - 34} stroke={navy} strokeWidth="2.75" strokeLinecap="round" />
</g>
<g transform={`rotate(${secondAngle} ${cx} ${cy})`}>
<line x1={cx} y1={cy + 10} x2={cx} y2={cy - 38} stroke={gold} strokeWidth="1.5" strokeLinecap="round" />
</g>
<circle cx={cx} cy={cy} r="3" fill={navy} />
<circle cx={cx} cy={cy} r="1.25" fill={gold} />
</svg>
);
}
@@ -114,6 +114,8 @@ import { EditableCell } from "@/components/editable-cell";
// #486: reuse the same dialog the upcoming-alert pops, so postponing
// from the schedule row quick-actions menu shares one implementation.
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
import { AnalogClock } from "@/components/executive-meetings/analog-clock";
import { formatDate, formatWeekday } from "@/lib/i18n-format";
import { safeHtml, htmlToPlainText, wrapAsParagraph } from "@/lib/safe-html";
import {
ALERT_COLOR_PRESETS,
@@ -850,6 +852,27 @@ function ExecutiveMeetingsPageInner() {
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
});
// #572: "President view" — a focused, presentation-style layout for users
// whose primary role is the CEO/president (`executive_ceo`) and who are
// NOT also wearing an admin hat (admin / office_manager). They get the
// schedule table only — no sub-nav tabs, no PDF shortcut, no audit log —
// plus an analog clock + weekday/date label in the toolbar. Their
// permissions on the backend are untouched; this is purely a UI gate.
const isPresidentView = useMemo(() => {
const roles = me?.roles ?? [];
if (!roles.includes("executive_ceo")) return false;
return !roles.some((r) => r === "admin" || r === "executive_office_manager");
}, [me]);
// Force the schedule section when the president lands on the page or
// their role flips on mid-session, so they never get stuck on a now-
// hidden tab.
useEffect(() => {
if (isPresidentView && section !== "schedule") {
setSection("schedule");
}
}, [isPresidentView, section]);
const { data: fontResp } = useQuery<FontSettingsResponse>({
queryKey: ["/api/executive-meetings/font-settings"],
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
@@ -939,23 +962,25 @@ function ExecutiveMeetingsPageInner() {
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
{!isPresidentView && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setSection("pdf")}
data-testid="em-export-shortcut"
>
<FileDown className="w-4 h-4" />
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
</Button>
)}
</div>
</div>
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex items-center gap-1 sm:gap-2">
<div className="flex gap-1 sm:gap-2 min-w-max">
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
{!isPresidentView && SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
({ key, icon: Icon }) => {
const active = section === key;
return (
@@ -999,6 +1024,8 @@ function ExecutiveMeetingsPageInner() {
columns={columns}
onColumnsChange={setColumns}
highlightPrefs={highlightPrefs}
isPresidentView={isPresidentView}
lang={lang}
/>
)}
{section === "manage" && me && (
@@ -1057,6 +1084,8 @@ function ScheduleSection({
columns,
onColumnsChange,
highlightPrefs,
isPresidentView = false,
lang = "en",
}: {
meetings: Meeting[];
date: string;
@@ -1070,6 +1099,8 @@ function ScheduleSection({
columns: ColumnSetting[];
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
highlightPrefs: HighlightPrefs;
isPresidentView?: boolean;
lang?: "ar" | "en";
}) {
const qc = useQueryClient();
const { toast } = useToast();
@@ -2645,6 +2676,22 @@ function ScheduleSection({
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
/>
</label>
{isPresidentView && (
<div
className="flex items-center gap-2 sm:gap-3 ps-2 sm:ps-3 ms-1 border-s border-gray-200"
data-testid="em-president-clock-bar"
>
<AnalogClock size={40} />
<div className="leading-tight">
<div className="text-[#0B1E3F] font-semibold text-sm sm:text-base">
{formatWeekday(date, lang, "long")}
</div>
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums">
{formatDate(date, lang)}
</div>
</div>
</div>
)}
</>
);