feat: savings allocations
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m31s

This commit is contained in:
juancwu 2026-05-04 03:19:36 +00:00
commit 2dac136049
17 changed files with 1140 additions and 4 deletions

View file

@ -0,0 +1,240 @@
package blocks
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
// AllocationFormState carries the previously-submitted values + per-field
// errors so handler responses can re-render forms with messages.
type AllocationFormState struct {
Name string
Amount string
TargetAmount string
NameErr string
AmountErr string
TargetErr string
GeneralErr string
}
templ allocationCard(spaceID, accountID string, a *model.Allocation) {
{{
editID := "alloc-edit-" + a.ID
viewID := "alloc-view-" + a.ID
percent := ""
barWidth := ""
if a.TargetAmount != nil && a.TargetAmount.IsPositive() {
ratio := a.Amount.Div(*a.TargetAmount)
pct := ratio.Mul(decimalHundred()).Truncate(0)
if pct.GreaterThan(decimalHundred()) {
pct = decimalHundred()
}
if pct.IsNegative() {
pct = decimalZero()
}
percent = pct.String() + "%"
barWidth = "width: " + pct.String() + "%;"
}
}}
<div id={ "alloc-" + a.ID } class="border rounded-md p-4 space-y-3">
<div id={ viewID }>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="font-semibold">{ a.Name }</p>
<p class="text-2xl font-bold">${ utils.FormatDecimalWithThousands(a.Amount.StringFixedBank(2)) }</p>
if a.TargetAmount != nil {
<p class="text-xs text-muted-foreground">
of ${ utils.FormatDecimalWithThousands(a.TargetAmount.StringFixedBank(2)) } goal
</p>
}
</div>
<div class="flex gap-1">
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Attributes: templ.Attributes{
"_": "on click add .hidden to #" + viewID + " then remove .hidden from #" + editID,
},
}) {
@icon.Pencil()
}
@dialog.Dialog(dialog.Props{ID: "alloc-delete-" + a.ID}) {
@dialog.Trigger(dialog.TriggerProps{For: "alloc-delete-" + a.ID}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
}) {
@icon.Trash2()
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete { a.Name }?
}
@dialog.Description() {
This removes the savings goal. Funds in this account are unaffected; they return to Available.
}
}
@dialog.Footer(dialog.FooterProps{Class: "mt-2"}) {
@dialog.Close(dialog.CloseProps{For: "alloc-delete-" + a.ID}) {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.allocation.delete", "spaceID", spaceID, "accountID", accountID, "allocationID", a.ID) }
hx-target="#allocations-section"
hx-swap="outerHTML"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Class: "flex gap-2 items-center",
}) {
@icon.Trash2()
Delete
}
</form>
}
}
}
</div>
</div>
if a.TargetAmount != nil && a.TargetAmount.IsPositive() {
<div class="mt-3 space-y-1">
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary" style={ barWidth }></div>
</div>
<p class="text-xs text-muted-foreground text-right">{ percent }</p>
</div>
}
</div>
<div id={ editID } class="hidden">
@allocationEditForm(spaceID, accountID, a, AllocationFormState{
Name: a.Name,
Amount: a.Amount.StringFixedBank(2),
TargetAmount: targetDisplay(a.TargetAmount),
}, viewID, editID)
</div>
</div>
}
templ allocationCreateForm(spaceID, accountID string, state AllocationFormState) {
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.create", "spaceID", spaceID, "accountID", accountID) }
hx-target="#allocations-section"
hx-swap="outerHTML"
class="border rounded-md p-4 space-y-3"
>
<p class="font-semibold">New savings goal</p>
if state.GeneralErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ state.GeneralErr }
}
}
@allocationFormFields(state)
<div class="flex justify-end gap-2">
@button.Button(button.Props{
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"type": "button",
"_": "on click add .hidden to #allocation-create-form",
},
}) {
Cancel
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
Create
}
</div>
</form>
}
templ allocationEditForm(spaceID, accountID string, a *model.Allocation, state AllocationFormState, viewID, editID string) {
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.allocations.allocation.edit", "spaceID", spaceID, "accountID", accountID, "allocationID", a.ID) }
hx-target="#allocations-section"
hx-swap="outerHTML"
class="space-y-3"
>
if state.GeneralErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ state.GeneralErr }
}
}
@allocationFormFields(state)
<div class="flex justify-end gap-2">
@button.Button(button.Props{
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"type": "button",
"_": "on click add .hidden to #" + editID + " then remove .hidden from #" + viewID,
},
}) {
Cancel
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ allocationFormFields(state AllocationFormState) {
@form.Item() {
@form.Label(form.LabelProps{For: "name"}) {
Name
}
@input.Input(input.Props{
ID: "name", Name: "name", Type: input.TypeText, Class: "rounded-sm",
Value: state.Name, HasError: state.NameErr != "", Required: true,
Placeholder: "e.g. Emergency Fund",
Attributes: templ.Attributes{"autocomplete": "off"},
})
if state.NameErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ state.NameErr }
}
}
}
<div class="grid grid-cols-2 gap-3">
@form.Item() {
@form.Label(form.LabelProps{For: "amount"}) {
Amount
}
@input.Input(input.Props{
ID: "amount", Name: "amount", Type: input.TypeText, Class: "rounded-sm",
Value: state.Amount, HasError: state.AmountErr != "", Required: true,
Placeholder: "0.00",
Attributes: templ.Attributes{"inputmode": "decimal"},
})
if state.AmountErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ state.AmountErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "target_amount"}) {
Goal (optional)
}
@input.Input(input.Props{
ID: "target_amount", Name: "target_amount", Type: input.TypeText, Class: "rounded-sm",
Value: state.TargetAmount, HasError: state.TargetErr != "",
Placeholder: "0.00",
Attributes: templ.Attributes{"inputmode": "decimal"},
})
if state.TargetErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ state.TargetErr }
}
}
}
</div>
}

