subs-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 1ae4feb2bebf
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.
← All files
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
1ae4feb2be · 17 hours ago
Bind agent address in policy
99bbab3f0c · 19 hours ago
Use OpenApps service URLs and add contribution strip
781c3c23e5 · 19 hours ago
Bounded source sync
048e3257a5 · 1 day ago
Bounded source sync
7a358a3be6 · 1 day ago
Bounded source sync
0c833fcb0e · 1 day ago