Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion internal/admin/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"

"breadbox/internal/service"
Expand Down Expand Up @@ -312,8 +313,38 @@ func RuleDetailPageHandler(svc *service.Service, sm *scs.SessionManager, tr *Tem
}
}

// Resolve display names for assign_series / assign_counterparty actions
// that bind by short_id only (no inline name). Without this the "Then"
// card shows the opaque surrogate ("wvmXi45D") instead of the entity's
// name ("Netflix"). Keyed by short_id; a failed lookup just leaves the
// short_id fallback in place. A rule carries at most a handful of
// actions, so these per-action lookups are cheap.
actionEntityNames := make(map[string]string)
for _, a := range rule.Actions {
switch a.Type {
case "assign_series":
if strings.TrimSpace(a.SeriesName) != "" {
continue
}
if sid := strings.TrimSpace(a.SeriesShortID); sid != "" {
if sr, err := svc.GetSeries(ctx, sid); err == nil {
actionEntityNames[sid] = sr.Name
}
}
case "assign_counterparty":
if strings.TrimSpace(a.CounterpartyName) != "" {
continue
}
if sid := strings.TrimSpace(a.CounterpartyShortID); sid != "" {
if cp, err := svc.GetCounterparty(ctx, sid); err == nil {
actionEntityNames[sid] = cp.Name
}
}
}
}

data := BaseTemplateData(r, sm, "rules", rule.Name)
renderRuleDetail(w, r, tr, data, rule, preview, stats, applications, applicationTxns, applicationMeta, previewTxns, hasMoreApps, syncHistory, actionCategoryName, categories)
renderRuleDetail(w, r, tr, data, rule, preview, stats, applications, applicationTxns, applicationMeta, previewTxns, hasMoreApps, syncHistory, actionCategoryName, actionEntityNames, categories)
}
}

