feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days
The right column drops the box per account for one table per currency with sparklines and the last five transactions on l, a list of what was entered since the last look (x marks reviewed), bills and piggy bank dates in the next seven days, and debts with friends net per currency. The left column opens with spending so far against a typical month, and $ shows totals in UAH.
This commit is contained in:
1 parent
cea51281cd
commit
8c4e72666b
11 files changed
+1452
-312
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { getContext, onMount, setContext } from "svelte";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
|
||||
export interface Row {
|
||||
edit?: () => void;
|
||||
expand?: () => void;
|
||||
fold?: () => void;
|
||||
key: string;
|
||||
open: () => void;
|
||||
review?: () => void;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
order: number;
|
||||
rows: () => Row[];
|
||||
}
|
||||
|
||||
const CONTEXT = Symbol("dashboard-column");
|
||||
const GROUP = "Dashboard";
|
||||
|
||||
class Column {
|
||||
sections = $state.raw<Section[]>([]);
|
||||
rows = $derived(this.sections.flatMap((section) => section.rows()));
|
||||
index = $derived(new Map(this.rows.map((row, i) => [row.key, i])));
|
||||
|
||||
nav = new ListNav<Row>({
|
||||
edit: (row) => row.edit?.(),
|
||||
extra: () => [
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "Enter",
|
||||
label: "Expand account or open row",
|
||||
run: () => this.run((row) => (row.expand ?? row.open)()),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "l",
|
||||
label: "Show last transactions of the account",
|
||||
run: () => this.run((row) => (row.expand ?? row.open)()),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "h",
|
||||
label: "Fold account",
|
||||
run: () => this.run((row) => row.fold?.()),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "o",
|
||||
label: "Open row",
|
||||
run: () => this.run((row) => row.open()),
|
||||
},
|
||||
{
|
||||
group: GROUP,
|
||||
keys: "x",
|
||||
label: "Mark entry reviewed",
|
||||
run: () => this.run((row) => row.review?.()),
|
||||
},
|
||||
],
|
||||
id: "dashboard",
|
||||
items: () => this.rows,
|
||||
key: (row) => row.key,
|
||||
});
|
||||
|
||||
private run(action: (row: Row) => void) {
|
||||
if (this.nav.current) {
|
||||
action(this.nav.current);
|
||||
}
|
||||
}
|
||||
|
||||
add(section: Section) {
|
||||
this.sections = [...this.sections, section].sort(
|
||||
(a, b) => a.order - b.order
|
||||
);
|
||||
return () => {
|
||||
this.sections = this.sections.filter((s) => s !== section);
|
||||
};
|
||||
}
|
||||
|
||||
id(key: string) {
|
||||
return this.nav.rowId(this.index.get(key) ?? -1);
|
||||
}
|
||||
|
||||
point(key: string) {
|
||||
this.nav.cursor = this.index.get(key) ?? -1;
|
||||
}
|
||||
|
||||
rowClass(key: string) {
|
||||
return this.nav.current?.key === key
|
||||
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
|
||||
: "hover:bg-row-hover";
|
||||
}
|
||||
}
|
||||
|
||||
export type { Column };
|
||||
|
||||
export function provideColumn() {
|
||||
const column = new Column();
|
||||
setContext(CONTEXT, column);
|
||||
column.nav.use();
|
||||
return column;
|
||||
}
|
||||
|
||||
export function useColumn(order: number, rows: () => Row[]) {
|
||||
const column = getContext<Column>(CONTEXT);
|
||||
onMount(() => column.add({ order, rows }));
|
||||
return column;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import type { ExchangeRate, Page, TransactionGroup } from "#lib/api/types.js";
|
||||
import { formatMoney, toNumber } from "#lib/format.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
|
||||
const KEY = "ffui.dashboard.in-primary";
|
||||
const OWN_SAMPLE = 100;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
export interface Rate {
|
||||
own: boolean;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
amount: number;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function median(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
class InPrimary {
|
||||
on = $state(
|
||||
typeof localStorage !== "undefined" && localStorage.getItem(KEY) === "1"
|
||||
);
|
||||
|
||||
toggle() {
|
||||
this.on = !this.on;
|
||||
localStorage.setItem(KEY, this.on ? "1" : "0");
|
||||
}
|
||||
}
|
||||
|
||||
export const inPrimary = new InPrimary();
|
||||
|
||||
function ownRates(
|
||||
groups: { attributes: TransactionGroup }[],
|
||||
primary: string
|
||||
): Record<string, Rate> {
|
||||
const implied = new Map<string, number[]>();
|
||||
const add = (code: string, rate: number) =>
|
||||
implied.set(code, [...(implied.get(code) ?? []), rate]);
|
||||
for (const s of groups.flatMap((g) => g.attributes.transactions)) {
|
||||
const amount = toNumber(s.amount);
|
||||
const foreign = toNumber(s.foreign_amount);
|
||||
if (!(amount && foreign && s.foreign_currency_code && s.currency_code)) {
|
||||
continue;
|
||||
}
|
||||
if (s.currency_code === primary) {
|
||||
add(s.foreign_currency_code, amount / foreign);
|
||||
} else if (s.foreign_currency_code === primary) {
|
||||
add(s.currency_code, foreign / amount);
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
[...implied].map(([code, values]) => [
|
||||
code,
|
||||
{ own: true, rate: median(values) },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function listedRates(
|
||||
listed: { attributes: ExchangeRate }[],
|
||||
primary: string
|
||||
): Record<string, Rate> {
|
||||
const rates: Record<string, Rate> = {};
|
||||
const dated = new Map<string, string>();
|
||||
for (const { attributes: r } of listed) {
|
||||
const direct = r.to_currency_code === primary;
|
||||
const code = direct ? r.from_currency_code : r.to_currency_code;
|
||||
const rate = toNumber(r.rate);
|
||||
const date = r.date ?? "";
|
||||
const usable = direct || r.from_currency_code === primary;
|
||||
if (code && rate && usable && date >= (dated.get(code) ?? "")) {
|
||||
dated.set(code, date);
|
||||
rates[code] = { own: false, rate: direct ? rate : 1 / rate };
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
}
|
||||
|
||||
async function loadRates(primary: string): Promise<Record<string, Rate>> {
|
||||
const [listed, own] = await Promise.all([
|
||||
all<ExchangeRate>("/v1/exchange-rates"),
|
||||
api<Page<TransactionGroup>>("/v1/search/transactions", {
|
||||
query: { limit: OWN_SAMPLE, query: "has_foreign_amount:true" },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
...ownRates(own.data, primary),
|
||||
...listedRates(listed, primary),
|
||||
[primary]: { own: false, rate: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
export const ratesQuery = () => ({
|
||||
enabled: inPrimary.on,
|
||||
queryFn: () => loadRates(session.primary?.code ?? ""),
|
||||
queryKey: ["dashboard-rates", session.primary?.code ?? ""],
|
||||
staleTime: HOUR_MS,
|
||||
});
|
||||
|
||||
export function inPrimaryTotal(
|
||||
parts: Part[],
|
||||
rates: Record<string, Rate> | undefined
|
||||
): number | null {
|
||||
if (!(inPrimary.on && rates)) {
|
||||
return null;
|
||||
}
|
||||
let total = 0;
|
||||
for (const part of parts) {
|
||||
const rate = rates[part.code];
|
||||
if (!rate) {
|
||||
return null;
|
||||
}
|
||||
total += part.amount * rate.rate;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function formatPrimary(amount: number, signed = false): string {
|
||||
const { primary } = session;
|
||||
return `≈${formatMoney(
|
||||
{
|
||||
amount,
|
||||
code: primary?.code,
|
||||
decimals: primary?.decimal_places,
|
||||
symbol: primary?.symbol,
|
||||
},
|
||||
signed
|
||||
)}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const UNTIL_KEY = "ffui.reviewed-until";
|
||||
const IDS_KEY = "ffui.reviewed-ids";
|
||||
const FIRST_LOOK_MS = 2 * 86_400_000;
|
||||
|
||||
export interface Entry {
|
||||
at: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const time = (value: string) => Date.parse(value);
|
||||
|
||||
function read(key: string): string | null {
|
||||
return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
|
||||
}
|
||||
|
||||
class Reviewed {
|
||||
until = $state(read(UNTIL_KEY) ?? "");
|
||||
ids = $state.raw(new Set<string>(JSON.parse(read(IDS_KEY) ?? "[]")));
|
||||
|
||||
constructor() {
|
||||
if (!this.until && typeof localStorage !== "undefined") {
|
||||
const first = new Date(Date.now() - FIRST_LOOK_MS).toISOString();
|
||||
this.save(first, this.ids);
|
||||
}
|
||||
}
|
||||
|
||||
isNew(entry: Entry) {
|
||||
return time(entry.at) > time(this.until) && !this.ids.has(entry.id);
|
||||
}
|
||||
|
||||
toggle(entry: Entry) {
|
||||
const ids = new Set(this.ids);
|
||||
if (this.isNew(entry)) {
|
||||
ids.add(entry.id);
|
||||
} else {
|
||||
ids.delete(entry.id);
|
||||
}
|
||||
this.save(this.until, ids);
|
||||
}
|
||||
|
||||
all(entries: Entry[]) {
|
||||
const newest = Math.max(
|
||||
time(this.until),
|
||||
...entries.map((e) => time(e.at))
|
||||
);
|
||||
this.save(new Date(newest).toISOString(), new Set());
|
||||
}
|
||||
|
||||
compact(entries: Entry[]) {
|
||||
const after = entries
|
||||
.filter((e) => time(e.at) > time(this.until))
|
||||
.sort((a, b) => time(a.at) - time(b.at));
|
||||
const open = after.find((e) => !this.ids.has(e.id));
|
||||
const limit = open ? time(open.at) : Number.POSITIVE_INFINITY;
|
||||
const done = after.filter((e) => time(e.at) < limit);
|
||||
const ids = after
|
||||
.filter((e) => time(e.at) >= limit && this.ids.has(e.id))
|
||||
.map((e) => e.id);
|
||||
this.save(done.at(-1)?.at ?? this.until, new Set(ids));
|
||||
}
|
||||
|
||||
private save(until: string, ids: Set<string>) {
|
||||
this.until = until;
|
||||
this.ids = ids;
|
||||
localStorage.setItem(UNTIL_KEY, until);
|
||||
localStorage.setItem(IDS_KEY, JSON.stringify([...ids]));
|
||||
}
|
||||
}
|
||||
|
||||
export const reviewed = new Reviewed();
|
||||
Reference in new issue
Block a user