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:
parent
709e83b6d5
commit
0b8fed2e96
4 changed files with 193 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
177
src/components/CalendarSummary.tsx
Normal file
177
src/components/CalendarSummary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}`)} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue