Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone e8a688fd21351d04b05468df
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/HabitPage.tsx
// One habit: today's check-in, streaks and completion, a month calendar and a year of days, notes,
// sharing with a friend, and a calendar reminder.
import { useEffect, useRef, useState } from "react";
import { useStore, editHabit, update } from "./lib/store";
import { met, stats, streakLabel, scheduleLabel, type Habit } from "./lib/habits";
import { dayNum, fmt, keyOf, weekday, weekOf, WEEKDAY_SHORT } from "./lib/dates";
import { reminderIcs, googleLink, reminderRepeats } from "./lib/ics";
import { navigate } from "./lib/router";
import { leaveTeam } from "./lib/sync";
import { useToday } from "./lib/useToday";
import { Editor } from "./Editor";
import { CheckButton, DaySheet } from "./Checkin";
import { ShareCard } from "./Share";
import { A, download, toast } from "./ui";
import { Back, Next, Pencil, Bell, Note, Archive } from "./icons";
function dayClass(h: Habit, d: number, now: number): string {
const key = keyOf(d);
const entry = h.days[key];
if (d > now || key < h.start) return h.kind !== "avoid" && met(h, entry) ? "done" : "off";
if (h.kind === "avoid") return met(h, entry) ? "slip" : "clean";
if (met(h, entry)) return "done";
return (entry?.n ?? 0) > 0 ? "part" : "";
}
function Calendar({ h, now, onPick }: { h: Habit; now: number; onPick: (day: string) => void }) {
const s = useStore();
const [offset, setOffset] = useState(0);
const base = new Date(now * 86400000);
const y = base.getUTCFullYear();
const m = base.getUTCMonth() + offset;
const first = Math.round(Date.UTC(y, m, 1) / 86400000);
const count = Math.round(Date.UTC(y, m + 1, 1) / 86400000) - first;
const lead = (weekday(first) - s.weekStart + 7) % 7;
const cells: (number | null)[] = [...Array(lead).fill(null), ...Array.from({ length: count }, (_, i) => first + i)];
const hits = cells.filter((d) => d !== null && met(h, h.days[keyOf(d)])).length;
const heads = Array.from({ length: 7 }, (_, i) => WEEKDAY_SHORT[(i + s.weekStart) % 7]);
return (
<div className="calendar">
<div className="calendar-head">
<button type="button" className="round-btn" aria-label="Previous month" onClick={() => setOffset(offset - 1)}><Back /></button>
<p><strong>{fmt(first, { month: "long", year: "numeric" })}</strong><span>{h.kind === "avoid" ? `${hits} ${hits === 1 ? "slip" : "slips"}` : `${hits} ${hits === 1 ? "day" : "days"} done`}</span></p>
<button type="button" className="round-btn" aria-label="Next month" disabled={offset >= 0} onClick={() => setOffset(offset + 1)}><Next /></button>
</div>
<div className="calendar-grid">
{heads.map((w) => <span key={w} className="calendar-wd" aria-hidden="true">{w.slice(0, 1)}</span>)}
{cells.map((d, i) => {
if (d === null) return <span key={`x${i}`} />;
const cls = dayClass(h, d, now);
const note = h.days[keyOf(d)]?.note;
return (
<button key={d} type="button" className={`calendar-day ${cls} ${d === now ? "today" : ""}`} disabled={d > now}
aria-label={`${fmt(d, { weekday: "long", month: "long", day: "numeric" })}: ${cls === "done" ? "done" : cls === "slip" ? "slipped" : cls === "part" ? "partly done" : "not done"}${note ? ", has a note" : ""}`}
onClick={() => onPick(keyOf(d))}>
{fmt(d, { day: "numeric" })}{note && <span className="note-dot" aria-hidden="true" />}
</button>
);
})}
</div>
</div>
);
}
function YearMap({ h, now }: { h: Habit; now: number }) {
const s = useStore();
const ref = useRef<HTMLDivElement>(null);
const end = weekOf(now, s.weekStart) + 6;
const start = end - 53 * 7 + 1;
const cols: number[][] = [];
for (let w = start; w <= end; w += 7) cols.push(Array.from({ length: 7 }, (_, i) => w + i));
useEffect(() => { if (ref.current) ref.current.scrollLeft = ref.current.scrollWidth; }, []);
const year = Array.from({ length: 365 }, (_, i) => now - i).filter((d) => met(h, h.days[keyOf(d)])).length;
return (
<div>
<div className="spread"><h2 className="h3">Past year</h2><span className="muted small">{h.kind === "avoid" ? `${year} ${year === 1 ? "slip" : "slips"}` : `${year} ${year === 1 ? "day" : "days"} done`}</span></div>
<div className="yearmap-scroll" ref={ref}>
<div className="yearmap-months" aria-hidden="true">
{cols.map((c, i) => {
const first = c.find((d) => new Date(d * 86400000).getUTCDate() === 1);
return <span key={c[0]}>{first !== undefined && i > 0 ? fmt(first, { month: "short" }) : ""}</span>;
})}
</div>
<div className="yearmap" role="img" aria-label={`A year of ${h.name}: ${year} days ${h.kind === "avoid" ? "with a slip" : "done"}`}>
{cols.map((c) => (
<div className="yearmap-col" key={c[0]}>
{c.map((d) => <span key={d} className={`ym ${dayClass(h, d, now)}`} title={`${fmt(d, { month: "short", day: "numeric", year: "numeric" })}`} />)}
</div>
))}
</div>
</div>
<p className="legend muted small">
{h.kind === "avoid"
? <><span className="ym clean" /> no slip <span className="ym slip" /> slip</>
: <><span className="ym" /> not done {h.kind === "count" && <><span className="ym part" /> part way</>} <span className="ym done" /> done</>}
</p>
</div>
);
}
function Reminder({ h }: { h: Habit }) {
const [time, setTime] = useState("08:00");
const file = `${h.name.replace(/[^\w -]+/g, "").trim().replace(/\s+/g, "-").toLowerCase() || "habit"}-reminder.ics`;
return (
<section className="card pad stack">
<h2 className="h3 icon-title"><Bell /> Reminder</h2>
<p className="muted small">A web page cannot reliably remind you on its own without a server sending notifications, so Habits does not pretend to. Instead, add a repeating event to your calendar and your phone reminds you. It repeats {reminderRepeats(h)}.</p>
<label className="field time-field">
<span>Time</span>
<input className="input" type="time" value={time} onChange={(e) => setTime(e.target.value || "08:00")} />
</label>
<div className="row">
<button type="button" className="btn" onClick={() => { download(file, reminderIcs(h, time), "text/calendar"); toast("Open the file to add it to your calendar"); }}>Add a reminder to my calendar</button>
<a className="btn btn-line" href={googleLink(h, time)} target="_blank" rel="noopener noreferrer">Google Calendar</a>
</div>
<p className="hint">The file works with Apple Calendar, Outlook and most calendar apps. To stop it, delete the event in your calendar.</p>
</section>
);
}
export function HabitPage({ id }: { id: string }) {
const s = useStore();
const now = useToday();
const h = s.habits[id];
const [editing, setEditing] = useState(false);
const [day, setDay] = useState<string | null>(null);
useEffect(() => { if (h) document.title = `${h.name} | Habits`; }, [h?.name]);
if (!h || h.deleted) {
return (
<div className="page">
<div className="empty">
<p>This habit is not on this device.</p>
<A className="btn" href="/today">Go to today</A>
</div>
</div>
);
}
const st = stats(h, s.weekStart, now);
const todayKey = keyOf(now);
const notes = Object.entries(h.days).filter(([, d]) => d.note).sort((a, b) => (a[0] < b[0] ? 1 : -1)).slice(0, 12);
const avoid = h.kind === "avoid";
return (
<div className="page habit">
<A href="/today" className="back-link"><Back size={16} /> Today</A>
<header className="page-head">
<div className="grow">
<p className="eyebrow">{scheduleLabel(h)}{h.kind === "count" ? ` · ${h.target}${h.unit ? ` ${h.unit}` : ""} a day` : ""}{h.archived ? " · Archived" : ""}</p>
<h1 className="h1 habit-title">{h.name}</h1>
</div>
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}><Pencil size={16} /> Edit</button>
</header>
<div className="habit-grid">
<div className="stack habit-main">
<section className="card pad today-card">
<div className="today-card-text">
<p className="eyebrow">Today</p>
<p className="today-state">
{avoid ? (st.doneToday ? "You slipped today. That is okay." : "Going without today.")
: st.doneToday ? "Done today."
: h.kind === "count" ? `${st.countToday} of ${h.target}${h.unit ? ` ${h.unit}` : ""} so far.`
: st.due ? "Not done yet." : st.rest || "Not due today."}
</p>
<button type="button" className="link-btn" onClick={() => setDay(todayKey)}><Note /> {h.days[todayKey]?.note ? "Edit today's note" : "Add a note"}</button>
</div>
<CheckButton h={h} day={todayKey} big />
</section>
<dl className="stats">
<div><dt>{avoid ? "Days without" : "Current streak"}</dt><dd>{st.current}<small> {avoid ? "" : streakLabel(st.current, st.unit).replace(/^\d+ /, "")}</small></dd></div>
<div><dt>Best streak</dt><dd>{st.best}<small> {streakLabel(st.best, st.unit).replace(/^\d+ /, "")}</small></dd></div>
<div><dt>{avoid ? "Days clean" : "Completion"}</dt><dd>{st.rate === null ? "-" : `${Math.round(st.rate * 100)}%`}</dd></div>
<div><dt>{avoid ? "Slips" : "Days done"}</dt><dd>{st.total}</dd></div>
</dl>
<section className="card pad"><Calendar h={h} now={now} onPick={setDay} /></section>
<section className="card pad"><YearMap h={h} now={now} /></section>
{notes.length > 0 && (
<section className="card pad stack">
<h2 className="h3 icon-title"><Note size={18} /> Notes</h2>
<ul className="notes">
{notes.map(([k, d]) => (
<li key={k}><button type="button" onClick={() => setDay(k)}><span className="note-date">{fmt(dayNum(k), { weekday: "short", month: "short", day: "numeric" })}</span><span className="note-text">{d.note}</span></button></li>
))}
</ul>
</section>
)}
</div>
<div className="stack habit-side">
<ShareCard h={h} />
<Reminder h={h} />
<section className="card pad stack">
<h2 className="h3 icon-title"><Archive /> {h.archived ? "Archived" : "Archive"}</h2>
{h.archived ? (
<>
<p className="muted small">Archived habits are hidden from Today and the week. Their history is kept.</p>
<div className="row">
<button type="button" className="btn btn-line btn-sm" onClick={() => { editHabit(h.id, (x) => { x.archived = false; }); toast("Restored"); }}>Restore</button>
<button type="button" className="btn btn-quiet btn-sm btn-danger" onClick={async () => {
if (!window.confirm(`Delete ${h.name} and all its history? This cannot be undone.`)) return;
if (h.team) await leaveTeam(h.id).catch(() => null);
update((x) => { x.habits[h.id] = { ...x.habits[h.id], deleted: true, days: {}, team: null, at: Date.now() }; });
toast("Deleted");
navigate("/habits");
}}>Delete for good</button>
</div>
</>
) : (
<>
<p className="muted small">Done with this one, or taking a break? Archive it to hide it from Today. The history is kept and you can restore it.</p>
<button type="button" className="btn btn-line btn-sm" onClick={() => { editHabit(h.id, (x) => { x.archived = true; }); toast("Archived", { label: "Undo", run: () => editHabit(h.id, (x) => { x.archived = false; }) }); }}>Archive habit</button>
</>
)}
</section>
</div>
</div>
{editing && <Editor habit={h} onClose={() => setEditing(false)} />}
{day && <DaySheet habitId={h.id} day={day} onClose={() => setDay(null)} />}
</div>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApp service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync