mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +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>
237 lines
12 KiB
Plaintext
237 lines
12 KiB
Plaintext
<% intro_mode = Current.user&.ui_layout_intro? %>
|
|
<% home_path = intro_mode ? chats_path : root_path %>
|
|
<% mobile_nav_items = if intro_mode
|
|
[
|
|
{ name: t(".nav.home"), path: chats_path, icon: "home", icon_custom: false, active: page_active?(chats_path) },
|
|
{ name: "Intro", path: intro_path, icon: "sparkles", icon_custom: false, active: page_active?(intro_path) }
|
|
]
|
|
else
|
|
[
|
|
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
|
|
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
|
|
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
|
|
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
|
|
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
|
|
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
|
|
].compact
|
|
end %>
|
|
|
|
<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>
|
|
<% expanded_sidebar_class = "w-full" %>
|
|
<% collapsed_sidebar_class = "w-0 overflow-hidden" %>
|
|
|
|
<%= render "layouts/shared/htmldoc" do %>
|
|
<div
|
|
class="flex flex-col lg:flex-row h-full bg-surface"
|
|
data-controller="app-layout privacy-mode sidebar-resize"
|
|
data-app-layout-expanded-sidebar-class="<%= expanded_sidebar_class %>"
|
|
data-app-layout-collapsed-sidebar-class="<%= collapsed_sidebar_class %>"
|
|
data-app-layout-user-id-value="<%= Current.user.id %>">
|
|
<%= link_to t("layouts.application.skip_to_main"), "#main",
|
|
class: "sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-3 focus:py-2 focus:rounded-lg focus:bg-container focus:text-primary focus:shadow-border-xs" %>
|
|
|
|
<div
|
|
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
|
|
data-app-layout-target="mobileSidebar">
|
|
<% unless intro_mode %>
|
|
<div class="mb-2">
|
|
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= render(
|
|
"accounts/account_sidebar_tabs",
|
|
family: Current.family,
|
|
active_tab: @account_group_tab,
|
|
mobile: true
|
|
) %>
|
|
</div>
|
|
|
|
<%# MOBILE - Top nav %>
|
|
<nav class="lg:hidden flex justify-between items-center p-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
|
<% if intro_mode %>
|
|
<% else %>
|
|
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
|
|
<% end %>
|
|
|
|
<%= link_to home_path, class: "block" do %>
|
|
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
|
<% end %>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button type="button"
|
|
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
|
|
data-action="click->privacy-mode#toggle"
|
|
data-privacy-mode-target="toggle"
|
|
title="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-label="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-pressed="false">
|
|
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
|
|
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
|
|
</button>
|
|
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %>
|
|
</div>
|
|
</nav>
|
|
|
|
<%# DESKTOP - Left navbar %>
|
|
<div class="hidden lg:block border-r border-divider">
|
|
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4">
|
|
<div class="pl-2 mb-3">
|
|
<%= link_to home_path, class: "block" do %>
|
|
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<ul class="space-y-0.5">
|
|
<% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>
|
|
<li>
|
|
<%= render "layouts/shared/nav_item", **nav_item %>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
|
|
<div class="pl-2 mt-auto mx-auto flex flex-col gap-2">
|
|
<%= render DS::Link.new(
|
|
variant: "icon",
|
|
icon: "message-circle-question",
|
|
href: "https://discord.gg/36ZGBsxYEK",
|
|
target: "_blank"
|
|
) %>
|
|
|
|
<%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
|
|
<%# DESKTOP - Left sidebar %>
|
|
<%= tag.div class: class_names(
|
|
"hidden lg:block relative py-4 shrink-0 transition-all duration-300 border-divider",
|
|
Current.user.show_sidebar? ? [expanded_sidebar_class, "border-r"] : [collapsed_sidebar_class, "border-r-0"],
|
|
),
|
|
style: "max-width: var(--left-sidebar-width, 320px)",
|
|
inert: !Current.user.show_sidebar?,
|
|
data: { app_layout_target: "leftSidebar", sidebar_resize_target: "leftSidebar" } do %>
|
|
<%= render "layouts/shared/sidebar_resize_handle", side: "left" %>
|
|
<div class="px-4 h-full flex flex-col overflow-y-auto">
|
|
<% if content_for?(:sidebar) %>
|
|
<%= yield :sidebar %>
|
|
<% else %>
|
|
<div class="grow">
|
|
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
|
|
</div>
|
|
|
|
<% if Current.family.trialing? && !self_hosted? %>
|
|
<div class="px-4 py-3 space-y-4 bg-container shadow-border-xs rounded-xl">
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-primary"><%= t("layouts.trial.open_demo") %></p>
|
|
<p class="text-sm text-secondary"><%= t("layouts.trial.data_deleted_in_days", days: Current.family.days_left_in_trial) %></p>
|
|
</div>
|
|
|
|
<%= render DS::Link.new(
|
|
text: t("layouts.trial.contribute"),
|
|
href: upgrade_subscription_path,
|
|
) %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-0.5 h-1.5">
|
|
<div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
|
|
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%# SHARED - Main content %>
|
|
<%= tag.main id: "main", class: class_names("grow overflow-y-auto px-3 lg:px-10 w-full mx-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0"), data: { app_layout_target: "content", viewport_target: "content" } do %>
|
|
<% unless intro_mode %>
|
|
<div class="hidden lg:flex gap-2 items-center justify-between mb-6 sticky top-0 z-10 -mx-3 lg:-mx-10 px-3 lg:px-10 py-4 bg-surface border-b border-tertiary">
|
|
<div class="flex items-center gap-2">
|
|
<%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
|
|
|
|
<% if content_for?(:breadcrumbs) %>
|
|
<%= yield :breadcrumbs %>
|
|
<% else %>
|
|
<%= render "layouts/shared/breadcrumbs", breadcrumbs: breadcrumbs %>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<% if Current.family %>
|
|
<% unread_insights_count = Current.family.insights.active.count %>
|
|
<%# Prefetch disabled: /insights marks insights read on real visits
|
|
and deliberately skips prefetch requests, so a prefetched
|
|
response served on click would never clear the badge. %>
|
|
<%= link_to insights_path,
|
|
class: "relative inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring",
|
|
title: t("layouts.application.insights"),
|
|
aria: { label: t("layouts.application.insights") },
|
|
data: { turbo_prefetch: false } do %>
|
|
<%= icon("lightbulb", size: "sm") %>
|
|
<% if unread_insights_count.positive? %>
|
|
<span class="absolute top-1 right-1 min-w-4 h-4 px-1 rounded-full bg-inverse text-inverse text-[10px] font-medium flex items-center justify-center pointer-events-none"><%= unread_insights_count > 9 ? "9+" : unread_insights_count %></span>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<button type="button"
|
|
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
|
|
data-action="click->privacy-mode#toggle"
|
|
data-privacy-mode-target="toggle"
|
|
title="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-label="<%= t("layouts.application.privacy_mode") %>"
|
|
aria-pressed="false">
|
|
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
|
|
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
|
|
</button>
|
|
|
|
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<% if content_for?(:page_header) %>
|
|
<%= yield :page_header %>
|
|
<% end %>
|
|
|
|
<%= yield %>
|
|
<% end %>
|
|
|
|
<%# DESKTOP - Right sidebar %>
|
|
<%= tag.div class: class_names(
|
|
"hidden lg:block relative h-full shrink-0 transition-all duration-300 border-divider",
|
|
Current.user.show_ai_sidebar? ? [expanded_sidebar_class, "border-l"] : [collapsed_sidebar_class, "border-l-0"],
|
|
),
|
|
style: "max-width: var(--right-sidebar-width, 400px)",
|
|
inert: !Current.user.show_ai_sidebar?,
|
|
data: { app_layout_target: "rightSidebar", sidebar_resize_target: "rightSidebar" } do %>
|
|
<%= render "layouts/shared/sidebar_resize_handle", side: "right" %>
|
|
<%= tag.div id: "chat-container", class: "relative h-full px-4 overflow-y-auto", data: { controller: "chat hotkey", turbo_permanent: true } do %>
|
|
<div class="flex flex-col h-full justify-between shrink-0">
|
|
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
|
<div class="flex justify-center items-center h-full">
|
|
<%= icon("loader-circle", class: "animate-spin") %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<% unless Current.user.ai_enabled? %>
|
|
<div class="absolute backdrop-blur-lg inset-0 h-full w-full flex flex-col justify-center items-center px-4">
|
|
<%= render "chats/ai_consent" %>
|
|
</div>
|
|
<% end %>
|
|
<% end %>
|
|
<% end %>
|
|
|
|
<%# MOBILE - Bottom Nav %>
|
|
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 border-t border-tertiary flex justify-around pb-[env(safe-area-inset-bottom)]",
|
|
data: { viewport_target: "bottomNav" } do %>
|
|
<% mobile_nav_items.each do |nav_item| %>
|
|
<%= render "layouts/shared/nav_item", **nav_item %>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|