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/lib/progress.ts
// Practice history: every finished session, kept on this device (with its moods), plus day totals
// that the guest account keeps a copy of.
import { useMemo } from "react";
import { deleteDays, remoteDays, saveDay, type DayTotals } from "./bounded";
import { read, useStored, write } from "./store";

export type Kind = "breathe" | "meditate" | "timer" | "story";
/** A finished session. Moods are 1 (very tense) to 5 (very calm). */
export type Entry = { at: number; day: string; kind: Kind; title: string; sec: number; before?: number; after?: number };

const LOG = "still.log";
const DAYS = "still.days";

export const MOODS = ["Very tense", "Tense", "Okay", "Calm", "Very calm"];

/** Today's date where the person is, as YYYY-MM-DD. */
export function dayKey(d = new Date()): string {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}

/** Sessions shorter than a minute are not counted. */
export const MIN_SECONDS = 60;

export function record(e: Omit<Entry, "at" | "day">): Entry {
  const entry: Entry = { ...e, at: Date.now(), day: dayKey(), sec: Math.round(e.sec) };
  write(LOG, [...read<Entry[]>(LOG, []), entry].slice(-3000));
  const days = { ...read<Record<string, DayTotals>>(DAYS, {}) };
  const t = days[entry.day] ?? { seconds: 0, sessions: 0 };
  days[entry.day] = { seconds: t.seconds + entry.sec, sessions: t.sessions + 1 };
  write(DAYS, days);
  void saveDay(entry.day, days[entry.day]).catch(() => null);
  return entry;
}

export function setAfterMood(at: number, mood: number) {
  write(LOG, read<Entry[]>(LOG, []).map((e) => (e.at === at ? { ...e, after: mood } : e)));
}

/** Brings device and account together: each day keeps the larger total, then the account catches up. */
export async function sync() {
  const remote = await remoteDays();
  const days = { ...read<Record<string, DayTotals>>(DAYS, {}) };
  let changed = false;
  for (const [d, r] of Object.entries(remote)) {
    const l = days[d];
    if (!l || r.seconds > l.seconds || r.sessions > l.sessions) {
      days[d] = { seconds: Math.max(r.seconds, l?.seconds ?? 0), sessions: Math.max(r.sessions, l?.sessions ?? 0) };
      changed = true;
    }
  }
  if (changed) write(DAYS, days);
  const behind = Object.entries(days).filter(([d, l]) => !remote[d] || remote[d].seconds < l.seconds || remote[d].sessions < l.sessions);
  for (const [d, l] of behind.slice(-60)) await saveDay(d, l);
}

export async function clearProgress() {
  const days = Object.keys(read<Record<string, DayTotals>>(DAYS, {}));
  write(LOG, []);
  write(DAYS, {});
  const remote = await remoteDays().catch(() => ({}));
  await deleteDays([...new Set([...days, ...Object.keys(remote)])]).catch(() => null);
}

export type Summary = { days: Record<string, DayTotals>; log: Entry[]; streak: number; longest: number; minutes: number; sessions: number };

export function useProgress(): Summary {
  const [days] = useStored<Record<string, DayTotals>>(DAYS, {});
  const [log] = useStored<Entry[]>(LOG, []);
  return useMemo(() => {
    const has = (d: Date) => (days[dayKey(d)]?.seconds ?? 0) > 0;
    const cursor = new Date();
    if (!has(cursor)) cursor.setDate(cursor.getDate() - 1);
    let streak = 0;
    while (has(cursor)) { streak++; cursor.setDate(cursor.getDate() - 1); }
    let longest = 0;
    let run = 0;
    let prev: Date | null = null;
    for (const d of Object.keys(days).filter((k) => days[k].seconds > 0).sort()) {
      const date = new Date(`${d}T12:00:00`);
      run = prev && Math.round((date.getTime() - prev.getTime()) / 86400000) === 1 ? run + 1 : 1;
      longest = Math.max(longest, run);
      prev = date;
    }
    const totals = Object.values(days).reduce((a, t) => ({ s: a.s + t.seconds, n: a.n + t.sessions }), { s: 0, n: 0 });
    return { days, log, streak, longest: Math.max(longest, streak), minutes: Math.round(totals.s / 60), sessions: totals.n };
  }, [days, log]);
}

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