View file

@ -0,0 +1,13 @@
package blocks
import "github.com/shopspring/decimal"
func decimalHundred() decimal.Decimal { return decimal.NewFromInt(100) }
func decimalZero() decimal.Decimal { return decimal.Zero }
func targetDisplay(t *decimal.Decimal) string {
if t == nil {
return ""
}
return t.StringFixedBank(2)
}

View file

@ -0,0 +1,102 @@
package blocks
import "git.juancwu.dev/juancwu/budgit/internal/service"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type AllocationsSectionProps struct {
SpaceID string
AccountID string
Summary *service.AllocationSummary
// CreateForm preserves user input + errors when re-rendering after a
// failed create submission. Nil for fresh renders.
CreateForm *AllocationFormState
// ShowCreateForm forces the create form to be visible (used after a
// validation error so the user sees what went wrong).
ShowCreateForm bool
}
// AllocationsSection renders the savings-goals card on the account overview.
// The whole card is the HTMX swap target so create/edit/delete handlers can
// return a fresh copy and the Available banner stays in sync.
templ AllocationsSection(props AllocationsSectionProps) {
<div id="allocations-section" class="space-y-4">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-4">
<div>
@card.Title() {
Savings Goals
}
@card.Description() {
Earmark portions of this account for things you're saving for.
}
</div>
@button.Button(button.Props{
Variant: button.VariantDefault,
Class: "flex items-center gap-2",
Attributes: templ.Attributes{
"_": "on click toggle .hidden on #allocation-create-form",
},
}) {
@icon.Plus()
New goal
}
</div>
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
if props.Summary != nil {
@allocationsAvailableBanner(props.Summary)
}
{{
createState := AllocationFormState{}
if props.CreateForm != nil {
createState = *props.CreateForm
}
createClasses := "hidden"
if props.ShowCreateForm {
createClasses = ""
}
}}
<div id="allocation-create-form" class={ createClasses }>
@allocationCreateForm(props.SpaceID, props.AccountID, createState)
</div>
if props.Summary != nil && len(props.Summary.Allocations) > 0 {
<div class="grid gap-3 md:grid-cols-2">
for _, a := range props.Summary.Allocations {
@allocationCard(props.SpaceID, props.AccountID, a)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No savings goals yet. Create one to start earmarking funds.</p>
}
}
}
</div>
}
templ allocationsAvailableBanner(summary *service.AllocationSummary) {
{{
availClasses := []string{"text-2xl font-bold"}
if summary.Overflow {
availClasses = append(availClasses, "text-red-600 dark:text-red-400")
}
}}
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 border rounded-md p-4 bg-muted/30">
<div>
<p class="text-xs text-muted-foreground uppercase tracking-wide">Available</p>
<p class={ utils.TwMerge(availClasses...) }>${ utils.FormatDecimalWithThousands(summary.Available.StringFixedBank(2)) }</p>
</div>
<p class="text-sm text-muted-foreground">
Allocated: ${ utils.FormatDecimalWithThousands(summary.Allocated.StringFixedBank(2)) }
</p>
if summary.Overflow {
<div class="text-sm text-red-600 dark:text-red-400 font-medium">
Over-allocated by ${ utils.FormatDecimalWithThousands(summary.Available.Abs().StringFixedBank(2)) }
</div>
}
</div>
}

