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 a8c677472ab5691741798a3d
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/Progress.tsx
// Progress: streak, minutes, a calendar of practice days and how check-ins went. Plus settings.
import { useEffect, useState } from "react";
import { bell, canVibrate } from "./lib/audio";
import { clearProgress, dayKey, MOODS, sync, useProgress, type Kind } from "./lib/progress";
import { useSettings, type Settings } from "./lib/store";
import { VoicePicker } from "./Meditate";
import { Chips, Icon, Toggle, fmtMinutes, toast } from "./ui";
const KIND: Record<Kind, string> = { breathe: "Breathing", meditate: "Guided", timer: "Timer", story: "Sleep story" };
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function Calendar({ days }: { days: Record<string, { seconds: number }> }) {
const [offset, setOffset] = useState(0);
const first = new Date();
first.setDate(1);
first.setMonth(first.getMonth() + offset);
const year = first.getFullYear();
const month = first.getMonth();
const lead = (first.getDay() + 6) % 7;
const count = new Date(year, month + 1, 0).getDate();
const today = dayKey();
const cells: (number | null)[] = [...Array(lead).fill(null), ...Array.from({ length: count }, (_, i) => i + 1)];
const practiced = cells.filter((d) => d && (days[dayKey(new Date(year, month, d))]?.seconds ?? 0) > 0).length;
return (
<div className="calendar">
<div className="calendar-head">
<button type="button" className="round-btn" aria-label="Previous month" onClick={() => setOffset(offset - 1)}><Icon name="back" size={18} /></button>
<p><strong>{first.toLocaleDateString(undefined, { month: "long", year: "numeric" })}</strong><span>{practiced} {practiced === 1 ? "day" : "days"} of practice</span></p>
<button type="button" className="round-btn flip" aria-label="Next month" disabled={offset >= 0} onClick={() => setOffset(offset + 1)}><Icon name="back" size={18} /></button>
</div>
<div className="calendar-grid" role="grid" aria-label="Practice days">
{WEEKDAYS.map((w) => <span key={w} className="calendar-wd" aria-hidden="true">{w.slice(0, 1)}</span>)}
{cells.map((d, i) => {
if (!d) return <span key={`x${i}`} />;
const key = dayKey(new Date(year, month, d));
const sec = days[key]?.seconds ?? 0;
return (
<span key={key} role="gridcell" className={`calendar-day${sec > 0 ? " did" : ""}${key === today ? " today" : ""}`} aria-label={`${key}: ${sec > 0 ? fmtMinutes(sec) : "no practice"}`}>
{d}
</span>
);
})}
</div>
</div>
);
}
const BELL_NAMES: Record<Settings["bell"], string> = { bowl: "Singing bowl", bell: "Temple bell", chime: "Chime" };
export function Progress() {
const p = useProgress();
const [s, setS] = useSettings();
useEffect(() => { void sync().catch(() => null); }, []);
const checked = p.log.filter((e) => e.before && e.after);
const calmer = checked.filter((e) => (e.after ?? 0) > (e.before ?? 0)).length;
const recent = [...p.log].reverse().slice(0, 12);
return (
<div className="page wrap">
<header className="page-head">
<p className="eyebrow">Progress</p>
<h1 className="page-title">Your practice.</h1>
</header>
<dl className="stats">
<div><dt>Current streak</dt><dd>{p.streak} {p.streak === 1 ? "day" : "days"}</dd></div>
<div><dt>Longest streak</dt><dd>{p.longest} {p.longest === 1 ? "day" : "days"}</dd></div>
<div><dt>Total time</dt><dd>{p.minutes} min</dd></div>
<div><dt>Sessions</dt><dd>{p.sessions}</dd></div>
</dl>
<div className="progress-grid">
<section className="card pad">
<Calendar days={p.days} />
</section>
<section className="card pad">
<h2 className="card-title">Check-ins</h2>
{checked.length ? (
<p className="mood-summary">You felt calmer after <strong>{calmer} of {checked.length}</strong> sessions where you checked in before and after.</p>
) : (
<p className="muted">Before a session, Still asks how you feel, and again after. Answer both and you will see how much sessions help here. Your answers stay on this device.</p>
)}
{recent.length > 0 && (
<ul className="history">
{recent.map((e) => (
<li key={e.at}>
<span className="history-main"><strong>{e.title}</strong><span>{KIND[e.kind]} · {fmtMinutes(e.sec)} · {new Date(e.at).toLocaleDateString(undefined, { month: "short", day: "numeric" })}</span></span>
{e.before && e.after ? <span className="history-mood">{MOODS[e.before - 1]} to {MOODS[e.after - 1].toLowerCase()}</span> : null}
</li>
))}
</ul>
)}
</section>
</div>
<h2 className="section-title">Settings</h2>
<div className="card pad settings">
<VoicePicker />
<div className="field">
<span>Bell</span>
<Chips label="Bell sound" options={["bowl", "bell", "chime"] as Settings["bell"][]} value={s.bell} onChange={(b) => { setS({ bell: b }); bell(b); }} format={(b) => BELL_NAMES[b]} />
</div>
<Toggle label="Breathing tones" hint="Soft tones that rise and fall with each breath." checked={s.tones} onChange={(v) => setS({ tones: v })} />
{canVibrate && <Toggle label="Vibration" hint="A short buzz as each part of a breath begins." checked={s.vibrate} onChange={(v) => setS({ vibrate: v })} />}
</div>
<p className="hint fine">Your practice days are kept on this device and in a guest account made for this browser, with no sign-up. Moods and saved mixes stay on this device only.</p>
<button type="button" className="btn btn-quiet btn-danger" onClick={async () => {
if (!window.confirm("Clear all your progress? This removes your streak, minutes and check-ins on this device and in your guest account.")) return;
await clearProgress();
toast("Progress cleared");
}}>Clear my progress</button>
</div>
);
}
Change history
Link to OpenApps pages and add a contribution strip
Bind the agent address in the policy
Add favicon.ico so browsers that ask for it stop getting a 404
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync