Compare commits
132 Commits
missing-se
...
zachgoll/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9858a67f | ||
|
|
d1b83541c1 | ||
|
|
dd75cadebc | ||
|
|
ed55ef624b | ||
|
|
f363fd4a4e | ||
|
|
b8a3ca7732 | ||
|
|
7b751ac7ca | ||
|
|
15d59959cf | ||
|
|
c66401dc0f | ||
|
|
9dcb9e8ed2 | ||
|
|
045fa1931c | ||
|
|
3f8351abfe | ||
|
|
dc44da6c00 | ||
|
|
2e4180fbf0 | ||
|
|
4b19ca50eb | ||
|
|
a3cd5f4f1d | ||
|
|
86bf47a32e | ||
|
|
5f8a3c9f50 | ||
|
|
eac5d5e663 | ||
|
|
26762477a3 | ||
|
|
372b64ffea | ||
|
|
9627a6bf6f | ||
|
|
cffafd23f0 | ||
|
|
f7fa8fa085 | ||
|
|
28bfcda50a | ||
|
|
e49bda4a2e | ||
|
|
071ad52c7f | ||
|
|
381e39bea8 | ||
|
|
eaa1b6abe0 | ||
|
|
e384369cfb | ||
|
|
8d0509fda0 | ||
|
|
d66c37939a | ||
|
|
cf59fe45e7 | ||
|
|
0544089710 | ||
|
|
5b2fa3d707 | ||
|
|
cf0e573533 | ||
|
|
4e96ca8376 | ||
|
|
c5da8ea550 | ||
|
|
e907b073ed | ||
|
|
4c4a4026c4 | ||
|
|
c95bb082a9 | ||
|
|
4d0df9b950 | ||
|
|
7c66f16750 | ||
|
|
fa0248056d | ||
|
|
624faa10d0 | ||
|
|
9138bd2b76 | ||
|
|
882857fcf0 | ||
|
|
d6793dec05 | ||
|
|
e771c8c1df | ||
|
|
58cc09f5ae | ||
|
|
98c842d3b8 | ||
|
|
fae781e1be | ||
|
|
8208722247 | ||
|
|
f7064fd4dd | ||
|
|
c610b0ba4b | ||
|
|
a4874815a6 | ||
|
|
763e222cdd | ||
|
|
e8390a68d8 | ||
|
|
0e76d753bd | ||
|
|
f5ff5332d5 | ||
|
|
0dea36ec7d | ||
|
|
95989a6c9b | ||
|
|
ac9703031f | ||
|
|
457e7062bf | ||
|
|
32ef6ca154 | ||
|
|
fd95f8d2bd | ||
|
|
da668f3dc0 | ||
|
|
cc11fec08a | ||
|
|
ce12e5b5c7 | ||
|
|
f96bb84c4c | ||
|
|
d75be2282b | ||
|
|
8539ac7dec | ||
|
|
c620d1fc1e | ||
|
|
79e1a2c0ff | ||
|
|
e9b29f6726 | ||
|
|
1b1e06070b | ||
|
|
f35b70e936 | ||
|
|
8e339dcbe0 | ||
|
|
366862fee1 | ||
|
|
73f826fdf2 | ||
|
|
999f1c5190 | ||
|
|
849c58dd3e | ||
|
|
c0e290a07e | ||
|
|
f1f2e103ce | ||
|
|
08a2d35308 | ||
|
|
a82c0303ce | ||
|
|
945a39d035 | ||
|
|
d31d5c5467 | ||
|
|
ab7f6a56f0 | ||
|
|
68d7cb5de6 | ||
|
|
fb6c6fa6bb | ||
|
|
077694bbde | ||
|
|
9e5f1574bc | ||
|
|
26197eda85 | ||
|
|
1dbf1ad74e | ||
|
|
ab632cf0be | ||
|
|
2b83fc787f | ||
|
|
536c82f2aa | ||
|
|
60925bd16c | ||
|
|
cf23673003 | ||
|
|
0dc25cda22 | ||
|
|
5eb5ec7aef | ||
|
|
331de2f997 | ||
|
|
972c850d27 | ||
|
|
78e34d68e9 | ||
|
|
21dc4b80f3 | ||
|
|
842e37658c | ||
|
|
7ba9063e04 | ||
|
|
df5f4c83fe | ||
|
|
e3ef1dd6b4 | ||
|
|
4aba9d1c0b | ||
|
|
cf014bc24f | ||
|
|
90f1ff8a0b | ||
|
|
b84a33c09d | ||
|
|
abd932c894 | ||
|
|
5b083c9e33 | ||
|
|
f498212b2d | ||
|
|
37aab45c19 | ||
|
|
c9c5eb315a | ||
|
|
75c8627577 | ||
|
|
15e8281d46 | ||
|
|
283d9cd8e2 | ||
|
|
3e06017ae1 | ||
|
|
058830591f | ||
|
|
32d826c047 | ||
|
|
bdec61f312 | ||
|
|
9c846e7de4 | ||
|
|
4c158934d0 | ||
|
|
50e5ffb257 | ||
|
|
983729cbdf | ||
|
|
89027f1fbf | ||
|
|
2a338eb01b |
@@ -1,64 +0,0 @@
|
||||
<!-- Copy this file to .cursorrules in the root of the project on your local machine if you'd like to use these rules with Cursor. -->
|
||||
|
||||
You are an expert in Ruby, Ruby on Rails, Postgres, Tailwind, Stimulus, Hotwire and Turbo and always use the latest stable versions of those technologies.
|
||||
|
||||
**Code Style and Structure**
|
||||
- Write concise, technical Ruby code with accurate examples.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., is_loading, has_error).
|
||||
- Structure files: models, controllers, views, helpers, services, jobs, mailers.
|
||||
|
||||
**Naming Conventions**
|
||||
- Use snake_case for file names and directories (e.g., app/models/user_profile.rb).
|
||||
- Use CamelCase for classes and modules (e.g., UserProfile).
|
||||
|
||||
**Ruby on Rails Usage**
|
||||
- Use Rails conventions for MVC structure.
|
||||
- Favor scopes over class methods for queries.
|
||||
- Use strong parameters for mass assignment protection.
|
||||
- Use partials to DRY up views.
|
||||
|
||||
**Syntax and Formatting**
|
||||
- Use two spaces for indentation.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use descriptive method names and keep methods short.
|
||||
|
||||
**Commenting Code**
|
||||
- Write clear, concise comments to explain the purpose of individual functions and methods.
|
||||
- Use comments to describe the intent and functionality of complex logic.
|
||||
- Avoid redundant comments that state the obvious.
|
||||
|
||||
**UI and Styling**
|
||||
- Use Tailwind CSS for styling.
|
||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach.
|
||||
- Use Stimulus for JavaScript behavior.
|
||||
- Use Turbo for asynchronous actions and updates.
|
||||
|
||||
**Performance Optimization**
|
||||
- Use eager loading to avoid N+1 queries.
|
||||
- Cache expensive queries and partials where appropriate.
|
||||
- Use background jobs for long-running tasks.
|
||||
- Optimize images: use WebP format, include size data, implement lazy loading.
|
||||
|
||||
**Database Querying & Data Model Creation**
|
||||
- Use ActiveRecord for data querying and model creation.
|
||||
- Favor database constraints and indexes for data integrity and performance.
|
||||
- Use migrations to manage schema changes.
|
||||
|
||||
**Key Conventions**
|
||||
- Follow Rails best practices for RESTful routing.
|
||||
- Optimize for performance and security.
|
||||
- Use environment variables for configuration.
|
||||
- Write tests for models, controllers, and features.
|
||||
|
||||
**AI Guidelines**
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- Confirm, then write code!
|
||||
- Suggest solutions that I didn't think about—anticipate my needs
|
||||
- Focus on readability over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Don't say things like "additional logic can be added here" — instead, add the logic.
|
||||
- Be concise. Minimize any other prose.
|
||||
- Consider new technologies and contrarian ideas, not just the conventional wisdom
|
||||
- If I ask for adjustments to code, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make.
|
||||
84
.cursor/rules/project-conventions.mdc
Normal file
84
.cursor/rules/project-conventions.mdc
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
description: This rule explains the project's tech stack and code conventions
|
||||
globs: *
|
||||
---
|
||||
This rule serves as high-level documentation for how the Maybe codebase is structured.
|
||||
|
||||
## Rules for AI
|
||||
|
||||
- Use this file to understand how the codebase works
|
||||
- Treat this rule/file as your "source of truth" when making code recommendations
|
||||
- When creating migrations, always use `rails g migration` instead of creating the file yourself
|
||||
|
||||
## Project Tech Stack
|
||||
|
||||
- Web framework: Ruby on Rails
|
||||
- Minitest + fixtures for testing
|
||||
- Propshaft for asset pipeline
|
||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||
- TailwindCSS for styles
|
||||
- Lucide Icons for icons
|
||||
- Database: PostgreSQL
|
||||
- Jobs: GoodJob
|
||||
- External
|
||||
- Payments: Stripe
|
||||
- User bank data syncing: Plaid
|
||||
- Market data: Synth (our custom API)
|
||||
|
||||
## Project conventions
|
||||
|
||||
These conventions should be used when writing code for Maybe.
|
||||
|
||||
### Convention 1: Minimize dependencies, vanilla Rails is plenty
|
||||
|
||||
Dependencies are a natural part of building software, but we aim to minimize them when possible to keep this open-source codebase easy to understand, maintain, and contribute to.
|
||||
|
||||
- Push Rails to its limits before adding new dependencies
|
||||
- When a new dependency is added, there must be a strong technical or business reason to add it
|
||||
- When adding dependencies, you should favor old and reliable over new and flashy
|
||||
|
||||
### Convention 2: Leverage POROs and concerns over "service objects"
|
||||
|
||||
This codebase adopts a "skinny controller, fat models" convention. Furthermore, we put almost _everything_ directly in the `app/models/` folder and avoid separate folders for business logic such as `app/services/`.
|
||||
|
||||
- Organize large pieces of business logic into Rails concerns and POROs (Plain ole' Ruby Objects)
|
||||
- While a Rails concern _may_ offer shared functionality (i.e. "duck types"), it can also be a "one-off" concern that is only included in one place for better organization and readability.
|
||||
- When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase.
|
||||
- When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`.
|
||||
|
||||
### Convention 3: Prefer server-side solutions over client-side solutions
|
||||
|
||||
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
|
||||
- When writing a client-side solution, use Stimulus controllers and keep it simple!
|
||||
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
|
||||
- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
|
||||
|
||||
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
|
||||
|
||||
This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance.
|
||||
|
||||
- Focus on good OOP design first, performance second
|
||||
- Be mindful of large performance bottlenecks, but don't sweat the small stuff
|
||||
|
||||
### Convention 5: Prefer semantic, native HTML features
|
||||
|
||||
The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include:
|
||||
|
||||
- Using the `dialog` element for modals
|
||||
- Using `summary` / `details` elements for disclosures (or `popover` attribute)
|
||||
|
||||
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
|
||||
|
||||
### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures
|
||||
|
||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb)
|
||||
|
||||
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
||||
|
||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
134
.cursor/rules/project-design.mdc
Normal file
134
.cursor/rules/project-design.mdc
Normal file
@@ -0,0 +1,134 @@
|
||||
---
|
||||
description: This rule explains the system architecture and data flow of the Rails app
|
||||
globs: *
|
||||
---
|
||||
|
||||
This file outlines how the codebase is structured and how data flows through the app.
|
||||
|
||||
This is a personal finance application built in Ruby on Rails. The primary domain entities for this app are outlined below. For an authoritative overview of the relationships, [schema.rb](mdc:db/schema.rb) is the source of truth.
|
||||
|
||||
## App Modes
|
||||
|
||||
The Maybe app runs in two distinct "modes", dictated by `Rails.application.config.app_mode`, which can be `managed` or `self_hosted`.
|
||||
|
||||
- "Managed" - in managed mode, the Maybe team operates and manages servers for users
|
||||
- "Self Hosted" - in self hosted mode, users host the Maybe app on their own infrastructure, typically through Docker Compose. We have an example [docker-compose.example.yml](mdc:docker-compose.example.yml) file that runs [Dockerfile](mdc:Dockerfile) for this mode.
|
||||
|
||||
## Families and Users
|
||||
|
||||
- `Family` - all Stripe subscriptions, financial accounts, and the majority of preferences are stored at the [family.rb](mdc:app/models/family.rb) level.
|
||||
- `User` - all [session.rb](mdc:app/models/session.rb) happen at the [user.rb](mdc:app/models/user.rb) level. A user belongs to a `Family` and can either be an `admin` or a `member`. Typically, a `Family` has a single admin, or "head of household" that manages finances while there will be several `member` users who can see the family's finances from varying perspectives.
|
||||
|
||||
## Currency Preference
|
||||
|
||||
Each `Family` selects a currency preference. This becomes the "main" currency in which all records are "normalized" to via [exchange_rate.rb](mdc:app/models/exchange_rate.rb) records so that the Maybe app can calculate metrics, historical graphs, and other insights in a single family currency.
|
||||
|
||||
## Accounts
|
||||
|
||||
The center of the app's domain is the [account.rb](mdc:app/models/account.rb). This represents a single financial account that has a `balance` and `currency`. For example, an `Account` could be "Chase Checking", which is a single financial account at Chase Bank. A user could have multiple accounts at a single institution (i.e. "Chase Checking", "Chase Credit Card", "Chase Savings") or an account could be a standalone account, such as "My Home" (a primary residence).
|
||||
|
||||
### Accountables
|
||||
|
||||
In the app, [account.rb](mdc:app/models/account.rb) is a Rails "delegated type" with the following subtypes (separate DB tables). Each account has a `classification` or either `asset` or `liability`. While the types are a flat hierarchy, below, they have been organized by their classification:
|
||||
|
||||
- Asset accountables
|
||||
- [depository.rb](mdc:app/models/depository.rb) - a typical "bank account" such as a savings or checking account
|
||||
- [investment.rb](mdc:app/models/investment.rb) - an account that has "holdings" such as a brokerage, 401k, etc.
|
||||
- [crypto.rb](mdc:app/models/crypto.rb) - an account that tracks the value of one or more crypto holdings
|
||||
- [property.rb](mdc:app/models/property.rb) - an account that tracks the value of a physical property such as a house or rental property
|
||||
- [vehicle.rb](mdc:app/models/vehicle.rb) - an account that tracks the value of a vehicle
|
||||
- [other_asset.rb](mdc:app/models/other_asset.rb) - an asset that cannot be classified by the other account types. For example, "jewelry".
|
||||
- Liability accountables
|
||||
- [credit_card.rb](mdc:app/models/credit_card.rb) - an account that tracks the debt owed on a credit card
|
||||
- [loan.rb](mdc:app/models/loan.rb) - an account that tracks the debt owed on a loan (i.e. mortgage, student loan)
|
||||
- [other_liability.rb](mdc:app/models/other_liability.rb) - a liability that cannot be classified by the other account types. For example, "IOU to a friend"
|
||||
|
||||
### Account Balances
|
||||
|
||||
An account [balance.rb](mdc:app/models/account/balance.rb) represents a single balance value for an account on a specific `date`. A series of balance records is generated daily for each account and is how we show a user's historical balance graph.
|
||||
|
||||
- For simple accounts like a "Checking Account", the balance represents the amount of cash in the account for a date.
|
||||
- For a more complex account like "Investment Brokerage", the `balance` represents the combination of the "cash balance" + "holdings value". Each accountable type has different components that make up the "balance", but in all cases, the "balance" represents "How much the account is worth" (when `classification` is `asset`) or "How much is owed on the account" (when `classification` is `liability`)
|
||||
|
||||
All balances are calculated daily by [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
|
||||
### Account Holdings
|
||||
|
||||
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
|
||||
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
|
||||
### Account Entries
|
||||
|
||||
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
|
||||
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
|
||||
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
|
||||
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
|
||||
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
|
||||
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
|
||||
|
||||
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
|
||||
### Account Transfers
|
||||
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
|
||||
- Must be from different accounts
|
||||
- Must be within 4 days of each other
|
||||
- Must be the same currency
|
||||
- Must be opposite values
|
||||
|
||||
There are two primary forms of a transfer:
|
||||
|
||||
- Regular transfer - a normal movement of money between two accounts. For example, "Transfer $500 from Checking account to Brokerage account".
|
||||
- Debt payment - a special form of transfer where the _receiver_ of funds is a [loan.rb](mdc:app/models/loan.rb) type account.
|
||||
|
||||
Regular transfers are typically _excluded_ from income and expense calculations while a debt payment is considered an "expense".
|
||||
|
||||
## Plaid Items
|
||||
|
||||
A [plaid_item.rb](mdc:app/models/plaid_item.rb) represents a "connection" maintained by our external data provider, Plaid in the "hosted" mode of the app. An "Item" has 1 or more [plaid_account.rb](mdc:app/models/plaid_account.rb) records, which are each associated 1:1 with an internal Maybe [account.rb](mdc:app/models/account.rb).
|
||||
|
||||
All relevant metadata about the item and its underlying accounts are stored on [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb), while the "normalized" data is then stored on internal Maybe domain models.
|
||||
|
||||
## "Syncs"
|
||||
|
||||
The Maybe app has the concept of a [syncable.rb](mdc:app/models/concerns/syncable.rb), which represents any model which can have its data "synced" in the background. "Syncables" include:
|
||||
|
||||
- `Account` - an account "sync" will sync account holdings, balances, and enhance transaction metadata
|
||||
- `PlaidItem` - a Plaid Item "sync" fetches data from Plaid APIs, normalizes that data, stores it on internal Maybe models, and then finally performs an "Account sync" for each of the underlying accounts created from the Plaid Item.
|
||||
- `Family` - a Family "sync" loops through the family's Plaid Items and individual Accounts and "syncs" each of them. A family is synced once per day, automatically through [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb).
|
||||
|
||||
Each "sync" creates a [sync.rb](mdc:app/models/sync.rb) record in the database, which keeps track of the status of the sync, any errors that it encounters, and acts as an "audit table" for synced data.
|
||||
|
||||
Below are brief descriptions of each type of sync in more detail.
|
||||
|
||||
### Account Syncs
|
||||
|
||||
The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks:
|
||||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates holdings and balances for the account
|
||||
- Enriches transaction data
|
||||
- Converts account balances that are not in the family's preferred currency to the preferred currency
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
|
||||
### Plaid Item Syncs
|
||||
|
||||
A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
|
||||
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
|
||||
|
||||
### Family Syncs
|
||||
|
||||
A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns/auto_sync.rb). A family sync is an "orchestrator" of Account and Plaid Item syncs.
|
||||
|
||||
|
||||
13
.cursor/rules/ui-ux-design-guidelines.mdc
Normal file
13
.cursor/rules/ui-ux-design-guidelines.mdc
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: This file describes Maybe's design system and how views should be styled
|
||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||
---
|
||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
||||
|
||||
The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css)
|
||||
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase
|
||||
- Always generate semantic HTML
|
||||
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
|
||||
- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials.
|
||||
- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`.
|
||||
54
.github/ISSUE_TEMPLATE/bug_report.md
vendored
54
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,31 +1,61 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
about: Open a bug report when you experience broken functionality within the latest
|
||||
version of the Maybe app
|
||||
title: 'Bug: [Add descriptive title here]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur? (required)**
|
||||
## Before you start (required)
|
||||
|
||||
- [ ] I am a self-hosted user reporting a bug from my self hosted app
|
||||
- [ ] I am a user of Maybe's paid app
|
||||
### General checklist
|
||||
|
||||
_Please note, if you are reporting a bug with sensitive data, please open an Intercom chat from within the app for help_
|
||||
- [ ] I have removed personal / sensitive data from screenshots and logs
|
||||
- [ ] I have searched [existing issues](https://github.com/maybe-finance/maybe/issues?q=is:issue) and [discussions](https://github.com/maybe-finance/maybe/discussions) to ensure this is not a duplicate issue
|
||||
|
||||
### How are you using Maybe?
|
||||
|
||||
- [ ] I am a paying Maybe customer (hosted version)
|
||||
- Paying Maybe users can also open requests in Intercom (if there is sensitive info involved)
|
||||
- [ ] I am a self-hosted user
|
||||
|
||||
### Self hoster checklist
|
||||
|
||||
_Paying, hosted users should delete this entire section._
|
||||
|
||||
If you are a self-hosted user, please complete all of the information below. Issues with incomplete information will be marked as `Needs Info` to help our small team prioritize bug fixes.
|
||||
|
||||
- Self hosted app commit SHA (find in user menu): [enter commit sha here]
|
||||
- [ ] I have confirmed that my app's commit is the latest version of Maybe
|
||||
- Where are you hosting?
|
||||
- [ ] Render
|
||||
- [ ] Docker Compose
|
||||
- [ ] Umbrel
|
||||
- [ ] Other (please specify)
|
||||
|
||||
---
|
||||
|
||||
## Bug description
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
### To Reproduce
|
||||
|
||||
Be as specific as possible so Maybe maintainers can quickly reproduce the bug you're experiencing.
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
### Expected behavior
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
What is the intended behavior that you would expect?
|
||||
|
||||
### Screenshots and/or recordings
|
||||
|
||||
We highly recommend providing additional context with screenshots and/or screen recordings. This will _significantly_ improve the chances of the bug being addressed and fixed quickly.
|
||||
|
||||
35
.github/ISSUE_TEMPLATE/other.md
vendored
35
.github/ISSUE_TEMPLATE/other.md
vendored
@@ -7,15 +7,36 @@ assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
## Before you start (required)
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
### Is this a bug?
|
||||
|
||||
----------------------
|
||||
A bug is _broken functionality_ of the app (i.e. it prevents you from using the app). For bugs, please use the ["Bug Report" template](https://github.com/maybe-finance/maybe/issues) instead.
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
### Is this a bug with _sensitive info_?
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
If you are a _paying_ Maybe user, you can open a support request in Intercom.
|
||||
|
||||
**Additional context**
|
||||
### Is this a feature request?
|
||||
|
||||
A feature request is functionality that you would like that is not already on our [Roadmap](https://github.com/maybe-finance/maybe/wiki/Roadmap).
|
||||
|
||||
All feature requests should be opened as Discussions here:
|
||||
|
||||
https://github.com/maybe-finance/maybe/discussions/categories/feature-requests
|
||||
|
||||
Be sure to search existing discussions prior to opening a new feature request.
|
||||
|
||||
### Is this related to Docker and/or hosting for self hosting?
|
||||
|
||||
If you are having a Docker configuration issue, please do not open a Github issue unless you've identified a bug in our Dockerfile. To get help with self hosting, there are several options:
|
||||
|
||||
- **First**: Read our [self hosting guides](https://github.com/maybe-finance/maybe/tree/main/docs/hosting) and follow them step-by-step
|
||||
- Open a [General Discussion](https://github.com/maybe-finance/maybe/discussions/categories/general)
|
||||
- Make a post in the "Self hosted" channel in our [Discord](https://link.maybe.co/discord)
|
||||
|
||||
---
|
||||
|
||||
## Issue description
|
||||
|
||||
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.
|
||||
|
||||
14
.github/workflows/publish.yml
vendored
14
.github/workflows/publish.yml
vendored
@@ -1,6 +1,13 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref (tag or commit SHA) to build'
|
||||
required: true
|
||||
type: string
|
||||
default: 'main'
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
@@ -22,6 +29,8 @@ jobs:
|
||||
name: Build docker image
|
||||
needs: [ ci ]
|
||||
|
||||
timeout-minutes: 60
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
@@ -31,6 +40,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -65,9 +76,10 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64,linux/arm64' }}
|
||||
platforms: 'linux/amd64,linux/arm64'
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
|
||||
build-args: BUILD_COMMIT_SHA=${{ github.sha }}
|
||||
|
||||
@@ -4,6 +4,8 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
|
||||
|
||||
## House Rules
|
||||
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.
|
||||
- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)
|
||||
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
|
||||
- When multiple PRs are submitted for the same issue, we take the one that most succinctly & efficiently solves a given problem and stays within the scope of work.
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -12,34 +12,36 @@ RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
||||
|
||||
# Set production environment
|
||||
ARG BUILD_COMMIT_SHA
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
BUNDLE_PATH="/usr/local/bundle" \
|
||||
BUNDLE_WITHOUT="development"
|
||||
|
||||
|
||||
BUNDLE_WITHOUT="development" \
|
||||
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential git libpq-dev pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
RUN bundle install && \
|
||||
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
|
||||
bundle exec bootsnap precompile --gemfile
|
||||
RUN bundle install
|
||||
|
||||
RUN rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
|
||||
|
||||
RUN bundle exec bootsnap precompile --gemfile -j 0
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Precompile bootsnap code for faster boot times
|
||||
RUN bundle exec bootsnap precompile app/ lib/
|
||||
RUN bundle exec bootsnap precompile -j 0 app/ lib/
|
||||
|
||||
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
|
||||
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
||||
|
||||
10
Gemfile
10
Gemfile
@@ -21,16 +21,19 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||
# Hotwire
|
||||
gem "stimulus-rails"
|
||||
gem "turbo-rails"
|
||||
gem "hotwire_combobox"
|
||||
|
||||
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
|
||||
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
|
||||
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "logtail-rails"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||
@@ -52,6 +55,9 @@ gem "redcarpet"
|
||||
gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
gem "ruby-openai"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[mri windows]
|
||||
|
||||
179
Gemfile.lock
179
Gemfile.lock
@@ -1,3 +1,14 @@
|
||||
GIT
|
||||
remote: https://github.com/josefarias/hotwire_combobox.git
|
||||
revision: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
ref: b827048a8305e1115d5f96931ba1c9750d1e59fc
|
||||
specs:
|
||||
hotwire_combobox (0.3.2)
|
||||
platform_agent (>= 1.0.1)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
||||
@@ -127,6 +138,7 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
@@ -155,6 +167,7 @@ GEM
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
@@ -180,7 +193,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.8.2)
|
||||
good_job (4.9.3)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -194,13 +207,9 @@ GEM
|
||||
actioncable (>= 7.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 7.0.0)
|
||||
hotwire_combobox (0.3.2)
|
||||
rails (>= 7.0.7.2)
|
||||
stimulus-rails (>= 1.2)
|
||||
turbo-rails (>= 1.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
i18n-tasks (1.0.15)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
erubi
|
||||
@@ -209,9 +218,10 @@ GEM
|
||||
parser (>= 3.2.2.1)
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.8, >= 1.8.1)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
@@ -229,7 +239,7 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.9.1)
|
||||
json (2.10.1)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
@@ -239,10 +249,22 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.5)
|
||||
logger (1.6.6)
|
||||
logtail (0.1.15)
|
||||
msgpack (~> 1.0)
|
||||
logtail-rack (0.2.6)
|
||||
logtail (~> 0.1)
|
||||
rack (>= 1.2, < 4.0)
|
||||
logtail-rails (0.2.10)
|
||||
actionpack (>= 5.0.0)
|
||||
activerecord (>= 5.0.0)
|
||||
logtail (~> 0.1, >= 0.1.14)
|
||||
logtail-rack (~> 0.1)
|
||||
railties (>= 5.0.0)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
@@ -253,12 +275,14 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.13.2)
|
||||
mini_magick (5.1.2)
|
||||
benchmark
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.5)
|
||||
msgpack (1.8.0)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
@@ -272,34 +296,37 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nokogiri (1.18.3-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
nokogiri (1.18.3-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
nokogiri (1.18.3-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
nokogiri (1.18.3-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
nokogiri (1.18.3-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
octokit (9.2.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.3.3)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
parser (3.3.7.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
plaid (35.1.0)
|
||||
plaid (36.1.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
platform_agent (1.0.1)
|
||||
activesupport (>= 5.2.0)
|
||||
useragent (~> 0.16.3)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
@@ -317,7 +344,7 @@ GEM
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.9)
|
||||
rack (3.1.11)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
@@ -369,50 +396,58 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
rbs (3.8.1)
|
||||
logger
|
||||
rdoc (6.11.0)
|
||||
rdoc (6.12.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.0)
|
||||
rubocop (1.71.0)
|
||||
rexml (3.4.1)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.73.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
rubocop-ast (1.38.1)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.29.1)
|
||||
rubocop-performance (1.24.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails (2.30.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails-omakase (1.0.0)
|
||||
rubocop
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.23.6)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.23.9)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.31)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-openai (7.4.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ruby-vips (2.2.3)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
@@ -421,16 +456,16 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.0)
|
||||
selenium-webdriver (4.29.1)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.22.3)
|
||||
sentry-rails (5.22.4)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.22.3)
|
||||
sentry-ruby (5.22.3)
|
||||
sentry-ruby (~> 5.22.4)
|
||||
sentry-ruby (5.22.4)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -440,27 +475,28 @@ GEM
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11781)
|
||||
stackprof (0.2.27)
|
||||
sorbet-runtime (0.5.11813)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.2)
|
||||
stripe (13.4.1)
|
||||
tailwindcss-rails (3.3.1)
|
||||
stringio (3.1.5)
|
||||
stripe (13.5.0)
|
||||
tailwindcss-rails (4.0.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 3.0)
|
||||
tailwindcss-ruby (3.4.17-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.17-arm-linux)
|
||||
tailwindcss-ruby (3.4.17-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.0.6)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.0.6-arm64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-darwin)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
turbo-rails (2.0.13)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (3.1.4)
|
||||
@@ -470,12 +506,13 @@ GEM
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.5.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.24.0)
|
||||
webmock (3.25.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -486,7 +523,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.1)
|
||||
zeitwerk (2.7.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -519,7 +556,7 @@ DEPENDENCIES
|
||||
faraday-retry
|
||||
good_job
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
hotwire_combobox!
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
@@ -527,6 +564,7 @@ DEPENDENCIES
|
||||
intercom-rails
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
@@ -539,19 +577,22 @@ DEPENDENCIES
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
simplecov
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
web-console
|
||||
webmock
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -33,6 +33,15 @@ There are 3 primary ways to use the Maybe app:
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
**If you are trying to _self-host_ the Maybe app, stop here. You
|
||||
@@ -107,15 +116,6 @@ In development, we use `letter_opener` to automatically open emails in your
|
||||
browser. When an email sends locally, a new browser tab will open with a
|
||||
preview.
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
BIN
app/assets/fonts/geist/Geist-Black.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Black.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Bold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Bold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-ExtraBold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-ExtraLight.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Light.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Light.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Medium.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Medium.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Regular.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Regular.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-SemiBold.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-SemiBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist-Thin.woff2
Normal file
BIN
app/assets/fonts/geist/Geist-Thin.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist/Geist[wght].woff2
Normal file
BIN
app/assets/fonts/geist/Geist[wght].woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Black.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Black.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Bold.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Bold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Light.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Light.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Medium.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Medium.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Regular.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Regular.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-SemiBold.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-SemiBold.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-Thin.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-Thin.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-UltraBlack.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-UltraBlack.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono-UltraLight.woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono-UltraLight.woff2
Normal file
Binary file not shown.
BIN
app/assets/fonts/geist_mono/GeistMono[wght].woff2
Normal file
BIN
app/assets/fonts/geist_mono/GeistMono[wght].woff2
Normal file
Binary file not shown.
12
app/assets/images/logomark-color.svg
Normal file
12
app/assets/images/logomark-color.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none">
|
||||
<path d="M8.39804 24.0315H4.09584C3.07641 24.0315 2.25 24.8609 2.25 25.8841C2.25 26.9072 3.07641 27.7367 4.09584 27.7367H8.39804C9.41747 27.7367 10.2439 26.9072 10.2439 25.8841C10.2439 24.8609 9.41747 24.0315 8.39804 24.0315Z" fill="#F23E94"/>
|
||||
<path d="M27.6403 27.7359H31.9425C32.9619 27.7359 33.7883 26.9065 33.7883 25.8833C33.7883 24.8601 32.9619 24.0307 31.9425 24.0307H27.6403C26.6209 24.0307 25.7945 24.8601 25.7945 25.8833C25.7945 26.9065 26.6209 27.7359 27.6403 27.7359Z" fill="#F23E94"/>
|
||||
<path d="M19.7588 24.0189H16.2567C15.2373 24.0189 14.4109 24.8483 14.4109 25.8715C14.4109 26.8947 15.2373 27.7241 16.2567 27.7241H19.7588C20.7783 27.7241 21.6047 26.8947 21.6047 25.8715C21.6047 24.8483 20.7783 24.0189 19.7588 24.0189Z" fill="#F23E94"/>
|
||||
<path d="M25.9683 22.4047H30.1112C31.1306 22.4047 31.957 21.5753 31.957 20.5521C31.957 19.529 31.1306 18.6995 30.1112 18.6995H25.9683C24.9489 18.6995 24.1225 19.529 24.1225 20.5521C24.1225 21.5753 24.9489 22.4047 25.9683 22.4047Z" fill="#6927DA"/>
|
||||
<path d="M9.99971 18.6993H5.85685C4.83742 18.6993 4.01101 19.5288 4.01101 20.5519C4.01101 21.5751 4.83742 22.4045 5.85685 22.4045H9.99971C11.0191 22.4045 11.8455 21.5751 11.8455 20.5519C11.8455 19.5288 11.0191 18.6993 9.99971 18.6993Z" fill="#6927DA"/>
|
||||
<path d="M21.0888 18.6875H14.924C13.9045 18.6875 13.0781 19.517 13.0781 20.5401C13.0781 21.5633 13.9045 22.3927 14.924 22.3927H21.0888C22.1082 22.3927 22.9346 21.5633 22.9346 20.5401C22.9346 19.517 22.1082 18.6875 21.0888 18.6875Z" fill="#6927DA"/>
|
||||
<path d="M15.5578 13.2072H7.69136C6.67193 13.2072 5.84552 14.0366 5.84552 15.0598C5.84552 16.0829 6.67193 16.9123 7.69136 16.9123H15.5578C16.5772 16.9123 17.4036 16.0829 17.4036 15.0598C17.4036 14.0366 16.5772 13.2072 15.5578 13.2072Z" fill="#1570EF"/>
|
||||
<path d="M20.9094 16.9116H28.2735C29.2929 16.9116 30.1193 16.0821 30.1193 15.059C30.1193 14.0358 29.2929 13.2064 28.2735 13.2064L20.9094 13.2064C19.89 13.2064 19.0636 14.0358 19.0636 15.059C19.0636 16.0821 19.89 16.9116 20.9094 16.9116Z" fill="#1570EF"/>
|
||||
<path d="M26.5036 7.875H22.3515C21.3321 7.875 20.5057 8.70443 20.5057 9.72759C20.5057 10.7507 21.3321 11.5802 22.3515 11.5802H26.5036C27.523 11.5802 28.3494 10.7507 28.3494 9.72759C28.3494 8.70443 27.523 7.875 26.5036 7.875Z" fill="#22CCEE"/>
|
||||
<path d="M13.6077 7.875H9.45557C8.43614 7.875 7.60973 8.70443 7.60973 9.72759C7.60973 10.7507 8.43614 11.5802 9.45557 11.5802H13.6077C14.6271 11.5802 15.4535 10.7507 15.4535 9.72759C15.4535 8.70443 14.6271 7.875 13.6077 7.875Z" fill="#22CCEE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,194 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Reset rules, default styles applied to plain HTML */
|
||||
@layer base {
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
|
||||
.form-field__label, .hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
select.form-field__input {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:active,
|
||||
select[multiple="multiple"] option:focus {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.maybe-switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper, .hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
@layer utilities {
|
||||
.scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
2
app/assets/stylesheets/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
144
app/assets/tailwind/application.css
Normal file
144
app/assets/tailwind/application.css
Normal file
@@ -0,0 +1,144 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import "./maybe-design-system.css";
|
||||
|
||||
@import "./geist-font.css";
|
||||
@import "./geist-mono-font.css";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
|
||||
@import "../stylesheets/simonweb_pickr.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
width: 21.5rem !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-palette:before{
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.pcr-color-chooser{
|
||||
height: 1.5em !important;
|
||||
}
|
||||
.pcr-picker{
|
||||
height: 20px !important;
|
||||
width: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.combobox {
|
||||
.hw-combobox__main__wrapper,
|
||||
.hw-combobox__input {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.hw-combobox__main__wrapper {
|
||||
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
|
||||
}
|
||||
|
||||
.hw-combobox__listbox {
|
||||
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
|
||||
}
|
||||
|
||||
.hw-combobox__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.hw_combobox__pagination__wrapper {
|
||||
@apply h-px;
|
||||
|
||||
&:only-child {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
--hw-border-color: rgba(0, 0, 0, 0.2);
|
||||
--hw-handle-width: 20px;
|
||||
--hw-handle-height: 20px;
|
||||
--hw-handle-offset-right: 0px;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-medium;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
details {
|
||||
@apply mb-4 rounded-xl mt-3.5;
|
||||
}
|
||||
|
||||
summary {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
video {
|
||||
@apply m-0 rounded-b-xl;
|
||||
}
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar implementation for Windows browsers */
|
||||
.windows {
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d6d6d6;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
85
app/assets/tailwind/geist-font.css
Normal file
85
app/assets/tailwind/geist-font.css
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
/* Variable font */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist[wght].woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Static fonts (fallback) */
|
||||
@supports not (font-variation-settings: normal) {
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-ExtraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-ExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('./geist/Geist-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
}
|
||||
83
app/assets/tailwind/geist-mono-font.css
Normal file
83
app/assets/tailwind/geist-mono-font.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* Variable font */
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono[wght].woff2') format('woff2-variations');
|
||||
font-weight: 100 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Static fonts (fallback) */
|
||||
@supports not (font-variation-settings: normal) {
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Thin.woff2') format('woff2');
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-UltraLight.woff2') format('woff2');
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-SemiBold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('./geist_mono/GeistMono-UltraBlack.woff2') format('woff2');
|
||||
font-weight: 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
}
|
||||
458
app/assets/tailwind/maybe-design-system.css
Normal file
458
app/assets/tailwind/maybe-design-system.css
Normal file
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
This file contains all of the Figma design tokens, components, etc. that
|
||||
are used globally across the app.
|
||||
|
||||
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Base colors */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #0B0B0B;
|
||||
--color-success: var(--color-green-600);
|
||||
--color-warning: var(--color-yellow-600);
|
||||
--color-destructive: var(--color-red-600);
|
||||
|
||||
/* Gray scale */
|
||||
--color-gray-25: #FAFAFA;
|
||||
--color-gray-50: #F7F7F7;
|
||||
--color-gray-100: #F0F0F0;
|
||||
--color-gray-200: #E7E7E7;
|
||||
--color-gray-300: #CFCFCF;
|
||||
--color-gray-400: #9E9E9E;
|
||||
--color-gray-500: #737373;
|
||||
--color-gray-600: #5C5C5C;
|
||||
--color-gray-700: #363636;
|
||||
--color-gray-800: #242424;
|
||||
--color-gray-900: #171717;
|
||||
--color-gray: var(--color-gray-500);
|
||||
--color-gray-tint-5: --alpha(var(--color-gray-500) / 5%);
|
||||
--color-gray-tint-10: --alpha(var(--color-gray-500) / 10%);
|
||||
|
||||
/* Alpha colors */
|
||||
--color-alpha-white-25: --alpha(var(--color-white) / 3%);
|
||||
--color-alpha-white-50: --alpha(var(--color-white) / 5%);
|
||||
--color-alpha-white-100: --alpha(var(--color-white) / 8%);
|
||||
--color-alpha-white-200: --alpha(var(--color-white) / 10%);
|
||||
--color-alpha-white-300: --alpha(var(--color-white) / 15%);
|
||||
--color-alpha-white-400: --alpha(var(--color-white) / 20%);
|
||||
--color-alpha-white-500: --alpha(var(--color-white) / 30%);
|
||||
--color-alpha-white-600: --alpha(var(--color-white) / 40%);
|
||||
--color-alpha-white-700: --alpha(var(--color-white) / 50%);
|
||||
--color-alpha-white-800: --alpha(var(--color-white) / 70%);
|
||||
--color-alpha-white-900: --alpha(var(--color-white) / 85%);
|
||||
|
||||
--color-alpha-black-25: --alpha(var(--color-black) / 3%);
|
||||
--color-alpha-black-50: --alpha(var(--color-black) / 5%);
|
||||
--color-alpha-black-100: --alpha(var(--color-black) / 8%);
|
||||
--color-alpha-black-200: --alpha(var(--color-black) / 10%);
|
||||
--color-alpha-black-300: --alpha(var(--color-black) / 15%);
|
||||
--color-alpha-black-400: --alpha(var(--color-black) / 20%);
|
||||
--color-alpha-black-500: --alpha(var(--color-black) / 30%);
|
||||
--color-alpha-black-600: --alpha(var(--color-black) / 40%);
|
||||
--color-alpha-black-700: --alpha(var(--color-black) / 50%);
|
||||
--color-alpha-black-800: --alpha(var(--color-black) / 70%);
|
||||
--color-alpha-black-900: --alpha(var(--color-black) / 85%);
|
||||
|
||||
/* Red scale */
|
||||
--color-red-25: #FFFBFB;
|
||||
--color-red-50: #FFF1F0;
|
||||
--color-red-100: #FFDEDB;
|
||||
--color-red-200: #FEB9B3;
|
||||
--color-red-300: #F88C86;
|
||||
--color-red-400: #ED4E4E;
|
||||
--color-red-500: #F13636;
|
||||
--color-red-600: #EC2222;
|
||||
--color-red-700: #C91313;
|
||||
--color-red-800: #A40E0E;
|
||||
--color-red-900: #7E0707;
|
||||
--color-red-tint-5: --alpha(var(--color-red-500) / 5%);
|
||||
--color-red-tint-10: --alpha(var(--color-red-500) / 10%);
|
||||
|
||||
/* Green scale */
|
||||
--color-green-25: #F6FEF9;
|
||||
--color-green-50: #ECFDF3;
|
||||
--color-green-100: #D1FADF;
|
||||
--color-green-200: #A6F4C5;
|
||||
--color-green-300: #6CE9A6;
|
||||
--color-green-400: #32D583;
|
||||
--color-green-500: #12B76A;
|
||||
--color-green-600: #10A861;
|
||||
--color-green-700: #078C52;
|
||||
--color-green-800: #05603A;
|
||||
--color-green-900: #054F31;
|
||||
--color-green-tint-5: --alpha(var(--color-green-500) / 5%);
|
||||
--color-green-tint-10: --alpha(var(--color-green-500) / 10%);
|
||||
|
||||
/* Yellow scale */
|
||||
--color-yellow-25: #FFFCF5;
|
||||
--color-yellow-50: #FFFAEB;
|
||||
--color-yellow-100: #FEF0C7;
|
||||
--color-yellow-200: #FEDF89;
|
||||
--color-yellow-300: #FEC84B;
|
||||
--color-yellow-400: #FDB022;
|
||||
--color-yellow-500: #F79009;
|
||||
--color-yellow-600: #DC6803;
|
||||
--color-yellow-700: #B54708;
|
||||
--color-yellow-800: #93370D;
|
||||
--color-yellow-900: #7A2E0E;
|
||||
--color-yellow-tint-5: --alpha(var(--color-yellow-500) / 5%);
|
||||
--color-yellow-tint-10: --alpha(var(--color-yellow-500) / 10%);
|
||||
|
||||
/* Cyan scale */
|
||||
--color-cyan-25: #F5FEFF;
|
||||
--color-cyan-50: #ECFDFF;
|
||||
--color-cyan-100: #CFF9FE;
|
||||
--color-cyan-200: #A5F0FC;
|
||||
--color-cyan-300: #67E3F9;
|
||||
--color-cyan-400: #22CCEE;
|
||||
--color-cyan-500: #06AED4;
|
||||
--color-cyan-600: #088AB2;
|
||||
--color-cyan-700: #0E7090;
|
||||
--color-cyan-800: #155B75;
|
||||
--color-cyan-900: #155B75;
|
||||
--color-cyan-tint-5: --alpha(var(--color-cyan-500) / 5%);
|
||||
--color-cyan-tint-10: --alpha(var(--color-cyan-500) / 10%);
|
||||
|
||||
/* Blue scale */
|
||||
--color-blue-25: #F5FAFF;
|
||||
--color-blue-50: #EFF8FF;
|
||||
--color-blue-100: #D1E9FF;
|
||||
--color-blue-200: #B2DDFF;
|
||||
--color-blue-300: #84CAFF;
|
||||
--color-blue-400: #53B1FD;
|
||||
--color-blue-500: #2E90FA;
|
||||
--color-blue-600: #1570EF;
|
||||
--color-blue-700: #175CD3;
|
||||
--color-blue-800: #1849A9;
|
||||
--color-blue-900: #194185;
|
||||
--color-blue-tint-5: --alpha(var(--color-blue-500) / 5%);
|
||||
--color-blue-tint-10: --alpha(var(--color-blue-500) / 10%);
|
||||
|
||||
/* Indigo scale */
|
||||
--color-indigo-25: #F5F8FF;
|
||||
--color-indigo-50: #EFF4FF;
|
||||
--color-indigo-100: #E0EAFF;
|
||||
--color-indigo-200: #C7D7FE;
|
||||
--color-indigo-300: #A4BCFD;
|
||||
--color-indigo-400: #8098F9;
|
||||
--color-indigo-500: #6172F3;
|
||||
--color-indigo-600: #444CE7;
|
||||
--color-indigo-700: #3538CD;
|
||||
--color-indigo-800: #2D31A6;
|
||||
--color-indigo-900: #2D3282;
|
||||
--color-indigo-tint-5: --alpha(var(--color-indigo-500) / 5%);
|
||||
--color-indigo-tint-10: --alpha(var(--color-indigo-500) / 10%);
|
||||
|
||||
/* Violet scale */
|
||||
--color-violet-25: #FBFAFF;
|
||||
--color-violet-50: #F5F3FF;
|
||||
--color-violet-100: #ECE9FE;
|
||||
--color-violet-200: #DDD6FE;
|
||||
--color-violet-300: #C3B5FD;
|
||||
--color-violet-400: #A48AFB;
|
||||
--color-violet-500: #875BF7;
|
||||
--color-violet-600: #7839EE;
|
||||
--color-violet-700: #6927DA;
|
||||
--color-violet-tint-5: --alpha(var(--color-violet-500) / 5%);
|
||||
--color-violet-tint-10: --alpha(var(--color-violet-500) / 10%);
|
||||
|
||||
/* Fuchsia scale */
|
||||
--color-fuchsia-25: #FEFAFF;
|
||||
--color-fuchsia-50: #FDF4FF;
|
||||
--color-fuchsia-100: #FBE8FF;
|
||||
--color-fuchsia-200: #F6D0FE;
|
||||
--color-fuchsia-300: #EEAAFD;
|
||||
--color-fuchsia-400: #E478FA;
|
||||
--color-fuchsia-500: #D444F1;
|
||||
--color-fuchsia-600: #BA24D5;
|
||||
--color-fuchsia-700: #9F1AB1;
|
||||
--color-fuchsia-800: #821890;
|
||||
--color-fuchsia-900: #6F1877;
|
||||
--color-fuchsia-tint-5: --alpha(var(--color-fuchsia-500) / 5%);
|
||||
--color-fuchsia-tint-10: --alpha(var(--color-fuchsia-500) / 10%);
|
||||
|
||||
/* Pink scale */
|
||||
--color-pink-25: #FFFAFC;
|
||||
--color-pink-50: #FEF0F7;
|
||||
--color-pink-100: #FFD1E2;
|
||||
--color-pink-200: #FFB1CE;
|
||||
--color-pink-300: #FD8FBA;
|
||||
--color-pink-400: #F86BA7;
|
||||
--color-pink-500: #F23E94;
|
||||
--color-pink-600: #D5327F;
|
||||
--color-pink-700: #BA256B;
|
||||
--color-pink-800: #9E1958;
|
||||
--color-pink-900: #840B45;
|
||||
--color-pink-tint-5: --alpha(var(--color-pink-500) / 5%);
|
||||
--color-pink-tint-10: --alpha(var(--color-pink-500) / 10%);
|
||||
|
||||
/* Orange scale */
|
||||
--color-orange-25: #FFF9F5;
|
||||
--color-orange-50: #FFF4ED;
|
||||
--color-orange-100: #FFE6D5;
|
||||
--color-orange-200: #FFD6AE;
|
||||
--color-orange-300: #FF9C66;
|
||||
--color-orange-400: #FF692E;
|
||||
--color-orange-500: #FF4405;
|
||||
--color-orange-600: #E62E05;
|
||||
--color-orange-700: #BC1B06;
|
||||
--color-orange-800: #97180C;
|
||||
--color-orange-900: #771A0D;
|
||||
--color-orange-tint-5: --alpha(var(--color-orange-500) / 5%);
|
||||
--color-orange-tint-10: --alpha(var(--color-orange-500) / 10%);
|
||||
|
||||
/* Border radius overrides */
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 10px;
|
||||
|
||||
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-black) / 6%);
|
||||
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-black) / 6%);
|
||||
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
|
||||
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
|
||||
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
||||
}
|
||||
|
||||
/* Custom shadow borders used for surfaces / containers */
|
||||
@utility shadow-border-xs {
|
||||
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-sm {
|
||||
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-md {
|
||||
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-lg {
|
||||
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
@utility shadow-border-xl {
|
||||
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||
}
|
||||
|
||||
/* Design system color utilities */
|
||||
@utility text-primary {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
@utility text-secondary {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
@utility text-subdued {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
@utility text-link {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
|
||||
@utility bg-surface {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-surface-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-surface-inset-hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
|
||||
@utility bg-container {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
@utility bg-container-hover {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
@utility bg-container-inset-hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
@utility bg-inverse {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
@utility bg-inverse-hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
|
||||
@utility bg-overlay {
|
||||
@apply bg-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-primary {
|
||||
@apply border-alpha-black-300;
|
||||
}
|
||||
|
||||
@utility border-secondary {
|
||||
@apply border-alpha-black-200;
|
||||
}
|
||||
|
||||
@utility border-tertiary {
|
||||
@apply border-alpha-black-100;
|
||||
}
|
||||
|
||||
@utility border-subdued {
|
||||
@apply border-alpha-black-50;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply text-gray-200;
|
||||
}
|
||||
|
||||
details>summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.btn--destructive {
|
||||
@apply bg-red-500 text-white hover:bg-red-600 disabled:bg-red-50 disabled:hover:bg-red-50 disabled:text-red-400;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-field {
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
|
||||
option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
@apply transition-opacity duration-300;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.checkbox {
|
||||
&[type='checkbox'] {
|
||||
@apply rounded-sm;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--light {
|
||||
&[type='checkbox'] {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
|
||||
/* Switches */
|
||||
.switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
|
||||
@apply after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
@apply transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@@ -11,9 +9,12 @@ class Account::HoldingsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@holding.destroy_holding_and_entries!
|
||||
|
||||
flash[:notice] = t(".success")
|
||||
if @holding.account.plaid_account_id.present?
|
||||
flash[:alert] = "You cannot delete this holding"
|
||||
else
|
||||
@holding.destroy_holding_and_entries!
|
||||
flash[:notice] = t(".success")
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@holding.account) }
|
||||
|
||||
@@ -10,7 +10,7 @@ class Account::TradesController < ApplicationController
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
|
||||
@@ -3,7 +3,7 @@ class Account::TransferMatchesController < ApplicationController
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.transfer_match_candidates
|
||||
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
25
app/controllers/accountable_sparklines_controller.rb
Normal file
25
app/controllers/accountable_sparklines_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class AccountableSparklinesController < ApplicationController
|
||||
def show
|
||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||
|
||||
@series = Rails.cache.fetch(cache_key) do
|
||||
family.accounts.active
|
||||
.where(accountable_type: @accountable.name)
|
||||
.balance_series(
|
||||
currency: family.currency,
|
||||
favorable_direction: @accountable.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
end
|
||||
|
||||
def cache_key
|
||||
family.build_cache_key("#{@accountable.name}_sparkline")
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,12 @@
|
||||
class AccountsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_account, only: %i[sync]
|
||||
before_action :set_account, only: %i[sync chart sparkline]
|
||||
include Periodable
|
||||
|
||||
def index
|
||||
@manual_accounts = Current.family.accounts.manual.alphabetically
|
||||
@plaid_items = Current.family.plaid_items.ordered
|
||||
end
|
||||
@manual_accounts = family.accounts.manual.alphabetically
|
||||
@plaid_items = family.plaid_items.ordered
|
||||
|
||||
def summary
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
end
|
||||
|
||||
def list
|
||||
@period = Period.from_param(params[:period])
|
||||
render layout: false
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def sync
|
||||
@@ -32,20 +18,28 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def chart
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def sparkline
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def sync_all
|
||||
unless Current.family.syncing?
|
||||
Current.family.sync_later
|
||||
unless family.syncing?
|
||||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
end
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@account = family.accounts.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
@@ -22,12 +22,6 @@ class ApplicationController < ActionController::Base
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def with_sidebar
|
||||
return "turbo_rails/frame" if turbo_frame_request?
|
||||
|
||||
"with_sidebar"
|
||||
end
|
||||
|
||||
def detect_os
|
||||
user_agent = request.user_agent
|
||||
@os = case user_agent
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class BudgetCategoriesController < ApplicationController
|
||||
before_action :set_budget
|
||||
|
||||
def index
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
@budget_categories = @budget.budget_categories.includes(:category)
|
||||
render layout: "wizard"
|
||||
end
|
||||
|
||||
def show
|
||||
@budget = Current.family.budgets.find(params[:budget_id])
|
||||
|
||||
@recent_transactions = @budget.entries
|
||||
@recent_transactions = @budget.transactions
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@@ -23,13 +23,26 @@ class BudgetCategoriesController < ApplicationController
|
||||
|
||||
def update
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@budget_category.update!(budget_category_params)
|
||||
|
||||
redirect_to budget_budget_categories_path(@budget_category.budget)
|
||||
if @budget_category.update(budget_category_params)
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to budget_budget_categories_path(@budget) }
|
||||
end
|
||||
else
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def budget_category_params
|
||||
params.require(:budget_category).permit(:budgeted_spending)
|
||||
params.require(:budget_category).permit(:budgeted_spending).tap do |params|
|
||||
params[:budgeted_spending] = params[:budgeted_spending].presence || 0
|
||||
end
|
||||
end
|
||||
|
||||
def set_budget
|
||||
start_date = Budget.param_to_date(params[:budget_month_year])
|
||||
@budget = Current.family.budgets.find_by(start_date: start_date)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,10 +6,6 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@next_budget = @budget.next_budget
|
||||
@previous_budget = @budget.previous_budget
|
||||
@latest_budget = Budget.find_or_bootstrap(Current.family)
|
||||
render layout: with_sidebar
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -21,12 +17,6 @@ class BudgetsController < ApplicationController
|
||||
redirect_to budget_budget_categories_path(@budget)
|
||||
end
|
||||
|
||||
def create
|
||||
start_date = Date.parse(budget_create_params[:start_date])
|
||||
@budget = Budget.find_or_bootstrap(Current.family, date: start_date)
|
||||
redirect_to budget_path(@budget)
|
||||
end
|
||||
|
||||
def picker
|
||||
render partial: "budgets/picker", locals: {
|
||||
family: Current.family,
|
||||
@@ -35,6 +25,7 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def budget_create_params
|
||||
params.require(:budget).permit(:start_date)
|
||||
end
|
||||
@@ -44,12 +35,13 @@ class BudgetsController < ApplicationController
|
||||
end
|
||||
|
||||
def set_budget
|
||||
@budget = Current.family.budgets.find(params[:id])
|
||||
@budget.sync_budget_categories
|
||||
start_date = Budget.param_to_date(params[:month_year])
|
||||
@budget = Budget.find_or_bootstrap(Current.family, start_date: start_date)
|
||||
raise ActiveRecord::RecordNotFound unless @budget
|
||||
end
|
||||
|
||||
def redirect_to_current_month_budget
|
||||
current_budget = Budget.find_or_bootstrap(Current.family)
|
||||
current_budget = Budget.find_or_bootstrap(Current.family, start_date: Date.current)
|
||||
redirect_to budget_path(current_budget)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
class CategoriesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category, only: %i[edit update destroy]
|
||||
before_action :set_categories, only: %i[update edit]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@categories = Current.family.categories.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
|
||||
17
app/controllers/chats_controller.rb
Normal file
17
app/controllers/chats_controller.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class ChatsController < ApplicationController
|
||||
def index
|
||||
Current.user.update!(current_chat: nil)
|
||||
@chats = Current.user.chats.ordered
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.create_with_defaults!
|
||||
|
||||
redirect_to chat_path(@chat)
|
||||
end
|
||||
|
||||
def show
|
||||
@chat = Current.user.chats.find(params[:id])
|
||||
Current.user.update!(current_chat: @chat)
|
||||
end
|
||||
end
|
||||
@@ -2,9 +2,8 @@ module AccountableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include ScrollFocusable
|
||||
include ScrollFocusable, Periodable
|
||||
|
||||
layout :with_sidebar
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
end
|
||||
@@ -24,6 +23,7 @@ module AccountableResource
|
||||
end
|
||||
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@@ -53,7 +53,7 @@ module AccountableResource
|
||||
private
|
||||
def set_link_token
|
||||
@us_link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
webhooks_url: plaid_us_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :us
|
||||
@@ -61,7 +61,7 @@ module AccountableResource
|
||||
|
||||
if Current.family.eu?
|
||||
@eu_link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
webhooks_url: plaid_eu_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :eu
|
||||
@@ -69,11 +69,16 @@ module AccountableResource
|
||||
end
|
||||
end
|
||||
|
||||
def webhooks_url
|
||||
def plaid_us_webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/"))
|
||||
base_url + "/webhooks/plaid"
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def plaid_eu_webhooks_url
|
||||
return webhooks_plaid_eu_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||
end
|
||||
|
||||
def accountable_type
|
||||
|
||||
@@ -4,11 +4,13 @@ module Authentication
|
||||
included do
|
||||
before_action :set_request_details
|
||||
before_action :authenticate_user!
|
||||
before_action :set_sentry_user
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def skip_authentication(**options)
|
||||
skip_before_action :authenticate_user!, **options
|
||||
skip_before_action :set_sentry_user, **options
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +28,13 @@ module Authentication
|
||||
end
|
||||
|
||||
def find_session_by_cookie
|
||||
Session.find_by(id: cookies.signed[:session_token])
|
||||
cookie_value = cookies.signed[:session_token]
|
||||
|
||||
if cookie_value.present?
|
||||
Session.find_by(id: cookie_value)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def create_session_for(user)
|
||||
@@ -43,4 +51,17 @@ module Authentication
|
||||
Current.user_agent = request.user_agent
|
||||
Current.ip_address = request.ip
|
||||
end
|
||||
|
||||
def set_sentry_user
|
||||
return unless defined?(Sentry) && ENV["SENTRY_DSN"].present?
|
||||
|
||||
if Current.user
|
||||
Sentry.set_user(
|
||||
id: Current.user.id,
|
||||
email: Current.user.email,
|
||||
username: Current.user.display_name,
|
||||
ip_address: Current.ip_address
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,8 @@ module AutoSync
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family.accounts.any?
|
||||
return false unless Current.family.accounts.active.any?
|
||||
|
||||
Current.family.last_synced_at.blank? ||
|
||||
Current.family.last_synced_at.to_date < Date.current
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
end
|
||||
end
|
||||
|
||||
13
app/controllers/concerns/breadcrumbable.rb
Normal file
13
app/controllers/concerns/breadcrumbable.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Breadcrumbable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_breadcrumbs
|
||||
end
|
||||
|
||||
private
|
||||
# The default, unless specific controller or action explicitly overrides
|
||||
def set_breadcrumbs
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ controller_name.titleize, nil ] ]
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,6 @@ module EntryableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
layout :with_sidebar
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
|
||||
14
app/controllers/concerns/periodable.rb
Normal file
14
app/controllers/concerns/periodable.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Periodable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_period
|
||||
end
|
||||
|
||||
private
|
||||
def set_period
|
||||
@period = Period.from_key(params[:period] || Current.user&.default_period)
|
||||
rescue Period::InvalidKeyError
|
||||
@period = Period.last_30_days
|
||||
end
|
||||
end
|
||||
@@ -1,11 +0,0 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
unless @article
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,11 +29,13 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:account_col_label,
|
||||
:qty_col_label,
|
||||
:ticker_col_label,
|
||||
:exchange_operating_mic_col_label,
|
||||
:price_col_label,
|
||||
:entity_type_col_label,
|
||||
:notes_col_label,
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
)
|
||||
end
|
||||
|
||||
@@ -4,6 +4,10 @@ class Import::ConfirmsController < ApplicationController
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
if @import.mapping_steps.empty?
|
||||
return redirect_to import_path(@import)
|
||||
end
|
||||
|
||||
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ class Import::RowsController < ApplicationController
|
||||
before_action :set_import_row
|
||||
|
||||
def update
|
||||
@row.assign_attributes(row_params)
|
||||
@row.save!(validate: false)
|
||||
@row.sync_mappings
|
||||
@row.update_and_sync(row_params)
|
||||
|
||||
redirect_to import_row_path(@row.import, @row)
|
||||
end
|
||||
|
||||
@@ -8,10 +8,11 @@ class Import::UploadsController < ApplicationController
|
||||
|
||||
def update
|
||||
if csv_valid?(csv_str)
|
||||
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
|
||||
@import.save!(validate: false)
|
||||
|
||||
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
|
||||
redirect_to import_configuration_path(@import, template_hint: true), notice: "CSV uploaded successfully."
|
||||
else
|
||||
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
|
||||
|
||||
@@ -29,10 +30,8 @@ class Import::UploadsController < ApplicationController
|
||||
end
|
||||
|
||||
def csv_valid?(str)
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true, col_sep: upload_params[:col_sep])
|
||||
csv = Import.parse_csv_str(str, col_sep: upload_params[:col_sep])
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, only: %i[show publish destroy]
|
||||
before_action :set_import, only: %i[show publish destroy revert apply_template]
|
||||
|
||||
def publish
|
||||
@import.publish_later
|
||||
@@ -10,7 +10,7 @@ class ImportsController < ApplicationController
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
|
||||
render layout: with_sidebar
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -18,7 +18,12 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
import = Current.family.imports.create! import_params
|
||||
account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
import = Current.family.imports.create!(
|
||||
type: import_params[:type],
|
||||
account: account,
|
||||
date_format: Current.family.date_format,
|
||||
)
|
||||
|
||||
redirect_to import_upload_path(import)
|
||||
end
|
||||
@@ -31,6 +36,20 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def revert
|
||||
@import.revert_later
|
||||
redirect_to imports_path, notice: "Import is reverting in the background."
|
||||
end
|
||||
|
||||
def apply_template
|
||||
if @import.suggested_template
|
||||
@import.apply_template!(@import.suggested_template)
|
||||
redirect_to import_configuration_path(@import), notice: "Template applied."
|
||||
else
|
||||
redirect_to import_configuration_path(@import), alert: "No template found, please manually configure your import."
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
23
app/controllers/messages_controller.rb
Normal file
23
app/controllers/messages_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class MessagesController < ApplicationController
|
||||
before_action :set_chat
|
||||
|
||||
def create
|
||||
@message = @chat.messages.create!(message_params.merge(role: "user"))
|
||||
|
||||
AiResponseJob.perform_later(@message)
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.html { redirect_to chat_path(@chat) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:content)
|
||||
end
|
||||
end
|
||||
56
app/controllers/mfa_controller.rb
Normal file
56
app/controllers/mfa_controller.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class MfaController < ApplicationController
|
||||
layout :determine_layout
|
||||
skip_authentication only: [ :verify, :verify_code ]
|
||||
|
||||
def new
|
||||
redirect_to root_path if Current.user.otp_required?
|
||||
Current.user.setup_mfa! unless Current.user.otp_secret.present?
|
||||
end
|
||||
|
||||
def create
|
||||
if Current.user.verify_otp?(params[:code])
|
||||
Current.user.enable_mfa!
|
||||
@backup_codes = Current.user.otp_backup_codes
|
||||
render :backup_codes
|
||||
else
|
||||
Current.user.disable_mfa!
|
||||
redirect_to new_mfa_path, alert: t(".invalid_code")
|
||||
end
|
||||
end
|
||||
|
||||
def verify
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to new_session_path
|
||||
end
|
||||
end
|
||||
|
||||
def verify_code
|
||||
@user = User.find_by(id: session[:mfa_user_id])
|
||||
|
||||
if @user&.verify_otp?(params[:code])
|
||||
session.delete(:mfa_user_id)
|
||||
@session = create_session_for(@user)
|
||||
redirect_to root_path
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_code")
|
||||
render :verify, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def disable
|
||||
Current.user.disable_mfa!
|
||||
redirect_to settings_security_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_layout
|
||||
if action_name.in?(%w[verify verify_code])
|
||||
"auth"
|
||||
else
|
||||
"settings"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,4 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
|
||||
@@ -1,40 +1,22 @@
|
||||
class PagesController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[early_access]
|
||||
layout :with_sidebar, except: %i[early_access]
|
||||
include Periodable
|
||||
|
||||
def dashboard
|
||||
@period = Period.from_param(params[:period])
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
snapshot_transactions = Current.family.snapshot_transactions
|
||||
@income_series = snapshot_transactions[:income_series]
|
||||
@spending_series = snapshot_transactions[:spending_series]
|
||||
@savings_rate_series = snapshot_transactions[:savings_rate_series]
|
||||
|
||||
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
||||
@top_spenders = snapshot_account_transactions[:top_spenders]
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
@top_savers = snapshot_account_transactions[:top_savers]
|
||||
|
||||
@accounts = Current.family.accounts.active
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.incomes_and_expenses.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
{ date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
|
||||
end
|
||||
@investing_series = TimeSeries.new(placeholder_series_data)
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
def changelog
|
||||
@release_notes = Provider::Github.new.fetch_latest_release_notes
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def feedback
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def early_access
|
||||
|
||||
@@ -21,7 +21,10 @@ class PlaidItemsController < ApplicationController
|
||||
@plaid_item.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
class SecuritiesController < ApplicationController
|
||||
def index
|
||||
query = params[:q]
|
||||
return render json: [] if query.blank? || query.length < 2 || query.length > 100
|
||||
|
||||
@securities = Security.search({
|
||||
search: query,
|
||||
@securities = Security.search_provider({
|
||||
search: params[:q],
|
||||
country: params[:country_code] == "US" ? "US" : nil
|
||||
})
|
||||
end
|
||||
|
||||
@@ -9,8 +9,13 @@ class SessionsController < ApplicationController
|
||||
|
||||
def create
|
||||
if user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
if user.otp_required?
|
||||
session[:mfa_user_id] = user.id
|
||||
redirect_to verify_mfa_path
|
||||
else
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
end
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_credentials")
|
||||
render :new, status: :unprocessable_entity
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::BillingsController < SettingsController
|
||||
class Settings::BillingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
class Settings::HostingsController < SettingsController
|
||||
class Settings::HostingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
before_action :raise_if_not_self_hosted
|
||||
before_action :ensure_admin, only: :clear_cache
|
||||
|
||||
def show
|
||||
@synth_usage = Current.family.synth_usage
|
||||
@@ -36,6 +39,11 @@ class Settings::HostingsController < SettingsController
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
DataCacheClearJob.perform_later(Current.family)
|
||||
redirect_to settings_hosting_path, notice: t(".cache_cleared")
|
||||
end
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :require_email_confirmation, :synth_api_key)
|
||||
@@ -44,4 +52,8 @@ class Settings::HostingsController < SettingsController
|
||||
def raise_if_not_self_hosted
|
||||
raise "Settings not available on non-self-hosted instance" unless self_hosted?
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::PreferencesController < SettingsController
|
||||
class Settings::PreferencesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Settings::ProfilesController < SettingsController
|
||||
class Settings::ProfilesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
@users = Current.family.users.order(:created_at)
|
||||
|
||||
6
app/controllers/settings/securities_controller.rb
Normal file
6
app/controllers/settings/securities_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class Settings::SecuritiesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class SettingsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class SubscriptionsController < ApplicationController
|
||||
before_action :redirect_to_root_if_self_hosted
|
||||
|
||||
def new
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = stripe_client.v1.customers.create(
|
||||
@@ -44,4 +46,8 @@ class SubscriptionsController < ApplicationController
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
end
|
||||
|
||||
def redirect_to_root_if_self_hosted
|
||||
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
class TagsController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_tag, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable
|
||||
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
search_query = Current.family.transactions.search(@q)
|
||||
transactions_query = Current.family.transactions.active.search(@q)
|
||||
|
||||
set_focused_record(search_query, params[:focused_record_id], default_per_page: 50)
|
||||
set_focused_record(transactions_query, params[:focused_record_id], default_per_page: 50)
|
||||
|
||||
@pagy, @transaction_entries = pagy(
|
||||
search_query.reverse_chronological.preload(
|
||||
:account,
|
||||
entryable: [
|
||||
:category, :merchant, :tags,
|
||||
:transfer_as_inflow,
|
||||
transfer_as_outflow: {
|
||||
inflow_transaction: { entry: :account },
|
||||
outflow_transaction: { entry: :account }
|
||||
}
|
||||
]
|
||||
),
|
||||
@pagy, @transactions = pagy(
|
||||
transactions_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
).reverse_chronological,
|
||||
limit: params[:per_page].presence || default_params[:per_page],
|
||||
params: ->(params) { params.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
@transfers = @transaction_entries.map { |entry| entry.entryable.transfer_as_outflow }.compact
|
||||
@totals = search_query.stats(Current.family.currency)
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
@@ -57,6 +49,7 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class TransfersController < ApplicationController
|
||||
layout :with_sidebar
|
||||
|
||||
before_action :set_transfer, only: %i[destroy show update]
|
||||
|
||||
def new
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class UsersController < ApplicationController
|
||||
before_action :set_user
|
||||
before_action :ensure_admin, only: :reset
|
||||
|
||||
def update
|
||||
@user = Current.user
|
||||
@@ -19,10 +20,18 @@ class UsersController < ApplicationController
|
||||
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
|
||||
@user.profile_image.purge if should_purge_profile_image?
|
||||
|
||||
handle_redirect(t(".success"))
|
||||
respond_to do |format|
|
||||
format.html { handle_redirect(t(".success")) }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reset
|
||||
FamilyResetJob.perform_later(Current.family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @user.deactivate
|
||||
Current.session.destroy
|
||||
@@ -57,7 +66,7 @@ class UsersController < ApplicationController
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ]
|
||||
)
|
||||
end
|
||||
@@ -65,4 +74,8 @@ class UsersController < ApplicationController
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,11 +6,29 @@ class WebhooksController < ApplicationController
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
Provider::Plaid.process_webhook(webhook_body)
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||
end
|
||||
|
||||
@@ -33,10 +51,12 @@ class WebhooksController < ApplicationController
|
||||
Rails.logger.info "Unhandled event type: #{event.type}"
|
||||
end
|
||||
|
||||
rescue JSON::ParserError
|
||||
rescue JSON::ParserError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid payload" }, status: :bad_request
|
||||
return
|
||||
rescue Stripe::SignatureVerificationError
|
||||
rescue Stripe::SignatureVerificationError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid signature" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
module Account::EntriesHelper
|
||||
def permitted_entryable_partial_path(entry, relative_partial_path)
|
||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
def entries_by_date(entries, totals: false)
|
||||
transfer_groups = entries.group_by do |entry|
|
||||
# Only check for transfer if it's a transaction
|
||||
next nil unless entry.entryable_type == "Account::Transaction"
|
||||
entry.entryable.transfer&.id
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
# For a more intuitive UX, we do not want to show the same transfer twice in the list
|
||||
deduped_entries = transfer_groups.flat_map do |transfer_id, grouped_entries|
|
||||
if transfer_id.nil? || grouped_entries.size == 1
|
||||
grouped_entries
|
||||
else
|
||||
grouped_entries.reject do |e|
|
||||
e.entryable_type == "Account::Transaction" &&
|
||||
e.entryable.transfer_as_inflow.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def entries_by_date(entries, transfers: [], selectable: true, totals: false)
|
||||
entries.group_by(&:date).map do |date, grouped_entries|
|
||||
deduped_entries.group_by(&:date).map do |date, grouped_entries|
|
||||
content = capture do
|
||||
yield [ grouped_entries, transfers.select { |t| t.outflow_transaction.entry.date == date } ]
|
||||
yield grouped_entries
|
||||
end
|
||||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
@@ -28,11 +37,4 @@ module Account::EntriesHelper
|
||||
entry.display_name
|
||||
].join(" • ")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation trade]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module Account::HoldingsHelper
|
||||
def brokerage_cash_holding(account)
|
||||
currency = Money::Currency.new(account.currency)
|
||||
|
||||
account.holdings.build \
|
||||
date: Date.current,
|
||||
qty: account.cash_balance,
|
||||
price: 1,
|
||||
amount: account.cash_balance,
|
||||
currency: currency.iso_code,
|
||||
security: Security.new(ticker: currency.iso_code, name: currency.name)
|
||||
end
|
||||
end
|
||||
@@ -1,87 +1,6 @@
|
||||
module AccountsHelper
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
|
||||
|
||||
days_apart = (end_date - start_date).to_i
|
||||
|
||||
# Handle specific cases
|
||||
if start_date == Date.current.beginning_of_week && end_date == Date.current
|
||||
return "Current Week to Date (CWD)"
|
||||
elsif start_date == Date.current.beginning_of_month && end_date == Date.current
|
||||
return "Current Month to Date (MTD)"
|
||||
elsif start_date == Date.current.beginning_of_quarter && end_date == Date.current
|
||||
return "Current Quarter to Date (CQD)"
|
||||
elsif start_date == Date.current.beginning_of_year && end_date == Date.current
|
||||
return "Current Year to Date (YTD)"
|
||||
end
|
||||
|
||||
# Default cases
|
||||
case days_apart
|
||||
when 1
|
||||
"vs. yesterday"
|
||||
when 7
|
||||
"vs. last week"
|
||||
when 30, 31
|
||||
"vs. last month"
|
||||
when 90
|
||||
"vs. last 3 months"
|
||||
when 365, 366
|
||||
"vs. last year"
|
||||
else
|
||||
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_card(title:, &block)
|
||||
content = capture(&block)
|
||||
render "accounts/summary_card", title: title, content: content
|
||||
end
|
||||
|
||||
def to_accountable_title(accountable)
|
||||
accountable.model_name.human
|
||||
end
|
||||
|
||||
def accountable_text_class(accountable_type)
|
||||
class_mapping(accountable_type)[:text]
|
||||
end
|
||||
|
||||
def accountable_fill_class(accountable_type)
|
||||
class_mapping(accountable_type)[:fill]
|
||||
end
|
||||
|
||||
def accountable_bg_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg]
|
||||
end
|
||||
|
||||
def accountable_bg_transparent_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg_transparent]
|
||||
end
|
||||
|
||||
def accountable_color(accountable_type)
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,10 @@ module ApplicationHelper
|
||||
content_for(:header_title) { page_title }
|
||||
end
|
||||
|
||||
def header_description(page_description)
|
||||
content_for(:header_description) { page_description }
|
||||
end
|
||||
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
@@ -68,18 +72,8 @@ module ApplicationHelper
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
classes = [
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-gray-500",
|
||||
(is_current ? "bg-white text-gray-900 shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
|
||||
].compact.join(" ")
|
||||
|
||||
link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
|
||||
concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
|
||||
concat(name)
|
||||
end
|
||||
def page_active?(path)
|
||||
current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
end
|
||||
|
||||
def mixed_hex_styles(hex)
|
||||
@@ -101,24 +95,6 @@ module ApplicationHelper
|
||||
uri.relative? ? uri.path : root_path
|
||||
end
|
||||
|
||||
def trend_styles(trend)
|
||||
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||
return fallback if trend.nil? || trend.direction.flat?
|
||||
|
||||
bg_class, text_class, symbol, icon = case trend.direction
|
||||
when "up"
|
||||
trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
||||
when "down"
|
||||
trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
|
||||
when "flat"
|
||||
[ "bg-gray-500/5", "text-gray-500", "", "minus" ]
|
||||
else
|
||||
raise ArgumentError, "Invalid trend direction: #{trend.direction}"
|
||||
end
|
||||
|
||||
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
|
||||
end
|
||||
|
||||
# Wrapper around I18n.l to support custom date formats
|
||||
def format_date(object, format = :default, options = {})
|
||||
date = object.to_date
|
||||
@@ -135,22 +111,12 @@ module ApplicationHelper
|
||||
def format_money(number_or_money, options = {})
|
||||
return nil unless number_or_money
|
||||
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
number_to_currency(money.amount, options)
|
||||
end
|
||||
|
||||
def format_money_without_symbol(number_or_money, options = {})
|
||||
return nil unless number_or_money
|
||||
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
Money.new(number_or_money).format(options)
|
||||
end
|
||||
|
||||
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false)
|
||||
collection.group_by(&:currency)
|
||||
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
|
||||
.transform_values { |item| calculate_total(item, money_method, negate) }
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
@@ -162,4 +128,11 @@ module ApplicationHelper
|
||||
|
||||
cookies[:admin] == "true"
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
total = items.sum(&money_method)
|
||||
negate ? -total : total
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module EmailConfirmationsHelper
|
||||
end
|
||||
@@ -11,25 +11,16 @@ module FormsHelper
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
|
||||
form.label name, for: form.field_id(name, value), class: "group has-disabled:cursor-not-allowed" do
|
||||
concat radio_tab_contents(label:, icon:)
|
||||
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [
|
||||
%w[CWD current_week], # Current Week to Date
|
||||
%w[7D last_7_days],
|
||||
%w[MTD current_month], # Month to Date
|
||||
%w[1M last_30_days],
|
||||
%w[CQD current_quarter], # Quarter to Date
|
||||
%w[3M last_90_days],
|
||||
%w[YTD current_year], # Year to Date
|
||||
%w[1Y last_365_days]
|
||||
]
|
||||
def period_select(form:, selected:, classes: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
|
||||
periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
|
||||
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
|
||||
@@ -39,9 +30,9 @@ end
|
||||
|
||||
private
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-white group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
concat tag.span(label, class: "group-has-[:checked]:font-semibold")
|
||||
concat tag.span(label, class: "group-has-checked:font-semibold")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module ImpersonationSessionsHelper
|
||||
end
|
||||
@@ -20,6 +20,7 @@ module ImportsHelper
|
||||
notes: "Notes",
|
||||
qty: "Quantity",
|
||||
ticker: "Ticker",
|
||||
exchange: "Exchange",
|
||||
price: "Price",
|
||||
entity_type: "Type"
|
||||
}[key]
|
||||
@@ -45,7 +46,7 @@ module ImportsHelper
|
||||
end
|
||||
|
||||
def cell_class(row, field)
|
||||
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
|
||||
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-subdued"
|
||||
|
||||
row.valid? # populate errors
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module InvitationsHelper
|
||||
end
|
||||
@@ -7,8 +7,8 @@ module MenusHelper
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal)
|
||||
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
|
||||
link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
@@ -26,7 +26,7 @@ module MenusHelper
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
20
app/helpers/mfa_helper.rb
Normal file
20
app/helpers/mfa_helper.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module MfaHelper
|
||||
def generate_mfa_qr_code(provisioning_uri)
|
||||
qr_code = RQRCode::QRCode.new(provisioning_uri).as_svg(
|
||||
color: "141414",
|
||||
module_size: 4,
|
||||
standalone: true,
|
||||
use_path: true,
|
||||
svg_attributes: {
|
||||
width: "240",
|
||||
height: "240",
|
||||
viewBox: "0 0 65 65"
|
||||
}
|
||||
)
|
||||
|
||||
# Whitelist specific SVG attributes and elements that we know are safe
|
||||
sanitize qr_code,
|
||||
tags: %w[svg g path rect],
|
||||
attributes: %w[viewBox height width fill stroke stroke-width d x y class]
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module PagesHelper
|
||||
end
|
||||
9
app/helpers/plaid_helper.rb
Normal file
9
app/helpers/plaid_helper.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module PlaidHelper
|
||||
def plaid_webhooks_url(region = :us)
|
||||
if Rails.env.production?
|
||||
region.to_sym == :eu ? webhooks_plaid_eu_url : webhooks_plaid_url
|
||||
else
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid#{region.to_sym == :eu ? '_eu' : ''}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module PropertiesHelper
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module SecuritiesHelper
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module Settings::BillingHelper
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module Settings::HostingHelper
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user