still-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 0fa0e84d537b
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.
← All files
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
0fa0e84d53 · 19 hours ago
Bind the agent address in the policy
6f3baa1c72 · 1 day ago
Add favicon.ico so browsers that ask for it stop getting a 404
1a004fe0e3 · 1 day ago
Bounded source sync
afe642fe50 · 2 days ago
Bounded source sync
2c581f55ea · 2 days ago
Bounded source sync
375934c28b · 2 days ago
Bounded source sync
74ff37d51a · 2 days ago