feat: tag clean-up converters, saved searches, quick entry subscriptions and foreign amounts

Tag clean-up lists tags named like a category, account or subscription,
tags used once and unused ones; s c and s s move a tag's transactions
to a category or subscription, Enter again deletes the tag. P pins a
search, pins and built-ins show in the palette. Quick entry takes
%subscription and =520 UAH. The transaction page compares the amount
with the usual one at the same place.
This commit is contained in:
hh committed 2026-10-16 15:10:47 +00:00
1 parent 6f39343e07
commit 01c53d982d
13 files changed
+1093 -102

No files matched your search

+17 -6
View File
@@ -26,6 +26,7 @@
tagsOf,
} from "#lib/components/editor/quick.js";
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
import { evalAmount } from "#lib/components/inputs/parse.js";
import { LIST } from "#lib/components/inputs/styles.js";
import { today } from "#lib/dates.js";
import { formatDate, formatMoney } from "#lib/format.js";
@@ -35,7 +36,7 @@
const SUGGESTIONS = 6;
const HINT =
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
"12.50 lunch @payee ^account !category $budget %subscription #tag ~yesterday · + in front for a deposit · >account for a transfer · 12.50 USD =520 UAH for another currency";
const catalog = new Catalog();
let input = $state<HTMLInputElement | null>(null);
@@ -61,6 +62,7 @@
"!": () => catalog.categoryOptions(query),
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
"#": () => catalog.tagOptions(query, tagsOf(quick)),
"%": () => catalog.billOptions(query),
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
"~": () => [],
@@ -107,12 +109,18 @@
function money(form: Form) {
const [split] = form.splits;
return formatMoney({
amount: quick.amount ?? 0,
code: split.currency?.code,
decimals: split.currency?.decimals,
symbol: split.currency?.symbol,
const main = formatMoney({
...split.currency,
amount: evalAmount(split.amount) ?? 0,
});
if (!split.foreignCurrency) {
return main;
}
const foreign = formatMoney({
...split.foreignCurrency,
amount: evalAmount(split.foreignAmount) ?? 0,
});
return `${main} (${foreign})`;
}
function syncCaret() {
@@ -252,6 +260,9 @@
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
});
}
if (split.bill.id) {
parts.push({ text: `subscription ${split.bill.name}` });
}
if (split.budget.id) {
parts.push({
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
+198 -39
View File
@@ -1,8 +1,11 @@
import type { Split } from "#lib/api/types.js";
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
import { today } from "#lib/dates.js";
import { session } from "#lib/session.svelte.js";
import type { Catalog } from "./catalog.svelte.js";
import {
type AccountRef,
type Cur,
emptyForm,
KINDS,
OWN_SIDE,
@@ -13,9 +16,9 @@ import {
toAccountRef,
} from "./model.js";
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
const WORD = /\S+/g;
const NUMERIC = /^[\d(]/;
@@ -27,21 +30,109 @@ export interface Segment {
value: string;
}
export interface Equal {
code: string;
text: string;
}
export interface Quick {
amount: number | null;
amountText: string;
currency: string;
description: string;
equal: Equal | null;
segments: Segment[];
type: TxType;
}
export function parseQuick(input: string): Quick {
const segments: Segment[] = [];
const words: string[] = [];
let amount: number | null = null;
let amountText = "";
const enabledCodes = () =>
new Set(
session.currencies
.filter((item) => item.attributes.enabled)
.map((item) => item.attributes.code)
);
interface Parse {
amount: number | null;
amountText: string;
coded: "amount" | "equal" | null;
currency: string;
current: Segment | null;
equal: Equal | null;
segments: Segment[];
words: string[];
}
function takeCode(state: Parse, word: string, codes: Set<string>) {
const { coded } = state;
const code = word.toUpperCase();
state.coded = null;
if (!(coded && codes.has(code))) {
return false;
}
if (coded === "amount") {
state.currency = code;
} else if (state.equal) {
state.equal.code = code;
}
return true;
}
function takeEqual(state: Parse, word: string) {
if (!word.startsWith("=") || state.equal) {
return false;
}
state.equal = { code: "", text: word.slice(1) };
state.coded = "equal";
state.current = null;
return true;
}
function takeMarker(state: Parse, word: string, start: number) {
const { current } = state;
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
state.segments.push(segment);
state.current = MULTI.has(segment.marker) ? segment : null;
return true;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
return true;
}
return false;
}
function takeAmount(state: Parse, word: string) {
const value =
state.amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value === null) {
return false;
}
state.amount = value;
state.amountText = word;
state.coded = "amount";
return true;
}
export function parseQuick(input: string, codes = enabledCodes()): Quick {
const state: Parse = {
amount: null,
amountText: "",
coded: null,
currency: "",
current: null,
equal: null,
segments: [],
words: [],
};
let deposit = false;
let current: Segment | null = null;
let first = true;
for (const match of input.matchAll(WORD)) {
let [word] = match;
@@ -52,42 +143,32 @@ export function parseQuick(input: string): Quick {
start += 1;
}
first = false;
if (!word) {
continue;
const taken =
!word ||
takeCode(state, word, codes) ||
takeEqual(state, word) ||
takeMarker(state, word, start) ||
takeAmount(state, word);
if (!taken) {
state.words.push(word);
}
if (MARKERS.has(word[0])) {
const segment = {
end: start + word.length,
marker: word[0] as Marker,
start,
value: word.slice(1),
};
segments.push(segment);
current = MULTI.has(segment.marker) ? segment : null;
continue;
}
if (current) {
current.value = `${current.value} ${word}`;
current.end = start + word.length;
continue;
}
const value: number | null =
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
if (value !== null) {
amount = value;
amountText = word;
continue;
}
words.push(word);
}
const hasTarget = segments.some((segment) => segment.marker === ">");
const hasTarget = state.segments.some((segment) => segment.marker === ">");
let type: TxType = "withdrawal";
if (hasTarget) {
type = "transfer";
} else if (deposit) {
type = "deposit";
}
return { amount, amountText, description: words.join(" "), segments, type };
return {
amount: state.amount,
amountText: state.amountText,
currency: state.currency,
description: state.words.join(" "),
equal: state.equal,
segments: state.segments,
type,
};
}
export const markerValue = (quick: Quick, marker: Marker) =>
@@ -158,6 +239,77 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
: [`No asset account or liability called "${ownName}"`];
}
function curOf(code: string | undefined): Cur | null {
const found = session.currencies.find(
(item) => item.attributes.code === code
);
return found
? {
code: found.attributes.code,
decimals: found.attributes.decimal_places ?? 2,
symbol: found.attributes.symbol,
}
: null;
}
function resolveMoney(
quick: Quick,
split: SplitForm,
own: AccountRef,
target: AccountRef | null
): string[] {
const native = own.currency?.code;
const { equal } = quick;
split.currency = own.currency;
split.amount = quick.amountText;
const first = quick.currency || native;
if (!native) {
return [];
}
if (!equal) {
return first === native
? []
: [`${own.name} is in ${native}: add =amount ${native}`];
}
const second =
equal.code || (first === native ? target?.currency?.code : native);
if (first === native && second && second !== native) {
split.foreignAmount = equal.text;
split.foreignCurrency = curOf(second);
return [];
}
if (second === native && first !== native) {
split.amount = equal.text;
split.foreignAmount = quick.amountText;
split.foreignCurrency = curOf(first);
return [];
}
const example =
session.currencies.find(
(item) => item.attributes.enabled && item.attributes.code !== native
)?.attributes.code ?? "USD";
return [`Name the other currency, like =${equal.text || "520"} ${example}`];
}
function resolveBill(quick: Quick, catalog: Catalog, split: SplitForm) {
const name = markerValue(quick, "%");
if (!name) {
return [];
}
if (quick.type !== "withdrawal") {
return ["Only a withdrawal can pay a subscription"];
}
const wanted = name.toLowerCase();
const bill = (catalog.bills.data ?? []).find(
(item) => item.attributes.name?.toLowerCase() === wanted
);
if (!bill) {
return [`No subscription called "${name}"`];
}
split.bill = { id: bill.id, name: bill.attributes.name ?? name };
return [];
}
function resolveLinks(
quick: Quick,
catalog: Catalog,
@@ -199,10 +351,17 @@ export function resolveQuick(
split,
counterparty ? recent[counterparty] : null
);
problems.push(...links.problems);
problems.push(
...links.problems,
...resolveBill(quick, catalog, split),
...resolveMoney(
quick,
split,
split[own],
quick.type === "transfer" ? split[other] : null
)
);
split.tags = tagsOf(quick);
split.amount = quick.amountText;
split.currency = split[own].currency;
split.description =
quick.description ||
split[other].name ||
+14
View File
@@ -5,6 +5,7 @@
import { fuzzyFilter } from "#lib/fuzzy.js";
import { keys, prettyKeys } from "#lib/keys.svelte.js";
import { allLinks, CREATE_LINKS } from "#lib/nav.js";
import { searches } from "#lib/transactions/searches.svelte.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
@@ -214,8 +215,21 @@
),
]);
const searchEntries = $derived<Entry[]>(
ui.palette
? searches.list().map((saved) => ({
group: "Searches",
hint: saved.pinned ? "pinned" : undefined,
id: `saved:${saved.query}`,
label: saved.label,
run: () => searches.open(saved),
}))
: []
);
const entries = $derived([
...pageEntries,
...searchEntries,
...staticEntries,
...commandEntries,
...entityEntries,
+226
View File
@@ -0,0 +1,226 @@
<script lang="ts">
import { untrack } from "svelte";
import { invalidate } from "#lib/api/lists.js";
import type { Item, Split, Tag } from "#lib/api/types.js";
import { fuzzyFilter } from "#lib/fuzzy.js";
import { convertTag, deleteTag } from "./actions.js";
export interface Choice {
id: string;
name: string;
}
const SUGGESTIONS = 6;
const NOUN = { bill: "subscription", category: "category" } as const;
let {
tag,
kind,
choices,
initial,
ondone,
}: {
tag: Item<Tag>;
kind: "bill" | "category";
choices: Choice[];
initial: string;
ondone: (status?: string) => void;
} = $props();
let value = $state(untrack(() => initial));
let active = $state(-1);
let busy = $state("");
let fresh = $state("");
let done = $state("");
let problem = $state("");
const lower = (text: string | null | undefined) =>
(text ?? "").trim().toLowerCase();
const exact = $derived(
choices.find((choice) => lower(choice.name) === lower(value))
);
const options = $derived(
done || busy || exact
? []
: fuzzyFilter(choices, value, (choice) => choice.name, SUGGESTIONS)
);
function focus(input: HTMLInputElement) {
input.focus();
input.select();
}
function failed(failure: unknown) {
problem = failure instanceof Error ? failure.message : String(failure);
}
function summary(name: string, splits: Split[]) {
if (kind === "bill") {
const others = splits.filter((s) => s.type !== "withdrawal").length;
const linked = `${splits.length - others} linked to ${name}`;
return others
? `${linked}, ${others} not withdrawals keep only the tag`
: linked;
}
const before = [
...new Set(
splits
.map((s) => s.category_name ?? "")
.filter((c) => c && lower(c) !== lower(name))
),
];
const moved = `${splits.length} in ${name} now`;
return before.length ? `${moved}, before: ${before.join(", ")}` : moved;
}
function patchFor(choice: Choice | undefined, name: string) {
if (kind === "bill") {
return (split: Split) =>
split.type === "withdrawal" && split.bill_id !== choice?.id
? { bill_id: choice?.id }
: null;
}
return (split: Split) =>
lower(split.category_name) === lower(name)
? null
: { category_name: name };
}
async function run(choice: Choice | undefined) {
const name = choice?.name ?? value.trim();
if (busy) {
return;
}
if (!name) {
problem = `Type a ${NOUN[kind]}`;
return;
}
if (kind === "bill" && !choice) {
problem = `No subscription called "${name}"`;
return;
}
if (!choice && fresh !== name) {
fresh = name;
return;
}
busy = "Finding transactions";
problem = "";
try {
const { outcome, splits } = await convertTag(
tag,
patchFor(choice, name),
(o) => (busy = `Moving ${o.done} of ${o.total}`)
);
await invalidate();
if (outcome.errors.length > 0) {
problem = `${outcome.errors.length} failed: ${outcome.errors[0]}`;
return;
}
done = summary(name, splits);
} catch (failure) {
failed(failure);
} finally {
busy = "";
}
}
async function remove() {
busy = "Deleting the tag";
try {
await deleteTag(tag);
await invalidate();
ondone();
} catch (failure) {
failed(failure);
busy = "";
}
}
function step(down: boolean) {
const count = options.length;
active =
(Math.max(active, down ? -1 : 0) + (down ? 1 : -1) + count) % count;
}
function onkeydown(event: KeyboardEvent) {
const arrow = event.key === "ArrowDown" || event.key === "ArrowUp";
if (arrow && options.length > 0) {
event.preventDefault();
step(event.key === "ArrowDown");
} else if (event.key === "Tab" && options.length > 0 && !event.shiftKey) {
event.preventDefault();
value = options[Math.max(active, 0)].name;
active = -1;
} else if (event.key === "Enter") {
event.preventDefault();
if (done) {
remove();
} else {
run(active >= 0 ? options[active] : exact);
}
} else if (event.key === "Escape") {
event.preventDefault();
ondone(done || undefined);
}
}
</script>
<div class="flex flex-wrap items-center gap-2 py-0.5">
<span class="text-[12px] text-cladd-fg-soft">To {NOUN[kind]}</span>
<input
aria-label="Move {tag.attributes.tag} to a {NOUN[kind]}"
class="cladd-cut cladd-focusable-input h-6 w-56 rounded-cladd-sm px-1.5 text-[13px]"
oninput={() => {
active = -1;
fresh = "";
problem = "";
}}
{onkeydown}
readonly={Boolean(busy || done)}
bind:value
{@attach focus}
>
{#if options.length > 0}
<span
aria-label="Suggestions"
class="flex flex-wrap items-baseline gap-x-2.5 text-[12px]"
role="listbox"
>
<span class="kbd">Tab</span>
{#each options as option, i (option.id)}
<span
aria-selected={i === Math.max(active, 0)}
class="cursor-pointer {i === Math.max(active, 0)
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onmousedown={(event) => {
event.preventDefault();
run(option);
}}
role="option"
tabindex="-1"
>
{option.name}
</span>
{/each}
</span>
{/if}
<span class="text-[12px] text-cladd-fg-soft" role="status">
{#if busy}
{busy}
{:else if done}
<span class="text-cladd-fg">{done}.</span>
<span class="kbd">↵</span>
delete the tag <span class="kbd">Esc</span> keep it
{:else if fresh}
No {NOUN[kind]} called "{fresh}" yet. <span class="kbd">↵</span>
again creates it
{:else}
<span class="kbd">↵</span>
move its transactions <span class="kbd">Esc</span> cancel
{/if}
</span>
{#if problem}
<span class="basis-full text-[12px] text-expense">{problem}</span>
{/if}
</div>
+26 -26
View File
@@ -71,18 +71,42 @@
}
</script>
<div class="relative flex flex-wrap items-center gap-2 py-0.5">
<div class="flex flex-wrap items-center gap-2 py-0.5">
<span class="text-[12px] text-cladd-fg-soft">Merge into</span>
<input
aria-label="Merge {tag.attributes.tag} into"
class="cladd-cut cladd-focusable-input h-6 w-60 rounded-cladd-sm px-1.5 text-[13px]"
disabled={busy}
oninput={() => (cursor = 0)}
{onkeydown}
placeholder="tag name"
readonly={busy}
bind:value
{@attach focus}
>
{#if !busy && options.length > 0}
<span
aria-label="Suggestions"
class="flex flex-wrap items-baseline gap-x-2.5 text-[12px]"
role="listbox"
>
{#each options as option, i (option.id)}
<span
aria-selected={i === cursor}
class="cursor-pointer {i === cursor
? "font-semibold text-cladd-fg"
: "text-cladd-fg-soft hover:text-cladd-fg"}"
onmousedown={(event) => {
event.preventDefault();
run(option.attributes.tag);
}}
role="option"
tabindex="-1"
>
{option.attributes.tag}
</span>
{/each}
</span>
{/if}
<span class="text-[12px] text-cladd-fg-soft">
{#if busy && outcome}
Moving {outcome.done} of {outcome.total} transactions
@@ -96,28 +120,4 @@
{#if problem}
<span class="basis-full text-[12px] text-expense">{problem}</span>
{/if}
{#if !busy && options.length > 0}
<div
class="cladd-surface cladd-outline cladd-level-1 absolute top-full left-[4.6rem] z-20 mt-1 w-60 rounded-cladd-md p-1 shadow-cladd-popover"
role="listbox"
>
{#each options as option, i (option.id)}
<div
aria-selected={i === cursor}
class="cursor-default truncate rounded-cladd-sm px-2 py-1 text-[13px] {i ===
cursor
? "bg-row-cursor"
: "hover:bg-row-hover"}"
onmousedown={(event) => {
event.preventDefault();
run(option.attributes.tag);
}}
role="option"
tabindex="-1"
>
{option.attributes.tag}
</div>
{/each}
</div>
{/if}
</div>
+49 -4
View File
@@ -1,12 +1,24 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Item, Page, Tag, TransactionGroup } from "#lib/api/types.js";
import type {
Item,
Page,
Split,
Tag,
TransactionGroup,
} from "#lib/api/types.js";
import {
type Outcome,
type Patch,
type Targets,
updateGroups,
} from "#lib/transactions/mass.js";
export interface Usage {
counts: Map<string, number>;
samples: Map<string, Item<TransactionGroup>>;
}
const lower = (tag: Item<Tag>) => tag.attributes.tag.toLowerCase();
const tagged = (groups: Item<TransactionGroup>[], name: string) =>
@@ -61,7 +73,31 @@ export async function mergeTag(
return outcome;
}
export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
export async function convertTag(
tag: Item<Tag>,
patch: (split: Split) => Patch | null,
progress: (outcome: Outcome) => void
): Promise<{ outcome: Outcome; splits: Split[] }> {
const name = tag.attributes.tag;
const groups = tagged(
await all<TransactionGroup>(`/v1/tags/${tag.id}/transactions`, {
type: "all",
}),
name
);
const has = (split: Split) => split.tags?.includes(name) ?? false;
const outcome = await updateGroups(
new Map(groups.map((group) => [group.id, null])),
(split) => (has(split) ? patch(split) : null),
progress
);
const splits = groups.flatMap((group) =>
group.attributes.transactions.filter(has)
);
return { outcome, splits };
}
export async function usage(tags: Item<Tag>[]): Promise<Usage> {
const keys = tags.map(lower);
const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i));
const plain = tags.filter((tag) => !clash.has(lower(tag)));
@@ -75,6 +111,11 @@ export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
const counts = new Map(
plain.map((tag, i) => [tag.id, pages[i].meta.pagination?.total ?? 0])
);
const samples = new Map(
plain.flatMap((tag, i) =>
pages[i].data[0] ? [[tag.id, pages[i].data[0]] as const] : []
)
);
await Promise.all(
[...clash].map(async (key) => {
const variants = tags.filter((tag) => lower(tag) === key);
@@ -83,11 +124,15 @@ export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
{ type: "all" }
);
for (const variant of variants) {
counts.set(variant.id, tagged(groups, variant.attributes.tag).length);
const own = tagged(groups, variant.attributes.tag);
counts.set(variant.id, own.length);
if (own[0]) {
samples.set(variant.id, own[0]);
}
}
})
);
return counts;
return { counts, samples };
}
export function sequence<T>(items: T[], run: (item: T) => Promise<void>) {
+80
View File
@@ -0,0 +1,80 @@
import type {
Account,
Bill,
Category,
Item,
Tag,
TransactionGroup,
} from "#lib/api/types.js";
import type { Usage } from "./actions.js";
import { squash } from "./tree.js";
export type Section = "same" | "once" | "unused" | "rest";
export interface Finding {
account?: Item<Account>;
bill?: Item<Bill>;
category?: Item<Category>;
sample?: Item<TransactionGroup>;
section: Section;
}
export const SECTIONS: Record<Section, string> = {
once: "Used once",
rest: "Other tags",
same: "Same name as a category, account or subscription",
unused: "Not used",
};
const ORDER: Section[] = ["same", "once", "unused", "rest"];
const MIN_PREFIX = 3;
export const leafOf = (tag: string) => tag.slice(tag.lastIndexOf("/") + 1);
export const rankOf = (finding: Finding | undefined) =>
ORDER.indexOf(finding?.section ?? "rest");
function sectionOf(finding: Omit<Finding, "section">, count?: number) {
if (finding.account || finding.bill || finding.category) {
return "same";
}
if (count === 1) {
return "once";
}
return count === 0 ? "unused" : "rest";
}
function index<T>(items: Item<T>[], name: (item: T) => string) {
return new Map(items.map((item) => [squash(name(item.attributes)), item]));
}
export function findings(
tags: Item<Tag>[],
lists: {
accounts: Item<Account>[];
bills: Item<Bill>[];
categories: Item<Category>[];
usage?: Usage;
}
): Map<string, Finding> {
const accounts = index(lists.accounts, (a) => a.name);
const categories = index(lists.categories, (c) => c.name);
return new Map(
tags.map((tag) => {
const leaf = squash(leafOf(tag.attributes.tag));
const count = lists.usage?.counts.get(tag.id);
const found = {
account: accounts.get(leaf),
bill:
leaf.length < MIN_PREFIX
? undefined
: lists.bills.find((bill) =>
squash(bill.attributes.name ?? "").startsWith(leaf)
),
category: categories.get(leaf),
sample: count === 1 ? lists.usage?.samples.get(tag.id) : undefined,
};
return [tag.id, { ...found, section: sectionOf(found, count) }];
})
);
}
+1 -1
View File
@@ -134,7 +134,7 @@ export function tagStats(items: Row[], rollup: boolean): Map<string, Stats> {
const normal = (value: string) =>
value.normalize("NFKD").replace(DIACRITICS, "").toLowerCase();
const squash = (value: string) => normal(value).replace(NOT_WORD, "");
export const squash = (value: string) => normal(value).replace(NOT_WORD, "");
function distance(a: string, b: string): number {
const rows = a.length + 1;
+79
View File
@@ -0,0 +1,79 @@
import { addDays, periodOf, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { goto } from "$app/navigation";
const KEY = "ffui.searches";
const SEEN_KEY = "ffui.searches.seen";
const FIRST_LOOK_DAYS = 7;
export interface SavedSearch {
label: string;
pinned: boolean;
query: string;
since?: boolean;
}
function load(): string[] {
try {
const value = JSON.parse(localStorage.getItem(KEY) ?? "[]");
return Array.isArray(value)
? value.filter((item) => typeof item === "string")
: [];
} catch {
return [];
}
}
const seen = () =>
localStorage.getItem(SEEN_KEY) ?? addDays(today(), -FIRST_LOOK_DAYS);
class Searches {
pinned = $state<string[]>(typeof localStorage === "undefined" ? [] : load());
has(query: string) {
return this.pinned.includes(query);
}
toggle(query: string): boolean {
const pin = !this.has(query);
this.pinned = pin
? [...this.pinned, query]
: this.pinned.filter((item) => item !== query);
localStorage.setItem(KEY, JSON.stringify(this.pinned));
return pin;
}
list(): SavedSearch[] {
const month = periodOf(today(), "1M");
const range = `date_after:${month.start} date_before:${month.end}`;
const builtins = [
{
label: `Since I last looked, ${formatDate(seen(), "short")}`,
query: `created_at_after:${seen()}`,
since: true,
},
{
label: "No budget this month",
query: `type:withdrawal has_no_budget:true ${range}`,
},
{
label: "No category this month",
query: `has_no_category:true ${range}`,
},
{ label: "Has notes", query: "has_notes:true" },
];
return [
...builtins.map((item) => ({ ...item, pinned: false })),
...this.pinned.map((query) => ({ label: query, pinned: true, query })),
];
}
open(saved: SavedSearch) {
if (saved.since) {
localStorage.setItem(SEEN_KEY, today());
}
return goto(`/search?search=${encodeURIComponent(saved.query)}`);
}
}
export const searches = new Searches();