habits-free.openapps.xyz

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.
Owned app · rev bc888334fc51
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.
← All files
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
bc888334fc · 18 hours ago
Bind agent address in policy
1f744a2b83 · 19 hours ago
Use OpenApp service URLs and add contribution strip
a9bb601ee0 · 19 hours ago
Bounded source sync
5646afc3a0 · 1 day ago
Bounded source sync
f1b1bac8d6 · 1 day ago
Bounded source sync
b1e14b41b0 · 1 day ago