feat: recurring transactions
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m36s

This commit is contained in:
juancwu 2026-05-04 04:42:22 +00:00
commit 448b6f6262
16 changed files with 1956 additions and 4 deletions

View file

@ -0,0 +1,5 @@
package forms
import "strconv"
func intToStr(n int) string { return strconv.Itoa(n) }

View file

@ -0,0 +1,336 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/misc/timezone"
import "git.juancwu.dev/juancwu/budgit/internal/model"
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/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea"
type RecurringEventFormProps struct {
SpaceID string
Action string
CancelHref string
SubmitLabel string
Accounts []*model.Account
Timezones []timezone.TimezoneOption
Title string
Kind string
SourceAccountID string
Amount string
Description string
Frequency string
IntervalCount string
DayOfWeek string
DayOfMonth string
MonthOfYear string
FireTime string
Timezone string
StartDate string
TitleErr string
KindErr string
SourceErr string
AmountErr string
FrequencyErr string
IntervalErr string
DayOfWeekErr string
DayOfMonthErr string
MonthOfYearErr string
FireTimeErr string
TimezoneErr string
StartDateErr string
GeneralErr string
}
func (p RecurringEventFormProps) HasError() bool {
return p.TitleErr != "" || p.KindErr != "" || p.SourceErr != "" ||
p.AmountErr != "" || p.FrequencyErr != "" || p.IntervalErr != "" ||
p.DayOfWeekErr != "" || p.DayOfMonthErr != "" || p.MonthOfYearErr != "" ||
p.FireTimeErr != "" || p.TimezoneErr != "" || p.StartDateErr != ""
}
var weekdayNames = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
var monthNames = []string{"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
templ RecurringEventForm(props RecurringEventFormProps) {
<form id="recurring-event-form" hx-post={ props.Action } hx-target="#recurring-event-form" hx-swap="outerHTML">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4 space-y-4"}) {
if props.GeneralErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.GeneralErr }
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "title"}) {
Title
}
@input.Input(input.Props{
ID: "title",
Name: "title",
Type: input.TypeText,
Placeholder: "e.g. Rent",
Class: "rounded-sm",
Value: props.Title,
HasError: props.TitleErr != "",
Required: true,
})
if props.TitleErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.TitleErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "kind"}) {
Kind
}
<select id="kind" name="kind" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value={ string(model.RecurringEventKindBill) } selected?={ props.Kind == string(model.RecurringEventKindBill) }>Bill (withdrawal)</option>
<option value={ string(model.RecurringEventKindFund) } selected?={ props.Kind == string(model.RecurringEventKindFund) }>Fund (deposit)</option>
</select>
if props.KindErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.KindErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "source_account"}) {
Account
}
<select id="source_account" name="source_account" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value="" selected?={ props.SourceAccountID == "" }>Select an account…</option>
for _, a := range props.Accounts {
<option value={ a.ID } selected?={ props.SourceAccountID == a.ID }>{ a.Name }</option>
}
</select>
if props.SourceErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.SourceErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "amount"}) {
Amount
}
@input.Input(input.Props{
ID: "amount",
Name: "amount",
Type: input.TypeNumber,
Placeholder: "0.00",
Class: "rounded-sm",
Value: props.Amount,
HasError: props.AmountErr != "",
Required: true,
Attributes: templ.Attributes{
"step": "0.01",
"min": "0",
"inputmode": "decimal",
},
})
if props.AmountErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.AmountErr }
}
}
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "frequency"}) {
Frequency
}
<select id="frequency" name="frequency" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
<option value={ string(model.RecurringFrequencyDaily) } selected?={ props.Frequency == string(model.RecurringFrequencyDaily) }>Daily</option>
<option value={ string(model.RecurringFrequencyWeekly) } selected?={ props.Frequency == string(model.RecurringFrequencyWeekly) }>Weekly</option>
<option value={ string(model.RecurringFrequencyMonthly) } selected?={ props.Frequency == string(model.RecurringFrequencyMonthly) }>Monthly</option>
<option value={ string(model.RecurringFrequencyYearly) } selected?={ props.Frequency == string(model.RecurringFrequencyYearly) }>Yearly</option>
</select>
if props.FrequencyErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.FrequencyErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "interval_count"}) {
Repeat every
}
@input.Input(input.Props{
ID: "interval_count",
Name: "interval_count",
Type: input.TypeNumber,
Class: "rounded-sm",
Value: props.IntervalCount,
HasError: props.IntervalErr != "",
Required: true,
Attributes: templ.Attributes{
"min": "1",
"step": "1",
},
})
@form.Description() {
e.g. "every 2" + Weekly = every other week.
}
if props.IntervalErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.IntervalErr }
}
}
}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "day_of_week"}) {
Day of week
}
<select id="day_of_week" name="day_of_week" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<option value="">—</option>
for i, name := range weekdayNames {
<option value={ intToStr(i) } selected?={ props.DayOfWeek == intToStr(i) }>{ name }</option>
}
</select>
@form.Description() {
Used for weekly events.
}
if props.DayOfWeekErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.DayOfWeekErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "day_of_month"}) {
Day of month
}
@input.Input(input.Props{
ID: "day_of_month",
Name: "day_of_month",
Type: input.TypeNumber,
Class: "rounded-sm",
Value: props.DayOfMonth,
HasError: props.DayOfMonthErr != "",
Attributes: templ.Attributes{
"min": "1",
"max": "31",
},
})
@form.Description() {
Used for monthly/yearly. Clamps to last day of short months.
}
if props.DayOfMonthErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.DayOfMonthErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "month_of_year"}) {
Month
}
<select id="month_of_year" name="month_of_year" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<option value="">—</option>
for i, name := range monthNames {
<option value={ intToStr(i + 1) } selected?={ props.MonthOfYear == intToStr(i + 1) }>{ name }</option>
}
</select>
@form.Description() {
Used for yearly events.
}
if props.MonthOfYearErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.MonthOfYearErr }
}
}
}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@form.Item() {
@form.Label(form.LabelProps{For: "fire_time"}) {
Time of day
}
@input.Input(input.Props{
ID: "fire_time",
Name: "fire_time",
Type: input.TypeTime,
Class: "rounded-sm",
Value: props.FireTime,
HasError: props.FireTimeErr != "",
Required: true,
})
if props.FireTimeErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.FireTimeErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "timezone"}) {
Timezone
}
<select id="timezone" name="timezone" class="flex h-9 w-full items-center rounded-sm border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" required>
for _, tz := range props.Timezones {
<option value={ tz.Value } selected?={ props.Timezone == tz.Value }>{ tz.Label }</option>
}
</select>
if props.TimezoneErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.TimezoneErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "start_date"}) {
Start date
}
@input.Input(input.Props{
ID: "start_date",
Name: "start_date",
Type: input.TypeDate,
Class: "rounded-sm",
Value: props.StartDate,
HasError: props.StartDateErr != "",
Required: true,
})
@form.Description() {
First firing on or after this local date.
}
if props.StartDateErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.StartDateErr }
}
}
}
</div>
@form.Item() {
@form.Label(form.LabelProps{For: "description"}) {
Description
}
@textarea.Textarea(textarea.Props{
ID: "description",
Name: "description",
Placeholder: "Optional",
Rows: 3,
Value: props.Description,
})
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Href: props.CancelHref,
}) {
Cancel
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
{ props.SubmitLabel }
}
}
}
</form>
}

View file

@ -0,0 +1,57 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
func accountLabel(ev *model.RecurringEvent, accountByID map[string]string) string {
src := accountByID[ev.SourceAccountID]
if src == "" {
src = ev.SourceAccountID
}
return src
}
var weekdayLabels = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
func recurrenceSummary(ev *model.RecurringEvent) string {
timePart := fmt.Sprintf(" at %02d:%02d", ev.FireHour, ev.FireMinute)
switch ev.Frequency {
case model.RecurringFrequencyDaily:
if ev.IntervalCount == 1 {
return "Daily" + timePart
}
return fmt.Sprintf("Every %d days%s", ev.IntervalCount, timePart)
case model.RecurringFrequencyWeekly:
dow := ""
if ev.DayOfWeek != nil && *ev.DayOfWeek >= 0 && *ev.DayOfWeek < len(weekdayLabels) {
dow = " on " + weekdayLabels[*ev.DayOfWeek]
}
if ev.IntervalCount == 1 {
return "Weekly" + dow + timePart
}
return fmt.Sprintf("Every %d weeks%s%s", ev.IntervalCount, dow, timePart)
case model.RecurringFrequencyMonthly:
dom := ""
if ev.DayOfMonth != nil {
dom = fmt.Sprintf(" on day %d", *ev.DayOfMonth)
}
if ev.IntervalCount == 1 {
return "Monthly" + dom + timePart
}
return fmt.Sprintf("Every %d months%s%s", ev.IntervalCount, dom, timePart)
case model.RecurringFrequencyYearly:
date := ""
if ev.MonthOfYear != nil && ev.DayOfMonth != nil {
date = fmt.Sprintf(" on %s %d", time.Month(*ev.MonthOfYear).String(), *ev.DayOfMonth)
}
if ev.IntervalCount == 1 {
return "Yearly" + date + timePart
}
return fmt.Sprintf("Every %d years%s%s", ev.IntervalCount, date, timePart)
}
return string(ev.Frequency)
}

