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/Overview.tsx
// The overview: what you pay a month and a year, what is coming up in the next 30 days, free trials
// about to end, free alternatives from the OpenApps store, what you marked as barely used, spending by
// category, and calendar reminders.
import { useStore, listSubs, update } from "./lib/store";
import { active, CATEGORIES, cycleLabel, daysUntil, nextCharge, onTrial, perMonth, totals, upcoming, type Sub } from "./lib/subs";
import { useFreeApps, freeAlternative, STORE_URL, type Match } from "./lib/alternatives";
import { remindersIcs } from "./lib/ics";
import { money } from "./lib/money";
import { dayKey, fmtDay, relDay } from "./lib/dates";
import { navigate } from "./lib/router";
import { A, download } from "./ui";
import { QuickPicks } from "./Start";
import { Download, Leaf, Out, Plus } from "./icons";
export function LeadPicker() {
const { lead } = useStore();
return (
<label className="inline-field lead-field">
<span>Remind me</span>
<select className="input" value={lead} onChange={(e) => update((s) => { s.lead = Number(e.target.value); s.at = Date.now(); })}>
{[1, 2, 3, 5, 7, 14].map((d) => <option key={d} value={d}>{d} day{d === 1 ? "" : "s"}</option>)}
</select>
<span>before</span>
</label>
);
}
export function RemindersCard() {
const s = useStore();
const subs = listSubs(s).filter(active);
return (
<section className="card pad stack">
<h2 className="h3">Reminders in your calendar</h2>
<p className="muted small">Subs cannot send you reminders by itself: a web page cannot reliably wake your phone without a server sending pushes. So it puts them in your calendar, which can.</p>
<LeadPicker />
<button type="button" className="btn btn-line btn-sm self-start" disabled={!subs.length}
onClick={() => download("subscription-reminders.ics", remindersIcs(subs, s.lead, s.currency), "text/calendar")}>
<Download size={16} /> Download all reminders (.ics)
</button>
<p className="hint">Open the file to add one event per subscription, repeating with each renewal. Download again after changes: the events update instead of doubling. Each subscription's page also has a Google Calendar link.</p>
</section>
);
}
function Empty() {
return (
<div className="empty-start">
<img className="art empty-art" src="/art/card-subs.webp" alt="" width="220" height="175" />
<h1 className="h2">What do you pay for every month?</h1>
<p className="muted">Add your subscriptions to see the total, get reminders before they renew, and find free apps that do the same job.</p>
<QuickPicks onPick={(n) => navigate(`/add?name=${encodeURIComponent(n)}`)} />
<A className="btn btn-lg" href="/add"><Plus size={18} /> Add a subscription</A>
</div>
);
}
function Swaps({ matches, currency }: { matches: { sub: Sub; m: Match }[]; currency: string }) {
const yearly = matches.reduce((n, x) => n + perMonth(x.sub) * 12, 0);
return (
<section className="card pad stack swaps">
<div className="icon-title"><Leaf /><h2 className="h3">{matches.length ? `Free alternatives to ${matches.length} of yours` : "Free alternatives"}</h2></div>
{matches.length ? (
<>
<p className="muted small">These free apps from OpenApps do the same job. If one covers what you use, that is up to <strong>{money(yearly, currency)} a year</strong> back.</p>
<ul className="swap-list">
{matches.map(({ sub, m }) => (
<li key={sub.id}>
<div className="swap-from"><A href={`/s/${sub.id}`}>{sub.name}</A><span className="muted">{money(sub.amount, currency)} {cycleLabel(sub)}</span></div>
<a className="swap-to" href={m.app.url} target="_blank" rel="noopener">
<strong>{m.app.name} by OpenApps <Out size={13} className="inline-icon" /></strong>
<span>{m.app.tagline}</span>
</a>
</li>
))}
</ul>
</>
) : (
<p className="muted small">None of your subscriptions has a free alternative in the OpenApps store yet. It has free apps for e-signatures, forms, meditation, language lessons, budgeting and more.</p>
)}
<p className="small"><a href={`${STORE_URL}/alternatives`} target="_blank" rel="noopener">See every free alternative, A to Z</a> · <a href={`${STORE_URL}/request`} target="_blank" rel="noopener">Ask for one</a></p>
</section>
);
}
export function OverviewPage() {
const s = useStore();
const apps = useFreeApps();
const today = dayKey();
const subs = listSubs(s);
if (!subs.length) return <div className="page"><Empty /></div>;
const t = totals(subs, today);
const soon = upcoming(subs, 30, today);
const soonTotal = soon.reduce((n, u) => n + u.sub.amount, 0);
const trials = subs.filter((x) => onTrial(x, today)).sort((a, b) => a.next.localeCompare(b.next));
const unused = subs.filter((x) => active(x) && x.unused);
const unusedYear = unused.reduce((n, x) => n + perMonth(x) * 12, 0);
const matches = subs.filter(active).map((sub) => ({ sub, m: freeAlternative(sub.name, apps) })).filter((x): x is { sub: Sub; m: Match } => Boolean(x.m));
const maxCat = Math.max(1, ...t.byCategory.map((c) => c.month));
const cancelled = subs.filter((x) => x.cancelled);
return (
<div className="page overview">
<header className="page-head">
<div>
<p className="eyebrow">You pay</p>
<h1 className="h1 total"><span>{money(t.month, s.currency)}</span> a month</h1>
<p className="muted">{money(t.year, s.currency)} a year for {t.count} subscription{t.count === 1 ? "" : "s"}.
{t.trialCount > 0 && ` Your ${t.trialCount} free trial${t.trialCount === 1 ? "" : "s"} would add ${money(t.trialMonth, s.currency)} a month.`}</p>
</div>
<A className="btn head-add" href="/add"><Plus size={18} /> Add</A>
</header>
<div className="overview-grid">
<div className="stack col-main">
{trials.length > 0 && (
<section className="card pad stack">
<h2 className="h3">Free trials</h2>
<ul className="plain-list">
{trials.map((x) => {
const left = daysUntil(x.next, today);
return (
<li key={x.id} className={left <= 3 ? "urgent" : ""}>
<A href={`/s/${x.id}`}>{x.name}</A>
<span className="muted">Ends {relDay(x.next, today)}, {fmtDay(x.next, today, { month: "short", day: "numeric" })}. Then {money(x.amount, s.currency)} {cycleLabel(x)}.</span>
</li>
);
})}
</ul>
</section>
)}
<section className="card pad stack">
<div className="spread"><h2 className="h3">Next 30 days</h2>{soon.length > 0 && <span className="muted small">{money(soonTotal, s.currency)} in all</span>}</div>
{soon.length ? (
<ul className="soon-list">
{soon.slice(0, 12).map((u) => (
<li key={u.sub.id + u.date}>
<span className="soon-date"><strong>{fmtDay(u.date, today, { month: "short", day: "numeric" })}</strong><span>{relDay(u.date, today)}</span></span>
<A className="soon-name" href={`/s/${u.sub.id}`}>{u.sub.name}{u.trialEnds && <span className="chip chip-cobalt">Trial ends</span>}</A>
<span className="soon-amount">{money(u.sub.amount, s.currency)}</span>
</li>
))}
</ul>
) : <p className="muted small">Nothing renews in the next 30 days.</p>}
{soon.length > 12 && <p className="hint">And {soon.length - 12} more. <A href="/all">See all</A></p>}
</section>
<Swaps matches={matches} currency={s.currency} />
<section className="card pad stack">
<h2 className="h3">Review these</h2>
{unused.length ? (
<>
<p className="muted small">You marked these as barely used. Together they cost <strong>{money(unusedYear, s.currency)} a year</strong>.</p>
<ul className="plain-list">
{unused.map((x) => (
<li key={x.id}><A href={`/s/${x.id}`}>{x.name}</A><span className="muted">{money(perMonth(x) * 12, s.currency)} a year · renews {relDay(nextCharge(x, today), today)}</span></li>
))}
</ul>
</>
) : <p className="muted small">Not using something much? Open it and tap I barely use this. It shows up here with what it costs you a year.</p>}
</section>
</div>
<div className="stack col-side">
{t.byCategory.length > 0 && (
<section className="card pad stack">
<h2 className="h3">By category, per month</h2>
<ul className="cat-bars">
{t.byCategory.map((c) => (
<li key={c.category}>
<span className="cat-name">{CATEGORIES[c.category]}<span className="muted"> · {c.count}</span></span>
<span className="cat-amount">{money(c.month, s.currency)}</span>
<span className="cat-bar" aria-hidden="true"><span style={{ width: `${Math.max(2, (c.month / maxCat) * 100)}%` }} /></span>
</li>
))}
</ul>
</section>
)}
<RemindersCard />
{cancelled.length > 0 && (
<p className="hint">You cancelled {cancelled.length}, which saves {money(cancelled.reduce((n, x) => n + perMonth(x) * 12, 0), s.currency)} a year. <A href="/all">See them</A></p>
)}
</div>
</div>
</div>
);
}
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