View file

@ -3,6 +3,7 @@ package pages
import "github.com/shopspring/decimal"
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/service"
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
@ -18,6 +19,7 @@ type SpaceAccountPageProps struct {
AccountBalance decimal.Decimal
RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary
}
templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -42,7 +44,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}
@card.Content() {
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
<p class="text-sm text-muted-foreground">Available Balance</p>
<p class="text-sm text-muted-foreground">Account Balance</p>
}
}
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
@ -79,6 +81,11 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}
}
</div>
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Summary: props.AllocationSummary,
})
<div>
@card.Card() {
@card.Header() {

View file

@ -2,6 +2,7 @@ package pages
import "encoding/json"
import "fmt"
import "sort"
import "strings"
import "git.juancwu.dev/juancwu/budgit/internal/model"
@ -77,6 +78,12 @@ templ activityIcon(action model.SpaceAuditAction) {
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAccountDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
case model.SpaceAuditActionAllocationCreated:
@icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAllocationUpdated:
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAllocationDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
default:
@icon.History(icon.Props{Class: "size-4 text-muted-foreground"})
}
@ -166,6 +173,45 @@ func activityMessage(log *model.SpaceAuditLogWithActor) string {
name = "an account"
}
return fmt.Sprintf("%s deleted the account %s.", actor, bold(name))
case model.SpaceAuditActionAllocationCreated:
var meta struct {
Name string `json:"name"`
Amount string `json:"amount"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.Name
if name == "" {
name = "a savings goal"
}
if meta.Amount != "" {
return fmt.Sprintf("%s created savings goal %s with $%s.", actor, bold(name), bold(meta.Amount))
}
return fmt.Sprintf("%s created savings goal %s.", actor, bold(name))
case model.SpaceAuditActionAllocationUpdated:
var meta struct {
Changes map[string]map[string]any `json:"changes"`
}
_ = json.Unmarshal(log.Metadata, &meta)
fields := make([]string, 0, len(meta.Changes))
for k := range meta.Changes {
fields = append(fields, k)
}
sort.Strings(fields)
if len(fields) == 0 {
return fmt.Sprintf("%s updated a savings goal.", actor)
}
return fmt.Sprintf("%s updated savings goal (%s).", actor, bold(strings.Join(fields, ", ")))
case model.SpaceAuditActionAllocationDeleted:
var meta struct {
Name string `json:"name"`
Amount string `json:"amount"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.Name
if name == "" {
name = "a savings goal"
}
return fmt.Sprintf("%s deleted savings goal %s.", actor, bold(name))
default:
return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action)))
}