mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* feat(insights): proactive financial intelligence feed Adds a nightly job that analyzes each family's finances in pure Ruby and surfaces typed, stateful insights on the dashboard and a new /insights page. - Insight model (active/read/dismissed) with a per-family dedup_key unique index so nightly re-runs refresh rows instead of duplicating them - Seven generators (spending anomaly, cash-flow warning, net worth milestone, subscription audit, savings rate change, idle cash, budget health) built on IncomeStatement, BalanceSheet, RecurringTransaction, and BudgetCategory - LLM used as a writer, not a reasoner: Insight::BodyWriter narrates pre-computed facts via the configured provider, with an i18n template fallback so self-hosted installs without API keys work identically; bodies are only (re)written when an insight is new or its numbers changed - GenerateInsightsJob: cron fan-out per family, per-family advisory lock, metadata-diff upsert that preserves read/dismissed state for unchanged signals and reactivates on material change - Dashboard insights_feed section (top 3, collapsible/reorderable) and an /insights feed page with turbo-stream dismissal; viewing the feed marks insights read - Tests for the model, job upsert semantics, and controller flows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address Codex review — expiry, budget metadata, LLM usage - Expire visible insights whose condition cleared: generators declare the insight types they produce, the registry reports which generators ran to completion, and the job expires visible insights of those types whose dedup_key was not regenerated. A crashing generator can't wipe out its healthy insights, and an expired insight reactivates when its condition returns — unlike a user-dismissed one, which stays dismissed. - Include a bucketed budget-spent percent in budget_at_risk metadata so the body refreshes when overall usage moves >=10 points even if the same categories remain flagged. - Pass family to chat_response so LLM narration is recorded in llm_usages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): address CodeRabbit review - Skip the mark-as-read write for Turbo hover-prefetch requests (X-Sec-Purpose) so unread badges don't clear before a real visit - Show the New pill on the dashboard feed (active = unread there; the feed never marks insights read) - Filter idle accounts in SQL instead of a per-account exists? loop - Eager-load merchants in the subscription audit query - Widen the advisory-lock key to the signed-bigint range and log when acquisition fails so a skipped nightly run is observable - Mirror the dedup_key unique index as a model validation - Assert the refresh action enqueues for the signed-in family Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): clear stale lifecycle timestamps on reactivation When an insight resurfaces, the row now leaves no contradictory state behind: the material-change path clears both read_at and dismissed_at, and the expired-recovery path clears read_at. Tests assert the contract, and the dashboard feed test now also locks in the unread "New" badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): drop redundant standalone family_id index Every composite index on insights already leads with family_id, so the auto-created single-column index was pure write overhead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4 * fix(insights): stop nightly drift from resurrecting dismissed insights Three generators stored continuously drifting values (projected amounts, starting balance, current net worth) in metadata, and any metadata diff counts as a material change: the body is rewritten (an LLM call when a provider is configured) and read/dismissed state is cleared. Dismissing a cash-flow warning was undone at the next 6:00 run. Metadata now carries only the signal identity plus a coarse bucket, the same damping the budget generators already use; display values live in facts. Also orders the idle-cash pick by balance so the selected accounts don't flip between runs, and documents the dollar-scale threshold assumption on the relevant constants. * fix(ds): map DS::Button aria_label into the aria hash A bare aria_label: option reaches the tag helpers as a literal aria_label attribute (Rails only dasherizes the nested aria: hash), so the icon-only fallback overrode it and screen readers announced the insight dismiss button and the popover trigger as "X". * fix(insights): gate LLM narration behind AI consent The nightly job runs unprompted for every family, so narration now requires someone in the family to have AI enabled: consent to share financial facts with the provider, and a cost cap in managed mode. The template fallback keeps behavior identical otherwise. Narration failures are captured via DebugLogEntry so support can see them. Adds BodyWriter coverage, including a template-interpolation test for every generator template key. * feat(insights): rework the dashboard feed The feed rendered full insight cards inside the section shell — the only widget nesting card-on-card — and appended below the fold for every family with a saved section order. It now mirrors the outflows and balance-sheet list idiom (inset well with an uppercase mini-header, white row block, 28px sentiment-tinted icon circles via color-mix on the DS CSS variables, right-aligned key figures) and leads the dashboard for saved orders that predate it. Icon color comes from sentiment, not priority — a savings-rate improvement is high priority AND good news, and must not render red; red is reserved for a projected-negative balance. Rows have no hover wash (cursor plus a gentle icon scale, like the sibling widgets), links to /insights disable Turbo prefetch so the mark-as-read actually fires for mouse users, and the standard widget-size popover offers Half/Full. Full stays the default: the feed is far shorter than any other single-width widget, so a half default leaves a grid hole the masonry cannot backfill. * feat(insights): actionable cards, dismiss undo, live refresh Each row now persists its display facts (new jsonb column) alongside the change-detection metadata. Facts refresh every run without touching the body or user state, which is exactly why they are not part of the material-change comparison. The card gains what the stored data always supported: a type-and-period meta line replacing "x minutes ago", a right-aligned key figure (green only for good news), and a contextual link resolved from the subject ids in metadata — category, account, recurring transaction, budget month — omitted when the subject no longer exists. Dismiss is forgiving: a toast in the notification tray offers undo, and undismissing restores the row as read rather than re-badging it. Milestone insights can never regenerate once dismissed, so this closes a real loss path. Upsert failures are captured via DebugLogEntry. Manual refresh gets feedback: the button swaps to a disabled checking state, the page subscribes to a family-scoped stream, and the job broadcasts the refreshed list and the idle button when it finishes (also after a lock-skipped run, so the button cannot stay stuck). Savings copy is sign-aware: a negative rate reads "you spent more than you earned" with true minus signs. The empty state swaps Lucide sparkles for the brand assistant glyph (DS::EmptyState learns icon_custom:). * feat(insights): top-bar entry with unread count The dashboard feed hides at zero insights and nothing else linked to /insights, so the manual refresh (and the empty state) were unreachable for exactly the families who need them: fresh setups before the first 6:00 run. The sticky top bar travels to every screen and its right cluster had room, so insights get an icon entry there with a monochrome unread badge. Prefetch is disabled on the link for the same mark-as-read reason as the feed. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com>
159 lines
6.5 KiB
Ruby
159 lines
6.5 KiB
Ruby
module InsightsHelper
|
||
INSIGHT_ICONS = {
|
||
"spending_anomaly" => "activity",
|
||
"cash_flow_warning" => "alert-triangle",
|
||
"net_worth_milestone" => "trophy",
|
||
"subscription_audit" => "repeat",
|
||
"savings_rate_change" => "piggy-bank",
|
||
"idle_cash" => "wallet",
|
||
"budget_at_risk" => "alert-triangle",
|
||
"budget_on_track" => "circle-check"
|
||
}.freeze
|
||
|
||
def insight_icon_key(insight)
|
||
INSIGHT_ICONS.fetch(insight.insight_type, "lightbulb")
|
||
end
|
||
|
||
# "Savings rate · June" / "Cash flow · Next 30 days" — the card's meta line.
|
||
# Uses the insight's stored period; falls back to the subject (account or
|
||
# merchant name from facts) for insights without one.
|
||
def insight_meta_line(insight)
|
||
parts = [ t("insights.types.#{insight.insight_type}", default: insight.insight_type.humanize) ]
|
||
parts << insight_period_label(insight)
|
||
parts.compact.join(" · ")
|
||
end
|
||
|
||
# [value, caption] for the card's right-aligned key figure, from the display
|
||
# facts stored on the row. Nil (no figure) for rows written before facts
|
||
# were persisted — they backfill on the next nightly run.
|
||
def insight_key_figure(insight)
|
||
facts = insight.facts || {}
|
||
return nil if facts.blank?
|
||
|
||
case insight.insight_type
|
||
when "savings_rate_change"
|
||
return nil unless facts["change_pp"]
|
||
sign = insight_sentiment(insight) == :positive ? "+" : "−"
|
||
[ "#{sign}#{facts["change_pp"]} pp", t("insights.figures.vs_previous") ]
|
||
when "net_worth_milestone"
|
||
facts["net_worth"] && [ facts["net_worth"], t("insights.figures.today") ]
|
||
when "spending_anomaly"
|
||
facts["projected_spend"] && [ facts["projected_spend"], t("insights.figures.on_pace") ]
|
||
when "cash_flow_warning"
|
||
facts["projected_low"] && [ facts["projected_low"], facts["projected_low_date"] ]
|
||
when "subscription_audit"
|
||
facts["amount"] && [ facts["amount"], t("insights.figures.days_overdue", count: facts["days_overdue"].to_i) ]
|
||
when "idle_cash"
|
||
facts["balance"] && [ facts["balance"], t("insights.figures.idle_days", count: facts["idle_days"].to_i) ]
|
||
when "budget_at_risk", "budget_on_track"
|
||
facts["budget_spent_pct"] && [ "#{facts["budget_spent_pct"]}%", t("insights.figures.of_budget") ]
|
||
end
|
||
end
|
||
|
||
# The contextual action for a card, built from the subject ids each
|
||
# generator stores in metadata. Returns nil when the subject no longer
|
||
# resolves (deleted category/account) — the card renders without a link.
|
||
# Looks up through insight.family, not Current, so broadcast renders work.
|
||
def insight_action(insight)
|
||
metadata = insight.metadata || {}
|
||
|
||
case insight.insight_type
|
||
when "spending_anomaly"
|
||
category = insight.family.categories.find_by(id: metadata["category_id"])
|
||
category && {
|
||
text: t("insights.actions.spending_anomaly", category: category.name),
|
||
href: transactions_path(q: { categories: [ category.name ] })
|
||
}
|
||
when "idle_cash"
|
||
account = insight.family.accounts.visible.find_by(id: metadata["account_id"])
|
||
account && { text: t("insights.actions.idle_cash"), href: account_path(account) }
|
||
when "subscription_audit"
|
||
{ text: t("insights.actions.subscription_audit"), href: recurring_transactions_path }
|
||
when "cash_flow_warning"
|
||
{ text: t("insights.actions.cash_flow_warning"), href: recurring_transactions_path }
|
||
when "savings_rate_change"
|
||
return nil unless insight.period_start && insight.period_end
|
||
{ text: t("insights.actions.savings_rate_change"),
|
||
href: transactions_path(q: { start_date: insight.period_start.to_s, end_date: insight.period_end.to_s }) }
|
||
when "net_worth_milestone"
|
||
{ text: t("insights.actions.net_worth_milestone"), href: reports_path }
|
||
when "budget_at_risk", "budget_on_track"
|
||
return nil unless insight.period_start
|
||
{ text: t("insights.actions.budget"), href: budget_path(Budget.date_to_param(insight.period_start)) }
|
||
end
|
||
end
|
||
|
||
# Sentiment picks the color; priority only orders the feed. The two are
|
||
# orthogonal — a big savings-rate improvement is high priority AND good news,
|
||
# and must not render red. Red is reserved for a projected-negative balance,
|
||
# matching the app's wider rule that even negative amounts aren't red.
|
||
def insight_icon_color(insight)
|
||
case insight_sentiment(insight)
|
||
when :positive then "success"
|
||
when :negative then "destructive"
|
||
when :warning then "warning"
|
||
else "default"
|
||
end
|
||
end
|
||
|
||
# CSS color for the tinted icon circle (FilledIcon-style, color-mix'd to a
|
||
# 10% tint) — CSS variables so dark mode retunes automatically.
|
||
def insight_icon_css_color(insight)
|
||
case insight_sentiment(insight)
|
||
when :positive then "var(--color-success)"
|
||
when :negative then "var(--color-destructive)"
|
||
when :warning then "var(--color-warning)"
|
||
else "var(--color-gray-500)"
|
||
end
|
||
end
|
||
|
||
# Derived from type + the direction already stored in metadata, so rows
|
||
# written before a metadata-shape change degrade to :warning, never :negative.
|
||
def insight_sentiment(insight)
|
||
metadata = insight.metadata || {}
|
||
|
||
case insight.insight_type
|
||
when "net_worth_milestone", "budget_on_track"
|
||
:positive
|
||
when "savings_rate_change"
|
||
metadata["current_rate"].to_f >= metadata["previous_rate"].to_f ? :positive : :warning
|
||
when "spending_anomaly"
|
||
metadata["direction"] == "below" ? :positive : :warning
|
||
when "cash_flow_warning"
|
||
metadata["negative"] ? :negative : :warning
|
||
when "budget_at_risk"
|
||
:warning
|
||
else
|
||
:neutral
|
||
end
|
||
end
|
||
|
||
private
|
||
# "June" for month-aligned periods, "Next 30 days" / "Last 30 days" for
|
||
# rolling windows, an explicit range otherwise; subject name (account,
|
||
# merchant) for insights without a period.
|
||
def insight_period_label(insight)
|
||
start_date = insight.period_start
|
||
end_date = insight.period_end
|
||
|
||
if start_date.nil? || end_date.nil?
|
||
facts = insight.facts || {}
|
||
return facts["account"] || facts["name"]
|
||
end
|
||
|
||
if start_date == start_date.beginning_of_month && end_date == start_date.end_of_month
|
||
format = start_date.year == Date.current.year ? "%B" : "%B %Y"
|
||
return I18n.l(start_date, format: format)
|
||
end
|
||
|
||
days = (end_date - start_date).to_i
|
||
if start_date >= Date.current - 1
|
||
t("insights.meta.next_n_days", count: days)
|
||
elsif end_date >= Date.current - 1
|
||
t("insights.meta.last_n_days", count: days)
|
||
else
|
||
t("insights.meta.date_range", from: I18n.l(start_date, format: :short), to: I18n.l(end_date, format: :short))
|
||
end
|
||
end
|
||
end
|