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/Editor.tsx
// Adding or changing a habit: name, what kind (do it, count it, avoid it) and how often.
import { useState } from "react";
import { getState, update, editHabit, nextOrder } from "./lib/store";
import { newHabit, MAX_NAME, type Habit, type Kind, type Schedule } from "./lib/habits";
import { WEEKDAY_SHORT } from "./lib/dates";
import { Modal, toast } from "./ui";

export type Draft = { name: string; kind: Kind; target: number; unit: string; schedule: Schedule };

const BLANK: Draft = { name: "", kind: "check", target: 8, unit: "", schedule: { type: "daily" } };
const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];

/** A new habit from a link, for example /new?name=Drink%20water&type=count&goal=8&unit=glasses or /new?name=Run&times=3. */
export function draftFromQuery(q: URLSearchParams): Draft {
  const d: Draft = { ...BLANK, name: (q.get("name") || "").slice(0, MAX_NAME) };
  const type = q.get("type");
  if (type === "count" || type === "avoid") d.kind = type;
  const goal = Number(q.get("goal"));
  if (goal >= 1) { d.target = Math.min(10000, Math.round(goal)); if (!type) d.kind = "count"; }
  d.unit = (q.get("unit") || "").slice(0, 20);
  const days = (q.get("days") || "").toLowerCase();
  const times = Number(q.get("times"));
  const every = Number(q.get("every"));
  if (days === "weekdays") d.schedule = { type: "days", days: [1, 2, 3, 4, 5] };
  else if (days === "weekends") d.schedule = { type: "days", days: [0, 6] };
  else if (days) {
    const list = days.split(",").map((x) => DAY_NAMES.indexOf(x.trim().slice(0, 3))).filter((x) => x >= 0);
    if (list.length) d.schedule = { type: "days", days: [...new Set(list)].sort() };
  } else if (times >= 1 && times <= 7) d.schedule = { type: "weekly", times: Math.round(times) };
  else if (every >= 2 && every <= 60) d.schedule = { type: "every", n: Math.round(every) };
  return d;
}

const KINDS: { id: Kind; label: string; hint: string }[] = [
  { id: "check", label: "Do it", hint: "Check it off when it is done." },
  { id: "count", label: "Count it", hint: "Done when you reach a number, like 8 glasses or 10 pages." },
  { id: "avoid", label: "Avoid it", hint: "Counts the days without it. Mark a slip if it happens." },
];

const SCHEDULES: { id: Schedule["type"]; label: string }[] = [
  { id: "daily", label: "Every day" },
  { id: "days", label: "On some days" },
  { id: "weekly", label: "Times a week" },
  { id: "every", label: "Every few days" },
];

function Stepper({ value, min, max, onChange, label }: { value: number; min: number; max: number; onChange: (n: number) => void; label: string }) {
  return (
    <div className="stepper" role="group" aria-label={label}>
      <button type="button" className="step-btn" aria-label={`Fewer`} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 1))}>-</button>
      <input className="input step-input" inputMode="numeric" aria-label={label} value={value}
        onChange={(e) => { const n = Number(e.target.value.replace(/\D/g, "")); if (!Number.isNaN(n)) onChange(Math.min(max, n)); }}
        onBlur={() => { if (value < min) onChange(min); }} />
      <button type="button" className="step-btn" aria-label={`More`} disabled={value >= max} onClick={() => onChange(Math.min(max, value + 1))}>+</button>
    </div>
  );
}

