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 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.
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×=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
Bind agent address in policy
Use OpenApp service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync