Calendar
index.jsx 774 lines
Files · 6
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import {
Page,
Header,
Button,
Input,
Row,
EmptyState,
Modal,
Sheet,
Toast,
useToast,
} from "../../shared/ui.jsx";
import { ConfirmDelete } from "../../shared/forms.jsx";
import { useStore, inserted, fieldsOf, localDate } from "../../shared/data.js";
import {
parseLocalDate,
fmtDate,
generateRecurrenceDates,
validDate,
validateRecurrence,
} from "./recurrence.js";
import "./style.css";
const L = window.lattice;
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const colors = ["#4f46e5", "#087f5b", "#be3453", "#9d620b", "#8b5cf6"];
const details = ["id", "title", "date", "time", "location", "description"];
const recurrenceOf = (row) => ({
frequency: row.frequency,
interval: row.interval_val,
daysOfWeek: row.days_of_week ? JSON.parse(row.days_of_week) : [],
startDate: row.start_date,
endDate: row.end_date,
});
const payloadOf = (row) =>
row.frequency
? {
id: row.id,
title: row.title,
date: row.start_date,
time: row.time || "",
location: row.location || "",
description: row.description || "",
recurrence: recurrenceOf(row),
}
: fieldsOf(row, details);
async function add(data) {
if (!data?.title) return;
let row;
if (data.recurrence?.frequency) {
const rec = {
...data.recurrence,
interval: data.recurrence.interval || 1,
daysOfWeek: data.recurrence.daysOfWeek || [],
startDate: data.recurrence.startDate || localDate(),
endDate:
data.recurrence.endDate == null || data.recurrence.endDate === ""
? null
: data.recurrence.endDate,
};
const error = validateRecurrence(rec);
if (error) throw Error(error);
row = await inserted(
"recurrences",
"INSERT INTO recurrences (title,time,location,description,color,frequency,interval_val,days_of_week,start_date,end_date) VALUES (?,?,?,?,?,?,?,?,?,?)",
[
data.title,
data.time || "",
data.location || "",
data.description || "",
data.color || colors[0],
rec.frequency,
rec.interval,
JSON.stringify(rec.daysOfWeek),
rec.startDate,
rec.endDate,
],
);
} else {
if (!data.date) return;
if (!validDate(data.date)) throw Error("Choose a valid event date.");
row = await inserted(
"events",
"INSERT INTO events (title,date,time,location,description,color) VALUES (?,?,?,?,?,?)",
[
data.title,
data.date,
data.time || "",
data.location || "",
data.description || "",
data.color || colors[0],
],
);
}
await L.events.emit("event-created", payloadOf(row));
try {
await L.notify.send(
"New event",
`${row.title} · ${row.start_date || row.date}`,
);
} catch {
}
}
async function setup() {
await L.db.exec(
`CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,date TEXT NOT NULL,time TEXT,location TEXT,description TEXT,color TEXT DEFAULT '#3b82f6',recurrence_id INTEGER,created_at TEXT DEFAULT (datetime('now')))`,
);
await L.db.exec(
`CREATE TABLE IF NOT EXISTS recurrences (id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT NOT NULL,time TEXT,location TEXT,description TEXT,color TEXT DEFAULT '#3b82f6',frequency TEXT NOT NULL,interval_val INTEGER DEFAULT 1,days_of_week TEXT,start_date TEXT NOT NULL,end_date TEXT,last_materialized TEXT,created_at TEXT DEFAULT (datetime('now')))`,
);
}
async function load() {
const recurrences = await L.db.query("SELECT * FROM recurrences");
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const end = localDate(yesterday);
for (const rec of recurrences) {
const start = rec.last_materialized
? parseLocalDate(rec.last_materialized)
: parseLocalDate(rec.start_date);
if (rec.last_materialized) start.setDate(start.getDate() + 1);
const until = rec.end_date && rec.end_date < end ? rec.end_date : end;
if (fmtDate(start) > until) continue;
for (const date of generateRecurrenceDates(rec, fmtDate(start), until)) {
await L.db.exec(
"INSERT INTO events (title,date,time,location,description,color,recurrence_id) SELECT ?,?,?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM events WHERE recurrence_id=? AND date=?)",
[
rec.title,
date,
rec.time || "",
rec.location || "",
rec.description || "",
rec.color || colors[0],
rec.id,
rec.id,
date,
],
);
}
await L.db.exec("UPDATE recurrences SET last_materialized=? WHERE id=?", [
until,
rec.id,
]);
}
return [
...(await L.db.query("SELECT * FROM events ORDER BY date,time,id")).map(
(row) => ({ ...row, kind: "event" }),
),
...recurrences.map((row) => ({ ...row, kind: "series" })),
];
}
function EventForm({
event,
defaultDate,
onSave,
onClose,
onDelete,
busy,
error,
}) {
const series = event?._recurrence;
const [form, setForm] = useState({
title: (series || event)?.title || "",
date: event?.date || defaultDate || localDate(),
time: (series || event)?.time || "",
location: (series || event)?.location || "",
description: (series || event)?.description || "",
color: (series || event)?.color || colors[0],
frequency: series?.frequency || "",
interval: series?.interval_val || 1,
daysOfWeek: series?.days_of_week ? JSON.parse(series.days_of_week) : [],
startDate: series?.start_date || defaultDate || localDate(),
endDate: series?.end_date || "",
});
const [validation, setValidation] = useState("");
const change = (key) => (e) => setForm({ ...form, [key]: e.target.value });
return (
<Modal
title={event ? (series ? "Edit series" : "Edit event") : "New event"}
onClose={busy ? undefined : onClose}
>
<form
onSubmit={async (e) => {
e.preventDefault();
const next = {
...form,
title: form.title.trim(),
interval: Number(form.interval),
};
const problem = !next.title
? "Enter a title."
: next.frequency
? validateRecurrence(next)
: !validDate(next.date)
? "Choose a valid event date."
: "";
setValidation(problem);
if (!problem) await onSave(next);
}}
>
<Input
label="Title"
required
value={form.title}
onChange={change("title")}
autoFocus
/>
{(!event || series) && (
<div className="ui-field">
<label htmlFor="event-repeat">Repeat</label>
<select
id="event-repeat"
value={form.frequency}
onChange={change("frequency")}
>
{!series && <option value="">Does not repeat</option>}
{["daily", "weekly", "monthly", "yearly"].map((value) => (
<option key={value} value={value}>
{value[0].toUpperCase() + value.slice(1)}
</option>
))}
</select>
</div>
)}
{form.frequency ? (
<>
<div className="calendar-form-grid">
<Input
label="Start date"
type="date"
required
value={form.startDate}
onChange={change("startDate")}
/>
<Input
label="End date"
type="date"
min={form.startDate}
value={form.endDate}
onChange={change("endDate")}
/>
</div>
<Input
label={`Repeat every (${{ daily: "days", weekly: "weeks", monthly: "months", yearly: "years" }[form.frequency]})`}
type="number"
min="1"
step="1"
required
value={form.interval}
onChange={change("interval")}
/>
{form.frequency === "weekly" && (
<div className="ui-field">
<span>On weekdays</span>
<div className="ui-actions">
{weekdays.map((day, index) => (
<Button
key={day}
aria-pressed={form.daysOfWeek.includes(index)}
variant={
form.daysOfWeek.includes(index)
? "primary"
: "secondary"
}
onClick={() =>
setForm({
...form,
daysOfWeek: form.daysOfWeek.includes(index)
? form.daysOfWeek.filter((day) => day !== index)
: [...form.daysOfWeek, index].sort(),
})
}
>
{day}
</Button>
))}
</div>
<small className="ui-muted">
Leave blank to repeat on the start date’s weekday.
</small>
</div>
)}
</>
) : (
<Input
label="Date"
type="date"
required
value={form.date}
onChange={change("date")}
/>
)}
<Input
label="Time"
type="time"
value={form.time}
onChange={change("time")}
/>
<Input
label="Location"
value={form.location}
onChange={change("location")}
/>
<Input
label="Description"
multiline
value={form.description}
onChange={change("description")}
/>
<div className="ui-field">
<span>Color</span>
<div className="ui-actions">
{colors.map((color, index) => (
<Button
key={color}
aria-label={`Color ${index + 1}`}
aria-pressed={form.color === color}
onClick={() => setForm({ ...form, color })}
style={{ borderColor: color, color }}
>
{form.color === color ? "●" : "○"}
</Button>
))}
</div>
</div>
{(validation || error) && (
<p role="alert" className="ui-error-text">
{validation || error}
</p>
)}
<div
className="ui-actions"
style={{ justifyContent: "flex-end", marginTop: 20 }}
>
{event && (
<Button
variant="danger"
onClick={() => onDelete(event)}
disabled={busy}
>
{series ? "Delete series" : "Delete event"}
</Button>
)}
<Button onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={busy}>
{busy ? "Saving…" : "Save"}
</Button>
</div>
</form>
</Modal>
);
}
function App() {
const today = localDate();
const [month, setMonth] = useState(
() => new Date(new Date().getFullYear(), new Date().getMonth(), 1),
),
[selectedDate, setSelectedDate] = useState(null),
[search, setSearch] = useState(""),
[view, setView] = useState("month");
const [editing, setEditing] = useState(null),
[deleting, setDeleting] = useState(null),
[defaultDate, setDefaultDate] = useState(today);
const { toast, notify, dismissToast } = useToast();
const store = useStore({ setup, load, listeners: { "add-event": add } });
const start = new Date(month);
start.setDate(1 - start.getDay());
const cells = Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(date.getDate() + index);
return fmtDate(date);
});
const concrete = store.rows.filter((row) => row.kind === "event"),
series = store.rows.filter((row) => row.kind === "series");
const all = [...concrete];
for (const rec of series) {
const from = cells[0] > today ? cells[0] : today;
for (const date of generateRecurrenceDates(rec, from, cells.at(-1))) {
if (
!concrete.some(
(row) => row.recurrence_id === rec.id && row.date === date,
)
)
all.push({
id: `rec-${rec.id}-${date}`,
title: rec.title,
date,
time: rec.time || "",
location: rec.location || "",
description: rec.description || "",
color: rec.color || colors[0],
recurrence_id: rec.id,
_recurrence: rec,
});
}
}
const query = search.trim().toLowerCase();
const filtered = all
.filter((row) =>
["title", "location", "description"].some((key) =>
String(row[key] || "")
.toLowerCase()
.includes(query),
),
)
.sort(
(a, b) =>
a.date.localeCompare(b.date) ||
(a.time || "").localeCompare(b.time || "") ||
a.title.localeCompare(b.title),
);
const monthRows = filtered.filter(
(row) => row.date.slice(0, 7) === fmtDate(month).slice(0, 7),
);
function edit(event) {
setSelectedDate(null);
setEditing(event);
}
async function save(form) {
if (
await store.act(async () => {
if (editing === "new")
await add({
title: form.title,
date: form.date,
time: form.time,
location: form.location,
description: form.description,
color: form.color,
...(form.frequency
? {
recurrence: {
frequency: form.frequency,
interval: form.interval,
daysOfWeek: form.daysOfWeek,
startDate: form.startDate,
endDate: form.endDate || null,
},
}
: {}),
});
else if (editing._recurrence) {
const id = editing._recurrence.id;
await L.db.exec(
"UPDATE recurrences SET title=?,time=?,location=?,description=?,color=?,frequency=?,interval_val=?,days_of_week=?,start_date=?,end_date=?,last_materialized=NULL WHERE id=?",
[
form.title,
form.time,
form.location,
form.description,
form.color,
form.frequency,
form.interval,
JSON.stringify(form.daysOfWeek),
form.startDate,
form.endDate || null,
id,
],
);
await L.events.emit("event-updated", {
id,
title: form.title,
date: form.startDate,
time: form.time,
location: form.location,
description: form.description,
recurrence: {
frequency: form.frequency,
interval: form.interval,
daysOfWeek: form.daysOfWeek,
startDate: form.startDate,
endDate: form.endDate || null,
},
});
} else {
await L.db.exec(
"UPDATE events SET title=?,date=?,time=?,location=?,description=?,color=? WHERE id=?",
[
form.title,
form.date,
form.time,
form.location,
form.description,
form.color,
editing.id,
],
);
await L.events.emit("event-updated", {
id: editing.id,
title: form.title,
date: form.date,
time: form.time,
location: form.location,
description: form.description,
});
}
})
) {
setEditing(null);
notify("Event saved");
}
}
function requestDelete(event) {
setEditing(null);
setDeleting(event);
}
const changeMonth = (delta) =>
setMonth(new Date(month.getFullYear(), month.getMonth() + delta, 1));
const eventRow = (row) => (
<Row
key={row.id}
leading={
<span
className="calendar-event-dot"
style={{ background: row.color || "var(--accent)" }}
/>
}
title={<button onClick={() => edit(row)}>{row.title}</button>}
subtitle={[row.date, row.time || "All day", row.location]
.filter(Boolean)
.join(" · ")}
actions={
<Button
variant="ghost"
aria-label={`Edit ${row.title}`}
onClick={() => edit(row)}
>
Edit
</Button>
}
>
{row.recurrence_id && (
<span className="ui-badge" style={{ marginTop: 5 }}>
Repeats
</span>
)}
</Row>
);
return (
<Page className="calendar-page">
<Header
title="Calendar"
subtitle={`${monthRows.length} events this month`}
actions={
<Button
variant="primary"
onClick={() => {
setDefaultDate(today);
setEditing("new");
}}
>
New event
</Button>
}
/>
<div className="ui-toolbar">
<Input
type="search"
aria-label="Search events"
placeholder="Search this month’s events"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
aria-pressed={view === "month"}
variant={view === "month" ? "primary" : "secondary"}
onClick={() => setView("month")}
>
Month
</Button>
<Button
aria-pressed={view === "agenda"}
variant={view === "agenda" ? "primary" : "secondary"}
onClick={() => setView("agenda")}
>
Agenda
</Button>
</div>
<div className="calendar-navigation">
<div className="ui-actions">
<Button aria-label="Previous month" onClick={() => changeMonth(-1)}>
‹
</Button>
<Button aria-label="Next month" onClick={() => changeMonth(1)}>
›
</Button>
<h2 aria-live="polite">
{month.toLocaleDateString([], { month: "long", year: "numeric" })}
</h2>
</div>
<Button
onClick={() =>
setMonth(
new Date(new Date().getFullYear(), new Date().getMonth(), 1),
)
}
>
Today
</Button>
</div>
{store.error && (
<p role="alert" className="ui-error-text">
{store.error}{" "}
<Button onClick={() => store.act(async () => {})}>Retry</Button>
</p>
)}
{!store.ready ? (
<div className="ui-panel">
<EmptyState
title={
store.error ? "Calendar could not be loaded" : "Loading calendar…"
}
/>
</div>
) : view === "agenda" ? (
<div className="ui-panel">
{monthRows.length ? (
monthRows.map(eventRow)
) : (
<EmptyState
title={query ? "No matching events" : "No events this month"}
action={
<Button
onClick={() => {
if (query) setSearch("");
else {
setDefaultDate(fmtDate(month));
setEditing("new");
}
}}
>
{query ? "Clear search" : "New event"}
</Button>
}
/>
)}
</div>
) : (
<div className="ui-panel calendar-grid">
{weekdays.map((day) => (
<div key={day} className="calendar-weekday">
{day}
</div>
))}
{cells.map((date) => {
const rows = filtered.filter((row) => row.date === date),
outside = date.slice(0, 7) !== fmtDate(month).slice(0, 7);
return (
<div
key={date}
className={`calendar-cell ${outside ? "outside" : ""} ${date === today ? "today" : ""}`}
>
<button
className="calendar-date"
aria-label={date}
aria-current={date === today ? "date" : undefined}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(-2))}
</button>
{rows.slice(0, 3).map((row) => (
<button
key={row.id}
className="calendar-event"
onClick={() => edit(row)}
title={[row.title, row.time, row.location]
.filter(Boolean)
.join(" · ")}
>
<span
style={{ background: row.color || "var(--accent)" }}
/>
{row._recurrence ? "↻ " : ""}
{row.title}
</button>
))}
{rows.length > 3 && (
<button
className="calendar-more"
onClick={() => setSelectedDate(date)}
>
+{rows.length - 3} more
</button>
)}
</div>
);
})}
</div>
)}
{selectedDate && !editing && !deleting && (
<Sheet
title={parseLocalDate(selectedDate).toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
})}
onClose={() => setSelectedDate(null)}
actions={
<Button
variant="primary"
onClick={() => {
setDefaultDate(selectedDate);
setSelectedDate(null);
setEditing("new");
}}
>
Add event
</Button>
}
>
{filtered.filter((row) => row.date === selectedDate).length ? (
filtered.filter((row) => row.date === selectedDate).map(eventRow)
) : (
<EmptyState title={query ? "No matching events" : "No events"} />
)}
</Sheet>
)}
{editing && (
<EventForm
event={editing === "new" ? null : editing}
defaultDate={defaultDate}
onSave={save}
onClose={() => setEditing(null)}
onDelete={requestDelete}
busy={store.busy}
error={store.error}
/>
)}
{deleting && (
<ConfirmDelete
title={
deleting._recurrence ? "Delete recurring series?" : "Delete event?"
}
onClose={() => setDeleting(null)}
busy={store.busy}
error={store.error}
onConfirm={async () => {
if (
await store.act(async () => {
const id = deleting._recurrence?.id || deleting.id;
await L.db.exec(
deleting._recurrence
? "DELETE FROM recurrences WHERE id=?"
: "DELETE FROM events WHERE id=?",
[id],
);
await L.events.emit("event-removed", {
id,
title: deleting.title,
});
})
) {
setDeleting(null);
notify("Event deleted");
}
}}
>
{deleting.title} will be removed.
{deleting._recurrence
? " Past occurrences stay in your calendar."
: ""}
</ConfirmDelete>
)}
<Toast message={toast} onDismiss={dismissToast} />
</Page>
);
}
createRoot(document.getElementById("root")).render(<App />);