hsa-app/internal/web/views.go

263 lines
7.6 KiB
Go
Raw Permalink Normal View History

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
Tags []string
}
// 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})
}
tags, err := s.store.ListReceiptTags(row.ID)
if err != nil {
s.serverError(w, "list receipt tags", err)
return
}
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"),
Filename: row.OriginalFilename,
Attachments: links,
Tags: tags,
})
}
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)
}