Journal

index.jsx 365 lines

Files · 9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
  Page,
  Header,
  Button,
  Input,
  Modal,
  EmptyState,
} from "../../shared/ui.jsx";
import { useModel } from "../../shared/data.js";
import { Select, ErrorNotice } from "../../shared/ui.jsx";
import { FormActions } from "../../shared/forms.jsx";
import { createModel, moods } from "./model.js";
import { localDate, addDays, weekday } from "./runtime.js";
import "./style.css";
const model = createModel(window.lattice);
const moodLabels = {
  "very-low": "Very low",
  low: "Low",
  neutral: "Neutral",
  good: "Good",
  great: "Great",
};
function monthShift(month, n) {
  const d = new Date(month + "-01T12:00:00Z");
  d.setUTCMonth(d.getUTCMonth() + n);
  return d.toISOString().slice(0, 7);
}
function Calendar({ month, setMonth, date, select, entries }) {
  const start = month + "-01",
    offset = (weekday(start) + 6) % 7,
    last = new Date(
      Number(month.slice(0, 4)),
      Number(month.slice(5, 7)),
      0,
    ).getDate();
  return (
    <div className="ui-panel ui-panel-pad">
      <div className="ui-actions" style={{ justifyContent: "space-between" }}>
        <Button
          variant="ghost"
          aria-label="Previous month"
          onClick={() => setMonth(monthShift(month, -1))}
        >
          ←
        </Button>
        <h2>
          {new Date(start + "T12:00:00").toLocaleDateString(undefined, {
            month: "long",
            year: "numeric",
          })}
        </h2>
        <Button
          variant="ghost"
          aria-label="Next month"
          onClick={() => setMonth(monthShift(month, 1))}
        >
          →
        </Button>
      </div>
      <div className="calendar">
        {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
          <span key={i} aria-hidden="true">
            {d}
          </span>
        ))}
        {Array.from({ length: offset }, (_, i) => (
          <div key={"blank" + i} />
        ))}
        {Array.from({ length: last }, (_, i) => {
          const d = `${month}-${String(i + 1).padStart(2, "0")}`,
            entry = entries.find((e) => e.date === d);
          return (
            <button
              key={d}
              aria-label={`${d}${entry ? ", " + moodLabels[entry.mood] + ", has entry" : ""}`}
              aria-pressed={d === date}
              className={entry ? "has-entry" : ""}
              onClick={() => select(d)}
            >
              {i + 1}
            </button>
          );
        })}
      </div>
    </div>
  );
}
function Editor({ entry, date, busy, onSave, onClose }) {
  const [text, setText] = useState(entry?.text ?? ""),
    [mood, setMood] = useState(entry?.mood ?? "neutral"),
    [tags, setTags] = useState(entry?.tags.join(", ") ?? "");
  return (
    <Modal title={entry ? "Edit entry" : "New entry"} onClose={onClose}>
      <form
        className="entry-editor"
        onSubmit={(e) => {
          e.preventDefault();
          onSave({
            date,
            text,
            mood,
            tags: [
              ...new Set(
                tags
                  .split(",")
                  .map((t) => t.trim())
                  .filter(Boolean),
              ),
            ],
          });
        }}
      >
        <p className="ui-muted" style={{ marginBottom: 16 }}>
          {date}
        </p>
        <Select
          label="Mood"
          value={mood}
          onChange={(e) => setMood(e.target.value)}
        >
          {moods.map((m) => (
            <option key={m} value={m}>
              {moodLabels[m]}
            </option>
          ))}
        </Select>
        <Input
          label="Entry"
          multiline
          value={text}
          onChange={(e) => setText(e.target.value)}
          required
          placeholder="Write about your day…"
        />
        <Input
          label="Tags"
          value={tags}
          onChange={(e) => setTags(e.target.value)}
          placeholder="e.g. work, friends"
          hint="Separate tags with commas."
        />
        <FormActions busy={busy} onCancel={onClose} label="Save entry" />
      </form>
    </Modal>
  );
}
function App() {
  const { data, error, busy, act } = useModel(model),
    [date, setDate] = useState(localDate()),
    [month, setMonth] = useState(localDate().slice(0, 7)),
    [query, setQuery] = useState(""),
    [moodFilter, setMoodFilter] = useState(""),
    [editing, setEditing] = useState(false),
    [deleting, setDeleting] = useState(false);
  useEffect(() => {
    const timer = setInterval(() => act(() => model.tick()), 60000);
    return () => clearInterval(timer);
  }, []);
  const entries = data?.entries ?? [],
    entry = entries.find((e) => e.date === date),
    select = (d) => {
      setDate(d);
      setMonth(d.slice(0, 7));
    };
  const filtered = entries.filter(
    (e) =>
      (!moodFilter || e.mood === moodFilter) &&
      (!query ||
        [e.date, e.text, ...e.tags]
          .join(" ")
          .toLocaleLowerCase()
          .includes(query.toLocaleLowerCase())),
  );
  return (
    <Page className="new-app journal">
      <Header
        title="Journal"
        subtitle="Entries, mood, and tags."
        actions={
          <>
            <Button
              variant="primary"
              onClick={() => {
                select(localDate());
                setEditing(true);
              }}
            >
              Write today
            </Button>
          </>
        }
      />
      <ErrorNotice error={error} pending={data?.pending} />
      <div className="ui-master-detail">
        <aside>
          <Calendar
            month={month}
            setMonth={setMonth}
            date={date}
            select={select}
            entries={entries}
          />
          <div className="ui-list-header">
            <h2>Find an entry</h2>
            <span className="ui-badge">{entries.length}</span>
          </div>
          <Input
            label="Search"
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Text, tags, or date"
          />
          <Select
            label="Mood filter"
            value={moodFilter}
            onChange={(e) => setMoodFilter(e.target.value)}
          >
            <option value="">All moods</option>
            {moods.map((m) => (
              <option key={m} value={m}>
                {moodLabels[m]}
              </option>
            ))}
          </Select>
          <div
            className="ui-panel"
            style={{ maxHeight: 360, overflowY: "auto" }}
          >
            {filtered.map((e) => (
              <button
                key={e.id}
                className="result-button"
                onClick={() => select(e.date)}
              >
                <strong>{e.date}</strong>
                <div className="ui-muted">
                  {moodLabels[e.mood]} · {e.text.slice(0, 55)}
                  {e.text.length > 55 ? "…" : ""}
                </div>
              </button>
            ))}
            {!!entries.length && !filtered.length && (
              <EmptyState title="No matching entries" />
            )}
          </div>
        </aside>
        <section aria-label="Selected entry">
          <div className="ui-list-header" style={{ marginTop: 0 }}>
            <div>
              <p className="ui-caption">
                {new Date(date + "T12:00:00").toLocaleDateString(undefined, {
                  weekday: "long",
                })}
              </p>
              <h2 style={{ fontSize: 24, marginTop: 5 }}>
                {new Date(date + "T12:00:00").toLocaleDateString(undefined, {
                  month: "long",
                  day: "numeric",
                  year: "numeric",
                })}
              </h2>
            </div>
            <Button variant="ghost" onClick={() => select(localDate())}>
              Today
            </Button>
          </div>
          <div className="ui-panel ui-panel-pad">
            {!data ? (
              <EmptyState title="Loading journal…" />
            ) : entry ? (
              <>
                <div className="ui-actions">
                  <span className="ui-badge ui-badge-accent">
                    Mood · {moodLabels[entry.mood]}
                  </span>
                  {entry.tags.map((t) => (
                    <span key={t} className="ui-badge">
                      #{t}
                    </span>
                  ))}
                </div>
                <p className="journal-text">{entry.text}</p>
                <div className="ui-list-header">
                  <span className="ui-muted">
                    Updated{" "}
                    {new Date(entry.updated_at).toLocaleTimeString(undefined, {
                      hour: "2-digit",
                      minute: "2-digit",
                    })}
                  </span>
                  <div className="ui-actions">
                    <Button variant="ghost" onClick={() => setDeleting(true)}>
                      Delete
                    </Button>
                    <Button onClick={() => setEditing(true)}>Edit entry</Button>
                  </div>
                </div>
              </>
            ) : (
              <EmptyState
                title="No entry for this day"
                action={
                  <Button onClick={() => setEditing(true)}>
                    Write an entry
                  </Button>
                }
              >
                Add a mood, a few words, or a longer reflection.
              </EmptyState>
            )}
          </div>
          {!entries.length && data && (
            <div style={{ marginTop: 18 }}>
              <Button disabled={busy} onClick={() => act(() => model.sample())}>
                Add sample data
              </Button>
            </div>
          )}
        </section>
      </div>
      {editing && (
        <Editor
          key={date}
          entry={entry}
          date={date}
          busy={busy}
          onClose={() => !busy && setEditing(false)}
          onSave={(f) =>
            act(() => model.save(f)).then((ok) => ok && setEditing(false))
          }
        />
      )}
      {deleting && entry && (
        <Modal
          title="Delete entry?"
          onClose={() => !busy && setDeleting(false)}
        >
          <p className="ui-muted">
            The entry for {date} will be permanently removed.
          </p>
          <div className="ui-form-actions">
            <Button disabled={busy} onClick={() => setDeleting(false)}>
              Cancel
            </Button>
            <Button
              variant="danger"
              disabled={busy}
              onClick={() =>
                act(() => model.remove(entry)).then(
                  (ok) => ok && setDeleting(false),
                )
              }
            >
              Delete entry
            </Button>
          </div>
        </Modal>
      )}
    </Page>
  );
}
createRoot(document.getElementById("root")).render(<App />);