Merge branch 'feat/rename-dashboard'
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m14s

This commit is contained in:
juancwu 2026-02-18 15:06:32 +00:00
commit 85ecd67bc1
9 changed files with 52 additions and 148 deletions

View file

@ -1,59 +0,0 @@
package handler
import (
"fmt"
"log/slog"
"net/http"
"strings"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
)
type dashboardHandler struct {
spaceService *service.SpaceService
expenseService *service.ExpenseService
}
func NewDashboardHandler(ss *service.SpaceService, es *service.ExpenseService) *dashboardHandler {
return &dashboardHandler{
spaceService: ss,
expenseService: es,
}
}
func (h *dashboardHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
spaces, err := h.spaceService.GetSpacesForUser(user.ID)
if err != nil {
slog.Error("failed to get spaces for user", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.Dashboard(spaces))
}
func (h *dashboardHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, `<p id="create-space-error" hx-swap-oob="true" class="text-sm text-destructive">Space name is required</p>`)
return
}
space, err := h.spaceService.CreateSpace(name, user.ID)
if err != nil {
slog.Error("failed to create space", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID)
w.WriteHeader(http.StatusOK)
}

View file

@ -1,70 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func TestDashboardHandler_DashboardPage(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
testutil.CreateTestSpace(t, dbi.DB, user.ID, "My Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/app/dashboard", user, profile, nil)
w := httptest.NewRecorder()
h.DashboardPage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestDashboardHandler_CreateSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/dashboard/spaces", user, profile, url.Values{"name": {"New Space"}})
w := httptest.NewRecorder()
h.CreateSpace(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.True(t, strings.HasPrefix(w.Header().Get("HX-Redirect"), "/app/spaces/"))
})
}
func TestDashboardHandler_CreateSpace_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/dashboard/spaces", user, profile, url.Values{"name": {""}})
w := httptest.NewRecorder()
h.CreateSpace(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
})
}

View file

@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
@ -49,6 +50,40 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
}
}
func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
spaces, err := h.spaceService.GetSpacesForUser(user.ID)
if err != nil {
slog.Error("failed to get spaces for user", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.Dashboard(spaces))
}
func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
w.Header().Set("HX-Reswap", "none")
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, `<p id="create-space-error" hx-swap-oob="true" class="text-sm text-destructive">Space name is required</p>`)
return
}
space, err := h.spaceService.CreateSpace(name, user.ID)
if err != nil {
slog.Error("failed to create space", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID)
w.WriteHeader(http.StatusOK)
}
// getExpenseForSpace fetches an expense and verifies it belongs to the given space.
func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense {
exp, err := h.expenseService.GetExpense(expenseID)

View file

@ -2,7 +2,7 @@ package middleware
import "net/http"
func Redirect(path string) http.Handler {
func Redirect(path string) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", path)

View file

@ -13,7 +13,6 @@ import (
func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService)
home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler(a.SpaceService, a.ExpenseService)
settings := handler.NewSettingsHandler(a.AuthService, a.UserService)
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.MoneyAccountService, a.PaymentMethodService, a.RecurringExpenseService, a.BudgetService, a.ReportService)
@ -55,8 +54,9 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("GET /auth/onboarding", middleware.RequireAuth(auth.OnboardingPage))
mux.Handle("POST /auth/onboarding", crudLimiter(http.HandlerFunc(middleware.RequireAuth(auth.CompleteOnboarding))))
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage))
mux.Handle("POST /app/spaces", crudLimiter(http.HandlerFunc(middleware.RequireAuth(dashboard.CreateSpace))))
mux.HandleFunc("GET /app/dashboard", middleware.Redirect("/app/spaces"))
mux.HandleFunc("GET /app/spaces", middleware.RequireAuth(space.DashboardPage))
mux.Handle("POST /app/spaces", crudLimiter(middleware.RequireAuth(space.CreateSpace)))
mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))

View file

@ -27,7 +27,7 @@ templ App(title string) {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Size: sidebar.MenuButtonSizeLg,
Href: "/app/dashboard",
Href: "/app/spaces",
}) {
@icon.LayoutDashboard()
<div class="flex flex-col">
@ -45,12 +45,12 @@ templ App(title string) {
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/dashboard",
IsActive: ctxkeys.URLPath(ctx) == "/app/dashboard",
Tooltip: "Dashboard",
Href: "/app/spaces",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces",
Tooltip: "Spaces",
}) {
@icon.House(icon.Props{Class: "size-4"})
<span>Dashboard</span>
<span>Spaces</span>
}
}
}

View file

@ -28,7 +28,7 @@ templ Space(title string, space *model.Space) {
@icon.LayoutDashboard()
<div class="flex flex-col">
<span class="text-sm font-bold">{ cfg.AppName }</span>
<span class="text-xs text-muted-foreground">Back to Dashboard</span>
<span class="text-xs text-muted-foreground">Back to Spaces</span>
</div>
}
}

View file

@ -13,13 +13,13 @@ import (
)
templ Dashboard(spaces []*model.Space) {
@layouts.App("Dashboard") {
@layouts.App("Spaces") {
<div class="container max-w-7xl px-6 py-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold">Dashboard</h1>
<h1 class="text-3xl font-bold">Spaces</h1>
<p class="text-muted-foreground mt-2">
Welcome back! Here's an overview of your spaces.
Welcome back!
</p>
</div>
</div>
@ -32,12 +32,10 @@ templ Dashboard(spaces []*model.Space) {
{ space.Name }
}
@card.Description() {
Manage expenses and shopping lists in this space.
Manage expenses in this space.
}
}
@card.Content() {
// You could add some summary stats here later
}
@card.Content()
}
</a>
}
@ -57,7 +55,7 @@ templ Dashboard(spaces []*model.Space) {
Create Space
}
@dialog.Description() {
Create a new space to organize expenses and shopping lists.
Create a new space to organize expenses and more.
}
}
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">

View file

@ -10,7 +10,7 @@ templ Forbidden() {
<h2 class="text-2xl font-semibold mb-2">Access Denied</h2>
<p class="text-muted-foreground mb-8">You do not have permission to access this page.</p>
<a href="/app/dashboard" class="text-primary hover:underline">
← Back to Dashboard
← Back to Spaces
</a>
</div>
</div>