From d65c0cd5fa61b4331a301437b18943fa72195617 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Tue, 14 Jul 2026 09:28:33 +0000 Subject: [PATCH] feat: add month hierarchy, AI categories, and UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure hierarchy: Month → Event → Expense (new months table, FK) - Add MonthHandler with CRUD, monthly reports, dropdown create form - Events now scoped under months with extended ownership chain - AI extraction: 16 specific expense categories (Airfare, Meals, etc.) - UI: category dropdown, button-consistent cards, centered mobile shell on desktop - Dashboard/month views show claim totals per card - Description field now mandatory, forms simplified - Months sorted by name chronologically (latest first) --- internal/ai/gemini.go | 2 +- internal/ai/openai.go | 2 +- internal/ai/receipt.go | 26 ++ internal/database/db.go | 179 +++++++- internal/handlers/events.go | 346 +++++++--------- internal/handlers/expenses.go | 21 +- internal/handlers/file.go | 65 +-- internal/handlers/helpers.go | 20 +- internal/handlers/months.go | 758 ++++++++++++++++++++++++++++++++++ main.go | 40 +- static/css/style.css | 27 ++ static/favicon.svg | 2 +- static/icons/icon-180.png | Bin 5290 -> 8316 bytes static/icons/icon-192.png | Bin 5713 -> 8953 bytes static/icons/icon-512.png | Bin 17548 -> 49153 bytes static/sw.js | 2 +- templates/dashboard.html | 127 +++--- templates/event_expenses.html | 76 +--- templates/index.html | 2 +- templates/month_events.html | 159 +++++++ templates/receipt_form.html | 25 +- 21 files changed, 1475 insertions(+), 404 deletions(-) create mode 100644 internal/handlers/months.go create mode 100644 templates/month_events.html diff --git a/internal/ai/gemini.go b/internal/ai/gemini.go index 05e64ac..4b81044 100644 --- a/internal/ai/gemini.go +++ b/internal/ai/gemini.go @@ -88,7 +88,7 @@ func (p geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { payload := geminiRequest{ Contents: []geminiContent{{ Parts: []geminiPart{ - {Text: "Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (Food/Travel/Lodging/Software/Other), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown."}, + {Text: fmt.Sprintf("Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (string, MUST be exactly one of: %s), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown.", categoryPrompt())}, {InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}}, }, }}, diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 4106e3b..a3aa4a2 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -86,7 +86,7 @@ func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { Messages: []openaiMessage{{ Role: "user", Content: []openaiContent{ - {Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences."}, + {Type: "text", Text: fmt.Sprintf("Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, MUST be exactly one of: %s), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences.", categoryPrompt())}, {Type: "image_url", ImageURL: &openaiImage{URL: dataURL}}, }, }}, diff --git a/internal/ai/receipt.go b/internal/ai/receipt.go index 2f9739e..6faaae6 100644 --- a/internal/ai/receipt.go +++ b/internal/ai/receipt.go @@ -23,6 +23,32 @@ type ReceiptData struct { Date string `json:"date"` } +// ValidCategories is the list of allowed expense categories the AI should +// classify receipts into. +var ValidCategories = []string{ + "Airfare", + "Accommodation", + "Meals Self", + "Staff Meal", + "Client Meal", + "Travel - Taxi", + "Travel - Phone", + "Misc Travel", + "Mobile / Office Phone", + "Office Supplies", + "Postage / Couriers", + "Other Expenses", + "Hotel", + "Per Diem", + "Visa Fees", + "Connectivity (internet connections)", +} + +// categoryPrompt returns the comma-separated category list for AI prompts. +func categoryPrompt() string { + return `"Airfare", "Accommodation", "Meals Self", "Staff Meal", "Client Meal", "Travel - Taxi", "Travel - Phone", "Misc Travel", "Mobile / Office Phone", "Office Supplies", "Postage / Couriers", "Other Expenses", "Hotel", "Per Diem", "Visa Fees", "Connectivity (internet connections)"` +} + // Provider is the interface that wraps receipt extraction. // Each provider (Gemini, OpenAI, Ollama) implements this interface. type Provider interface { diff --git a/internal/database/db.go b/internal/database/db.go index ec1eafd..c70f4bf 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -36,10 +36,19 @@ type OTP struct { ExpiresAt string } +// Month represents a row in the months table. +type Month struct { + ID string + UserID string + Name string + CreatedAt string +} + // Event represents a row in the events table. type Event struct { ID string UserID string + MonthID string Name string Status string BaseCurrency string @@ -108,7 +117,7 @@ func Init() (*sql.DB, error) { return DB, nil } -// createTables executes the DDL statements for all four tables. +// createTables executes the DDL statements for all tables. func createTables(db *sql.DB) error { statements := []string{ `CREATE TABLE IF NOT EXISTS users ( @@ -124,15 +133,24 @@ func createTables(db *sql.DB) error { otp_code TEXT NOT NULL, expires_at DATETIME NOT NULL )`, + `CREATE TABLE IF NOT EXISTS months ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )`, `CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, + month_id TEXT NOT NULL, name TEXT NOT NULL, status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open', base_currency TEXT NOT NULL DEFAULT 'EUR', exchange_rate REAL NOT NULL DEFAULT 1.0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY(user_id) REFERENCES users(id) + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS expenses ( id TEXT PRIMARY KEY, @@ -175,6 +193,7 @@ func migrateTables(db *sql.DB) error { "ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''", "ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''", "ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE events ADD COLUMN month_id TEXT NOT NULL DEFAULT ''", } for _, stmt := range migrations { @@ -287,12 +306,124 @@ func DeleteOTP(db *sql.DB, email string) error { return err } +// --------------------------------------------------------------------------- +// Month queries +// --------------------------------------------------------------------------- + +// CreateMonth inserts a new month row. +func CreateMonth(db *sql.DB, id, userID, name string) error { + _, err := db.Exec( + "INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)", + id, userID, name, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreateMonth(%s, %s, %s): %v", + time.Now().Format(time.RFC3339), id, userID, name, err) + } + return err +} + +// GetMonthsByUser returns all months belonging to a user, ordered by creation date descending. +func GetMonthsByUser(db *sql.DB, userID string) ([]Month, error) { + rows, err := db.Query( + "SELECT id, user_id, name, created_at FROM months WHERE user_id = ? ORDER BY created_at DESC", + userID, + ) + if err != nil { + log.Printf("ERROR [%s] database: GetMonthsByUser(%s): %v", + time.Now().Format(time.RFC3339), userID, err) + return nil, err + } + defer rows.Close() + + var months []Month + for rows.Next() { + var m Month + if err := rows.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil { + log.Printf("ERROR [%s] database: GetMonthsByUser scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + months = append(months, m) + } + return months, rows.Err() +} + +// GetMonthByID returns a single month by ID, or nil if not found. +func GetMonthByID(db *sql.DB, id string) (*Month, error) { + row := db.QueryRow("SELECT id, user_id, name, created_at FROM months WHERE id = ?", id) + m := &Month{} + if err := row.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetMonthByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return m, nil +} + +// UpdateMonth updates the name of an existing month. +func UpdateMonth(db *sql.DB, id, name string) error { + _, err := db.Exec("UPDATE months SET name = ? WHERE id = ?", name, id) + if err != nil { + log.Printf("ERROR [%s] database: UpdateMonth(%s): %v", + time.Now().Format(time.RFC3339), id, err) + } + return err +} + +// DeleteMonth removes a month and all its events (cascade deletes expenses via FK). +func DeleteMonth(db *sql.DB, id string) error { + _, err := db.Exec("DELETE FROM months WHERE id = ?", id) + if err != nil { + log.Printf("ERROR [%s] database: DeleteMonth(%s): %v", + time.Now().Format(time.RFC3339), id, err) + } + return err +} + +// GetMonthTotalClaim returns the sum of all converted_amounts across all +// events and expenses in a given month. Returns 0 if no expenses exist. +func GetMonthTotalClaim(db *sql.DB, monthID string) (float64, error) { + var total sql.NullFloat64 + err := db.QueryRow( + `SELECT COALESCE(SUM(e.converted_amount), 0) + FROM expenses e + JOIN events ev ON e.event_id = ev.id + WHERE ev.month_id = ?`, monthID, + ).Scan(&total) + if err != nil { + return 0, err + } + if total.Valid { + return total.Float64, nil + } + return 0, nil +} + +// GetEventTotalClaim returns the sum of all converted_amounts for a given event. +func GetEventTotalClaim(db *sql.DB, eventID string) (float64, error) { + var total sql.NullFloat64 + err := db.QueryRow( + `SELECT COALESCE(SUM(converted_amount), 0) FROM expenses WHERE event_id = ?`, eventID, + ).Scan(&total) + if err != nil { + return 0, err + } + if total.Valid { + return total.Float64, nil + } + return 0, nil +} + // --------------------------------------------------------------------------- // Event queries // --------------------------------------------------------------------------- // CreateEvent inserts a new event row with optional base currency and exchange rate. -func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate float64) error { +func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error { if baseCurrency == "" { baseCurrency = "EUR" } @@ -300,12 +431,12 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate exchangeRate = 1.0 } _, err := db.Exec( - "INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)", - id, userID, name, baseCurrency, exchangeRate, + "INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)", + id, userID, monthID, name, baseCurrency, exchangeRate, ) if err != nil { - log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v", - time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err) + log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v", + time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, err) } return err } @@ -313,7 +444,7 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate // GetEventsByUser returns all events belonging to a user, ordered by creation date descending. func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { rows, err := db.Query( - "SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", + "SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", userID, ) if err != nil { @@ -326,7 +457,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { var events []Event for rows.Next() { var e Event - if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { log.Printf("ERROR [%s] database: GetEventsByUser scan: %v", time.Now().Format(time.RFC3339), err) return nil, err @@ -336,11 +467,37 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { return events, rows.Err() } +// GetEventsByMonth returns all events under a given month, ordered by creation date descending. +func GetEventsByMonth(db *sql.DB, monthID string) ([]Event, error) { + rows, err := db.Query( + "SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE month_id = ? ORDER BY created_at DESC", + monthID, + ) + if err != nil { + log.Printf("ERROR [%s] database: GetEventsByMonth(%s): %v", + time.Now().Format(time.RFC3339), monthID, err) + return nil, err + } + defer rows.Close() + + var events []Event + for rows.Next() { + var e Event + if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + log.Printf("ERROR [%s] database: GetEventsByMonth scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + // GetEventByID returns a single event by ID, or nil if not found. func GetEventByID(db *sql.DB, id string) (*Event, error) { - row := db.QueryRow("SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id) + row := db.QueryRow("SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id) e := &Event{} - if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { + if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } diff --git a/internal/handlers/events.go b/internal/handlers/events.go index 428acd2..0611325 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -1,7 +1,7 @@ // Package handlers provides HTTP request handlers for NextExpense. // -// This file implements event management endpoints including dashboard -// listing, event creation, reopening, and expense viewing. +// This file implements event management endpoints including event creation, +// reopening, closing, and expense viewing — all scoped under a parent month. package handlers import ( @@ -36,66 +36,32 @@ func NewEventHandler(db *sql.DB) *EventHandler { } // --------------------------------------------------------------------------- -// GET /dashboard — Dashboard +// POST /months/{mid}/events — CreateEvent // --------------------------------------------------------------------------- -// Dashboard renders the main dashboard page showing all events belonging -// to the authenticated user, along with the new event creation form. -func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) { - userID := getUserID(r) - if userID == "" { - log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339)) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Redirect to onboarding if the user hasn't completed it yet. - user, _ := database.GetUserByID(h.DB, userID) - if user != nil && !user.Onboarded { - w.Header().Set("HX-Redirect", "/onboarding") - w.WriteHeader(http.StatusOK) - return - } - - events, err := database.GetEventsByUser(h.DB, userID) - if err != nil { - log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v", - time.Now().Format(time.RFC3339), err) - http.Error(w, "Failed to load events", http.StatusInternalServerError) - return - } - - tmpl := getTemplate("dashboard.html") - - data := map[string]interface{}{ - "Events": events, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.Execute(w, data); err != nil { - log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v", - time.Now().Format(time.RFC3339), err) - } -} - -// --------------------------------------------------------------------------- -// POST /events — CreateEvent -// --------------------------------------------------------------------------- - -// CreateEvent handles the creation of a new event for the authenticated user. -// It reads the event name from the form, generates a UUID, persists the -// event, and redirects to the dashboard via HX-Redirect. +// CreateEvent handles the creation of a new event under a given month. func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + if monthID == "" { + http.Error(w, "Missing month ID", http.StatusBadRequest) + return + } + userID := getUserID(r) if userID == "" { - log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + name := r.FormValue("name") if name == "" { - log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339)) http.Error(w, "Event name is required", http.StatusBadRequest) return } @@ -105,8 +71,7 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { baseCurrency = "USD" } - // Compute exchange rate from user-provided sample: - // e.g. receipt=1000 KES, claimed=7.73 USD → rate = 7.73 / 1000 = 0.00773 + // Compute exchange rate from user-provided sample. exchangeRate := 1.0 sampleReceipt := r.FormValue("sample_receipt_amount") sampleClaim := r.FormValue("sample_claim_amount") @@ -115,201 +80,176 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64) if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 { exchangeRate = sampleClaimVal / sampleReceiptVal - log.Printf("INFO [%s] handlers: CreateEvent: computed rate %.6f from sample %s %s → %.2f %s", - time.Now().Format(time.RFC3339), exchangeRate, - r.FormValue("sample_receipt_currency"), sampleReceipt, sampleClaimVal, baseCurrency) } } id := utils.NewUUID() - if err := database.CreateEvent(h.DB, id, userID, name, baseCurrency, exchangeRate); err != nil { + if err := database.CreateEvent(h.DB, id, userID, monthID, name, baseCurrency, exchangeRate); err != nil { log.Printf("ERROR [%s] handlers: CreateEvent: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to create event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// PUT /events/{id}/reopen — ReopenEvent +// PUT /months/{mid}/events/{eid}/reopen — ReopenEvent // --------------------------------------------------------------------------- -// ReopenEvent sets an event's status back to "open" and returns an HTMX -// fragment replacing the event's status badge with a green "open" badge. -// It verifies that the requesting user owns the event. +// ReopenEvent sets an event's status back to "open". Verifies month and event ownership. func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID", - time.Now().Format(time.RFC3339)) - http.Error(w, "Missing event ID", http.StatusBadRequest) + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { - log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID", - time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - event, err := database.GetEventByID(h.DB, eventID) - if err != nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to retrieve event", http.StatusInternalServerError) - return - } - if event == nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found", - time.Now().Format(time.RFC3339), eventID) - http.Error(w, "Event not found", http.StatusNotFound) - return - } - if event.UserID != userID { - log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s", - time.Now().Format(time.RFC3339), eventID) + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } - if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { - log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to reopen event", http.StatusInternalServerError) - return - } - - // Clean up any stale download packages — user must regenerate after reopening. - if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { - for _, fn := range oldFiles { - os.Remove(filepath.Join("storage", "postbox", fn)) - } - } - - // Redirect to dashboard so the full page renders with updated status. - w.Header().Set("HX-Redirect", "/dashboard") - w.WriteHeader(http.StatusOK) -} - -// --------------------------------------------------------------------------- -// POST /events/{id}/close — CloseEvent -// --------------------------------------------------------------------------- - -// CloseEvent sets an event's status to "closed". Only the event owner may -// close it. On success, redirects to the dashboard. -func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - http.Error(w, "Missing event ID", http.StatusBadRequest) - return - } - - userID := getUserID(r) - if userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } - if event.UserID != userID { + if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } - if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { - log.Printf("ERROR [%s] handlers: CloseEvent: UpdateEventStatus(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to close event", http.StatusInternalServerError) + if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { + log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to reopen event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + // Clean up stale download packages. + if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { + for _, fn := range oldFiles { + os.Remove(filepath.Join("storage", "postbox", fn)) + } + } + + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// GET /events/{id}/expenses — ViewEventExpenses +// POST /months/{mid}/events/{eid}/close — CloseEvent // --------------------------------------------------------------------------- -// ViewEventExpenses displays the expense collection view for a specific event. -// It verifies event ownership, sets the current_event_id cookie, and renders -// the event_expenses.html template with the event and its expense list. -func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID", - time.Now().Format(time.RFC3339)) - http.Error(w, "Missing event ID", http.StatusBadRequest) +// CloseEvent sets an event's status to "closed". +func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) return } userID := getUserID(r) if userID == "" { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID", - time.Now().Format(time.RFC3339)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - event, err := database.GetEventByID(h.DB, eventID) - if err != nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) - http.Error(w, "Failed to retrieve event", http.StatusInternalServerError) + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) return } - if event == nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found", - time.Now().Format(time.RFC3339), eventID) + + event, err := database.GetEventByID(h.DB, eventID) + if err != nil || event == nil { http.Error(w, "Event not found", http.StatusNotFound) return } - if event.UserID != userID { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s", - time.Now().Format(time.RFC3339), eventID) + if event.MonthID != monthID || event.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { + log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to close event", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/months/"+monthID) + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses +// --------------------------------------------------------------------------- + +// ViewEventExpenses displays the expense collection view for a specific event. +func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + http.Error(w, "Missing ID", http.StatusBadRequest) + return + } + + userID := getUserID(r) + if userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Verify month ownership. + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + event, err := database.GetEventByID(h.DB, eventID) + if err != nil || event == nil { + http.Error(w, "Event not found", http.StatusNotFound) + return + } + if event.MonthID != monthID || event.UserID != userID { http.Error(w, "Forbidden", http.StatusForbidden) return } expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { - log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v", - time.Now().Format(time.RFC3339), eventID, err) + log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err) http.Error(w, "Failed to load expenses", http.StatusInternalServerError) return } - // Set the current_event_id cookie so subsequent expense operations - // (SaveExpense, UploadReceipt) know which event to associate with. + // Set current_event_id cookie for expense operations. setCurrentEventID(w, eventID) tmpl := getTemplate("event_expenses.html") - totalClaim := 0.0 - for _, exp := range expenses { - if exp.ConvertedAmount > 0 { - totalClaim += exp.ConvertedAmount - } - } - - // Normalize ImagePath for all expenses (old DB entries may have storage/ prefix). for i := range expenses { expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath) } data := map[string]interface{}{ - "Event": event, - "Expenses": expenses, - "TotalClaim": totalClaim, + "Month": month, + "Event": event, + "Expenses": expenses, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -320,56 +260,59 @@ func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) } // --------------------------------------------------------------------------- -// GET /events/{id}/edit — EditEvent +// GET /months/{mid}/events/{eid}/edit — EditEvent // --------------------------------------------------------------------------- // EditEvent returns the event edit form fragment pre-filled with current data. func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } - // Build a slug for the sample receipt currency (use base currency as default). rcptCur := event.BaseCurrency if rcptCur == "" { rcptCur = "KES" } - // Compute a rough reverse sample from the exchange rate: - // rate = claim / receipt → sample_receipt = 1000, sample_claim = 1000 * rate sampleClm := event.ExchangeRate * 1000 w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `

Edit Event

-
+
-
- - -
-
-
Conversion Sample
-

Update the sample to recalculate the rate.

-
-
- +
+
Conversion Rate
+
+
+ + +
+
+ + +
+
+
+
+
-
+
-
- - +
+ Rate = Claim / Local
@@ -378,21 +321,23 @@ func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) { onclick="document.getElementById('create-event-form').innerHTML='';document.getElementById('create-event-form').classList.add('hidden')">Cancel - +
-
`, event.ID, template.HTMLEscapeString(event.Name), template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), sampleClm, event.ID, event.ID, event.ID) +
`, monthID, event.ID, template.HTMLEscapeString(event.Name), sampleClm, template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), event.ID, monthID, event.ID, event.ID) } // --------------------------------------------------------------------------- -// PUT /events/{id} — UpdateEvent +// PUT /months/{mid}/events/{eid} — UpdateEvent // --------------------------------------------------------------------------- // UpdateEvent updates the event's base currency and exchange rate. func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } @@ -419,33 +364,34 @@ func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) { return } - // Recalculate all existing expense converted amounts with the new rate. if err := database.RecalculateExpenses(h.DB, eventID, baseCurrency, exchangeRate); err != nil { log.Printf("ERROR [%s] handlers: UpdateEvent: recalc expenses: %v", time.Now().Format(time.RFC3339), err) - // Non-fatal — the event was updated, but expenses may have stale conversions. } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- -// DELETE /events/{id} — DeleteEvent +// DELETE /months/{mid}/events/{eid} — DeleteEvent // --------------------------------------------------------------------------- // DeleteEvent removes an event and its expenses after ownership verification. func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + event, err := database.GetEventByID(h.DB, eventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { http.Error(w, "Forbidden", http.StatusForbidden) return } + if err := database.DeleteEvent(h.DB, eventID); err != nil { log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err) http.Error(w, "Failed to delete event", http.StatusInternalServerError) return } - w.Header().Set("HX-Redirect", "/dashboard") + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index e615ad1..a061f13 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -261,6 +261,9 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) { if date == "" { missing = append(missing, "date") } + if description == "" { + missing = append(missing, "description") + } if len(missing) > 0 { log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s", time.Now().Format(time.RFC3339), strings.Join(missing, ", ")) @@ -373,12 +376,16 @@ func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, expense.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } tmpl := getTemplate("receipt_form.html") @@ -442,12 +449,16 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, existing.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } expense := database.Expense{ ID: expenseID, @@ -520,12 +531,16 @@ func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { return } - // Verify ownership: the expense's event must belong to the current user. + // Verify ownership: expense → event → month → user. event, err := database.GetEventByID(h.DB, existing.EventID) if err != nil || event == nil || event.UserID != getUserID(r) { http.Error(w, "Forbidden", http.StatusForbidden) return } + if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } eventID := existing.EventID diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 6141016..42eaacd 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -47,24 +47,15 @@ type FileHandler struct { // FileEvent generates an expense report (CSV or PDF) for a given event and // emails it as an attachment to the specified recipient. On success the // event status is updated to "closed" and the client is redirected to the -// dashboard via the HX-Redirect header. -// -// Flow: -// 1. Extract event ID from the URL via chi.URLParam -// 2. Parse the form for target email and report format -// 3. Verify the authenticated user owns this event -// 4. Fetch all expenses for the event from the database -// 5. Generate the report in the requested format (CSV or PDF) -// 6. Send the report as an email attachment -// 7. Update the event status to "closed" -// 8. Return an HX-Redirect header pointing to /dashboard +// month view via the HX-Redirect header. func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { - // 1. Get event ID from the URL path parameter. - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL", + // 1. Get month and event IDs from the URL path parameters. + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL", time.Now().Format(time.RFC3339)) - renderFileError(w, "Missing event ID.") + renderFileError(w, "Missing ID.") return } @@ -85,7 +76,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } - // 3. Verify the authenticated user owns this event. + // 3. Verify the authenticated user owns this event (via month). userID := getUserID(r) if userID == "" { log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", @@ -94,6 +85,11 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } + if !verifyMonthOwnership(h.DB, monthID, userID) { + renderFileError(w, "You do not have permission to file this event.") + return + } + event, err := database.GetEventByID(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v", @@ -107,7 +103,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { renderFileError(w, "Event not found.") return } - if event.UserID != userID { + if event.MonthID != monthID || event.UserID != userID { log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s", time.Now().Format(time.RFC3339), userID, eventID) renderFileError(w, "You do not have permission to file this event.") @@ -184,8 +180,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { return } - // 8. Redirect to the dashboard via HTMX. - w.Header().Set("HX-Redirect", "/dashboard") + // 8. Redirect to the month view via HTMX. + w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } @@ -198,11 +194,12 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // HTMX fragment with download and email-link options. The event is NOT // closed — the user can add more receipts and regenerate. func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { - eventID := chi.URLParam(r, "id") - if eventID == "" { - log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID", + monthID := chi.URLParam(r, "mid") + eventID := chi.URLParam(r, "eid") + if monthID == "" || eventID == "" { + log.Printf("ERROR [%s] handlers: GenerateReport: missing ID", time.Now().Format(time.RFC3339)) - renderFileError(w, "Missing event ID.") + renderFileError(w, "Missing ID.") return } @@ -219,12 +216,17 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { return } + if !verifyMonthOwnership(h.DB, monthID, userID) { + renderFileError(w, "You do not have permission to access this event.") + return + } + event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { renderFileError(w, "Event not found.") return } - if event.UserID != userID { + if event.MonthID != monthID || event.UserID != userID { renderFileError(w, "You do not have permission to access this event.") return } @@ -345,7 +347,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {

