215 lines
11 KiB
Svelte
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>
|