Add calendar summary to dashboard; default portal to 1hr inactivity timeout

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").
This commit is contained in:
jtricerolph 2026-07-28 12:45:29 +00:00
parent 709e83b6d5
commit 0b8fed2e96
4 changed files with 193 additions and 5 deletions

View file

@ -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
}

View file

@ -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<EventSummary[]>([])
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 (
<div style={{
background: 'var(--card-bg)', borderRadius: 'var(--radius)',
border: '1px solid var(--card-border)', boxShadow: 'var(--shadow-sm)',
padding: '1.25rem', marginBottom: '2rem',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<CalendarDays size={20} color={calendarApp.theme_color} strokeWidth={1.75} />
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)' }}>Calendar</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ display: 'flex', background: 'var(--body-bg)', borderRadius: '6px', padding: '2px' }}>
{(['mine', 'all'] as const).map(t => (
<button key={t} onClick={() => 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'}
</button>
))}
</div>
<button
onClick={() => 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 <ChevronRight size={14} strokeWidth={1.75} />
</button>
</div>
</div>
{loading && <p style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>Loading</p>}
{!loading && error && <p style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>Couldn't load calendar events.</p>}
{!loading && !error && (
<div style={{ display: 'grid', gap: '1.25rem', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
<div>
<h3 style={sectionTitleStyle}>Today</h3>
{today.length === 0
? <p style={emptyStyle}>Nothing on today.</p>
: today.map(ev => <EventRow key={ev.id} ev={ev} />)}
</div>
<div>
<h3 style={sectionTitleStyle}>Upcoming</h3>
{upcoming.length === 0
? <p style={emptyStyle}>Nothing else in the next {RANGE_DAYS} days.</p>
: (
<>
{upcoming.slice(0, UPCOMING_LIMIT).map(ev => <EventRow key={ev.id} ev={ev} showDay />)}
{upcoming.length > UPCOMING_LIMIT && (
<p style={{ ...emptyStyle, marginTop: '0.25rem' }}>+{upcoming.length - UPCOMING_LIMIT} more</p>
)}
</>
)}
</div>
</div>
)}
</div>
)
}
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 (
<div style={{ display: 'flex', gap: '0.6rem', padding: '0.5rem 0', borderBottom: '1px solid var(--card-border)' }}>
<div style={{ width: 3, borderRadius: 2, background: ev.calendar_color, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-dark)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ev.title}
</div>
<div style={{ display: 'flex', gap: '0.7rem', fontSize: '0.75rem', color: 'var(--text-mid)', marginTop: '0.15rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Clock size={12} strokeWidth={1.75} />
{showDay && `${formatDayHeader(ev.start_at)} · `}
{ev.all_day ? 'All day' : formatTime(ev.start_at)}
</span>
{ev.location && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<MapPin size={12} strokeWidth={1.75} />
{ev.location}
</span>
)}
</div>
</div>
</div>
)
}

View file

@ -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() {

View file

@ -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 }) {
</p>
)}
<CalendarSummary user={user} />
{/* Uncategorised apps */}
{uncategorised.length > 0 && (
<AppGrid apps={uncategorised} onOpen={slug => navigate(`/app/${slug}`)} />