Reorganize Settings sections + add LLM model/prompt configs (#116)

* Reshuffle/organize settings UI
* Settings: AI prompt display/minor touch-ups
* API key settings tests
* Moved import/export together
* Collapsible LLM prompt DIVs
* Add export tests
This commit is contained in:
Juan José Mata
2025-08-22 11:43:24 -07:00
committed by GitHub
parent fb6e094f78
commit d054cd0bb2
38 changed files with 1036 additions and 420 deletions

View File

@@ -3,13 +3,29 @@
turbo_refresh_url: family_exports_path,
turbo_refresh_interval: 3000
} : {} do %>
<div class="mt-4 space-y-3 max-h-96 overflow-y-auto">
<div class="space-y-0 max-h-96 overflow-y-auto">
<% if exports.any? %>
<% exports.each do |export| %>
<div class="flex items-center justify-between bg-container p-4 rounded-lg border border-primary">
<div>
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
<p class="text-xs text-secondary"><%= export.filename %></p>
<div class="flex items-center justify-between mx-4 py-4">
<div class="flex items-center gap-2 mb-1">
<div>
<p class="text-sm font-medium text-primary">Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
<p class="text-xs text-secondary"><%= export.filename %></p>
</div>
<% if export.processing? || export.pending? %>
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-secondary border border-alpha-black-50">
<%= t(".in_progress") %>
</span>
<% elsif export.completed? %>
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
<%= t(".complete") %>
</span>
<% elsif export.failed? %>
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
<%= t(".failed") %>
</span>
<% end %>
</div>
<% if export.processing? || export.pending? %>
@@ -18,22 +34,44 @@
<span class="text-sm">Exporting...</span>
</div>
<% elsif export.completed? %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<span class="text-sm font-medium">Download</span>
<% end %>
<div class="flex items-center gap-2">
<%= button_to family_export_path(export),
method: :delete,
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
data: {
turbo_confirm: "Are you sure you want to delete this export? This action cannot be undone.",
turbo_frame: "_top"
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
<%= link_to download_family_export_path(export),
class: "flex items-center gap-2 text-primary hover:text-primary-hover",
data: { turbo_frame: "_top" } do %>
<%= icon "download", class: "w-5 h-5" %>
<% end %>
</div>
<% elsif export.failed? %>
<div class="flex items-center gap-2 text-destructive">
<%= icon "alert-circle", class: "w-4 h-4" %>
<span class="text-sm">Failed</span>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 text-destructive">
<%= icon "alert-circle", class: "w-4 h-4" %>
</div>
<%= button_to family_export_path(export),
method: :delete,
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
data: {
turbo_confirm: "Are you sure you want to delete this failed export?",
turbo_frame: "_top"
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-secondary text-center py-4">No exports yet</p>
<p class="text-sm text-primary text-center py-4 mb-1 font-medium">No exports yet.</p>
<% end %>
</div>
<% end %>

View File

@@ -1,13 +1,5 @@
<div class="flex justify-center items-center py-20">
<div class="text-center flex flex-col items-center max-w-[300px] gap-4">
<p class="text-primary mb-1 font-medium text-sm"><%= t(".message") %></p>
<%= render DS::Link.new(
text: t(".new"),
variant: "primary",
href: new_import_path,
icon: "plus",
frame: :modal
) %>
</div>
</div>

View File

@@ -36,30 +36,36 @@
<% end %>
</div>
<%= render DS::Menu.new do |menu| %>
<% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %>
<div class="flex items-center gap-2">
<% if import.complete? || import.revert_failed? %>
<% menu.with_item(
variant: "button",
text: t(".revert"),
href: revert_import_path(import),
icon: "rotate-ccw",
method: :put,
confirm: CustomConfirm.new(
title: "Revert import?",
body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.",
btn_text: "Revert"
)) %>
<%= button_to revert_import_path(import),
method: :put,
class: "flex items-center gap-2 text-orange-500 hover:text-orange-600",
data: {
turbo_confirm: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time."
} do %>
<%= icon "rotate-ccw", class: "w-5 h-5 text-destructive" %>
<% end %>
<% else %>
<% menu.with_item(
variant: "button",
text: t(".delete"),
href: import_path(import),
icon: "trash-2",
method: :delete,
confirm: CustomConfirm.for_resource_deletion("import")) %>
<%= button_to import_path(import),
method: :delete,
class: "flex items-center gap-2 text-destructive hover:text-destructive-hover",
data: {
turbo_confirm: CustomConfirm.for_resource_deletion("import")
} do %>
<%= icon "trash-2", class: "w-5 h-5 text-destructive" %>
<% end %>
<% end %>
<% end %>
<%= link_to import_path(import),
class: "flex items-center gap-2 text-primary hover:text-primary-hover" do %>
<%= icon "eye", class: "w-5 h-5" %>
<% end %>
</div>
</div>

View File

@@ -1,25 +1,39 @@
<div class="flex items-center justify-between">
<h1 class="text-xl font-medium text-primary"><%= t(".title") %></h1>
<%= render DS::Link.new(
text: "New import",
href: new_import_path,
icon: "plus",
variant: "primary",
frame: :modal
) %>
</div>
<div class="bg-container shadow-border-xs rounded-xl p-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="rounded-xl bg-container-inset p-1">
<h2 class="uppercase px-4 py-2 text-secondary text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
<div class="border border-alpha-black-100 rounded-lg bg-container shadow-xs">
<%= render partial: "imports/import", collection: @imports.ordered, spacer_template: "shared/ruler" %>
<%= settings_section title: t(".imports") do %>
<div class="space-y-4">
<% if @imports.empty? %>
<%= render partial: "imports/empty" %>
<% else %>
<div class="bg-container rounded-lg shadow-border-xs">
<%= render partial: "imports/import", collection: @imports.ordered %>
</div>
<% end %>
<%= link_to new_import_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= icon("plus") %>
<%= t(".new") %>
<% end %>
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: t(".exports") do %>
<div class="space-y-4">
<div class="bg-container rounded-lg shadow-border-xs">
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
<div class="mt-4 text-center text-secondary py-8">
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
</div>
<% end %>
</div>
<%= link_to new_family_export_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= icon("plus") %>
<%= t(".new_export") %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -3,30 +3,37 @@ nav_sections = [
{
header: t(".general_section_title"),
items: [
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" },
{ label: t(".imports_label"), path: imports_path, icon: "download" }
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? }
]
},
{
header: t(".transactions_section_title"),
items: [
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" }
]
},
{
header: t(".advanced_section_title"),
items: [
{ label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" },
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: "SimpleFin", path: simplefin_items_path, icon: "building-2" }
]
},
{
header: t(".other_section_title"),
items: [
{ label: t(".guides_label"), path: settings_guides_path, icon: "book-open" },
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
]

View File

@@ -0,0 +1,95 @@
<%= content_for :page_title, t(".page_title") %>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary">
<p><%= t(".openai_label") %></p>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<div class="space-y-4 p-4">
<!-- Main System Prompt Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "message-circle" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".main_system_prompt.title") %></p>
<p class="text-xs text-secondary"><%= t(".main_system_prompt.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::MODELS.join(", ") %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:instructions] %></pre>
</div>
</details>
</div>
</div>
<div class="border-t border-primary"></div>
<!-- Auto-Categorization Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "brain" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".transaction_categorizer.title") %></p>
<p class="text-xs text-secondary"><%= t(".transaction_categorizer.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoCategorizer::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_categorizer]&.instructions || Provider::Openai::AutoCategorizer.new(nil).instructions %></pre>
</div>
</details>
</div>
</div>
<div class="border-t border-primary"></div>
<!-- Merchant Detection Section -->
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-gray-25 flex justify-center items-center">
<%= icon "store" %>
</div>
<div>
<p class="text-sm font-medium text-primary"><%= t(".merchant_detector.title") %></p>
<p class="text-xs text-secondary"><%= t(".merchant_detector.subtitle") %></p>
</div>
</div>
<div class="pl-12 space-y-2">
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer">
<span class="text-xs font-medium text-primary uppercase"><%= t(".prompt_instructions") %></span>
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
</summary>
<pre class="whitespace-pre-wrap text-xs font-mono text-primary">[<%= Provider::Openai::AutoMerchantDetector::DEFAULT_MODEL %>]</pre>
<div class="mt-2 px-3 py-2 bg-surface-default border border-primary rounded-lg">
<pre class="whitespace-pre-wrap text-xs font-mono text-primary"><%= @assistant_config[:auto_merchant]&.instructions || Provider::Openai::AutoMerchantDetector.new(nil, model: "", transactions: [], user_merchants: []).instructions %></pre>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<%= content_for :page_title, "Create New API Key" %>
<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
<%= settings_section title: nil, subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
<%= form.text_field :name,
placeholder: "e.g., My Budget App, Portfolio Tracker",
@@ -51,7 +51,7 @@
) %>
<%= render DS::Button.new(
text: "Create API Key",
text: "Save API Key",
variant: "primary",
type: "submit"
) %>

View File

@@ -1,7 +1,9 @@
<%= content_for :page_title, "API Key" %>
<% if @newly_created && @plain_key %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">API Key Created Successfully</h1>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
@@ -51,9 +53,18 @@
) %>
</div>
</div>
<% end %>
</div>
<% elsif @current_api_key %>
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %>
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">Your API Key</h1>
<%= render DS::Link.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3">
@@ -122,13 +133,7 @@
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
<%= render DS::Link.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render DS::Button.new(
text: "Revoke Key",
href: settings_api_key_path,
@@ -140,9 +145,18 @@
) %>
</div>
</div>
<% end %>
</div>
<% else %>
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %>
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">API Key</h1>
<%= render DS::Link.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</header>
<div class="bg-container rounded-xl shadow-border-xs p-4">
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
@@ -179,14 +193,6 @@
</li>
</ul>
</div>
<div class="flex justify-start">
<%= render DS::Link.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= content_for :page_title, "Guides" %>
<div class="bg-container rounded-xl shadow-border-xs p-4 prose prose-sm max-w-none">
<%= @guide_content.html_safe %>
</div>

View File

@@ -122,29 +122,6 @@
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: "Data Import/Export" do %>
<div class="space-y-4">
<div class="flex gap-4 items-center">
<%= render DS::Link.new(
text: "Export data",
icon: "database",
href: new_family_export_path,
variant: "secondary",
full_width: true,
data: { turbo_frame: :modal }
) %>
</div>
<%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
<div class="mt-4 text-center text-secondary">
<div class="animate-spin inline-block h-4 w-4 border-2 border-secondary border-t-transparent rounded-full"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".danger_zone_title") do %>
<div class="space-y-4">
<% if Current.user.admin? %>

View File

@@ -30,7 +30,7 @@
<% end %>
<% end %>
<% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: settings_profile_path(return_to: request.fullpath)) %>
<% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %>
<% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %>
<% if self_hosted? %>