feat(transactions): the period as a local ledger with Excel-style filters
The list loads the whole period (or twelve months with Y) and filters it in the browser: a text line over descriptions, notes, accounts and amounts, dropdowns with counts per type, category, budget, account, currency, tag and subscription, group-by with subtotals per currency, pinned filters on 1 to 9. Possible duplicates show with a delete for the newer one, one account adds the balance column, s s sets a subscription and s . repeats the last mass action.
This commit is contained in:
1 parent
01c53d982d
commit
153a8cb191
9 files changed
+1693
-131
No files matched your search
@@ -2,32 +2,26 @@
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { tick } from "svelte";
|
||||
import { invalidate, lists } from "#lib/api/lists.js";
|
||||
import type { Split } from "#lib/api/types.js";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import {
|
||||
deleteTargets,
|
||||
type MassAction,
|
||||
MODES,
|
||||
type Mode,
|
||||
type Outcome,
|
||||
type Patch,
|
||||
patchFor,
|
||||
type Row,
|
||||
splitsOf,
|
||||
targetsOf,
|
||||
updateGroups,
|
||||
} from "#lib/transactions/mass.js";
|
||||
import type { Suggestion } from "#lib/transactions/query.js";
|
||||
import { lastAction } from "#lib/transactions/repeat.svelte.js";
|
||||
import SuggestInput from "./SuggestInput.svelte";
|
||||
|
||||
export type Mode = "category" | "budget" | "tag" | "untag";
|
||||
|
||||
const ARM_MS = 3000;
|
||||
const STATUS_MS = 8000;
|
||||
const NONE = "\u0000";
|
||||
|
||||
const MODES: { mode: Mode; keys: string; label: string; verb: string }[] = [
|
||||
{ keys: "s c", label: "Category", mode: "category", verb: "Set category" },
|
||||
{ keys: "s b", label: "Budget", mode: "budget", verb: "Set budget" },
|
||||
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
|
||||
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
|
||||
];
|
||||
const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb]));
|
||||
|
||||
let {
|
||||
@@ -58,6 +52,10 @@
|
||||
...lists.tags(),
|
||||
enabled: mode === "tag",
|
||||
}));
|
||||
const bills = createQuery(() => ({
|
||||
...lists.bills(),
|
||||
enabled: mode === "bill",
|
||||
}));
|
||||
|
||||
const visible = $derived(
|
||||
selected > 0 || mode !== null || armed || status !== ""
|
||||
@@ -80,6 +78,14 @@
|
||||
"(no budget)",
|
||||
false,
|
||||
];
|
||||
case "bill":
|
||||
return [
|
||||
(bills.data ?? [])
|
||||
.filter((b) => b.attributes.active !== false)
|
||||
.map((b) => b.attributes.name ?? ""),
|
||||
"(no subscription)",
|
||||
false,
|
||||
];
|
||||
case "tag":
|
||||
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
|
||||
case "untag":
|
||||
@@ -146,28 +152,23 @@
|
||||
text = "";
|
||||
}
|
||||
|
||||
function patcher(current: Mode, value: string | null) {
|
||||
const budgetId =
|
||||
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null;
|
||||
const patches: Record<Mode, (split: Split) => Patch | null> = {
|
||||
budget: (split) =>
|
||||
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId
|
||||
? null
|
||||
: { budget_id: budgetId },
|
||||
category: (split) =>
|
||||
(split.category_name ?? null) === value
|
||||
? null
|
||||
: { category_name: value },
|
||||
tag: (split) =>
|
||||
!value || split.tags?.includes(value)
|
||||
? null
|
||||
: { tags: [...(split.tags ?? []), value] },
|
||||
untag: (split) =>
|
||||
value && split.tags?.includes(value)
|
||||
? { tags: split.tags.filter((tag) => tag !== value) }
|
||||
: null,
|
||||
};
|
||||
return patches[current];
|
||||
function idOf(current: Mode, value: string | null): string | null {
|
||||
const items = current === "bill" ? bills.data : budgets.data;
|
||||
return items?.find((item) => item.attributes.name === value)?.id ?? null;
|
||||
}
|
||||
|
||||
async function run(action: MassAction, rows: Row[]) {
|
||||
const verb = VERB[action.mode];
|
||||
busy = true;
|
||||
const outcome = await updateGroups(
|
||||
targetsOf(rows),
|
||||
patchFor(action),
|
||||
(progress) =>
|
||||
say(`${verb}: updated ${progress.done} of ${progress.total}`)
|
||||
);
|
||||
await invalidate();
|
||||
busy = false;
|
||||
report("Updated", outcome);
|
||||
}
|
||||
|
||||
async function apply(raw: string) {
|
||||
@@ -178,17 +179,25 @@
|
||||
if (value === null && (mode === "tag" || mode === "untag")) {
|
||||
return;
|
||||
}
|
||||
const verb = VERB[mode];
|
||||
const patch = patcher(mode, value);
|
||||
const rows = active;
|
||||
const byId = mode === "budget" || mode === "bill";
|
||||
const id = byId ? idOf(mode, value) : null;
|
||||
if (byId && value !== null && id === null) {
|
||||
say(`Nothing called “${value}”`);
|
||||
return;
|
||||
}
|
||||
const action = { id, mode, value };
|
||||
lastAction.current = action;
|
||||
mode = null;
|
||||
busy = true;
|
||||
const outcome = await updateGroups(targetsOf(rows), patch, (progress) =>
|
||||
say(`${verb}: updated ${progress.done} of ${progress.total}`)
|
||||
);
|
||||
await invalidate();
|
||||
busy = false;
|
||||
report("Updated", outcome);
|
||||
await run(action, active);
|
||||
}
|
||||
|
||||
export async function repeat(rows: Row[] = targets) {
|
||||
const action = lastAction.current;
|
||||
if (!action || busy || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
active = rows;
|
||||
await run(action, rows);
|
||||
}
|
||||
|
||||
export async function remove(rows: Row[] = targets, confirmed = false) {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import Funnel from "@lucide/svelte/icons/funnel";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import { DropdownMenu } from "bits-ui";
|
||||
import DropdownMenuCheckboxItem from "#lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte";
|
||||
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||
import DropdownMenuRadioGroup from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte";
|
||||
import DropdownMenuRadioItem from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte";
|
||||
import DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte";
|
||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import {
|
||||
type Count,
|
||||
countFacets,
|
||||
FACETS,
|
||||
type FacetId,
|
||||
GROUP_BY,
|
||||
GROUP_LABEL,
|
||||
type GroupBy,
|
||||
isFiltered,
|
||||
toggleValue,
|
||||
valueLabel,
|
||||
} from "#lib/transactions/ledger.js";
|
||||
import type { LedgerView } from "#lib/transactions/ledger-view.svelte.js";
|
||||
import type { Group } from "#lib/transactions/split.js";
|
||||
|
||||
interface Option extends Count {
|
||||
facet: string;
|
||||
id: FacetId;
|
||||
}
|
||||
|
||||
const NOTE_MS = 4000;
|
||||
const SUGGESTIONS = 6;
|
||||
const LABEL = Object.fromEntries(FACETS.map((f) => [f.id, f.label]));
|
||||
const TRIGGER =
|
||||
"inline-flex h-7 shrink-0 items-center gap-1 rounded-cladd-sm px-2 text-[12px] hover:bg-row-hover hover:text-cladd-fg data-[state=open]:bg-row-hover";
|
||||
const ITEM =
|
||||
"min-h-7 gap-3 rounded-cladd-sm py-0.5 ps-9 pe-2 font-normal text-[13px]";
|
||||
const LINK = "inline-flex items-center gap-1.5 hover:text-cladd-fg";
|
||||
|
||||
let {
|
||||
view,
|
||||
groups,
|
||||
keep,
|
||||
ids,
|
||||
shown,
|
||||
total,
|
||||
duplicates,
|
||||
onsearch,
|
||||
}: {
|
||||
view: LedgerView;
|
||||
groups: Group[];
|
||||
keep: (group: Group) => boolean;
|
||||
ids: FacetId[];
|
||||
shown: number;
|
||||
total: number;
|
||||
duplicates: number;
|
||||
onsearch: () => void;
|
||||
} = $props();
|
||||
|
||||
let input = $state<HTMLInputElement>();
|
||||
let focused = $state(false);
|
||||
let cursor = $state(-1);
|
||||
let note = $state("");
|
||||
let noteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const filters = $derived(view.filters);
|
||||
const typed = $derived(filters.text.trim());
|
||||
const counts = $derived(countFacets(groups, filters, ids, keep));
|
||||
const options = $derived.by<Option[]>(() => {
|
||||
if (!(typed && focused)) {
|
||||
return [];
|
||||
}
|
||||
const loose = countFacets(groups, { ...filters, text: "" }, ids, keep);
|
||||
return [...loose].flatMap(([id, list]) =>
|
||||
list
|
||||
.filter((c) => c.count > 0 && !filters.facets[id]?.includes(c.key))
|
||||
.map((c) => ({ ...c, facet: LABEL[id], id }))
|
||||
);
|
||||
});
|
||||
const suggestions = $derived(
|
||||
fuzzyFilter(options, typed, (o) => `${o.label} ${o.facet}`, SUGGESTIONS)
|
||||
);
|
||||
const open = $derived(focused && typed !== "");
|
||||
const nextGroup = $derived(
|
||||
[null, ...GROUP_BY][
|
||||
(GROUP_BY.indexOf(filters.group as GroupBy) + 2) % (GROUP_BY.length + 1)
|
||||
]
|
||||
);
|
||||
|
||||
function focus() {
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}
|
||||
|
||||
function say(message: string) {
|
||||
note = message;
|
||||
clearTimeout(noteTimer);
|
||||
noteTimer = setTimeout(() => (note = ""), NOTE_MS);
|
||||
}
|
||||
|
||||
function choose(option: Option | undefined) {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
view.set({
|
||||
...toggleValue(filters, option.id, option.key, true),
|
||||
text: "",
|
||||
});
|
||||
input?.blur();
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const count = suggestions.length;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
cursor = ((cursor + 1 + step + count + 1) % (count + 1)) - 1;
|
||||
} else if (event.key === "Tab" && !event.shiftKey && count > 0) {
|
||||
event.preventDefault();
|
||||
choose(suggestions[Math.max(cursor, 0)]);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (cursor >= 0) {
|
||||
choose(suggestions[cursor]);
|
||||
} else if (typed) {
|
||||
onsearch();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summary(id: FacetId, keys: string[]) {
|
||||
return keys.length === 1
|
||||
? valueLabel(id, keys[0])
|
||||
: `${keys.length} values`;
|
||||
}
|
||||
|
||||
function setGroup(value: string) {
|
||||
view.set({
|
||||
...filters,
|
||||
group: GROUP_BY.includes(value as GroupBy) ? (value as GroupBy) : null,
|
||||
});
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{ group: "Filter", keys: "f", label: "Filter this list", run: focus },
|
||||
{ group: "Filter", keys: "/", label: "Filter this list", run: focus },
|
||||
{
|
||||
group: "Filter",
|
||||
keys: "F",
|
||||
label: "Clear filters",
|
||||
run: () => view.clear(),
|
||||
},
|
||||
{
|
||||
group: "Filter",
|
||||
keys: ";",
|
||||
label: `Group by ${nextGroup ? GROUP_LABEL[nextGroup].toLowerCase() : "nothing"}`,
|
||||
run: () => view.cycleGroup(),
|
||||
},
|
||||
{
|
||||
group: "Filter",
|
||||
keys: "P",
|
||||
label: view.pinned >= 0 ? "Unpin this filter" : "Pin this filter",
|
||||
run: () => say(view.togglePin()),
|
||||
},
|
||||
...view.pins.map((pin, index) => ({
|
||||
group: "Pinned filters",
|
||||
keys: String(index + 1),
|
||||
label: `Filter: ${pin.label}`,
|
||||
run: () => view.apply(pin),
|
||||
})),
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#snippet facetMenu(
|
||||
id: FacetId
|
||||
)}
|
||||
{@const values = counts.get(id) ?? []}
|
||||
{@const selected = filters.facets[id] ?? []}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="{TRIGGER} {selected.length
|
||||
? "text-cladd-fg"
|
||||
: "text-cladd-fg-soft"}"
|
||||
>
|
||||
{#if selected.length}
|
||||
<Funnel class="size-3 text-brand" />
|
||||
{LABEL[id]}:
|
||||
<span class="max-w-40 truncate font-semibold"
|
||||
>{summary(id, selected)}</span
|
||||
>
|
||||
{:else}
|
||||
{LABEL[id]}
|
||||
{/if}
|
||||
<ChevronDown class="size-3 opacity-60" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenuContent align="start" class="w-72 p-1">
|
||||
{#if selected.length}
|
||||
<DropdownMenuItem
|
||||
class={ITEM}
|
||||
onSelect={() =>
|
||||
view.set({ ...filters, facets: { ...filters.facets, [id]: [] } })}
|
||||
>
|
||||
Clear this filter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/if}
|
||||
{#each values as value (value.key)}
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selected.includes(value.key)}
|
||||
class={ITEM}
|
||||
closeOnSelect={false}
|
||||
onCheckedChange={(on) =>
|
||||
view.set(toggleValue(filters, id, value.key, on))}
|
||||
textValue={value.label}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{value.label}</span>
|
||||
<span class="num text-[12px] text-cladd-fg-soft">{value.count}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
{:else}
|
||||
<p class="px-2 py-1 text-cladd-fg-soft">Nothing to filter by</p>
|
||||
{/each}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu.Root>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-1 border-cladd-outline border-b px-2 pt-2 pb-1.5">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div class="relative w-64 max-md:w-full">
|
||||
<Search
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
|
||||
/>
|
||||
<input
|
||||
aria-activedescendant={open ? `ledger-filter-${cursor}` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="ledger-filter-list"
|
||||
aria-expanded={open}
|
||||
aria-label="Filter this list"
|
||||
autocomplete="off"
|
||||
class="cladd-cut cladd-focusable-input h-7 w-full rounded-cladd-md pr-2 pl-7 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
|
||||
id="ledger-filter"
|
||||
onblur={() => (focused = false)}
|
||||
onfocus={() => (focused = true)}
|
||||
oninput={(event) => {
|
||||
cursor = -1;
|
||||
view.type(event.currentTarget.value);
|
||||
}}
|
||||
{onkeydown}
|
||||
placeholder="Filter: text, amount, >100"
|
||||
role="combobox"
|
||||
spellcheck="false"
|
||||
value={view.text}
|
||||
bind:this={input}
|
||||
>
|
||||
{#if open}
|
||||
<div
|
||||
class="cladd-surface cladd-outline absolute top-full left-0 z-30 mt-1 w-80 max-w-[calc(100vw-2rem)] rounded-cladd-md p-1 shadow-cladd-popover"
|
||||
id="ledger-filter-list"
|
||||
role="listbox"
|
||||
>
|
||||
<div
|
||||
aria-selected={cursor === -1}
|
||||
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {cursor ===
|
||||
-1
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"}"
|
||||
id="ledger-filter--1"
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
onsearch();
|
||||
}}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate"
|
||||
>Search Firefly for “{typed}”</span
|
||||
>
|
||||
<span class="kbd">↵</span>
|
||||
</div>
|
||||
{#each suggestions as option, index (`${option.id}:${option.key}`)}
|
||||
<div
|
||||
aria-selected={index === cursor}
|
||||
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {index ===
|
||||
cursor
|
||||
? "bg-row-cursor"
|
||||
: "hover:bg-row-hover"}"
|
||||
id={`ledger-filter-${index}`}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(option);
|
||||
}}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="w-22 shrink-0 text-[12px] text-cladd-fg-soft"
|
||||
>{option.facet}</span
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<span class="num text-[12px] text-cladd-fg-soft"
|
||||
>{option.count}</span
|
||||
>
|
||||
{#if index === Math.max(cursor, 0)}
|
||||
<span class="kbd">Tab</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 items-center max-md:-mx-2 max-md:w-[calc(100%+1rem)] max-md:overflow-x-auto max-md:px-1 md:flex-wrap"
|
||||
>
|
||||
{#each ids as id (id)}
|
||||
{@render facetMenu(id)}
|
||||
{/each}
|
||||
{#if duplicates > 0 || filters.dupes}
|
||||
<button
|
||||
aria-pressed={filters.dupes}
|
||||
class="{TRIGGER} {filters.dupes ? "text-cladd-fg" : "text-warn"}"
|
||||
onclick={() => view.set({ ...filters, dupes: !filters.dupes })}
|
||||
type="button"
|
||||
>
|
||||
{#if filters.dupes}
|
||||
<Funnel class="size-3 text-brand" />
|
||||
{/if}
|
||||
Possible duplicates
|
||||
<span class="num font-semibold">{duplicates}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-6 flex-wrap items-center gap-x-3 gap-y-1 px-1 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
{#each view.pins as pin, index (pin.query)}
|
||||
{@const active = index === view.pinned}
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
<button
|
||||
aria-label={`Filter: ${pin.label}`}
|
||||
aria-pressed={active}
|
||||
class="{LINK} {active ? "font-semibold text-cladd-fg" : ""}"
|
||||
onclick={() => view.apply(pin)}
|
||||
type="button"
|
||||
>
|
||||
<span class="kbd max-md:hidden">{index + 1}</span>
|
||||
{pin.label}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Unpin ${pin.label}`}
|
||||
class="grid size-4 place-items-center rounded text-cladd-fg-softer hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={() => view.unpin(pin)}
|
||||
type="button"
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
<span class="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span aria-live="polite">{note}</span>
|
||||
<span class="flex items-center gap-0.5">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="{TRIGGER} {filters.group
|
||||
? "text-cladd-fg"
|
||||
: "text-cladd-fg-soft"}"
|
||||
>
|
||||
Group by:
|
||||
<span class="font-semibold"
|
||||
>{filters.group ? GROUP_LABEL[filters.group] : "None"}</span
|
||||
>
|
||||
<ChevronDown class="size-3 opacity-60" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenuContent align="end" class="w-44 p-1">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={setGroup}
|
||||
value={filters.group ?? "none"}
|
||||
>
|
||||
<DropdownMenuRadioItem class={ITEM} value="none"
|
||||
>None</DropdownMenuRadioItem
|
||||
>
|
||||
{#each GROUP_BY as value (value)}
|
||||
<DropdownMenuRadioItem class={ITEM} {value}
|
||||
>{GROUP_LABEL[value]}</DropdownMenuRadioItem
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu.Root>
|
||||
<span class="kbd max-md:hidden">;</span>
|
||||
</span>
|
||||
<span class="num text-cladd-fg" id="ledger-count">
|
||||
{#if isFiltered(filters)}
|
||||
<strong>{shown}</strong>
|
||||
of {total}
|
||||
{:else}
|
||||
{total}
|
||||
{total === 1 ? "transaction" : "transactions"}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isFiltered(filters)}
|
||||
<button class={LINK} onclick={() => view.clear()} type="button">
|
||||
Clear <span class="kbd max-md:hidden">F</span>
|
||||
</button>
|
||||
<button
|
||||
class={LINK}
|
||||
onclick={() => say(view.togglePin())}
|
||||
type="button"
|
||||
>
|
||||
{view.pinned >= 0 ? "Unpin" : "Pin"}
|
||||
<span class="kbd max-md:hidden">P</span>
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,7 +5,6 @@
|
||||
import {
|
||||
type Group,
|
||||
type Signed,
|
||||
signedAmount,
|
||||
sumByCurrency,
|
||||
} from "#lib/transactions/split.js";
|
||||
|
||||
@@ -16,9 +15,10 @@
|
||||
["transfer", "Transferred"],
|
||||
] as const;
|
||||
|
||||
interface Entry extends Signed {
|
||||
interface Entry {
|
||||
href?: string;
|
||||
name: string;
|
||||
sums: Signed[];
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -36,25 +36,29 @@
|
||||
})).filter((row) => row.sums.length > 0)
|
||||
);
|
||||
|
||||
const size = (entry: Entry) =>
|
||||
Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)));
|
||||
|
||||
function aggregate(
|
||||
list: Split[],
|
||||
pick: (split: Split) => [string, string | undefined]
|
||||
): { entries: Entry[]; more: number } {
|
||||
const map = new Map<string, Entry>();
|
||||
const map = new Map<string, { href?: string; splits: Split[] }>();
|
||||
for (const split of list) {
|
||||
const [name, href] = pick(split);
|
||||
const money = signedAmount(split);
|
||||
const key = `${name}|${money.code}`;
|
||||
const entry = map.get(key);
|
||||
if (entry) {
|
||||
entry.amount += money.amount;
|
||||
} else {
|
||||
map.set(key, { ...money, href, name });
|
||||
}
|
||||
const entry = map.get(name) ?? { href, splits: [] };
|
||||
entry.splits.push(split);
|
||||
map.set(name, entry);
|
||||
}
|
||||
const sorted = [...map.values()].sort(
|
||||
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
|
||||
);
|
||||
const sorted = [...map]
|
||||
.map(([name, entry]) => ({
|
||||
href: entry.href,
|
||||
name,
|
||||
sums: sumByCurrency(entry.splits).sort(
|
||||
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => size(b) - size(a));
|
||||
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
|
||||
}
|
||||
|
||||
@@ -127,8 +131,10 @@
|
||||
{#each blocks as block (block.title)}
|
||||
<Box body="py-1" class="max-md:hidden" title={block.title}>
|
||||
<ul>
|
||||
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
|
||||
<li class="flex h-6 items-center justify-between gap-3 px-3">
|
||||
{#each block.entries as entry (entry.name)}
|
||||
<li
|
||||
class="flex min-h-6 items-start justify-between gap-3 px-3 py-0.5"
|
||||
>
|
||||
{#if entry.href}
|
||||
<a class="truncate hover:underline" href={entry.href}
|
||||
>{entry.name}</a
|
||||
@@ -136,7 +142,11 @@
|
||||
{:else}
|
||||
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
||||
{/if}
|
||||
{@render amount(entry)}
|
||||
<span class="flex flex-col items-end">
|
||||
{#each entry.sums as sum (sum.code)}
|
||||
{@render amount(sum)}
|
||||
{/each}
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-3 py-1 text-cladd-fg-soft">
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import NotebookText from "@lucide/svelte/icons/notebook-text";
|
||||
import Paperclip from "@lucide/svelte/icons/paperclip";
|
||||
import { untrack } from "svelte";
|
||||
import type { Split } from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -17,7 +19,14 @@
|
||||
import type { Binding } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { buildRows, type Row } from "#lib/transactions/mass.js";
|
||||
import { type Section, subtotals } from "#lib/transactions/ledger.js";
|
||||
import {
|
||||
actionLabel,
|
||||
buildRows,
|
||||
type Mode,
|
||||
type Row,
|
||||
} from "#lib/transactions/mass.js";
|
||||
import { lastAction } from "#lib/transactions/repeat.svelte.js";
|
||||
import {
|
||||
accountUrl,
|
||||
balanceAfter,
|
||||
@@ -34,7 +43,7 @@
|
||||
} from "#lib/transactions/split.js";
|
||||
import { ui } from "#lib/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import ActionBar, { type Mode } from "./ActionBar.svelte";
|
||||
import ActionBar from "./ActionBar.svelte";
|
||||
import TypeIcon from "./TypeIcon.svelte";
|
||||
|
||||
let {
|
||||
@@ -44,6 +53,7 @@
|
||||
loading = false,
|
||||
id = "transactions",
|
||||
empty = "No transactions.",
|
||||
section,
|
||||
}: {
|
||||
groups: Group[];
|
||||
context?: { accountId?: string };
|
||||
@@ -51,8 +61,15 @@
|
||||
loading?: boolean;
|
||||
id?: string;
|
||||
empty?: string;
|
||||
section?: (group: Group) => Section;
|
||||
} = $props();
|
||||
|
||||
interface Block extends Section {
|
||||
groups: Group[];
|
||||
keys: string[];
|
||||
sums: Signed[];
|
||||
}
|
||||
|
||||
let collapsed = $state(new Set<string>());
|
||||
let opened = $state(new Set<string>());
|
||||
let bar = $state<ActionBar>();
|
||||
@@ -65,6 +82,56 @@
|
||||
|
||||
const accountId = $derived(context.accountId);
|
||||
const rows = $derived(buildRows(groups, collapsed));
|
||||
const blocks = $derived.by(() => {
|
||||
const starts = new Map<number, Block>();
|
||||
if (!section) {
|
||||
return starts;
|
||||
}
|
||||
const byKey = new Map<string, Block>();
|
||||
let previous: string | null = null;
|
||||
rows.forEach((row, index) => {
|
||||
const value = section(row.group);
|
||||
let block = byKey.get(value.key);
|
||||
if (!block) {
|
||||
block = { ...value, groups: [], keys: [], sums: [] };
|
||||
byKey.set(value.key, block);
|
||||
}
|
||||
if (row.kind !== "split") {
|
||||
block.groups.push(row.group);
|
||||
}
|
||||
block.keys.push(row.key);
|
||||
if (value.key !== previous) {
|
||||
starts.set(index, block);
|
||||
previous = value.key;
|
||||
}
|
||||
});
|
||||
for (const block of byKey.values()) {
|
||||
block.sums = subtotals(block.groups, context.accountId);
|
||||
}
|
||||
return starts;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const present = new Set(rows.map((row) => row.key));
|
||||
untrack(() => {
|
||||
const kept = [...nav.selected].filter((key) => present.has(key));
|
||||
if (kept.length !== nav.selected.size) {
|
||||
nav.selected = new Set(kept);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function selectKeys(keys: string[], on: boolean) {
|
||||
const next = new Set(nav.selected);
|
||||
for (const key of keys) {
|
||||
if (on) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
nav.selected = next;
|
||||
}
|
||||
|
||||
function toggled(set: Set<string>, key: string, on: boolean) {
|
||||
const next = new Set(set);
|
||||
@@ -157,6 +224,7 @@
|
||||
list.push(
|
||||
action("category", "Set category", "s c"),
|
||||
action("budget", "Set budget", "s b"),
|
||||
action("bill", "Set subscription", "s s"),
|
||||
action("tag", "Add tag", "s t"),
|
||||
action("untag", "Remove tag", "s T"),
|
||||
{
|
||||
@@ -167,6 +235,14 @@
|
||||
}
|
||||
);
|
||||
}
|
||||
if (selectable && lastAction.current) {
|
||||
list.push({
|
||||
group: "Selection",
|
||||
keys: "s .",
|
||||
label: `Repeat: ${actionLabel(lastAction.current)}`,
|
||||
run: () => bar?.repeat(),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -402,6 +478,77 @@
|
||||
</td>
|
||||
{/snippet}
|
||||
|
||||
{#snippet blockRow(
|
||||
block: Block
|
||||
)}
|
||||
{@const all = block.keys.every((key) => nav.selected.has(key))}
|
||||
<tr
|
||||
class="border-cladd-outline border-t bg-cladd-surface-next [&>td]:px-2 [&>td]:py-1"
|
||||
>
|
||||
{#if selectable}
|
||||
<td class="max-sm:hidden">
|
||||
<input
|
||||
aria-label={`Select ${block.label}`}
|
||||
checked={all}
|
||||
class={CHECKBOX}
|
||||
onclick={(event) => {
|
||||
event.currentTarget.blur();
|
||||
selectKeys(block.keys, !all);
|
||||
}}
|
||||
type="checkbox"
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
<td class="max-sm:hidden"></td>
|
||||
<td class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if block.href}
|
||||
<a class="truncate font-semibold hover:underline" href={block.href}
|
||||
>{block.label}</a
|
||||
>
|
||||
{:else}
|
||||
<span class="truncate font-semibold">{block.label}</span>
|
||||
{/if}
|
||||
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||
>{block.groups.length}</span
|
||||
>
|
||||
</div>
|
||||
{#if block.actions?.length}
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
{#each block.actions as item (item.label)}
|
||||
{#if item.confirm}
|
||||
<ConfirmButton onconfirm={item.run}>{item.label}</ConfirmButton>
|
||||
{:else}
|
||||
<button
|
||||
class="inline-flex h-cladd-sm items-center rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={item.run}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-right font-semibold">
|
||||
{#each block.sums as sum (`${sum.tone}|${sum.code}`)}
|
||||
<div>{@render money(sum)}</div>
|
||||
{/each}
|
||||
</td>
|
||||
<td class="max-lg:hidden"></td>
|
||||
{#if accountId}
|
||||
<td></td>
|
||||
{/if}
|
||||
<td></td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td class="max-lg:hidden"></td>
|
||||
<td class="max-xl:hidden"></td>
|
||||
<td class="max-2xl:hidden"></td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
{#snippet headCells(
|
||||
row: Row
|
||||
)}
|
||||
@@ -534,6 +681,10 @@
|
||||
</thead>
|
||||
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
|
||||
{#each rows as row, index (row.key)}
|
||||
{@const block = blocks.get(index)}
|
||||
{#if block}
|
||||
{@render blockRow(block)}
|
||||
{/if}
|
||||
<tr
|
||||
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
|
||||
data-index={index}
|
||||
@@ -597,6 +748,9 @@
|
||||
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
|
||||
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => bar?.start("bill", menuRows)}>
|
||||
Set subscription... <ContextMenuShortcut>s s</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
|
||||
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
describe,
|
||||
type Filters,
|
||||
filterParams,
|
||||
GROUP_BY,
|
||||
isFiltered,
|
||||
readFilters,
|
||||
withFilters,
|
||||
} from "./ledger.js";
|
||||
|
||||
const TYPING_MS = 300;
|
||||
const MAX_PINS = 9;
|
||||
const PINS_KEY = "ffui.ledger.pins";
|
||||
const IGNORED_KEY = "ffui.ledger.notDuplicates";
|
||||
|
||||
export interface Pin {
|
||||
label: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PINS: Pin[] = [
|
||||
{ label: "No budget", query: "budget=none" },
|
||||
{ label: "No category", query: "category=none" },
|
||||
{ label: "With notes", query: "notes=yes" },
|
||||
];
|
||||
|
||||
function stored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const pinQuery = (filters: Filters) =>
|
||||
filterParams({ ...filters, group: null }).toString();
|
||||
|
||||
export class LedgerView {
|
||||
text = $state("");
|
||||
pins = $state<Pin[]>(stored(PINS_KEY, DEFAULT_PINS));
|
||||
ignored = $state(new Set<string>(stored<string[]>(IGNORED_KEY, [])));
|
||||
private written = "";
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
url = $derived(readFilters(page.url.searchParams));
|
||||
filters = $derived<Filters>({ ...this.url, text: this.text });
|
||||
pinned = $derived(
|
||||
this.pins.findIndex((pin) => pin.query === pinQuery(this.filters))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.text = this.url.text;
|
||||
this.written = this.text;
|
||||
$effect(() => {
|
||||
const incoming = this.url.text;
|
||||
untrack(() => {
|
||||
if (incoming !== this.written) {
|
||||
this.written = incoming;
|
||||
this.text = incoming;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private go(filters: Filters) {
|
||||
this.written = filters.text;
|
||||
goto(withFilters(page.url, filters), { replace: true, reset: false });
|
||||
}
|
||||
|
||||
set(next: Filters) {
|
||||
clearTimeout(this.timer);
|
||||
this.text = next.text;
|
||||
this.go(next);
|
||||
}
|
||||
|
||||
type(value: string) {
|
||||
this.text = value;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(
|
||||
() => this.go({ ...this.url, text: value }),
|
||||
TYPING_MS
|
||||
);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.set({ ...this.filters, dupes: false, facets: {}, text: "" });
|
||||
}
|
||||
|
||||
cycleGroup() {
|
||||
const order = [null, ...GROUP_BY];
|
||||
const next = order[(order.indexOf(this.filters.group) + 1) % order.length];
|
||||
this.set({ ...this.filters, group: next });
|
||||
}
|
||||
|
||||
apply(pin: Pin) {
|
||||
const active = pin.query === pinQuery(this.filters);
|
||||
const next = readFilters(new URLSearchParams(active ? "" : pin.query));
|
||||
this.set({ ...next, group: this.filters.group });
|
||||
}
|
||||
|
||||
private savePins(pins: Pin[]) {
|
||||
this.pins = pins;
|
||||
localStorage.setItem(PINS_KEY, JSON.stringify(pins));
|
||||
}
|
||||
|
||||
togglePin(): string {
|
||||
if (this.pinned >= 0) {
|
||||
const { label } = this.pins[this.pinned];
|
||||
this.savePins(this.pins.filter((_, index) => index !== this.pinned));
|
||||
return `Unpinned ${label}`;
|
||||
}
|
||||
if (!isFiltered(this.filters)) {
|
||||
return "Filter first, then pin";
|
||||
}
|
||||
if (this.pins.length >= MAX_PINS) {
|
||||
return `Up to ${MAX_PINS} pins`;
|
||||
}
|
||||
const label = describe({ ...this.filters, group: null });
|
||||
this.savePins([...this.pins, { label, query: pinQuery(this.filters) }]);
|
||||
return `Pinned as ${this.pins.length}`;
|
||||
}
|
||||
|
||||
unpin(pin: Pin) {
|
||||
this.savePins(this.pins.filter((item) => item.query !== pin.query));
|
||||
}
|
||||
|
||||
ignore(id: string) {
|
||||
this.ignored = new Set([...this.ignored, id]);
|
||||
localStorage.setItem(IGNORED_KEY, JSON.stringify([...this.ignored]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
import type { Split } from "#lib/api/types.js";
|
||||
import { addDays, addMonths, type Range } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney } from "#lib/format.js";
|
||||
import {
|
||||
accountUrl,
|
||||
type Group,
|
||||
type Signed,
|
||||
signedAmount,
|
||||
TYPE_LABEL,
|
||||
} from "./split.js";
|
||||
|
||||
export type FacetId =
|
||||
| "kind"
|
||||
| "category"
|
||||
| "budget"
|
||||
| "account"
|
||||
| "currency"
|
||||
| "tag"
|
||||
| "bill"
|
||||
| "notes"
|
||||
| "foreign";
|
||||
export type GroupBy = "day" | "destination" | "category" | "budget";
|
||||
|
||||
export interface Facet {
|
||||
id: FacetId;
|
||||
keys: (split: Split) => string[];
|
||||
label: string;
|
||||
names?: Record<string, string>;
|
||||
none?: string;
|
||||
}
|
||||
|
||||
export interface Count {
|
||||
count: number;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
dupes: boolean;
|
||||
facets: Partial<Record<FacetId, string[]>>;
|
||||
group: GroupBy | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
actions?: {
|
||||
confirm?: boolean;
|
||||
label: string;
|
||||
run: () => void | Promise<void>;
|
||||
}[];
|
||||
href?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Cluster {
|
||||
groups: Group[];
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const NONE = "none";
|
||||
export const GROUP_BY: GroupBy[] = ["day", "destination", "category", "budget"];
|
||||
export const GROUP_LABEL: Record<GroupBy, string> = {
|
||||
budget: "Budget",
|
||||
category: "Category",
|
||||
day: "Day",
|
||||
destination: "Destination",
|
||||
};
|
||||
|
||||
const OWN = new Set([
|
||||
"Asset account",
|
||||
"Default account",
|
||||
"Debt",
|
||||
"Loan",
|
||||
"Mortgage",
|
||||
]);
|
||||
const TONES = ["expense", "income", "transfer", "neutral"];
|
||||
const WINDOW_MS = 48 * 3600 * 1000;
|
||||
const HOUR_MS = 3600 * 1000;
|
||||
const WHITESPACE = /\s+/;
|
||||
const COMPARE = /^([<>])(\d+(?:[.,]\d+)?)$/;
|
||||
const NEEDS_QUOTES = /[\s"]/;
|
||||
const QUOTE = /"/g;
|
||||
const PARENS = /\(([^)]*)\)/g;
|
||||
const YES_NO = (yes: string, no: string) => ({ no, yes });
|
||||
const flag = (on: boolean) => [on ? "yes" : "no"];
|
||||
const named = (name: string | null | undefined, applies: boolean) => {
|
||||
if (name) {
|
||||
return [name];
|
||||
}
|
||||
return applies ? [NONE] : [];
|
||||
};
|
||||
|
||||
export const FACETS: Facet[] = [
|
||||
{
|
||||
id: "kind",
|
||||
keys: (s) => [s.type],
|
||||
label: "Type",
|
||||
names: TYPE_LABEL,
|
||||
},
|
||||
{
|
||||
id: "category",
|
||||
keys: (s) => named(s.category_name, true),
|
||||
label: "Category",
|
||||
none: "(no category)",
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
keys: (s) => named(s.budget_name, s.type === "withdrawal"),
|
||||
label: "Budget",
|
||||
none: "(no budget)",
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
keys: (s) =>
|
||||
[
|
||||
OWN.has(s.source_type ?? "") ? s.source_name : null,
|
||||
OWN.has(s.destination_type ?? "") ? s.destination_name : null,
|
||||
].filter((name): name is string => Boolean(name)),
|
||||
label: "Account",
|
||||
},
|
||||
{
|
||||
id: "currency",
|
||||
keys: (s) =>
|
||||
[s.currency_code, s.foreign_currency_code].filter(
|
||||
(code): code is string => Boolean(code)
|
||||
),
|
||||
label: "Currency",
|
||||
},
|
||||
{
|
||||
id: "tag",
|
||||
keys: (s) => (s.tags?.length ? s.tags : [NONE]),
|
||||
label: "Tag",
|
||||
none: "(no tags)",
|
||||
},
|
||||
{
|
||||
id: "bill",
|
||||
keys: (s) =>
|
||||
named(s.bill_name ?? s.subscription_name, s.type === "withdrawal"),
|
||||
label: "Subscription",
|
||||
none: "(no subscription)",
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
keys: (s) => flag(Boolean(s.notes?.trim())),
|
||||
label: "Notes",
|
||||
names: YES_NO("With notes", "Without notes"),
|
||||
},
|
||||
{
|
||||
id: "foreign",
|
||||
keys: (s) => flag(Boolean(s.foreign_amount && s.foreign_currency_code)),
|
||||
label: "Foreign",
|
||||
names: YES_NO("With foreign amount", "Without foreign amount"),
|
||||
},
|
||||
];
|
||||
|
||||
const FACET = Object.fromEntries(FACETS.map((f) => [f.id, f])) as Record<
|
||||
FacetId,
|
||||
Facet
|
||||
>;
|
||||
const PARAMS = ["q", "group", "dupes", "page", ...FACETS.map((f) => f.id)];
|
||||
|
||||
export function valueLabel(id: FacetId, key: string): string {
|
||||
const facet = FACET[id];
|
||||
if (key === NONE && facet.none) {
|
||||
return facet.none;
|
||||
}
|
||||
return facet.names?.[key] ?? key;
|
||||
}
|
||||
|
||||
export function readFilters(
|
||||
params: Pick<URLSearchParams, "get" | "getAll">
|
||||
): Filters {
|
||||
const facets: Filters["facets"] = {};
|
||||
for (const facet of FACETS) {
|
||||
const values = params.getAll(facet.id).filter(Boolean);
|
||||
if (values.length > 0) {
|
||||
facets[facet.id] = [...new Set(values)];
|
||||
}
|
||||
}
|
||||
const group = params.get("group") as GroupBy;
|
||||
return {
|
||||
dupes: params.get("dupes") === "1",
|
||||
facets,
|
||||
group: GROUP_BY.includes(group) ? group : null,
|
||||
text: params.get("q") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function filterParams(filters: Filters): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
for (const facet of FACETS) {
|
||||
for (const value of [...(filters.facets[facet.id] ?? [])].sort()) {
|
||||
params.append(facet.id, value);
|
||||
}
|
||||
}
|
||||
if (filters.text.trim()) {
|
||||
params.set("q", filters.text);
|
||||
}
|
||||
if (filters.group) {
|
||||
params.set("group", filters.group);
|
||||
}
|
||||
if (filters.dupes) {
|
||||
params.set("dupes", "1");
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function withFilters(url: { href: string }, filters: Filters): URL {
|
||||
const next = new URL(url.href);
|
||||
for (const name of PARAMS) {
|
||||
next.searchParams.delete(name);
|
||||
}
|
||||
for (const [name, value] of filterParams(filters)) {
|
||||
next.searchParams.append(name, value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isFiltered(filters: Filters): boolean {
|
||||
return (
|
||||
filters.dupes ||
|
||||
filters.text.trim() !== "" ||
|
||||
Object.values(filters.facets).some((values) => values.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleValue(
|
||||
filters: Filters,
|
||||
id: FacetId,
|
||||
key: string,
|
||||
on?: boolean
|
||||
): Filters {
|
||||
const current = new Set(filters.facets[id] ?? []);
|
||||
if (on ?? !current.has(key)) {
|
||||
current.add(key);
|
||||
} else {
|
||||
current.delete(key);
|
||||
}
|
||||
return { ...filters, facets: { ...filters.facets, [id]: [...current] } };
|
||||
}
|
||||
|
||||
const haystacks = new WeakMap<Split, string>();
|
||||
|
||||
function plainAmount(
|
||||
value: string | null | undefined,
|
||||
decimals?: number | null
|
||||
) {
|
||||
return value ? Number(value).toFixed(decimals ?? 2) : "";
|
||||
}
|
||||
|
||||
function haystack(split: Split): string {
|
||||
let text = haystacks.get(split);
|
||||
if (text === undefined) {
|
||||
text = [
|
||||
split.description,
|
||||
split.notes,
|
||||
split.source_name,
|
||||
split.destination_name,
|
||||
split.category_name,
|
||||
split.budget_name,
|
||||
split.bill_name,
|
||||
split.currency_code,
|
||||
split.foreign_currency_code,
|
||||
plainAmount(split.amount, split.currency_decimal_places),
|
||||
plainAmount(split.foreign_amount, split.foreign_currency_decimal_places),
|
||||
...(split.tags ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
haystacks.set(split, text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function wordTest(word: string): (split: Split) => boolean {
|
||||
const match = COMPARE.exec(word);
|
||||
if (!match) {
|
||||
return (split) => haystack(split).includes(word);
|
||||
}
|
||||
const limit = Number(match[2].replace(",", "."));
|
||||
const size = (split: Split) => Math.abs(Number(split.amount));
|
||||
return match[1] === ">"
|
||||
? (split) => size(split) > limit
|
||||
: (split) => size(split) < limit;
|
||||
}
|
||||
|
||||
export function splitTest(
|
||||
filters: Filters,
|
||||
skip?: FacetId
|
||||
): (split: Split) => boolean {
|
||||
const words = filters.text
|
||||
.toLowerCase()
|
||||
.split(WHITESPACE)
|
||||
.filter((word) => word && !word.includes(":"))
|
||||
.map(wordTest);
|
||||
const active = FACETS.filter(
|
||||
(facet) => facet.id !== skip && filters.facets[facet.id]?.length
|
||||
).map((facet) => ({ facet, wanted: new Set(filters.facets[facet.id]) }));
|
||||
return (split) =>
|
||||
active.every(({ facet, wanted }) =>
|
||||
facet.keys(split).some((key) => wanted.has(key))
|
||||
) && words.every((test) => test(split));
|
||||
}
|
||||
|
||||
export function narrow(
|
||||
groups: Group[],
|
||||
test: (split: Split) => boolean,
|
||||
keep: (group: Group) => boolean = () => true
|
||||
): Group[] {
|
||||
const out: Group[] = [];
|
||||
for (const group of groups) {
|
||||
if (!keep(group)) {
|
||||
continue;
|
||||
}
|
||||
const splits = group.attributes.transactions;
|
||||
const kept = splits.filter(test);
|
||||
if (kept.length === splits.length) {
|
||||
out.push(group);
|
||||
} else if (kept.length > 0) {
|
||||
out.push({
|
||||
...group,
|
||||
attributes: { ...group.attributes, transactions: kept },
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function countFacets(
|
||||
groups: Group[],
|
||||
filters: Filters,
|
||||
ids: FacetId[],
|
||||
keep: (group: Group) => boolean
|
||||
): Map<FacetId, Count[]> {
|
||||
const result = new Map<FacetId, Count[]>();
|
||||
for (const id of ids) {
|
||||
const facet = FACET[id];
|
||||
const test = splitTest(filters, id);
|
||||
const found = new Map<string, Set<string>>(
|
||||
(filters.facets[id] ?? []).map((key) => [key, new Set()])
|
||||
);
|
||||
for (const group of groups) {
|
||||
if (!keep(group)) {
|
||||
continue;
|
||||
}
|
||||
for (const split of group.attributes.transactions) {
|
||||
if (!test(split)) {
|
||||
continue;
|
||||
}
|
||||
for (const key of facet.keys(split)) {
|
||||
const seen = found.get(key) ?? new Set<string>();
|
||||
seen.add(group.id);
|
||||
found.set(key, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
const counts = [...found].map(([key, seen]) => ({
|
||||
count: seen.size,
|
||||
key,
|
||||
label: valueLabel(id, key),
|
||||
}));
|
||||
counts.sort(
|
||||
(a, b) =>
|
||||
Number(b.key === NONE) - Number(a.key === NONE) ||
|
||||
b.count - a.count ||
|
||||
a.label.localeCompare(b.label)
|
||||
);
|
||||
result.set(id, counts);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sectionOf(by: GroupBy, group: Group): Section {
|
||||
const [split] = group.attributes.transactions;
|
||||
switch (by) {
|
||||
case "day": {
|
||||
const day = split?.date?.slice(0, 10) ?? "";
|
||||
return { key: day, label: formatDate(day, "day") };
|
||||
}
|
||||
case "destination":
|
||||
return {
|
||||
href: accountUrl(split?.destination_id),
|
||||
key: `account:${split?.destination_id ?? ""}`,
|
||||
label: split?.destination_name ?? "(cash)",
|
||||
};
|
||||
case "category":
|
||||
return {
|
||||
href: split?.category_id
|
||||
? `/categories/show/${split.category_id}`
|
||||
: undefined,
|
||||
key: split?.category_name ?? NONE,
|
||||
label: split?.category_name ?? "(no category)",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
href: split?.budget_id ? `/budgets/show/${split.budget_id}` : undefined,
|
||||
key: split?.budget_name ?? NONE,
|
||||
label: split?.budget_name ?? "(no budget)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function bySection(
|
||||
groups: Group[],
|
||||
section: (group: Group) => Section
|
||||
): Group[] {
|
||||
const buckets = new Map<string, Group[]>();
|
||||
for (const group of groups) {
|
||||
const { key } = section(group);
|
||||
const list = buckets.get(key) ?? [];
|
||||
list.push(group);
|
||||
buckets.set(key, list);
|
||||
}
|
||||
return [...buckets]
|
||||
.sort(
|
||||
([a, left], [b, right]) =>
|
||||
Number(b === NONE) - Number(a === NONE) || right.length - left.length
|
||||
)
|
||||
.flatMap(([, list]) => list);
|
||||
}
|
||||
|
||||
export function subtotals(groups: Group[], accountId?: string): Signed[] {
|
||||
const sums = new Map<string, Signed>();
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions) {
|
||||
const money = signedAmount(split, accountId);
|
||||
const key = `${money.tone}|${money.code}`;
|
||||
const sum = sums.get(key);
|
||||
if (sum) {
|
||||
sum.amount += money.amount;
|
||||
} else {
|
||||
sums.set(key, { ...money });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...sums.values()].sort(
|
||||
(a, b) =>
|
||||
TONES.indexOf(a.tone) - TONES.indexOf(b.tone) ||
|
||||
(a.code ?? "").localeCompare(b.code ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function newestFirst(a: Group, b: Group) {
|
||||
const dateA = a.attributes.transactions[0]?.date ?? "";
|
||||
const dateB = b.attributes.transactions[0]?.date ?? "";
|
||||
return dateB.localeCompare(dateA) || Number(b.id) - Number(a.id);
|
||||
}
|
||||
|
||||
function spanLabel(ms: number): string {
|
||||
const hours = Math.max(1, Math.round(ms / HOUR_MS));
|
||||
if (hours < 24) {
|
||||
return `${hours} h`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return days === 1 ? "1 day" : `${days} days`;
|
||||
}
|
||||
|
||||
function clusterOf(
|
||||
run: { group: Group; time: number }[],
|
||||
split: Split
|
||||
): Cluster {
|
||||
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
|
||||
const span = (run.at(-1)?.time ?? 0) - (run[0]?.time ?? 0);
|
||||
const money = formatMoney({
|
||||
amount: split.amount,
|
||||
code: split.currency_code,
|
||||
decimals: split.currency_decimal_places,
|
||||
symbol: split.currency_symbol,
|
||||
});
|
||||
return {
|
||||
groups,
|
||||
id: groups
|
||||
.map((group) => group.id)
|
||||
.sort()
|
||||
.join("+"),
|
||||
label: `${split.destination_name ?? "(cash)"} · ${money} · ${spanLabel(span)} apart`,
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicates(
|
||||
groups: Group[],
|
||||
ignored: Set<string>
|
||||
): Map<string, Cluster> {
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ group: Group; split: Split; time: number }[]
|
||||
>();
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions) {
|
||||
if (!split.destination_id) {
|
||||
continue;
|
||||
}
|
||||
const key = `${split.type}|${split.destination_id}|${split.currency_code}|${Number(split.amount)}`;
|
||||
const list = buckets.get(key) ?? [];
|
||||
list.push({ group, split, time: Date.parse(split.date) });
|
||||
buckets.set(key, list);
|
||||
}
|
||||
}
|
||||
const found = new Map<string, Cluster>();
|
||||
const flush = (run: { group: Group; split: Split; time: number }[]) => {
|
||||
if (new Set(run.map((item) => item.group.id)).size < 2) {
|
||||
return;
|
||||
}
|
||||
const cluster = clusterOf(run, run[0].split);
|
||||
if (ignored.has(cluster.id)) {
|
||||
return;
|
||||
}
|
||||
for (const group of cluster.groups) {
|
||||
found.set(group.id, cluster);
|
||||
}
|
||||
};
|
||||
for (const list of buckets.values()) {
|
||||
list.sort((a, b) => a.time - b.time);
|
||||
let run: typeof list = [];
|
||||
for (const item of list) {
|
||||
const last = run.at(-1);
|
||||
if (last && item.time - last.time > WINDOW_MS) {
|
||||
flush(run);
|
||||
run = [];
|
||||
}
|
||||
run.push(item);
|
||||
}
|
||||
flush(run);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function accountIdOf(groups: Group[], name: string): string | undefined {
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions) {
|
||||
if (split.source_name === name && OWN.has(split.source_type ?? "")) {
|
||||
return split.source_id ?? undefined;
|
||||
}
|
||||
if (
|
||||
split.destination_name === name &&
|
||||
OWN.has(split.destination_type ?? "")
|
||||
) {
|
||||
return split.destination_id ?? undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function yearRange(end: string): Range {
|
||||
return { end, start: addDays(addMonths(end, -12), 1) };
|
||||
}
|
||||
|
||||
const quote = (value: string) =>
|
||||
NEEDS_QUOTES.test(value) ? `"${value.replace(QUOTE, '\\"')}"` : value;
|
||||
|
||||
const OPERATOR: Partial<Record<FacetId, [string, string]>> = {
|
||||
account: ["account_is", ""],
|
||||
bill: ["bill_is", "has_no_bill:true"],
|
||||
budget: ["budget_is", "has_no_budget:true"],
|
||||
category: ["category_is", "has_no_category:true"],
|
||||
currency: ["currency_is", ""],
|
||||
kind: ["type", ""],
|
||||
tag: ["tag_is", "has_no_tag:true"],
|
||||
};
|
||||
const FLAG_OPERATOR: Record<string, Record<string, string>> = {
|
||||
foreign: { no: "-has_foreign_amount:true", yes: "has_foreign_amount:true" },
|
||||
notes: { no: "no_notes:true", yes: "has_any_notes:true" },
|
||||
};
|
||||
|
||||
export function searchQuery(
|
||||
filters: Filters,
|
||||
type: string,
|
||||
range: Range | null
|
||||
): string {
|
||||
const parts = [filters.text.trim()];
|
||||
if (type !== "all") {
|
||||
parts.push(`type:${type}`);
|
||||
}
|
||||
for (const [id, values] of Object.entries(filters.facets)) {
|
||||
if (values?.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const [value] = values;
|
||||
const flagged = FLAG_OPERATOR[id]?.[value];
|
||||
const [operator, none] = OPERATOR[id as FacetId] ?? ["", ""];
|
||||
if (flagged) {
|
||||
parts.push(flagged);
|
||||
} else if (value === NONE && none) {
|
||||
parts.push(none);
|
||||
} else if (operator) {
|
||||
parts.push(`${operator}:${quote(value)}`);
|
||||
}
|
||||
}
|
||||
if (range) {
|
||||
parts.push(`date_after:${range.start}`, `date_before:${range.end}`);
|
||||
}
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function describe(filters: Filters): string {
|
||||
const parts: string[] = [];
|
||||
for (const facet of FACETS) {
|
||||
const values = filters.facets[facet.id] ?? [];
|
||||
if (values.length > 0) {
|
||||
parts.push(values.map((key) => valueLabel(facet.id, key)).join(" or "));
|
||||
}
|
||||
}
|
||||
if (filters.text.trim()) {
|
||||
parts.push(`“${filters.text.trim()}”`);
|
||||
}
|
||||
if (filters.dupes) {
|
||||
parts.push("Duplicates");
|
||||
}
|
||||
if (filters.group) {
|
||||
parts.push(`by ${GROUP_LABEL[filters.group].toLowerCase()}`);
|
||||
}
|
||||
const label = parts.join(", ").replace(PARENS, "$1");
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
@@ -4,6 +4,86 @@ import { type Group, groupTitle, journalId } from "./split.js";
|
||||
|
||||
export type Patch = Record<string, unknown>;
|
||||
export type Targets = Map<string, Set<string> | null>;
|
||||
export type Mode = "category" | "budget" | "bill" | "tag" | "untag";
|
||||
|
||||
export interface MassAction {
|
||||
id: string | null;
|
||||
mode: Mode;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export const MODES: {
|
||||
keys: string;
|
||||
label: string;
|
||||
mode: Mode;
|
||||
none?: string;
|
||||
verb: string;
|
||||
}[] = [
|
||||
{
|
||||
keys: "s c",
|
||||
label: "Category",
|
||||
mode: "category",
|
||||
none: "(no category)",
|
||||
verb: "Set category",
|
||||
},
|
||||
{
|
||||
keys: "s b",
|
||||
label: "Budget",
|
||||
mode: "budget",
|
||||
none: "(no budget)",
|
||||
verb: "Set budget",
|
||||
},
|
||||
{
|
||||
keys: "s s",
|
||||
label: "Subscription",
|
||||
mode: "bill",
|
||||
none: "(no subscription)",
|
||||
verb: "Set subscription",
|
||||
},
|
||||
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
|
||||
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
|
||||
];
|
||||
|
||||
const MODE = Object.fromEntries(MODES.map((m) => [m.mode, m]));
|
||||
|
||||
export function actionLabel(action: MassAction): string {
|
||||
const { verb, none } = MODE[action.mode];
|
||||
return `${verb} “${action.value ?? none}”`;
|
||||
}
|
||||
|
||||
export function patchFor({
|
||||
mode,
|
||||
value,
|
||||
id,
|
||||
}: MassAction): (split: Split) => Patch | null {
|
||||
switch (mode) {
|
||||
case "budget":
|
||||
return (split) =>
|
||||
split.type !== "withdrawal" || (split.budget_id ?? null) === id
|
||||
? null
|
||||
: { budget_id: id };
|
||||
case "bill":
|
||||
return (split) =>
|
||||
split.type !== "withdrawal" || (split.bill_id ?? null) === id
|
||||
? null
|
||||
: { bill_id: id };
|
||||
case "category":
|
||||
return (split) =>
|
||||
(split.category_name ?? null) === value
|
||||
? null
|
||||
: { category_name: value };
|
||||
case "tag":
|
||||
return (split) =>
|
||||
!value || split.tags?.includes(value)
|
||||
? null
|
||||
: { tags: [...(split.tags ?? []), value] };
|
||||
default:
|
||||
return (split) =>
|
||||
value && split.tags?.includes(value)
|
||||
? { tags: split.tags.filter((tag) => tag !== value) }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Row {
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MassAction } from "./mass.js";
|
||||
|
||||
export const lastAction = $state<{ current: MassAction | null }>({
|
||||
current: null,
|
||||
});
|
||||
Reference in new issue
Block a user