Add persistent multi-tag include and exclude filters

This commit is contained in:
Lars Nolden
2026-09-19 12:44:05 +02:00
parent 8aab21fe9e
commit 9cc3130b4f
11 changed files with 409 additions and 46 deletions
+6 -3
View File
@@ -261,8 +261,11 @@ export function Overview({
setLoading(true);
setError("");
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter))
if (value) params.set(key, value);
for (const [key, value] of Object.entries(filter)) {
if (Array.isArray(value)) {
for (const id of value) params.append(key, id);
} else if (value) params.set(key, value);
}
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
.then((value) => {
for (const key of [
@@ -498,7 +501,7 @@ export function Overview({
groups={dashboard.tags.filter(
(g) => g.currency === currency,
)}
onSelect={(id) => drill({ tag_id: id })}
onSelect={(id) => drill({ tag_ids: [id] })}
/>
</div>
<Recurring
+4 -1
View File
@@ -136,6 +136,7 @@ export function Transactions({
(!filter.from || f.booking_date >= filter.from) &&
(!filter.to || f.booking_date <= filter.to) &&
(!filter.currency || f.currency === filter.currency) &&
(!filter.account_id || f.account_id === filter.account_id) &&
(!filter.category_id || categories.has(e.category_id || "")) &&
(!needsReview ||
e.classification.confidence !== "high" ||
@@ -149,7 +150,9 @@ export function Transactions({
e.classification.source || "",
)
: e.classification.source === status)) &&
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
(!filter.tag_ids.length ||
filter.tag_ids.some((id) => e.tag_ids.includes(id))) &&
!filter.exclude_tag_ids.some((id) => e.tag_ids.includes(id)) &&
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
(!query ||
`${f.raw_description} ${f.counterparty || ""} ${data.merchants.find((m) => m.id === e.merchant_id)?.name || ""} ${f.amount}`
+4 -2
View File
@@ -214,7 +214,8 @@ export interface Filter {
currency: string;
account_id: string;
category_id: string;
tag_id: string;
tag_ids: string[];
exclude_tag_ids: string[];
merchant_id: string;
}
export interface Preview {
@@ -578,7 +579,8 @@ export const emptyFilter: Filter = {
currency: "",
account_id: "",
category_id: "",
tag_id: "",
tag_ids: [],
exclude_tag_ids: [],
merchant_id: "",
};
// A six-month window is the default view: long enough to show a trend and a
+33 -1
View File
@@ -64,7 +64,39 @@ function App() {
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState(defaultFilter);
const [filter, setFilter] = useState(() => {
const initial = defaultFilter();
try {
const saved = JSON.parse(
localStorage.getItem("finance-duck.tag-filters") || "null",
);
for (const key of ["tag_ids", "exclude_tag_ids"] as const) {
if (Array.isArray(saved?.[key])) {
initial[key] = [
...new Set<string>(
saved[key].filter((id: unknown) => typeof id === "string" && id),
),
];
}
}
} catch {
// Unavailable storage or an invalid saved value must not block the journal.
}
return initial;
});
useEffect(() => {
try {
localStorage.setItem(
"finance-duck.tag-filters",
JSON.stringify({
tag_ids: filter.tag_ids,
exclude_tag_ids: filter.exclude_tag_ids,
}),
);
} catch {
// Filters still work for this visit when browser storage is unavailable.
}
}, [filter.tag_ids, filter.exclude_tag_ids]);
const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value));
setConflict(false);
+40
View File
@@ -2385,6 +2385,43 @@ footer span:first-child {
padding-top: 13px;
background: transparent;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 14px 20px;
padding: 0 18px 17px;
}
.tag-filter {
flex: 1 1 250px;
min-width: 0;
}
.tag-filter .field {
gap: 6px;
}
.tag-filter .combo > input {
min-height: 35px;
padding: 7px 9px;
font-size: 12px;
background: #fcfdfe;
}
.tag-filter .tag-edit {
margin-top: 8px;
}
.tag-filter .tag-chip {
min-height: 32px;
max-width: 100%;
text-align: left;
}
.tag-filter .tag-chip span {
overflow-wrap: anywhere;
}
.tag-filter .tag-chip svg {
flex-shrink: 0;
}
.tag-filter .tag-chip.excluded {
border-color: #e8cece;
color: var(--danger);
}
.chip {
border: 1px solid #dde4ea;
background: #fcfdfe;
@@ -2664,6 +2701,9 @@ footer span:first-child {
.range-row .filter-reset {
margin-left: 0;
}
.tag-filters {
padding: 0 13px 13px;
}
.chart-body {
padding: 4px 12px 18px;
}
+72 -15
View File
@@ -829,8 +829,10 @@ export function Filters({
value: Filter;
onChange: (filter: Filter) => void;
}) {
const update = (key: keyof Filter, text: string) =>
onChange({ ...value, [key]: text });
const update = (
key: Exclude<keyof Filter, "tag_ids" | "exclude_tag_ids">,
text: string,
) => onChange({ ...value, [key]: text });
const currencies = Array.from(
new Set([
...data.accounts.map((a) => a.currency),
@@ -849,6 +851,22 @@ export function Filters({
{ label: "YTD", title: "Year to date", from: yearStart(), to: "" },
{ label: "All", title: "All time", from: "", to: "" },
];
const tagFilters = [
{
key: "tag_ids",
opposite: "exclude_tag_ids",
label: "Include tags",
polarity: "Include",
hint: "Match any selected tag; empty includes all.",
},
{
key: "exclude_tag_ids",
opposite: "tag_ids",
label: "Exclude tags",
polarity: "Exclude",
hint: "Hide transactions with any selected tag.",
},
] as const;
return (
<div className="filter-bar">
<div className="range-row">
@@ -928,19 +946,6 @@ export function Filters({
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
@@ -955,6 +960,58 @@ export function Filters({
</select>
</Field>
</div>
<div className="tag-filters">
{tagFilters.map(({ key, opposite, label, polarity, hint }) => (
<div className="tag-filter" key={key}>
<Field label={label} hint={hint}>
<Combobox
options={data.tags
.filter((tag) => !value[key].includes(tag.id))
.map((tag) => ({ value: tag.id, label: tag.name }))}
value=""
placeholder={`Add tag to ${polarity.toLowerCase()}`}
emptyText="No more matching tags."
onChange={(id) =>
onChange({
...value,
[key]: value[key].includes(id)
? value[key]
: [...value[key], id],
[opposite]: value[opposite].filter((tag) => tag !== id),
})
}
/>
</Field>
{value[key].length > 0 && (
<div className="tag-edit" role="group" aria-label={label}>
{value[key].map((id) => {
const name =
data.tags.find((tag) => tag.id === id)?.name || id;
return (
<button
type="button"
className={`tag-chip ${key === "exclude_tag_ids" ? "excluded" : ""}`}
key={id}
aria-label={`Remove ${name} from ${polarity.toLowerCase()} tags`}
onClick={() =>
onChange({
...value,
[key]: value[key].filter((tag) => tag !== id),
})
}
>
<span>
{polarity}: {name}
</span>
<X size={12} aria-hidden="true" />
</button>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
}