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 5af1a249bbbef1db1b4c5ec8
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 subscription. The name field suggests popular services (with their website,
// category and, for some, a typical price to check), and shows right away when the OpenApps store has
// a free alternative. Everything else is a few taps: price, how often, the next date, free trial.
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useStore, saveSubs, getState } from "./lib/store";
import { suggest, serviceNamed, type Service } from "./lib/services";
import { useFreeApps, freeAlternative, type FreeApp } from "./lib/alternatives";
import { addDays, addMonths, dayKey, isDayKey } from "./lib/dates";
import { fromMinor, money, toMinor, minorDigits } from "./lib/money";
import { CATEGORIES, MAX_METHOD, MAX_NAME, MAX_NOTES, MAX_URL, blank, cycleLabel, isCategory, isUnit, problem, type Category, type Sub, type Unit } from "./lib/subs";
import { toast } from "./ui";
import { Out } from "./icons";
type Cycle = "week" | "month" | "year" | "custom";
const CYCLES: { v: Cycle; label: string }[] = [
{ v: "week", label: "Weekly" }, { v: "month", label: "Monthly" }, { v: "year", label: "Yearly" }, { v: "custom", label: "Custom" },
];
/** A new subscription filled in from a link (/add?name=Netflix&price=15.49&cycle=monthly&next=2026-11-03&trial=1). */
export function draftFromQuery(q: URLSearchParams, currency: string): Sub | null {
const name = (q.get("name") || "").trim().slice(0, MAX_NAME);
if (!name) return null;
const today = dayKey();
const s = blank(today);
s.name = name;
const sv = serviceNamed(name);
if (sv) { s.url = sv.url; s.category = sv.category; }
const price = toMinor(q.get("price") || "", currency);
if (Number.isFinite(price)) s.amount = price;
const cycle = (q.get("cycle") || "").toLowerCase();
const every = Math.min(365, Math.max(1, Math.floor(Number(q.get("every")) || 1)));
const unit = ({ weekly: "week", monthly: "month", yearly: "year", annual: "year", daily: "day", week: "week", month: "month", year: "year", day: "day" } as Record<string, Unit>)[cycle];
if (isUnit(unit)) { s.unit = unit; s.every = every; }
const cat = q.get("category");
if (isCategory(cat)) s.category = cat;
s.trial = q.get("trial") === "1" || q.get("trial") === "yes" || q.get("trial") === "true";
const next = q.get("next") || "";
s.next = isDayKey(next) ? next : s.trial ? addDays(today, 7) : nextFrom(today, s.unit, s.every);
return s;
}
const nextFrom = (today: string, unit: Unit, every: number) =>
unit === "day" ? addDays(today, every) : unit === "week" ? addDays(today, 7 * every) : addMonths(today, unit === "year" ? 12 * every : every);
const usd = (v: string) => (v.includes(".") ? v : `${v}.00`);
function typicalText(sv: Service, currency: string): string | null {
if (!sv.typical || currency !== "USD") return null;
return `${money(toMinor(sv.typical.usd, "USD"), "USD")} ${cycleLabel({ unit: sv.typical.unit, every: 1 })}`;
}
function NameField({ value, onChange, onPick, currency, apps, inputId }: {
value: string; onChange: (v: string) => void; onPick: (sv: Service) => void; currency: string; apps: FreeApp[]; inputId: string;
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const list = useMemo(() => suggest(value), [value]);
const id = useId();
const show = open && list.length > 0 && !(list.length === 1 && list[0].name === value);
const pick = (sv: Service) => { onPick(sv); setOpen(false); setActive(-1); };
return (
<div className="combo">
<input id={inputId} className="input" value={value} maxLength={MAX_NAME} autoComplete="off" spellCheck={false}
placeholder="Netflix, Spotify, a gym..." role="combobox" aria-autocomplete="list" aria-expanded={show}
aria-controls={`${id}-list`} aria-activedescendant={show && active >= 0 ? `${id}-o${active}` : undefined}
onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" && list.length) { e.preventDefault(); setOpen(true); setActive((a) => (a + 1) % list.length); }
else if (e.key === "ArrowUp" && list.length) { e.preventDefault(); setActive((a) => (a <= 0 ? list.length - 1 : a - 1)); }
else if (e.key === "Enter" && show && active >= 0) { e.preventDefault(); pick(list[active]); }
else if (e.key === "Escape" && show) { e.preventDefault(); setOpen(false); }
}} />
{show && (
<ul className="combo-list" role="listbox" id={`${id}-list`} aria-label="Popular services">
{list.map((sv, i) => {
const alt = freeAlternative(sv.name, apps);
const typical = typicalText(sv, currency);
return (
<li key={sv.name} id={`${id}-o${i}`} role="option" aria-selected={i === active} className={i === active ? "on" : ""}
onMouseDown={(e) => { e.preventDefault(); pick(sv); }}>
<strong>{sv.name}</strong>
<span>{CATEGORIES[sv.category]}{typical ? ` · typical ${typical}` : ""}</span>
{alt && <span className="chip chip-free">Free alternative: {alt.app.name}</span>}
</li>
);
})}
</ul>
)}
</div>
);
}
export function Editor({ initial, mode, fixedDate = false, onDone }: { initial: Sub; mode: "add" | "edit" | "start"; fixedDate?: boolean; onDone: (s: Sub, again: boolean) => void }) {
const { currency } = useStore();
const apps = useFreeApps();
const ids = useId();
const today = dayKey();
// A new one named after a listed service starts with that service's typical price, marked as such.
const sv0 = mode !== "edit" && !initial.amount ? serviceNamed(initial.name) : undefined;
const t0 = sv0?.typical && currency === "USD" ? sv0.typical : null;
const initialCycle: Cycle = t0 ? (t0.unit as Cycle) : initial.every === 1 && initial.unit !== "day" ? (initial.unit as Cycle) : "custom";
const [name, setName] = useState(initial.name);
const [price, setPrice] = useState(initial.amount || mode === "edit" ? fromMinor(initial.amount, currency) : t0 ? usd(t0.usd) : "");
const [cycle, setCycle] = useState<Cycle>(initialCycle);
const [every, setEvery] = useState(String(initial.every));
const [unit, setUnit] = useState<Unit>(initial.unit);
const [next, setNext] = useState(initial.next);
const [dateTouched, setDateTouched] = useState(mode === "edit" || fixedDate);
const [trial, setTrial] = useState(initial.trial);
const [category, setCategory] = useState<Category>(initial.category);
const [method, setMethod] = useState(initial.method);
const [url, setUrl] = useState(initial.url);
const [notes, setNotes] = useState(initial.notes);
const [details, setDetails] = useState(mode === "edit" && Boolean(initial.method || initial.notes));
const [typical, setTypical] = useState<string | null>(sv0 ? typicalText(sv0, currency) : null);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const realUnit: Unit = cycle === "custom" ? unit : cycle;
const realEvery = cycle === "custom" ? Math.floor(Number(every)) : 1;
const match = name.trim() ? freeAlternative(name, apps) : null;
// Until the person picks a date, it follows the cycle: one cycle from today, or a week for a trial.
useEffect(() => {
if (dateTouched) return;
setNext(trial ? addDays(today, 7) : nextFrom(today, realUnit, Math.max(1, realEvery || 1)));
}, [trial, realUnit, realEvery, dateTouched, today]);
const pick = (sv: Service) => {
setName(sv.name);
setCategory(sv.category);
setUrl(sv.url);
if (sv.typical && currency === "USD" && !price) {
setPrice(usd(sv.typical.usd));
setCycle(sv.typical.unit === "day" ? "custom" : sv.typical.unit);
setTypical(typicalText(sv, currency));
}
};
const build = (): Sub => {
// A typed name that matches a listed service gets its website and category, as if picked.
const known = serviceNamed(name);
const site = url.trim();
return {
...initial,
name: name.trim(),
amount: price.trim() === "" ? NaN : toMinor(price.replace(/^[^\d]+/, ""), currency),
unit: realUnit,
every: realEvery,
next,
trial,
category: category === "other" && known ? known.category : category,
method: method.trim(),
url: !site ? known?.url ?? "" : /^https?:\/\//.test(site) ? site : `https://${site}`,
notes: notes.trim(),
};
};
const submit = (again: boolean) => {
const s = build();
const p = Number.isNaN(s.amount) ? `Enter the price${minorDigits(currency) ? ", like 9.99" : ""}.` : problem(s);
if (p) {
setError(p);
window.setTimeout(() => formRef.current?.querySelector<HTMLElement>(".notice-error")?.scrollIntoView({ block: "nearest", behavior: "smooth" }), 0);
return;
}
setError(null);
const fresh = mode !== "edit" && getState().subs[s.id] ? { ...s, id: blank().id } : s;
saveSubs([fresh]);
toast(mode === "edit" ? "Saved" : `Added ${fresh.name}`);
onDone(fresh, again);
};
const sv = serviceNamed(name);
return (
<form ref={formRef} className="editor" noValidate onSubmit={(e) => { e.preventDefault(); submit(false); }}>
<div className="field">
<label htmlFor={`${ids}-name`}>Name</label>
<NameField inputId={`${ids}-name`} value={name} currency={currency} apps={apps}
onChange={(v) => { setName(v); setTypical(null); }} onPick={pick} />
{match && (
<p className="alt-note">
<span className="chip chip-free">Free alternative</span>{" "}
<a href={match.app.url} target="_blank" rel="noopener">{match.app.name} by OpenApps <Out size={13} className="inline-icon" /></a>
<span className="muted"> instead of {match.paid}</span>
</p>
)}
</div>
<div className="field">
<label htmlFor={`${ids}-price`}>Price</label>
<div className="price-row">
<span className="price-cur" aria-hidden="true">{currency}</span>
<input id={`${ids}-price`} className="input" inputMode="decimal" autoComplete="off" placeholder={minorDigits(currency) ? "9.99" : "1000"}
value={price} onChange={(e) => { setPrice(e.target.value); setTypical(null); }} aria-describedby={typical ? `${ids}-typical` : undefined} />
</div>
{typical && <small id={`${ids}-typical`} className="typical">Typical price: {typical}. Check yours, plans and countries differ.</small>}
</div>
<div className="field">
<span id={`${ids}-cycle`}>Billed</span>
<div className="segmented four" role="radiogroup" aria-labelledby={`${ids}-cycle`}>
{CYCLES.map((c) => (
<button key={c.v} type="button" role="radio" aria-checked={cycle === c.v} onClick={() => setCycle(c.v)}>{c.label}</button>
))}
</div>
{cycle === "custom" && (
<div className="inline-field">
<label htmlFor={`${ids}-every`}>Every</label>
<input id={`${ids}-every`} className="input every-input" inputMode="numeric" value={every} onChange={(e) => setEvery(e.target.value.replace(/\D/g, "").slice(0, 3))} />
<select className="input unit-select" aria-label="Days, weeks, months or years" value={unit} onChange={(e) => setUnit(e.target.value as Unit)}>
<option value="day">days</option><option value="week">weeks</option><option value="month">months</option><option value="year">years</option>
</select>
</div>
)}
</div>
<label className="check trial-check">
<input type="checkbox" checked={trial} onChange={(e) => setTrial(e.target.checked)} />
It is a free trial
</label>
<div className="field">
<label htmlFor={`${ids}-next`}>{trial ? "Trial ends on" : "Next renewal"}</label>
<input id={`${ids}-next`} className="input date-input" type="date" value={next} min="2000-01-01" max="2100-12-31"
onChange={(e) => { setNext(e.target.value); setDateTouched(true); }} />
<small>{trial ? "The day the first charge happens unless you cancel." : "Check your last receipt or the app store's subscription list for the date."}</small>
</div>
<div className="field">
<label htmlFor={`${ids}-cat`}>Category</label>
<select id={`${ids}-cat`} className="input" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
{Object.entries(CATEGORIES).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
</select>
</div>
{details ? (
<>
<div className="field">
<label htmlFor={`${ids}-method`}>Paid with</label>
<input id={`${ids}-method`} className="input" value={method} maxLength={MAX_METHOD} placeholder="Visa ending 4242, PayPal..." onChange={(e) => setMethod(e.target.value)} />
<small>A label to find it on your statement. Never your full card number.</small>
</div>
<div className="field">
<label htmlFor={`${ids}-url`}>Website</label>
<input id={`${ids}-url`} className="input" value={url} maxLength={MAX_URL} inputMode="url" placeholder="https://" onChange={(e) => setUrl(e.target.value)} />
{sv && url === sv.url && <small>Where you can manage or cancel it.</small>}
</div>
<div className="field">
<label htmlFor={`${ids}-notes`}>Notes</label>
<textarea id={`${ids}-notes`} className="textarea" value={notes} maxLength={MAX_NOTES} placeholder="Shared with family, student price ends in June..." onChange={(e) => setNotes(e.target.value)} />
</div>
</>
) : (
<button type="button" className="link-btn" onClick={() => setDetails(true)}>Add how you pay, the website and notes</button>
)}
{error && <p className="notice notice-error" role="alert">{error}</p>}
<div className="editor-actions">
<button type="submit" className="btn btn-lg">{mode === "edit" ? "Save changes" : "Add subscription"}</button>
{mode === "add" && <button type="button" className="btn btn-line btn-lg" onClick={() => submit(true)}>Add and start another</button>}
</div>
</form>
);
}
Change history
Point store links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync