From 2ff5c3847c9f69b82a3ab4d976075f37711c9f20 Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Sat, 20 Jun 2026 08:35:14 -0400 Subject: [PATCH] Optional receipt tags; changelog 0.0.2 Add a shared, free-form tag vocabulary attachable to a receipt at upload. New tags/receipt_tags tables (case-insensitive label dedup); an in-page chip-mosaic picker after "Who" with inline tag creation. Tags are resolved/created only on successful submit, and shown on the confirm page and recent lists. Documented as spec item 14. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++++ internal/storage/storage.go | 10 +++ internal/storage/tags.go | 90 +++++++++++++++++++++++ internal/storage/tags_test.go | 107 ++++++++++++++++++++++++++++ internal/web/static/style.css | 59 +++++++++++++++ internal/web/templates/confirm.html | 1 + internal/web/templates/recent.html | 1 + internal/web/templates/upload.html | 87 ++++++++++++++++++++++ internal/web/upload.go | 74 +++++++++++++++++++ internal/web/upload_test.go | 57 +++++++++++++++ internal/web/views.go | 7 ++ spec.md | 68 +++++++++++++++++- 12 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 internal/storage/tags.go create mode 100644 internal/storage/tags_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bf574..9513500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Versions are git tags; release tags `X.Y.Z` are built and deployed automatically (pre-release tags such as `0.0.0a1` are built and staged only). +## [0.0.2] - 2026-06-20 + +### Added +- Optional tags on a receipt. An "Add tags" button on the upload form (after + "Who") opens an in-page card with an alphabetical chip mosaic; tap to select or + deselect, and create a new tag inline (auto-selected for this receipt). Tags are + a shared, free-form vocabulary stored separately from the fixed categories. + - New tags are written to the catalog only when the receipt is actually saved, + matched case-insensitively so casing variants don't duplicate. + - A receipt's tags are shown on the confirmation page and in the recent lists. + - See spec.md item 14. Out of scope for now: tag-based filtering, tally-by-tag, + and editing/merging tags in Manage. + ## [0.0.1] - 2026-06-19 ### Added diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7593d75..114a986 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -65,6 +65,16 @@ CREATE TABLE IF NOT EXISTS attachments ( deleted_at TEXT ); CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at); +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL COLLATE NOCASE UNIQUE +); +CREATE TABLE IF NOT EXISTS receipt_tags ( + receipt_id TEXT NOT NULL REFERENCES receipts(id), + tag_id INTEGER NOT NULL REFERENCES tags(id), + PRIMARY KEY (receipt_id, tag_id) +); +CREATE INDEX IF NOT EXISTS idx_receipt_tags_tag ON receipt_tags(tag_id); ` // Open opens (creating if needed) the SQLite database at path, applies the schema, diff --git a/internal/storage/tags.go b/internal/storage/tags.go new file mode 100644 index 0000000..df26a4c --- /dev/null +++ b/internal/storage/tags.go @@ -0,0 +1,90 @@ +package storage + +import ( + "fmt" + "strings" +) + +// ListTags returns all tags ordered alphabetically (case-insensitive), same shape +// as categories/people. +func (s *Store) ListTags() ([]Lookup, error) { return s.listLookup("tags") } + +// SetReceiptTags links the given tag labels to a receipt, creating any tag that +// does not yet exist (matched case-insensitively via the column's NOCASE +// collation). Labels are trimmed; blanks and case-insensitive duplicates are +// collapsed. It first clears the receipt's existing links, so it applies the full +// desired set idempotently. Runs in one transaction so a receipt's tag set is +// applied atomically. +func (s *Store) SetReceiptTags(receiptID string, labels []string) error { + clean := normalizeLabels(labels) + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("begin set tags: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM receipt_tags WHERE receipt_id = ?`, receiptID); err != nil { + return fmt.Errorf("clear receipt tags: %w", err) + } + for _, label := range clean { + // Create-if-missing: NOCASE-unique label means a differently-cased dup is + // ignored, and the SELECT then resolves to the existing row. + if _, err := tx.Exec(`INSERT OR IGNORE INTO tags(label) VALUES (?)`, label); err != nil { + return fmt.Errorf("upsert tag %q: %w", label, err) + } + var id int64 + if err := tx.QueryRow(`SELECT id FROM tags WHERE label = ?`, label).Scan(&id); err != nil { + return fmt.Errorf("find tag %q: %w", label, err) + } + if _, err := tx.Exec( + `INSERT OR IGNORE INTO receipt_tags(receipt_id, tag_id) VALUES (?, ?)`, + receiptID, id); err != nil { + return fmt.Errorf("link tag %q: %w", label, err) + } + } + return tx.Commit() +} + +// ListReceiptTags returns a receipt's tag labels, alphabetical (case-insensitive). +func (s *Store) ListReceiptTags(receiptID string) ([]string, error) { + rows, err := s.db.Query( + `SELECT t.label FROM receipt_tags rt + JOIN tags t ON t.id = rt.tag_id + WHERE rt.receipt_id = ? + ORDER BY t.label COLLATE NOCASE`, receiptID) + if err != nil { + return nil, fmt.Errorf("list receipt tags: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var l string + if err := rows.Scan(&l); err != nil { + return nil, fmt.Errorf("scan receipt tag: %w", err) + } + out = append(out, l) + } + return out, rows.Err() +} + +// normalizeLabels trims each label, drops blanks, and removes case-insensitive +// duplicates while preserving the first-seen casing and order. +func normalizeLabels(labels []string) []string { + seen := map[string]bool{} + var out []string + for _, l := range labels { + l = strings.TrimSpace(l) + if l == "" { + continue + } + key := strings.ToLower(l) + if seen[key] { + continue + } + seen[key] = true + out = append(out, l) + } + return out +} diff --git a/internal/storage/tags_test.go b/internal/storage/tags_test.go new file mode 100644 index 0000000..6588881 --- /dev/null +++ b/internal/storage/tags_test.go @@ -0,0 +1,107 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" + + "maisym.com/hsa/internal/receipt" +) + +func tagTestStore(t *testing.T) *Store { + t.Helper() + s, err := Open(filepath.Join(t.TempDir(), "tags.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func insertReceipt(t *testing.T, s *Store, id string) { + t.Helper() + cats, err := s.ListCategories() + if err != nil || len(cats) == 0 { + t.Fatalf("categories: %v", err) + } + rec := receipt.Receipt{ + ID: id, UploadedBy: "jm", UploadedAt: time.Now(), ReceiptDate: time.Now(), + AmountCents: 100, CategoryID: cats[0].ID, FilePath: "x", ImageData: []byte("x"), + FileSizeBytes: 1, OriginalFilename: "x.png", MimeType: "image/png", + } + if err := s.Insert(rec); err != nil { + t.Fatalf("insert receipt: %v", err) + } +} + +func TestSetReceiptTags_CreatesLinksAndDedups(t *testing.T) { + s := tagTestStore(t) + insertReceipt(t, s, "r1") + + // Mixed casing + blanks + a dup that differs only by case must collapse to two. + if err := s.SetReceiptTags("r1", []string{" Dental ", "tax-2026", "DENTAL", ""}); err != nil { + t.Fatal(err) + } + + got, err := s.ListReceiptTags("r1") + if err != nil { + t.Fatal(err) + } + // Alphabetical (case-insensitive); first-seen casing "Dental" is preserved. + want := []string{"Dental", "tax-2026"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("ListReceiptTags = %v, want %v", got, want) + } + + tags, err := s.ListTags() + if err != nil { + t.Fatal(err) + } + if len(tags) != 2 { + t.Errorf("catalog has %d tags, want 2 (no case-dup): %v", len(tags), tags) + } +} + +func TestSetReceiptTags_ReusesExistingTagAcrossReceipts(t *testing.T) { + s := tagTestStore(t) + insertReceipt(t, s, "r1") + insertReceipt(t, s, "r2") + + if err := s.SetReceiptTags("r1", []string{"Vision"}); err != nil { + t.Fatal(err) + } + // Same label, different casing, on another receipt: must reuse the one tag row. + if err := s.SetReceiptTags("r2", []string{"vision"}); err != nil { + t.Fatal(err) + } + + tags, err := s.ListTags() + if err != nil { + t.Fatal(err) + } + if len(tags) != 1 { + t.Fatalf("catalog has %d tags, want 1 shared: %v", len(tags), tags) + } + if r1, _ := s.ListReceiptTags("r1"); len(r1) != 1 { + t.Errorf("r1 tags = %v, want 1", r1) + } + if r2, _ := s.ListReceiptTags("r2"); len(r2) != 1 { + t.Errorf("r2 tags = %v, want 1", r2) + } +} + +func TestSetReceiptTags_ReplacesPriorSet(t *testing.T) { + s := tagTestStore(t) + insertReceipt(t, s, "r1") + + if err := s.SetReceiptTags("r1", []string{"a", "b"}); err != nil { + t.Fatal(err) + } + if err := s.SetReceiptTags("r1", []string{"b", "c"}); err != nil { + t.Fatal(err) + } + got, _ := s.ListReceiptTags("r1") + if len(got) != 2 || got[0] != "b" || got[1] != "c" { + t.Errorf("after replace, tags = %v, want [b c]", got) + } +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 356e32a..308a664 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -134,6 +134,65 @@ a.button { a.button.green { background: #137333; } a.button.blue { background: #1a73e8; } +/* Tag picker: trigger button, summary, chip mosaic, and the modal it opens. */ +button.tags-btn { + font-size: 1rem; + padding: .5rem .8rem; + border: 1px solid #1a73e8; + background: #fff; + color: #1a73e8; + border-radius: .4rem; +} +.tags-summary { margin-left: .5rem; font-size: .9rem; color: #555; } +#tags-hidden { display: none; } + +.modal { + position: fixed; + inset: 0; + z-index: 10; + background: rgba(0, 0, 0, .45); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} +.modal[hidden] { display: none; } +.modal-card { + background: #fff; + border-radius: .5rem; + padding: 1rem; + width: 100%; + max-width: 26rem; + max-height: 80vh; + overflow-y: auto; +} +.modal-card h2 { margin-top: 0; } +.chips { display: flex; flex-wrap: wrap; gap: .4rem; margin: .5rem 0 1rem; } +.chip { + padding: .35rem .7rem; + border: 1px solid #ccc; + border-radius: 1rem; + background: #f1f3f4; + color: #333; + font-size: .9rem; + cursor: pointer; +} +.chip.sel { background: #137333; border-color: #137333; color: #fff; } +.newtag { display: flex; gap: .4rem; margin-bottom: 1rem; } +.newtag input { flex: 1; } + +/* Tag chips shown read-only in the recent list. */ +ul.recent .tags { margin-left: .15rem; } +ul.recent .tag { + display: inline-block; + font-size: .72rem; + background: #e8f0fe; + color: #1a56c4; + border-radius: .8rem; + padding: .02rem .4rem; + margin-left: .2rem; +} + /* Inline rename/add rows on the manage page. */ form.row { display: flex; diff --git a/internal/web/templates/confirm.html b/internal/web/templates/confirm.html index 4c6062a..a373e3a 100644 --- a/internal/web/templates/confirm.html +++ b/internal/web/templates/confirm.html @@ -3,6 +3,7 @@

✓ Receipt saved

{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}
{{.Filename}}

{{if .Attachments}}

+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}

{{end}} +{{if .Tags}}

Tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t}}{{end}}

{{end}} Add another

Manage · Export · Log out

{{end}} diff --git a/internal/web/templates/recent.html b/internal/web/templates/recent.html index e36d472..ff98f49 100644 --- a/internal/web/templates/recent.html +++ b/internal/web/templates/recent.html @@ -18,6 +18,7 @@ ${{.Amount}} {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}} {{if .Attachments}}{{range .Attachments}}📎{{end}}{{end}} + {{if .Tags}}{{range .Tags}}{{.}}{{end}}{{end}} {{.When}} {{end}} diff --git a/internal/web/templates/upload.html b/internal/web/templates/upload.html index 69e6d58..87a5795 100644 --- a/internal/web/templates/upload.html +++ b/internal/web/templates/upload.html @@ -30,6 +30,27 @@ {{range .People}}{{end}} + + + +
+ {{range .TagChips}}{{if .Selected}}{{end}}{{end}} +
+ + +