Expand All @@ -337,6 +368,7 @@ func renderRuleDetail(
hasMoreApps bool,
syncHistory []map[string]any,
actionCategoryName string,
actionEntityNames map[string]string,
categories []service.CategoryResponse,
) {
data["Breadcrumbs"] = []components.Breadcrumb{
Expand All @@ -354,6 +386,7 @@ func renderRuleDetail(
HasMoreApplications: hasMoreApps,
SyncHistory: syncHistory,
ActionCategoryName: actionCategoryName,
ActionEntityNames: actionEntityNames,
Categories: categories,
ConditionSummary: service.ConditionSummary(rule.Conditions),
TriggerLabel: service.TriggerLabel(rule.Trigger),
Expand Down
12 changes: 6 additions & 6 deletions internal/templates/components/pages/rule_detail.templ
Original file line number Diff line number Diff line change
Expand Up @@ -235,20 +235,20 @@ templ ruleDetailActionRow(p RuleDetailProps, a service.RuleAction) {
@components.LucideIcon("repeat", "w-4 h-4 text-primary")
</div>
<div class="flex-1 min-w-0">
if ruleActionEntityName(a) != "" {
<p class="text-sm">Assign to series <span class="font-semibold">{ ruleActionEntityName(a) }</span></p>
if ruleActionEntityName(p, a) != "" {
<p class="text-sm">Assign to series <span class="font-semibold">{ ruleActionEntityName(p, a) }</span></p>
} else {
<p class="text-sm">Assign to series</p>
}
<p class="text-xs text-base-content/40">Matching transactions are linked to the series during sync.</p>
</div>
case "assign_counterparty":
<div class="w-8 h-8 rounded-lg bg-secondary/10 flex items-center justify-center shrink-0">
@components.LucideIcon("store", "w-4 h-4 text-secondary")
<div class="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center shrink-0">
@components.LucideIcon("store", "w-4 h-4 text-info")
</div>
<div class="flex-1 min-w-0">
if ruleActionEntityName(a) != "" {
<p class="text-sm">Assign to counterparty <span class="font-semibold">{ ruleActionEntityName(a) }</span></p>
if ruleActionEntityName(p, a) != "" {
<p class="text-sm">Assign to counterparty <span class="font-semibold">{ ruleActionEntityName(p, a) }</span></p>
} else {
<p class="text-sm">Assign to counterparty</p>
}
Expand Down
28 changes: 23 additions & 5 deletions internal/templates/components/pages/rule_detail_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ type RuleDetailProps struct {
ConditionRows []components.ConditionRowProps
// TriggerLabel is the human label for Rule.Trigger (service.TriggerLabel).
TriggerLabel string
// ActionEntityNames maps a short_id targeted by an assign_series /
// assign_counterparty action to the live entity's display name. The
// handler hydrates it so the "Then" card can show "Assign to
// counterparty Netflix" instead of the opaque surrogate "wvmXi45D" when
// the rule binds by short_id (no inline name stored). Keyed by short_id;
// empty when the action already carries an inline name or the lookup
// failed.
ActionEntityNames map[string]string
}

// RuleApplicationMeta carries the per-application action info shown above
Expand Down Expand Up @@ -258,20 +266,30 @@ func syncHistoryHitCount(h map[string]any) int {

// ruleActionEntityName returns the human display name for an assign_series /
// assign_counterparty action — the by-name value when the rule mints/binds by
// name, otherwise the short ID it targets, otherwise "". Used by the rule
// detail "Then" card so surrogate-first actions read legibly.
func ruleActionEntityName(a service.RuleAction) string {
// name, else the resolved name for the short_id it targets (hydrated by the
// handler into p.ActionEntityNames), else the short_id as a last resort,
// otherwise "". Used by the rule detail "Then" card so surrogate-first actions
// read legibly ("Netflix" rather than the opaque "wvmXi45D").
func ruleActionEntityName(p RuleDetailProps, a service.RuleAction) string {
switch a.Type {
case "assign_series":
if n := strings.TrimSpace(a.SeriesName); n != "" {
return n
}
return strings.TrimSpace(a.SeriesShortID)
sid := strings.TrimSpace(a.SeriesShortID)
if n := strings.TrimSpace(p.ActionEntityNames[sid]); n != "" {
return n
}
return sid
case "assign_counterparty":
if n := strings.TrimSpace(a.CounterpartyName); n != "" {
return n
}
return strings.TrimSpace(a.CounterpartyShortID)
sid := strings.TrimSpace(a.CounterpartyShortID)
if n := strings.TrimSpace(p.ActionEntityNames[sid]); n != "" {
return n
}
return sid
}
return ""
}
Expand Down
43 changes: 43 additions & 0 deletions internal/templates/components/pages/rule_detail_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,49 @@ func TestRuleHasRetroactiveActionExcludesCommentOnly(t *testing.T) {
}
}

func TestRuleActionEntityNamePrefersInlineName(t *testing.T) {
// An action that carries an inline name renders it verbatim, regardless of
// any resolved-name map.
p := RuleDetailProps{ActionEntityNames: map[string]string{"wvmXi45D": "Resolved"}}
cp := service.RuleAction{Type: "assign_counterparty", CounterpartyName: "Netflix", CounterpartyShortID: "wvmXi45D"}
if got := ruleActionEntityName(p, cp); got != "Netflix" {
t.Errorf("ruleActionEntityName(counterparty inline) = %q, want Netflix", got)
}
sr := service.RuleAction{Type: "assign_series", SeriesName: "Hulu", SeriesShortID: "abcd1234"}
if got := ruleActionEntityName(p, sr); got != "Hulu" {
t.Errorf("ruleActionEntityName(series inline) = %q, want Hulu", got)
}
}

func TestRuleActionEntityNameResolvesShortID(t *testing.T) {
// The bug from #1916: a rule that binds an existing counterparty by short_id
// stores no inline name, so the card used to render the opaque surrogate.
// With the handler-hydrated ActionEntityNames map, the short_id resolves to
// the entity's display name.
p := RuleDetailProps{ActionEntityNames: map[string]string{
"wvmXi45D": "Netflix",
"abcd1234": "Spotify",
}}
cp := service.RuleAction{Type: "assign_counterparty", CounterpartyShortID: "wvmXi45D"}
if got := ruleActionEntityName(p, cp); got != "Netflix" {
t.Errorf("ruleActionEntityName(counterparty short_id) = %q, want Netflix", got)
}
sr := service.RuleAction{Type: "assign_series", SeriesShortID: "abcd1234"}
if got := ruleActionEntityName(p, sr); got != "Spotify" {
t.Errorf("ruleActionEntityName(series short_id) = %q, want Spotify", got)
}
}

func TestRuleActionEntityNameFallsBackToShortID(t *testing.T) {
// When the lookup misses (entity deleted, resolution failed), the short_id
// is the last-resort display value rather than an empty label.
p := RuleDetailProps{ActionEntityNames: map[string]string{}}
cp := service.RuleAction{Type: "assign_counterparty", CounterpartyShortID: "wvmXi45D"}
if got := ruleActionEntityName(p, cp); got != "wvmXi45D" {
t.Errorf("ruleActionEntityName(unresolved counterparty) = %q, want wvmXi45D", got)
}
}

func TestRuleHasRetroactiveActionMixedRetroWins(t *testing.T) {
// A rule that mixes a sync-only action with a materializing one is still
// retroactive — the materializing action gets backfilled.
Expand Down
Loading