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 {
    /* Calendar remains usable without desktop notification delivery. */
  }
}
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,
        ],
      );
    }
    // Advance even when no occurrence fell in this window; never skip dates
    // because an iteration cap was reached.
    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 />);