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/Checkin.tsx
// Checking in: the one-tap button (done, +1, or a slip), friends' streaks on a shared habit, and
// the sheet for changing any past day (count and note).
import { useState } from "react";
import { useQuery } from "@bounded-sh/client";
import { setDay, getState, useStore } from "./lib/store";
import { useUser, type Member } from "./lib/sync";
import { met, stats, streakLabel, MAX_NOTE, type Habit, type Unit } from "./lib/habits";
import { dayNum, fmt } from "./lib/dates";
import { Modal, toast } from "./ui";
import { Check, Plus } from "./icons";
const buzz = () => { try { navigator.vibrate?.(12); } catch { /* not supported */ } };
/** Sets a day's count, then says what changed, with an undo. */
function change(h: Habit, day: string, n: number, message: () => string) {
const prev = h.days[day]?.n ?? 0;
setDay(h.id, day, n);
buzz();
toast(message(), { label: "Undo", run: () => setDay(h.id, day, prev) });
}
/** What the toast says after a check-in: the streak it makes. */
function doneMessage(id: string): string {
const s = getState();
const st = stats(s.habits[id], s.weekStart);
if (st.current < 2) return "Done. Nice start.";
return `Done. ${st.unit === "time" ? streakLabel(st.current, "time") : `${streakLabel(st.current, st.unit)} in a row`}.`;
}
/** The big round button: check off, add one, or (for avoid habits) mark a slip. */
export function CheckButton({ h, day, big = false }: { h: Habit; day: string; big?: boolean }) {
const d = h.days[day];
const n = d?.n ?? 0;
const done = met(h, d);
if (h.kind === "avoid") {
return (
<button type="button" className={`slip-btn ${done ? "on" : ""} ${big ? "big" : ""}`} aria-pressed={done}
onClick={() => done ? change(h, day, 0, () => "Slip removed") : change(h, day, 1, () => "Slip noted. Tomorrow starts a new run.")}>
{done ? "Slipped" : "I slipped"}
</button>
);
}
if (h.kind === "count") {
const target = Math.max(1, h.target);
const r = big ? 34 : 24;
const c = 2 * Math.PI * r;
const frac = Math.min(1, n / target);
const of = (x: number) => `${x} of ${target}${h.unit ? ` ${h.unit}` : ""}`;
return (
<button type="button" className={`check-btn count ${done ? "done" : ""} ${big ? "big" : ""}`}
aria-label={`${h.name}: ${of(n)}. Add one.`}
onClick={() => change(h, day, n + 1, () => (n + 1 === target ? doneMessage(h.id) : of(n + 1)))}>
<svg className="ring" viewBox={`0 0 ${2 * r + 6} ${2 * r + 6}`} aria-hidden="true">
<circle cx={r + 3} cy={r + 3} r={r} className="ring-track" />
{frac > 0 && <circle cx={r + 3} cy={r + 3} r={r} className="ring-fill" strokeDasharray={`${c * frac} ${c}`} transform={`rotate(-90 ${r + 3} ${r + 3})`} />}
</svg>
<span className="check-inner">{done ? <Check size={big ? 28 : 22} /> : n > 0 ? <span className="count-n">{n}</span> : <Plus size={big ? 26 : 20} />}</span>
</button>
);
}
return (
<button type="button" className={`check-btn ${done ? "done" : ""} ${big ? "big" : ""}`} aria-pressed={done}
aria-label={done ? `${h.name}: done. Tap to undo.` : `Check off ${h.name}`}
onClick={() => (done ? change(h, day, 0, () => "Unchecked") : change(h, day, 1, () => doneMessage(h.id)))}>
<span className="check-inner"><Check size={big ? 30 : 24} /></span>
</button>
);
}
/** "12 days", with the right unit, for a friend's row. */
export function memberStreak(m: Member): string {
if (m.unit === "without") return streakLabel(m.streak, "day", true);
return streakLabel(m.streak, (["day", "week", "time"].includes(m.unit) ? m.unit : "day") as Unit);
}
/** The friends on a shared habit, live. Everyone's own row is written by their own device. */
export function useMembers(team: string | null): { rows: Member[] | undefined; error: Error | null } {
const user = useUser();
const { data, error } = useQuery(team && user ? `teams/${team}/members` : null, { limit: 50 }) as { data: Member[] | undefined; error: Error | null };
return { rows: data, error };
}
/** One line for the Today list: "With Sam, 12 days". */
export function FriendsLine({ team }: { team: string }) {
const user = useUser();
const { rows } = useMembers(team);
const others = (rows || []).filter((m) => m.id !== user?.id);
if (!others.length) return <span className="meta-friends">Shared, waiting for your friend</span>;
const first = others[0];
return <span className="meta-friends">With {first.name}{others.length > 1 ? ` and ${others.length - 1} more` : ""}: {memberStreak(first)}</span>;
}
/** The sheet for one day of a habit: done or not (or the count, or a slip), and a note. */
export function DaySheet({ habitId, day, onClose }: { habitId: string; day: string; onClose: () => void }) {
const s = useStore();
const h = s.habits[habitId];
const d = h?.days[day];
const [n, setN] = useState(d?.n ?? 0);
const [note, setNote] = useState(d?.note ?? "");
if (!h) return null;
const save = () => {
setDay(h.id, day, n, note.slice(0, MAX_NOTE));
toast("Saved");
onClose();
};
const title = fmt(dayNum(day), { weekday: "long", month: "long", day: "numeric" });
return (
<Modal title={title} onClose={onClose}
footer={<><button type="button" className="btn btn-line" onClick={onClose}>Cancel</button><button type="button" className="btn grow" onClick={save}>Save</button></>}>
<div className="stack">
<p className="muted">{h.name}</p>
{h.kind === "count" ? (
<div className="day-count">
<button type="button" className="round-btn" aria-label="One less" disabled={n <= 0} onClick={() => setN(Math.max(0, n - 1))}>-</button>
<p><strong>{n}</strong><span>of {h.target}{h.unit ? ` ${h.unit}` : ""}</span></p>
<button type="button" className="round-btn" aria-label="One more" onClick={() => setN(n + 1)}>+</button>
</div>
) : (
<div className="segmented two-up" role="radiogroup" aria-label="This day">
<button type="button" role="radio" aria-checked={n === 0} onClick={() => setN(0)}>{h.kind === "avoid" ? "Went without" : "Not done"}</button>
<button type="button" role="radio" aria-checked={n > 0} onClick={() => setN(1)}>{h.kind === "avoid" ? "Slipped" : "Done"}</button>
</div>
)}
<label className="field">
<span>Note</span>
<textarea className="textarea" value={note} maxLength={MAX_NOTE} placeholder="How it went, if you like" onChange={(e) => setNote(e.target.value)} />
<small>{MAX_NOTE - note.length} characters left. Notes stay private.</small>
</label>
</div>
</Modal>
);
}
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