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/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
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