From 4a693a83c99f120513ea193208bb645eedd04ac8 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 03:38:31 +0000 Subject: [PATCH] feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups --- src/lib/components/groups/GroupInput.svelte | 16 + src/lib/components/groups/Totals.svelte | 35 ++ src/lib/components/groups/object-groups.ts | 95 ++++ .../components/piggy-banks/MoneyEditor.svelte | 138 +++++ .../components/piggy-banks/PiggyForm.svelte | 282 ++++++++++ src/lib/components/piggy-banks/piggy.ts | 94 ++++ src/lib/components/shell/PageHeader.svelte | 2 +- .../components/subscriptions/BillForm.svelte | 261 +++++++++ .../subscriptions/PaidStatus.svelte | 20 + src/lib/components/subscriptions/Strip.svelte | 30 ++ src/lib/components/subscriptions/bills.ts | 176 ++++++ src/routes/bills/[...rest]/+page.ts | 6 + src/routes/groups/+page.svelte | 309 +++++++++++ src/routes/groups/delete/[id=id]/+page.ts | 5 + src/routes/groups/edit/[id=id]/+page.svelte | 158 ++++++ src/routes/piggy-banks/+page.svelte | 499 +++++++++++++++++ .../piggy-banks/add-money/[id=id]/+page.ts | 5 + src/routes/piggy-banks/create/+page.svelte | 11 + .../piggy-banks/delete/[id=id]/+page.ts | 5 + .../piggy-banks/edit/[id=id]/+page.svelte | 34 ++ .../piggy-banks/remove-money/[id=id]/+page.ts | 5 + .../piggy-banks/show/[id=id]/+page.svelte | 400 ++++++++++++++ src/routes/subscriptions/+page.svelte | 501 ++++++++++++++++++ src/routes/subscriptions/create/+page.svelte | 14 + .../subscriptions/delete/[id=id]/+page.ts | 5 + .../subscriptions/edit/[id=id]/+page.svelte | 34 ++ .../subscriptions/show/[id=id]/+page.svelte | 353 ++++++++++++ 27 files changed, 3492 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/groups/GroupInput.svelte create mode 100644 src/lib/components/groups/Totals.svelte create mode 100644 src/lib/components/groups/object-groups.ts create mode 100644 src/lib/components/piggy-banks/MoneyEditor.svelte create mode 100644 src/lib/components/piggy-banks/PiggyForm.svelte create mode 100644 src/lib/components/piggy-banks/piggy.ts create mode 100644 src/lib/components/subscriptions/BillForm.svelte create mode 100644 src/lib/components/subscriptions/PaidStatus.svelte create mode 100644 src/lib/components/subscriptions/Strip.svelte create mode 100644 src/lib/components/subscriptions/bills.ts create mode 100644 src/routes/bills/[...rest]/+page.ts create mode 100644 src/routes/groups/+page.svelte create mode 100644 src/routes/groups/delete/[id=id]/+page.ts create mode 100644 src/routes/groups/edit/[id=id]/+page.svelte create mode 100644 src/routes/piggy-banks/+page.svelte create mode 100644 src/routes/piggy-banks/add-money/[id=id]/+page.ts create mode 100644 src/routes/piggy-banks/create/+page.svelte create mode 100644 src/routes/piggy-banks/delete/[id=id]/+page.ts create mode 100644 src/routes/piggy-banks/edit/[id=id]/+page.svelte create mode 100644 src/routes/piggy-banks/remove-money/[id=id]/+page.ts create mode 100644 src/routes/piggy-banks/show/[id=id]/+page.svelte create mode 100644 src/routes/subscriptions/+page.svelte create mode 100644 src/routes/subscriptions/create/+page.svelte create mode 100644 src/routes/subscriptions/delete/[id=id]/+page.ts create mode 100644 src/routes/subscriptions/edit/[id=id]/+page.svelte create mode 100644 src/routes/subscriptions/show/[id=id]/+page.svelte diff --git a/src/lib/components/groups/GroupInput.svelte b/src/lib/components/groups/GroupInput.svelte new file mode 100644 index 0000000..5d98591 --- /dev/null +++ b/src/lib/components/groups/GroupInput.svelte @@ -0,0 +1,16 @@ + + + + + {#each groups.data ?? [] as group (group.id)} + + {/each} + diff --git a/src/lib/components/groups/Totals.svelte b/src/lib/components/groups/Totals.svelte new file mode 100644 index 0000000..5cd4a84 --- /dev/null +++ b/src/lib/components/groups/Totals.svelte @@ -0,0 +1,35 @@ + + +{#each totals as total (total.code)} +
+ {#if tone === "plain"} + {formatMoney(total)} + {:else} + + {/if} +
+{:else} + {#if empty} + {empty} + {/if} +{/each} diff --git a/src/lib/components/groups/object-groups.ts b/src/lib/components/groups/object-groups.ts new file mode 100644 index 0000000..5f95a4b --- /dev/null +++ b/src/lib/components/groups/object-groups.ts @@ -0,0 +1,95 @@ +import { queryClient } from "#lib/api/lists.js"; +import type { Item } from "#lib/api/types.js"; +import type { Money } from "#lib/format.js"; +import { toNumber } from "#lib/format.js"; + +interface Groupable { + object_group_id?: string | null; + object_group_order?: number | null; + object_group_title?: string | null; + order?: number; +} + +export interface Section { + id: string; + items: T[]; + order: number; + title: string; +} + +export const UNGROUPED = "(ungrouped)"; + +export function byObjectGroup( + items: Item[] +): Section>[] { + const sections = new Map>>(); + for (const item of items) { + const { object_group_id, object_group_order, object_group_title } = + item.attributes; + const id = object_group_id ?? ""; + let section = sections.get(id); + if (!section) { + section = { + id, + items: [], + order: id ? (object_group_order ?? 0) : -1, + title: object_group_title ?? UNGROUPED, + }; + sections.set(id, section); + } + section.items.push(item); + } + const list = [...sections.values()].sort((a, b) => a.order - b.order); + for (const section of list) { + section.items.sort( + (a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0) + ); + } + return list; +} + +export interface Total { + amount: number; + code: string; + decimals: number; + symbol: string; +} + +export function sumByCurrency(entries: Money[]): Total[] { + const totals = new Map(); + for (const entry of entries) { + const code = entry.code ?? ""; + const total = totals.get(code) ?? { + amount: 0, + code, + decimals: entry.decimals ?? 2, + symbol: entry.symbol ?? code, + }; + total.amount += toNumber(entry.amount); + totals.set(code, total); + } + return [...totals.values()]; +} + +export const LINK = "text-brand hover:underline dark:text-(--ff-series-1)"; + +export function swapOrder( + key: readonly unknown[], + a: Item, + b: Item +): number | undefined { + const { order: first } = a.attributes; + const { order: second } = b.attributes; + queryClient.setQueryData[]>(key, (old) => + old?.map((item) => { + if (item.id === a.id) { + return { ...item, attributes: { ...item.attributes, order: second } }; + } + if (item.id === b.id) { + return { ...item, attributes: { ...item.attributes, order: first } }; + } + return item; + }) + ); + return second; +} diff --git a/src/lib/components/piggy-banks/MoneyEditor.svelte b/src/lib/components/piggy-banks/MoneyEditor.svelte new file mode 100644 index 0000000..158fde8 --- /dev/null +++ b/src/lib/components/piggy-banks/MoneyEditor.svelte @@ -0,0 +1,138 @@ + + +
{ + e.preventDefault(); + save(); + }} +> + + {mode === "add" ? "Add money to" : "Remove money from"} + {piggy.attributes.name} + + {#each open as link, i (link.id)} +
+ {#if open.length > 1} + {link.name} + {/if} + { + if (i === 0) { + node.focus(); + } + }} + /> + + up to + + +
+ {:else} + + {mode === "add" + ? "You have no money to put in this piggy bank." + : "There is no money in this piggy bank."} + + {/each} + {#if open.length > 0} + + {/if} + + + diff --git a/src/lib/components/piggy-banks/PiggyForm.svelte b/src/lib/components/piggy-banks/PiggyForm.svelte new file mode 100644 index 0000000..2dc9a03 --- /dev/null +++ b/src/lib/components/piggy-banks/PiggyForm.svelte @@ -0,0 +1,282 @@ + + +
{ + e.preventDefault(); + save(); + }} +> +
+ + + + + + + + + + + + k.startsWith("accounts.") + )?.[1][0]} + > +
+ {#each accounts as account (account.id)} + {@const on = account.id in linked} +
+ + {#if on} + + {/if} +
+ {:else} +

+ {assets.isPending + ? "Loading accounts" + : "No asset account or liability uses this currency."} +

+ {/each} +
+
+
+ + {#if piggy} + + + + {/if} + + + + + + + + + + +
+
+ + + {#if piggy} + Delete + {/if} +
+
diff --git a/src/lib/components/piggy-banks/piggy.ts b/src/lib/components/piggy-banks/piggy.ts new file mode 100644 index 0000000..25244a3 --- /dev/null +++ b/src/lib/components/piggy-banks/piggy.ts @@ -0,0 +1,94 @@ +import { api } from "#lib/api/client.js"; +import type { Account, Item, One, PiggyBank } from "#lib/api/types.js"; +import type { Money } from "#lib/format.js"; +import { toNumber } from "#lib/format.js"; + +export type Mode = "add" | "remove"; + +export interface Room { + account: Item | undefined; + balance: number; + earmarked: number; + free: number; +} + +export function rooms( + piggies: Item[], + accounts: Item[] +): Map { + const byId = new Map(accounts.map((a) => [a.id, a])); + const result = new Map(); + for (const piggy of piggies) { + for (const link of piggy.attributes.accounts ?? []) { + const id = link.account_id ?? ""; + const account = byId.get(id); + const room = result.get(id) ?? { + account, + balance: toNumber(account?.attributes.current_balance), + earmarked: 0, + free: 0, + }; + room.earmarked += toNumber(link.current_amount); + room.free = room.balance - room.earmarked; + result.set(id, room); + } + } + return result; +} + +export function limit( + piggy: PiggyBank, + accountId: string, + mode: Mode, + room: Room | undefined +): number { + const link = piggy.accounts?.find((a) => a.account_id === accountId); + if (mode === "remove") { + return toNumber(link?.current_amount); + } + const free = Math.max(0, room?.free ?? 0); + const target = toNumber(piggy.target_amount); + return target > 0 ? Math.min(free, toNumber(piggy.left_to_save)) : free; +} + +export const piggyMoney = (piggy: PiggyBank, amount: unknown): Money => ({ + amount: amount as string, + code: piggy.currency_code, + decimals: piggy.currency_decimal_places, + symbol: piggy.currency_symbol, +}); + +export async function changeMoney( + piggy: Item, + deltas: Record +): Promise> { + const decimals = piggy.attributes.currency_decimal_places ?? 2; + const accounts = (piggy.attributes.accounts ?? []).map((link) => ({ + account_id: link.account_id, + current_amount: ( + toNumber(link.current_amount) + (deltas[link.account_id ?? ""] ?? 0) + ).toFixed(decimals), + })); + const saved = await api>(`/v1/piggy-banks/${piggy.id}`, { + body: { accounts }, + method: "PUT", + }); + const after = new Map( + (saved.data.attributes.accounts ?? []).map((a) => [ + a.account_id, + toNumber(a.current_amount), + ]) + ); + const refused = accounts.find( + (a) => + !after.has(a.account_id) || + Math.abs((after.get(a.account_id) ?? 0) - toNumber(a.current_amount)) > + 10 ** -decimals / 2 + ); + if (refused) { + throw new Error( + "Firefly kept the old amount: there is not enough money on the account" + ); + } + return saved.data; +} diff --git a/src/lib/components/shell/PageHeader.svelte b/src/lib/components/shell/PageHeader.svelte index 95021aa..ba3b3fd 100644 --- a/src/lib/components/shell/PageHeader.svelte +++ b/src/lib/components/shell/PageHeader.svelte @@ -34,7 +34,7 @@ {subtitle} {/if} -
+
{@render actions?.()}
    + import { untrack } from "svelte"; + import { api } from "#lib/api/client.js"; + import { invalidate } from "#lib/api/lists.js"; + import type { Bill, Item, One } from "#lib/api/types.js"; + import Box from "#lib/components/Box.svelte"; + import ConfirmButton from "#lib/components/ConfirmButton.svelte"; + import GroupInput from "#lib/components/groups/GroupInput.svelte"; + import { amountString } from "#lib/components/inputs/parse.js"; + import { Action } from "#lib/components/misc/action.svelte.js"; + import Field from "#lib/components/misc/Field.svelte"; + import Status from "#lib/components/misc/Status.svelte"; + import { select, textarea } from "#lib/components/misc/styles.js"; + import { Button } from "#lib/components/ui/button/index.js"; + import { Input } from "#lib/components/ui/input/index.js"; + import { today } from "#lib/dates.js"; + import { prettyKeys, useKeys } from "#lib/keys.svelte.js"; + import { session } from "#lib/session.svelte.js"; + import { goto } from "$app/navigation"; + import { FREQUENCIES, FREQUENCY_LABELS } from "./bills.js"; + + let { bill }: { bill?: Item } = $props(); + + const initial = untrack(() => bill?.attributes); + const form = $state({ + active: initial?.active ?? true, + amount_max: initial?.amount_max ?? "", + amount_min: initial?.amount_min ?? "", + currency_code: initial?.currency_code ?? session.primary?.code ?? "", + date: initial?.date?.slice(0, 10) ?? today(), + end_date: initial?.end_date?.slice(0, 10) ?? "", + extension_date: initial?.extension_date?.slice(0, 10) ?? "", + name: initial?.name ?? "", + notes: initial?.notes ?? "", + object_group_title: initial?.object_group_title ?? "", + repeat_freq: initial?.repeat_freq ?? "monthly", + skip: String(initial?.skip ?? 0), + }); + + const action = new Action(); + const currencies = $derived( + session.currencies.filter( + (c) => c.attributes.enabled || c.attributes.code === form.currency_code + ) + ); + const decimals = $derived( + currencies.find((c) => c.attributes.code === form.currency_code)?.attributes + .decimal_places ?? 2 + ); + + function body() { + const min = amountString(form.amount_min, decimals); + return { + active: form.active, + amount_max: amountString(form.amount_max, decimals) || min, + amount_min: min, + currency_code: + form.currency_code === initial?.currency_code + ? undefined + : form.currency_code, + date: form.date, + end_date: form.end_date || undefined, + extension_date: form.extension_date || undefined, + name: form.name.trim(), + notes: form.notes.trim() || null, + object_group_title: + form.object_group_title.trim() || (bill ? "" : undefined), + repeat_freq: form.repeat_freq, + skip: Number(form.skip) || 0, + }; + } + + async function save() { + let id = bill?.id; + const ok = await action.run(async () => { + const saved = await api>( + bill ? `/v1/bills/${bill.id}` : "/v1/bills", + { body: body(), method: bill ? "PUT" : "POST" } + ); + ({ id } = saved.data); + }); + if (ok) { + await invalidate(); + await goto(`/subscriptions/show/${id}`); + } + } + + async function remove() { + if (!bill) { + return; + } + const ok = await action.run(() => + api(`/v1/bills/${bill.id}`, { method: "DELETE" }) + ); + if (ok) { + await goto("/subscriptions"); + await invalidate(); + } + } + + useKeys(() => [ + { + group: "Form", + input: true, + keys: "mod+Enter", + label: bill ? "Update subscription" : "Store new subscription", + run: save, + }, + ]); + + +
    { + e.preventDefault(); + save(); + }} +> +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {#if bill} + + + + {/if} + +
    +
    + + + {#if bill} + Delete + {/if} +
    +
    diff --git a/src/lib/components/subscriptions/PaidStatus.svelte b/src/lib/components/subscriptions/PaidStatus.svelte new file mode 100644 index 0000000..70f51d2 --- /dev/null +++ b/src/lib/components/subscriptions/PaidStatus.svelte @@ -0,0 +1,20 @@ + + +{#if state.kind === "paid"} + + {#each state.paid as paid (paid.transaction_journal_id)} + ✓ {formatDate(paid.date, "short")} + {/each} + +{:else} + {statusText(state)} +{/if} diff --git a/src/lib/components/subscriptions/Strip.svelte b/src/lib/components/subscriptions/Strip.svelte new file mode 100644 index 0000000..bb13bac --- /dev/null +++ b/src/lib/components/subscriptions/Strip.svelte @@ -0,0 +1,30 @@ + + + + {#each cells as cell, i (months[i])} + + {/each} + diff --git a/src/lib/components/subscriptions/bills.ts b/src/lib/components/subscriptions/bills.ts new file mode 100644 index 0000000..fe17fd8 --- /dev/null +++ b/src/lib/components/subscriptions/bills.ts @@ -0,0 +1,176 @@ +import type { Bill, Item } from "#lib/api/types.js"; +import { addMonths, daysBetween, type Range, today } from "#lib/dates.js"; +import type { Money } from "#lib/format.js"; +import { toNumber } from "#lib/format.js"; + +export const FREQUENCIES = [ + "weekly", + "monthly", + "quarterly", + "half-year", + "yearly", +] as const; +export type Frequency = (typeof FREQUENCIES)[number]; + +const PER_YEAR: Record = { + "half-year": 2, + monthly: 12, + quarterly: 4, + weekly: 52.17, + yearly: 1, +}; + +const MONTHS_PER: Record = { + "half-year": 6, + monthly: 1, + quarterly: 3, + weekly: 0, + yearly: 12, +}; + +const UNIT: Record = { + "half-year": ["every half year", "half years"], + monthly: ["monthly", "months"], + quarterly: ["quarterly", "quarters"], + weekly: ["weekly", "weeks"], + yearly: ["yearly", "years"], +}; + +export const FREQUENCY_LABELS: Record = { + "half-year": "Every half year", + monthly: "Monthly", + quarterly: "Quarterly", + weekly: "Weekly", + yearly: "Yearly", +}; + +const freq = (bill: Bill): Frequency => + (bill.repeat_freq ?? "monthly") as Frequency; + +export const average = (bill: Bill) => + (toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2; + +export const timesPerYear = (bill: Bill) => + PER_YEAR[freq(bill)] / ((bill.skip ?? 0) + 1); + +export function repeats(bill: Bill): string { + const [single, plural] = UNIT[freq(bill)]; + const every = (bill.skip ?? 0) + 1; + return every > 1 ? `every ${every} ${plural}` : single; +} + +export const money = (bill: Bill, amount: number): Money => ({ + amount, + code: bill.currency_code, + decimals: bill.currency_decimal_places, + symbol: bill.currency_symbol, +}); + +export const monthly = (bill: Bill) => + money(bill, (average(bill) * timesPerYear(bill)) / 12); + +export const yearly = (bill: Bill) => + money(bill, average(bill) * timesPerYear(bill)); + +export const dueIn = (range: Range, bill: Bill) => + (bill.pay_dates ?? []) + .map((date) => date.slice(0, 10)) + .filter((date) => date >= range.start && date <= range.end); + +export type Status = + | { kind: "inactive" } + | { kind: "paid"; paid: NonNullable } + | { kind: "due" | "overdue"; date: string; days: number } + | { kind: "none" }; + +export function status(bill: Bill, range: Range): Status { + if (!bill.active) { + return { kind: "inactive" }; + } + const paid = bill.paid_dates ?? []; + if (paid.length > 0) { + return { kind: "paid", paid }; + } + const [date] = dueIn(range, bill); + if (!date) { + return { kind: "none" }; + } + const days = daysBetween(today(), date); + return { date, days, kind: days < 0 ? "overdue" : "due" }; +} + +export function statusText(state: Status): string { + switch (state.kind) { + case "inactive": + return "Inactive"; + case "none": + return "Not expected this period"; + case "overdue": + return `Overdue ${-state.days} ${-state.days === 1 ? "day" : "days"}`; + case "due": + if (state.days === 0) { + return "Due today"; + } + return `Due in ${state.days} ${state.days === 1 ? "day" : "days"}`; + default: + return "Paid"; + } +} + +export const STATUS_TONE: Record = { + due: "text-warn", + inactive: "text-cladd-fg-softer", + none: "text-cladd-fg-softer", + overdue: "text-expense", + paid: "text-income", +}; + +export const nextExpected = (bill: Bill) => + bill.pay_dates?.[0]?.slice(0, 10) ?? bill.next_expected_match ?? null; + +export type Cell = "paid" | "missed" | "due" | "off" | "idle"; + +export function expectedIn(bill: Bill, month: string): boolean { + const start = (bill.date ?? "").slice(0, 7); + const end = bill.end_date?.slice(0, 7); + if (!start || month < start || (end && month > end)) { + return false; + } + const step = MONTHS_PER[freq(bill)] * ((bill.skip ?? 0) + 1); + if (step === 0) { + return true; + } + const [y1, m1] = start.split("-").map(Number); + const [y2, m2] = month.split("-").map(Number); + return ((y2 - y1) * 12 + (m2 - m1)) % step === 0; +} + +export function lastMonths(end: string, count = 12): string[] { + const first = addMonths(`${end.slice(0, 7)}-01`, 1 - count); + return Array.from({ length: count }, (_, i) => + addMonths(first, i).slice(0, 7) + ); +} + +export function strip(bill: Bill, months: string[]): Cell[] { + const paid = new Set((bill.paid_dates ?? []).map((p) => p.date?.slice(0, 7))); + const day = today(); + const now = day.slice(0, 7); + const late = (bill.pay_dates ?? []).some( + (date) => date.slice(0, 7) === now && date.slice(0, 10) < day + ); + return months.map((month) => { + if (paid.has(month)) { + return "paid"; + } + if (!(bill.active && expectedIn(bill, month))) { + return "off"; + } + if (month > now) { + return "idle"; + } + return month === now && !late ? "due" : "missed"; + }); +} + +export const billKey = (item: Item) => item.id; diff --git a/src/routes/bills/[...rest]/+page.ts b/src/routes/bills/[...rest]/+page.ts new file mode 100644 index 0000000..5af0207 --- /dev/null +++ b/src/routes/bills/[...rest]/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params, url }) { + const rest = params.rest ? `/${params.rest}` : ""; + redirect(307, `/subscriptions${rest}${url.search}`); +} diff --git a/src/routes/groups/+page.svelte b/src/routes/groups/+page.svelte new file mode 100644 index 0000000..00c6a38 --- /dev/null +++ b/src/routes/groups/+page.svelte @@ -0,0 +1,309 @@ + + + + + + {#snippet tools()} + {#if failure} + {failure} + {/if} + {/snippet} + {#if groups.isPending} +

    Loading groups

    + {:else if rows.length === 0} +

    + Some things in Firefly III can be divided into groups. Piggy banks and + subscriptions have a "Group" field on their edit page. Give them a group + and it shows up here. +

    + {:else} + open || (armed = false)}> + + + + + + + + + + + {#each rows as group, i (group.id)} + {@const kinds = members.get(group.id) ?? {}} + nav.click(i, e)} + oncontextmenu={() => { + menuFor = group; + nav.cursor = i; + }} + > + + + + + {/each} + +
    #TitleMembers
    + {i + 1} + + {#if renaming === group.id} +
    { + e.preventDefault(); + saveTitle(group); + }} + > + (renaming = null)} + onkeydown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + renaming = null; + } + }} + bind:value={title} + {@attach (node) => node.focus()} + /> +
    + {:else} + + {/if} +
    + {#each Object.entries(kinds) as [kind, list] (kind)} +
    + {kind}: + {#each list as member, m (member.id)} + {#if m > 0} + , + {/if} + {member.name} + {/each} +
    + {:else} + Empty + {/each} +
    +
    + + {#if menuFor} + {@const target = menuFor} + rename(target)} + >Rename + goto(`/groups/edit/${target.id}`)} + >Edit + + location.assign(oldUiUrl(`${location.origin}/groups`))} + >Open in old UI + + { + if (!armed) { + e.preventDefault(); + armed = true; + return; + } + run(() => + api(`/v1/object-groups/${target.id}`, { method: "DELETE" }) + ); + }} + variant="destructive" + >{armed ? "Press again to delete" : "Delete"} + {/if} + +
    + {/if} +
    diff --git a/src/routes/groups/delete/[id=id]/+page.ts b/src/routes/groups/delete/[id=id]/+page.ts new file mode 100644 index 0000000..82fa83b --- /dev/null +++ b/src/routes/groups/delete/[id=id]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params }) { + redirect(307, `/groups/edit/${params.id}`); +} diff --git a/src/routes/groups/edit/[id=id]/+page.svelte b/src/routes/groups/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..3b3c56b --- /dev/null +++ b/src/routes/groups/edit/[id=id]/+page.svelte @@ -0,0 +1,158 @@ + + + + +{#if group.error} +

    {group.error.message}

    +{:else if !group.data} +

    Loading group

    +{:else} +
    +
    { + e.preventDefault(); + save(); + }} + > + + + (title = e.currentTarget.value)} + required + {value} + /> + + {#snippet footer()} + + + Delete + {/snippet} + +
    + + {@const billList = bills.data?.data ?? []} + {@const piggyList = piggies.data?.data ?? []} + {#if billList.length > 0} +

    + Subscriptions +

    + + {/if} + {#if piggyList.length > 0} +

    + Piggy banks +

    + + {/if} + {#if billList.length === 0 && piggyList.length === 0} +

    + {bills.isPending ? "Loading members" : "Nothing is in this group."} +

    + {/if} +
    +
    +{/if} diff --git a/src/routes/piggy-banks/+page.svelte b/src/routes/piggy-banks/+page.svelte new file mode 100644 index 0000000..12f877a --- /dev/null +++ b/src/routes/piggy-banks/+page.svelte @@ -0,0 +1,499 @@ + + + + {#snippet actions()} + + Create new piggy bankc + + {/snippet} + + + + {#snippet tools()} + {#if failure} + {failure} + {/if} + {/snippet} + {#if piggies.isPending} +

    Loading piggy banks

    + {:else if piggies.error} +

    {piggies.error.message}

    + {:else if rows.length === 0} +

    + No piggy banks yet. + Create a piggy bank +

    + {:else} + open || (armed = false)}> + +
    + + + + + + + + + + + + + {#each sections as section, s (section.id)} + + + + + + + + + + + {#each section.items as item, j (item.id)} + {@const i = offsets[s] + j} + {@const p = item.attributes} + {@const pct = p.percentage} + nav.click(i, e)} + oncontextmenu={() => { + menuFor = item; + nav.cursor = i; + }} + > + + + + + + + + + + + {#if editing?.id === item.id} + + + + {/if} + {/each} + + {/each} +
    Piggy bankSaved so farTarget amountLeft to save
    + {section.title} + {section.items.length} + + + + + + +
    + {p.name} + + + + + + {#if pct !== null && pct !== undefined} +
    + = 100 ? tones.income : undefined, + value: toNumber(p.current_amount), + }, + ]} + /> + {percent(pct / 100)} +
    + {/if} +
    + + + {#if toNumber(p.target_amount) > 0} + {formatMoney(piggyMoney(p, p.target_amount))} + {/if} + + {#if toNumber(p.left_to_save) > 0} + {formatMoney(piggyMoney(p, p.left_to_save))} + {/if} +
    + (editing = null)} + piggy={item} + rooms={room} + /> +
    +
    +
    + + {#if menuFor} + {@const target = menuFor} + goto(`/piggy-banks/show/${target.id}`)} + >Show + goto(`/piggy-banks/edit/${target.id}`)} + >Edit + startMoney(target, "add")} + >Add money + startMoney(target, "remove")} + >Remove money + + + location.assign( + oldUiUrl(`${location.origin}/piggy-banks/show/${target.id}`) + )} + >Open in old UI + + { + if (!armed) { + e.preventDefault(); + armed = true; + return; + } + remove(target); + }} + variant="destructive" + >{armed ? "Press again to delete" : "Delete"} + {/if} + +
    + {/if} + {#snippet footer()} + Create new piggy bank + {/snippet} +
    + +{#if room.size > 0} + +
    + + + + + + + + + + + + + {#each [...room] as [id, r] (id)} + {@const linked = all.filter((p) => + p.attributes.accounts?.some((a) => a.account_id === id) + )} + {@const a = r.account?.attributes} + {@const money = { + code: a?.currency_code, + decimals: a?.currency_decimal_places, + symbol: a?.currency_symbol, + }} + {@const targets = linked.reduce( + (t, p) => t + toNumber(p.attributes.target_amount), + 0 + )} + + + + + + + + + {/each} + +
    AccountBalanceLeft for piggy banksSum of piggy banksSaved so farLeft to save
    + {a?.name ?? id} + + + + + + {formatMoney({ ...money, amount: targets })} + + + + {formatMoney({ + ...money, + amount: Math.max(0, targets - r.earmarked), + })} +
    +
    +
    +{/if} diff --git a/src/routes/piggy-banks/add-money/[id=id]/+page.ts b/src/routes/piggy-banks/add-money/[id=id]/+page.ts new file mode 100644 index 0000000..1c5c3f8 --- /dev/null +++ b/src/routes/piggy-banks/add-money/[id=id]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params }) { + redirect(307, `/piggy-banks/show/${params.id}?money=add`); +} diff --git a/src/routes/piggy-banks/create/+page.svelte b/src/routes/piggy-banks/create/+page.svelte new file mode 100644 index 0000000..c60a8ac --- /dev/null +++ b/src/routes/piggy-banks/create/+page.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/src/routes/piggy-banks/delete/[id=id]/+page.ts b/src/routes/piggy-banks/delete/[id=id]/+page.ts new file mode 100644 index 0000000..4c82251 --- /dev/null +++ b/src/routes/piggy-banks/delete/[id=id]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params }) { + redirect(307, `/piggy-banks/edit/${params.id}`); +} diff --git a/src/routes/piggy-banks/edit/[id=id]/+page.svelte b/src/routes/piggy-banks/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..fc6b0b9 --- /dev/null +++ b/src/routes/piggy-banks/edit/[id=id]/+page.svelte @@ -0,0 +1,34 @@ + + + + +{#if piggy.data} + {#key id} + + {/key} +{:else if piggy.error} +

    {piggy.error.message}

    +{:else} +

    Loading piggy bank

    +{/if} diff --git a/src/routes/piggy-banks/remove-money/[id=id]/+page.ts b/src/routes/piggy-banks/remove-money/[id=id]/+page.ts new file mode 100644 index 0000000..4a4c81c --- /dev/null +++ b/src/routes/piggy-banks/remove-money/[id=id]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params }) { + redirect(307, `/piggy-banks/show/${params.id}?money=remove`); +} diff --git a/src/routes/piggy-banks/show/[id=id]/+page.svelte b/src/routes/piggy-banks/show/[id=id]/+page.svelte new file mode 100644 index 0000000..ff9c42b --- /dev/null +++ b/src/routes/piggy-banks/show/[id=id]/+page.svelte @@ -0,0 +1,400 @@ + + + + {#snippet actions()} + Open in old UI + Edit e + Delete + {/snippet} + + +{#if query.error} +

    {query.error.message}

    +{:else if !(piggy && item)} +

    Loading piggy bank

    +{:else} + {@const target = toNumber(piggy.target_amount)} +
    +
    + + {#if events.isPending} +

    Loading events

    + {:else} + formatMoney(piggyMoney(piggy, v))} + height={260} + label="Saved over time" + series={[ + { + color: tones.income, + format: (v) => formatMoney(piggyMoney(piggy, v)), + key: "saved", + label: "Saved so far", + points, + }, + ]} + toggle={false} + zero + /> + {/if} +
    + {#if piggy.notes} + +

    {piggy.notes}

    +
    + {/if} +
    +
    + + + + + + + + + + + + + + + + {#if (piggy.accounts ?? []).length > 1} + {#each piggy.accounts ?? [] as account (account.account_id)} + + + + + {/each} + {/if} + + + + + {#if target > 0} + + + + + {/if} + + + + + + + + + {#if piggy.target_date && toNumber(piggy.save_per_month) > 0} + + + + + {/if} + +
    Save on account(s) + {#each piggy.accounts ?? [] as account, i (account.account_id)} + {#if i > 0} + , + {/if} + {account.name} + {/each} +
    Group + {#if piggy.object_group_id} + {piggy.object_group_title} + {:else} + No group + {/if} +
    Target amount + {target > 0 + ? formatMoney(piggyMoney(piggy, piggy.target_amount)) + : "No target"} +
    Saved so far ({account.name}) + +
    Saved so far + +
    Left to save + {formatMoney(piggyMoney(piggy, piggy.left_to_save))} +
    Start date + {formatDate(piggy.start_date) || "No start date"} +
    Target date + {formatDate(piggy.target_date) || "No target date"} +
    Suggested monthly amount to save + {formatMoney(piggyMoney(piggy, piggy.save_per_month))} +
    + {#if target > 0 && + piggy.percentage !== null && + piggy.percentage !== undefined} +
    + + {percent(piggy.percentage / 100)} +
    + {/if} + {#snippet footer()} + {#if mode} + setMode(null)} + piggy={item} + rooms={room} + /> + {:else} + + + {/if} + {/snippet} +
    + + {#if history.length > 0} + + + {#each history as entry (entry.id)} + {@const amount = toNumber(entry.attributes.amount)} + + + + + {/each} + +
    + {#if entry.attributes.transaction_group_id} + {formatDate(when(entry))} + {:else} + {formatDateTime(entry.attributes.created_at)} + {/if} + + {amount < 0 ? "Removed" : "Added"} + {formatMoney(piggyMoney(piggy, Math.abs(amount)))} +
    + {:else} +

    + {events.isPending ? "Loading events" : "No events yet."} +

    + {/if} +
    +
    +
    +{/if} diff --git a/src/routes/subscriptions/+page.svelte b/src/routes/subscriptions/+page.svelte new file mode 100644 index 0000000..3188b85 --- /dev/null +++ b/src/routes/subscriptions/+page.svelte @@ -0,0 +1,501 @@ + + + + {#snippet actions()} + + Create new subscriptionc + + {/snippet} + + +
    + {#each [ + { label: "Expected this period", totals: expected }, + { label: "Paid", totals: paid }, + { label: "Still to pay", totals: unpaid }, + ] as stat (stat.label)} + +
    + +
    +
    + {/each} +
    + + + {#snippet tools()} + {#if failure} + {failure} + {/if} + {period.label} + + {/snippet} + {#if bills.isPending} +

    Loading subscriptions

    + {:else if bills.error} +

    {bills.error.message}

    + {:else if rows.length === 0} +

    + No subscriptions yet. + Create a subscription +

    + {:else} + open || (armed = false)}> + +
    + + + + + + + + + + + + + {#each sections as section, s (section.id)} + {@const monthTotals = perMonth(section.items)} + + + + + + + + + + + {#each section.items as item, j (item.id)} + {@const i = offsets[s] + j} + {@const bill = item.attributes} + {@const ends = bill.end_date?.slice(0, 10)} + nav.click(i, e)} + oncontextmenu={() => { + menuFor = item; + nav.cursor = i; + }} + > + + + + + + + + + {/each} + + {/each} + {#if sections.length > 1} + + + + + + + + + + + + {/if} +
    NameAmountPaid this period
    + {section.title} + {section.items.length} + + + + {monthTotals.length > 0 ? "a month" : ""} +
    + + {#if !bill.active} + + {/if} + {bill.name} + + + + {#if bill.amount_max !== bill.amount_min} + – + + {/if} + + +
    + Total, active subscriptions + + + a month
    +
    +
    + + {#if menuFor} + {@const target = menuFor} + show(target)}>Show + edit(target)}>Edit + + Move to group + + {#each groups.data ?? [] as group (group.id)} + + update(target, { + object_group_title: group.attributes.title, + })} + >{group.attributes.title} + {/each} + + update(target, { object_group_title: "" })} + >No group + + + + + location.assign(`/rules/create-from-bill/${target.id}`)} + >Create rule from subscription + + location.assign( + oldUiUrl(`${location.origin}/subscriptions/show/${target.id}`) + )} + >Open in old UI + + { + if (!armed) { + e.preventDefault(); + armed = true; + return; + } + remove(target); + }} + variant="destructive" + >{armed ? "Press again to delete" : "Delete"} + {/if} + +
    + {/if} + {#snippet footer()} + Create new subscription + {/snippet} +
    diff --git a/src/routes/subscriptions/create/+page.svelte b/src/routes/subscriptions/create/+page.svelte new file mode 100644 index 0000000..e7044c7 --- /dev/null +++ b/src/routes/subscriptions/create/+page.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/routes/subscriptions/delete/[id=id]/+page.ts b/src/routes/subscriptions/delete/[id=id]/+page.ts new file mode 100644 index 0000000..64a61c4 --- /dev/null +++ b/src/routes/subscriptions/delete/[id=id]/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from "@sveltejs/kit"; + +export function load({ params }) { + redirect(307, `/subscriptions/edit/${params.id}`); +} diff --git a/src/routes/subscriptions/edit/[id=id]/+page.svelte b/src/routes/subscriptions/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..ae67a00 --- /dev/null +++ b/src/routes/subscriptions/edit/[id=id]/+page.svelte @@ -0,0 +1,34 @@ + + + + +{#if bill.data} + {#key id} + + {/key} +{:else if bill.error} +

    {bill.error.message}

    +{:else} +

    Loading subscription

    +{/if} diff --git a/src/routes/subscriptions/show/[id=id]/+page.svelte b/src/routes/subscriptions/show/[id=id]/+page.svelte new file mode 100644 index 0000000..53cad7f --- /dev/null +++ b/src/routes/subscriptions/show/[id=id]/+page.svelte @@ -0,0 +1,353 @@ + + + + {#snippet actions()} + Edit E + {/snippet} + + +{#if query.error} +

    {query.error.message}

    +{:else if !bill} +

    Loading subscription

    +{:else} +
    + + + + + + + + + + + + + + + + + + + + + + + {#if bill.end_date} + + + + + {/if} + {#if bill.extension_date} + + + + + {/if} + + + + + + + + + +
    + Matches transactions between + + and + . Repeats {repeats(bill)}. +
    Subscription is active{bill.active ? "Yes" : "No"}
    Paid this period ({period.label}) + +
    Next expected match + {formatDate(nextExpected(bill)) || "unknown"} +
    Group + {#if bill.object_group_id} + {bill.object_group_title} + {:else} + No group + {/if} +
    End date{formatDate(bill.end_date)}
    Extension date{formatDate(bill.extension_date)}
    Average subscription amount ({year}) + +
    Average subscription amount (overall) + +
    + {#snippet footer()} + Edit + Open in old UI + Delete + {/snippet} +
    +
    + + {#if rules.data?.data.length} +
      + {#each rules.data.data as rule (rule.id)} +
    • + {rule.attributes.title} + {#if !rule.attributes.active} + (inactive rule) + {/if} +
    • + {/each} +
    + {:else if rules.isPending} +

    Loading rules

    + {:else} +

    + No rule links transactions to this subscription. +

    + {/if} + {#snippet footer()} + Create rule from subscription + {/snippet} +
    + {#if bill.notes} + +

    {bill.notes}

    +
    + {/if} +
    +
    + + + {#if chartRows.length > 0} + + formatMoney({ + amount: v, + decimals: payments[0]?.decimals, + symbol: codes.length === 1 ? payments[0]?.symbol : null, + })} + height={220} + keys={chartKeys} + label="Paid per month" + rows={chartRows} + /> + {:else} +

    + {transactions.isPending + ? "Loading transactions" + : "No transactions are linked to this subscription yet."} +

    + {/if} +
    + + + + +{/if}