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 <noreply@anthropic.com>
This commit is contained in:
parent
7ba0d5abe7
commit
5cc464115d
12 changed files with 573 additions and 1 deletions
13
CHANGELOG.md
13
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
|
release tags `X.Y.Z` are built and deployed automatically (pre-release tags such
|
||||||
as `0.0.0a1` are built and staged only).
|
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
|
## [0.0.1] - 2026-06-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ CREATE TABLE IF NOT EXISTS attachments (
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachments_receipt ON attachments(receipt_id, deleted_at);
|
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,
|
// Open opens (creating if needed) the SQLite database at path, applies the schema,
|
||||||
|
|
|
||||||
90
internal/storage/tags.go
Normal file
90
internal/storage/tags.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
107
internal/storage/tags_test.go
Normal file
107
internal/storage/tags_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,65 @@ a.button {
|
||||||
a.button.green { background: #137333; }
|
a.button.green { background: #137333; }
|
||||||
a.button.blue { background: #1a73e8; }
|
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. */
|
/* Inline rename/add rows on the manage page. */
|
||||||
form.row {
|
form.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<p class="ok">✓ Receipt saved</p>
|
<p class="ok">✓ Receipt saved</p>
|
||||||
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
|
<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p>
|
||||||
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
|
{{if .Attachments}}<p><small>+ {{len .Attachments}} attachment(s): {{range $i, $f := .Attachments}}{{if $i}}, {{end}}{{$f}}{{end}}</small></p>{{end}}
|
||||||
|
{{if .Tags}}<p><small>Tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}{{$t}}{{end}}</small></p>{{end}}
|
||||||
<a class="button green" href="/">Add another</a>
|
<a class="button green" href="/">Add another</a>
|
||||||
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
|
<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
<a class="amt" href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
||||||
<span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span>
|
<span class="meta">{{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}</span>
|
||||||
{{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}}
|
{{if .Attachments}}<span class="atts">{{range .Attachments}}<a href="/attachment/{{.ID}}/file" target="_blank" title="{{.Filename}}">📎</a>{{end}}</span>{{end}}
|
||||||
|
{{if .Tags}}<span class="tags">{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</span>{{end}}
|
||||||
<span class="when">{{.When}}</span>
|
<span class="when">{{.When}}</span>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,27 @@
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</select>
|
||||||
|
<label>Tags (optional)</label>
|
||||||
|
<button type="button" class="tags-btn" id="tags-btn">Add tags</button>
|
||||||
|
<span class="tags-summary" id="tags-summary"></span>
|
||||||
|
<div id="tags-hidden">
|
||||||
|
{{range .TagChips}}{{if .Selected}}<input type="hidden" name="tags" value="{{.Label}}">{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="tags-modal" hidden>
|
||||||
|
<div class="modal-card">
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<div class="chips" id="chip-list">
|
||||||
|
{{range .TagChips}}<button type="button" class="chip{{if .Selected}} sel{{end}}" data-label="{{.Label}}">{{.Label}}</button>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="newtag">
|
||||||
|
<input type="text" id="newtag-input" placeholder="Create a tag" autocomplete="off">
|
||||||
|
<button type="button" id="newtag-add">Add</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="primary" id="tags-done">Done with tags</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label for="attachments">Additional files (optional)</label>
|
<label for="attachments">Additional files (optional)</label>
|
||||||
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
|
<input id="attachments" type="file" name="attachments" accept="image/*,application/pdf" multiple>
|
||||||
<div class="dup-warn" id="dup-warn" hidden>
|
<div class="dup-warn" id="dup-warn" hidden>
|
||||||
|
|
@ -40,6 +61,72 @@
|
||||||
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- Tag picker: in-page modal with a chip mosaic. Selection is mirrored into
|
||||||
|
// hidden "tags" inputs so it submits with the rest of the form. ---
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById("tags-btn");
|
||||||
|
var modal = document.getElementById("tags-modal");
|
||||||
|
var chipList = document.getElementById("chip-list");
|
||||||
|
var hidden = document.getElementById("tags-hidden");
|
||||||
|
var summary = document.getElementById("tags-summary");
|
||||||
|
var newInput = document.getElementById("newtag-input");
|
||||||
|
var newAdd = document.getElementById("newtag-add");
|
||||||
|
var done = document.getElementById("tags-done");
|
||||||
|
|
||||||
|
function chips() { return Array.prototype.slice.call(chipList.querySelectorAll(".chip")); }
|
||||||
|
function sync() {
|
||||||
|
var labels = chips().filter(function (c) { return c.classList.contains("sel"); })
|
||||||
|
.map(function (c) { return c.getAttribute("data-label"); });
|
||||||
|
hidden.innerHTML = "";
|
||||||
|
labels.forEach(function (l) {
|
||||||
|
var i = document.createElement("input");
|
||||||
|
i.type = "hidden"; i.name = "tags"; i.value = l;
|
||||||
|
hidden.appendChild(i);
|
||||||
|
});
|
||||||
|
summary.textContent = labels.length ? labels.length + " selected" : "none yet";
|
||||||
|
}
|
||||||
|
function find(label) {
|
||||||
|
var lower = label.toLowerCase();
|
||||||
|
return chips().filter(function (c) {
|
||||||
|
return c.getAttribute("data-label").toLowerCase() === lower;
|
||||||
|
})[0];
|
||||||
|
}
|
||||||
|
function addNew() {
|
||||||
|
var label = newInput.value.trim();
|
||||||
|
if (!label) return;
|
||||||
|
var existing = find(label);
|
||||||
|
if (existing) {
|
||||||
|
existing.classList.add("sel");
|
||||||
|
} else {
|
||||||
|
var c = document.createElement("button");
|
||||||
|
c.type = "button"; c.className = "chip sel";
|
||||||
|
c.setAttribute("data-label", label); c.textContent = label;
|
||||||
|
chipList.appendChild(c);
|
||||||
|
}
|
||||||
|
newInput.value = "";
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
chipList.addEventListener("click", function (e) {
|
||||||
|
var chip = e.target.closest(".chip");
|
||||||
|
if (!chip) return;
|
||||||
|
chip.classList.toggle("sel");
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
newAdd.addEventListener("click", addNew);
|
||||||
|
newInput.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); addNew(); }
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", function () { modal.hidden = false; });
|
||||||
|
done.addEventListener("click", function () { modal.hidden = true; });
|
||||||
|
modal.addEventListener("click", function (e) {
|
||||||
|
if (e.target === modal) modal.hidden = true; // tap the backdrop to close
|
||||||
|
});
|
||||||
|
sync(); // reflect any pre-selected chips (e.g. carried across an error re-render)
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var form = document.getElementById("upload-form");
|
var form = document.getElementById("upload-form");
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -31,6 +32,16 @@ type formView struct {
|
||||||
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
||||||
ClassifyModel string
|
ClassifyModel string
|
||||||
ClassifyEnabled bool
|
ClassifyEnabled bool
|
||||||
|
|
||||||
|
// TagChips is the alphabetical tag picker: every known tag plus any unsaved
|
||||||
|
// labels carried across an error re-render, each marked selected or not.
|
||||||
|
TagChips []tagChip
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagChip is one selectable tag in the upload form's picker.
|
||||||
|
type tagChip struct {
|
||||||
|
Label string
|
||||||
|
Selected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -63,12 +74,53 @@ func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
|
||||||
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
||||||
v.ClassifyModel = s.cfg.ClassifyModel
|
v.ClassifyModel = s.cfg.ClassifyModel
|
||||||
v.ClassifyEnabled = s.classifier != nil
|
v.ClassifyEnabled = s.classifier != nil
|
||||||
|
if v.TagChips == nil { // callers that carry selected tags set this themselves
|
||||||
|
if chips, err := s.tagChipsFor(nil); err == nil {
|
||||||
|
v.TagChips = chips
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
||||||
s.serverError(w, "render form", err)
|
s.serverError(w, "render form", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagChipsFor builds the upload form's tag picker: every known tag in alphabetical
|
||||||
|
// order, plus any selected labels that aren't saved tags yet (a chip the user
|
||||||
|
// created before an error re-render), with the selected ones marked.
|
||||||
|
func (s *Server) tagChipsFor(selected []string) ([]tagChip, error) {
|
||||||
|
tags, err := s.store.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selSet := map[string]bool{}
|
||||||
|
for _, l := range selected {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
selSet[strings.ToLower(l)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
known := map[string]bool{}
|
||||||
|
var chips []tagChip
|
||||||
|
for _, t := range tags {
|
||||||
|
key := strings.ToLower(t.Label)
|
||||||
|
known[key] = true
|
||||||
|
chips = append(chips, tagChip{Label: t.Label, Selected: selSet[key]})
|
||||||
|
}
|
||||||
|
for _, l := range selected { // pending labels not yet persisted
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
key := strings.ToLower(l)
|
||||||
|
if l == "" || known[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known[key] = true
|
||||||
|
chips = append(chips, tagChip{Label: l, Selected: true})
|
||||||
|
}
|
||||||
|
sort.Slice(chips, func(i, j int) bool {
|
||||||
|
return strings.ToLower(chips[i].Label) < strings.ToLower(chips[j].Label)
|
||||||
|
})
|
||||||
|
return chips, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
sess := sessionFrom(r.Context())
|
sess := sessionFrom(r.Context())
|
||||||
cats, people, err := s.lookups()
|
cats, people, err := s.lookups()
|
||||||
|
|
@ -150,8 +202,18 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
attachments, attErrs := readAttachments(r)
|
attachments, attErrs := readAttachments(r)
|
||||||
errs = append(errs, attErrs...)
|
errs = append(errs, attErrs...)
|
||||||
|
|
||||||
|
// Selected tags (existing labels and/or newly-typed ones) ride along as repeated
|
||||||
|
// "tags" fields. Resolved/created at save time so an abandoned upload adds none.
|
||||||
|
var tagLabels []string
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
tagLabels = r.MultipartForm.Value["tags"]
|
||||||
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
view.Errors = errs
|
view.Errors = errs
|
||||||
|
if chips, err := s.tagChipsFor(tagLabels); err == nil {
|
||||||
|
view.TagChips = chips // keep the user's tag selection across the re-render
|
||||||
|
}
|
||||||
s.renderForm(w, view)
|
s.renderForm(w, view)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +246,14 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link tags now that the receipt id exists (create-if-missing by label).
|
||||||
|
if len(tagLabels) > 0 {
|
||||||
|
if err := s.store.SetReceiptTags(id, tagLabels); err != nil {
|
||||||
|
s.serverError(w, "save tags", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save each attachment now that the parent receipt id exists, keyed to the
|
// Save each attachment now that the parent receipt id exists, keyed to the
|
||||||
// receipt's date+amount so the files sort alongside it.
|
// receipt's date+amount so the files sort alongside it.
|
||||||
var attachmentNames []string
|
var attachmentNames []string
|
||||||
|
|
@ -210,6 +280,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
attachmentNames = append(attachmentNames, a.filename)
|
attachmentNames = append(attachmentNames, a.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savedTags, _ := s.store.ListReceiptTags(id)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
_ = confirmPage.ExecuteTemplate(w, "base", confirmView{
|
||||||
Category: labelFor(categoryID, cats),
|
Category: labelFor(categoryID, cats),
|
||||||
|
|
@ -218,6 +290,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
Filename: header.Filename,
|
Filename: header.Filename,
|
||||||
Attachments: attachmentNames,
|
Attachments: attachmentNames,
|
||||||
|
Tags: savedTags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +302,7 @@ type confirmView struct {
|
||||||
Date string
|
Date string
|
||||||
Filename string
|
Filename string
|
||||||
Attachments []string
|
Attachments []string
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
|
// pendingAttachment is a validated extra file waiting to be saved with its receipt.
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,63 @@ func TestUpload_WithAttachments(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadForm_RendersTagPicker(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
if err := s.store.SetReceiptTags("noop", nil); err != nil { // ensure table is queryable
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := get(t, s, "/")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{`id="tags-btn"`, `id="tags-modal"`, `Done with tags`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("upload form missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpload_WithTags(t *testing.T) {
|
||||||
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
_ = mw.WriteField("amount", "12.34")
|
||||||
|
_ = mw.WriteField("receipt_date", "2026-06-01")
|
||||||
|
_ = mw.WriteField("category_id", aCategoryID(t, s))
|
||||||
|
_ = mw.WriteField("tags", "Dental")
|
||||||
|
_ = mw.WriteField("tags", "tax-2026")
|
||||||
|
_ = mw.WriteField("tags", "dental") // case-dup of the first — must collapse
|
||||||
|
rw, _ := mw.CreateFormFile("receipt", "receipt.png")
|
||||||
|
rw.Write(fakePNG())
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/upload", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(authCookie(t, s))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Routes().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Tags:") {
|
||||||
|
t.Errorf("confirm page missing tags listing: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _, err := s.store.ListRecent("uploaded_at", 10, 0)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ListRecent: %v len=%d", err, len(rows))
|
||||||
|
}
|
||||||
|
got, err := s.store.ListReceiptTags(rows[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != "Dental" || got[1] != "tax-2026" {
|
||||||
|
t.Errorf("receipt tags = %v, want [Dental tax-2026]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpload_RejectsBadAttachment(t *testing.T) {
|
func TestUpload_RejectsBadAttachment(t *testing.T) {
|
||||||
s := testServerWithStore(t)
|
s := testServerWithStore(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ type recentRow struct {
|
||||||
When string
|
When string
|
||||||
Filename string
|
Filename string
|
||||||
Attachments []attachLink
|
Attachments []attachLink
|
||||||
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachLink is a viewable attachment reference shown under a recent row.
|
// attachLink is a viewable attachment reference shown under a recent row.
|
||||||
|
|
@ -125,6 +126,11 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
|
||||||
for _, a := range atts {
|
for _, a := range atts {
|
||||||
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
|
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
|
||||||
}
|
}
|
||||||
|
tags, err := s.store.ListReceiptTags(row.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.serverError(w, "list receipt tags", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
view.Rows = append(view.Rows, recentRow{
|
view.Rows = append(view.Rows, recentRow{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Date: row.ReceiptDate.Format(dateLayout),
|
Date: row.ReceiptDate.Format(dateLayout),
|
||||||
|
|
@ -134,6 +140,7 @@ func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, t
|
||||||
When: row.UploadedAt.Local().Format("2006-01-02"),
|
When: row.UploadedAt.Local().Format("2006-01-02"),
|
||||||
Filename: row.OriginalFilename,
|
Filename: row.OriginalFilename,
|
||||||
Attachments: links,
|
Attachments: links,
|
||||||
|
Tags: tags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if hasMore {
|
if hasMore {
|
||||||
|
|
|
||||||
66
spec.md
66
spec.md
|
|
@ -383,3 +383,69 @@ not just EXIF-aware ones.
|
||||||
|
|
||||||
Out of scope: content-based auto-rotation (detecting "up" without metadata),
|
Out of scope: content-based auto-rotation (detecting "up" without metadata),
|
||||||
rotating PDFs, and backfilling already-stored receipts.
|
rotating PDFs, and backfilling already-stored receipts.
|
||||||
|
|
||||||
|
14. Optional tags on a receipt
|
||||||
|
|
||||||
|
Let the user attach zero or more free-form TAGS to a receipt at upload time, on top
|
||||||
|
of the fixed category and the optional "who". Tags are a shared, user-extensible
|
||||||
|
vocabulary (e.g. "orthodontics", "tax-2026", "Jude-braces") for grouping receipts
|
||||||
|
however the household likes, without touching the fixed category list. Optional:
|
||||||
|
a receipt with no tags is normal.
|
||||||
|
|
||||||
|
Upload UI:
|
||||||
|
- An "Add tags" button on the upload form, placed AFTER the "Who" control. A
|
||||||
|
summary next to it shows the current selection (e.g. "3 tags" or the chip
|
||||||
|
labels), so the user sees what's attached without opening the picker.
|
||||||
|
- Tapping it opens an IN-PAGE overlay (modal card), NOT a separate page. The
|
||||||
|
upload form already holds in-progress state — the chosen file, the AI-filled
|
||||||
|
amount/date/category, the "who" — and navigating away would lose it (the file
|
||||||
|
input especially). The picker must preserve all of that; closing it returns to
|
||||||
|
the same half-filled form.
|
||||||
|
- The card shows every existing tag as a chip in a wrap/mosaic layout, sorted
|
||||||
|
alphabetically (case-insensitive, like the other lookups). Tap a chip to select
|
||||||
|
it; tap again to deselect. Selected chips are visually distinct (filled vs
|
||||||
|
outline, checkmark, etc.). Multiple selections allowed.
|
||||||
|
- At the bottom, a "new tag" text field + add control. Creating a tag adds its
|
||||||
|
chip to the mosaic and marks it SELECTED for this receipt by default. The new
|
||||||
|
chip is client-side only until the receipt is submitted (see persistence).
|
||||||
|
Creating a name that already exists (case-insensitive, trimmed) just selects
|
||||||
|
the existing chip rather than making a duplicate.
|
||||||
|
- A "Done with tags" button closes the card and returns to the form with the
|
||||||
|
selection retained. The receipt is then submitted normally; tags ride along in
|
||||||
|
the same POST /upload submission (no separate endpoint), like attachments.
|
||||||
|
|
||||||
|
Data model — new `tags` lookup table and a many-to-many join:
|
||||||
|
- tags: id (INTEGER PK), label (TEXT, unique case-insensitively). Same shape and
|
||||||
|
ordering convention as categories/people (ORDER BY label COLLATE NOCASE).
|
||||||
|
- receipt_tags: (receipt_id FK → receipts.id, tag_id FK → tags.id), composite
|
||||||
|
primary key (receipt_id, tag_id) so a tag can't be linked twice to one receipt.
|
||||||
|
Index on tag_id for "receipts with this tag" lookups later.
|
||||||
|
- Tags are global/shared (both users see the same catalog), consistent with the
|
||||||
|
shared-visibility model. No per-user tag namespaces.
|
||||||
|
|
||||||
|
Persistence and submission:
|
||||||
|
- The form submits the selected tags as a list (existing tag ids and/or new tag
|
||||||
|
LABELS). On insert, the server resolves each: known label/id → reuse; unknown
|
||||||
|
label → create the tag row first (create-if-missing by normalized label), then
|
||||||
|
link. This means a NEW tag is written to the catalog only when its receipt is
|
||||||
|
actually saved — an abandoned upload never litters the tag list.
|
||||||
|
- Tag links are written after the receipt row exists (it owns the id), in the same
|
||||||
|
request that saves the receipt and its attachments.
|
||||||
|
- Normalization: trim surrounding whitespace; match/dedup case-insensitively;
|
||||||
|
store the label as the user first typed it (display casing preserved).
|
||||||
|
- Soft-deleting a receipt hides its tag links along with it (the catalog entries
|
||||||
|
persist). Tags never affect duplicate detection or Tally totals.
|
||||||
|
|
||||||
|
Display:
|
||||||
|
- A receipt's tags are shown as chips wherever its details appear (the recent
|
||||||
|
lists, item 4/5, and the confirm page after upload). AI auto-fill does NOT
|
||||||
|
suggest tags; tagging is a manual, deliberate act.
|
||||||
|
|
||||||
|
Out of scope (for now):
|
||||||
|
- Tag-based filtering/search of receipts and a tally-by-tag view (likely the next
|
||||||
|
step once tags exist).
|
||||||
|
- Editing/renaming/merging/deleting tags in the Manage page. Free-form tags will
|
||||||
|
accumulate cruft and a cleanup surface (akin to item 6 for people) will be
|
||||||
|
wanted eventually, but not in this item.
|
||||||
|
- Adding/removing tags on an already-saved receipt (no edit/detail page yet, same
|
||||||
|
limitation as attachments in item 10).
|
||||||
Loading…
Reference in a new issue