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 => (
+ setTab(t)} style={{
+ border: 'none', borderRadius: '5px', padding: '0.3rem 0.7rem',
+ fontSize: '0.76rem', fontWeight: 600,
+ background: tab === t ? 'var(--card-bg)' : 'transparent',
+ color: tab === t ? 'var(--text-dark)' : 'var(--text-mid)',
+ boxShadow: tab === t ? 'var(--shadow-sm)' : 'none',
+ }}>
+ {t === 'mine' ? 'Mine' : 'All'}
+
+ ))}
+
+
navigate(`/app/${calendarApp.slug}`)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: '0.2rem', border: 'none', background: 'none',
+ color: 'var(--text-mid)', fontSize: '0.78rem', fontWeight: 600,
+ }}>
+ Open calendar
+
+
+
+
+ {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}`)} />