Add persistent multi-tag include and exclude filters
This commit is contained in:
@@ -443,6 +443,19 @@ The classifier learns from you in three ways. Manually linking a merchant record
|
||||
|
||||
Wherever a category or tag is assigned — the transaction editor, an **Analyse** correction, or a merchant's defaults — the picker creates missing entries in place. Type a name and choose **Create "…" in …**: a bare name lands under the kind's root, and **Parent / Name** creates under that parent. New tags are typed next to the tag checkboxes. Assignment pickers offer leaf categories only, matching what the server accepts, and an existing name is selected rather than duplicated. Creating during an **Analyse** review keeps the preview applicable as long as the transactions themselves are unchanged.
|
||||
|
||||
## Filter by tags
|
||||
|
||||
**Overview** and **Transactions** share **Include tags** and **Exclude tags** pickers. Search for a tag and select it to add a removable pill; both pickers accept multiple tags.
|
||||
|
||||
- **Include tags** matches transactions carrying **any** selected tag. Leave it empty to include tagged and untagged transactions.
|
||||
- **Exclude tags** hides transactions carrying **any** selected tag, including transactions that also carry an included tag.
|
||||
- Both lists combine with the date, currency, account, category, and merchant filters. Adding a tag to one picker removes it from the other.
|
||||
- Tag selections are remembered in this browser across visits. Remove an individual pill to clear it, or use **Reset** to clear all filters and restore the default six-month period.
|
||||
|
||||
For private spending, leave **Include tags** empty and add `business` to **Exclude tags**. Business-tagged expenses, including any taxes you tag that way, leave the overview's totals, charts, comparisons, and the transaction list. Untagged income remains included: net cash flow and income-based figures describe the filtered transactions, not your actual savings. Wealth and account balances remain unfiltered.
|
||||
|
||||
The dashboard API accepts repeated `tag_ids` and `exclude_tag_ids` query parameters, for example `?tag_ids=tag_holiday&tag_ids=tag_shared&exclude_tag_ids=tag_business`. Values are literal tag IDs, not comma-separated lists.
|
||||
|
||||
## Data, backups, and recovery
|
||||
|
||||
Back up the **entire canonical finance directory**, including registry files, journals, `config.toml` when present, and operational/recovery state, plus any separately stored environment-managed secrets. `state/openrouter.json` and `state/enablebanking.json` contain UI-managed credentials: protect backups accordingly, including the matching banking session state. Stop the service for a consistent filesystem backup. DuckDB under `cache/` can be excluded and rebuilt.
|
||||
|
||||
+18
-20
@@ -15,13 +15,14 @@ import (
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
type Filter struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Currency string `json:"currency"`
|
||||
AccountID string `json:"account_id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
TagID string `json:"tag_id"`
|
||||
MerchantID string `json:"merchant_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Currency string `json:"currency"`
|
||||
AccountID string `json:"account_id"`
|
||||
CategoryID string `json:"category_id"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
ExcludeTagIDs []string `json:"exclude_tag_ids"`
|
||||
MerchantID string `json:"merchant_id"`
|
||||
}
|
||||
|
||||
type Total struct {
|
||||
@@ -272,21 +273,18 @@ func (f Filter) where() (string, []any) {
|
||||
add("t.account_id = ?", f.AccountID)
|
||||
add("t.merchant_id = ?", f.MerchantID)
|
||||
add("EXISTS (SELECT 1 FROM category_ancestors ca WHERE ca.category_id = t.category_id AND ca.ancestor_id = ?)", f.CategoryID)
|
||||
if f.TagID != "" {
|
||||
ids := strings.Split(f.TagID, ",")
|
||||
placeholders := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
addTags := func(predicate string, ids []string) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
if len(placeholders) == 0 {
|
||||
clauses = append(clauses, "FALSE")
|
||||
} else {
|
||||
clauses = append(clauses, "EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
|
||||
placeholders := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
clauses = append(clauses, predicate+" (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id IN ("+strings.Join(placeholders, ",")+"))")
|
||||
}
|
||||
addTags("EXISTS", f.TagIDs)
|
||||
addTags("NOT EXISTS", f.ExcludeTagIDs)
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestMonthlySplitsDirectionsAndRanksLargestPerCurrency(t *testing.T) {
|
||||
|
||||
func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
|
||||
s := openFixture(t, fixture())
|
||||
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagID: "tag_shared,tag_work,tag_shared"}
|
||||
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: []string{"tag_shared", "tag_work", "tag_shared"}}
|
||||
got := queryFixture(t, s, filter)
|
||||
want := []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "0.0000", Net: "-900719925474.1000"}}
|
||||
if !reflect.DeepEqual(got.Totals, want) {
|
||||
@@ -141,17 +141,132 @@ func TestTagUnionNeverDuplicatesTransactions(t *testing.T) {
|
||||
if len(got.Monthly) != 1 || got.Monthly[0].Count != 2 {
|
||||
t.Fatalf("tag union count: %#v", got.Monthly)
|
||||
}
|
||||
filter.TagID = "tag_shared"
|
||||
filter.TagIDs = []string{"tag_shared"}
|
||||
got = queryFixture(t, s, filter)
|
||||
if len(got.Totals) != 1 || got.Totals[0].Expenses != "900719925474.0991" {
|
||||
t.Fatalf("single tag filter: %#v", got.Totals)
|
||||
}
|
||||
filter.TagID = "tag_shared') OR TRUE --"
|
||||
filter.TagIDs = []string{"tag_shared') OR TRUE --"}
|
||||
if totals := queryFixture(t, s, filter).Totals; len(totals) != 0 {
|
||||
t.Fatalf("tag input altered SQL predicate: %#v", totals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagExclusionsAndComposition(t *testing.T) {
|
||||
s := openFixture(t, fixture())
|
||||
cases := []struct {
|
||||
name string
|
||||
include []string
|
||||
exclude []string
|
||||
totals []Total
|
||||
count int64
|
||||
tagIDs []string
|
||||
}{
|
||||
{
|
||||
name: "excluded tag removes whole multi-tag transaction",
|
||||
exclude: []string{"tag_shared"},
|
||||
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "100.1235", Net: "100.1226"}},
|
||||
count: 3,
|
||||
tagIDs: []string{"tag_work"},
|
||||
},
|
||||
{
|
||||
name: "any excluded tag removes transaction and untagged income survives",
|
||||
exclude: []string{"tag_shared", "tag_work"},
|
||||
totals: []Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.1235", Net: "100.1235"}},
|
||||
count: 2,
|
||||
tagIDs: []string{},
|
||||
},
|
||||
{
|
||||
name: "include union and exclusion intersect with exclusion winning overlap",
|
||||
include: []string{"tag_shared", "tag_work"},
|
||||
exclude: []string{"tag_shared"},
|
||||
totals: []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}},
|
||||
count: 1,
|
||||
tagIDs: []string{"tag_work"},
|
||||
},
|
||||
{
|
||||
name: "identical include and exclude match nothing",
|
||||
include: []string{"tag_shared"},
|
||||
exclude: []string{"tag_shared"},
|
||||
totals: []Total{},
|
||||
tagIDs: []string{},
|
||||
},
|
||||
{
|
||||
name: "exclusion values cannot alter SQL",
|
||||
exclude: []string{"tag_shared') OR TRUE --"},
|
||||
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
|
||||
count: 4,
|
||||
tagIDs: []string{"tag_shared", "tag_work"},
|
||||
},
|
||||
{
|
||||
name: "empty lists leave transactions unrestricted",
|
||||
include: []string{},
|
||||
exclude: []string{},
|
||||
totals: []Total{{Currency: "EUR", Expenses: "900719925474.1000", Income: "100.1235", Net: "-900719925373.9765"}},
|
||||
count: 4,
|
||||
tagIDs: []string{"tag_shared", "tag_work"},
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := queryFixture(t, s, Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", TagIDs: tt.include, ExcludeTagIDs: tt.exclude})
|
||||
if !reflect.DeepEqual(got.Totals, tt.totals) {
|
||||
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.totals)
|
||||
}
|
||||
var count int64
|
||||
for _, month := range got.Monthly {
|
||||
count += month.Count
|
||||
}
|
||||
if count != tt.count {
|
||||
t.Fatalf("transaction count: got %d, want %d", count, tt.count)
|
||||
}
|
||||
tagIDs := make([]string, 0, len(got.Tags))
|
||||
for _, tag := range got.Tags {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
if !reflect.DeepEqual(tagIDs, tt.tagIDs) {
|
||||
t.Fatalf("tag groups: got %#v, want %#v", got.Tags, tt.tagIDs)
|
||||
}
|
||||
accounts := []Group{}
|
||||
if len(tt.totals) != 0 {
|
||||
accounts = append(accounts, Group{ID: "acc_eur", Name: "Current", Currency: "EUR", Amount: tt.totals[0].Net, Count: tt.count})
|
||||
}
|
||||
if !reflect.DeepEqual(got.Accounts, accounts) {
|
||||
t.Fatalf("account groups: got %#v, want %#v", got.Accounts, accounts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagFiltersApplyToPreviousPeriodAndCategoryRollups(t *testing.T) {
|
||||
data := fixture()
|
||||
// Mirror current transactions into the preceding month, including the
|
||||
// untagged income and the multi-tag expense that must be excluded.
|
||||
for _, transaction := range data.Transactions[:4] {
|
||||
transaction.Facts.ID += "_previous"
|
||||
transaction.Facts.Fingerprint += "_previous"
|
||||
transaction.Facts.BookingDate = "2026-01-15"
|
||||
data.Transactions = append(data.Transactions, transaction)
|
||||
}
|
||||
s := openFixture(t, data)
|
||||
got := queryFixture(t, s, Filter{
|
||||
From: "2026-02-01", To: "2026-02-28", Currency: "EUR",
|
||||
TagIDs: []string{"tag_shared", "tag_work"}, ExcludeTagIDs: []string{"tag_shared"},
|
||||
})
|
||||
want := []Total{{Currency: "EUR", Expenses: "0.0009", Income: "0.0000", Net: "-0.0009"}}
|
||||
if !reflect.DeepEqual(got.Totals, want) || !reflect.DeepEqual(got.Previous, want) {
|
||||
t.Fatalf("period totals: current %#v, previous %#v, want %#v", got.Totals, got.Previous, want)
|
||||
}
|
||||
groups := []Group{
|
||||
{ID: "cat_expenses", Name: "Expenses", Currency: "EUR", Amount: "-0.0009", Count: 1},
|
||||
{ID: "cat_food", Name: "Food", Currency: "EUR", Amount: "-0.0009", Count: 1},
|
||||
{ID: "cat_living", Name: "Living", Currency: "EUR", Amount: "-0.0009", Count: 1},
|
||||
}
|
||||
if !reflect.DeepEqual(got.Categories, groups) || !reflect.DeepEqual(got.PreviousCategories, groups) {
|
||||
t.Fatalf("category rollups: current %#v, previous %#v, want %#v", got.Categories, got.PreviousCategories, groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAncestorFilteringAndRollups(t *testing.T) {
|
||||
s := openFixture(t, fixture())
|
||||
filter := Filter{From: "2026-02-01", To: "2026-02-28", Currency: "EUR", CategoryID: "cat_living"}
|
||||
|
||||
@@ -237,7 +237,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, nil, errors.New("from must not exceed to"))
|
||||
return
|
||||
}
|
||||
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagID: q.Get("tag_id"), MerchantID: q.Get("merchant_id")})
|
||||
v, e := s.app.Dashboard(r.Context(), analytics.Filter{From: from, To: to, Currency: q.Get("currency"), AccountID: q.Get("account_id"), CategoryID: q.Get("category_id"), TagIDs: q["tag_ids"], ExcludeTagIDs: q["exclude_tag_ids"], MerchantID: q.Get("merchant_id")})
|
||||
respond(w, v, e)
|
||||
}
|
||||
func (s *Server) account(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -11,12 +12,15 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"finance-duck/internal/analytics"
|
||||
"finance-duck/internal/app"
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
|
||||
@@ -415,3 +419,99 @@ func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
|
||||
}
|
||||
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) {
|
||||
t.Setenv("OPENROUTER_API_KEY", "")
|
||||
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||
a, err := app.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
state, err := a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = a.Mutate(context.Background(), state.Revision, func(data *domain.Dataset) error {
|
||||
data.Accounts = append(data.Accounts, domain.Account{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true})
|
||||
data.Tags = append(data.Tags, domain.Tag{ID: "tag_shared", Name: "Shared"}, domain.Tag{ID: "tag_work", Name: "Work"})
|
||||
for _, item := range []struct {
|
||||
id string
|
||||
amount domain.Money
|
||||
kind string
|
||||
category string
|
||||
tags []string
|
||||
}{
|
||||
{"tx_both", "-10.0000", "expense", domain.ExpenseFallback, []string{"tag_shared", "tag_work"}},
|
||||
{"tx_work", "-20.0000", "expense", domain.ExpenseFallback, []string{"tag_work"}},
|
||||
{"tx_income", "100.0000", "income", domain.IncomeFallback, []string{}},
|
||||
} {
|
||||
data.Transactions = append(data.Transactions, domain.Transaction{
|
||||
Facts: domain.Facts{ID: item.id, Source: "test", AccountID: "acc_eur", BookingDate: "2026-02-10",
|
||||
Amount: item.amount, Currency: "EUR", RawDescription: item.id, Fingerprint: item.id},
|
||||
Enrichment: domain.Enrichment{Kind: item.kind, CategoryID: item.category, TagIDs: item.tags},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := New(a, fstest.MapFS{}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
include []string
|
||||
exclude []string
|
||||
want []analytics.Total
|
||||
}{
|
||||
{
|
||||
name: "repeated includes use union without duplication",
|
||||
include: []string{"tag_shared", "tag_work"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "30.0000", Income: "0.0000", Net: "-30.0000"}},
|
||||
},
|
||||
{
|
||||
name: "repeated exclusions preserve untagged income",
|
||||
exclude: []string{"tag_shared", "tag_work"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.0000", Net: "100.0000"}},
|
||||
},
|
||||
{
|
||||
name: "include and exclude compose with exclusion winning",
|
||||
include: []string{"tag_shared", "tag_work"},
|
||||
exclude: []string{"tag_missing", "tag_shared"},
|
||||
want: []analytics.Total{{Currency: "EUR", Expenses: "20.0000", Income: "0.0000", Net: "-20.0000"}},
|
||||
},
|
||||
{
|
||||
name: "comma separated values are not a list",
|
||||
include: []string{"tag_shared,tag_work"},
|
||||
want: []analytics.Total{},
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
query := url.Values{"from": {"2026-02-01"}, "to": {"2026-02-28"}, "currency": {"EUR"}}
|
||||
for _, id := range tt.include {
|
||||
query.Add("tag_ids", id)
|
||||
}
|
||||
for _, id := range tt.exclude {
|
||||
query.Add("exclude_tag_ids", id)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/dashboard?"+query.Encode(), nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET dashboard: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got analytics.Dashboard
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Totals, tt.want) {
|
||||
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user