hsa-app/internal/web/views.go
Jean-Michel Tremblay b92787be7a Recent list: initial the first name, keep timestamp from wrapping
Abbreviate "First Last" → "F. Last" in the recent list and nowrap the upload
timestamp, so rows fit on narrow phone screens instead of wrapping.

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

245 lines
7.1 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 "F. Last" to keep the recent
// list compact on narrow screens. Single-word or empty labels are left as-is.
func shortWho(label string) string {
fields := strings.Fields(label)
if len(fields) < 2 {
return label
}
first := []rune(fields[0])
return string(first[0]) + ". " + 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)
}