diff --git a/src/lib/components/misc/Field.svelte b/src/lib/components/misc/Field.svelte new file mode 100644 index 0000000..34b5691 --- /dev/null +++ b/src/lib/components/misc/Field.svelte @@ -0,0 +1,35 @@ + + +
+
+ +
+ {@render children()} + {#if problem} +

{problem}

+ {:else if help} +

{help}

+ {/if} +
+
+
diff --git a/src/lib/components/misc/ListTable.svelte b/src/lib/components/misc/ListTable.svelte new file mode 100644 index 0000000..5121903 --- /dev/null +++ b/src/lib/components/misc/ListTable.svelte @@ -0,0 +1,76 @@ + + +{#snippet rows()} + {#each nav.items as item, i (key(item))} + nav.click(i, event)} + oncontextmenu={() => (nav.cursor = i)} + > + {@render row(item, i)} + + {:else} + + {empty} + + {/each} +{/snippet} + +
+ + + + {@render head()} + + + {#if menu} + + + {#snippet child({ + props, + })} + + {@render rows()} + + {/snippet} + + + {#if nav.current} + {@render menu(nav.current)} + {/if} + + + {:else} + + {@render rows()} + + {/if} +
+
diff --git a/src/lib/components/misc/MenuDelete.svelte b/src/lib/components/misc/MenuDelete.svelte new file mode 100644 index 0000000..7dae219 --- /dev/null +++ b/src/lib/components/misc/MenuDelete.svelte @@ -0,0 +1,26 @@ + + + { + if (!armed) { + event.preventDefault(); + } + arm.press(id, run); + }} + variant="destructive" +> + {armed ? "Press again to delete" : label} + D + diff --git a/src/lib/components/misc/OldUi.svelte b/src/lib/components/misc/OldUi.svelte new file mode 100644 index 0000000..fabba70 --- /dev/null +++ b/src/lib/components/misc/OldUi.svelte @@ -0,0 +1,14 @@ + + +

+ {@render children()} + {label} +

diff --git a/src/lib/components/misc/Status.svelte b/src/lib/components/misc/Status.svelte new file mode 100644 index 0000000..4e7ce5e --- /dev/null +++ b/src/lib/components/misc/Status.svelte @@ -0,0 +1,15 @@ + + +{#if action.busy} + {busy} +{:else if action.message} + {action.message} +{/if} diff --git a/src/lib/components/misc/action.svelte.ts b/src/lib/components/misc/action.svelte.ts new file mode 100644 index 0000000..995bf60 --- /dev/null +++ b/src/lib/components/misc/action.svelte.ts @@ -0,0 +1,66 @@ +import { ApiError } from "#lib/api/client.js"; + +const UNAUTHORIZED = 401; +const ARM_MS = 3000; + +export function errorText(error: unknown): string { + if (error instanceof ApiError) { + if (error.status === UNAUTHORIZED) { + return "Only the owner can do this"; + } + return Object.values(error.errors)[0]?.[0] ?? error.message; + } + return error instanceof Error ? error.message : String(error); +} + +export class Action { + busy = $state(false); + message = $state(""); + failed = $state(false); + errors = $state>({}); + + async run(task: () => Promise, done = "Saved"): Promise { + this.busy = true; + this.message = ""; + this.failed = false; + this.errors = {}; + try { + await task(); + this.message = done; + return true; + } catch (error) { + this.failed = true; + this.message = errorText(error); + this.errors = error instanceof ApiError ? error.errors : {}; + return false; + } finally { + this.busy = false; + } + } + + field(name: string): string | undefined { + return this.errors[name]?.[0]; + } + + reset() { + this.message = ""; + this.failed = false; + } +} + +export class Arm { + key = $state(null); + private timer: ReturnType | undefined; + + press(key: string, run: () => unknown) { + clearTimeout(this.timer); + if (this.key === key) { + this.key = null; + return run(); + } + this.key = key; + this.timer = setTimeout(() => { + this.key = null; + }, ARM_MS); + } +} diff --git a/src/lib/components/misc/download.ts b/src/lib/components/misc/download.ts new file mode 100644 index 0000000..eeefb5a --- /dev/null +++ b/src/lib/components/misc/download.ts @@ -0,0 +1,32 @@ +import { ApiError, apiHeaders, apiUrl, type Query } from "#lib/api/client.js"; + +const FILENAME = /filename="?([^";]+)"?/; +const REVOKE_MS = 1000; + +export async function download( + path: string, + query: Query, + fallback: string +): Promise { + const response = await fetch(apiUrl(path, query), { + credentials: "same-origin", + headers: apiHeaders(false), + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + message?: string; + } | null; + throw new ApiError(response.status, body?.message ?? response.statusText); + } + const blob = await response.blob(); + const name = + response.headers.get("Content-Disposition")?.match(FILENAME)?.[1] ?? + fallback; + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), REVOKE_MS); + return blob.size; +} diff --git a/src/lib/components/misc/queries.ts b/src/lib/components/misc/queries.ts new file mode 100644 index 0000000..1ccf22c --- /dev/null +++ b/src/lib/components/misc/queries.ts @@ -0,0 +1,45 @@ +import { api } from "#lib/api/client.js"; +import { all } from "#lib/api/lists.js"; +import type { Currency, One } from "#lib/api/types.js"; + +interface Me { + email: string; + role?: string | null; +} + +const FOREVER = Number.POSITIVE_INFINITY; + +export const misc = { + config: (key: string) => ({ + queryFn: () => + api<{ data: { value: unknown } }>(`/v1/configuration/${key}`).then( + (body) => body.data.value + ), + queryKey: ["configuration", key], + staleTime: FOREVER, + }), + currencies: () => ({ + queryFn: () => all("/v1/currencies"), + queryKey: ["currencies"], + }), + me: () => ({ + queryFn: () => + api>("/v1/about/user").then((body) => body.data.attributes), + queryKey: ["about-user"], + staleTime: FOREVER, + }), +}; + +export const dateOnly = (value?: string | null) => value?.slice(0, 10); + +export function sizeText(bytes: number | null | undefined): string { + const value = bytes ?? 0; + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`; +} diff --git a/src/lib/components/misc/styles.ts b/src/lib/components/misc/styles.ts new file mode 100644 index 0000000..a171cca --- /dev/null +++ b/src/lib/components/misc/styles.ts @@ -0,0 +1,11 @@ +export const th = + "h-8 whitespace-nowrap border-cladd-outline border-b px-2 text-left font-semibold text-[12px] text-cladd-fg-soft"; +export const thNum = `${th} text-right`; +export const td = "px-2 py-1 align-middle"; +export const tdNum = `${td} num text-right`; +export const link = "text-brand hover:underline"; +export const select = + "cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full min-w-0 rounded-cladd-md px-2 font-medium text-cladd-fg text-cladd-xs"; +export const textarea = + "cladd-cut cladd-hoverable cladd-focusable-input min-h-24 w-full min-w-0 rounded-cladd-md px-2.5 py-1.5 font-medium text-cladd-fg text-cladd-xs"; +export const muted = "text-[12px] text-cladd-fg-soft"; diff --git a/src/lib/components/misc/url.ts b/src/lib/components/misc/url.ts new file mode 100644 index 0000000..12e2018 --- /dev/null +++ b/src/lib/components/misc/url.ts @@ -0,0 +1,14 @@ +import { goto } from "$app/navigation"; +import { page } from "$app/state"; + +export function setQuery(values: Record) { + const url = new URL(page.url.href); + for (const [key, value] of Object.entries(values)) { + if (value) { + url.searchParams.set(key, value); + } else { + url.searchParams.delete(key); + } + } + return goto(url, { replace: true, state: page.state }); +} diff --git a/src/lib/components/shell/Shell.svelte b/src/lib/components/shell/Shell.svelte index 75c5e6c..01a5719 100644 --- a/src/lib/components/shell/Shell.svelte +++ b/src/lib/components/shell/Shell.svelte @@ -36,10 +36,12 @@ await invalidate(); } - function cycleTheme() { + async function cycleTheme() { const order = ["system", "light", "dark"] as const; const current = userPrefersMode.current ?? "system"; - setMode(order[(order.indexOf(current) + 1) % order.length]); + const next = order[(order.indexOf(current) + 1) % order.length]; + setMode(next); + await session.setPreference("darkMode", next === "system" ? "browser" : next); } function focusSearch() { diff --git a/src/lib/format.ts b/src/lib/format.ts index ff4c0d6..62a6b9f 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -95,11 +95,8 @@ function toDate(value: string | Date): Date { if (value instanceof Date) { return value; } - if (value.length === 10) { - const [y, m, d] = value.split("-").map(Number); - return new Date(y, m - 1, d); - } - return new Date(value); + const [y, m, d] = value.slice(0, 10).split("-").map(Number); + return new Date(y, m - 1, d); } export function formatDate( diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index b79ee11..46b289d 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -1,3 +1,4 @@ +import { setMode } from "mode-watcher"; import { api, keepAlive, @@ -98,6 +99,15 @@ class Session { configureFormat(this.locale, this.anonymous); } + applyTheme() { + const theme = this.prefs.darkMode; + if (theme === "light" || theme === "dark") { + setMode(theme); + } else if (theme === "browser") { + setMode("system"); + } + } + async boot(): Promise { const state = await ping(); if (state !== "ok") { @@ -113,6 +123,7 @@ class Session { this.version = about.data?.version ?? ""; this.apiVersion = about.data?.api_version ?? ""; this.email = user.data.attributes.email; + this.applyTheme(); this.ready = true; keepAlive(); return takeReturnPath(); diff --git a/src/lib/spa-paths.ts b/src/lib/spa-paths.ts index e998979..4ed0acd 100644 --- a/src/lib/spa-paths.ts +++ b/src/lib/spa-paths.ts @@ -20,6 +20,7 @@ export const SPA_EXACT = [ "/recurring", "/webhooks/index", "/settings/links", + "/settings/links/create", ]; export const SPA_PREFIXES = [ @@ -41,10 +42,10 @@ export const SPA_PREFIXES = [ "/recurring/show/", "/webhooks/show/", "/settings/links/show/", + "/settings/links/edit/", ]; export const FIREFLY_PREFIXES = [ - "/accounts/reconcile/", "/attachments/download/", "/attachments/view/", "/transactions/convert/", diff --git a/src/routes/administrations/+page.svelte b/src/routes/administrations/+page.svelte new file mode 100644 index 0000000..d518bff --- /dev/null +++ b/src/routes/administrations/+page.svelte @@ -0,0 +1,92 @@ + + + + +
+ + Switching the active administration is not possible through the Firefly III + API. + + + {#if query.isPending} +

Loading administrations

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + item.id} {nav}> + {#snippet head()} + Title + Primary currency + Members + In use + {/snippet} + {#snippet row( + item + )} + {@const g = item.attributes} + + {g.title} + + + {g.primary_currency_name ?? ""} + ({g.primary_currency_code}) + + + {#if g.can_see_members} + {#each g.members ?? [] as member (member.user_id)} +
+ {member.user_email}{member.you ? " (you)" : ""} + · {roleText(member.roles)} +
+ {/each} + {:else} + Hidden + {/if} + + {g.in_use ? "Yes" : "No"} + {/snippet} + {#snippet menu( + item + )} + edit(item)}> + Edit + e + + {/snippet} +
+ {/if} +
+
diff --git a/src/routes/administrations/edit/[id=id]/+page.svelte b/src/routes/administrations/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..bc0b47f --- /dev/null +++ b/src/routes/administrations/edit/[id=id]/+page.svelte @@ -0,0 +1,113 @@ + + + + +{#if group} +
+ +
+ + + + + + +
+ {#snippet footer()} + + + {/snippet} +
+
+{:else if query.error} +

{errorText(query.error)}

+{:else} +

Loading administration

+{/if} diff --git a/src/routes/administrations/groups.ts b/src/routes/administrations/groups.ts new file mode 100644 index 0000000..122f865 --- /dev/null +++ b/src/routes/administrations/groups.ts @@ -0,0 +1,45 @@ +import { api } from "#lib/api/client.js"; +import { all } from "#lib/api/lists.js"; +import type { One, UserGroup } from "#lib/api/types.js"; + +export interface Member { + roles?: string[]; + user_email: string; + user_id: string; + you?: boolean; +} + +export type Group = UserGroup & { + members?: Member[]; + primary_currency_name?: string; +}; + +const ROLES: Record = { + full: "Full access", + mng_budgets: "Manage budgets", + mng_currencies: "Manage currencies", + mng_meta: "Manage classification and meta-data", + mng_piggies: "Manage piggy banks", + mng_recurring: "Manage recurring transactions", + mng_rules: "Manage rules", + mng_subscriptions: "Manage subscriptions", + mng_trx: "Manage transactions", + mng_webhooks: "Manage webhooks", + owner: "Owner", + ro: "Read-only", + view_reports: "View reports", +}; + +export const roleText = (roles: string[] = []) => + roles.map((role) => ROLES[role] ?? role).join(", "); + +export const groupQueries = { + all: () => ({ + queryFn: () => all("/v1/user-groups"), + queryKey: ["user-groups"], + }), + one: (id: string) => ({ + queryFn: () => api>(`/v1/user-groups/${id}`), + queryKey: ["user-group", id], + }), +}; diff --git a/src/routes/attachments/+page.svelte b/src/routes/attachments/+page.svelte new file mode 100644 index 0000000..30cf320 --- /dev/null +++ b/src/routes/attachments/+page.svelte @@ -0,0 +1,162 @@ + + + + + + {#if query.isPending} +

Loading attachments

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + item.id} + {nav} + > + {#snippet head()} + File name + Title + Size + Type + Attached to + Notes + Uploaded + + {/snippet} + {#snippet row( + item + )} + {@const a = item.attributes} + + {a.filename} + + {a.title ?? ""} + {sizeText(a.size)} + {a.mime} + + + + + {a.notes ?? ""} + + + {formatDate(dateOnly(a.created_at))} + + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {/if} + + {/snippet} + {#snippet menu( + item + )} + view(item)}> + View + ↵ + + save(item)}> + Download + d + + edit(item)}> + Edit + e + + remove(item)} /> + {/snippet} + + {/if} +
diff --git a/src/routes/attachments/AttachedTo.svelte b/src/routes/attachments/AttachedTo.svelte new file mode 100644 index 0000000..96e3c03 --- /dev/null +++ b/src/routes/attachments/AttachedTo.svelte @@ -0,0 +1,75 @@ + + +{#if query.data} + {query.data.label} +{:else if query.error || !known} + {type} #{id} +{/if} diff --git a/src/routes/attachments/edit/[id=id]/+page.svelte b/src/routes/attachments/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..35e3aca --- /dev/null +++ b/src/routes/attachments/edit/[id=id]/+page.svelte @@ -0,0 +1,160 @@ + + + + +{#if attachment} +
+ +
+ +

+ {attachment.filename} + Download +

+
+ +

{attachment.mime}

+
+ +

{sizeText(attachment.size)}

+
+ +

+ +

+
+ + + + + + +
+ {#snippet footer()} + + + + + Delete attachment + + {/snippet} +
+
+{:else if query.error} +

{errorText(query.error)}

+{:else} +

Loading attachment

+{/if} diff --git a/src/routes/currencies/+page.svelte b/src/routes/currencies/+page.svelte new file mode 100644 index 0000000..c3748a4 --- /dev/null +++ b/src/routes/currencies/+page.svelte @@ -0,0 +1,213 @@ + + + + {#snippet actions()} + {#if owner} + + {/if} + {/snippet} + + + + {#snippet tools()} + {#if me.data && !owner} + Ask the site owner to add, remove or edit currencies + {/if} + {/snippet} + {#if query.isPending} +

Loading currencies

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + item.id} {nav}> + {#snippet head()} + Enabled + Code + Currency + Symbol + Number of decimals + Primary + + {/snippet} + {#snippet row( + item + )} + {@const c = item.attributes} + + toggle(item)} + type="checkbox" + > + + + {c.code} + + + {c.name} + + + {c.symbol} + + {c.decimal_places} + + {#if c.primary} + Primary currency + {:else if c.enabled} + + {/if} + + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {/if} + + {/snippet} + {#snippet menu( + item + )} + edit(item)}> + Edit + e + + {#if !item.attributes.primary} + toggle(item)}> + {item.attributes.enabled ? "Disable" : "Enable"} + t + + makePrimary(item)}> + Make primary + p + + remove(item)} /> + {/if} + {/snippet} + + {/if} +
diff --git a/src/routes/currencies/CurrencyForm.svelte b/src/routes/currencies/CurrencyForm.svelte new file mode 100644 index 0000000..8910b21 --- /dev/null +++ b/src/routes/currencies/CurrencyForm.svelte @@ -0,0 +1,132 @@ + + +
+ +
+ + + + + + + + + + + + + {#if item} + + + + {/if} +
+ {#snippet footer()} + + + {#if item} + + + Delete currency + + {/if} + {/snippet} +
+
diff --git a/src/routes/currencies/create/+page.svelte b/src/routes/currencies/create/+page.svelte new file mode 100644 index 0000000..a6d76d0 --- /dev/null +++ b/src/routes/currencies/create/+page.svelte @@ -0,0 +1,13 @@ + + + + diff --git a/src/routes/currencies/edit/[id=id]/+page.svelte b/src/routes/currencies/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..6000a74 --- /dev/null +++ b/src/routes/currencies/edit/[id=id]/+page.svelte @@ -0,0 +1,31 @@ + + + +{#if item} + {#key item.id} + + {/key} +{:else if query.error} +

{errorText(query.error)}

+{:else if query.isPending} +

Loading currency

+{:else} +

There is no currency with this id.

+{/if} diff --git a/src/routes/exchange-rates/+page.svelte b/src/routes/exchange-rates/+page.svelte new file mode 100644 index 0000000..09d4a1e --- /dev/null +++ b/src/routes/exchange-rates/+page.svelte @@ -0,0 +1,164 @@ + + + + +
+ {#if flag.data === false} + + Exchange rates are switched off in the Firefly III configuration, so + amounts are not converted. + + {/if} + + {#if rates.isPending || currencies.isPending} +

Loading exchange rates

+ {:else if rates.error} +

{errorText(rates.error)}

+ {:else} + pair.key} + {nav} + > + {#snippet head()} + Pair + Between + Rates + Latest date + Latest rate + {/snippet} + {#snippet row( + pair + )} + {@const rate = latestRate(pair)} + + {pair.from} + → {pair.to} + + + Between {names.get(pair.from) ?? pair.from} and + {names.get(pair.to) ?? pair.to} + (and the other way around) + + {pair.count} + {formatDate(dateOnly(pair.latest?.date))} + + {rate === null ? "" : formatNumber(rate, 6)} + + {/snippet} + + {/if} +
+
diff --git a/src/routes/exchange-rates/[from]/[to]/+page.svelte b/src/routes/exchange-rates/[from]/[to]/+page.svelte new file mode 100644 index 0000000..52d0fce --- /dev/null +++ b/src/routes/exchange-rates/[from]/[to]/+page.svelte @@ -0,0 +1,357 @@ + + + + +
+
+ {#if rows.length > 1} + + formatNumber(n, 4)} + height={200} + label="Exchange rate {from} to {to} by date" + {series} + toggle={false} + xFormat={(day) => monthLabel.format(parse(day))} + /> + + {/if} + + {#snippet tools()} + + {/snippet} + {#if query.isPending} +

Loading exchange rates

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + row.date} + {nav} + > + {#snippet head()} + Date + {from} → {to} + {to} → {from} + + {/snippet} + {#snippet row( + row + )} + {#if editing === row.date} + {formatDate(row.date)} + +
save(event, row)} + > + + + + + remove(row)} + >Delete + + + + {:else} + {formatDate(row.date)} + + {show(forwardOf(row))} + + + {show(backwardOf(row))} + + + {#if arm.key === row.date} + Press D again to delete + {/if} + + {/if} + {/snippet} + {#snippet menu( + row + )} + edit(row)}> + Edit + e + + remove(row)} /> + {/snippet} +
+ {/if} +
+
+ +
+ +
+ + + + + + +
+ {#snippet footer()} + + + {/snippet} +
+
+
diff --git a/src/routes/export/+page.svelte b/src/routes/export/+page.svelte new file mode 100644 index 0000000..cd68db8 --- /dev/null +++ b/src/routes/export/+page.svelte @@ -0,0 +1,186 @@ + + + + +
+ +
+ +
+ {#each TYPES as [key, label] (key)} + + {/each} +
+
+ +
+ setQuery({ from: event.currentTarget.value })} + type="date" + value={from} + /> + to + setQuery({ to: event.currentTarget.value })} + type="date" + value={to} + /> + + +
+
+ {#if type === "transactions"} + +
+

+ {picked.length === 0 + ? "Transactions from all asset accounts. Pick accounts to narrow it down." + : `Transactions from ${picked.length} of ${accounts.length} asset accounts.`} + {#if picked.length > 0} + + {/if} +

+
+ {#each accounts as account (account.id)} + + {/each} +
+
+
+ {/if} +
+ {#snippet footer()} + + + {/snippet} +
+
diff --git a/src/routes/preferences/+page.svelte b/src/routes/preferences/+page.svelte new file mode 100644 index 0000000..f5180bf --- /dev/null +++ b/src/routes/preferences/+page.svelte @@ -0,0 +1,69 @@ + + + + +
+ {#each TABS as t, i (t.key)} + + {/each} +
+ +{#if tab === "frontpage"} + +{:else if tab === "layout"} + +{:else if tab === "notifications"} + + Notification settings (email, Slack, Pushover) stay in the old interface. + +{:else} + +{/if} diff --git a/src/routes/preferences/General.svelte b/src/routes/preferences/General.svelte new file mode 100644 index 0000000..4fae1f1 --- /dev/null +++ b/src/routes/preferences/General.svelte @@ -0,0 +1,259 @@ + + +
+
+
+ languageAction.run(() => session.setPreference("language", language))} + title="Languages" + > + +
+ +
+ localeAction.run(() => session.setPreference("locale", locale))} + title="Locale settings" + > + + {#if sample} +

Looks like {sample}

+ {/if} +
+ +
+
+ + + + + +
+
+ +
+ + {#if exchange.data === false} +

+ Exchange rates are switched off in the Firefly III configuration, so + this has no effect yet. +

+ {/if} +
+ +
+ +
+
+ +
+ fieldsAction.run(() => + session.setPreference("transaction_journal_optional_fields", { + ...Object.fromEntries( + FIELD_GROUPS.flatMap((g) => g.fields).map(([key]) => [key, false]) + ), + ...optional, + }) + )} + title="Optional fields for transactions" + > +
+ {#each FIELD_GROUPS as group (group.label)} +
+ + {group.label} + + {#each group.fields as [key, label] (key)} + + {/each} +
+ {/each} +
+
+
diff --git a/src/routes/preferences/HomeScreen.svelte b/src/routes/preferences/HomeScreen.svelte new file mode 100644 index 0000000..aaa7f78 --- /dev/null +++ b/src/routes/preferences/HomeScreen.svelte @@ -0,0 +1,202 @@ + + +
+
+
+ item.id} {nav}> + {#snippet head()} + + # + Account + Type + + {/snippet} + {#snippet row( + item + )} + {@const position = chosen?.indexOf(item.id) ?? -1} + + = 0} + class="size-3.5 accent-brand align-middle" + onchange={() => toggle(item)} + type="checkbox" + > + + + {position >= 0 ? position + 1 : ""} + + + {item.attributes.name} + + + {kind(item.attributes)} + + + {#if position >= 0} + + + {/if} + + {/snippet} + +
+
+
diff --git a/src/routes/preferences/Layout.svelte b/src/routes/preferences/Layout.svelte new file mode 100644 index 0000000..9891ccc --- /dev/null +++ b/src/routes/preferences/Layout.svelte @@ -0,0 +1,132 @@ + + +
+
+
+ {#each VIEW_RANGES as [value, label] (value)} + + {/each} +
+
+ +
+
+ + + + +
+ +
+
+ {#each DARK as [value, label] (value)} + + {/each} +
+
+
+
diff --git a/src/routes/preferences/Section.svelte b/src/routes/preferences/Section.svelte new file mode 100644 index 0000000..786ef94 --- /dev/null +++ b/src/routes/preferences/Section.svelte @@ -0,0 +1,40 @@ + + +
+ + {#if help} +

{help}

+ {/if} + {@render children()} + {#snippet footer()} + + + {/snippet} +
+
diff --git a/src/routes/recurring/+page.svelte b/src/routes/recurring/+page.svelte new file mode 100644 index 0000000..4fa35c4 --- /dev/null +++ b/src/routes/recurring/+page.svelte @@ -0,0 +1,211 @@ + + + + {#snippet actions()} + + {/snippet} + + + + {#snippet tools()} + + {/snippet} + {#if query.isPending} +

Loading recurring transactions

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + item.id} + {nav} + > + {#snippet head()} + Title + Type + Amount + From → to + Repetitions + Next + Active + + {/snippet} + {#snippet row( + item + )} + {@const r = item.attributes} + {@const tx = r.transactions?.[0]} + + {r.title} + {#if r.description} +
+ {r.description} +
+ {/if} + + + {TYPES[r.type ?? ""] ?? r.type} + + + {#each r.transactions ?? [] as split, i (split.id ?? i)} +
+ +
+ {/each} + + + {#if tx} + {tx.source_name} + → + {tx.destination_name} + {/if} + + + {#each r.repetitions ?? [] as rep, i (rep.id ?? i)} +
{describe(rep)}
+ {/each} +
+ {stopped(r) + ? `Stopped repeating on ${formatDate(dateOnly(r.repeat_until))}` + : endsText(r)} +
+ + + {#each upcoming(r, 2) as date (date)} +
{formatDate(date)}
+ {/each} + + {r.active ? "Yes" : "No"} + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {/if} + + {/snippet} + {#snippet menu( + item + )} + open(item)}> + Show + ↵ + + edit(item)}> + Edit in the old interface + e + + remove(item)} /> + {/snippet} +
+ {/if} +
diff --git a/src/routes/recurring/recurrence.ts b/src/routes/recurring/recurrence.ts new file mode 100644 index 0000000..a8dacf0 --- /dev/null +++ b/src/routes/recurring/recurrence.ts @@ -0,0 +1,101 @@ +import type { Recurrence } from "#lib/api/types.js"; +import { dateOnly } from "#lib/components/misc/queries.js"; +import { parse, today } from "#lib/dates.js"; +import { currentLocale, formatDate } from "#lib/format.js"; + +export const TYPES: Record = { + deposit: "Deposit", + transfer: "Transfer", + withdrawal: "Withdrawal", +}; + +export const TONES = { + deposit: "income", + transfer: "transfer", + withdrawal: "expense", +} as const; + +export const toneOf = (type?: string) => + TONES[(type ?? "withdrawal") as keyof typeof TONES] ?? "neutral"; + +export function endsText(r: Recurrence): string { + if (r.repeat_until) { + return `Repeats until ${formatDate(dateOnly(r.repeat_until))}`; + } + if (r.nr_of_repetitions) { + return r.nr_of_repetitions === 1 + ? "Repeats 1 time" + : `Repeats ${r.nr_of_repetitions} times`; + } + return "Repeats forever"; +} + +export function stopped(r: Recurrence): boolean { + return Boolean(r.repeat_until && r.repeat_until.slice(0, 10) < today()); +} + +export function upcoming(r: Recurrence, count = 3): string[] { + const now = today(); + const dates = (r.repetitions ?? []) + .flatMap((rep) => rep.occurrences ?? []) + .map((date) => date.slice(0, 10)) + .filter((date) => date >= now); + return [...new Set(dates)].sort().slice(0, count); +} + +type Repetition = NonNullable[number]; + +const DAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; +const WEEKEND: Record = { + 2: ", skipped on weekends", + 3: ", moved to Friday on weekends", + 4: ", moved to Monday on weekends", +}; +const SUFFIX = ["th", "st", "nd", "rd"]; + +export function ordinal(n: number): string { + const teen = n % 100 >= 11 && n % 100 <= 13; + return `${n}${teen ? "th" : (SUFFIX[n % 10] ?? "th")}`; +} + +function every(unit: string, skip = 0): string { + if (skip === 0) { + return `Every ${unit}`; + } + return skip === 1 + ? `Every other ${unit}` + : `Every ${ordinal(skip + 1)} ${unit}`; +} + +function base(rep: Repetition): string { + const day = (value: string | number) => + DAYS[Number(value) - 1] ?? String(value); + switch (rep.type) { + case "daily": + return every("day", rep.skip); + case "weekly": + return `${every("week", rep.skip)} on ${day(rep.moment)}`; + case "monthly": + return `${every("month", rep.skip)} on the ${ordinal(Number(rep.moment))}`; + case "ndom": { + const [week, weekday] = rep.moment.split(","); + return `${every("month", rep.skip)} on the ${ordinal(Number(week))} ${day(weekday)}`; + } + case "yearly": + return `${every("year", rep.skip)} on ${new Intl.DateTimeFormat(currentLocale(), { day: "numeric", month: "long" }).format(parse(rep.moment))}`; + default: + return rep.description ?? rep.type; + } +} + +export function describe(rep: Repetition): string { + return `${base(rep)}${WEEKEND[rep.weekend ?? 1] ?? ""}`; +} diff --git a/src/routes/recurring/show/[id=id]/+page.svelte b/src/routes/recurring/show/[id=id]/+page.svelte new file mode 100644 index 0000000..1e1710f --- /dev/null +++ b/src/routes/recurring/show/[id=id]/+page.svelte @@ -0,0 +1,336 @@ + + + + +{#if r} +
+
+ +
+ {#if r.description} +

{r.description}

+ {/if} + {#if !r.active} +

+ This recurring transaction is not active and will not generate new + transactions. +

+ {/if} + {#if r.nr_of_repetitions} +

+ This recurring transaction will be created at most + {r.nr_of_repetitions} + times, and has been created {count} times already. +

+ {/if} +
+
Repeats
+
+ {#each r.repetitions ?? [] as rep, i (rep.id ?? i)} +
{describe(rep)}
+ {/each} +
+
First date
+
{formatDate(dateOnly(r.first_date))}
+
Ends
+
+ {stopped(r) + ? `This recurring transaction stopped repeating on ${formatDate(dateOnly(r.repeat_until))}.` + : endsText(r)} +
+
Last created
+
+ {r.latest_date ? formatDate(dateOnly(r.latest_date)) : "Never"} +
+
Apply rules
+
{r.apply_rules ? "Yes" : "No"}
+ {#if r.notes} +
Notes
+
{r.notes}
+ {/if} +
+
+ {#snippet footer()} + + + + 0 + ? `Press again, the ${count} created stay` + : "Press again to delete"} + onconfirm={remove} + >Delete + + {/snippet} +
+ + + {#each r.repetitions ?? [] as rep, i (rep.id ?? i)} +

+ {describe(rep)} +

+ + + {#each dates(rep.occurrences) as date (date)} + + + + + {:else} + + + + {/each} + +
{formatDate(date)} + {#if fired[date]} + {fired[date]} + {:else if createdDates.has(date)} + Created + {:else} + + {/if} +
No upcoming dates.
+ {/each} +
+
+ + +
+ + + + + + + + + + + + + + {#each r.transactions ?? [] as tx, i (tx.id ?? i)} + + + + + + + + + + {/each} + +
DescriptionSource accountDestination accountAmountCategoryBudgetOther
{tx.description} + {tx.source_name} + + {tx.destination_name} + + + {#if tx.foreign_amount} + + () + + {/if} + + {#if tx.category_id} + {tx.category_name} + {/if} + + {#if tx.budget_id} + {tx.budget_name} + {/if} + + {#if tx.tags?.length} +
Tags: {tx.tags.join(", ")}
+ {/if} + {#if tx.piggy_bank_id} +
+ Piggy bank: + {tx.piggy_bank_name} +
+ {/if} + {#if tx.subscription_id} +
+ Subscription: + {tx.subscription_name} +
+ {/if} +
+
+
+ + + + +
+{:else if query.error} +

{errorText(query.error)}

+{:else} +

Loading recurring transaction

+{/if} diff --git a/src/routes/settings/links/+page.svelte b/src/routes/settings/links/+page.svelte new file mode 100644 index 0000000..4d909ca --- /dev/null +++ b/src/routes/settings/links/+page.svelte @@ -0,0 +1,164 @@ + + + + {#snippet actions()} + + {/snippet} + + + + {#if types.isPending} +

Loading link types

+ {:else if types.error} +

{errorText(types.error)}

+ {:else} + item.id} {nav}> + {#snippet head()} + Name + Inward description + Outward description + Number of transactions + + {/snippet} + {#snippet row( + item + )} + {@const t = item.attributes} + + {t.name} + + {t.inward} + {t.outward} + + {links.data ? (counts.get(item.id) ?? 0) : ""} + + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {:else if !t.editable} + Built in + {/if} + + {/snippet} + {#snippet menu( + item + )} + open(item)}> + Show linked transactions + ↵ + + {#if item.attributes.editable} + edit(item)}> + Edit + e + + remove(item)} /> + {/if} + {/snippet} + + {/if} +
diff --git a/src/routes/settings/links/Journal.svelte b/src/routes/settings/links/Journal.svelte new file mode 100644 index 0000000..0003eef --- /dev/null +++ b/src/routes/settings/links/Journal.svelte @@ -0,0 +1,44 @@ + + +{#if group && split} + + {split.description} + + +{:else if query.error} + Transaction #{id} +{/if} diff --git a/src/routes/settings/links/LinkTypeForm.svelte b/src/routes/settings/links/LinkTypeForm.svelte new file mode 100644 index 0000000..6a84905 --- /dev/null +++ b/src/routes/settings/links/LinkTypeForm.svelte @@ -0,0 +1,139 @@ + + +
+ +
+ {#if locked} +

+ This link type is built into Firefly III and cannot be changed. +

+ {/if} + + + + + + + + + +
+ {#snippet footer()} + + + {#if item && !locked} + + + 0 + ? `Press again to delete it and ${count} links` + : "Press again to delete"} + onconfirm={remove} + >Delete link type + + {/if} + {/snippet} +
+
diff --git a/src/routes/settings/links/create/+page.svelte b/src/routes/settings/links/create/+page.svelte new file mode 100644 index 0000000..c1f4e04 --- /dev/null +++ b/src/routes/settings/links/create/+page.svelte @@ -0,0 +1,15 @@ + + + + diff --git a/src/routes/settings/links/edit/[id=id]/+page.svelte b/src/routes/settings/links/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..e93eef3 --- /dev/null +++ b/src/routes/settings/links/edit/[id=id]/+page.svelte @@ -0,0 +1,35 @@ + + + +{#if item} + {#key item.id} + + {/key} +{:else if query.error} +

{errorText(query.error)}

+{:else} +

Loading link type

+{/if} diff --git a/src/routes/settings/links/queries.ts b/src/routes/settings/links/queries.ts new file mode 100644 index 0000000..a990209 --- /dev/null +++ b/src/routes/settings/links/queries.ts @@ -0,0 +1,19 @@ +import { api } from "#lib/api/client.js"; +import { all } from "#lib/api/lists.js"; +import type { LinkType, One, TransactionLink } from "#lib/api/types.js"; + +export const linkQueries = { + links: (name?: string) => ({ + queryFn: () => + all("/v1/transaction-links", name ? { name } : {}), + queryKey: ["transaction-links", name ?? ""], + }), + one: (id: string) => ({ + queryFn: () => api>(`/v1/link-types/${id}`), + queryKey: ["link-type", id], + }), + types: () => ({ + queryFn: () => all("/v1/link-types"), + queryKey: ["link-types"], + }), +}; diff --git a/src/routes/settings/links/show/[id=id]/+page.svelte b/src/routes/settings/links/show/[id=id]/+page.svelte new file mode 100644 index 0000000..a918037 --- /dev/null +++ b/src/routes/settings/links/show/[id=id]/+page.svelte @@ -0,0 +1,156 @@ + + + + {#snippet actions()} + {#if type.data?.data.attributes.editable} + + {/if} + {/snippet} + + + + {#if type.error} +

{errorText(type.error)}

+ {:else if links.isPending} +

Loading links

+ {:else if links.error} +

{errorText(links.error)}

+ {:else} + item.id} + {nav} + > + {#snippet head()} + Inward transaction + + Outward transaction + + {/snippet} + {#snippet row( + item + )} + + + {type.data?.data.attributes.outward} + + + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {:else if item.attributes.notes} + {item.attributes.notes} + {/if} + + {/snippet} + {#snippet menu( + item + )} + swap(item)}> + Switch direction + s + + remove(item)} /> + {/snippet} + + {/if} +
diff --git a/src/routes/webhooks/Attempts.svelte b/src/routes/webhooks/Attempts.svelte new file mode 100644 index 0000000..e2b6fe1 --- /dev/null +++ b/src/routes/webhooks/Attempts.svelte @@ -0,0 +1,51 @@ + + +{#if query.isPending} +

Loading attempts

+{:else if query.error} +

{errorText(query.error)}

+{:else} + {#each query.data ?? [] as attempt (attempt.id)} + {@const a = attempt.attributes} +
+

+ Attempt at {formatDateTime(a.created_at)} + {a.status_code ?? "no answer"} +

+ {#if a.logs} +
{a.logs}
+ {/if} + {#if a.response} +
{a.response}
+ {/if} +
+ {:else} +

No attempts yet.

+ {/each} +{/if} diff --git a/src/routes/webhooks/Disabled.svelte b/src/routes/webhooks/Disabled.svelte new file mode 100644 index 0000000..a1dec20 --- /dev/null +++ b/src/routes/webhooks/Disabled.svelte @@ -0,0 +1,9 @@ + + + + Webhooks are switched off in this Firefly III (ALLOW_WEBHOOKS=false). + diff --git a/src/routes/webhooks/index/+page.svelte b/src/routes/webhooks/index/+page.svelte new file mode 100644 index 0000000..47993ef --- /dev/null +++ b/src/routes/webhooks/index/+page.svelte @@ -0,0 +1,152 @@ + + + + {#snippet actions()} + {#if !off} + + {/if} + {/snippet} + + +{#if off} + +{:else} + + {#snippet tools()} + + {/snippet} + {#if query.isPending} +

Loading webhooks

+ {:else if query.error} +

{errorText(query.error)}

+ {:else} + item.id} + {nav} + > + {#snippet head()} + Title + Responds when + Responds with (delivery) + Active + URL + + {/snippet} + {#snippet row( + item + )} + {@const w = item.attributes} + + {w.title} + + {labels(w.triggers, TRIGGERS)} + + {labels(w.responses, RESPONSES)} + ({labels(w.deliveries, DELIVERIES)}) + + {w.active ? "Yes" : "No"} + {w.url} + + {#if arm.key === item.id} + Press D again to delete + {:else if notes[item.id]} + {notes[item.id]} + {/if} + + {/snippet} + {#snippet menu( + item + )} + open(item)}> + Inspect + ↵ + + edit(item)}> + Edit in the old interface + e + + remove(item)} /> + {/snippet} + + {/if} +
+{/if} diff --git a/src/routes/webhooks/show/[id=id]/+page.svelte b/src/routes/webhooks/show/[id=id]/+page.svelte new file mode 100644 index 0000000..1abd8c8 --- /dev/null +++ b/src/routes/webhooks/show/[id=id]/+page.svelte @@ -0,0 +1,234 @@ + + + + +{#if switchedOff(query.error)} + +{:else if query.error} +

{errorText(query.error)}

+{:else if w} +
+
+ +
+
Title
+
{w.title}
+
Active
+
{w.active ? "Yes" : "No"}
+
Responds when
+
{labels(w.triggers, TRIGGERS)}
+
Responds with
+
{labels(w.responses, RESPONSES)}
+
Delivery
+
{labels(w.deliveries, DELIVERIES)}
+
+ {#snippet footer()} + + + + Delete + + {/snippet} +
+ + +
+
URL
+
+ {w.url} +
+
Secret
+
+ {secret ? w.secret : "••••••••••••"} + +
+
+ {#snippet footer()} +
+ + + + + + {/snippet} +
+
+ +
+ + {#if messages.isPending} +

Loading messages

+ {:else if messages.error} +

{errorText(messages.error)}

+ {:else} + item.id} + {nav} + > + {#snippet head()} + Date and time + UUID + Sent + Errored + {/snippet} + {#snippet row( + item + )} + {@const m = item.attributes} + {formatDateTime(m.created_at)} + {m.uuid} + {m.sent ? "Yes" : "No"} + + {m.errored ? "Yes" : "No"} + + {/snippet} + + {/if} +
+ + {#if current} + +
{pretty(current.attributes.message)}
+

Attempts

+ {#key current.id} + + {/key} +
+ {/if} +
+
+{:else} +

Loading webhook

+{/if} diff --git a/src/routes/webhooks/webhooks.ts b/src/routes/webhooks/webhooks.ts new file mode 100644 index 0000000..7b8d77c --- /dev/null +++ b/src/routes/webhooks/webhooks.ts @@ -0,0 +1,32 @@ +import { ApiError } from "#lib/api/client.js"; + +const NOT_FOUND = 404; + +export const TRIGGERS: Record = { + ANY: "After any event", + DESTROY_BUDGET: "After budget delete", + DESTROY_TRANSACTION: "After transaction delete", + STORE_BUDGET: "After budget creation", + STORE_TRANSACTION: "After transaction creation", + STORE_UPDATE_BUDGET_LIMIT: "After budgeted amount change", + UPDATE_BUDGET: "After budget update", + UPDATE_TRANSACTION: "After transaction update", +}; + +export const RESPONSES: Record = { + ACCOUNTS: "Account details", + BUDGET: "Budget details", + NONE: "No details", + RELEVANT: "Relevant details", + TRANSACTIONS: "Transaction details", +}; + +export const DELIVERIES: Record = { JSON: "JSON" }; + +export const labels = ( + values: string[] | undefined, + map: Record +) => (values ?? []).map((value) => map[value] ?? value).join(", "); + +export const switchedOff = (error: unknown) => + error instanceof ApiError && error.status === NOT_FOUND;