/** The add or edit sheet. With `habit`, it changes that habit; otherwise it adds a new one from `initial`. */
export function Editor({ habit, initial, onClose, onSaved }: { habit?: Habit; initial?: Draft; onClose: () => void; onSaved?: (id: string) => void }) {
  const [d, setD] = useState<Draft>(() => habit
    ? { name: habit.name, kind: habit.kind, target: habit.kind === "count" ? habit.target : 8, unit: habit.unit, schedule: habit.schedule }
    : { ...BLANK, ...initial });
  const [error, setError] = useState("");
  const set = (patch: Partial<Draft>) => { setD({ ...d, ...patch }); setError(""); };
  const sched = d.schedule;

  const pickSchedule = (type: Schedule["type"]) => {
    if (type === sched.type) return;
    if (type === "daily") set({ schedule: { type } });
    if (type === "days") set({ schedule: { type, days: [1, 3, 5] } });
    if (type === "weekly") set({ schedule: { type, times: 3 } });
    if (type === "every") set({ schedule: { type, n: 2 } });
  };

  const save = () => {
    const name = d.name.trim();
    if (!name) return setError("Give the habit a name.");
    if (sched.type === "days" && !sched.days.length) return setError("Pick at least one day.");
    if (d.kind === "count" && !(d.target >= 1)) return setError("Set a goal of 1 or more.");
    const schedule: Schedule = d.kind === "avoid" ? { type: "daily" }
      : sched.type === "weekly" ? { type: "weekly", times: Math.min(7, Math.max(1, sched.times)) }
      : sched.type === "every" ? { type: "every", n: Math.min(60, Math.max(2, sched.n)) }
      : sched;
    const fields = {
      name: name.slice(0, MAX_NAME),
      kind: d.kind,
      target: d.kind === "count" ? Math.round(d.target) : 1,
      unit: d.kind === "count" ? d.unit.trim().slice(0, 20) : "",
      schedule,
    };
    if (habit) {
      editHabit(habit.id, (h) => Object.assign(h, fields));
      toast("Saved");
      onSaved?.(habit.id);
    } else {
      const h = newHabit(fields, nextOrder(getState()));
      update((s) => { s.habits[h.id] = h; });
      toast(`Added ${h.name}`);
      onSaved?.(h.id);
    }
    onClose();
  };

  return (
    <Modal title={habit ? "Edit habit" : "New habit"} onClose={onClose}
      footer={<><button type="button" className="btn btn-line" onClick={onClose}>Cancel</button><button type="button" className="btn grow" onClick={save}>{habit ? "Save" : "Add habit"}</button></>}>
      <form className="editor" onSubmit={(e) => { e.preventDefault(); save(); }}>
        <label className="field">
          <span>Name</span>
          <input className="input" value={d.name} maxLength={MAX_NAME} autoFocus={!habit && !d.name} placeholder="Read, walk, drink water..." onChange={(e) => set({ name: e.target.value })} />
        </label>

        <div className="field">
          <span id="kind-label">Kind</span>
          <div className="segmented three" role="radiogroup" aria-labelledby="kind-label">
            {KINDS.map((k) => (
              <button key={k.id} type="button" role="radio" aria-checked={d.kind === k.id} onClick={() => set({ kind: k.id })}>{k.label}</button>
            ))}
          </div>
          <small>{KINDS.find((k) => k.id === d.kind)!.hint}</small>
        </div>

        {d.kind === "count" && (
          <div className="two">
            <div className="field"><span>Goal each day</span><Stepper label="Goal each day" value={d.target} min={1} max={10000} onChange={(n) => set({ target: n })} /></div>
            <label className="field"><span>Of what</span><input className="input" value={d.unit} maxLength={20} placeholder="glasses, pages" onChange={(e) => set({ unit: e.target.value })} /></label>
          </div>
        )}

        {d.kind !== "avoid" && (
          <div className="field">
            <span id="sched-label">How often</span>
            <div className="choice-list" role="radiogroup" aria-labelledby="sched-label">
              {SCHEDULES.map((o) => (
                <button key={o.id} type="button" role="radio" aria-checked={sched.type === o.id} className="choice" onClick={() => pickSchedule(o.id)}>
                  <span className="choice-dot" aria-hidden="true" />{o.label}
                </button>
              ))}
            </div>
            {sched.type === "days" && (
              <div className="day-picks" role="group" aria-label="Days">
                {[1, 2, 3, 4, 5, 6, 0].map((wd) => {
                  const on = sched.days.includes(wd);
                  return (
                    <button key={wd} type="button" aria-pressed={on} className={`day-pick ${on ? "on" : ""}`}
                      onClick={() => set({ schedule: { type: "days", days: on ? sched.days.filter((x) => x !== wd) : [...sched.days, wd].sort() } })}>
                      {WEEKDAY_SHORT[wd]}
                    </button>
                  );
                })}
              </div>
            )}
            {sched.type === "weekly" && (
              <div className="inline-field"><Stepper label="Times a week" value={sched.times} min={1} max={7} onChange={(n) => set({ schedule: { type: "weekly", times: n } })} /><span>times a week, on any days</span></div>
            )}
            {sched.type === "every" && (
              <div className="inline-field"><span>Every</span><Stepper label="Every how many days" value={sched.n} min={2} max={60} onChange={(n) => set({ schedule: { type: "every", n } })} /><span>days</span></div>
            )}
          </div>
        )}
        {error && <p className="notice notice-error" role="alert">{error}</p>}
        <button type="submit" hidden />
      </form>
    </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