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 />);