hsa-app/internal/web/views.go
Jean-Michel Tremblay 011f033f4b Recent list: full first-name initials for hyphenated names
Jean-Michel -> JM (every hyphen-separated part), Lynna -> L. Adds a shortWho test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:04:11 -04:00

255 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"maisym.com/hsa/internal/receipt"
)
const recentPageSize = 10
// dupMatch is one possible-duplicate receipt returned to the upload page.
type dupMatch struct {
Date string `json:"date"`
Amount string `json:"amount"`
Category string `json:"category"`
Who string `json:"who"`
By string `json:"by"`
Uploaded string `json:"uploaded"`
Filename string `json:"filename"`
}
// handleDuplicates returns receipts already posted with the same date+amount, so
// the upload page can warn before the user submits. Read-only; mutates nothing.
func (s *Server) handleDuplicates(w http.ResponseWriter, r *http.Request) {
dateStr := r.URL.Query().Get("date")
amountStr := r.URL.Query().Get("amount")
date, err := time.Parse(dateLayout, dateStr)
if err != nil {
http.Error(w, "bad date", http.StatusBadRequest)
return
}
amountCents, err := receipt.ParseAmountCents(amountStr)
if err != nil {
http.Error(w, "bad amount", http.StatusBadRequest)
return
}
matches, err := s.store.FindDuplicates(date, amountCents)
if err != nil {
s.serverError(w, "find duplicates", err)
return
}
out := make([]dupMatch, 0, len(matches))
for _, m := range matches {
out = append(out, dupMatch{
Date: m.ReceiptDate.Format(dateLayout),
Amount: dollars(m.AmountCents),
Category: m.Category,
Who: m.Who,
By: m.UploadedBy,
Uploaded: m.UploadedAt.Local().Format("2006-01-02 15:04"),
Filename: m.OriginalFilename,
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"matches": out})
}
// recentView is the data for the recent-uploads / recent-receipts tab.
type recentView struct {
Title string // page heading
ByLabel string // "upload date" or "receipt date"
Rows []recentRow // the receipts on this page
PrevURL string // "" when on the first page
NextURL string // "" when there are no more rows
BasePath string // /recent or /recent/receipts
}
// recentRow is one listing row with display-formatted fields.
type recentRow struct {
ID string
Date string
Amount string
Category string
Who string
When string
Filename string
Attachments []attachLink
}
// attachLink is a viewable attachment reference shown under a recent row.
type attachLink struct {
ID string
Filename string
}
func (s *Server) handleRecentUploads(w http.ResponseWriter, r *http.Request) {
s.renderRecent(w, r, "uploaded_at", "Recent uploads", "upload date", "/recent")
}
func (s *Server) handleRecentReceipts(w http.ResponseWriter, r *http.Request) {
s.renderRecent(w, r, "receipt_date", "Recent receipts", "receipt date", "/recent/receipts")
}
func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, title, byLabel, basePath string) {
offset := 0
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
offset = n
}
}
rows, hasMore, err := s.store.ListRecent(orderBy, recentPageSize, offset)
if err != nil {
s.serverError(w, "list recent", err)
return
}
view := recentView{Title: title, ByLabel: byLabel, BasePath: basePath}
for _, row := range rows {
atts, err := s.store.ListAttachmentMeta(row.ID)
if err != nil {
s.serverError(w, "list attachments", err)
return
}
var links []attachLink
for _, a := range atts {
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
}
view.Rows = append(view.Rows, recentRow{
ID: row.ID,
Date: row.ReceiptDate.Format(dateLayout),
Amount: dollars(row.AmountCents),
Category: row.Category,
Who: shortWho(row.Who),
When: row.UploadedAt.Local().Format("2006-01-02 15:04"),
Filename: row.OriginalFilename,
Attachments: links,
})
}
if hasMore {
view.NextURL = fmt.Sprintf("%s?offset=%d", basePath, offset+recentPageSize)
}
if offset > 0 {
prev := offset - recentPageSize
if prev <= 0 {
view.PrevURL = basePath
} else {
view.PrevURL = fmt.Sprintf("%s?offset=%d", basePath, prev)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := recentPage.ExecuteTemplate(w, "base", view); err != nil {
s.serverError(w, "render recent", err)
}
}
// tallyView is the rendered person × year matrix.
type tallyView struct {
Years []int
Rows []tallyRowView
YearTotals []string // aligned with Years
Grand string
Empty bool
}
type tallyRowView struct {
Person string
Cells []string // aligned with Years (dollars, "" for no data)
Total string
}
func (s *Server) handleTally(w http.ResponseWriter, r *http.Request) {
t, err := s.store.Tally()
if err != nil {
s.serverError(w, "tally", err)
return
}
view := tallyView{Years: t.Years, Empty: len(t.Rows) == 0, Grand: dollars(t.Grand)}
for _, y := range t.Years {
view.YearTotals = append(view.YearTotals, dollars(t.YearTotals[y]))
}
for _, row := range t.Rows {
rv := tallyRowView{Person: row.Person, Total: dollars(row.Total)}
for _, y := range t.Years {
if v, ok := row.ByYear[y]; ok {
rv.Cells = append(rv.Cells, dollars(v))
} else {
rv.Cells = append(rv.Cells, "")
}
}
view.Rows = append(view.Rows, rv)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tallyPage.ExecuteTemplate(w, "base", view); err != nil {
s.serverError(w, "render tally", err)
}
}
// handleReceiptFile serves a stored receipt's original file (from the DB blob, so
// it works even if the on-disk copy is missing). Used by the recent-list links.
func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rec, err := s.store.Get(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", rec.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename))
http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData))
}
// handleAttachmentFile serves a receipt attachment's bytes from the DB blob.
func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.store.GetAttachment(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", a.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.ImageData))
}
// shortWho abbreviates a "First Last" person label to "<initials>. Last" to keep
// the recent list compact on narrow screens. The first name's initials include
// every hyphen-separated part ("Jean-Michel" -> "JM", "Lynna" -> "L"). Single-word
// or empty labels are left as-is.
func shortWho(label string) string {
fields := strings.Fields(label)
if len(fields) < 2 {
return label
}
var initials strings.Builder
for _, part := range strings.Split(fields[0], "-") {
if part = strings.TrimSpace(part); part != "" {
initials.WriteString(strings.ToUpper(string([]rune(part)[0])))
}
}
if initials.Len() == 0 {
return label
}
return initials.String() + ". " + fields[len(fields)-1]
}
// dollars formats integer cents as a plain dollar string, e.g. 1234 -> "12.34".
func dollars(cents int64) string {
if cents < 0 {
return "-" + dollars(-cents)
}
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}