Or send a download link via email (tiny email, no attachment limits):

-
+ @@ -356,17 +358,18 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
`, len(expenses), template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), - template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) + template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) } // --------------------------------------------------------------------------- -// POST /events/{id}/send-link — SendDownloadLink +// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink // --------------------------------------------------------------------------- // SendDownloadLink emails a download link for a previously generated report -// package to the specified recipient. Returns an HTMX fragment with success -// or error feedback. +// package to the specified recipient. func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + if err := r.ParseForm(); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Failed to parse form.
`) @@ -390,7 +393,7 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { } event, err := database.GetEventByID(h.DB, dt.EventID) - if err != nil || event == nil || event.UserID != getUserID(r) { + if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Permission denied.
`) return diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 18d0e9a..e58071d 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -1,6 +1,11 @@ package handlers -import "strings" +import ( + "database/sql" + "strings" + + "github.com/cclohmar/NextExpense/internal/database" +) // normalizeImagePath strips a legacy "storage/" prefix if present, so that // the template can safely build "/storage/{filename}" URLs regardless of @@ -8,3 +13,16 @@ import "strings" func normalizeImagePath(path string) string { return strings.TrimPrefix(path, "storage/") } + +// verifyMonthOwnership checks that a month exists and belongs to the given user. +// Returns true if the month is owned by the user, false otherwise. +func verifyMonthOwnership(db *sql.DB, monthID, userID string) bool { + if monthID == "" { + return false + } + month, err := database.GetMonthByID(db, monthID) + if err != nil || month == nil { + return false + } + return month.UserID == userID +} diff --git a/internal/handlers/months.go b/internal/handlers/months.go new file mode 100644 index 0000000..d675ff8 --- /dev/null +++ b/internal/handlers/months.go @@ -0,0 +1,758 @@ +// Package handlers provides HTTP request handlers for NextExpense. +// +// This file implements month management endpoints including month listing, +// creation, editing, deletion, event viewing within a month, and monthly +// report generation that aggregates all events across a month. +package handlers + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/cclohmar/NextExpense/internal/database" + "github.com/cclohmar/NextExpense/internal/email" + "github.com/cclohmar/NextExpense/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/jung-kurt/gofpdf" +) + +// --------------------------------------------------------------------------- +// MonthHandler +// --------------------------------------------------------------------------- + +// MonthHandler groups HTTP handlers related to month management. +// It depends on a shared *sql.DB handle for database operations and an +// optional *email.Sender for delivering monthly reports via email. +type MonthHandler struct { + DB *sql.DB + EmailSender *email.Sender +} + +// NewMonthHandler creates a new MonthHandler with the given database handle. +func NewMonthHandler(db *sql.DB) *MonthHandler { + return &MonthHandler{DB: db} +} + +// --------------------------------------------------------------------------- +// GET /dashboard — ListMonths (replaces old EventHandler.Dashboard) +// --------------------------------------------------------------------------- + +// ListMonths renders the main dashboard page showing all months belonging +// to the authenticated user, along with the create month form. +func (h *MonthHandler) ListMonths(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + log.Printf("ERROR [%s] handlers: ListMonths: missing user ID", time.Now().Format(time.RFC3339)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Redirect to onboarding if the user hasn't completed it yet. + user, _ := database.GetUserByID(h.DB, userID) + if user != nil && !user.Onboarded { + w.Header().Set("HX-Redirect", "/onboarding") + w.WriteHeader(http.StatusOK) + return + } + + months, err := database.GetMonthsByUser(h.DB, userID) + if err != nil { + log.Printf("ERROR [%s] handlers: ListMonths: GetMonthsByUser: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to load months", http.StatusInternalServerError) + return + } + + // Sort by month name descending (latest first): "December 2026" before "January 2026". + sort.Slice(months, func(i, j int) bool { + yi, mi := parseMonthName(months[i].Name) + yj, mj := parseMonthName(months[j].Name) + if yi != yj { + return yi > yj + } + return mi > mj + }) + + // Compute total claim per month. + type MonthWithTotal struct { + Month database.Month + Total float64 + } + var items []MonthWithTotal + for _, m := range months { + total, _ := database.GetMonthTotalClaim(h.DB, m.ID) + items = append(items, MonthWithTotal{Month: m, Total: total}) + } + + tmpl := getTemplate("dashboard.html") + + data := map[string]interface{}{ + "Months": items, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR [%s] handlers: ListMonths: execute template: %v", + time.Now().Format(time.RFC3339), err) + } +} + +// --------------------------------------------------------------------------- +// POST /months — CreateMonth +// --------------------------------------------------------------------------- + +// CreateMonth handles the creation of a new month for the authenticated user. +func (h *MonthHandler) CreateMonth(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + log.Printf("ERROR [%s] handlers: CreateMonth: missing user ID", time.Now().Format(time.RFC3339)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + month := strings.TrimSpace(r.FormValue("month")) + year := strings.TrimSpace(r.FormValue("year")) + if month == "" || year == "" { + log.Printf("ERROR [%s] handlers: CreateMonth: missing month or year", time.Now().Format(time.RFC3339)) + http.Error(w, "Month and year are required", http.StatusBadRequest) + return + } + name := month + " " + year + + id := utils.NewUUID() + if err := database.CreateMonth(h.DB, id, userID, name); err != nil { + log.Printf("ERROR [%s] handlers: CreateMonth: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to create month", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// GET /months/{mid} — ViewMonth +// --------------------------------------------------------------------------- + +// ViewMonth displays all events under a given month. +func (h *MonthHandler) ViewMonth(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + if monthID == "" { + http.Error(w, "Missing month ID", http.StatusBadRequest) + return + } + + userID := getUserID(r) + if userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil { + http.Error(w, "Month not found", http.StatusNotFound) + return + } + if month.UserID != userID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + events, err := database.GetEventsByMonth(h.DB, monthID) + if err != nil { + log.Printf("ERROR [%s] handlers: ViewMonth: GetEventsByMonth(%s): %v", + time.Now().Format(time.RFC3339), monthID, err) + http.Error(w, "Failed to load events", http.StatusInternalServerError) + return + } + + // Compute total claim per event. + type EventWithTotal struct { + Event database.Event + Total float64 + Currency string + } + var items []EventWithTotal + for _, evt := range events { + total, _ := database.GetEventTotalClaim(h.DB, evt.ID) + items = append(items, EventWithTotal{Event: evt, Total: total, Currency: evt.BaseCurrency}) + } + + tmpl := getTemplate("month_events.html") + + data := map[string]interface{}{ + "Month": month, + "Events": items, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR [%s] handlers: ViewMonth: execute template: %v", + time.Now().Format(time.RFC3339), err) + } +} + +// --------------------------------------------------------------------------- +// GET /months/{mid}/edit — EditMonth +// --------------------------------------------------------------------------- + +// EditMonth returns an inline edit form fragment for a month. +func (h *MonthHandler) EditMonth(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != getUserID(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
+

Edit Month

+ +
+ + +
+ +
+ + + +
+ +
`, month.ID, template.HTMLEscapeString(month.Name), month.ID, month.ID, month.ID) +} + +// --------------------------------------------------------------------------- +// PUT /months/{mid} — UpdateMonth +// --------------------------------------------------------------------------- + +// UpdateMonth updates a month's name after ownership verification. +func (h *MonthHandler) UpdateMonth(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != getUserID(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Month name is required", http.StatusBadRequest) + return + } + + if err := database.UpdateMonth(h.DB, monthID, name); err != nil { + log.Printf("ERROR [%s] handlers: UpdateMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err) + http.Error(w, "Failed to update month", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// DELETE /months/{mid} — DeleteMonth +// --------------------------------------------------------------------------- + +// DeleteMonth removes a month and all its events (cascade deletes expenses). +func (h *MonthHandler) DeleteMonth(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil || month.UserID != getUserID(r) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := database.DeleteMonth(h.DB, monthID); err != nil { + log.Printf("ERROR [%s] handlers: DeleteMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err) + http.Error(w, "Failed to delete month", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// POST /months/{mid}/generate — GenerateMonthlyReport +// --------------------------------------------------------------------------- + +// GenerateMonthlyReport aggregates all expenses across all events in a month +// into a single report package (CSV/PDF + all receipt images ZIP), stores +// it with a download token, and returns an HTMX fragment with download link. +func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Request) { + monthID := chi.URLParam(r, "mid") + if monthID == "" { + renderFileError(w, "Missing month ID.") + return + } + + if err := r.ParseForm(); err != nil { + renderFileError(w, "Cannot parse form data.") + return + } + + format := strings.ToLower(strings.TrimSpace(r.FormValue("format"))) + if format != "csv" && format != "pdf" { + format = "pdf" + } + + userID := getUserID(r) + if userID == "" { + renderFileError(w, "Session expired. Please log in again.") + return + } + + month, err := database.GetMonthByID(h.DB, monthID) + if err != nil || month == nil { + renderFileError(w, "Month not found.") + return + } + if month.UserID != userID { + renderFileError(w, "You do not have permission to access this month.") + return + } + + // Get all events for this month. + events, err := database.GetEventsByMonth(h.DB, monthID) + if err != nil { + renderFileError(w, "Failed to retrieve events.") + return + } + if len(events) == 0 { + renderFileError(w, "No events in this month.") + return + } + + // Aggregate all expenses across all events. + var allExpenses []database.Expense + for _, evt := range events { + expenses, err := database.GetExpensesByEvent(h.DB, evt.ID) + if err != nil { + continue + } + allExpenses = append(allExpenses, expenses...) + } + if len(allExpenses) == 0 { + renderFileError(w, "No expenses to include in the report.") + return + } + + // Fetch user info for report personalisation. + repUser, _ := database.GetUserByID(h.DB, userID) + uName := "" + uDept := "" + if repUser != nil { + uName = repUser.Name + uDept = repUser.Department + } + + // Generate the report. + var reportAtt *email.Attachment + if format == "csv" { + reportAtt, err = generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept) + } else { + reportAtt, err = generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept) + } + if err != nil { + log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate %s: %v", + time.Now().Format(time.RFC3339), format, err) + renderFileError(w, "Failed to generate report.") + return + } + + // Package everything into a single flat ZIP. + var pkgBuf bytes.Buffer + pkg := zip.NewWriter(&pkgBuf) + + addToZip(pkg, reportAtt.Filename, reportAtt.Content) + + // Add all receipt images from all events. + imgIdx := 0 + for _, evt := range events { + expenses, _ := database.GetExpensesByEvent(h.DB, evt.ID) + for _, exp := range expenses { + if exp.ImagePath == "" { + continue + } + normPath := normalizeImagePath(exp.ImagePath) + safePath := filepath.Join("storage", filepath.Base(normPath)) + data, err := os.ReadFile(safePath) + if err != nil { + continue + } + ext := filepath.Ext(exp.ImagePath) + if ext == "" { + ext = ".jpg" + } + imgIdx++ + imgName := fmt.Sprintf("receipt-%d%s", imgIdx, ext) + addToZip(pkg, imgName, data) + } + } + + if err := pkg.Close(); err != nil { + renderFileError(w, "Failed to create package.") + return + } + + // Save to postbox directory. + os.MkdirAll("storage/postbox", 0755) + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + renderFileError(w, "Failed to generate download token.") + return + } + token := hex.EncodeToString(tokenBytes) + + safeMonth := sanitiseFilename(month.Name) + if safeMonth == "" { + safeMonth = "monthly-report" + } + dlName := safeMonth + ".zip" + pkgFilename := token + ".zip" + pkgPath := filepath.Join("storage", "postbox", pkgFilename) + + if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil { + log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: write %s: %v", + time.Now().Format(time.RFC3339), pkgPath, err) + renderFileError(w, "Failed to save report package.") + return + } + + // Store token in DB (24h expiry). Use monthID as event_id for token tracking. + expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339) + if err := database.CreateDownloadToken(h.DB, token, monthID, pkgFilename, expiresAt); err != nil { + os.Remove(pkgPath) + renderFileError(w, "Failed to store download token.") + return + } + + log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s", + time.Now().Format(time.RFC3339), pkgFilename, monthID) + + ext := format + reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(month.Name), ext) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
+
Monthly Report Ready
+

