Files
parentcalendar/frontend/src/routes/CalendarPage.svelte
T

215 lines
11 KiB
Svelte

<script lang="ts">
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import CalendarPlus from '@lucide/svelte/icons/calendar-plus';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import Download from '@lucide/svelte/icons/download';
import FileDown from '@lucide/svelte/icons/file-down';
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
import List from '@lucide/svelte/icons/list';
import MapPin from '@lucide/svelte/icons/map-pin';
import { api, KINDS, type CalEvent, type EventValues } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
import { KIND_ICONS } from '../lib/icons';
import PageHeader from '../lib/ui/PageHeader.svelte';
import EmptyState from '../lib/ui/EmptyState.svelte';
import Segmented from '../lib/ui/Segmented.svelte';
import EventEditor from './EventEditor.svelte';
import MonthGrid from './MonthGrid.svelte';
const calendars = $derived(app.household!.calendars);
let calendarId = $state<number | null>(null);
let month = $state(thisMonth());
let view = $state<'list' | 'grid'>('list');
let events = $state<CalEvent[] | null>(null);
let editing = $state<number | 'new' | null>(null);
let newDate = $state('');
let editDay = $state(''); // a multi-day event is listed on each day; its editor opens under the one tapped
const isOpen = (e: CalEvent, day: string) => editing === e.id && editDay === day;
let error = $state('');
$effect(() => {
if (calendarId === null && calendars.length) calendarId = calendars[0].id;
});
const calendar = $derived(calendars.find((c) => c.id === calendarId));
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)';
const byDay = $derived.by(() => {
const days = new Map<string, CalEvent[]>();
const last = monthEnd(month);
for (const e of events ?? []) {
// A multi-day event shows on each of its days in this month.
for (let d = parse(e.date < `${month}-01` ? `${month}-01` : e.date); iso(d) <= (e.end_date ?? e.date) && iso(d) <= last; d.setDate(d.getDate() + 1)) {
const key = iso(d);
if (!days.has(key)) days.set(key, []);
days.get(key)!.push(e);
}
}
return [...days.entries()].sort();
});
const today = iso(new Date());
const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null);
async function load() {
if (!calendarId) return;
error = '';
events = null;
try {
events = await api<CalEvent[]>(`/events?calendar_id=${calendarId}&start=${month}-01&end=${monthEnd(month)}`);
} catch (e: any) {
error = e.message;
}
}
$effect(() => {
void calendarId;
void month;
load();
});
async function run(work: () => Promise<unknown>, done: string) {
try {
await work();
editing = null;
toast(done);
await load();
} catch (e: any) {
toast(e.message);
}
}
const save = (id: number) => (v: EventValues) => run(() => api(`/events/${id}`, { method: 'PATCH', json: v }), 'Saved');
const remove = (id: number) => () => run(() => api(`/events/${id}`, { method: 'DELETE' }), 'Deleted');
const add = (v: EventValues) => run(() => api('/events', { method: 'POST', json: v }), 'Added');
function addOn(date: string) {
newDate = date;
editing = 'new';
view = 'list';
}
</script>
<PageHeader title="Calendar" subtitle="Everything that's been published, by month." />
{#if !calendars.length}
<EmptyState icon={CalendarDays} title="No calendar yet">
<p>A calendar is what you download into Apple or Google Calendar: one per child, or one for the family.</p>
<a class="btn primary" href="#/settings">Add a calendar</a>
</EmptyState>
{:else}
<div class="stack-lg">
<div class="controls">
<select bind:value={calendarId} aria-label="Calendar" class="cal-select">
{#each calendars as c (c.id)}<option value={c.id}>{c.name}</option>{/each}
</select>
<Segmented label="View" bind:value={view} options={[{ value: 'list', label: 'List', icon: List }, { value: 'grid', label: 'Month', icon: LayoutGrid }]} />
</div>
<section class="card export">
<div class="export-head">
<span class="export-icon"><FileDown size={22} aria-hidden="true" /></span>
<div>
<h2>Add to your phone's calendar</h2>
<p class="hint">On iPhone, open the file and tap “Add All”. Each download holds only what changed since the last one, so nothing is duplicated.</p>
</div>
</div>
<div class="row">
<a class="btn primary" href={`/api/calendars/${calendarId}/export.ics`} download><Download size={18} aria-hidden="true" />New and changed</a>
<a class="btn" href={`/api/calendars/${calendarId}/export.ics?everything=true`} download>Everything</a>
</div>
{#if lastDownload}<p class="hint">Last download {lastDownload.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</p>{/if}
</section>
<div class="month-bar">
<button class="icon" aria-label="Previous month" onclick={() => (month = shiftMonth(month, -1))}><ChevronLeft size={22} aria-hidden="true" /></button>
<h2 class="month-name" aria-live="polite">{monthLabel(month)}</h2>
<button class="icon" aria-label="Next month" onclick={() => (month = shiftMonth(month, 1))}><ChevronRight size={22} aria-hidden="true" /></button>
<span class="grow"></span>
<button class="sm" onclick={() => addOn(`${month}-01`)}><CalendarPlus size={16} aria-hidden="true" /><span class="hide-sm">Add event</span></button>
</div>
{#if editing === 'new'}
<div class="card">
<h2>New event</h2>
<EventEditor defaults={{ date: newDate, child_ids: calendar?.child_ids ?? [] }} onsave={add} oncancel={() => (editing = null)} />
</div>
{/if}
{#if error}
<div class="alert error" role="alert"><CircleAlert size={18} aria-hidden="true" /><span>{error}</span></div>
{:else if events === null}
<div class="stack" aria-busy="true" aria-label="Loading events">
{#each [1, 2, 3] as _}<div class="skeleton" style="height:64px"></div>{/each}
</div>
{:else if view === 'grid'}
<MonthGrid {month} {events} {colorFor} onday={addOn} showTitle={false} />
{:else if !events.length}
<EmptyState icon={CalendarDays} title="Nothing this month">
<p>Publish a scan, or add an event by hand.</p>
</EmptyState>
{:else}
<ol class="agenda">
{#each byDay as [day, list] (day)}
{@const d = parse(day)}
{@const isToday = day === today}
<li class="day" class:today={isToday} aria-current={isToday ? 'date' : undefined}>
<div class="date" aria-hidden="true">
<span class="dow">{d.toLocaleDateString(undefined, { weekday: 'short' })}</span>
<span class="dnum tabular">{d.getDate()}</span>
{#if isToday}<span class="today-tag">Today</span>{/if}
</div>
{#if isToday}<span class="visually-hidden">Today</span>{/if}
<ul class="card-list grow">
{#each list as e (e.id)}
{@const KindIcon = KIND_ICONS[e.kind]}
<li class="event" style:--child={colorFor(e)}>
<button class="item" onclick={() => { editing = isOpen(e, day) ? null : e.id; editDay = day; }} aria-expanded={isOpen(e, day)}>
<span class="kind"><KindIcon size={18} aria-hidden="true" /></span>
<span class="grow">
<span class="title">{e.title}</span>
<span class="meta">{whenLabel(e)} · {KINDS[e.kind]}{#if e.location} · <MapPin size={12} aria-hidden="true" /> {e.location}{/if}</span>
</span>
</button>
{#if isOpen(e, day)}
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
{/if}
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{/if}
</div>
{/if}
<style>
.controls { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: center; }
.cal-select { width: auto; min-width: 200px; flex: 1 1 200px; max-width: 360px; font-weight: 600; }
.export { display: flex; flex-direction: column; gap: var(--s3); }
.export-head { display: flex; gap: var(--s3); align-items: flex-start; }
.export-icon { display: grid; place-items: center; width: 44px; height: 44px; flex: none; border-radius: var(--r-md); background: var(--primary-soft); color: var(--primary-text); }
.export h2 { font-size: var(--fs-md); margin-bottom: 2px; }
.month-bar { display: flex; align-items: center; gap: var(--s1); }
.month-name { font-size: var(--fs-xl); min-width: 10ch; text-align: center; }
.agenda { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s4); }
.agenda > .day { display: flex; gap: var(--s3); align-items: flex-start; }
.date { flex: none; width: 52px; display: flex; flex-direction: column; align-items: center; padding-top: var(--s2); }
.dow { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.dnum { font-size: var(--fs-xl); font-weight: 750; line-height: 1.1; }
/* Today: same filled day number and label as the Lunch screen, and its events get the same tint. */
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; margin-top: 2px; }
.today-tag { margin-top: var(--s1); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
.today .event { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); border-left-color: var(--child); }
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2) var(--s1) var(--s2); box-shadow: var(--shadow-1); }
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
.item:hover:not(:disabled) { background: none; }
.item:active:not(:disabled) { transform: none; }
.kind { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: var(--r-md); background: var(--surface-2); color: var(--text-2); }
.title { display: block; font-weight: 650; color: var(--text); }
.meta { display: block; font-size: var(--fs-sm); color: var(--text-2); }
</style>