From 0b8fed2e96b577c89d47bbce9e2f552fdcd3e38e Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 28 Jul 2026 12:45:29 +0000 Subject: [PATCH] Add calendar summary to dashboard; default portal to 1hr inactivity timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard now shows today's and upcoming events (mine or all, toggle) for users with calendar app access. Also gives the portal shell a 1-hour inactivity default for devices that have never been configured, instead of following child apps into an unlimited 30-day session — that's why the dashboard could sit logged in for days without prompting. An explicit "Off" choice in Admin Settings still overrides it (now written as mins=0 instead of clearing the cookie, so it's distinguishable from "never configured"). --- src/components/AuthGate.tsx | 11 +- src/components/CalendarSummary.tsx | 177 +++++++++++++++++++++++++++++ src/pages/AdminSettings.tsx | 7 +- src/pages/Dashboard.tsx | 3 + 4 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 src/components/CalendarSummary.tsx diff --git a/src/components/AuthGate.tsx b/src/components/AuthGate.tsx index 5c528a9..fe6d16f 100644 --- a/src/components/AuthGate.tsx +++ b/src/components/AuthGate.tsx @@ -2,12 +2,19 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import type { User } from '../types' +const DEFAULT_INACTIVITY_MS = 60 * 60 * 1000 + +// Unlike the child apps, the portal defaults an unconfigured device to a +// 1-hour timeout rather than an unlimited session — this is the shell most +// people leave open in a tab. An explicit choice in Admin Settings → Device +// (including "Off", written as hnf_inactivity_mins=0) always overrides it. function getInactivityMs(): number | null { if (window.matchMedia('(display-mode: standalone)').matches) return null const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins=')) - if (!c) return null + if (!c) return DEFAULT_INACTIVITY_MS const mins = parseInt(c.split('=')[1]) - return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 + if (isNaN(mins)) return DEFAULT_INACTIVITY_MS + return mins <= 0 ? null : mins * 60 * 1000 } diff --git a/src/components/CalendarSummary.tsx b/src/components/CalendarSummary.tsx new file mode 100644 index 0000000..b9dbe99 --- /dev/null +++ b/src/components/CalendarSummary.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { CalendarDays, Clock, MapPin, ChevronRight } from 'lucide-react' +import type { User } from '../types' + +interface EventSummary { + id: number + calendar_name: string + calendar_color: string + title: string + location: string | null + start_at: string + end_at: string + all_day: boolean +} + +const RANGE_DAYS = 7 +const UPCOMING_LIMIT = 6 + +function toISODate(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +function occursToday(ev: EventSummary, today: Date): boolean { + const start = new Date(ev.start_at) + const end = new Date(ev.end_at) + const dayStart = new Date(today); dayStart.setHours(0, 0, 0, 0) + const dayEnd = new Date(today); dayEnd.setHours(23, 59, 59, 999) + return start.getTime() <= dayEnd.getTime() && end.getTime() >= dayStart.getTime() +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function formatDayHeader(iso: string): string { + return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }) +} + +export function CalendarSummary({ user }: { user: User }) { + const navigate = useNavigate() + const calendarApp = user.apps.find(a => a.slug === 'calendar') + const [tab, setTab] = useState<'mine' | 'all'>('mine') + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + useEffect(() => { + if (!calendarApp) return + setLoading(true) + setError(false) + const url = tab === 'mine' + ? `/calendar/api/me/upcoming?days=${RANGE_DAYS}` + : `/calendar/api/events?from=${toISODate(new Date())}&to=${toISODate(new Date(Date.now() + RANGE_DAYS * 86400000))}` + fetch(url, { credentials: 'include' }) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(setEvents) + .catch(() => setError(true)) + .finally(() => setLoading(false)) + }, [tab, calendarApp]) + + const { today, upcoming } = useMemo(() => { + const now = new Date() + const t: EventSummary[] = [] + const u: EventSummary[] = [] + for (const ev of events) { + if (occursToday(ev, now)) t.push(ev) + else if (new Date(ev.start_at) > now) u.push(ev) + } + return { today: t, upcoming: u } + }, [events]) + + if (!calendarApp) return null + + return ( +
+
+
+ +

Calendar

+
+ +
+
+ {(['mine', 'all'] as const).map(t => ( + + ))} +
+ +
+
+ + {loading &&

Loading…

} + {!loading && error &&

Couldn't load calendar events.

} + + {!loading && !error && ( +
+
+

Today

+ {today.length === 0 + ?

Nothing on today.

+ : today.map(ev => )} +
+ +
+

Upcoming

+ {upcoming.length === 0 + ?

Nothing else in the next {RANGE_DAYS} days.

+ : ( + <> + {upcoming.slice(0, UPCOMING_LIMIT).map(ev => )} + {upcoming.length > UPCOMING_LIMIT && ( +

+{upcoming.length - UPCOMING_LIMIT} more

+ )} + + )} +
+
+ )} +
+ ) +} + +const sectionTitleStyle: React.CSSProperties = { + fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)', + textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '0.6rem', +} + +const emptyStyle: React.CSSProperties = { fontSize: '0.82rem', color: 'var(--text-mid)' } + +function EventRow({ ev, showDay = false }: { ev: EventSummary; showDay?: boolean }) { + return ( +
+
+
+
+ {ev.title} +
+
+ + + {showDay && `${formatDayHeader(ev.start_at)} · `} + {ev.all_day ? 'All day' : formatTime(ev.start_at)} + + {ev.location && ( + + + {ev.location} + + )} +
+
+
+ ) +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index ce502ea..1331590 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -861,10 +861,11 @@ function getInactivityMins(): number { return c ? parseInt(c.split('=')[1]) || 0 : 0 } +// Always writes the cookie, including for "Off" (mins=0) — this makes an +// explicit Off choice distinguishable from a device that was never visited, +// which the portal's own inactivity default relies on. function setInactivityMins(mins: number) { - document.cookie = mins > 0 - ? `hnf_inactivity_mins=${mins}; max-age=31536000; path=/; SameSite=Strict` - : 'hnf_inactivity_mins=; max-age=0; path=/; SameSite=Strict' + document.cookie = `hnf_inactivity_mins=${mins}; max-age=31536000; path=/; SameSite=Strict` } function DeviceTab() { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 31cb449..31824d9 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom' import { Download } from 'lucide-react' import { PageShell } from '../components/PageShell' import { AppIcon } from '../components/AppIcon' +import { CalendarSummary } from '../components/CalendarSummary' import type { User, App } from '../types' export function Dashboard({ user }: { user: User }) { @@ -30,6 +31,8 @@ export function Dashboard({ user }: { user: User }) {

)} + + {/* Uncategorised apps */} {uncategorised.length > 0 && ( navigate(`/app/${slug}`)} />