View file

@ -0,0 +1,29 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ui/forms"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceCreateRecurringEventPageProps struct {
SpaceID string
SpaceName string
Form forms.RecurringEventFormProps
}
templ SpaceCreateRecurringEventPage(props SpaceCreateRecurringEventPageProps) {
@layouts.AppWithBreadcrumb(
"New Recurring",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "New Recurring"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">New Recurring Event</h1>
<p class="text-muted-foreground mt-2">
Schedule a bill, fund, or transfer to repeat automatically.
</p>
</div>
@forms.RecurringEventForm(props.Form)
</div>
}
}

View file

@ -0,0 +1,30 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ui/forms"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceEditRecurringEventPageProps struct {
SpaceID string
SpaceName string
EventID string
Form forms.RecurringEventFormProps
}
templ SpaceEditRecurringEventPage(props SpaceEditRecurringEventPageProps) {
@layouts.AppWithBreadcrumb(
"Edit Recurring",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Edit Recurring"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">Edit Recurring Event</h1>
<p class="text-muted-foreground mt-2">
Changes apply going forward. Past transactions are not modified.
</p>
</div>
@forms.RecurringEventForm(props.Form)
</div>
}
}

View file

@ -74,6 +74,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
<span>Members</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.recurring", "spaceID", spaceID),
Tooltip: "Recurring",
}) {
@icon.CalendarSync()
<span>Recurring</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID),

View file

@ -0,0 +1,132 @@
package pages
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/badge"
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/layouts"
type SpaceRecurringEventsPageProps struct {
SpaceID string
SpaceName string
Events []*model.RecurringEvent
AccountByID map[string]string
}
templ SpaceRecurringEventsPage(props SpaceRecurringEventsPageProps) {
@layouts.AppWithBreadcrumb(
"Recurring",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Recurring"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-5xl px-6 py-8 mx-auto space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">Recurring</h1>
<p class="text-muted-foreground mt-2">
Bills, funds, and transfers that fire automatically on a schedule.
</p>
</div>
@button.Button(button.Props{
Href: routeurl.URL("page.app.spaces.space.recurring.create", "spaceID", props.SpaceID),
Class: "flex gap-2 items-center",
}) {
@icon.Plus()
New Recurring
}
</div>
if len(props.Events) == 0 {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-8 text-center text-muted-foreground"}) {
No recurring events yet.
}
}
} else {
<div class="space-y-3">
for _, ev := range props.Events {
@recurringEventRow(props.SpaceID, ev, props.AccountByID)
}
</div>
}
</div>
}
}
templ recurringEventRow(spaceID string, ev *model.RecurringEvent, accountByID map[string]string) {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-3"}) {
<div class="space-y-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold truncate">{ ev.Title }</span>
@kindBadge(ev.Kind)
if ev.Paused {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
<div class="text-sm text-muted-foreground">
{ accountLabel(ev, accountByID) } · ${ ev.Amount.StringFixedBank(2) } · { recurrenceSummary(ev) }
</div>
<div class="text-xs text-muted-foreground">
Next: { ev.NextRunAt.Format("2006-01-02 15:04 MST") } ({ ev.Timezone })
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
if ev.Paused {
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.resume", "spaceID", spaceID, "eventID", ev.ID) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantOutline,
Size: button.SizeSm,
}) {
Resume
}
</form>
} else {
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.pause", "spaceID", spaceID, "eventID", ev.ID) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantOutline,
Size: button.SizeSm,
}) {
Pause
}
</form>
}
@button.Button(button.Props{
Variant: button.VariantOutline,
Size: button.SizeSm,
Href: routeurl.URL("page.app.spaces.space.recurring.event.edit", "spaceID", spaceID, "eventID", ev.ID),
}) {
Edit
}
<form hx-post={ routeurl.URL("action.app.spaces.space.recurring.event.delete", "spaceID", spaceID, "eventID", ev.ID) } hx-confirm="Delete this recurring event? This does not delete previously generated transactions.">
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Size: button.SizeSm,
}) {
@icon.Trash2()
}
</form>
</div>
}
}
}
templ kindBadge(kind model.RecurringEventKind) {
switch kind {
case model.RecurringEventKindBill:
@badge.Badge(badge.Props{Variant: badge.VariantDestructive}) {
Bill
}
case model.RecurringEventKindFund:
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Fund
}
}
}