%s & %d events, %d receipt images packaged.

+ +
+

Or send a download link via email:

+
+ + + +
+ + +
+
`, + template.HTMLEscapeString(reportName), len(events), imgIdx, + template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), + template.HTMLEscapeString(monthID), template.HTMLEscapeString(token)) +} + +// --------------------------------------------------------------------------- +// POST /months/{mid}/send-link — SendMonthlyDownloadLink +// --------------------------------------------------------------------------- + +// SendMonthlyDownloadLink emails a download link for a previously generated +// monthly report package. +func (h *MonthHandler) SendMonthlyDownloadLink(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Failed to parse form.
`) + return + } + + token := strings.TrimSpace(r.FormValue("token")) + to := strings.TrimSpace(r.FormValue("email")) + if token == "" || to == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Token and email are required.
`) + return + } + + // Verify token exists. + dt, err := database.GetDownloadTokenByToken(h.DB, token) + if err != nil || dt == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Invalid or expired download token.
`) + return + } + + // For monthly reports, the token's event_id stores the month_id. + // Verify the month belongs to the user. + month, err := database.GetMonthByID(h.DB, dt.EventID) + if err != nil || month == nil || month.UserID != getUserID(r) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Permission denied.
`) + return + } + + if h.EmailSender == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
SMTP not configured.
`) + return + } + + scheme := "https" + host := r.Host + baseURL := os.Getenv("BASE_URL") + if host == "" && baseURL != "" { + if strings.HasPrefix(baseURL, "https://") { + host = strings.TrimPrefix(baseURL, "https://") + } else if strings.HasPrefix(baseURL, "http://") { + scheme = "http" + host = strings.TrimPrefix(baseURL, "http://") + } + } + if host == "" { + host = "localhost:8080" + } + + safeName := sanitiseFilename(month.Name) + if safeName == "" { + safeName = "monthly-report" + } + link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName) + + subject := "Monthly Expense Report: " + month.Name + body := fmt.Sprintf("Monthly expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", month.Name, link) + + if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil { + log.Printf("ERROR [%s] handlers: SendMonthlyDownloadLink: %v", + time.Now().Format(time.RFC3339), err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Failed to send: %s
`, + template.HTMLEscapeString(err.Error())) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
Download link sent to %s.
`, + template.HTMLEscapeString(to)) +} + +// --------------------------------------------------------------------------- +// Monthly report generation helpers +// --------------------------------------------------------------------------- + +// generateMonthlyCSV creates a CSV attachment aggregating expenses across +// all events in a month. Each event is prefixed with a section header. +func generateMonthlyCSV(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("Monthly Expense Report: %s\r\n", monthName)) + if userName != "" { + metaLine := fmt.Sprintf("Prepared by: %s", userName) + if userDept != "" && userDept != "-" { + metaLine += fmt.Sprintf(" | Department: %s", userDept) + } + buf.WriteString(metaLine + "\r\n") + } + buf.WriteString("\r\n") + + // Group expenses by event. + expensesByEvent := make(map[string][]database.Expense) + eventNames := make(map[string]string) + for _, evt := range events { + eventNames[evt.ID] = evt.Name + } + for _, exp := range expenses { + expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp) + } + + itemNum := 1 + var grandTotalOrig, grandTotalConv float64 + + for _, evt := range events { + evtExpenses := expensesByEvent[evt.ID] + if len(evtExpenses) == 0 { + continue + } + + buf.WriteString(fmt.Sprintf("\r\n--- %s ---\r\n", eventNames[evt.ID])) + buf.WriteString("#,Date,Merchant,Amount,Currency,Category,Description\r\n") + + var evtTotal float64 + for _, exp := range evtExpenses { + buf.WriteString(fmt.Sprintf("%d,%s,%s,%.2f,%s,%s,%s\r\n", + itemNum, exp.Date, exp.Merchant, exp.Amount, exp.Currency, exp.Category, exp.Description)) + evtTotal += exp.Amount + if exp.ConvertedAmount > 0 { + grandTotalConv += exp.ConvertedAmount + } else { + grandTotalConv += exp.Amount + } + itemNum++ + } + buf.WriteString(fmt.Sprintf("Event Total,,,,%.2f,,\r\n", evtTotal)) + grandTotalOrig += evtTotal + } + buf.WriteString(fmt.Sprintf("\r\nGrand Total,,,,%.2f,,\r\n", grandTotalOrig)) + + filename := fmt.Sprintf("monthly-%s-report.csv", sanitiseFilename(monthName)) + return &email.Attachment{ + Filename: filename, + Content: []byte(buf.String()), + }, nil +} + +// generateMonthlyPDF creates a PDF attachment aggregating expenses across +// all events in a month, with a section per event. +func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + + // Title. + pdf.SetFont("Helvetica", "B", 16) + pdf.Cell(0, 10, "Monthly Expense Report: "+monthName) + pdf.Ln(8) + + // User info. + if userName != "" { + pdf.SetFont("Helvetica", "", 9) + infoLine := fmt.Sprintf("Prepared by: %s", userName) + if userDept != "" && userDept != "-" { + infoLine += fmt.Sprintf(" | Department: %s", userDept) + } + pdf.Cell(0, 6, infoLine) + pdf.Ln(10) + } + + // Group expenses by event. + expensesByEvent := make(map[string][]database.Expense) + eventNames := make(map[string]string) + for _, evt := range events { + eventNames[evt.ID] = evt.Name + } + for _, exp := range expenses { + expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp) + } + + itemNum := 1 + colWidths := []float64{8, 22, 38, 18, 14, 24, 30} + headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Desc."} + marginBottom := 20.0 + + var grandTotal float64 + for _, evt := range events { + evtExpenses := expensesByEvent[evt.ID] + if len(evtExpenses) == 0 { + continue + } + + // Event section header with page break check. + if pdf.GetY() > 260 { + pdf.AddPage() + } + pdf.SetFont("Helvetica", "B", 11) + pdf.Cell(0, 8, eventNames[evt.ID]) + pdf.Ln(10) + + // Column headers. + pdf.SetFont("Helvetica", "B", 9) + for j, h := range headers { + pdf.Cell(colWidths[j], 8, h) + } + pdf.Ln(8) + + var evtTotal float64 + pdf.SetFont("Helvetica", "", 9) + for _, exp := range evtExpenses { + if pdf.GetY() > 297-marginBottom { + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 9) + for j, h := range headers { + pdf.Cell(colWidths[j], 8, h) + } + pdf.Ln(8) + pdf.SetFont("Helvetica", "", 9) + } + pdf.Cell(colWidths[0], 7, fmt.Sprintf("%d", itemNum)) + pdf.Cell(colWidths[1], 7, exp.Date) + pdf.Cell(colWidths[2], 7, truncateString(exp.Merchant, 18)) + pdf.Cell(colWidths[3], 7, fmt.Sprintf("%.2f", exp.Amount)) + pdf.Cell(colWidths[4], 7, exp.Currency) + pdf.Cell(colWidths[5], 7, truncateString(exp.Category, 12)) + pdf.Cell(colWidths[6], 7, truncateString(exp.Description, 18)) + pdf.Ln(7) + evtTotal += exp.Amount + itemNum++ + } + + // Event subtotal with separator. + pdf.SetDrawColor(71, 85, 105) + pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1) + pdf.Ln(3) + pdf.SetFont("Helvetica", "B", 9) + pdf.Cell(colWidths[0], 8, "") + pdf.Cell(colWidths[1], 8, "") + pdf.Cell(colWidths[2], 8, "Event Total") + pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtTotal)) + pdf.Ln(10) + grandTotal += evtTotal + } + + // Grand total. + pdf.SetFont("Helvetica", "B", 11) + pdf.Cell(0, 8, fmt.Sprintf("Grand Total: %.2f", grandTotal)) + pdf.Ln(10) + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, fmt.Errorf("PDF output: %w", err) + } + + filename := fmt.Sprintf("monthly-%s-report.pdf", sanitiseFilename(monthName)) + return &email.Attachment{ + Filename: filename, + Content: buf.Bytes(), + }, nil +} + +// parseMonthName extracts year and month number from a name like "July 2026". +// Returns (0, 0) if parsing fails. +func parseMonthName(name string) (year, month int) { + parts := strings.Fields(name) + if len(parts) < 2 { + return 0, 0 + } + months := map[string]int{ + "january": 1, "february": 2, "march": 3, "april": 4, + "may": 5, "june": 6, "july": 7, "august": 8, + "september": 9, "october": 10, "november": 11, "december": 12, + } + m, ok := months[strings.ToLower(parts[0])] + if !ok { + return 0, 0 + } + y, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, 0 + } + return y, m +} diff --git a/main.go b/main.go index 797519c..181821b 100644 --- a/main.go +++ b/main.go @@ -100,6 +100,9 @@ func main() { EmailSender: emailSender, } + monthHandler := handlers.NewMonthHandler(db) + monthHandler.EmailSender = emailSender + eventHandler := handlers.NewEventHandler(db) expenseHandler := handlers.NewExpenseHandler(db) fileHandler := &handlers.FileHandler{ @@ -209,19 +212,30 @@ func main() { r.Group(func(r chi.Router) { r.Use(authHandler.RequireAuth) - // Events. - r.Get("/dashboard", eventHandler.Dashboard) + // Dashboard (months). + r.Get("/dashboard", monthHandler.ListMonths) r.Get("/onboarding", authHandler.OnboardingPage) r.Post("/onboarding", authHandler.SaveOnboarding) r.Get("/profile", authHandler.ProfilePage) r.Post("/profile", authHandler.SaveProfile) - r.Post("/events", eventHandler.CreateEvent) - r.Put("/events/{id}", eventHandler.UpdateEvent) - r.Get("/events/{id}/edit", eventHandler.EditEvent) - r.Delete("/events/{id}", eventHandler.DeleteEvent) - r.Put("/events/{id}/reopen", eventHandler.ReopenEvent) - r.Post("/events/{id}/close", eventHandler.CloseEvent) - r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses) + + // Months. + r.Post("/months", monthHandler.CreateMonth) + r.Put("/months/{mid}", monthHandler.UpdateMonth) + r.Delete("/months/{mid}", monthHandler.DeleteMonth) + r.Get("/months/{mid}/edit", monthHandler.EditMonth) + r.Get("/months/{mid}", monthHandler.ViewMonth) + r.Post("/months/{mid}/generate", monthHandler.GenerateMonthlyReport) + r.Post("/months/{mid}/send-link", monthHandler.SendMonthlyDownloadLink) + + // Events (scoped under months). + r.Post("/months/{mid}/events", eventHandler.CreateEvent) + r.Put("/months/{mid}/events/{eid}", eventHandler.UpdateEvent) + r.Get("/months/{mid}/events/{eid}/edit", eventHandler.EditEvent) + r.Delete("/months/{mid}/events/{eid}", eventHandler.DeleteEvent) + r.Put("/months/{mid}/events/{eid}/reopen", eventHandler.ReopenEvent) + r.Post("/months/{mid}/events/{eid}/close", eventHandler.CloseEvent) + r.Get("/months/{mid}/events/{eid}/expenses", eventHandler.ViewEventExpenses) // Expenses. r.Post("/expenses/upload", expenseHandler.UploadReceipt) @@ -230,10 +244,10 @@ func main() { r.Put("/expenses/{id}", expenseHandler.UpdateExpense) r.Delete("/expenses/{id}", expenseHandler.DeleteExpense) - // Filing. - r.Post("/events/{id}/file", fileHandler.FileEvent) - r.Post("/events/{id}/generate", fileHandler.GenerateReport) - r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink) + // Filing (event-level). + r.Post("/months/{mid}/events/{eid}/file", fileHandler.FileEvent) + r.Post("/months/{mid}/events/{eid}/generate", fileHandler.GenerateReport) + r.Post("/months/{mid}/events/{eid}/send-link", fileHandler.SendDownloadLink) // Storage (receipt images) — protected by auth + path traversal check. r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) { diff --git a/static/css/style.css b/static/css/style.css index d7a85b2..48cfc81 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -129,6 +129,23 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Center app in a mobile-width shell on desktop */ +.app-shell { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + min-height: 100dvh; + border-left: 1px solid var(--color-border); + border-right: 1px solid var(--color-border); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); +} + +@media (min-width: 481px) { + body { + background-color: #070d19; + } +} + img { max-width: 100%; height: auto; @@ -1902,3 +1919,13 @@ small, .text-sm { .animate-stagger > *:nth-child(4) { animation-delay: 180ms; } .animate-stagger > *:nth-child(5) { animation-delay: 240ms; } .animate-stagger > *:nth-child(6) { animation-delay: 300ms; } + +/* Month card — left border accent distinguishes from event cards */ +.month-card { + transition: border-color var(--transition-base), background var(--transition-base); +} +.month-card:hover { + border-color: var(--color-primary); + background: rgba(16, 185, 129, 0.05); +} + diff --git a/static/favicon.svg b/static/favicon.svg index 4938093..80cbeed 100644 --- a/static/favicon.svg +++ b/static/favicon.svg @@ -1,4 +1,4 @@ - Rx + Nx diff --git a/static/icons/icon-180.png b/static/icons/icon-180.png index ec9fadcae6fe99901c9e8b5fbd32fc01a8a1f6ca..6ac1323c3cbfaa09f00ccd63bcaf7762131d0161 100644 GIT binary patch literal 8316 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 literal 5290 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 literal 5713 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 diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png index aa924b185654b1546ecb9b6c340e1bbd2057a4c1..3d69c259f1a3e627cabd8b9d3891523fcdecf543 100644 GIT binary patch literal 49153 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 literal 17548 zcmeHu_ghn4({8Lhf`Up@q=*VA2#5$MRUSb^I!GrVU0P^?(6NEiRhoblgLDYeA)%-U zNHSEN77RA~6b2Lj2!k;@eZp!; zf`6PeQ&Ny4Q+`hAZ4QEWsE~>(@>H`Y*v~Ksh6l?vz+iGm73E}~c=Rt2P)6Nu<7D#Y zJ=TqfgQux@sY!ViDjkC{R#vuCLs?nxU$y*dG0Mt{vx$q3^Bx)EbA9plE%n>C!8iNH zm-fef{A4f#73H74&7H|_*w3GH*WbE3nA;Y)>Qyt$LiOf3Oh)DewASOr=wsj&#=v$G zdbxb=zc2qa!GELRzX##}nOu-agkAO*x+)>Q?PpHPcd+;%fIU(7s=9P6Q9{hYudQ-^ z`=an#Y$1P#HBr#g(4yrxInv?1^wnw2(zH?k3`~nnq~-cVY72iOpLz8!ClzDHRrx@r zm01N7i*iXr@JG7RN;dR|v>{{Fz5T>%y~kB_^4GY~CLA!>i3j5&XU~pI^N1h2RS$da z^|Ph@A61N#tDDQ#c)yq^Prg_2!qn`<5>Lr1U9LAW8`bNUD}Q*5ZTMXKE%bu~PQXU$ zwvNMK#wmk$Rt-e)lj(82g&O@g(y&i7qUwXugTi~Zq{Su+d2gv3S$(x|9`<^8RqufX zaQdKgg`2D{G=a&zT5l`n{ACa-EAwWZ~%MXcCc*_NM(;@}3~K*@@B3BBdp>@pb2sm)y4 zSn=V{?C6%sn?ZL%$FnqU%_8+wW;hk>I_3flrhfX*z|6xqp1lzxSBKa)3bUeam-@e8 z!Uz>B~M*p*5_u8H&%u+4tM;&P8%+Ume^z&B1H9(%&-)Xr@$2j zU(P&vc}Mt-pi7bM7*cuQrp+G=%c^}$G%XY11Wa85c#6TB9hVy(J?r1~d8SA0(#AoV zk>R2WIs^cNIo`!8(?{*_sqr*k9tJCc))3S^s+aXHdsmD<)1UOpzjePP3^@Z`e@qAlOL=YQ zU(xj;yH(U*f0*)aui|Op>_Lblu(#iVfoD$VYAh%22)q(DGGf7;Cme-cw*`iJ*M+LGZClv;f>(WW zN?=;93<|k688C5MjKPM>s>^=0=qv7)|}t6Ixwyw z6B>$Y7f+1g!pe&?Mzhvw+{^T_^r+99F>ZZIZ&DQ2-^v}w)LBxEwV|y@AK>S?Tc40_ zAnm4nOQj^#H5dvMqX4>_w14Ea!^;MVTN+$d-Ip71(c?G8#C@NTF0Jm0UX7BgQ;6f= zsSE0wes<4)aki#HGSh!DkN$?*^sod!v=hBMw6sT#`0X*!XVVK8rbaG`O&#<<@Y54sidkMbbq4cCL8tP!N5=kE@HERJ^5ck0wY*8gZ4%Gv)PIrY z_=-;MZW)Z%s*Uvn-Hle3l*MFoZs;ycAay+N8e=QW?f4X49I}3T&POgt>cU1e`uZYo zaJMfMJ#2RO@;A0{hGB&{wum{&+_hX5i9FoLwOcAoaJXJ}(%1M=2Jz8HX~dZHVLsF= z)iwtBaoBYaqvNMx+mH6I@<`cz$GnN)C7fn)-@}rJMQl}MM%Qa=^;P~wv6cFMm3fd* ztb;T+jdLk~jO2^F-Q3#S|MM*f3 z%2XR%ytDuPZ?Xa({|+6gBi>(S;0eX0h9UlbCjNVJkBthdGpa|0Oc$$7rtHJK(1xHU zyrLvgCyvj4HE{M)8me{Qq z-jVb>&D}uL%WnN4v;@asumt%Do4FQXW0_ghdP~d>bSsyeU={I6`j!pd*<9G8u%&b; z>wUp+@rd~US=tRp@qKDKM_92vP4%Mu>RA{A3f%SVQ^kkni*Yul1g!xn!ZqxqXe%SL~(3HTA6rLpRPJi@ex+IBo^u+V$r#GJ5trCz%^MP|20T%P)6ZWDm z_ujhiIx{Xqy%eHGEW^KLWOpjGUERb2k_A=JCtIz0&Ep|27nZh=dJ%;ezR!@=t>=VC<+)tWCBNin**MC&YPbio$`zcW6#Kaj1!P^w@+pwHO~9(u-xuYK&&ji%gD;zrN@FJ3sDdyc zfZy@db+*clVZTUg}(e!P*AequU##l&@a zu#=ve2fq~CjV?o$vW32?nZ>uBSClKKE2*{;H!l)?&GY#;kY_h9t#5P@wINq2n?zHV|?+?43mgVK+f^==hu(2$fF%7_WJNq4Sx}ZcUbuD@(aMN)NnqLj}N!I1mn= zNhhYBG=HUsFH3?D?w2A=#Cq;_oKwQ{ZzpU#=zcEhYD-&|^@Lj;NfTOv8y`%weJ(z{ zBeQ+oRjH}z)Z{DXg>8K#O)v-Kk*GQ(J>$X89r7Q(Bf%!K=>U_MIi2~Ieeb=5fa+ae zX}_m(5_tZ4WieY(8)4nOdEY#Ra#3~qFL5`p{{*A2i}JfQw{i=f$#P2>@CEn3)Vmhg zNHQ6lwQ5s#1A7~L0|iIgl=r-(M5Hjct0~48aUYXRjA0f=hF@fQg0h~B^1UZfWG}~j zF=w-jw7)_Q71YXdQ_?nB=Zwgw(>58J5sqbIVq3T(Uv|OJk)6@aQ|?dfipTm2q7lYN72g%WrEV$i^--8%+Oh28vuEDDpDSD`^-H4V%>=139^H) zS5)sL@#;d7*U-#OY~9B`YL!oop^SEwnDz1o=M=OxLB6;4rss@j{wGrMTZB-0exAp4 z8(TH!Z%^-%E}7s4yhP?jD6BK~9<3^tCI@3YlAIj+1Glo#e8$6i$tktCL3)wCs*YIj z<({STD(gB_{hIscGvgL%^Bbf+v+P9T$FV6hXM(g5GNgdRc97sBzM33CKNKc@9eaZ7 z2<6&8sF-#a&``uufN*ID#=}==RrqDH_DAgaRLg#veAz^M*(-$rcm2SDkHLr=+e5Zf zYldm&4+Jaf#dxn`>;9BXSht$9v?U{Zr+>2I`LExjoFc}D@oI&axIqZMq?Mdo&BPFc z#Y)_kYX5xuO5lu8c)fOxVAe8dGVtq~?{@MKjo0ZOIa?;%HFU^h?q&^c9%U{6(@?1Y ztN6r+fsx=LW*&H;8D6)P_#&2ka21#1m*|&7yb{GqvA-BdFdLg-6YXKg8o8drE+{uq zyAz6b{+8LY3wDJq(gTw%zwRNV60%8|nvJ&xat+35ZAf0tva~4qvOQhHih(>j!#Ck7 zLu@#CB+X+=4pL1FcK4g~evA2)Jeu*r)IoDES%;aRc`K>3&He+{2d(Y2loX6MToKDb zmXvRF@(mXSyHV2^BAcSn=Pbm9j z**SPu@719BR+v}dF6#~cGj`Kn(`}3d&379{9&4yy>oPpjgBR|qBSVBjW(-US#T;~A zZu9J)6}vL`wtvrBd;A<)$up+G(&-EoB>(&}AAONFHRE{Q{Ghr~QqhaCx6pwas~l+g zzT;eBGgV@b2F?^D-gik>aFf$zh-oi-)6#FUGpcqmBqYOPIC0BxxL0RqPI@oH|L7Tq5s>sF1rxwxbT zuVT?(uW?)EDWfj*&O>Khu57|l)j(vJHW@cy5aW8`8a8dqrt-UKzETE0xqR56^brWq z1@?#*#A|yyveQF#P}!nK zI9lCrxS7(mVo!LuvJ#^|?%G|N>Cnwxt{xgs;?~s9%p_tSLm?f>=kuQm{q6)5{NIu~I!MTgtWE9;_lRMN{A!F;(pd5pEbf=x{gt^U9uy6HXs6w|?3~u~0D-f& zDc3|QEicJCMx>OQc+D4T_PR38N5kqI*S=nIbJtP*ZIQK9fV`ds9R%ty_x^}{56s%4 z)qbu*5TeoKTMNiZaCu#Z#3|qt z>b~OJc+V&px}a`@Tn#`QhUOhyE9|awZpf}D;l{6GPq;w!jrX+Y{Ow}Xdw9n7)#;Hp z1ur@Xfh)@Q2OZBf=n4e0`f|#G5SkOHnF1@*fI5#Igx zxgH5?AvKZ)j%nRa_G%MA;aW}Y@ci%20ZScs#afB>@|L3XDwG}7K$hGsb1?h$>X?`= z&l?#GKUO|8KWWfie8oX;v&&sg3}ne3?&g_ClMi0qG(z0Tyo{BHr?+FWiyQNV+P&gj zzY=34A&l$lR@+MzJ!H(kKk&)3Z&jXI_95{3S^IZs&0!}QTR_LA+yNri@j=TgMcu$4 z%-P1JhYk~i-_|9vXYKfSeNO%ud>|5o+Q~Jurl}&RMwOaJG zk-(YZBVTsk9omFd{n>~B2Uphf$SfvjkRmq6-@l!l&bT1(hLM0~8r~sjs6IomJ|eA_ zV~9-V3xcr*nk9YD%lO!{r@4VWvj(y~%677+oDv?V9RoH{G|Y00bP)0JOweih zrUC$HP(F99Ov5y!mnRhrlnbMYBRUHPrTk6s?VH6eWF0FIW82mK&EqNi1h1^vaZv!t z7y>{hk#AYLoZ*JWHV@%GmtLhN+c?)t4HtDLpRn|{_!ETCNKJ8Vw`X7YC^Hk@-gpmO zrLST(`=gq{C(Rt4UMjR<@Jj^XS$a@)nV$&iS}F6ZwE~zN_nKj@=%FEI7pNt9 zzw^DKrys?Uz0h`zJfoWMgj_z`Os8;DMob?W($CN9El64m<s>-wF+yOqswB+b)MXd4zUJtsZ+r#Dym z@>j$xw8Dq%v5oF(rjcGYI#>f-N1ylV)#ekQHHbFj#q&QG0#JzoTM)*#V2=>yN$(G$y@57N zvTmM#^9nO8McTWwOVY>l*C`VJOb`kTrvd}kWo!jN4)etzaCOIPr&ZC0)E^pesGM*p z9Yx4srQ$ga7zw)8t8{eDaE{`+(bUIcuK=5{Z4QjZ8NZcT*lBp+N~dFTuwf1E!!s!4 zaDUHH>!2#`Ykn>P5PvePhOXpB^N!XZc?cDWeARLvX0~vt&qlyy2rKqvyVQ=1=--vyYZCHOs zkboC^t*^awg}0QeVe5o&pZRU(U;$o?3&DkF+EzjBRB&Gf=U?tBB-B^zS5dcwxe^&M z)Q-Ji@@{s!Lgm@aX8Jy$ty%$YAb*ECl!vWn3~EwsJND;s7enNzjjMJLk$7%-vv11Iy ziQ~_i_&%eYKXr6f+;018j|l0AWYH|IAGU#7pr9}tx=Z{R8|*S+A)w0F^pHx5=H(0H z1=(twgmtKrCRoR5Ck=Q>-zXcf3n9kn3RfGX6*JKsl4sxzNlNO>k`kLQrOt;PIba4+ zQ+c~K;oye9_A)|hv#{2XBYfc2FZ3A5gm>OI{VOksCDpmVkpzvHwR_m_(Awb;#Er&! zcdYZXSe!WBYoFojserarrNle9XCeGPZ$dX_s4en7gVL&4R+vL^>#Qw|Xnb~vsY*mddA=lKW8Iv5=6A~k zeZ3~Va1IHs3$Djp_T`wPE$|7Q5_T6aPoK9)mA6$4 z+q8C0zmHv!CTEjR+DEp8Ei$X#)`SmnGGe&Gs>n^D2Uev;%X1L&Yl;Ki48PRXT4b-( zo)d^$ek;(_xvJPlC@Hni*LHVnt?Bk0XcF=rl#1^Y5q~E0dF&nt@HYSCTMK?UX=Szi zZK~RXa=ozpa5Wh0QIi=qC?8?)x8Rb-N%$5-ZH3psjxmjInZw>hN5+Q8q7=HVt3;c- ztVajfH}}a~;!tl{=RCe}WaLFFW_`OzAY${pOE$Y5z(=&E)zE zJDQOEg&OVj8`4e2lhfqg!knm}rbV=g;dgzrb3DAU+8w$!Tq|oXyMJ$vRc+g`z#F~$WDI9%!;;$O$~FW$OScRLjC(cK zP}kenxHxsF9?MHAg(&I?Vo!MZ9*$8DB4OP@7yIyyo0m!wx9;2;9F^#mJ*B>7o$)Bn zs27~Yc!w^;?Z&Q?yt46e=#Z{oP9|hU{qGoWU9#Wui-RV0y!@>V*M0E@FaO_%d?P3< zXD;};ZN$KLOQjK>bJ>}J?;{6wMctN?waszu9{Ax1N$?*V&$-#>qq}cHckL`o%4v;3 zyBo}V^1gjp%j>S&<24m{&rKCP|NZYH^DKb*4y5rhER&3_%-)pY;HkXirj9(9#52U% z%-d??(|Rcpg#(Sfj#WjU!Q2J1e$v43jSjKlc)73$tBr^w#%wr+L&hukKy~6G=%L+v z1=S3#MHS&-@^eV6#dPS*4pl@4cv$;4+|yHU zo~K?!IhU2(oWAw!uoVdLtMj~4Y%@ony+vE)wmAa2@B81OswVW*BYxhxKJaib40 zN*r&JB~#IcrYxrmGd)se14;|hklF^7L%Lh#xy|U4c0&_<3@2+^2FR=hr?&+vb|Py4 zsF8mG3h$3WqiEKH@{I-34F_|vzb!HRc*&L({LjQV_hI4W2hC{5+5CM1$q3SP7iUcLmm{@>1gwtk%ZU^zC)q=u9sCPKFPDO!8s zEaAlaL(17nHW??&18TF-Mx#S4f0#~b_RLzj=G-(nnxk6R*|y4!Ej*e!%i!DQIWKyx z2m-*Vb+7hk`oEN*4WFayDGWk)yOmtrP@s?#7_vG4A-SVI;hvlUw-Bmc^eFq0HE+kQ zs7SZ@7O_ni6dZBeTmQLk1L+)jPT+ttYz;dAjlg^9Wwh(+{2lM&n|D3tpytsOu;@Zi zT-mNC^;el3B-bReSkT=qa(45r@goq7Zstq84k7)EZ#;y{d*aV(!+GOUT@zjQZ(_hPc?+13=VqzR zyJ_uRnJCly&7pXabWEOjMTt(Cf`r^0AC^Y`yHiBGrvgHOLzod9ku5<)M*K}Xd~ZSM z?x|XGXM6sS9dT`+Y8Tb)QbQy>NyyhxnXqkF`5;C+K9b0E{~B-xgYe^}i~ zz5FN_j63LB&q!pa=XI4emY#=4Wvoaw>ot5G-%My_sjlUejkP_;gMFmIFnQSU6iGhl zr1J-5RGkJ|ymw4$F%L2;MANR@YxDczTv6^)=BbVr2139OmwcCO*G~;M+kdY;$oAIC zNunnQ4rI^{ob%XTlsT=Y&jdf73DF&oqfBeclWZ&JBf8h8eSYuz6B15v)%MeF3L3Mx zUg1I^+kM(c6szf$P71_9il_jmrh6IvkZtm zo5h$UpP2Np=noRm3~64E97HFuAd-2(%CCZzN9pnVvQlBo_7{u@4!-$u2&mRGz+b;) zb`#xqLJx%I|Leu32Wqiot=l=k1;h*)w>s@U!OK5qp$uu~%4_>tQyU|ws>IzR&Jte! zC|v&isx1HE$(G;}96<#c^2o#;D>5}ghwA^bC6fmVpt@j;&{9+OH8v&qS&qddH*tqJ zBGdI?^s_4>G$LL!{tj2+*npbS0s#V`9v{Ub?sZIjv9YaiY*`#QEhyGH*er_O17d_y zJjOVNW}eEs${d$Bzuw>K5ZS!`q@7KPT5Y&)lB(-qVQ_Y9FUmHFPJTYmkgeEt*EZ=+zk^Kmm}l+&y7!yt=CzeOlCPDJi*v%Evm7dw z*{I=8fzcnkGIM7(qnQZ-FN+CR>*pUnLc#kGBD?E8n(nAPeslmQ`X02 zXrL-cx2MDbTQM!KX*$F@PAy+kuo8UU7|G>kx`?SL9AWCM8=4!ZyK^2Nmfya#^;OoA zPr&x;LnIA#vo-2v+5G-Yqe7garVikqZ~CkHlnXhH&=!5P{9d_EWejoH#*fW3dzz4M zxGrw`v@7RW`)3b!e3v<(2M5}v=lS4)moVNf-&|+L)_EaAVj><<_VF_I#EEt%W#@ml zN9w%&m_SF*KHvVEJ1@~|&8pLv4-}$0sOL4-KLmGiiHNX!Yp+HnegD#1BrlQhbLo!y z;g`IAme`C>0wPpzgS_`=Q71y?AlTT+;Vm)ds9FB;>wbWZt)Cx!hm_ex)L+5N-e0Z- z*-p%5U$$fZBizBLeLIg-VYyK&+q)Pn;3aaEsX|}W`Hmq3`#G^E0_v8cV6|$k@@17- zlLJ?NwDBZ{hU2{QUlExorM)=jzE?f({<^Lr3msytGc(+}qaU&J4P-{F zHW+?8R(c1&4(vz717H^{1|^!W`YMP8af{uBF$pR) zA1*NM^zHhBE;TM7_uWcbEMM)M$65D^Vi)xWNm0oJ5mchz(AzUF(#pJnuE?aR^ep^1 zgCmp-8CLg6*LkAC-k^u8S#Dq_>sTDW4$pNow$`m+ACwcB7%=)tpJ(1w16+3r!gVpR zB8!V(`x|ORS<67$i30O?Fdj~>J4r_{JQyww;-sSmbHsun)5+VFlJdGKP8Y*-!s5>} z6uCaYB;3{~EFKr|iE~;z80ES+T5TP?yebAO(O8$+fT<5!hWAKJZqb!i>+I*(oOY~~ zW0nHli)F2yR|w|5#$B@SUhyv-8TKGOKSUuhW-o2Nxi8LA*hT;MbGDk*a`BX>%MtW| z&pA~3bop!Fhuh&?C&?wQ-P%IQ*RUt#O^4nO;2V@5G^EdmYZE$Oikt$4))09@{Wg@s z-sWy`p_{f9|1-T+Iw*ee_??y2s%f|`VITw{t8vRn{ZiGvD%TG=fJ{1^dG9Z)L$xw(kfHrXfM4{sNq=UKAOm^{E zz+}frhaV#h_+eYuo9o*ER`>wT@=wk)sY{X8oq*BL@1YW{ zZQhvrEw7k%NUN|vktpqTXa>_-EXd)4_0E@5oQKQC>$mi;JVzHBiYj% z!SE%5ZOg{))SYd!Xt%s65lO_N{xTbwp9hZLzb^}cHQ0^aLX=hi@fI&L=6)5V(t+)N zrrXq-Rjd-Y6}SvJt;sr11Z`*x)KkyJAXsl&DiV5+smlZ@e;}FB|2oFc1bKow5DYKD z07aXl*RNx)6cJ(dwnrCc4-Pgoy#qw?gb*X;?}5Ect$c0SR)1QeMj@D-H|cY&MbDuC`S*^=%H^}BNS11vEWy+P8Y6LzKlVn_CYITkkh~*ng8mQMY$Iw z$m?Ek1=#w#Z84F$GMD36B`Duc6dfASo(TQ#r-|R9TJ+TLjP;+?;^4*XSFuM0MJGz< zVjl8Ikeu9CpIrgS0N9}&@+#0Nz#lFZQKR`nqXGYc4?dD&8@;GsCm!JI`*mlfOa{>1 zOsrH#lOVxG_SpduVa}3VU^Mq3?y}ZMIo-?t{r6SgSxsdxmxyib{c?JsjF+GN;e2_- z9||;H$(fkAcbX580YZl?!CTDz18%A8`L!7T@iQCVaB;wJ=aVI985~RS&M5#^@>3eG zSyosN94XcEL0_Ic3t{i=X%%+>cCAWoT|p z&9^)uINZ;Z0Zr@p)1ctLb1Pq?UB&!cwM8s)%r}D3KXKNHs)jv18++R#x|Df=o?Cm# zGYI?+oC2mf37o7R5DFR=dw*cZ++mTKHXfMm?ZbhjluQ{d|9%o!N^(BkJ-3ktV(Uy| zq!Gj#dc-DBM54OLSfT&94yEzwJ_E}+;Epje$fnJ+6_>(vd9Cq~Q1Sfd2_LMTVh|mph%fQ-0%s}4N-R})hkw&Q z3OUE(%AP#UI&k>tNT;`X z0a}iBGc@cpuH439ZQX9Poy^yMaFKi$-P-?flMNs_xI^gp{dbhnye!H5M_ zq*Bs^@I65Chi?KE+rWBjO+SR-PuvV!0EZ_VJ8kUB`p*i*V;_(6zF_HwAGa^n!1Y~w zfRt~{ps>OIEYnp5(+WLsWv`FfMB1Gq+t=wF2L}gX#egvaG(??*g?`#S6)Bbuh}JLZ~3U-%9UojheknT3<~( z-{`jhK30mEoy_I}U(2}zdHy-Y^IDVVE*FKd9^o}AvFj_nj`-HiELsrOmEBX(p-nUZj`bWU$HZ{ zu+EumwgJ8tWU5u9j{JjW!PSklt1+YfUF*x*fcX-VlxuOa=EUdU1v~mz5gJ2k-@2F9 zbmSJ{XD>3I82rH|U7+ax@9MGido} zEk_LODe4Jy_)oa`9esKaz8esJME#q=&fPOeM~U-K=359k9LER^^8BJ<1inP-KsK|fHro4PQmq#P4W^k`_cRUI8 zcLH&}y_X!g?=_!rNLbz68J40*uF~?8Uf*Ne*I7edp=|F5w7uVnJ(){ao*v5qY7P}G zV5sB6s!PhA(^-c^3YdxRtvZk4G^lj<&TCBWT`SB><`-a+DTiVK3xUysi-wug!B&h;ZP8FBHH*^ z3g|TdRHjk1(DKeDheP8u)p+A#7VwSsXkNuy^d>)!VQ4(}vCM$}2fy~smf~+C*47-| z%mg!V3a^tWv5xID=%$59=)$#WzGu{iq`nX_8p>Mi*zW8o%2{L}eBiOWkMOVRBbv&( zp>#TW+t%9>`LNgh4|Ssj5dTpjkfDu0VpNguL3aPV1&N<{KN{)2EEbm6ooE;Oey8rX zDpKBg3}L6=X8Mkt%Q7&0Q4nxylns$t$>=lb5n#c1QeHn3fV z|E)RQ@ResH42b@w!nf=++JiO_G}%e6?;ZLxca33Bs-i!RV$q&?!$+_((_XAGKIXTK}F!6T)WcI?~s zm%bAs@Ne0w#*2RteBKKoL+r8()ez-sI*xmwx_vUy+iLaM8!#h8jW)kd-K5K(aV5|B zn}OMT1n`Rh#V@u8$5S9NmV2$7Zf5Mv!v*fhmToCKF`~#f=-i7!`iT+##vFF?zHfs! zquL}uRu}v(mI<;IRQg}_wM7RfWj_4|Oh}Q91w3K&KdNr}di$E;$#3K}%c~Kb3)Pv2 zj2_tS1=1&BugB^wZR>}^S2Y6)1ge0x6O1=bAb{ z+%B50D|7o+10Q0cK_t7lC?E`U7fKzqzU|7mm``1MrVsG^Cl>X(|M;SO4z~EbzL#)< zTQ)dfK9rnsuG6ji9aY~!75Vj~=HiH6AG3%_qGo-?UHoDRl`iR?<*a;_wSXb5R6#Qu_yg@?8QG z{4-Oa_ueJarXZFpf&xiUYZQvW8tU9K*Xj#TaLGn?164y*vl#tGG$hrTs%6ELmrR4r zQ}m$k&|_+_(~yKxu-gO~;E^c9FN~{ii->>H)nmv9qPkFNRH}{31c(VYQnH9K=pu;; z{M!UycPSsE8LM}o?E`txww98U_bNqa{5A-o^1vLrxC#fCz9clC9Iso)Ji7a18fVD{ zN{hgFF7S=Tc0Nsl&mhpzT+k92Rt5toiu57~m>HC;ehA~qUr9=abYTI zkrVsx7cM$GmHCaVRfI5AP6N2d1olQG-uwU#hb1IXdjEAY1*KgZl&d=SP3XrOf3q_<7503tpu@sDyB1WE&K@ir zZ|_1SfkBjezDvAoWdlh3c@i5SirnI!F|t->m^D>@O4QHBN%1wjNTHz6_iwGUvZan% z{1zyU!eleZ*c=M__^iGB2Uo-Y4i=dAsl2)FFJBtCL0l5pERMeCP?#%6mDRj|$wrI5 zx3!>hzjbsGG?(DaorNC9;XRL08A#~??j0hRRJuCQ;Th#k?Ws0}TA5jrW_f{c5~&X zVGGD{uzbpZBpWB$5Bjccoy8LbDu93FDH`YV7o&B=K7SZ}4l>I0#U=NE2kV1Cawpjt z1|h4QlihwDd@c3?+-(+tL^Sy+#1)PEK&47;|G`2;m{x=YMQliRUru~JZUV;l0O6^x zK}qOJV8#)-{3g9U`!I0n864)ND;wQkUKS`v91k^VVr3em^mKkA5y3mCpN+)woK10% zi~M5t>4yN%eW=(BKD=rabX9RNU9MqoBSWC!yL*kO$v?(T*7Ahs zS~;zUUmGR6@M`}em_Z09ye5fT@L|)+n-0eS3zzP=O=CalRNyPb@$yjZMpIOd6}BIu z7^=XPLsV|%OXg4R+1BVM+mva9rk*j=-qp()BjP@z{O-nG5*M{Z%`+WlQfdmen-13M=pmZj z0ug6BwUw}20G>foMZjAQ62?}wXTHqhWCRSIHGI3_&dW;~q4MoLKHCTU5wAAThL2fl zqeabq5v)U#Nzl-7*qam7-mh6CIw{y?FITVWYq7-Ei;2koV7U8tzKj7La7@MU;&(mN>JFbF2}0b6JJ`NEO2~Yb!!4g~{FWZXHMsZ^lxNZa z0OoUD{6Y-YElRO&tw>B>yJs*ojKhNX(Enrrsjt%f#pbM~9s`wStBmALl<)8F%W6Y4 zzKTetMlrE@cXH~~x%LjT^F4p9djYbpCQs@(CXl)4k~E6|8ouwmz*B{|Byf%iZjnp= zP3<7P#|@SKxn$JRgWZWL3-ok4jnlxe5S!AQKL8#~{QL{v??*{e4s{$$ESEg;nzg(pZa zIBhNr3rhbsVdDiL3@}Akr^?sueoAjKZ5wk5rrLGhu9BK#_3z2;`?_#W23DQaO?*9+&$DUH$0V*)fD>9f`HF==Qv890wnLi&{sFNU7dWGg3)%MA8*>`)M8<>g^- zo6HF|cc|jBHzKseWdR-!pi8qYRx5=W1;WeS!<0bbu2O7umADXL2v%sQ@&T}^V{(+y z8E$sGU(G8Z4(MUyGD(g35}oV;Lq7i2{i(l4Hs#B{{pnf|vnC0${T-7~s-WXbkaX+5 zK{W#t7=pR3YNedppS6%H&gih;TgqPs0>)FBpIHEj+N3& zzf=B*m@XdXq$Mv9fa<5Ahc*;5Ez0#j6PeZvcYgo;ft;n#H#@(a_%uO|*>LdfSn26@ zSm0>;hnkV_GFIdU-ygU3*~2dN@3pf3K8_{{o?U^xI|`nk1V85i68Z1Te@*b;DER;E bAZ+`8ZBadQcQO1Q$aRYHs&bfrpT7J*bg#~p diff --git a/static/sw.js b/static/sw.js index aa961e3..5c10f0e 100644 --- a/static/sw.js +++ b/static/sw.js @@ -5,7 +5,7 @@ * Strategy: Cache-first for shell assets, network-only for API * ============================================================ */ -const CACHE_NAME = 'nextexpense-v1'; +const CACHE_NAME = 'nextexpense-v2'; // Shell assets to pre-cache on install const SHELL_ASSETS = [ diff --git a/templates/dashboard.html b/templates/dashboard.html index d8ccc7f..2168325 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -8,7 +8,7 @@ - + @@ -25,103 +25,90 @@
-

My Events

+

My Months

-