mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Compare commits
387 commits
Author | SHA1 | Date | |
---|---|---|---|
|
e8eb32d2ae | ||
|
ab6fdbbb68 | ||
|
d5b147f2cd | ||
|
8c97c9d31a | ||
|
3eea5a9891 | ||
|
52333e3fa6 | ||
|
89cc64418e | ||
|
c1d98fe73b | ||
|
9110ab27d2 | ||
|
afbfb474c2 | ||
|
fe8aebe920 | ||
|
188126d402 | ||
|
1a2d973f4b | ||
|
a91441bcc8 | ||
|
8d0c1c5a56 | ||
|
e848db2aa1 | ||
|
e7043328e4 | ||
|
d77c683d59 | ||
|
aaf24e1309 | ||
|
f9b131a5db | ||
|
a63d36d10c | ||
|
662f2c04ce | ||
|
ba7e8d3893 | ||
|
65329b333d | ||
|
0974783a6b | ||
|
48f792c20e | ||
|
869462a9a5 | ||
|
e4a82d85e8 | ||
|
18148acd69 | ||
|
8db95623cf | ||
|
e60b5df442 | ||
|
f3ab4a27ee | ||
|
4b50acff2b | ||
|
637d630388 | ||
|
72a0f87a9c | ||
|
cea49d5038 | ||
|
c0617f74cd | ||
|
653decbc0b | ||
|
1cfa6cfca8 | ||
|
e809335a47 | ||
|
956008acbf | ||
|
8b56262573 | ||
|
615912040c | ||
|
fcf14f5f27 | ||
|
63d8114b05 | ||
|
c003e8c6ed | ||
|
ab1c17ea14 | ||
|
1aae00f586 | ||
|
7aca5a2277 | ||
|
8296e10246 | ||
|
9336719242 | ||
|
cba0bdf0e2 | ||
|
404066eaa1 | ||
|
94202b2a6b | ||
|
4d3c710291 | ||
|
b65e4d376e | ||
|
fc921c0cd2 | ||
|
b803ddac96 | ||
|
13a64a1694 | ||
|
b900cc9272 | ||
|
dc505cfcff | ||
|
96ac1dd45f | ||
|
5a38159c28 | ||
|
38cad49d6c | ||
|
968cd7981a | ||
|
6d9bb7f0eb | ||
|
a5f1677f60 | ||
|
84b2426e54 | ||
|
cdad31812a | ||
|
5a4c955522 | ||
|
0d62e60da1 | ||
|
10ce2c8e23 | ||
|
dab693d74f | ||
|
019a0d873c | ||
|
9fabcf4c72 | ||
|
4044a8519f | ||
|
9afc50a146 | ||
|
0063921de9 | ||
|
1d2e7fcae0 | ||
|
9f6c9b4057 | ||
|
d05946596e | ||
|
a76cc2dff8 | ||
|
870b543640 | ||
|
1f8a994b4e | ||
|
ee9fe1b62d | ||
|
4f5068e7e5 | ||
|
e7f1506728 | ||
|
6f67827f14 | ||
|
3cc88f3e98 | ||
|
6dae236fe0 | ||
|
07ca33f2f4 | ||
|
fe33fe086a | ||
|
bf2426ce82 | ||
|
6e202bd7ec | ||
|
e1b81ef879 | ||
|
151bf25d27 | ||
|
854a21993a | ||
|
d21e385962 | ||
|
c701755b02 | ||
|
43a403e431 | ||
|
03e0230e99 | ||
|
ffc5f844b2 | ||
|
5125411822 | ||
|
aecb5aafd8 | ||
|
6935ffa3d1 | ||
|
03a146222d | ||
|
5c82af0e8c | ||
|
5cfb4addbd | ||
|
fd65b5a747 | ||
|
6d4509fbe6 | ||
|
c7d9c94489 | ||
|
fcdc42760d | ||
|
19804d2b05 | ||
|
fe24117c50 | ||
|
e4ee06c9f6 | ||
|
857436d894 | ||
|
092350f1f8 | ||
|
b719a8b80d | ||
|
a71b62575c | ||
|
2fbd6cbc5d | ||
|
a7438e5c78 | ||
|
fd3b583737 | ||
|
34b3e4ae20 | ||
|
8070986763 | ||
|
3d2213b760 | ||
|
cc9a75ee28 | ||
|
443b834b46 | ||
|
868d4ede6e | ||
|
caf35701ef | ||
|
94a807c3c9 | ||
|
dd605a577e | ||
|
137219c121 | ||
|
ab5bce3462 | ||
|
a262a749fe | ||
|
7e7ae31216 | ||
|
efdd03cfe7 | ||
|
1b4577e21e | ||
|
e569ad0a8c | ||
|
6f68d66eda | ||
|
e26e5c5aec | ||
|
f82f77466a | ||
|
74c7b0941d | ||
|
29a8ac9d8a | ||
|
9f13b5bb83 | ||
|
10f255a9a9 | ||
|
b8903d0980 | ||
|
35d1447494 | ||
|
6dc1d22672 | ||
|
6917cecf33 | ||
|
5efa8268f6 | ||
|
9155e737b2 | ||
|
a565343102 | ||
|
10dd9e061a | ||
|
9793cc74f9 | ||
|
3f48992aea | ||
|
bcb47a9d29 | ||
|
bebe7b40d6 | ||
|
050d5ebaad | ||
|
30d3eef67f | ||
|
df8e22afe9 | ||
|
0fb689290a | ||
|
9e6e4b1ce6 | ||
|
908b3e2489 | ||
|
a268c5a563 | ||
|
0006b6f6ca | ||
|
48a07d6158 | ||
|
5d798fe0a0 | ||
|
f07c41821e | ||
|
7605b0221d | ||
|
ab2cec55e7 | ||
|
03e3899541 | ||
|
3371243a00 | ||
|
d8e058d7c6 | ||
|
867318cbc1 | ||
|
1e5edd9f2f | ||
|
42207e487e | ||
|
ea1b6f2bd8 | ||
|
2707a40a2a | ||
|
8b857e9c8a | ||
|
a07e9d40a3 | ||
|
71be2a04ad | ||
|
a67f36bf64 | ||
|
628d266980 | ||
|
64d5a73eb7 | ||
|
2b2dfd03e0 | ||
|
fb7107d614 | ||
|
c26a7dd2dd | ||
|
5da4bb6dc3 | ||
|
8c10e87387 | ||
|
60c3a04a48 | ||
|
c0267d5665 | ||
|
0fdeebceb1 | ||
|
2e0794b8e1 | ||
|
2000f05453 | ||
|
470b753833 | ||
|
fea1baeb1e | ||
|
c022e862aa | ||
|
dcc43cb253 | ||
|
6e4d35d6ae | ||
|
98644f1b87 | ||
|
fc9961d420 | ||
|
441f436187 | ||
|
bc7e32deab | ||
|
a7a29b4780 | ||
|
1e1ed5ca45 | ||
|
793a5d2502 | ||
|
84eb2c90d4 | ||
|
1210a8f3a3 | ||
|
752835f492 | ||
|
a1d64d6c2e | ||
|
c24ae1762f | ||
|
adc5bf58d7 | ||
|
0c79b335f1 | ||
|
be0d51057d | ||
|
cf72f1a387 | ||
|
0946a1497a | ||
|
aebbb9a3c1 | ||
|
194dad702d | ||
|
17fa5413f6 | ||
|
38b6e30bea | ||
|
a51c4d2cba | ||
|
79b4a3769b | ||
|
9a291edbc8 | ||
|
d266b6a35e | ||
|
d8cf35eca7 | ||
|
23adfb2ef0 | ||
|
ed8011f792 | ||
|
90a9546f32 | ||
|
1aafed5f8b | ||
|
47017a6432 | ||
|
ae41b3de46 | ||
|
d8e34cf791 | ||
|
e70295394a | ||
|
8019a0b33c | ||
|
fe578c8f08 | ||
|
9be0553b18 | ||
|
218040584d | ||
|
90d491906e | ||
|
3370ae260d | ||
|
341a800b65 | ||
|
e6b69c1f5c | ||
|
71bc51ca15 | ||
|
ce83418f0b | ||
|
210b89cd17 | ||
|
47aeaf8cea | ||
|
db34f6d7a2 | ||
|
b6cf6198f4 | ||
|
a7dfafc907 | ||
|
9b33e50b89 | ||
|
1b1add38f2 | ||
|
08091d24f9 | ||
|
21623eeb2d | ||
|
bcfbc4b324 | ||
|
04ee1e73be | ||
|
79243822bd | ||
|
297a695d0f | ||
|
8edd7ecef0 | ||
|
c88fe2e3b2 | ||
|
8cf077f28d | ||
|
8985592967 | ||
|
d22a16d8de | ||
|
77a2d6a048 | ||
|
65e1bc6edd | ||
|
6a21f26d2d | ||
|
298e150f43 | ||
|
e657c40d19 | ||
|
f181ba941f | ||
|
5cb2183bdf | ||
|
6f70a54d6f | ||
|
f235697178 | ||
|
e517127062 | ||
|
b06fd1edf0 | ||
|
1e01840fee | ||
|
48c8499b70 | ||
|
8648f11413 | ||
|
9fa3698823 | ||
|
88a6373e84 | ||
|
52d170e36c | ||
|
2bc3887262 | ||
|
0da057b792 | ||
|
0e1c902b63 | ||
|
bb0f0239fb | ||
|
e6c1c5f368 | ||
|
02bbeeaec5 | ||
|
1649e991b4 | ||
|
7096eefa2b | ||
|
4c72231312 | ||
|
d86ccd36b6 | ||
|
02bfa9f251 | ||
|
f2020a816a | ||
|
5f2a031d4c | ||
|
939244bd3e | ||
|
0a17b84566 | ||
|
5cf758bd03 | ||
|
6331788b33 | ||
|
83bee295ca | ||
|
dc17a0a298 | ||
|
29f445d75e | ||
|
9fadfe074b | ||
|
2a505b000c | ||
|
36a66baf00 | ||
|
67716f3006 | ||
|
1061aacb0f | ||
|
2f6b11c18f | ||
|
8e6b81af77 | ||
|
9f062de6b4 | ||
|
3dfdd0aea5 | ||
|
86431e79a3 | ||
|
54f5a44a60 | ||
|
b41897b5e5 | ||
|
f8d64561cf | ||
|
5a8074c7ee | ||
|
9122eafd31 | ||
|
19cc63c8f4 | ||
|
a7db914005 | ||
|
06468a05b1 | ||
|
087dd720c1 | ||
|
78baf2b327 | ||
|
56203b04d3 | ||
|
f65b93a352 | ||
|
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 |
1202 changed files with 46622 additions and 13427 deletions
53
.cursor/rules/cursor_rules.mdc
Normal file
53
.cursor/rules/cursor_rules.mdc
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness.
|
||||||
|
globs: .cursor/rules/*.mdc
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Required Rule Structure:**
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
description: Clear, one-line description of what the rule enforces
|
||||||
|
globs: path/to/files/*.ext, other/path/**/*
|
||||||
|
alwaysApply: boolean
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Main Points in Bold**
|
||||||
|
- Sub-points with details
|
||||||
|
- Examples and explanations
|
||||||
|
```
|
||||||
|
|
||||||
|
- **File References:**
|
||||||
|
- Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files
|
||||||
|
- Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references
|
||||||
|
- Example: [schema.prisma](mdc:prisma/schema.prisma) for code references
|
||||||
|
|
||||||
|
- **Code Examples:**
|
||||||
|
- Use language-specific code blocks
|
||||||
|
```typescript
|
||||||
|
// ✅ DO: Show good examples
|
||||||
|
const goodExample = true;
|
||||||
|
|
||||||
|
// ❌ DON'T: Show anti-patterns
|
||||||
|
const badExample = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Rule Content Guidelines:**
|
||||||
|
- Start with high-level overview
|
||||||
|
- Include specific, actionable requirements
|
||||||
|
- Show examples of correct implementation
|
||||||
|
- Reference existing code when possible
|
||||||
|
- Keep rules DRY by referencing other rules
|
||||||
|
|
||||||
|
- **Rule Maintenance:**
|
||||||
|
- Update rules when new patterns emerge
|
||||||
|
- Add examples from actual codebase
|
||||||
|
- Remove outdated patterns
|
||||||
|
- Cross-reference related rules
|
||||||
|
|
||||||
|
- **Best Practices:**
|
||||||
|
- Use bullet points for clarity
|
||||||
|
- Keep descriptions concise
|
||||||
|
- Include both DO and DON'T examples
|
||||||
|
- Reference actual code over theoretical examples
|
||||||
|
- Use consistent formatting across rules
|
21
.cursor/rules/general-rules.mdc
Normal file
21
.cursor/rules/general-rules.mdc
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
description: Miscellaneous rules to get the AI to behave
|
||||||
|
globs: *
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# General rules for AI
|
||||||
|
|
||||||
|
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||||
|
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||||
|
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||||
|
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||||
|
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||||
|
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||||
|
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||||
|
|
||||||
|
## Prohibited actions
|
||||||
|
|
||||||
|
- Do not run `rails server` in your responses.
|
||||||
|
- Do not run `touch tmp/restart.txt`
|
||||||
|
- Do not run `rails credentials`
|
||||||
|
- Do not automatically run migrations
|
|
@ -1,14 +1,9 @@
|
||||||
---
|
---
|
||||||
description: This rule explains the project's tech stack and code conventions
|
description:
|
||||||
globs: *
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
This rule serves as high-level documentation for how the Maybe codebase is structured.
|
This rule serves as high-level documentation for how you should write code for the Maybe codebase.
|
||||||
|
|
||||||
## 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
|
## Project Tech Stack
|
||||||
|
|
||||||
|
@ -18,8 +13,9 @@ This rule serves as high-level documentation for how the Maybe codebase is struc
|
||||||
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
- Hotwire Turbo/Stimulus for SPA-like UI/UX
|
||||||
- TailwindCSS for styles
|
- TailwindCSS for styles
|
||||||
- Lucide Icons for icons
|
- Lucide Icons for icons
|
||||||
|
- OpenAI for AI chat
|
||||||
- Database: PostgreSQL
|
- Database: PostgreSQL
|
||||||
- Jobs: GoodJob
|
- Jobs: Sidekiq + Redis
|
||||||
- External
|
- External
|
||||||
- Payments: Stripe
|
- Payments: Stripe
|
||||||
- User bank data syncing: Plaid
|
- User bank data syncing: Plaid
|
||||||
|
@ -46,39 +42,32 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
|
||||||
- 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 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`.
|
- 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
|
### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions
|
||||||
|
|
||||||
- When possible, leverage Turbo frames over complex, JS-driven client-side solutions
|
- Native HTML is always preferred over JS-based components
|
||||||
- When writing a client-side solution, use Stimulus controllers and keep it simple!
|
- Example 1: Use `<dialog>` element for modals instead of creating a custom component
|
||||||
- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display
|
- Example 2: Use `<details><summary>...</summary></details>` for disclosures rather than custom components
|
||||||
- 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.
|
- Leverage Turbo frames to break up the page over JS-driven client-side solutions
|
||||||
|
- Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout
|
||||||
### Convention 4: Sacrifice performance, optimize for simplicitly and clarity
|
- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state.
|
||||||
|
- Use Turbo streams to enhance functionality, but do not solely depend on it
|
||||||
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.
|
- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only
|
||||||
|
- Keep client-side code for where it truly shines. For example, @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.
|
||||||
- Focus on good OOP design first, performance second
|
- Always use the `icon` helper in [application_helper.rb](mdc:app/helpers/application_helper.rb) for icons. NEVER use `lucide_icon` helper directly.
|
||||||
- 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.
|
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
|
### Convention 4: Optimize for simplicitly and clarity
|
||||||
|
|
||||||
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
All code should maximize readability and simplicity.
|
||||||
|
|
||||||
- Always use Minitest and fixtures for testing.
|
- Prioritize good OOP domain design over performance
|
||||||
- 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.
|
- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff.
|
||||||
- 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)
|
- Example 1: be mindful of loading large data payloads in global layouts
|
||||||
|
- Example 2: Avoid N+1 queries
|
||||||
|
|
||||||
### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB
|
### Convention 5: 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
|
- 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.
|
- 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
|
- Complex validations and business logic should remain in ActiveRecord
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
description: This rule explains the system architecture and data flow of the Rails app
|
description: This rule explains the system architecture and data flow of the Rails app
|
||||||
globs: *
|
globs: *
|
||||||
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
This file outlines how the codebase is structured and how data flows through the app.
|
This file outlines how the codebase is structured and how data flows through the app.
|
||||||
|
@ -54,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco
|
||||||
|
|
||||||
### Account Holdings
|
### 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`.
|
An account [holding.rb](mdc:app/models/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).
|
For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_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 [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb).
|
||||||
|
|
||||||
### Account Entries
|
### 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`.
|
An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/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:
|
The `amount` of an [entry.rb](mdc:app/models/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 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 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
|
- 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:
|
There are 3 entry types, defined as [entryable.rb](mdc:app/models/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.
|
- `Valuation` - an account [valuation.rb](mdc:app/models/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 `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".
|
- `Transaction` - an account [transaction.rb](mdc:app/models/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`.
|
- `Trade` - an account [trade.rb](mdc:app/models/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
|
### 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:
|
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/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||||
|
|
||||||
- Must be from different accounts
|
- Must be from different accounts
|
||||||
- Must be within 4 days of each other
|
- Must be within 4 days of each other
|
||||||
|
@ -110,14 +111,14 @@ Below are brief descriptions of each type of sync in more detail.
|
||||||
|
|
||||||
### Account Syncs
|
### 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:
|
The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks:
|
||||||
|
|
||||||
- Auto-matches transfer records for the account
|
- Auto-matches transfer records for the account
|
||||||
- Calculates holdings and balances for the account
|
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||||
- Enriches transaction data
|
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||||
- Converts account balances that are not in the family's preferred currency to the preferred currency
|
- Enriches transaction data if enabled by user
|
||||||
|
|
||||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated.
|
||||||
|
|
||||||
### Plaid Item Syncs
|
### Plaid Item Syncs
|
||||||
|
|
||||||
|
@ -125,10 +126,124 @@ 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
|
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
|
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.
|
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/entry.rb), the internal Maybe representations of the data.
|
||||||
|
|
||||||
### Family Syncs
|
### 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.
|
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.
|
||||||
|
|
||||||
|
## Data Providers
|
||||||
|
|
||||||
|
The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured.
|
||||||
|
|
||||||
|
Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys:
|
||||||
|
|
||||||
|
There are two types of 3rd party data in the Maybe app:
|
||||||
|
|
||||||
|
1. "Concept" data
|
||||||
|
2. One-off data
|
||||||
|
|
||||||
|
### "Concept" data
|
||||||
|
|
||||||
|
Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept".
|
||||||
|
|
||||||
|
Each "concept" has an interface defined in the `app/models/provider/concepts` directory.
|
||||||
|
|
||||||
|
```
|
||||||
|
app/models/
|
||||||
|
exchange_rate/
|
||||||
|
provided.rb # <- Responsible for selecting the concept provider from the registry
|
||||||
|
provider.rb # <- Base provider class
|
||||||
|
provider/
|
||||||
|
registry.rb <- Defines available providers by concept
|
||||||
|
concepts/
|
||||||
|
exchange_rate.rb <- defines the interface required for the exchange rate concept
|
||||||
|
synth.rb # <- Concrete provider implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-off data
|
||||||
|
|
||||||
|
For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
class SomeModel < Application
|
||||||
|
def synth_usage
|
||||||
|
Provider::Registry.get_provider(:synth)&.usage
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## "Provided" Concerns
|
||||||
|
|
||||||
|
In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for:
|
||||||
|
|
||||||
|
- Choosing the provider to use for this "concept"
|
||||||
|
- Providing convenience methods on the model for accessing data
|
||||||
|
|
||||||
|
For example, [exchange_rate.rb](mdc:app/models/exchange_rate.rb) has a [provided.rb](mdc:app/models/exchange_rate/provided.rb) concern with the following convenience methods:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
module ExchangeRate::Provided
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def provider
|
||||||
|
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||||
|
registry.get_provider(:synth)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||||
|
# Implementation
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_provider_rates(from:, to:, start_date:, end_date: Date.current)
|
||||||
|
# Implementation
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
This exposes a generic access pattern where the caller does not care _which_ provider has been chosen for the concept of exchange rates and can get a predictable response:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
def access_patterns_example
|
||||||
|
# Call exchange rate provider directly
|
||||||
|
ExchangeRate.provider.fetch_exchange_rate(from: "USD", to: "CAD", date: Date.current)
|
||||||
|
|
||||||
|
# Call convenience method
|
||||||
|
ExchangeRate.sync_provider_rates(from: "USD", to: "CAD", start_date: 2.days.ago.to_date)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concrete provider implementations
|
||||||
|
|
||||||
|
Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
class ConcreteProvider < Provider
|
||||||
|
def fetch_some_data
|
||||||
|
with_provider_response do
|
||||||
|
ExampleData.new(
|
||||||
|
example: "data"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
class ConcreteProvider < Provider
|
||||||
|
def fetch_some_data
|
||||||
|
with_provider_response do
|
||||||
|
data = nil
|
||||||
|
|
||||||
|
# Raise an error if data cannot be returned
|
||||||
|
raise ProviderError.new("Could not find the data you need") if data.nil?
|
||||||
|
|
||||||
|
data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
72
.cursor/rules/self_improve.mdc
Normal file
72
.cursor/rules/self_improve.mdc
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices.
|
||||||
|
globs: **/*
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
- **Rule Improvement Triggers:**
|
||||||
|
- New code patterns not covered by existing rules
|
||||||
|
- Repeated similar implementations across files
|
||||||
|
- Common error patterns that could be prevented
|
||||||
|
- New libraries or tools being used consistently
|
||||||
|
- Emerging best practices in the codebase
|
||||||
|
|
||||||
|
- **Analysis Process:**
|
||||||
|
- Compare new code with existing rules
|
||||||
|
- Identify patterns that should be standardized
|
||||||
|
- Look for references to external documentation
|
||||||
|
- Check for consistent error handling patterns
|
||||||
|
- Monitor test patterns and coverage
|
||||||
|
|
||||||
|
- **Rule Updates:**
|
||||||
|
- **Add New Rules When:**
|
||||||
|
- A new technology/pattern is used in 3+ files
|
||||||
|
- Common bugs could be prevented by a rule
|
||||||
|
- Code reviews repeatedly mention the same feedback
|
||||||
|
- New security or performance patterns emerge
|
||||||
|
|
||||||
|
- **Modify Existing Rules When:**
|
||||||
|
- Better examples exist in the codebase
|
||||||
|
- Additional edge cases are discovered
|
||||||
|
- Related rules have been updated
|
||||||
|
- Implementation details have changed
|
||||||
|
|
||||||
|
- **Example Pattern Recognition:**
|
||||||
|
```typescript
|
||||||
|
// If you see repeated patterns like:
|
||||||
|
const data = await prisma.user.findMany({
|
||||||
|
select: { id: true, email: true },
|
||||||
|
where: { status: 'ACTIVE' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc):
|
||||||
|
// - Standard select fields
|
||||||
|
// - Common where conditions
|
||||||
|
// - Performance optimization patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Rule Quality Checks:**
|
||||||
|
- Rules should be actionable and specific
|
||||||
|
- Examples should come from actual code
|
||||||
|
- References should be up to date
|
||||||
|
- Patterns should be consistently enforced
|
||||||
|
|
||||||
|
- **Continuous Improvement:**
|
||||||
|
- Monitor code review comments
|
||||||
|
- Track common development questions
|
||||||
|
- Update rules after major refactors
|
||||||
|
- Add links to relevant documentation
|
||||||
|
- Cross-reference related rules
|
||||||
|
|
||||||
|
- **Rule Deprecation:**
|
||||||
|
- Mark outdated patterns as deprecated
|
||||||
|
- Remove rules that no longer apply
|
||||||
|
- Update references to deprecated rules
|
||||||
|
- Document migration paths for old patterns
|
||||||
|
|
||||||
|
- **Documentation Updates:**
|
||||||
|
- Keep examples synchronized with code
|
||||||
|
- Update references to external docs
|
||||||
|
- Maintain links between related rules
|
||||||
|
- Document breaking changes
|
||||||
|
Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure.
|
64
.cursor/rules/stimulus_conventions.mdc
Normal file
64
.cursor/rules/stimulus_conventions.mdc
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
This rule describes how to write Stimulus controllers.
|
||||||
|
|
||||||
|
- **Use declarative actions, not imperative event listeners**
|
||||||
|
- Instead of assigning a Stimulus target and binding it to an event listener in the initializer, always write Controllers + ERB views declaratively by using Stimulus actions in ERB to call methods in the Stimulus JS controller. Below are good vs. bad code.
|
||||||
|
|
||||||
|
BAD code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BAD!!!! DO NOT DO THIS!!
|
||||||
|
// Imperative - controller does all the work
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["button", "content"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.buttonTarget.addEventListener("click", this.toggle.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.contentTarget.classList.toggle("hidden")
|
||||||
|
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
GOOD code:
|
||||||
|
|
||||||
|
```erb
|
||||||
|
<!-- Declarative - HTML declares what happens -->
|
||||||
|
|
||||||
|
<div data-controller="toggle">
|
||||||
|
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||||
|
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Declarative - controller just responds
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["button", "content"]
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.contentTarget.classList.toggle("hidden")
|
||||||
|
this.buttonTarget.textContent = this.contentTarget.classList.contains("hidden") ? "Show" : "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Keep Stimulus controllers lightweight and simple**
|
||||||
|
- Always aim for less than 7 controller targets. Any more is a sign of too much complexity.
|
||||||
|
- Use private methods and expose a clear public API
|
||||||
|
|
||||||
|
- **Keep Stimulus controllers focused on what they do best**
|
||||||
|
- Domain logic does NOT belong in a Stimulus controller
|
||||||
|
- Stimulus controllers should aim for a single responsibility, or a group of highly related responsibilities
|
||||||
|
- Make good use of Stimulus's callbacks, actions, targets, values, and classes
|
||||||
|
|
||||||
|
- **Component controllers should not be used outside the component**
|
||||||
|
- If a Stimulus controller is in the app/components directory, it should only be used in its component view. It should not be used anywhere in app/views.
|
||||||
|
|
87
.cursor/rules/testing.mdc
Normal file
87
.cursor/rules/testing.mdc
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: test/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
Use this rule to learn how to write tests for the Maybe codebase.
|
||||||
|
|
||||||
|
Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability.
|
||||||
|
|
||||||
|
- **General testing rules**
|
||||||
|
- Always use Minitest and fixtures for testing, NEVER rspec or factories
|
||||||
|
- 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 to help create the records needed for the test, then inline the creation. For example, [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) provides helpers to easily do this.
|
||||||
|
|
||||||
|
- **Write minimal, effective tests**
|
||||||
|
- Use system tests sparingly as they increase the time to complete the test suite
|
||||||
|
- Only write tests for critical and important code paths
|
||||||
|
- Write tests as you go, when required
|
||||||
|
- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_.
|
||||||
|
|
||||||
|
Below are examples of necessary vs. unnecessary tests:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
# GOOD!!
|
||||||
|
# Necessary test - in this case, we're testing critical domain business logic
|
||||||
|
test "syncs balances" do
|
||||||
|
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||||
|
|
||||||
|
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||||
|
|
||||||
|
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||||
|
[
|
||||||
|
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||||
|
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_difference "@account.balances.count", 2 do
|
||||||
|
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# BAD!!
|
||||||
|
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||||
|
test "saves balance" do
|
||||||
|
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||||
|
|
||||||
|
assert balance_record.save
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Test boundaries correctly**
|
||||||
|
- Distinguish between commands and query methods. Test output of query methods; test that commands were called with the correct params. See an example below:
|
||||||
|
|
||||||
|
```rb
|
||||||
|
class ExampleClass
|
||||||
|
def do_something
|
||||||
|
result = 2 + 2
|
||||||
|
|
||||||
|
CustomEventProcessor.process_result(result)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ExampleClass < ActiveSupport::TestCase
|
||||||
|
test "boundaries are tested correctly" do
|
||||||
|
result = ExampleClass.new.do_something
|
||||||
|
|
||||||
|
# GOOD - we're only testing that the command was received, not internal implementation details
|
||||||
|
# The actual tests for CustomEventProcessor belong in a different test suite!
|
||||||
|
CustomEventProcessor.expects(:process_result).with(4).once
|
||||||
|
|
||||||
|
# GOOD - we're testing the implementation of ExampleClass inside its own test suite
|
||||||
|
assert_equal 4, result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
- Never test the implementation details of one class in another classes test suite
|
||||||
|
|
||||||
|
- **Stubs and mocks**
|
||||||
|
- Use `mocha` gem
|
||||||
|
- Always prefer `OpenStruct` when creating mock instances, or in complex cases, a mock class
|
||||||
|
- Only mock what's necessary. If you're not testing return values, don't mock a return value.
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,22 @@
|
||||||
---
|
---
|
||||||
description: This file describes Maybe's design system and how views should be styled
|
description: This file describes Maybe's design system and how views should be styled
|
||||||
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
globs: app/views/**,app/helpers/**,app/javascript/controllers/**
|
||||||
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js.
|
Use the rules below when:
|
||||||
|
|
||||||
|
- You are writing HTML
|
||||||
|
- You are writing CSS
|
||||||
|
- You are writing styles in a JavaScript Stimulus controller
|
||||||
|
|
||||||
|
## Rules for AI (mandatory)
|
||||||
|
|
||||||
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)
|
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 start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
|
||||||
- Always generate semantic HTML
|
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
|
||||||
|
- Example 1: use `text-primary` rather than `text-white`
|
||||||
|
- Example 2: use `bg-container` rather than `bg-white`
|
||||||
|
- Example 3: use `border border-primary` rather than `border border-gray-200`
|
||||||
- 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
|
- 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 generate semantic HTML
|
||||||
- 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`.
|
|
||||||
|
|
100
.cursor/rules/view_conventions.mdc
Normal file
100
.cursor/rules/view_conventions.mdc
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
---
|
||||||
|
description:
|
||||||
|
globs: app/views/**,app/javascript/**,app/components/**/*.js
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
Use this rule to learn how to write ERB views, partials, and Stimulus controllers should be incorporated into them.
|
||||||
|
|
||||||
|
- **Component vs. Partial Decision Making**
|
||||||
|
- **Use ViewComponents when:**
|
||||||
|
- Element has complex logic or styling patterns
|
||||||
|
- Element will be reused across multiple views/contexts
|
||||||
|
- Element needs structured styling with variants/sizes (like buttons, badges)
|
||||||
|
- Element requires interactive behavior or Stimulus controllers
|
||||||
|
- Element has configurable slots or complex APIs
|
||||||
|
- Element needs accessibility features or ARIA support
|
||||||
|
|
||||||
|
- **Use Partials when:**
|
||||||
|
- Element is primarily static HTML with minimal logic
|
||||||
|
- Element is used in only one or few specific contexts
|
||||||
|
- Element is simple template content (like CTAs, static sections)
|
||||||
|
- Element doesn't need variants, sizes, or complex configuration
|
||||||
|
- Element is more about content organization than reusable functionality
|
||||||
|
|
||||||
|
- **Prefer components over partials**
|
||||||
|
- If there is a component available for the use case in app/components, use it
|
||||||
|
- If there is no component, look for a partial
|
||||||
|
- If there is no partial, decide between component or partial based on the criteria above
|
||||||
|
|
||||||
|
- **Examples of Component vs. Partial Usage**
|
||||||
|
```erb
|
||||||
|
<%# Component: Complex, reusable with variants and interactivity %>
|
||||||
|
<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
|
||||||
|
<% dialog.with_header(title: "Account Settings") %>
|
||||||
|
<% dialog.with_body { "Dialog content here" } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Component: Interactive with complex styling options %>
|
||||||
|
<%= render ButtonComponent.new(text: "Save Changes", variant: "primary", confirm: "Are you sure?") %>
|
||||||
|
|
||||||
|
<%# Component: Reusable with variants %>
|
||||||
|
<%= render FilledIconComponent.new(icon: "credit-card", variant: :surface) %>
|
||||||
|
|
||||||
|
<%# Partial: Static template content %>
|
||||||
|
<%= render "shared/logo" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple, context-specific content with basic styling %>
|
||||||
|
<%= render "shared/trend_change", trend: @account.trend, comparison_label: "vs last month" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple divider/utility %>
|
||||||
|
<%= render "shared/ruler", classes: "my-4" %>
|
||||||
|
|
||||||
|
<%# Partial: Simple form utility %>
|
||||||
|
<%= render "shared/form_errors", model: @account %>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Keep domain logic out of the views**
|
||||||
|
```erb
|
||||||
|
<%# BAD!!! %>
|
||||||
|
|
||||||
|
<%# This belongs in the component file, not the template file! %>
|
||||||
|
<% button_classes = { class: "bg-blue-500 hover:bg-blue-600" } %>
|
||||||
|
|
||||||
|
<%= tag.button class: button_classes do %>
|
||||||
|
Save Account
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# GOOD! %>
|
||||||
|
|
||||||
|
<%= tag.button class: computed_button_classes do %>
|
||||||
|
Save Account
|
||||||
|
<% end %>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Stimulus Integration in Views**
|
||||||
|
- Always use the **declarative approach** when integrating Stimulus controllers
|
||||||
|
- The ERB template should declare what happens, the Stimulus controller should respond
|
||||||
|
- Refer to [stimulus_conventions.mdc](mdc:.cursor/rules/stimulus_conventions.mdc) to learn how to incorporate them into
|
||||||
|
|
||||||
|
GOOD Stimulus controller integration into views:
|
||||||
|
|
||||||
|
```erb
|
||||||
|
<!-- Declarative - HTML declares what happens -->
|
||||||
|
|
||||||
|
<div data-controller="toggle">
|
||||||
|
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||||
|
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Stimulus Controller Placement Guidelines**
|
||||||
|
- **Component controllers** (in `app/components/`) should only be used within their component templates
|
||||||
|
- **Global controllers** (in `app/javascript/controllers/`) can be used across any view
|
||||||
|
- Pass data from Rails to Stimulus using `data-*-value` attributes, not inline JavaScript
|
||||||
|
- Use Stimulus targets to reference DOM elements, not manual `getElementById` calls
|
||||||
|
|
||||||
|
- **Naming Conventions**
|
||||||
|
- **Components**: Use `ComponentName` suffix (e.g., `ButtonComponent`, `DialogComponent`, `FilledIconComponent`)
|
||||||
|
- **Partials**: Use underscore prefix (e.g., `_trend_change.html.erb`, `_form_errors.html.erb`, `_sync_indicator.html.erb`)
|
||||||
|
- **Shared partials**: Place in `app/views/shared/` directory for reusable content
|
||||||
|
- **Context-specific partials**: Place in relevant controller view directory (e.g., `accounts/_account_sidebar_tabs.html.erb`)
|
|
@ -1,4 +1,4 @@
|
||||||
ARG RUBY_VERSION=3.4.1
|
ARG RUBY_VERSION=3.4.4
|
||||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||||
|
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
@ -10,6 +10,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
iproute2 \
|
iproute2 \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libyaml-dev \
|
||||||
|
libyaml-0-2 \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
vim
|
vim
|
||||||
|
|
|
@ -1,4 +1,15 @@
|
||||||
version: "3"
|
x-db-env: &db_env
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
|
x-rails-env: &rails_env
|
||||||
|
DB_HOST: db
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
BUNDLE_PATH: /bundle
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
|
@ -16,32 +27,41 @@ services:
|
||||||
command: sleep infinity
|
command: sleep infinity
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
<<: *rails_env
|
||||||
HOST: "0.0.0.0"
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
BUNDLE_PATH: /bundle
|
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: .devcontainer/Dockerfile
|
||||||
|
command: bundle exec sidekiq
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *rails_env
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
db:
|
db:
|
||||||
image: postgres:latest
|
image: postgres:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
<<: *db_env
|
||||||
POSTGRES_DB: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
redis-data:
|
||||||
bundle_cache:
|
bundle_cache:
|
||||||
|
|
102
.env.example
102
.env.example
|
@ -1,20 +1,31 @@
|
||||||
# ================================ PLEASE READ ==========================================
|
# ================================ PLEASE READ ===========================================================
|
||||||
# This file outlines all the possible environment variables supported by the Maybe app.
|
# This file outlines all the possible environment variables supported by the Maybe app for self hosting.
|
||||||
#
|
|
||||||
# This includes several features that are for our "hosted" version of Maybe, which most
|
|
||||||
# open-source contributors won't need.
|
|
||||||
#
|
#
|
||||||
# If you are developing locally, you should be referencing `.env.local.example` instead.
|
# If you're a developer setting up your local environment, please use `.env.local.example` instead.
|
||||||
# =======================================================================================
|
# ========================================================================================================
|
||||||
|
|
||||||
|
# Required self-hosting vars
|
||||||
|
# --------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enables self hosting features (should be set to true unless you know what you're doing)
|
||||||
|
SELF_HOSTED=true
|
||||||
|
|
||||||
|
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||||
|
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||||
|
SECRET_KEY_BASE=secret-value
|
||||||
|
|
||||||
|
# Optional self-hosting vars
|
||||||
|
# --------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Optional: Synth API Key for exchange rates + stock prices
|
||||||
|
# (you can also set this in your self-hosted settings page)
|
||||||
|
# Get it here: https://synthfinance.com/
|
||||||
|
SYNTH_API_KEY=
|
||||||
|
|
||||||
# Custom port config
|
# Custom port config
|
||||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Exchange Rate & Stock Pricing API
|
|
||||||
# This is used to convert between different currencies in the app. In addition, it fetches global stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
|
||||||
SYNTH_API_KEY=
|
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||||
# Resend.com is a good option that offers a free tier for sending emails.
|
# Resend.com is a good option that offers a free tier for sending emails.
|
||||||
|
@ -37,60 +48,28 @@ POSTGRES_USER=postgres
|
||||||
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
|
# This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places.
|
||||||
APP_DOMAIN=
|
APP_DOMAIN=
|
||||||
|
|
||||||
## Error and Performance Monitoring
|
|
||||||
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
|
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
|
|
||||||
# This is useful for controlling who can sign up for your Maybe instance.
|
|
||||||
REQUIRE_INVITE_CODE=false
|
|
||||||
|
|
||||||
# Enables self hosting features (should be set to true for most folks)
|
|
||||||
SELF_HOSTED=true
|
|
||||||
|
|
||||||
# The hosting platform used to deploy the app (e.g. "render")
|
|
||||||
# `localhost` (or unset) is used for local development and testing
|
|
||||||
HOSTING_PLATFORM=localhost
|
|
||||||
|
|
||||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
|
||||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
|
||||||
SECRET_KEY_BASE=secret-value
|
|
||||||
|
|
||||||
# Disable enforcing SSL connections
|
# Disable enforcing SSL connections
|
||||||
# DISABLE_SSL=true
|
# DISABLE_SSL=true
|
||||||
|
|
||||||
# ======================================================================================================
|
# Active Record Encryption Keys (Optional)
|
||||||
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
|
# These keys are used to encrypt sensitive data like API keys in the database.
|
||||||
# ======================================================================================================
|
# If not provided, they will be automatically generated based on your SECRET_KEY_BASE.
|
||||||
#
|
# You can generate your own keys by running: rails db:encryption:init
|
||||||
# UPGRADES_ENABLED: Enables Upgrader class functionality.
|
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
|
||||||
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
|
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
|
||||||
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
|
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
|
||||||
#
|
|
||||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
|
||||||
UPGRADES_MODE=manual # `manual` or `auto`
|
|
||||||
UPGRADES_TARGET=release # `release` or `commit`
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================================================
|
|
||||||
# Git Repository Module - responsible for fetching latest commit data for upgrades
|
|
||||||
# ======================================================================================================
|
|
||||||
#
|
|
||||||
GITHUB_REPO_OWNER=maybe-finance
|
|
||||||
GITHUB_REPO_NAME=maybe
|
|
||||||
GITHUB_REPO_BRANCH=main
|
|
||||||
|
|
||||||
# ======================================================================================================
|
# ======================================================================================================
|
||||||
# Active Storage Configuration - responsible for storing file uploads
|
# Active Storage Configuration - responsible for storing file uploads
|
||||||
# ======================================================================================================
|
# ======================================================================================================
|
||||||
#
|
#
|
||||||
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
# * Defaults to disk storage but you can also use Amazon S3 or Cloudflare R2
|
||||||
# * Set the appropriate environment variables to use these services.
|
# * Set the appropriate environment variables to use these services.
|
||||||
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||||
#
|
#
|
||||||
# Amazon S3
|
# Amazon S3
|
||||||
# ==========
|
# ==========
|
||||||
# ACTIVE_STORAGE_SERVICE=amazon
|
# ACTIVE_STORAGE_SERVICE=amazon <- Enables Amazon S3 storage
|
||||||
# S3_ACCESS_KEY_ID=
|
# S3_ACCESS_KEY_ID=
|
||||||
# S3_SECRET_ACCESS_KEY=
|
# S3_SECRET_ACCESS_KEY=
|
||||||
# S3_REGION= # defaults to `us-east-1` if not set
|
# S3_REGION= # defaults to `us-east-1` if not set
|
||||||
|
@ -98,26 +77,9 @@ GITHUB_REPO_BRANCH=main
|
||||||
#
|
#
|
||||||
# Cloudflare R2
|
# Cloudflare R2
|
||||||
# =============
|
# =============
|
||||||
# ACTIVE_STORAGE_SERVICE=cloudflare
|
# ACTIVE_STORAGE_SERVICE=cloudflare <- Enables Cloudflare R2 storage
|
||||||
# CLOUDFLARE_ACCOUNT_ID=
|
# CLOUDFLARE_ACCOUNT_ID=
|
||||||
# CLOUDFLARE_ACCESS_KEY_ID=
|
# CLOUDFLARE_ACCESS_KEY_ID=
|
||||||
# CLOUDFLARE_SECRET_ACCESS_KEY=
|
# CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||||
# CLOUDFLARE_BUCKET=
|
# CLOUDFLARE_BUCKET=
|
||||||
|
|
||||||
# ======================================================================================================
|
|
||||||
# Billing Module - responsible for handling billing
|
|
||||||
# ======================================================================================================
|
|
||||||
#
|
#
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
|
||||||
STRIPE_SECRET_KEY=
|
|
||||||
STRIPE_WEBHOOK_SECRET=
|
|
||||||
|
|
||||||
# ======================================================================================================
|
|
||||||
# Plaid Configuration
|
|
||||||
# ======================================================================================================
|
|
||||||
#
|
|
||||||
PLAID_CLIENT_ID=
|
|
||||||
PLAID_SECRET=
|
|
||||||
PLAID_ENV=
|
|
||||||
PLAID_EU_CLIENT_ID=
|
|
||||||
PLAID_EU_SECRET=
|
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
SELF_HOSTED=false
|
|
||||||
SYNTH_API_KEY=fookey
|
|
||||||
|
|
||||||
# Set to true if you want SimpleCov reports generated
|
|
||||||
COVERAGE=false
|
|
||||||
|
|
||||||
# Set to true to run test suite serially
|
|
||||||
DISABLE_PARALLELIZATION=false
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
SELF_HOSTED=false
|
||||||
|
|
||||||
# ================
|
# ================
|
||||||
# Data Providers
|
# Data Providers
|
||||||
# ---------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------
|
||||||
|
|
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,31 +1,61 @@
|
||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Open a bug report when you experience broken functionality within the latest
|
||||||
|
version of the Maybe app
|
||||||
title: 'Bug: [Add descriptive title here]'
|
title: 'Bug: [Add descriptive title here]'
|
||||||
labels: ''
|
labels: ''
|
||||||
assignees: ''
|
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
|
### General checklist
|
||||||
- [ ] I am a user of Maybe's paid app
|
|
||||||
|
|
||||||
_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.
|
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:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
**Expected behavior**
|
### Expected behavior
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots / Recordings**
|
What is the intended behavior that you would expect?
|
||||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
|
||||||
|
### 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.
|
||||||
|
|
32
.github/ISSUE_TEMPLATE/other.md
vendored
32
.github/ISSUE_TEMPLATE/other.md
vendored
|
@ -7,15 +7,33 @@ 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).
|
### Is this a bug?
|
||||||
- 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.
|
|
||||||
|
|
||||||
----------------------
|
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 in a [Feature request Discussion](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 [Docker hosting guide](https://github.com/maybe-finance/maybe/tree/main/docs/hosting/docker.md) and follow it step-by-step
|
||||||
|
- Open a [Docker Discussion](https://github.com/maybe-finance/maybe/discussions/categories/docker-compose-hosting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue description
|
||||||
|
|
||||||
|
If your issue does not fall into the categories above, please provide a **descriptive and complete** overview of your issue.
|
||||||
|
|
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
|
@ -77,7 +77,10 @@ jobs:
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
PLAID_CLIENT_ID: foo
|
||||||
|
PLAID_SECRET: bar
|
||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
RAILS_ENV: test
|
RAILS_ENV: test
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
@ -90,6 +93,12 @@ jobs:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
|
||||||
|
|
12
.github/workflows/publish.yml
vendored
12
.github/workflows/publish.yml
vendored
|
@ -1,6 +1,13 @@
|
||||||
name: Publish Docker image
|
name: Publish Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: 'Git ref (tag or commit SHA) to build'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
default: 'main'
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
@ -33,6 +40,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
@ -67,9 +76,10 @@ jobs:
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
provenance: false
|
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
|
# 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
|
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 }}
|
||||||
|
|
44
.gitignore
vendored
44
.gitignore
vendored
|
@ -11,7 +11,6 @@
|
||||||
# Ignore all environment files (except templates).
|
# Ignore all environment files (except templates).
|
||||||
/.env*
|
/.env*
|
||||||
!/.env*.erb
|
!/.env*.erb
|
||||||
!.env.test
|
|
||||||
!.env*.example
|
!.env*.example
|
||||||
|
|
||||||
# Ignore all logfiles and tempfiles.
|
# Ignore all logfiles and tempfiles.
|
||||||
|
@ -63,6 +62,47 @@ gcp-storage-keyfile.json
|
||||||
|
|
||||||
coverage
|
coverage
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
.cursor/rules/structure.mdc
|
||||||
|
.cursor/rules/agent.mdc
|
||||||
|
|
||||||
# Ignore node related files
|
# Ignore node related files
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
compose.yml
|
||||||
|
|
||||||
|
plaid_test_accounts/
|
||||||
|
|
||||||
|
# Added by Claude Task Master
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
dev-debug.log
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
*.roo*
|
||||||
|
# OS specific
|
||||||
|
# Task files
|
||||||
|
.taskmaster/
|
||||||
|
tasks.json
|
||||||
|
.taskmaster/tasks/
|
||||||
|
.taskmaster/reports/
|
||||||
|
.taskmaster/state.json
|
||||||
|
*.mcp.json
|
||||||
|
scripts/
|
||||||
|
.cursor/mcp.json
|
||||||
|
.taskmasterconfig
|
||||||
|
.windsurfrules
|
||||||
|
.cursor/rules/dev_workflow.mdc
|
||||||
|
.cursor/rules/taskmaster.mdc
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.4.1
|
3.4.4
|
||||||
|
|
273
CLAUDE.md
Normal file
273
CLAUDE.md
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Common Development Commands
|
||||||
|
|
||||||
|
### Development Server
|
||||||
|
- `bin/dev` - Start development server (Rails, Sidekiq, Tailwind CSS watcher)
|
||||||
|
- `bin/rails server` - Start Rails server only
|
||||||
|
- `bin/rails console` - Open Rails console
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- `bin/rails test` - Run all tests
|
||||||
|
- `bin/rails test:db` - Run tests with database reset
|
||||||
|
- `bin/rails test:system` - Run system tests only (use sparingly - they take longer)
|
||||||
|
- `bin/rails test test/models/account_test.rb` - Run specific test file
|
||||||
|
- `bin/rails test test/models/account_test.rb:42` - Run specific test at line
|
||||||
|
|
||||||
|
### Linting & Formatting
|
||||||
|
- `bin/rubocop` - Run Ruby linter
|
||||||
|
- `npm run lint` - Check JavaScript/TypeScript code
|
||||||
|
- `npm run lint:fix` - Fix JavaScript/TypeScript issues
|
||||||
|
- `npm run format` - Format JavaScript/TypeScript code
|
||||||
|
- `bin/brakeman` - Run security analysis
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- `bin/rails db:prepare` - Create and migrate database
|
||||||
|
- `bin/rails db:migrate` - Run pending migrations
|
||||||
|
- `bin/rails db:rollback` - Rollback last migration
|
||||||
|
- `bin/rails db:seed` - Load seed data
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
- `bin/setup` - Initial project setup (installs dependencies, prepares database)
|
||||||
|
|
||||||
|
## Pre-Pull Request CI Workflow
|
||||||
|
|
||||||
|
ALWAYS run these commands before opening a pull request:
|
||||||
|
|
||||||
|
1. **Tests** (Required):
|
||||||
|
- `bin/rails test` - Run all tests (always required)
|
||||||
|
- `bin/rails test:system` - Run system tests (only when applicable, they take longer)
|
||||||
|
|
||||||
|
2. **Linting** (Required):
|
||||||
|
- `bin/rubocop -f github -a` - Ruby linting with auto-correct
|
||||||
|
- `bundle exec erb_lint ./app/**/*.erb -a` - ERB linting with auto-correct
|
||||||
|
|
||||||
|
3. **Security** (Required):
|
||||||
|
- `bin/brakeman --no-pager` - Security analysis
|
||||||
|
|
||||||
|
Only proceed with pull request creation if ALL checks pass.
|
||||||
|
|
||||||
|
## General Development Rules
|
||||||
|
|
||||||
|
### Authentication Context
|
||||||
|
- Use `Current.user` for the current user. Do NOT use `current_user`.
|
||||||
|
- Use `Current.family` for the current family. Do NOT use `current_family`.
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
- Prior to generating any code, carefully read the project conventions and guidelines
|
||||||
|
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development
|
||||||
|
- Do not run `rails server` in your responses
|
||||||
|
- Do not run `touch tmp/restart.txt`
|
||||||
|
- Do not run `rails credentials`
|
||||||
|
- Do not automatically run migrations
|
||||||
|
|
||||||
|
## High-Level Architecture
|
||||||
|
|
||||||
|
### Application Modes
|
||||||
|
The Maybe app runs in two distinct modes:
|
||||||
|
- **Managed**: The Maybe team operates and manages servers for users (Rails.application.config.app_mode = "managed")
|
||||||
|
- **Self Hosted**: Users host the Maybe app on their own infrastructure, typically through Docker Compose (Rails.application.config.app_mode = "self_hosted")
|
||||||
|
|
||||||
|
### Core Domain Model
|
||||||
|
The application is built around financial data management with these key relationships:
|
||||||
|
- **User** → has many **Accounts** → has many **Transactions**
|
||||||
|
- **Account** types: checking, savings, credit cards, investments, crypto, loans, properties
|
||||||
|
- **Transaction** → belongs to **Category**, can have **Tags** and **Rules**
|
||||||
|
- **Investment accounts** → have **Holdings** → track **Securities** via **Trades**
|
||||||
|
|
||||||
|
### API Architecture
|
||||||
|
The application provides both internal and external APIs:
|
||||||
|
- Internal API: Controllers serve JSON via Turbo for SPA-like interactions
|
||||||
|
- External API: `/api/v1/` namespace with Doorkeeper OAuth and API key authentication
|
||||||
|
- API responses use Jbuilder templates for JSON rendering
|
||||||
|
- Rate limiting via Rack Attack with configurable limits per API key
|
||||||
|
|
||||||
|
### Sync & Import System
|
||||||
|
Two primary data ingestion methods:
|
||||||
|
1. **Plaid Integration**: Real-time bank account syncing
|
||||||
|
- `PlaidItem` manages connections
|
||||||
|
- `Sync` tracks sync operations
|
||||||
|
- Background jobs handle data updates
|
||||||
|
2. **CSV Import**: Manual data import with mapping
|
||||||
|
- `Import` manages import sessions
|
||||||
|
- Supports transaction and balance imports
|
||||||
|
- Custom field mapping with transformation rules
|
||||||
|
|
||||||
|
### Background Processing
|
||||||
|
Sidekiq handles asynchronous tasks:
|
||||||
|
- Account syncing (`SyncAccountsJob`)
|
||||||
|
- Import processing (`ImportDataJob`)
|
||||||
|
- AI chat responses (`CreateChatResponseJob`)
|
||||||
|
- Scheduled maintenance via sidekiq-cron
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
- **Hotwire Stack**: Turbo + Stimulus for reactive UI without heavy JavaScript
|
||||||
|
- **ViewComponents**: Reusable UI components in `app/components/`
|
||||||
|
- **Stimulus Controllers**: Handle interactivity, organized alongside components
|
||||||
|
- **Charts**: D3.js for financial visualizations (time series, donut, sankey)
|
||||||
|
- **Styling**: Tailwind CSS v4.x with custom design system
|
||||||
|
- Design system defined in `app/assets/tailwind/maybe-design-system.css`
|
||||||
|
- Always use functional tokens (e.g., `text-primary` not `text-white`)
|
||||||
|
- Prefer semantic HTML elements over JS components
|
||||||
|
- Use `icon` helper for icons, never `lucide_icon` directly
|
||||||
|
|
||||||
|
### Multi-Currency Support
|
||||||
|
- All monetary values stored in base currency (user's primary currency)
|
||||||
|
- Exchange rates fetched from Synth API
|
||||||
|
- `Money` objects handle currency conversion and formatting
|
||||||
|
- Historical exchange rates for accurate reporting
|
||||||
|
|
||||||
|
### Security & Authentication
|
||||||
|
- Session-based auth for web users
|
||||||
|
- API authentication via:
|
||||||
|
- OAuth2 (Doorkeeper) for third-party apps
|
||||||
|
- API keys with JWT tokens for direct API access
|
||||||
|
- Scoped permissions system for API access
|
||||||
|
- Strong parameters and CSRF protection throughout
|
||||||
|
|
||||||
|
### Testing Philosophy
|
||||||
|
- Comprehensive test coverage using Rails' built-in Minitest
|
||||||
|
- Fixtures for test data (avoid FactoryBot)
|
||||||
|
- Keep fixtures minimal (2-3 per model for base cases)
|
||||||
|
- VCR for external API testing
|
||||||
|
- System tests for critical user flows (use sparingly)
|
||||||
|
- Test helpers in `test/support/` for common scenarios
|
||||||
|
- Only test critical code paths that significantly increase confidence
|
||||||
|
- Write tests as you go, when required
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
- Database queries optimized with proper indexes
|
||||||
|
- N+1 queries prevented via includes/joins
|
||||||
|
- Background jobs for heavy operations
|
||||||
|
- Caching strategies for expensive calculations
|
||||||
|
- Turbo Frames for partial page updates
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
- Feature branches merged to `main`
|
||||||
|
- Docker support for consistent environments
|
||||||
|
- Environment variables via `.env` files
|
||||||
|
- Lookbook for component development (`/lookbook`)
|
||||||
|
- Letter Opener for email preview in development
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
### Convention 1: Minimize Dependencies
|
||||||
|
- Push Rails to its limits before adding new dependencies
|
||||||
|
- Strong technical/business reason required for new dependencies
|
||||||
|
- Favor old and reliable over new and flashy
|
||||||
|
|
||||||
|
### Convention 2: Skinny Controllers, Fat Models
|
||||||
|
- Business logic in `app/models/` folder, avoid `app/services/`
|
||||||
|
- Use Rails concerns and POROs for organization
|
||||||
|
- Models should answer questions about themselves: `account.balance_series` not `AccountSeries.new(account).call`
|
||||||
|
|
||||||
|
### Convention 3: Hotwire-First Frontend
|
||||||
|
- **Native HTML preferred over JS components**
|
||||||
|
- Use `<dialog>` for modals, `<details><summary>` for disclosures
|
||||||
|
- **Leverage Turbo frames** for page sections over client-side solutions
|
||||||
|
- **Query params for state** over localStorage/sessions
|
||||||
|
- **Server-side formatting** for currencies, numbers, dates
|
||||||
|
- **Always use `icon` helper** in `application_helper.rb`, NEVER `lucide_icon` directly
|
||||||
|
|
||||||
|
### Convention 4: Optimize for Simplicity
|
||||||
|
- Prioritize good OOP domain design over performance
|
||||||
|
- Focus performance only on critical/global areas (avoid N+1 queries, mindful of global layouts)
|
||||||
|
|
||||||
|
### Convention 5: Database vs ActiveRecord Validations
|
||||||
|
- Simple validations (null checks, unique indexes) in DB
|
||||||
|
- ActiveRecord validations for convenience in forms (prefer client-side when possible)
|
||||||
|
- Complex validations and business logic in ActiveRecord
|
||||||
|
|
||||||
|
## TailwindCSS Design System
|
||||||
|
|
||||||
|
### Design System Rules
|
||||||
|
- **Always reference `app/assets/tailwind/maybe-design-system.css`** for primitives and tokens
|
||||||
|
- **Use functional tokens** defined in design system:
|
||||||
|
- `text-primary` instead of `text-white`
|
||||||
|
- `bg-container` instead of `bg-white`
|
||||||
|
- `border border-primary` instead of `border border-gray-200`
|
||||||
|
- **NEVER create new styles** in design system files without permission
|
||||||
|
- **Always generate semantic HTML**
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### ViewComponent vs Partials Decision Making
|
||||||
|
|
||||||
|
**Use ViewComponents when:**
|
||||||
|
- Element has complex logic or styling patterns
|
||||||
|
- Element will be reused across multiple views/contexts
|
||||||
|
- Element needs structured styling with variants/sizes
|
||||||
|
- Element requires interactive behavior or Stimulus controllers
|
||||||
|
- Element has configurable slots or complex APIs
|
||||||
|
- Element needs accessibility features or ARIA support
|
||||||
|
|
||||||
|
**Use Partials when:**
|
||||||
|
- Element is primarily static HTML with minimal logic
|
||||||
|
- Element is used in only one or few specific contexts
|
||||||
|
- Element is simple template content
|
||||||
|
- Element doesn't need variants, sizes, or complex configuration
|
||||||
|
- Element is more about content organization than reusable functionality
|
||||||
|
|
||||||
|
**Component Guidelines:**
|
||||||
|
- Prefer components over partials when available
|
||||||
|
- Keep domain logic OUT of view templates
|
||||||
|
- Logic belongs in component files, not template files
|
||||||
|
|
||||||
|
### Stimulus Controller Guidelines
|
||||||
|
|
||||||
|
**Declarative Actions (Required):**
|
||||||
|
```erb
|
||||||
|
<!-- GOOD: Declarative - HTML declares what happens -->
|
||||||
|
<div data-controller="toggle">
|
||||||
|
<button data-action="click->toggle#toggle" data-toggle-target="button">Show</button>
|
||||||
|
<div data-toggle-target="content" class="hidden">Hello World!</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controller Best Practices:**
|
||||||
|
- Keep controllers lightweight and simple (< 7 targets)
|
||||||
|
- Use private methods and expose clear public API
|
||||||
|
- Single responsibility or highly related responsibilities
|
||||||
|
- Component controllers stay in component directory, global controllers in `app/javascript/controllers/`
|
||||||
|
- Pass data via `data-*-value` attributes, not inline JavaScript
|
||||||
|
|
||||||
|
## Testing Philosophy
|
||||||
|
|
||||||
|
### General Testing Rules
|
||||||
|
- **ALWAYS use Minitest + fixtures** (NEVER RSpec or factories)
|
||||||
|
- Keep fixtures minimal (2-3 per model for base cases)
|
||||||
|
- Create edge cases on-the-fly within test context
|
||||||
|
- Use Rails helpers for large fixture creation needs
|
||||||
|
|
||||||
|
### Test Quality Guidelines
|
||||||
|
- **Write minimal, effective tests** - system tests sparingly
|
||||||
|
- **Only test critical and important code paths**
|
||||||
|
- **Test boundaries correctly:**
|
||||||
|
- Commands: test they were called with correct params
|
||||||
|
- Queries: test output
|
||||||
|
- Don't test implementation details of other classes
|
||||||
|
|
||||||
|
### Testing Examples
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# GOOD - Testing critical domain business logic
|
||||||
|
test "syncs balances" do
|
||||||
|
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||||
|
assert_difference "@account.balances.count", 2 do
|
||||||
|
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# BAD - Testing ActiveRecord functionality
|
||||||
|
test "saves balance" do
|
||||||
|
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||||
|
assert balance_record.save
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stubs and Mocks
|
||||||
|
- Use `mocha` gem
|
||||||
|
- Prefer `OpenStruct` for mock instances
|
||||||
|
- Only mock what's necessary
|
|
@ -4,7 +4,7 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
|
||||||
|
|
||||||
## House Rules
|
## 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.
|
- 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.
|
- 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)
|
- 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.
|
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
|
||||||
|
|
12
Dockerfile
12
Dockerfile
|
@ -1,7 +1,7 @@
|
||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.1
|
ARG RUBY_VERSION=3.4.4
|
||||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
|
@ -9,19 +9,21 @@ WORKDIR /rails
|
||||||
|
|
||||||
# Install base packages
|
# Install base packages
|
||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2
|
||||||
|
|
||||||
# Set production environment
|
# Set production environment
|
||||||
|
ARG BUILD_COMMIT_SHA
|
||||||
ENV RAILS_ENV="production" \
|
ENV RAILS_ENV="production" \
|
||||||
BUNDLE_DEPLOYMENT="1" \
|
BUNDLE_DEPLOYMENT="1" \
|
||||||
BUNDLE_PATH="/usr/local/bundle" \
|
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
|
# Throw-away build stage to reduce size of final image
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
|
|
||||||
# Install packages needed to build gems
|
# 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 libyaml-dev
|
||||||
|
|
||||||
# Install application gems
|
# Install application gems
|
||||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||||
|
|
43
Gemfile
43
Gemfile
|
@ -7,6 +7,7 @@ gem "rails", "~> 7.2.2"
|
||||||
|
|
||||||
# Drivers
|
# Drivers
|
||||||
gem "pg", "~> 1.5"
|
gem "pg", "~> 1.5"
|
||||||
|
gem "redis", "~> 5.4"
|
||||||
|
|
||||||
# Deployment
|
# Deployment
|
||||||
gem "puma", ">= 5.0"
|
gem "puma", ">= 5.0"
|
||||||
|
@ -18,30 +19,43 @@ gem "propshaft"
|
||||||
gem "tailwindcss-rails"
|
gem "tailwindcss-rails"
|
||||||
gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||||
|
|
||||||
# Hotwire
|
# Hotwire + UI
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
gem "turbo-rails"
|
gem "turbo-rails"
|
||||||
|
gem "view_component"
|
||||||
|
|
||||||
# Temporary pin to commit to fix crypto.randomUUID() errors. Revert this when the change has been released.
|
# https://github.com/lookbook-hq/lookbook/issues/712
|
||||||
gem "hotwire_combobox", github: "josefarias/hotwire_combobox", ref: "b827048a8305e1115d5f96931ba1c9750d1e59fc"
|
# TODO: Remove max version constraint when fixed
|
||||||
|
gem "lookbook", "2.3.11"
|
||||||
|
|
||||||
|
gem "hotwire_combobox"
|
||||||
|
|
||||||
# Background Jobs
|
# Background Jobs
|
||||||
gem "good_job"
|
gem "sidekiq"
|
||||||
|
gem "sidekiq-cron"
|
||||||
|
|
||||||
# Error logging
|
# Monitoring
|
||||||
gem "stackprof"
|
gem "vernier"
|
||||||
gem "rack-mini-profiler"
|
gem "rack-mini-profiler"
|
||||||
gem "sentry-ruby"
|
gem "sentry-ruby"
|
||||||
gem "sentry-rails"
|
gem "sentry-rails"
|
||||||
|
gem "sentry-sidekiq"
|
||||||
gem "logtail-rails"
|
gem "logtail-rails"
|
||||||
|
gem "skylight", groups: [ :production ]
|
||||||
|
|
||||||
# Active Storage
|
# Active Storage
|
||||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||||
gem "image_processing", ">= 1.2"
|
gem "image_processing", ">= 1.2"
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
|
gem "ostruct"
|
||||||
gem "bcrypt", "~> 3.1"
|
gem "bcrypt", "~> 3.1"
|
||||||
gem "jwt"
|
gem "jwt"
|
||||||
|
gem "jbuilder"
|
||||||
|
|
||||||
|
# OAuth & API Security
|
||||||
|
gem "doorkeeper"
|
||||||
|
gem "rack-attack", "~> 6.6"
|
||||||
gem "faraday"
|
gem "faraday"
|
||||||
gem "faraday-retry"
|
gem "faraday-retry"
|
||||||
gem "faraday-multipart"
|
gem "faraday-multipart"
|
||||||
|
@ -56,7 +70,15 @@ gem "stripe"
|
||||||
gem "intercom-rails"
|
gem "intercom-rails"
|
||||||
gem "plaid"
|
gem "plaid"
|
||||||
gem "rotp", "~> 6.3"
|
gem "rotp", "~> 6.3"
|
||||||
gem "rqrcode", "~> 2.2"
|
gem "rqrcode", "~> 3.0"
|
||||||
|
gem "activerecord-import"
|
||||||
|
|
||||||
|
# State machines
|
||||||
|
gem "aasm"
|
||||||
|
gem "after_commit_everywhere", "~> 1.0"
|
||||||
|
|
||||||
|
# AI
|
||||||
|
gem "ruby-openai"
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem "debug", platforms: %i[mri windows]
|
gem "debug", platforms: %i[mri windows]
|
||||||
|
@ -67,6 +89,10 @@ group :development, :test do
|
||||||
gem "dotenv-rails"
|
gem "dotenv-rails"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if ENV["BENCHMARKING_ENABLED"]
|
||||||
|
gem "dotenv-rails", groups: [ :production ]
|
||||||
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
gem "hotwire-livereload"
|
gem "hotwire-livereload"
|
||||||
gem "letter_opener"
|
gem "letter_opener"
|
||||||
|
@ -74,6 +100,9 @@ group :development do
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
gem "faker"
|
gem "faker"
|
||||||
gem "benchmark-ips"
|
gem "benchmark-ips"
|
||||||
|
gem "stackprof"
|
||||||
|
gem "derailed_benchmarks"
|
||||||
|
gem "foreman"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
|
397
Gemfile.lock
397
Gemfile.lock
|
@ -1,14 +1,3 @@
|
||||||
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
|
GIT
|
||||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||||
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
|
||||||
|
@ -19,6 +8,8 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
|
aasm (5.5.1)
|
||||||
|
concurrent-ruby (~> 1.0)
|
||||||
actioncable (7.2.2.1)
|
actioncable (7.2.2.1)
|
||||||
actionpack (= 7.2.2.1)
|
actionpack (= 7.2.2.1)
|
||||||
activesupport (= 7.2.2.1)
|
activesupport (= 7.2.2.1)
|
||||||
|
@ -72,6 +63,8 @@ GEM
|
||||||
activemodel (= 7.2.2.1)
|
activemodel (= 7.2.2.1)
|
||||||
activesupport (= 7.2.2.1)
|
activesupport (= 7.2.2.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
|
activerecord-import (2.2.0)
|
||||||
|
activerecord (>= 4.2)
|
||||||
activestorage (7.2.2.1)
|
activestorage (7.2.2.1)
|
||||||
actionpack (= 7.2.2.1)
|
actionpack (= 7.2.2.1)
|
||||||
activejob (= 7.2.2.1)
|
activejob (= 7.2.2.1)
|
||||||
|
@ -92,26 +85,31 @@ GEM
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
addressable (2.8.7)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
ast (2.4.2)
|
after_commit_everywhere (1.6.0)
|
||||||
aws-eventstream (1.3.0)
|
activerecord (>= 4.2)
|
||||||
aws-partitions (1.1043.0)
|
activesupport
|
||||||
aws-sdk-core (3.217.0)
|
ast (2.4.3)
|
||||||
|
aws-eventstream (1.4.0)
|
||||||
|
aws-partitions (1.1113.0)
|
||||||
|
aws-sdk-core (3.225.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
aws-sdk-kms (1.97.0)
|
logger
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-kms (1.104.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.225.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.177.0)
|
aws-sdk-s3 (1.177.0)
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.2.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.0)
|
benchmark (0.4.1)
|
||||||
benchmark-ips (2.14.0)
|
benchmark-ips (2.14.0)
|
||||||
better_html (2.1.1)
|
better_html (2.1.1)
|
||||||
actionview (>= 6.0)
|
actionview (>= 6.0)
|
||||||
|
@ -120,11 +118,11 @@ GEM
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
parser (>= 2.4)
|
parser (>= 2.4)
|
||||||
smart_properties
|
smart_properties
|
||||||
bigdecimal (3.1.9)
|
bigdecimal (3.2.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.18.4)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (7.0.0)
|
brakeman (7.1.0)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
capybara (3.40.0)
|
capybara (3.40.0)
|
||||||
|
@ -141,22 +139,48 @@ GEM
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.0)
|
connection_pool (2.5.3)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
csv (3.3.2)
|
cronex (0.15.0)
|
||||||
|
tzinfo
|
||||||
|
unicode (>= 0.4.4.5)
|
||||||
|
css_parser (1.21.1)
|
||||||
|
addressable
|
||||||
|
csv (3.3.5)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.10.0)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
|
derailed_benchmarks (2.2.1)
|
||||||
|
base64
|
||||||
|
benchmark-ips (~> 2)
|
||||||
|
bigdecimal
|
||||||
|
drb
|
||||||
|
get_process_mem
|
||||||
|
heapy (~> 0)
|
||||||
|
logger
|
||||||
|
memory_profiler (>= 0, < 2)
|
||||||
|
mini_histogram (>= 0.3.0)
|
||||||
|
mutex_m
|
||||||
|
ostruct
|
||||||
|
rack (>= 1)
|
||||||
|
rack-test
|
||||||
|
rake (> 10, < 14)
|
||||||
|
ruby-statistics (>= 4.0.1)
|
||||||
|
ruby2_keywords
|
||||||
|
thor (>= 0.19, < 2)
|
||||||
docile (1.4.1)
|
docile (1.4.1)
|
||||||
dotenv (3.1.7)
|
doorkeeper (5.8.2)
|
||||||
dotenv-rails (3.1.7)
|
railties (>= 5)
|
||||||
dotenv (= 3.1.7)
|
dotenv (3.1.8)
|
||||||
|
dotenv-rails (3.1.8)
|
||||||
|
dotenv (= 3.1.8)
|
||||||
railties (>= 6.1)
|
railties (>= 6.1)
|
||||||
drb (2.2.1)
|
drb (2.2.3)
|
||||||
|
erb (5.0.1)
|
||||||
erb_lint (0.9.0)
|
erb_lint (0.9.0)
|
||||||
activesupport
|
activesupport
|
||||||
better_html (>= 2.0.1)
|
better_html (>= 2.0.1)
|
||||||
|
@ -167,48 +191,55 @@ GEM
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
faker (3.5.1)
|
event_stream_parser (1.0.0)
|
||||||
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.12.2)
|
faraday (2.13.2)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-multipart (1.1.0)
|
faraday-multipart (1.1.1)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.1)
|
||||||
net-http (>= 0.5.0)
|
net-http (>= 0.5.0)
|
||||||
faraday-retry (2.2.1)
|
faraday-retry (2.3.2)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
ffi (1.17.1-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.1-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.1-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
ffi (1.17.1-arm-linux-musl)
|
ffi (1.17.2-arm-linux-musl)
|
||||||
ffi (1.17.1-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.1-x86_64-darwin)
|
ffi (1.17.2-x86_64-darwin)
|
||||||
ffi (1.17.1-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.1-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
|
foreman (0.88.1)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
|
get_process_mem (1.0.0)
|
||||||
|
bigdecimal (>= 2.0)
|
||||||
|
ffi (~> 1.0)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
good_job (4.9.0)
|
hashdiff (1.2.0)
|
||||||
activejob (>= 6.1.0)
|
heapy (0.2.0)
|
||||||
activerecord (>= 6.1.0)
|
thor
|
||||||
concurrent-ruby (>= 1.3.1)
|
|
||||||
fugit (>= 1.11.0)
|
|
||||||
railties (>= 6.1.0)
|
|
||||||
thor (>= 1.0.0)
|
|
||||||
hashdiff (1.1.2)
|
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hotwire-livereload (2.0.0)
|
hotwire-livereload (2.0.0)
|
||||||
actioncable (>= 7.0.0)
|
actioncable (>= 7.0.0)
|
||||||
listen (>= 3.0.0)
|
listen (>= 3.0.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
|
hotwire_combobox (0.4.0)
|
||||||
|
platform_agent (>= 1.0.1)
|
||||||
|
rails (>= 7.0.7.2)
|
||||||
|
stimulus-rails (>= 1.2)
|
||||||
|
turbo-rails (>= 1.2)
|
||||||
|
htmlbeautifier (1.4.3)
|
||||||
|
htmlentities (4.3.4)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (1.0.14)
|
i18n-tasks (1.0.15)
|
||||||
activesupport (>= 4.0.2)
|
activesupport (>= 4.0.2)
|
||||||
ast (>= 2.1.0)
|
ast (>= 2.1.0)
|
||||||
erubi
|
erubi
|
||||||
|
@ -217,6 +248,7 @@ GEM
|
||||||
parser (>= 3.2.2.1)
|
parser (>= 3.2.2.1)
|
||||||
rails-i18n
|
rails-i18n
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
ruby-progressbar (~> 1.8, >= 1.8.1)
|
||||||
terminal-table (>= 1.5.1)
|
terminal-table (>= 1.5.1)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
|
@ -232,26 +264,30 @@ GEM
|
||||||
activesupport (> 4.0)
|
activesupport (> 4.0)
|
||||||
jwt (~> 2.0)
|
jwt (~> 2.0)
|
||||||
io-console (0.8.0)
|
io-console (0.8.0)
|
||||||
irb (1.15.1)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
jbuilder (2.13.0)
|
||||||
|
actionview (>= 5.0.0)
|
||||||
|
activesupport (>= 5.0.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.10.1)
|
json (2.12.2)
|
||||||
jwt (2.10.1)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
language_server-protocol (3.17.0.4)
|
language_server-protocol (3.17.0.5)
|
||||||
launchy (3.1.0)
|
launchy (3.1.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
childprocess (~> 5.0)
|
childprocess (~> 5.0)
|
||||||
logger (~> 1.6)
|
logger (~> 1.6)
|
||||||
letter_opener (1.10.0)
|
letter_opener (1.10.0)
|
||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
|
lint_roller (1.1.0)
|
||||||
listen (3.9.0)
|
listen (3.9.0)
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
rb-inotify (~> 0.9, >= 0.9.10)
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
logger (1.6.6)
|
logger (1.7.0)
|
||||||
logtail (0.1.15)
|
logtail (0.1.17)
|
||||||
msgpack (~> 1.0)
|
msgpack (~> 1.0)
|
||||||
logtail-rack (0.2.6)
|
logtail-rack (0.2.6)
|
||||||
logtail (~> 0.1)
|
logtail (~> 0.1)
|
||||||
|
@ -262,9 +298,21 @@ GEM
|
||||||
logtail (~> 0.1, >= 0.1.14)
|
logtail (~> 0.1, >= 0.1.14)
|
||||||
logtail-rack (~> 0.1)
|
logtail-rack (~> 0.1)
|
||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
loofah (2.24.0)
|
loofah (2.24.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
|
lookbook (2.3.11)
|
||||||
|
activemodel
|
||||||
|
css_parser
|
||||||
|
htmlbeautifier (~> 1.3)
|
||||||
|
htmlentities (~> 4.3.4)
|
||||||
|
marcel (~> 1.0)
|
||||||
|
railties (>= 5.0)
|
||||||
|
redcarpet (~> 3.5)
|
||||||
|
rouge (>= 3.26, < 5.0)
|
||||||
|
view_component (>= 2.0)
|
||||||
|
yard (~> 0.9)
|
||||||
|
zeitwerk (~> 2.5)
|
||||||
mail (2.8.1)
|
mail (2.8.1)
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
|
@ -272,53 +320,58 @@ GEM
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
matrix (0.4.2)
|
matrix (0.4.2)
|
||||||
mini_magick (5.1.2)
|
memory_profiler (1.1.0)
|
||||||
|
method_source (1.1.0)
|
||||||
|
mini_histogram (0.3.1)
|
||||||
|
mini_magick (5.2.0)
|
||||||
benchmark
|
benchmark
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.25.4)
|
minitest (5.25.5)
|
||||||
mocha (2.7.1)
|
mocha (2.7.1)
|
||||||
ruby2_keywords (>= 0.0.5)
|
ruby2_keywords (>= 0.0.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.5)
|
net-imap (0.5.8)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.2.2)
|
net-protocol (0.2.2)
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.0)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-aarch64-linux-musl)
|
nokogiri (1.18.8-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-arm-linux-gnu)
|
nokogiri (1.18.8-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-arm-linux-musl)
|
nokogiri (1.18.8-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-arm64-darwin)
|
nokogiri (1.18.8-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-x86_64-darwin)
|
nokogiri (1.18.8-x86_64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.2-x86_64-linux-musl)
|
nokogiri (1.18.8-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
octokit (9.2.0)
|
octokit (10.0.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
sawyer (~> 0.9)
|
sawyer (~> 0.9)
|
||||||
pagy (9.3.3)
|
ostruct (0.6.2)
|
||||||
parallel (1.26.3)
|
pagy (9.3.5)
|
||||||
parser (3.3.7.0)
|
parallel (1.27.0)
|
||||||
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pg (1.5.9)
|
pg (1.5.9)
|
||||||
plaid (36.0.0)
|
plaid (41.0.0)
|
||||||
faraday (>= 1.0.1, < 3.0)
|
faraday (>= 1.0.1, < 3.0)
|
||||||
faraday-multipart (>= 1.0.1, < 2.0)
|
faraday-multipart (>= 1.0.1, < 2.0)
|
||||||
platform_agent (1.0.1)
|
platform_agent (1.0.1)
|
||||||
|
@ -327,24 +380,26 @@ GEM
|
||||||
pp (0.6.2)
|
pp (0.6.2)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.3.0)
|
prism (1.4.0)
|
||||||
propshaft (1.1.0)
|
propshaft (1.1.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
psych (5.2.3)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.1)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.0)
|
puma (6.6.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.1.10)
|
rack (3.1.16)
|
||||||
rack-mini-profiler (3.3.1)
|
rack-attack (6.7.0)
|
||||||
|
rack (>= 1.0, < 4)
|
||||||
|
rack-mini-profiler (4.0.0)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-session (2.1.0)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
|
@ -365,7 +420,7 @@ GEM
|
||||||
activesupport (= 7.2.2.1)
|
activesupport (= 7.2.2.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 7.2.2.1)
|
railties (= 7.2.2.1)
|
||||||
rails-dom-testing (2.2.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
|
@ -387,61 +442,72 @@ GEM
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.2.1)
|
rake (13.3.0)
|
||||||
rb-fsevent (0.11.2)
|
rb-fsevent (0.11.2)
|
||||||
rb-inotify (0.11.1)
|
rb-inotify (0.11.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
rbs (3.8.1)
|
rbs (3.9.4)
|
||||||
logger
|
logger
|
||||||
rdoc (6.12.0)
|
rdoc (6.14.2)
|
||||||
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
redcarpet (3.6.0)
|
redcarpet (3.6.1)
|
||||||
|
redis (5.4.0)
|
||||||
|
redis-client (>= 0.22.0)
|
||||||
|
redis-client (0.25.0)
|
||||||
|
connection_pool
|
||||||
regexp_parser (2.10.0)
|
regexp_parser (2.10.0)
|
||||||
reline (0.6.0)
|
reline (0.6.1)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.0)
|
rexml (3.4.1)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (2.2.0)
|
rouge (4.5.2)
|
||||||
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (1.2.0)
|
rqrcode_core (2.0.0)
|
||||||
rubocop (1.71.0)
|
rubocop (1.76.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (>= 3.17.0)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
lint_roller (~> 1.1.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.36.2, < 2.0)
|
rubocop-ast (>= 1.45.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.38.0)
|
rubocop-ast (1.45.1)
|
||||||
parser (>= 3.3.1.0)
|
parser (>= 3.3.7.2)
|
||||||
rubocop-minitest (0.36.0)
|
prism (~> 1.4)
|
||||||
rubocop (>= 1.61, < 2.0)
|
rubocop-performance (1.25.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
lint_roller (~> 1.1)
|
||||||
rubocop-performance (1.23.1)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop (>= 1.48.1, < 2.0)
|
rubocop-ast (>= 1.38.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-rails (2.32.0)
|
||||||
rubocop-rails (2.29.1)
|
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.52.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.31.1, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rails-omakase (1.0.0)
|
rubocop-rails-omakase (1.1.0)
|
||||||
rubocop
|
rubocop (>= 1.72)
|
||||||
rubocop-minitest
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-performance
|
rubocop-rails (>= 2.30)
|
||||||
rubocop-rails
|
ruby-lsp (0.24.1)
|
||||||
ruby-lsp (0.23.9)
|
|
||||||
language_server-protocol (~> 3.17.0)
|
language_server-protocol (~> 3.17.0)
|
||||||
prism (>= 1.2, < 2.0)
|
prism (>= 1.2, < 2.0)
|
||||||
rbs (>= 3, < 4)
|
rbs (>= 3, < 5)
|
||||||
sorbet-runtime (>= 0.5.10782)
|
sorbet-runtime (>= 0.5.10782)
|
||||||
ruby-lsp-rails (0.4.0)
|
ruby-lsp-rails (0.4.6)
|
||||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
ruby-lsp (>= 0.24.0, < 0.25.0)
|
||||||
|
ruby-openai (8.1.0)
|
||||||
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||||
|
faraday (>= 1)
|
||||||
|
faraday-multipart (>= 1)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.2.3)
|
ruby-statistics (4.1.0)
|
||||||
|
ruby-vips (2.2.4)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
|
@ -450,89 +516,112 @@ GEM
|
||||||
addressable (>= 2.3.5)
|
addressable (>= 2.3.5)
|
||||||
faraday (>= 0.17.3, < 3)
|
faraday (>= 0.17.3, < 3)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.28.0)
|
selenium-webdriver (4.34.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (5.22.4)
|
sentry-rails (5.26.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
sentry-ruby (~> 5.22.4)
|
sentry-ruby (~> 5.26.0)
|
||||||
sentry-ruby (5.22.4)
|
sentry-ruby (5.26.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
|
sentry-sidekiq (5.26.0)
|
||||||
|
sentry-ruby (~> 5.26.0)
|
||||||
|
sidekiq (>= 3.0)
|
||||||
|
sidekiq (8.0.5)
|
||||||
|
connection_pool (>= 2.5.0)
|
||||||
|
json (>= 2.9.0)
|
||||||
|
logger (>= 1.6.2)
|
||||||
|
rack (>= 3.1.0)
|
||||||
|
redis-client (>= 0.23.2)
|
||||||
|
sidekiq-cron (2.3.0)
|
||||||
|
cronex (>= 0.13.0)
|
||||||
|
fugit (~> 1.8, >= 1.11.1)
|
||||||
|
globalid (>= 1.0.1)
|
||||||
|
sidekiq (>= 6.5.0)
|
||||||
simplecov (0.22.0)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.1)
|
simplecov-html (0.13.1)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
|
skylight (6.0.4)
|
||||||
|
activesupport (>= 5.2.0)
|
||||||
smart_properties (1.17.0)
|
smart_properties (1.17.0)
|
||||||
sorbet-runtime (0.5.11813)
|
sorbet-runtime (0.5.12163)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.3)
|
stringio (3.1.7)
|
||||||
stripe (13.4.1)
|
stripe (15.3.0)
|
||||||
tailwindcss-rails (4.0.0)
|
tailwindcss-rails (4.2.3)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.0.6)
|
tailwindcss-ruby (4.1.8)
|
||||||
tailwindcss-ruby (4.0.6-aarch64-linux-gnu)
|
tailwindcss-ruby (4.1.8-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.0.6-aarch64-linux-musl)
|
tailwindcss-ruby (4.1.8-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.0.6-arm64-darwin)
|
tailwindcss-ruby (4.1.8-arm64-darwin)
|
||||||
tailwindcss-ruby (4.0.6-x86_64-darwin)
|
tailwindcss-ruby (4.1.8-x86_64-darwin)
|
||||||
tailwindcss-ruby (4.0.6-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.8-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.0.6-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.8-x86_64-linux-musl)
|
||||||
terminal-table (4.0.0)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 4)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
thor (1.3.2)
|
thor (1.3.2)
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
turbo-rails (2.0.11)
|
turbo-rails (2.0.16)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
unicode (0.4.4.5)
|
||||||
unicode-display_width (3.1.4)
|
unicode-display_width (3.1.4)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uri (1.0.2)
|
uri (1.0.3)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
vcr (6.3.1)
|
vcr (6.3.1)
|
||||||
base64
|
base64
|
||||||
|
vernier (1.8.0)
|
||||||
|
view_component (3.23.2)
|
||||||
|
activesupport (>= 5.2.0, < 8.1)
|
||||||
|
concurrent-ruby (~> 1)
|
||||||
|
method_source (~> 1.0)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
webmock (3.25.0)
|
webmock (3.25.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket (1.2.11)
|
websocket (1.2.11)
|
||||||
websocket-driver (0.7.7)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.1)
|
yard (0.9.37)
|
||||||
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
|
||||||
aarch64-linux-gnu
|
aarch64-linux-gnu
|
||||||
aarch64-linux-musl
|
aarch64-linux-musl
|
||||||
arm-linux
|
|
||||||
arm-linux-gnu
|
arm-linux-gnu
|
||||||
arm-linux-musl
|
arm-linux-musl
|
||||||
arm64-darwin
|
arm64-darwin
|
||||||
x86_64-darwin
|
x86_64-darwin
|
||||||
x86_64-linux
|
|
||||||
x86_64-linux-gnu
|
x86_64-linux-gnu
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
aasm
|
||||||
|
activerecord-import
|
||||||
|
after_commit_everywhere (~> 1.0)
|
||||||
aws-sdk-s3 (~> 1.177.0)
|
aws-sdk-s3 (~> 1.177.0)
|
||||||
bcrypt (~> 3.1)
|
bcrypt (~> 3.1)
|
||||||
benchmark-ips
|
benchmark-ips
|
||||||
|
@ -542,43 +631,55 @@ DEPENDENCIES
|
||||||
climate_control
|
climate_control
|
||||||
csv
|
csv
|
||||||
debug
|
debug
|
||||||
|
derailed_benchmarks
|
||||||
|
doorkeeper
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
erb_lint
|
erb_lint
|
||||||
faker
|
faker
|
||||||
faraday
|
faraday
|
||||||
faraday-multipart
|
faraday-multipart
|
||||||
faraday-retry
|
faraday-retry
|
||||||
good_job
|
foreman
|
||||||
hotwire-livereload
|
hotwire-livereload
|
||||||
hotwire_combobox!
|
hotwire_combobox
|
||||||
i18n-tasks
|
i18n-tasks
|
||||||
image_processing (>= 1.2)
|
image_processing (>= 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
inline_svg
|
inline_svg
|
||||||
intercom-rails
|
intercom-rails
|
||||||
|
jbuilder
|
||||||
jwt
|
jwt
|
||||||
letter_opener
|
letter_opener
|
||||||
logtail-rails
|
logtail-rails
|
||||||
|
lookbook (= 2.3.11)
|
||||||
lucide-rails!
|
lucide-rails!
|
||||||
mocha
|
mocha
|
||||||
octokit
|
octokit
|
||||||
|
ostruct
|
||||||
pagy
|
pagy
|
||||||
pg (~> 1.5)
|
pg (~> 1.5)
|
||||||
plaid
|
plaid
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
|
rack-attack (~> 6.6)
|
||||||
rack-mini-profiler
|
rack-mini-profiler
|
||||||
rails (~> 7.2.2)
|
rails (~> 7.2.2)
|
||||||
rails-settings-cached
|
rails-settings-cached
|
||||||
redcarpet
|
redcarpet
|
||||||
|
redis (~> 5.4)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 2.2)
|
rqrcode (~> 3.0)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
ruby-lsp-rails
|
ruby-lsp-rails
|
||||||
|
ruby-openai
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails
|
sentry-rails
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
|
sentry-sidekiq
|
||||||
|
sidekiq
|
||||||
|
sidekiq-cron
|
||||||
simplecov
|
simplecov
|
||||||
|
skylight
|
||||||
stackprof
|
stackprof
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
stripe
|
stripe
|
||||||
|
@ -586,11 +687,13 @@ DEPENDENCIES
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
vcr
|
vcr
|
||||||
|
vernier
|
||||||
|
view_component
|
||||||
web-console
|
web-console
|
||||||
webmock
|
webmock
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.6.3
|
2.6.9
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||||
css: bundle exec bin/rails tailwindcss:watch
|
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
|
||||||
worker: bundle exec good_job start
|
worker: bundle exec sidekiq
|
||||||
|
|
61
README.md
61
README.md
|
@ -1,14 +1,11 @@
|
||||||
<img width="1440" alt="dashboard_mockup" src="https://github.com/maybe-finance/maybe/assets/35243/a7763d0e-a942-42db-bde7-eb8d28106917">
|
|
||||||
<sup><i>(Note: The image above is a mockup of what we're working towards. We're rapidly approaching the functionality shown, but not all of the parts are ready just yet.)</i></sup>
|
|
||||||
|
|
||||||
# Maybe: The OS for your personal finances
|
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/13fc5ef4-ce0f-4073-a163-9dbc3eb4c8e5" />
|
||||||
|
|
||||||
|
# Maybe: The personal finance app for everyone
|
||||||
|
|
||||||
<b>Get
|
<b>Get
|
||||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||||
|
|
||||||
_If you're looking for the previous React codebase, you can find it
|
|
||||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
|
||||||
|
|
||||||
## Backstory
|
## Backstory
|
||||||
|
|
||||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||||
|
@ -27,11 +24,10 @@ and eventually offer a hosted version of the app for a small monthly fee.
|
||||||
|
|
||||||
## Maybe Hosting
|
## Maybe Hosting
|
||||||
|
|
||||||
There are 3 primary ways to use the Maybe app:
|
There are 2 primary ways to use the Maybe app:
|
||||||
|
|
||||||
1. Managed (easiest) - _coming soon..._
|
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
@ -42,6 +38,14 @@ Once you've done that, please visit
|
||||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||||
to get started!
|
to get started!
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests, along with the stacktraces to help debug them.
|
||||||
|
|
||||||
|
Any contributions that help improve performance are very much welcome.
|
||||||
|
|
||||||
|
https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints
|
||||||
|
|
||||||
## Local Development Setup
|
## Local Development Setup
|
||||||
|
|
||||||
**If you are trying to _self-host_ the Maybe app, stop here. You
|
**If you are trying to _self-host_ the Maybe app, stop here. You
|
||||||
|
@ -63,7 +67,7 @@ bin/setup
|
||||||
bin/dev
|
bin/dev
|
||||||
|
|
||||||
# Optionally, load demo data
|
# Optionally, load demo data
|
||||||
rake demo_data:reset
|
rake demo_data:default
|
||||||
```
|
```
|
||||||
|
|
||||||
And visit http://localhost:3000 to see the app. You can use the following
|
And visit http://localhost:3000 to see the app. You can use the following
|
||||||
|
@ -84,37 +88,10 @@ If you'd like multi-currency support, there are a few extra steps to follow.
|
||||||
|
|
||||||
### Setup Guides
|
### Setup Guides
|
||||||
|
|
||||||
#### Dev Container (optional)
|
- [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide)
|
||||||
|
- [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide)
|
||||||
This is 100% optional and meant for devs who don't want to worry about
|
- [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide)
|
||||||
installing requirements manually for their platform. You can
|
- Dev containers - visit [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more
|
||||||
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
|
|
||||||
to learn more about Dev Containers.
|
|
||||||
|
|
||||||
If you run into `could not connect to server` errors, you may need to change
|
|
||||||
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
|
|
||||||
Postgres container.
|
|
||||||
|
|
||||||
#### Mac
|
|
||||||
|
|
||||||
Please visit
|
|
||||||
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
|
||||||
|
|
||||||
#### Linux
|
|
||||||
|
|
||||||
Please visit
|
|
||||||
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
|
||||||
|
|
||||||
#### Windows
|
|
||||||
|
|
||||||
Please visit
|
|
||||||
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
|
||||||
|
|
||||||
### Testing Emails
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Repo Activity
|
## Repo Activity
|
||||||
|
|
||||||
|
|
71
app/assets/images/ai-dark.svg
Normal file
71
app/assets/images/ai-dark.svg
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<g filter="url(#filter0_i_2942_1229)">
|
||||||
|
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1229)"/>
|
||||||
|
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_ii_2942_1229)">
|
||||||
|
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1229)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||||
|
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1229)"/>
|
||||||
|
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||||
|
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1229)"/>
|
||||||
|
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||||
|
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1229)"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_2942_1229" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.49869"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_ii_2942_1229" x="1.75" y="0.75" width="28.5" height="30.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="-1"/>
|
||||||
|
<feGaussianBlur stdDeviation="1"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.2 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="1"/>
|
||||||
|
<feGaussianBlur stdDeviation="1"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.15 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_innerShadow_2942_1229" result="effect2_innerShadow_2942_1229"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2942_1229" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2942_1229" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#171717"/>
|
||||||
|
<stop offset="0.3" stop-color="#0B0B0B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 8.6 KiB |
71
app/assets/images/ai.svg
Normal file
71
app/assets/images/ai.svg
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<g filter="url(#filter0_i_2942_1218)">
|
||||||
|
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1218)"/>
|
||||||
|
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#filter1_ii_2942_1218)">
|
||||||
|
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1218)"/>
|
||||||
|
</g>
|
||||||
|
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||||
|
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1218)"/>
|
||||||
|
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||||
|
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1218)"/>
|
||||||
|
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||||
|
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1218)"/>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_i_2942_1218" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset/>
|
||||||
|
<feGaussianBlur stdDeviation="0.49869"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="filter1_ii_2942_1218" x="1.75" y="0.861111" width="28.5" height="30.2778" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="-0.888889"/>
|
||||||
|
<feGaussianBlur stdDeviation="0.888889"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||||
|
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dy="0.888889"/>
|
||||||
|
<feGaussianBlur stdDeviation="0.888889"/>
|
||||||
|
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_innerShadow_2942_1218" result="effect2_innerShadow_2942_1218"/>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="paint0_linear_2942_1218" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_2942_1218" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white"/>
|
||||||
|
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint2_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint3_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint4_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#22CCEE"/>
|
||||||
|
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||||
|
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||||
|
<stop offset="1" stop-color="#F23E94"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 8.7 KiB |
37
app/assets/images/icon-assistant.svg
Normal file
37
app/assets/images/icon-assistant.svg
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25"
|
||||||
|
class="gradient-fill"
|
||||||
|
fill="url(#paint0_linear_2046_1939)" />
|
||||||
|
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path
|
||||||
|
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||||
|
fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||||
|
fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||||
|
fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||||
|
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||||
|
<path
|
||||||
|
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||||
|
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||||
|
<path
|
||||||
|
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||||
|
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||||
|
<style>
|
||||||
|
[data-theme=dark] .gradient-fill {
|
||||||
|
fill: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_2046_1939" x1="10" y1="6.25" x2="10" y2="20"
|
||||||
|
gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white" />
|
||||||
|
<stop offset="0.3" stop-color="#F7F7F7" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
After Width: | Height: | Size: 5.6 KiB |
5
app/assets/images/icon-csv.svg
Normal file
5
app/assets/images/icon-csv.svg
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M2.5 7.5H17.5M2.5 12.5H17.5M7.5 7.5V17.5M12.5 7.5V17.5M4.16667 2.5H15.8333C16.7538 2.5 17.5 3.24619 17.5 4.16667V15.8333C17.5 16.7538 16.7538 17.5 15.8333 17.5H4.16667C3.24619 17.5 2.5 16.7538 2.5 15.8333V4.16667C2.5 3.24619 3.24619 2.5 4.16667 2.5Z"
|
||||||
|
stroke="#737373" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 468 B |
|
@ -1 +0,0 @@
|
||||||
/* Application styles */
|
|
|
@ -8,10 +8,38 @@
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@plugin "@tailwindcss/forms";
|
@plugin "@tailwindcss/forms";
|
||||||
|
|
||||||
|
@import "./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;
|
||||||
|
}
|
||||||
|
.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 {
|
.combobox {
|
||||||
.hw-combobox__main__wrapper,
|
.hw-combobox__main__wrapper,
|
||||||
.hw-combobox__input {
|
.hw-combobox__input {
|
||||||
@apply w-full;
|
@apply bg-container text-primary w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hw-combobox__main__wrapper {
|
.hw-combobox__main__wrapper {
|
||||||
|
@ -25,6 +53,10 @@
|
||||||
.hw-combobox__label {
|
.hw-combobox__label {
|
||||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hw-combobox__option {
|
||||||
|
@apply bg-container hover:bg-container-hover;
|
||||||
|
}
|
||||||
|
|
||||||
.hw_combobox__pagination__wrapper {
|
.hw_combobox__pagination__wrapper {
|
||||||
@apply h-px;
|
@apply h-px;
|
||||||
|
@ -42,18 +74,22 @@
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
.prose {
|
.prose {
|
||||||
@apply max-w-none;
|
@apply max-w-none text-primary;
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply text-link;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-xl font-medium;
|
@apply text-xl font-medium text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@apply text-lg font-medium;
|
@apply text-lg font-medium text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
@apply m-0;
|
@apply m-0 text-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
details {
|
details {
|
||||||
|
@ -83,6 +119,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose--ai-chat {
|
||||||
|
@apply break-words;
|
||||||
|
|
||||||
|
p, li {
|
||||||
|
@apply text-sm text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar implementation for Windows browsers */
|
/* Custom scrollbar implementation for Windows browsers */
|
||||||
.windows {
|
.windows {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|
|
@ -5,6 +5,14 @@
|
||||||
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@import './maybe-design-system/background-utils.css';
|
||||||
|
@import './maybe-design-system/foreground-utils.css';
|
||||||
|
@import './maybe-design-system/text-utils.css';
|
||||||
|
@import './maybe-design-system/border-utils.css';
|
||||||
|
@import './maybe-design-system/component-utils.css';
|
||||||
|
|
||||||
|
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Font families */
|
/* Font families */
|
||||||
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--font-sans: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
@ -16,6 +24,12 @@
|
||||||
--color-success: var(--color-green-600);
|
--color-success: var(--color-green-600);
|
||||||
--color-warning: var(--color-yellow-600);
|
--color-warning: var(--color-yellow-600);
|
||||||
--color-destructive: var(--color-red-600);
|
--color-destructive: var(--color-red-600);
|
||||||
|
--color-shadow: --alpha(var(--color-black) / 6%);
|
||||||
|
|
||||||
|
/* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */
|
||||||
|
/* See @layer base block below for dark mode overrides */
|
||||||
|
--budget-unused-fill: var(--color-gray-200);
|
||||||
|
--budget-unallocated-fill: var(--color-gray-50);
|
||||||
|
|
||||||
/* Gray scale */
|
/* Gray scale */
|
||||||
--color-gray-25: #FAFAFA;
|
--color-gray-25: #FAFAFA;
|
||||||
|
@ -215,201 +229,166 @@
|
||||||
--shadow-md: 0px 4px 8px -2px --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-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
|
||||||
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
||||||
|
|
||||||
|
--animate-stroke-fill: stroke-fill 3s 300ms forwards;
|
||||||
|
|
||||||
|
@keyframes stroke-fill {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 43.9822971503;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom shadow borders used for surfaces / containers */
|
/* Specific override for strong tags in prose under dark mode */
|
||||||
@utility shadow-border-xs {
|
.prose:where([data-theme=dark], [data-theme=dark] *) strong {
|
||||||
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
|
color: theme(colors.white) !important;
|
||||||
}
|
|
||||||
|
|
||||||
@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 {
|
@layer base {
|
||||||
form>button {
|
[data-theme="dark"] {
|
||||||
@apply cursor-pointer;
|
--color-success: var(--color-green-500);
|
||||||
|
--color-warning: var(--color-yellow-400);
|
||||||
|
--color-destructive: var(--color-red-400);
|
||||||
|
--color-shadow: --alpha(var(--color-white) / 8%);
|
||||||
|
|
||||||
|
/* Dark mode overrides for colors used in Stimulus controllers with SVGs */
|
||||||
|
--budget-unused-fill: var(--color-gray-500);
|
||||||
|
--budget-unallocated-fill: var(--color-gray-700);
|
||||||
|
|
||||||
|
--shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
|
||||||
|
--shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
|
||||||
|
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);
|
||||||
|
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%);
|
||||||
|
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
@apply text-gray-200;
|
@apply text-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* We control the sizing through DialogComponent, so reset this value */
|
||||||
|
dialog:modal {
|
||||||
|
max-width: 100dvw;
|
||||||
|
max-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
details>summary::-webkit-details-marker {
|
details>summary::-webkit-details-marker {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary {
|
details>summary {
|
||||||
@apply list-none;
|
@apply list-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
select[multiple="multiple"] {
|
input[type='radio'] {
|
||||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600;
|
||||||
}
|
/* Default light mode */
|
||||||
|
|
||||||
select[multiple="multiple"] option {
|
@variant theme-dark {
|
||||||
@apply py-2 rounded-md;
|
/* Dark mode radio button base and checked styles */
|
||||||
}
|
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
|
||||||
|
}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 */
|
/* Forms */
|
||||||
.form-field {
|
.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 flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
|
||||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply focus-within:ring-alpha-white-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-container-inset after:text-gray-500 after:ml-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
option:active,
|
||||||
|
option:focus {
|
||||||
|
@apply bg-container-inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* New form field structure components */
|
||||||
|
.form-field__header {
|
||||||
|
@apply flex items-center justify-between gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__body {
|
||||||
|
@apply flex flex-col gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field__actions {
|
||||||
|
@apply flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
.form-field__label {
|
.form-field__label {
|
||||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__input {
|
.form-field__input {
|
||||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
|
||||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||||
@apply placeholder-shown:opacity-50;
|
@apply placeholder-shown:opacity-50;
|
||||||
@apply disabled:text-gray-400;
|
@apply disabled:text-subdued;
|
||||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||||
|
@apply transition-opacity duration-300;
|
||||||
&select {
|
@apply placeholder:text-subdued;
|
||||||
@apply pr-8;
|
|
||||||
|
@variant theme-dark {
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__radio {
|
select.form-field__input {
|
||||||
@apply text-gray-900;
|
@apply pr-10 appearance-none;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||||
|
background-position: right -0.15rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.25rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-field__radio {
|
||||||
|
@apply text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
.form-field__submit {
|
.form-field__submit {
|
||||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checkboxes */
|
/* Checkboxes */
|
||||||
.checkbox {
|
.checkbox {
|
||||||
&[type='checkbox'] {
|
&[type='checkbox'] {
|
||||||
@apply rounded-sm;
|
@apply rounded-sm;
|
||||||
|
@apply transition-colors duration-300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -417,35 +396,51 @@
|
||||||
&[type='checkbox'] {
|
&[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;
|
@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 {
|
&[type='checkbox']:disabled {
|
||||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@variant theme-dark {
|
||||||
|
&[type='checkbox'] {
|
||||||
|
@apply ring-gray-900 checked:text-white;
|
||||||
|
background-color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[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='white' 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");
|
||||||
|
background-color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox--dark {
|
.checkbox--dark {
|
||||||
&[type='checkbox'] {
|
&[type='checkbox'] {
|
||||||
@apply ring-gray-900 checked:text-white;
|
@apply ring-gray-900 checked:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[type='checkbox']:disabled {
|
&[type='checkbox']:disabled {
|
||||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[type='checkbox']:checked {
|
&[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");
|
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 after:transition-transform after:duration-300 after:ease-in-out;
|
|
||||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltips */
|
/* Tooltips */
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply hidden absolute;
|
@apply hidden absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qrcode svg path {
|
||||||
|
fill: var(--color-black);
|
||||||
|
@variant theme-dark {
|
||||||
|
fill: var(--color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
@utility bg-surface {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-surface-hover {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-surface-inset {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-surface-inset-hover {
|
||||||
|
@apply bg-gray-200;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-container {
|
||||||
|
@apply bg-white;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-container-hover {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-container-inset {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-container-inset-hover {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-inverse {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-inverse-hover {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-overlay {
|
||||||
|
background-color: --alpha(var(--color-gray-100) / 50%);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
background-color: var(--color-alpha-black-900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-loader {
|
||||||
|
@apply bg-surface-inset animate-pulse;
|
||||||
|
}
|
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility shadow-border-sm {
|
||||||
|
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility shadow-border-md {
|
||||||
|
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility shadow-border-lg {
|
||||||
|
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility shadow-border-xl {
|
||||||
|
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-primary {
|
||||||
|
@apply border-alpha-black-300;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-alpha-white-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-secondary {
|
||||||
|
@apply border-alpha-black-200;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-alpha-white-300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-tertiary {
|
||||||
|
@apply border-alpha-black-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-alpha-white-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-divider {
|
||||||
|
@apply border-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-subdued {
|
||||||
|
@apply border-alpha-black-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-alpha-white-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-solid {
|
||||||
|
@apply border-black;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility border-destructive {
|
||||||
|
@apply border-red-500;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply border-red-400;
|
||||||
|
}
|
||||||
|
}
|
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal file
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
/* Button Backgrounds */
|
||||||
|
@utility button-bg-primary {
|
||||||
|
@apply bg-gray-900;
|
||||||
|
/* Maps to fg-primary light */
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-white;
|
||||||
|
/* Maps to fg-primary dark */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-primary-hover {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
/* Maps to fg-primary-variant light */
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
/* Maps to fg-primary-variant dark */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-secondary {
|
||||||
|
@apply bg-gray-50; /* Maps to fg-secondary light */
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-700; /* Maps to fg-secondary dark */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-secondary-hover {
|
||||||
|
@apply bg-gray-100; /* Maps to fg-secondary-variant light */
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-600; /* Maps to fg-secondary-variant dark */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-disabled {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-destructive {
|
||||||
|
@apply bg-red-500;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-red-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-destructive-hover {
|
||||||
|
@apply bg-red-600;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-ghost-hover {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800 fg-inverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility button-bg-outline-hover {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Styles */
|
||||||
|
@utility tab-item-active {
|
||||||
|
@apply bg-white;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility tab-item-hover {
|
||||||
|
@apply bg-gray-200;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility tab-bg-group {
|
||||||
|
@apply bg-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-alpha-black-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-nav-indicator {
|
||||||
|
@apply bg-black;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-white;
|
||||||
|
}
|
||||||
|
}
|
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal file
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
@utility fg-gray {
|
||||||
|
@apply text-gray-500;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-contrast {
|
||||||
|
@apply text-gray-400;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-inverse {
|
||||||
|
@apply text-white;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-primary {
|
||||||
|
@apply text-gray-900;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-primary-variant {
|
||||||
|
@apply text-gray-800;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-secondary {
|
||||||
|
@apply text-gray-50;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-secondary-variant {
|
||||||
|
@apply text-gray-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility fg-subdued {
|
||||||
|
@apply text-gray-400;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-500;
|
||||||
|
}
|
||||||
|
}
|
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal file
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
@utility text-primary {
|
||||||
|
@apply text-gray-900;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-inverse {
|
||||||
|
@apply text-white;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-secondary {
|
||||||
|
@apply text-gray-500;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-subdued {
|
||||||
|
@apply text-gray-400;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-link {
|
||||||
|
@apply text-blue-600;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply text-blue-500;
|
||||||
|
}
|
||||||
|
}
|
2
app/assets/tailwind/simonweb_pickr.css
Normal file
2
app/assets/tailwind/simonweb_pickr.css
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,4 +1,10 @@
|
||||||
module ApplicationCable
|
module ApplicationCable
|
||||||
class Connection < ActionCable::Connection::Base
|
class Connection < ActionCable::Connection::Base
|
||||||
|
rescue_from StandardError, with: :report_error
|
||||||
|
|
||||||
|
private
|
||||||
|
def report_error(e)
|
||||||
|
Sentry.capture_exception(e)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
7
app/components/DS/alert.html.erb
Normal file
7
app/components/DS/alert.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div class="<%= container_classes %>">
|
||||||
|
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
|
||||||
|
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<%= message %>
|
||||||
|
</div>
|
||||||
|
</div>
|
52
app/components/DS/alert.rb
Normal file
52
app/components/DS/alert.rb
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
class DS::Alert < DesignSystemComponent
|
||||||
|
def initialize(message:, variant: :info)
|
||||||
|
@message = message
|
||||||
|
@variant = variant
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :message, :variant
|
||||||
|
|
||||||
|
def container_classes
|
||||||
|
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
|
||||||
|
|
||||||
|
variant_classes = case variant
|
||||||
|
when :info
|
||||||
|
"bg-blue-50 text-blue-700 border-blue-200 theme-dark:bg-blue-900/20 theme-dark:text-blue-400 theme-dark:border-blue-800"
|
||||||
|
when :success
|
||||||
|
"bg-green-50 text-green-700 border-green-200 theme-dark:bg-green-900/20 theme-dark:text-green-400 theme-dark:border-green-800"
|
||||||
|
when :warning
|
||||||
|
"bg-yellow-50 text-yellow-700 border-yellow-200 theme-dark:bg-yellow-900/20 theme-dark:text-yellow-400 theme-dark:border-yellow-800"
|
||||||
|
when :error, :destructive
|
||||||
|
"bg-red-50 text-red-700 border-red-200 theme-dark:bg-red-900/20 theme-dark:text-red-400 theme-dark:border-red-800"
|
||||||
|
end
|
||||||
|
|
||||||
|
"#{base_classes} #{variant_classes}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_name
|
||||||
|
case variant
|
||||||
|
when :info
|
||||||
|
"info"
|
||||||
|
when :success
|
||||||
|
"check-circle"
|
||||||
|
when :warning
|
||||||
|
"alert-triangle"
|
||||||
|
when :error, :destructive
|
||||||
|
"x-circle"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_color
|
||||||
|
case variant
|
||||||
|
when :success
|
||||||
|
"success"
|
||||||
|
when :warning
|
||||||
|
"warning"
|
||||||
|
when :error, :destructive
|
||||||
|
"destructive"
|
||||||
|
else
|
||||||
|
"blue-600"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
13
app/components/DS/button.html.erb
Normal file
13
app/components/DS/button.html.erb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<%= container do %>
|
||||||
|
<% if icon && (icon_position != :right) %>
|
||||||
|
<%= helpers.icon(icon, size: size, color: icon_color, class: icon_classes) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% unless icon_only? %>
|
||||||
|
<%= text %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if icon && icon_position == :right %>
|
||||||
|
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
41
app/components/DS/button.rb
Normal file
41
app/components/DS/button.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
|
||||||
|
# options available.
|
||||||
|
class DS::Button < DS::Buttonish
|
||||||
|
attr_reader :confirm
|
||||||
|
|
||||||
|
def initialize(confirm: nil, **opts)
|
||||||
|
super(**opts)
|
||||||
|
@confirm = confirm
|
||||||
|
end
|
||||||
|
|
||||||
|
def container(&block)
|
||||||
|
if href.present?
|
||||||
|
button_to(href, **merged_opts, &block)
|
||||||
|
else
|
||||||
|
content_tag(:button, **merged_opts, &block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def merged_opts
|
||||||
|
merged_opts = opts.dup || {}
|
||||||
|
extra_classes = merged_opts.delete(:class)
|
||||||
|
href = merged_opts.delete(:href)
|
||||||
|
data = merged_opts.delete(:data) || {}
|
||||||
|
|
||||||
|
if confirm.present?
|
||||||
|
data = data.merge(turbo_confirm: confirm.to_data_attribute)
|
||||||
|
end
|
||||||
|
|
||||||
|
if frame.present?
|
||||||
|
data = data.merge(turbo_frame: frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
merged_opts.merge(
|
||||||
|
class: class_names(container_classes, extra_classes),
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
156
app/components/DS/buttonish.rb
Normal file
156
app/components/DS/buttonish.rb
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
class DS::Buttonish < DesignSystemComponent
|
||||||
|
VARIANTS = {
|
||||||
|
primary: {
|
||||||
|
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
|
||||||
|
icon_classes: "fg-inverse"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
|
||||||
|
icon_classes: "fg-primary"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600",
|
||||||
|
icon_classes: "fg-white"
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
|
||||||
|
icon_classes: "fg-gray"
|
||||||
|
},
|
||||||
|
outline_destructive: {
|
||||||
|
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
|
||||||
|
icon_classes: "fg-gray"
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
|
||||||
|
icon_classes: "fg-gray"
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700",
|
||||||
|
icon_classes: "fg-gray"
|
||||||
|
},
|
||||||
|
icon_inverse: {
|
||||||
|
container_classes: "bg-inverse hover:bg-inverse-hover",
|
||||||
|
icon_classes: "fg-inverse"
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
SIZES = {
|
||||||
|
sm: {
|
||||||
|
container_classes: "px-2 py-1",
|
||||||
|
icon_container_classes: "inline-flex items-center justify-center w-8 h-8",
|
||||||
|
radius_classes: "rounded-md",
|
||||||
|
text_classes: "text-sm"
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container_classes: "px-3 py-2",
|
||||||
|
icon_container_classes: "inline-flex items-center justify-center w-9 h-9",
|
||||||
|
radius_classes: "rounded-lg",
|
||||||
|
text_classes: "text-sm"
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
container_classes: "px-4 py-3",
|
||||||
|
icon_container_classes: "inline-flex items-center justify-center w-10 h-10",
|
||||||
|
radius_classes: "rounded-xl",
|
||||||
|
text_classes: "text-base"
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
attr_reader :variant, :size, :href, :icon, :icon_position, :text, :full_width, :extra_classes, :frame, :opts
|
||||||
|
|
||||||
|
def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, frame: nil, **opts)
|
||||||
|
@variant = variant.to_s.underscore.to_sym
|
||||||
|
@size = size.to_sym
|
||||||
|
@href = href
|
||||||
|
@icon = icon
|
||||||
|
@icon_position = icon_position.to_sym
|
||||||
|
@text = text
|
||||||
|
@full_width = full_width
|
||||||
|
@extra_classes = opts.delete(:class)
|
||||||
|
@frame = frame
|
||||||
|
@opts = opts
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
|
||||||
|
end
|
||||||
|
|
||||||
|
def container_classes(override_classes = nil)
|
||||||
|
class_names(
|
||||||
|
"font-medium whitespace-nowrap",
|
||||||
|
merged_base_classes,
|
||||||
|
full_width ? "w-full justify-center" : nil,
|
||||||
|
container_size_classes,
|
||||||
|
size_data.dig(:text_classes),
|
||||||
|
variant_data.dig(:container_classes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def container_size_classes
|
||||||
|
icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_color
|
||||||
|
# Map variant to icon color for the icon helper
|
||||||
|
case variant
|
||||||
|
when :primary, :icon_inverse
|
||||||
|
:white
|
||||||
|
when :destructive, :outline_destructive
|
||||||
|
:destructive
|
||||||
|
else
|
||||||
|
:default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_classes
|
||||||
|
class_names(
|
||||||
|
variant_data.dig(:icon_classes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_only?
|
||||||
|
variant.in?([ :icon, :icon_inverse ])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def variant_data
|
||||||
|
self.class::VARIANTS.dig(variant)
|
||||||
|
end
|
||||||
|
|
||||||
|
def size_data
|
||||||
|
self.class::SIZES.dig(size)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Make sure that user can override common classes like `hidden`
|
||||||
|
def merged_base_classes
|
||||||
|
base_display_classes = "inline-flex items-center gap-1"
|
||||||
|
base_radius_classes = size_data.dig(:radius_classes)
|
||||||
|
|
||||||
|
extra_classes_list = (extra_classes || "").split
|
||||||
|
|
||||||
|
has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) }
|
||||||
|
has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) }
|
||||||
|
|
||||||
|
base_classes = []
|
||||||
|
|
||||||
|
unless has_display_override
|
||||||
|
base_classes << base_display_classes
|
||||||
|
end
|
||||||
|
|
||||||
|
unless has_radius_override
|
||||||
|
base_classes << base_radius_classes
|
||||||
|
end
|
||||||
|
|
||||||
|
class_names(
|
||||||
|
base_classes,
|
||||||
|
extra_classes
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_radius_override_classes
|
||||||
|
[ "rounded-full" ]
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_display_override_classes
|
||||||
|
[ "hidden", "flex" ]
|
||||||
|
end
|
||||||
|
end
|
38
app/components/DS/dialog.html.erb
Normal file
38
app/components/DS/dialog.html.erb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<%= wrapper_element do %>
|
||||||
|
<%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
|
||||||
|
<%= tag.div class: dialog_outer_classes do %>
|
||||||
|
<%= tag.div class: dialog_inner_classes, data: { DS__dialog_target: "content" } do %>
|
||||||
|
<div class="grow overflow-y-auto py-4 space-y-4 flex flex-col">
|
||||||
|
<% if header? %>
|
||||||
|
<%= header %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if body? %>
|
||||||
|
<div class="px-4 grow">
|
||||||
|
<%= body %>
|
||||||
|
|
||||||
|
<% if sections.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% sections.each do |section| %>
|
||||||
|
<%= section %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Optional, for customizing dialogs %>
|
||||||
|
<%= content %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if actions? %>
|
||||||
|
<div class="flex items-center gap-2 justify-end p-4">
|
||||||
|
<% actions.each do |action| %>
|
||||||
|
<%= action %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
115
app/components/DS/dialog.rb
Normal file
115
app/components/DS/dialog.rb
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
class DS::Dialog < DesignSystemComponent
|
||||||
|
renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
|
||||||
|
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
|
||||||
|
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
|
||||||
|
title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
|
||||||
|
close_icon = render DS::Button.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "DS--dialog#close" }) unless hide_close_icon
|
||||||
|
safe_join([ title, close_icon ].compact)
|
||||||
|
end
|
||||||
|
|
||||||
|
subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle
|
||||||
|
|
||||||
|
block_content = capture(&block) if block
|
||||||
|
|
||||||
|
safe_join([ title_div, subtitle, block_content ].compact)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
renders_one :body
|
||||||
|
|
||||||
|
renders_many :actions, ->(cancel_action: false, **button_opts) do
|
||||||
|
merged_opts = if cancel_action
|
||||||
|
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
|
||||||
|
else
|
||||||
|
button_opts
|
||||||
|
end
|
||||||
|
|
||||||
|
render DS::Button.new(**merged_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
renders_many :sections, ->(title:, **disclosure_opts, &block) do
|
||||||
|
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
|
||||||
|
block.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :opts
|
||||||
|
|
||||||
|
VARIANTS = %w[modal drawer].freeze
|
||||||
|
WIDTHS = {
|
||||||
|
sm: "lg:max-w-[300px]",
|
||||||
|
md: "lg:max-w-[550px]",
|
||||||
|
lg: "lg:max-w-[700px]",
|
||||||
|
full: "lg:max-w-full"
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, **opts)
|
||||||
|
@variant = variant.to_sym
|
||||||
|
@auto_open = auto_open
|
||||||
|
@reload_on_close = reload_on_close
|
||||||
|
@width = width.to_sym
|
||||||
|
@frame = frame
|
||||||
|
@disable_frame = disable_frame
|
||||||
|
@opts = opts
|
||||||
|
end
|
||||||
|
|
||||||
|
def frame
|
||||||
|
@frame || variant
|
||||||
|
end
|
||||||
|
|
||||||
|
# Caller must "opt-out" of using the default turbo-frame based on the variant
|
||||||
|
def wrapper_element(&block)
|
||||||
|
if disable_frame
|
||||||
|
content_tag(:div, &block)
|
||||||
|
else
|
||||||
|
content_tag("turbo-frame", id: frame, &block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dialog_outer_classes
|
||||||
|
variant_classes = if drawer?
|
||||||
|
"items-end justify-end"
|
||||||
|
else
|
||||||
|
"items-center justify-center"
|
||||||
|
end
|
||||||
|
|
||||||
|
class_names(
|
||||||
|
"flex h-full w-full",
|
||||||
|
variant_classes
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dialog_inner_classes
|
||||||
|
variant_classes = if drawer?
|
||||||
|
"lg:w-[550px] h-full"
|
||||||
|
else
|
||||||
|
class_names(
|
||||||
|
"max-h-full",
|
||||||
|
WIDTHS[width]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
class_names(
|
||||||
|
"flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full overflow-hidden",
|
||||||
|
variant_classes
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_opts
|
||||||
|
merged_opts = opts.dup
|
||||||
|
data = merged_opts.delete(:data) || {}
|
||||||
|
|
||||||
|
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
|
||||||
|
data[:DS__dialog_auto_open_value] = auto_open
|
||||||
|
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
||||||
|
data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
|
||||||
|
data[:hotkey] = "esc:DS--dialog#close"
|
||||||
|
merged_opts[:data] = data
|
||||||
|
|
||||||
|
merged_opts
|
||||||
|
end
|
||||||
|
|
||||||
|
def drawer?
|
||||||
|
variant == :drawer
|
||||||
|
end
|
||||||
|
end
|
33
app/components/DS/dialog_controller.js
Normal file
33
app/components/DS/dialog_controller.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Connects to data-controller="dialog"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["content"]
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
autoOpen: { type: Boolean, default: false },
|
||||||
|
reloadOnClose: { type: Boolean, default: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.element.open) return;
|
||||||
|
if (this.autoOpenValue) {
|
||||||
|
this.element.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user clicks anywhere outside of the visible content, close the dialog
|
||||||
|
clickOutside(e) {
|
||||||
|
if (!this.contentTarget.contains(e.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.element.close();
|
||||||
|
|
||||||
|
if (this.reloadOnCloseValue) {
|
||||||
|
Turbo.visit(window.location.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
app/components/DS/disclosure.html.erb
Normal file
27
app/components/DS/disclosure.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<details class="group" <%= "open" if open %>>
|
||||||
|
<%= tag.summary class: class_names(
|
||||||
|
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||||
|
) do %>
|
||||||
|
<% if summary_content? %>
|
||||||
|
<%= summary_content %>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<% if align == :left %>
|
||||||
|
<%= helpers.icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
|
||||||
|
<%= title %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if align == :right %>
|
||||||
|
<%= helpers.icon "chevron-down", class: "group-open:transform group-open:rotate-180" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= content %>
|
||||||
|
</div>
|
||||||
|
</details>
|
12
app/components/DS/disclosure.rb
Normal file
12
app/components/DS/disclosure.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class DS::Disclosure < DesignSystemComponent
|
||||||
|
renders_one :summary_content
|
||||||
|
|
||||||
|
attr_reader :title, :align, :open, :opts
|
||||||
|
|
||||||
|
def initialize(title: nil, align: "right", open: false, **opts)
|
||||||
|
@title = title
|
||||||
|
@align = align.to_sym
|
||||||
|
@open = open
|
||||||
|
@opts = opts
|
||||||
|
end
|
||||||
|
end
|
8
app/components/DS/filled_icon.html.erb
Normal file
8
app/components/DS/filled_icon.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<%= tag.div style: transparent? ? container_styles : nil,
|
||||||
|
class: container_classes do %>
|
||||||
|
<% if icon %>
|
||||||
|
<%= helpers.icon(icon, size: icon_size, color: "current") %>
|
||||||
|
<% elsif text %>
|
||||||
|
<%= tag.span text.first, class: text_classes %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
99
app/components/DS/filled_icon.rb
Normal file
99
app/components/DS/filled_icon.rb
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
class DS::FilledIcon < DesignSystemComponent
|
||||||
|
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||||
|
|
||||||
|
VARIANTS = %i[default text surface container inverse].freeze
|
||||||
|
|
||||||
|
SIZES = {
|
||||||
|
sm: {
|
||||||
|
container_size: "w-6 h-6",
|
||||||
|
container_radius: "rounded-md",
|
||||||
|
icon_size: "sm",
|
||||||
|
text_size: "text-xs"
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
container_size: "w-8 h-8",
|
||||||
|
container_radius: "rounded-lg",
|
||||||
|
icon_size: "md",
|
||||||
|
text_size: "text-xs"
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
container_size: "w-9 h-9",
|
||||||
|
container_radius: "rounded-xl",
|
||||||
|
icon_size: "lg",
|
||||||
|
text_size: "text-sm"
|
||||||
|
}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false)
|
||||||
|
@variant = variant.to_sym
|
||||||
|
@icon = icon
|
||||||
|
@text = text
|
||||||
|
@hex_color = hex_color
|
||||||
|
@size = size.to_sym
|
||||||
|
@rounded = rounded
|
||||||
|
end
|
||||||
|
|
||||||
|
def container_classes
|
||||||
|
class_names(
|
||||||
|
"flex justify-center items-center shrink-0",
|
||||||
|
size_classes,
|
||||||
|
radius_classes,
|
||||||
|
transparent? ? "border" : solid_bg_class
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_size
|
||||||
|
SIZES[size][:icon_size]
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_classes
|
||||||
|
class_names(
|
||||||
|
"text-center font-medium uppercase",
|
||||||
|
SIZES[size][:text_size]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def container_styles
|
||||||
|
<<~STYLE.strip
|
||||||
|
background-color: #{transparent_bg_color};
|
||||||
|
border-color: #{transparent_border_color};
|
||||||
|
color: #{custom_fg_color};
|
||||||
|
STYLE
|
||||||
|
end
|
||||||
|
|
||||||
|
def transparent?
|
||||||
|
variant.in?(%i[default text])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def solid_bg_class
|
||||||
|
case variant
|
||||||
|
when :surface
|
||||||
|
"bg-surface-inset"
|
||||||
|
when :container
|
||||||
|
"bg-container-inset"
|
||||||
|
when :inverse
|
||||||
|
"bg-container"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def size_classes
|
||||||
|
SIZES[size][:container_size]
|
||||||
|
end
|
||||||
|
|
||||||
|
def radius_classes
|
||||||
|
rounded ? "rounded-full" : SIZES[size][:container_radius]
|
||||||
|
end
|
||||||
|
|
||||||
|
def custom_fg_color
|
||||||
|
hex_color || "var(--color-gray-500)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def transparent_bg_color
|
||||||
|
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
||||||
|
end
|
||||||
|
|
||||||
|
def transparent_border_color
|
||||||
|
"color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
|
||||||
|
end
|
||||||
|
end
|
13
app/components/DS/link.html.erb
Normal file
13
app/components/DS/link.html.erb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<%= link_to href, **merged_opts do %>
|
||||||
|
<% if icon && (icon_position != "right") %>
|
||||||
|
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% unless icon_only? %>
|
||||||
|
<%= text %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if icon && icon_position == "right" %>
|
||||||
|
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
31
app/components/DS/link.rb
Normal file
31
app/components/DS/link.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
|
||||||
|
# options available.
|
||||||
|
class DS::Link < DS::Buttonish
|
||||||
|
attr_reader :frame
|
||||||
|
|
||||||
|
VARIANTS = VARIANTS.reverse_merge(
|
||||||
|
default: {
|
||||||
|
container_classes: "",
|
||||||
|
icon_classes: "fg-gray"
|
||||||
|
}
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
def merged_opts
|
||||||
|
merged_opts = opts.dup || {}
|
||||||
|
data = merged_opts.delete(:data) || {}
|
||||||
|
|
||||||
|
if frame
|
||||||
|
data = data.merge(turbo_frame: frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
merged_opts.merge(
|
||||||
|
class: class_names(container_classes, extra_classes),
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def container_size_classes
|
||||||
|
super unless variant == :default
|
||||||
|
end
|
||||||
|
end
|
27
app/components/DS/menu.html.erb
Normal file
27
app/components/DS/menu.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
|
||||||
|
<% if variant == :icon %>
|
||||||
|
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
|
||||||
|
<% elsif variant == :button %>
|
||||||
|
<%= button %>
|
||||||
|
<% elsif variant == :avatar %>
|
||||||
|
<button data-DS--menu-target="button">
|
||||||
|
<div class="w-9 h-9 cursor-pointer">
|
||||||
|
<%= render "settings/user_avatar", avatar_url: avatar_url, initials: initials %>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||||
|
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
|
||||||
|
<%= header %>
|
||||||
|
|
||||||
|
<%= tag.div class: class_names("py-1" => !no_padding) do %>
|
||||||
|
<% items.each do |item| %>
|
||||||
|
<%= item %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= custom_content %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
38
app/components/DS/menu.rb
Normal file
38
app/components/DS/menu.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DS::Menu < DesignSystemComponent
|
||||||
|
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
|
||||||
|
|
||||||
|
renders_one :button, ->(**button_options, &block) do
|
||||||
|
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
|
||||||
|
|
||||||
|
if block
|
||||||
|
content_tag(:button, **options_with_target, &block)
|
||||||
|
else
|
||||||
|
DS::Button.new(**options_with_target)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
renders_one :header, ->(&block) do
|
||||||
|
content_tag(:div, class: "border-b border-tertiary", &block)
|
||||||
|
end
|
||||||
|
|
||||||
|
renders_one :custom_content
|
||||||
|
|
||||||
|
renders_many :items, DS::MenuItem
|
||||||
|
|
||||||
|
VARIANTS = %i[icon button avatar].freeze
|
||||||
|
|
||||||
|
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
|
||||||
|
@variant = variant.to_sym
|
||||||
|
@avatar_url = avatar_url
|
||||||
|
@initials = initials
|
||||||
|
@placement = placement
|
||||||
|
@offset = offset
|
||||||
|
@icon_vertical = icon_vertical
|
||||||
|
@no_padding = no_padding
|
||||||
|
@testid = testid
|
||||||
|
|
||||||
|
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
|
||||||
|
end
|
||||||
|
end
|
12
app/components/DS/menu_item.html.erb
Normal file
12
app/components/DS/menu_item.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<% if variant == :divider %>
|
||||||
|
<%= render "shared/ruler", classes: "my-1" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="px-1">
|
||||||
|
<%= wrapper do %>
|
||||||
|
<% if icon %>
|
||||||
|
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>
|
||||||
|
<% end %>
|
||||||
|
<%= tag.span(text, class: text_classes) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
62
app/components/DS/menu_item.rb
Normal file
62
app/components/DS/menu_item.rb
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
class DS::MenuItem < DesignSystemComponent
|
||||||
|
VARIANTS = %i[link button divider].freeze
|
||||||
|
|
||||||
|
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
|
||||||
|
|
||||||
|
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)
|
||||||
|
@variant = variant.to_sym
|
||||||
|
@text = text
|
||||||
|
@icon = icon
|
||||||
|
@href = href
|
||||||
|
@method = method.to_sym
|
||||||
|
@destructive = destructive
|
||||||
|
@confirm = confirm
|
||||||
|
@frame = frame
|
||||||
|
@opts = opts
|
||||||
|
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
|
||||||
|
end
|
||||||
|
|
||||||
|
def wrapper(&block)
|
||||||
|
if variant == :button
|
||||||
|
button_to href, method: method, class: container_classes, **merged_opts, &block
|
||||||
|
elsif variant == :link
|
||||||
|
link_to href, class: container_classes, **merged_opts, &block
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def text_classes
|
||||||
|
[
|
||||||
|
"text-sm",
|
||||||
|
destructive? ? "text-destructive" : "text-primary"
|
||||||
|
].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def destructive?
|
||||||
|
method == :delete || destructive
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def container_classes
|
||||||
|
[
|
||||||
|
"flex items-center gap-2 p-2 rounded-md w-full",
|
||||||
|
destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover"
|
||||||
|
].join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_opts
|
||||||
|
merged_opts = opts.dup || {}
|
||||||
|
data = merged_opts.delete(:data) || {}
|
||||||
|
|
||||||
|
if confirm.present?
|
||||||
|
data = data.merge(turbo_confirm: confirm.to_data_attribute)
|
||||||
|
end
|
||||||
|
|
||||||
|
if frame.present?
|
||||||
|
data = data.merge(turbo_frame: frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
merged_opts.merge(data: data)
|
||||||
|
end
|
||||||
|
end
|
12
app/components/DS/tab.rb
Normal file
12
app/components/DS/tab.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
class DS::Tab < DesignSystemComponent
|
||||||
|
attr_reader :id, :label
|
||||||
|
|
||||||
|
def initialize(id:, label:)
|
||||||
|
@id = id
|
||||||
|
@label = label
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
content
|
||||||
|
end
|
||||||
|
end
|
18
app/components/DS/tabs.html.erb
Normal file
18
app/components/DS/tabs.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%= tag.div data: {
|
||||||
|
controller: "DS--tabs",
|
||||||
|
testid: testid,
|
||||||
|
DS__tabs_session_key_value: session_key,
|
||||||
|
DS__tabs_url_param_key_value: url_param_key,
|
||||||
|
DS__tabs_nav_btn_active_class: active_btn_classes,
|
||||||
|
DS__tabs_nav_btn_inactive_class: inactive_btn_classes
|
||||||
|
} do %>
|
||||||
|
<% if unstyled? %>
|
||||||
|
<%= content %>
|
||||||
|
<% else %>
|
||||||
|
<%= nav %>
|
||||||
|
|
||||||
|
<% panels.each do |panel| %>
|
||||||
|
<%= panel %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
66
app/components/DS/tabs.rb
Normal file
66
app/components/DS/tabs.rb
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
class DS::Tabs < DesignSystemComponent
|
||||||
|
renders_one :nav, ->(classes: nil) do
|
||||||
|
DS::Tabs::Nav.new(
|
||||||
|
active_tab: active_tab,
|
||||||
|
active_btn_classes: active_btn_classes,
|
||||||
|
inactive_btn_classes: inactive_btn_classes,
|
||||||
|
btn_classes: base_btn_classes,
|
||||||
|
classes: unstyled? ? classes : class_names(nav_container_classes, classes)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
renders_many :panels, ->(tab_id:, &block) do
|
||||||
|
content_tag(
|
||||||
|
:div,
|
||||||
|
class: ("hidden" unless tab_id == active_tab),
|
||||||
|
data: { id: tab_id, DS__tabs_target: "panel" },
|
||||||
|
&block
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
VARIANTS = {
|
||||||
|
default: {
|
||||||
|
active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
|
||||||
|
inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
|
||||||
|
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200",
|
||||||
|
nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attr_reader :active_tab, :url_param_key, :session_key, :variant, :testid
|
||||||
|
|
||||||
|
def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
||||||
|
@active_tab = active_tab
|
||||||
|
@url_param_key = url_param_key
|
||||||
|
@session_key = session_key
|
||||||
|
@variant = variant.to_sym
|
||||||
|
@active_btn_classes = active_btn_classes
|
||||||
|
@inactive_btn_classes = inactive_btn_classes
|
||||||
|
@testid = testid
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_btn_classes
|
||||||
|
unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def inactive_btn_classes
|
||||||
|
unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def unstyled?
|
||||||
|
variant == :unstyled
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_btn_classes
|
||||||
|
unless unstyled?
|
||||||
|
VARIANTS.dig(variant, :base_btn_classes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def nav_container_classes
|
||||||
|
unless unstyled?
|
||||||
|
VARIANTS.dig(variant, :nav_container_classes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
29
app/components/DS/tabs/nav.rb
Normal file
29
app/components/DS/tabs/nav.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
class DS::Tabs::Nav < DesignSystemComponent
|
||||||
|
erb_template <<~ERB
|
||||||
|
<%= tag.nav class: classes do %>
|
||||||
|
<% btns.each do |btn| %>
|
||||||
|
<%= btn %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
ERB
|
||||||
|
|
||||||
|
renders_many :btns, ->(id:, label:, classes: nil, &block) do
|
||||||
|
content_tag(
|
||||||
|
:button, label, id: id,
|
||||||
|
type: "button",
|
||||||
|
class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
|
||||||
|
data: { id: id, action: "DS--tabs#show", DS__tabs_target: "navBtn" },
|
||||||
|
&block
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes
|
||||||
|
|
||||||
|
def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
|
||||||
|
@active_tab = active_tab
|
||||||
|
@classes = classes
|
||||||
|
@active_btn_classes = active_btn_classes
|
||||||
|
@inactive_btn_classes = inactive_btn_classes
|
||||||
|
@btn_classes = btn_classes
|
||||||
|
end
|
||||||
|
end
|
11
app/components/DS/tabs/panel.rb
Normal file
11
app/components/DS/tabs/panel.rb
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
class DS::Tabs::Panel < DesignSystemComponent
|
||||||
|
attr_reader :tab_id
|
||||||
|
|
||||||
|
def initialize(tab_id:)
|
||||||
|
@tab_id = tab_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
content
|
||||||
|
end
|
||||||
|
end
|
57
app/components/DS/tabs_controller.js
Normal file
57
app/components/DS/tabs_controller.js
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
// Connects to data-controller="tabs--components"
|
||||||
|
export default class extends Controller {
|
||||||
|
static classes = ["navBtnActive", "navBtnInactive"];
|
||||||
|
static targets = ["panel", "navBtn"];
|
||||||
|
static values = { sessionKey: String, urlParamKey: String };
|
||||||
|
|
||||||
|
show(e) {
|
||||||
|
const btn = e.target.closest("button");
|
||||||
|
const selectedTabId = btn.dataset.id;
|
||||||
|
|
||||||
|
this.navBtnTargets.forEach((navBtn) => {
|
||||||
|
if (navBtn.dataset.id === selectedTabId) {
|
||||||
|
navBtn.classList.add(...this.navBtnActiveClasses);
|
||||||
|
navBtn.classList.remove(...this.navBtnInactiveClasses);
|
||||||
|
} else {
|
||||||
|
navBtn.classList.add(...this.navBtnInactiveClasses);
|
||||||
|
navBtn.classList.remove(...this.navBtnActiveClasses);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panelTargets.forEach((panel) => {
|
||||||
|
if (panel.dataset.id === selectedTabId) {
|
||||||
|
panel.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.urlParamKeyValue) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update URL with the selected tab
|
||||||
|
if (this.sessionKeyValue) {
|
||||||
|
this.#updateSessionPreference(selectedTabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateSessionPreference(selectedTabId) {
|
||||||
|
fetch("/current_session", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"current_session[tab_key]": this.sessionKeyValue,
|
||||||
|
"current_session[tab_value]": selectedTabId,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
app/components/DS/toggle.html.erb
Normal file
5
app/components/DS/toggle.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="relative inline-block select-none">
|
||||||
|
<%= hidden_field_tag name, unchecked_value, id: nil %>
|
||||||
|
<%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
|
||||||
|
<%= label_tag name, " ".html_safe, class: label_classes, for: id %>
|
||||||
|
</div>
|
26
app/components/DS/toggle.rb
Normal file
26
app/components/DS/toggle.rb
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
class DS::Toggle < DesignSystemComponent
|
||||||
|
attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
|
||||||
|
|
||||||
|
def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)
|
||||||
|
@id = id
|
||||||
|
@name = name
|
||||||
|
@checked = checked
|
||||||
|
@disabled = disabled
|
||||||
|
@checked_value = checked_value
|
||||||
|
@unchecked_value = unchecked_value
|
||||||
|
@opts = opts
|
||||||
|
end
|
||||||
|
|
||||||
|
def label_classes
|
||||||
|
class_names(
|
||||||
|
"block w-9 h-5 cursor-pointer",
|
||||||
|
"rounded-full bg-gray-100 theme-dark:bg-gray-700",
|
||||||
|
"transition-colors duration-300",
|
||||||
|
"after:content-[''] after:block after:bg-white after:absolute after:rounded-full",
|
||||||
|
"after:top-0.5 after:left-0.5 after:w-4 after:h-4",
|
||||||
|
"after:transition-transform after:duration-300 after:ease-in-out",
|
||||||
|
"peer-checked:bg-green-600 peer-checked:after:translate-x-4",
|
||||||
|
"peer-disabled:opacity-70 peer-disabled:cursor-not-allowed"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
9
app/components/DS/tooltip.html.erb
Normal file
9
app/components/DS/tooltip.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
|
||||||
|
<%= helpers.icon icon_name, size: size, color: color %>
|
||||||
|
|
||||||
|
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
|
||||||
|
<div class="fg-inverse font-normal max-w-[200px]">
|
||||||
|
<%= tooltip_content %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
17
app/components/DS/tooltip.rb
Normal file
17
app/components/DS/tooltip.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
class DS::Tooltip < ApplicationComponent
|
||||||
|
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
|
||||||
|
|
||||||
|
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||||
|
@text = text
|
||||||
|
@placement = placement
|
||||||
|
@offset = offset
|
||||||
|
@cross_axis = cross_axis
|
||||||
|
@icon_name = icon
|
||||||
|
@size = size
|
||||||
|
@color = color
|
||||||
|
end
|
||||||
|
|
||||||
|
def tooltip_content
|
||||||
|
content? ? content : @text
|
||||||
|
end
|
||||||
|
end
|
87
app/components/DS/tooltip_controller.js
Normal file
87
app/components/DS/tooltip_controller.js
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from "@floating-ui/dom";
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["tooltip"];
|
||||||
|
static values = {
|
||||||
|
placement: { type: String, default: "top" },
|
||||||
|
offset: { type: Number, default: 10 },
|
||||||
|
crossAxis: { type: Number, default: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this._cleanup = null;
|
||||||
|
this.boundUpdate = this.update.bind(this);
|
||||||
|
this.addEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.removeEventListeners();
|
||||||
|
this.stopAutoUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListeners() {
|
||||||
|
this.element.addEventListener("mouseenter", this.show);
|
||||||
|
this.element.addEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListeners() {
|
||||||
|
this.element.removeEventListener("mouseenter", this.show);
|
||||||
|
this.element.removeEventListener("mouseleave", this.hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
show = () => {
|
||||||
|
this.tooltipTarget.classList.remove("hidden");
|
||||||
|
this.startAutoUpdate();
|
||||||
|
this.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
hide = () => {
|
||||||
|
this.tooltipTarget.classList.add("hidden");
|
||||||
|
this.stopAutoUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
startAutoUpdate() {
|
||||||
|
if (!this._cleanup) {
|
||||||
|
const reference = this.element.querySelector("[data-icon]");
|
||||||
|
this._cleanup = autoUpdate(
|
||||||
|
reference || this.element,
|
||||||
|
this.tooltipTarget,
|
||||||
|
this.boundUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAutoUpdate() {
|
||||||
|
if (this._cleanup) {
|
||||||
|
this._cleanup();
|
||||||
|
this._cleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const reference = this.element.querySelector("[data-icon]");
|
||||||
|
computePosition(reference || this.element, this.tooltipTarget, {
|
||||||
|
placement: this.placementValue,
|
||||||
|
middleware: [
|
||||||
|
offset({
|
||||||
|
mainAxis: this.offsetValue,
|
||||||
|
crossAxis: this.crossAxisValue,
|
||||||
|
}),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 5 }),
|
||||||
|
],
|
||||||
|
}).then(({ x, y }) => {
|
||||||
|
Object.assign(this.tooltipTarget.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
103
app/components/UI/account/activity_date.html.erb
Normal file
103
app/components/UI/account/activity_date.html.erb
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
|
||||||
|
<details class="group">
|
||||||
|
<summary>
|
||||||
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
|
||||||
|
<div class="flex pl-0.5 items-center gap-4">
|
||||||
|
<%= check_box_tag "#{date}_entries_selection",
|
||||||
|
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
|
||||||
|
id: "selection_entry_#{date}",
|
||||||
|
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||||
|
|
||||||
|
<p class="uppercase space-x-1.5">
|
||||||
|
<%= tag.span I18n.l(date, format: :long) %>
|
||||||
|
<span>·</span>
|
||||||
|
<%= tag.span entries.size %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium"><%= balance_trend.current.format %></span>
|
||||||
|
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||||
|
</div>
|
||||||
|
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Start of day balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd class="font-bold"><%= start_balance_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<% if account.balance_type == :investment %>
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Cash
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= cash_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Holdings
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= holdings_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Cash
|
||||||
|
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= cash_change_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
End of day balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<hr class="border border-primary">
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Δ Value adjustments
|
||||||
|
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-secondary">
|
||||||
|
<dd><%= adjustments_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||||
|
<dt class="flex items-center gap-2">
|
||||||
|
Closing balance
|
||||||
|
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
|
||||||
|
</dt>
|
||||||
|
<hr class="grow border-dashed border-primary">
|
||||||
|
<dd class="font-bold"><%= end_balance_money.format %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="bg-container shadow-border-xs rounded-lg">
|
||||||
|
<% entries.each do |entry| %>
|
||||||
|
<%= render entry, view_ctx: "account" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
51
app/components/UI/account/activity_date.rb
Normal file
51
app/components/UI/account/activity_date.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
class UI::Account::ActivityDate < ApplicationComponent
|
||||||
|
attr_reader :account, :data
|
||||||
|
|
||||||
|
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
|
||||||
|
|
||||||
|
def initialize(account:, data:)
|
||||||
|
@account = account
|
||||||
|
@data = data
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
dom_id(account, "entries_#{date}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_channel
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_balance_money
|
||||||
|
balance_trend.previous
|
||||||
|
end
|
||||||
|
|
||||||
|
def cash_change_money
|
||||||
|
cash_balance_trend.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings_change_money
|
||||||
|
holdings_value_trend.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_balance_before_adjustments_money
|
||||||
|
balance_trend.previous + cash_change_money + holdings_change_money
|
||||||
|
end
|
||||||
|
|
||||||
|
def adjustments_money
|
||||||
|
end_balance_money - end_balance_before_adjustments_money
|
||||||
|
end
|
||||||
|
|
||||||
|
def end_balance_money
|
||||||
|
balance_trend.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_refresh!
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(
|
||||||
|
broadcast_channel,
|
||||||
|
target: id,
|
||||||
|
renderable: self,
|
||||||
|
layout: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
94
app/components/UI/account/activity_feed.html.erb
Normal file
94
app/components/UI/account/activity_feed.html.erb
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||||
|
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||||
|
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||||
|
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||||
|
|
||||||
|
<% if account.manual? %>
|
||||||
|
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||||
|
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||||
|
|
||||||
|
<% menu.with_item(
|
||||||
|
variant: "link",
|
||||||
|
text: "New balance",
|
||||||
|
icon: "circle-dollar-sign",
|
||||||
|
href: new_valuation_path(account_id: account.id),
|
||||||
|
data: { turbo_frame: :modal }) %>
|
||||||
|
|
||||||
|
<% unless account.crypto? %>
|
||||||
|
<% menu.with_item(
|
||||||
|
variant: "link",
|
||||||
|
text: "New transaction",
|
||||||
|
icon: "credit-card",
|
||||||
|
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
|
||||||
|
data: { turbo_frame: :modal }) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form_with url: account_path(account),
|
||||||
|
id: "entries-search",
|
||||||
|
scope: :q,
|
||||||
|
method: :get,
|
||||||
|
data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<div class="grow">
|
||||||
|
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||||
|
<%= helpers.icon("search") %>
|
||||||
|
|
||||||
|
<%= hidden_field_tag :account_id, account.id %>
|
||||||
|
|
||||||
|
<%= form.search_field :search,
|
||||||
|
placeholder: "Search entries by name",
|
||||||
|
value: search,
|
||||||
|
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
|
||||||
|
"data-auto-submit-form-target": "auto" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if activity_dates.empty? %>
|
||||||
|
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||||
|
<% else %>
|
||||||
|
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||||
|
data: {
|
||||||
|
controller: "bulk-select",
|
||||||
|
bulk_select_singular_label_value: "entry",
|
||||||
|
bulk_select_plural_label_value: "entries"
|
||||||
|
} do %>
|
||||||
|
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||||
|
<%= render "entries/selection_bar" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||||
|
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||||
|
<%= check_box_tag "selection_entry",
|
||||||
|
class: "checkbox checkbox--light",
|
||||||
|
data: { action: "bulk-select#togglePageSelection" } %>
|
||||||
|
<p>Date</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% activity_dates.each do |activity_date_data| %>
|
||||||
|
<%= render UI::Account::ActivityDate.new(
|
||||||
|
account: account,
|
||||||
|
data: activity_date_data
|
||||||
|
) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||||
|
<%= render "shared/pagination", pagy: pagy %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
35
app/components/UI/account/activity_feed.rb
Normal file
35
app/components/UI/account/activity_feed.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
class UI::Account::ActivityFeed < ApplicationComponent
|
||||||
|
attr_reader :feed_data, :pagy, :search
|
||||||
|
|
||||||
|
def initialize(feed_data:, pagy:, search: nil)
|
||||||
|
@feed_data = feed_data
|
||||||
|
@pagy = pagy
|
||||||
|
@search = search
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
dom_id(account, :activity_feed)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_channel
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_refresh!
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(
|
||||||
|
broadcast_channel,
|
||||||
|
target: id,
|
||||||
|
renderable: self,
|
||||||
|
layout: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def activity_dates
|
||||||
|
feed_data.entries_by_date
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def account
|
||||||
|
feed_data.account
|
||||||
|
end
|
||||||
|
end
|
58
app/components/UI/account/chart.html.erb
Normal file
58
app/components/UI/account/chart.html.erb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
<div id="<%= dom_id(account, :chart) %>" class="bg-container shadow-border-xs rounded-xl space-y-2">
|
||||||
|
<div class="flex justify-between flex-col-reverse lg:flex-row gap-2 px-4 pt-4 mb-2">
|
||||||
|
<div class="space-y-2 w-full">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
|
||||||
|
|
||||||
|
<% if account.investment? %>
|
||||||
|
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2 items-baseline">
|
||||||
|
<%= tag.p view_balance_money.format, class: "text-primary text-3xl font-medium truncate" %>
|
||||||
|
|
||||||
|
<% if converted_balance_money %>
|
||||||
|
<%= tag.p converted_balance_money.format, class: "text-sm font-medium text-secondary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<% if account.investment? %>
|
||||||
|
<%= form.select :chart_view,
|
||||||
|
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
|
||||||
|
{ selected: view },
|
||||||
|
class: "bg-container border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
|
||||||
|
data: { "auto-submit-form-target": "auto" } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form.select :period,
|
||||||
|
Period.as_options,
|
||||||
|
{ selected: period.key },
|
||||||
|
data: { "auto-submit-form-target": "auto" },
|
||||||
|
class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||||
|
<div class="px-4">
|
||||||
|
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: period.comparison_label } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-64 pb-4">
|
||||||
|
<% if series.any? %>
|
||||||
|
<div
|
||||||
|
id="lineChart"
|
||||||
|
class="w-full h-full"
|
||||||
|
data-controller="time-series-chart"
|
||||||
|
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-secondary text-sm">No data available</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
72
app/components/UI/account/chart.rb
Normal file
72
app/components/UI/account/chart.rb
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
class UI::Account::Chart < ApplicationComponent
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account:, period: nil, view: nil)
|
||||||
|
@account = account
|
||||||
|
@period = period
|
||||||
|
@view = view
|
||||||
|
end
|
||||||
|
|
||||||
|
def period
|
||||||
|
@period ||= Period.last_30_days
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings_value_money
|
||||||
|
account.balance_money - account.cash_balance_money
|
||||||
|
end
|
||||||
|
|
||||||
|
def view_balance_money
|
||||||
|
case view
|
||||||
|
when "balance"
|
||||||
|
account.balance_money
|
||||||
|
when "holdings_balance"
|
||||||
|
holdings_value_money
|
||||||
|
when "cash_balance"
|
||||||
|
account.cash_balance_money
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def title
|
||||||
|
case account.accountable_type
|
||||||
|
when "Investment", "Crypto"
|
||||||
|
case view
|
||||||
|
when "balance"
|
||||||
|
"Total account value"
|
||||||
|
when "holdings_balance"
|
||||||
|
"Holdings value"
|
||||||
|
when "cash_balance"
|
||||||
|
"Cash value"
|
||||||
|
end
|
||||||
|
when "Property", "Vehicle"
|
||||||
|
"Estimated #{account.accountable_type.humanize.downcase} value"
|
||||||
|
when "CreditCard", "OtherLiability"
|
||||||
|
"Debt balance"
|
||||||
|
when "Loan"
|
||||||
|
"Remaining principal balance"
|
||||||
|
else
|
||||||
|
"Balance"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def foreign_currency?
|
||||||
|
account.currency != account.family.currency
|
||||||
|
end
|
||||||
|
|
||||||
|
def converted_balance_money
|
||||||
|
return nil unless foreign_currency?
|
||||||
|
|
||||||
|
account.balance_money.exchange_to(account.family.currency, fallback_rate: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def view
|
||||||
|
@view ||= "balance"
|
||||||
|
end
|
||||||
|
|
||||||
|
def series
|
||||||
|
account.balance_series(period: period, view: view)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trend
|
||||||
|
series.trend
|
||||||
|
end
|
||||||
|
end
|
29
app/components/UI/account_page.html.erb
Normal file
29
app/components/UI/account_page.html.erb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<%= turbo_stream_from account %>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag id do %>
|
||||||
|
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||||
|
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
||||||
|
|
||||||
|
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
|
||||||
|
|
||||||
|
<div class="min-h-[800px]" data-testid="account-details">
|
||||||
|
<% if tabs.count > 1 %>
|
||||||
|
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
|
||||||
|
<% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
|
||||||
|
<% tabs.each do |tab| %>
|
||||||
|
<% nav.with_btn(id: tab, label: tab.to_s.humanize, classes: "px-6") %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% tabs.each do |tab| %>
|
||||||
|
<% tabs_container.with_panel(tab_id: tab) do %>
|
||||||
|
<%= tab_content_for(tab) %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%= tab_content_for(tabs.first) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
59
app/components/UI/account_page.rb
Normal file
59
app/components/UI/account_page.rb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
class UI::AccountPage < ApplicationComponent
|
||||||
|
attr_reader :account, :chart_view, :chart_period
|
||||||
|
|
||||||
|
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
|
||||||
|
|
||||||
|
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
|
||||||
|
@account = account
|
||||||
|
@chart_view = chart_view
|
||||||
|
@chart_period = chart_period
|
||||||
|
@active_tab = active_tab
|
||||||
|
end
|
||||||
|
|
||||||
|
def id
|
||||||
|
dom_id(account, :container)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_channel
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_refresh!
|
||||||
|
Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def title
|
||||||
|
account.name
|
||||||
|
end
|
||||||
|
|
||||||
|
def subtitle
|
||||||
|
return nil unless account.property?
|
||||||
|
|
||||||
|
account.property.address
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_tab
|
||||||
|
tabs.find { |tab| tab == @active_tab&.to_sym } || tabs.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def tabs
|
||||||
|
case account.accountable_type
|
||||||
|
when "Investment"
|
||||||
|
[ :activity, :holdings ]
|
||||||
|
when "Property", "Vehicle", "Loan"
|
||||||
|
[ :activity, :overview ]
|
||||||
|
else
|
||||||
|
[ :activity ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def tab_content_for(tab)
|
||||||
|
case tab
|
||||||
|
when :activity
|
||||||
|
activity_feed
|
||||||
|
when :holdings, :overview
|
||||||
|
# Accountable is responsible for implementing the partial in the correct folder
|
||||||
|
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
4
app/components/application_component.rb
Normal file
4
app/components/application_component.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class ApplicationComponent < ViewComponent::Base
|
||||||
|
# These don't work as expected with helpers.turbo_frame_tag, etc., so we include them here
|
||||||
|
include Turbo::FramesHelper, Turbo::StreamsHelper
|
||||||
|
end
|
2
app/components/design_system_component.rb
Normal file
2
app/components/design_system_component.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class DesignSystemComponent < ViewComponent::Base
|
||||||
|
end
|
|
@ -1,37 +0,0 @@
|
||||||
class Account::TradesController < ApplicationController
|
|
||||||
include EntryableResource
|
|
||||||
|
|
||||||
permitted_entryable_attributes :id, :qty, :price
|
|
||||||
|
|
||||||
private
|
|
||||||
def build_entry
|
|
||||||
Account::TradeBuilder.new(create_entry_params)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_entry_params
|
|
||||||
params.require(:account_entry).permit(
|
|
||||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
|
|
||||||
).tap do |params|
|
|
||||||
account_id = params.delete(:account_id)
|
|
||||||
params[:account] = Current.family.accounts.find(account_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_entry_params
|
|
||||||
return entry_params unless entry_params[:entryable_attributes].present?
|
|
||||||
|
|
||||||
update_params = entry_params
|
|
||||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
|
||||||
|
|
||||||
qty = update_params[:entryable_attributes][:qty]
|
|
||||||
price = update_params[:entryable_attributes][:price]
|
|
||||||
|
|
||||||
if qty.present? && price.present?
|
|
||||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
|
||||||
update_params[:entryable_attributes][:qty] = qty
|
|
||||||
update_params[:amount] = qty * price.to_d
|
|
||||||
end
|
|
||||||
|
|
||||||
update_params.except(:nature)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,22 +0,0 @@
|
||||||
class Account::TransactionCategoriesController < ApplicationController
|
|
||||||
def update
|
|
||||||
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
|
||||||
@entry.update!(entry_params)
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
|
||||||
format.turbo_stream do
|
|
||||||
render turbo_stream: turbo_stream.replace(
|
|
||||||
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
|
||||||
partial: "categories/menu",
|
|
||||||
locals: { transaction: @entry.account_transaction }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def entry_params
|
|
||||||
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,37 +0,0 @@
|
||||||
class Account::TransactionsController < ApplicationController
|
|
||||||
include EntryableResource
|
|
||||||
|
|
||||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
|
||||||
|
|
||||||
def bulk_delete
|
|
||||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
|
||||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_edit
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_update
|
|
||||||
updated = Current.family
|
|
||||||
.entries
|
|
||||||
.where(id: bulk_update_params[:entry_ids])
|
|
||||||
.bulk_update!(bulk_update_params)
|
|
||||||
|
|
||||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def bulk_delete_params
|
|
||||||
params.require(:bulk_delete).permit(entry_ids: [])
|
|
||||||
end
|
|
||||||
|
|
||||||
def bulk_update_params
|
|
||||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_params
|
|
||||||
params.fetch(:q, {})
|
|
||||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,3 +0,0 @@
|
||||||
class Account::ValuationsController < ApplicationController
|
|
||||||
include EntryableResource
|
|
||||||
end
|
|
|
@ -2,16 +2,24 @@ class AccountableSparklinesController < ApplicationController
|
||||||
def show
|
def show
|
||||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||||
|
|
||||||
@series = Rails.cache.fetch(cache_key) do
|
etag_key = cache_key
|
||||||
family.accounts.active
|
|
||||||
.where(accountable_type: @accountable.name)
|
|
||||||
.balance_series(
|
|
||||||
currency: family.currency,
|
|
||||||
favorable_direction: @accountable.favorable_direction
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
render layout: false
|
# Use HTTP conditional GET so the client receives 304 Not Modified when possible.
|
||||||
|
if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at)
|
||||||
|
@series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do
|
||||||
|
builder = Balance::ChartSeriesBuilder.new(
|
||||||
|
account_ids: account_ids,
|
||||||
|
currency: family.currency,
|
||||||
|
period: Period.last_30_days,
|
||||||
|
favorable_direction: @accountable.favorable_direction,
|
||||||
|
interval: "1 day"
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.balance_series
|
||||||
|
end
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -19,7 +27,15 @@ class AccountableSparklinesController < ApplicationController
|
||||||
Current.family
|
Current.family
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def accountable
|
||||||
|
Accountable.from_type(params[:accountable_type]&.classify)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_ids
|
||||||
|
family.accounts.visible.where(accountable_type: accountable.name).pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
def cache_key
|
def cache_key
|
||||||
family.build_cache_key("#{@accountable.name}_sparkline")
|
family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
class AccountsController < ApplicationController
|
class AccountsController < ApplicationController
|
||||||
before_action :set_account, only: %i[sync chart sparkline]
|
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
|
||||||
|
include Periodable
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@manual_accounts = family.accounts.manual.alphabetically
|
@manual_accounts = family.accounts.manual.alphabetically
|
||||||
|
@ -8,6 +9,17 @@ class AccountsController < ApplicationController
|
||||||
render layout: "settings"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@chart_view = params[:chart_view] || "balance"
|
||||||
|
@tab = params[:tab]
|
||||||
|
@q = params.fetch(:q, {}).permit(:search)
|
||||||
|
entries = @account.entries.search(@q).reverse_chronological
|
||||||
|
|
||||||
|
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||||
|
|
||||||
|
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
||||||
|
end
|
||||||
|
|
||||||
def sync
|
def sync
|
||||||
unless @account.syncing?
|
unless @account.syncing?
|
||||||
@account.sync_later
|
@account.sync_later
|
||||||
|
@ -16,22 +28,35 @@ class AccountsController < ApplicationController
|
||||||
redirect_to account_path(@account)
|
redirect_to account_path(@account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def chart
|
|
||||||
render layout: "application"
|
|
||||||
end
|
|
||||||
|
|
||||||
def sparkline
|
def sparkline
|
||||||
render layout: false
|
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
|
||||||
|
|
||||||
|
# Short-circuit with 304 Not Modified when the client already has the latest version.
|
||||||
|
# We defer the expensive series computation until we know the content is stale.
|
||||||
|
if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)
|
||||||
|
@sparkline_series = @account.sparkline_series
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_all
|
def toggle_active
|
||||||
unless family.syncing?
|
if @account.active?
|
||||||
family.sync_later
|
@account.disable!
|
||||||
|
elsif @account.disabled?
|
||||||
|
@account.enable!
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to accounts_path
|
redirect_to accounts_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
if @account.linked?
|
||||||
|
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||||
|
else
|
||||||
|
@account.destroy_later
|
||||||
|
redirect_to accounts_path, notice: "Account scheduled for deletion"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def family
|
def family
|
||||||
Current.family
|
Current.family
|
||||||
|
|
59
app/controllers/api/v1/accounts_controller.rb
Normal file
59
app/controllers/api/v1/accounts_controller.rb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::AccountsController < Api::V1::BaseController
|
||||||
|
include Pagy::Backend
|
||||||
|
|
||||||
|
# Ensure proper scope authorization for read access
|
||||||
|
before_action :ensure_read_scope
|
||||||
|
|
||||||
|
def index
|
||||||
|
# Test with Pagy pagination
|
||||||
|
family = current_resource_owner.family
|
||||||
|
accounts_query = family.accounts.visible.alphabetically
|
||||||
|
|
||||||
|
# Handle pagination with Pagy
|
||||||
|
@pagy, @accounts = pagy(
|
||||||
|
accounts_query,
|
||||||
|
page: safe_page_param,
|
||||||
|
limit: safe_per_page_param
|
||||||
|
)
|
||||||
|
|
||||||
|
@per_page = safe_per_page_param
|
||||||
|
|
||||||
|
# Rails will automatically use app/views/api/v1/accounts/index.json.jbuilder
|
||||||
|
render :index
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "AccountsController error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_read_scope
|
||||||
|
authorize_scope!(:read)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def safe_page_param
|
||||||
|
page = params[:page].to_i
|
||||||
|
page > 0 ? page : 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_per_page_param
|
||||||
|
per_page = params[:per_page].to_i
|
||||||
|
|
||||||
|
# Default to 25, max 100
|
||||||
|
case per_page
|
||||||
|
when 1..100
|
||||||
|
per_page
|
||||||
|
else
|
||||||
|
25
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
210
app/controllers/api/v1/auth_controller.rb
Normal file
210
app/controllers/api/v1/auth_controller.rb
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AuthController < BaseController
|
||||||
|
include Invitable
|
||||||
|
|
||||||
|
skip_before_action :authenticate_request!
|
||||||
|
skip_before_action :check_api_key_rate_limit
|
||||||
|
skip_before_action :log_api_access
|
||||||
|
|
||||||
|
def signup
|
||||||
|
# Check if invite code is required
|
||||||
|
if invite_code_required? && params[:invite_code].blank?
|
||||||
|
render json: { error: "Invite code is required" }, status: :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate invite code if provided
|
||||||
|
if params[:invite_code].present? && !InviteCode.exists?(token: params[:invite_code]&.downcase)
|
||||||
|
render json: { error: "Invalid invite code" }, status: :forbidden
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
password_errors = validate_password(params[:user][:password])
|
||||||
|
if password_errors.any?
|
||||||
|
render json: { errors: password_errors }, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate device info
|
||||||
|
unless valid_device_info?
|
||||||
|
render json: { error: "Device information is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
user = User.new(user_signup_params)
|
||||||
|
|
||||||
|
# Create family for new user
|
||||||
|
family = Family.new
|
||||||
|
user.family = family
|
||||||
|
user.role = :admin
|
||||||
|
|
||||||
|
if user.save
|
||||||
|
# Claim invite code if provided
|
||||||
|
InviteCode.claim!(params[:invite_code]) if params[:invite_code].present?
|
||||||
|
|
||||||
|
# Create device and OAuth token
|
||||||
|
device = create_or_update_device(user)
|
||||||
|
token_response = create_oauth_token_for_device(user, device)
|
||||||
|
|
||||||
|
render json: token_response.merge(
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name
|
||||||
|
}
|
||||||
|
), status: :created
|
||||||
|
else
|
||||||
|
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def login
|
||||||
|
user = User.find_by(email: params[:email])
|
||||||
|
|
||||||
|
if user&.authenticate(params[:password])
|
||||||
|
# Check MFA if enabled
|
||||||
|
if user.otp_required?
|
||||||
|
unless params[:otp_code].present? && user.verify_otp?(params[:otp_code])
|
||||||
|
render json: {
|
||||||
|
error: "Two-factor authentication required",
|
||||||
|
mfa_required: true
|
||||||
|
}, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate device info
|
||||||
|
unless valid_device_info?
|
||||||
|
render json: { error: "Device information is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create device and OAuth token
|
||||||
|
device = create_or_update_device(user)
|
||||||
|
token_response = create_oauth_token_for_device(user, device)
|
||||||
|
|
||||||
|
render json: token_response.merge(
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else
|
||||||
|
render json: { error: "Invalid email or password" }, status: :unauthorized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh
|
||||||
|
# Find the refresh token
|
||||||
|
refresh_token = params[:refresh_token]
|
||||||
|
|
||||||
|
unless refresh_token.present?
|
||||||
|
render json: { error: "Refresh token is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the access token associated with this refresh token
|
||||||
|
access_token = Doorkeeper::AccessToken.by_refresh_token(refresh_token)
|
||||||
|
|
||||||
|
if access_token.nil? || access_token.revoked?
|
||||||
|
render json: { error: "Invalid refresh token" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new access token
|
||||||
|
new_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: access_token.application,
|
||||||
|
resource_owner_id: access_token.resource_owner_id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: access_token.scopes,
|
||||||
|
use_refresh_token: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke old access token
|
||||||
|
access_token.revoke
|
||||||
|
|
||||||
|
# Update device last seen
|
||||||
|
user = User.find(access_token.resource_owner_id)
|
||||||
|
device = user.mobile_devices.find_by(device_id: params[:device][:device_id])
|
||||||
|
device&.update_last_seen!
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
access_token: new_token.plaintext_token,
|
||||||
|
refresh_token: new_token.plaintext_refresh_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: new_token.expires_in,
|
||||||
|
created_at: new_token.created_at.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_signup_params
|
||||||
|
params.require(:user).permit(:email, :password, :first_name, :last_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_password(password)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if password.blank?
|
||||||
|
errors << "Password can't be blank"
|
||||||
|
return errors
|
||||||
|
end
|
||||||
|
|
||||||
|
errors << "Password must be at least 8 characters" if password.length < 8
|
||||||
|
errors << "Password must include both uppercase and lowercase letters" unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
|
||||||
|
errors << "Password must include at least one number" unless password.match?(/\d/)
|
||||||
|
errors << "Password must include at least one special character" unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
|
||||||
|
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_device_info?
|
||||||
|
device = params[:device]
|
||||||
|
return false if device.nil?
|
||||||
|
|
||||||
|
required_fields = %w[device_id device_name device_type os_version app_version]
|
||||||
|
required_fields.all? { |field| device[field].present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_or_update_device(user)
|
||||||
|
# Handle both string and symbol keys
|
||||||
|
device_data = params[:device].permit(:device_id, :device_name, :device_type, :os_version, :app_version)
|
||||||
|
|
||||||
|
device = user.mobile_devices.find_or_initialize_by(device_id: device_data[:device_id])
|
||||||
|
device.update!(device_data.merge(last_seen_at: Time.current))
|
||||||
|
device
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_oauth_token_for_device(user, device)
|
||||||
|
# Create OAuth application for this device if needed
|
||||||
|
oauth_app = device.create_oauth_application!
|
||||||
|
|
||||||
|
# Revoke any existing tokens for this device
|
||||||
|
device.revoke_all_tokens!
|
||||||
|
|
||||||
|
# Create new access token with 30-day expiration
|
||||||
|
access_token = Doorkeeper::AccessToken.create!(
|
||||||
|
application: oauth_app,
|
||||||
|
resource_owner_id: user.id,
|
||||||
|
expires_in: 30.days.to_i,
|
||||||
|
scopes: "read_write",
|
||||||
|
use_refresh_token: true
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
access_token: access_token.plaintext_token,
|
||||||
|
refresh_token: access_token.plaintext_refresh_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: access_token.expires_in,
|
||||||
|
created_at: access_token.created_at.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
279
app/controllers/api/v1/base_controller.rb
Normal file
279
app/controllers/api/v1/base_controller.rb
Normal file
|
@ -0,0 +1,279 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::BaseController < ApplicationController
|
||||||
|
include Doorkeeper::Rails::Helpers
|
||||||
|
|
||||||
|
# Skip regular session-based authentication for API
|
||||||
|
skip_authentication
|
||||||
|
|
||||||
|
# Skip CSRF protection for API endpoints
|
||||||
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
|
# Skip onboarding requirements for API endpoints
|
||||||
|
skip_before_action :require_onboarding_and_upgrade
|
||||||
|
|
||||||
|
# Force JSON format for all API requests
|
||||||
|
before_action :force_json_format
|
||||||
|
# Use our custom authentication that supports both OAuth and API keys
|
||||||
|
before_action :authenticate_request!
|
||||||
|
before_action :check_api_key_rate_limit
|
||||||
|
before_action :log_api_access
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Override Doorkeeper's default behavior to return JSON instead of redirecting
|
||||||
|
def doorkeeper_unauthorized_render_options(error: nil)
|
||||||
|
{ json: { error: "unauthorized", message: "Access token is invalid, expired, or missing" } }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Error handling for common API errors
|
||||||
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
||||||
|
rescue_from Doorkeeper::Errors::DoorkeeperError, with: :handle_unauthorized
|
||||||
|
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Force JSON format for all API requests
|
||||||
|
def force_json_format
|
||||||
|
request.format = :json
|
||||||
|
end
|
||||||
|
|
||||||
|
# Authenticate using either OAuth or API key
|
||||||
|
def authenticate_request!
|
||||||
|
return if authenticate_oauth
|
||||||
|
return if authenticate_api_key
|
||||||
|
render_unauthorized unless performed?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try OAuth authentication first
|
||||||
|
def authenticate_oauth
|
||||||
|
return false unless request.headers["Authorization"].present?
|
||||||
|
|
||||||
|
# Manually verify the token (bypassing doorkeeper_authorize! which had scope issues)
|
||||||
|
token_string = request.authorization&.split(" ")&.last
|
||||||
|
access_token = Doorkeeper::AccessToken.by_token(token_string)
|
||||||
|
|
||||||
|
# Check token validity and scope (read_write includes read access)
|
||||||
|
has_sufficient_scope = access_token&.scopes&.include?("read") || access_token&.scopes&.include?("read_write")
|
||||||
|
|
||||||
|
unless access_token && !access_token.expired? && has_sufficient_scope
|
||||||
|
render_json({ error: "unauthorized", message: "Access token is invalid, expired, or missing required scope" }, status: :unauthorized)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the doorkeeper_token for compatibility
|
||||||
|
@_doorkeeper_token = access_token
|
||||||
|
|
||||||
|
if doorkeeper_token&.resource_owner_id
|
||||||
|
@current_user = User.find_by(id: doorkeeper_token.resource_owner_id)
|
||||||
|
|
||||||
|
# If user doesn't exist, the token is invalid (user was deleted)
|
||||||
|
unless @current_user
|
||||||
|
Rails.logger.warn "API OAuth Token Invalid: Access token resource_owner_id #{doorkeeper_token.resource_owner_id} does not exist"
|
||||||
|
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
|
||||||
|
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
@authentication_method = :oauth
|
||||||
|
setup_current_context_for_api
|
||||||
|
true
|
||||||
|
rescue Doorkeeper::Errors::DoorkeeperError => e
|
||||||
|
Rails.logger.warn "API OAuth Error: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try API key authentication
|
||||||
|
def authenticate_api_key
|
||||||
|
api_key_value = request.headers["X-Api-Key"]
|
||||||
|
return false unless api_key_value
|
||||||
|
|
||||||
|
@api_key = ApiKey.find_by_value(api_key_value)
|
||||||
|
return false unless @api_key && @api_key.active?
|
||||||
|
|
||||||
|
@current_user = @api_key.user
|
||||||
|
@api_key.update_last_used!
|
||||||
|
@authentication_method = :api_key
|
||||||
|
@rate_limiter = ApiRateLimiter.new(@api_key)
|
||||||
|
setup_current_context_for_api
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check rate limits for API key authentication
|
||||||
|
def check_api_key_rate_limit
|
||||||
|
return unless @authentication_method == :api_key && @rate_limiter
|
||||||
|
|
||||||
|
if @rate_limiter.rate_limit_exceeded?
|
||||||
|
usage_info = @rate_limiter.usage_info
|
||||||
|
render_rate_limit_exceeded(usage_info)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Increment request count for successful API key requests
|
||||||
|
@rate_limiter.increment_request_count!
|
||||||
|
|
||||||
|
# Add rate limit headers to response
|
||||||
|
add_rate_limit_headers(@rate_limiter.usage_info)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Render rate limit exceeded response
|
||||||
|
def render_rate_limit_exceeded(usage_info)
|
||||||
|
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
|
||||||
|
response.headers["X-RateLimit-Remaining"] = "0"
|
||||||
|
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
|
||||||
|
response.headers["Retry-After"] = usage_info[:reset_time].to_s
|
||||||
|
|
||||||
|
Rails.logger.warn "API Rate Limit Exceeded: API Key #{@api_key.name} (User: #{@current_user.email}) - #{usage_info[:current_count]}/#{usage_info[:rate_limit]} requests"
|
||||||
|
|
||||||
|
render_json({
|
||||||
|
error: "rate_limit_exceeded",
|
||||||
|
message: "Rate limit exceeded. Try again in #{usage_info[:reset_time]} seconds.",
|
||||||
|
details: {
|
||||||
|
limit: usage_info[:rate_limit],
|
||||||
|
current: usage_info[:current_count],
|
||||||
|
reset_in_seconds: usage_info[:reset_time]
|
||||||
|
}
|
||||||
|
}, status: :too_many_requests)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add rate limit headers to successful responses
|
||||||
|
def add_rate_limit_headers(usage_info)
|
||||||
|
response.headers["X-RateLimit-Limit"] = usage_info[:rate_limit].to_s
|
||||||
|
response.headers["X-RateLimit-Remaining"] = usage_info[:remaining].to_s
|
||||||
|
response.headers["X-RateLimit-Reset"] = usage_info[:reset_time].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# Render unauthorized response
|
||||||
|
def render_unauthorized
|
||||||
|
render_json({ error: "unauthorized", message: "Access token or API key is invalid, expired, or missing" }, status: :unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the user that owns the access token or API key
|
||||||
|
def current_resource_owner
|
||||||
|
@current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get current scopes from either authentication method
|
||||||
|
def current_scopes
|
||||||
|
case @authentication_method
|
||||||
|
when :oauth
|
||||||
|
doorkeeper_token&.scopes&.to_a || []
|
||||||
|
when :api_key
|
||||||
|
@api_key&.scopes || []
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if the current authentication has the required scope
|
||||||
|
# Implements hierarchical scope checking where read_write includes read access
|
||||||
|
def authorize_scope!(required_scope)
|
||||||
|
scopes = current_scopes
|
||||||
|
|
||||||
|
case required_scope.to_s
|
||||||
|
when "read"
|
||||||
|
# Read access requires either "read" or "read_write" scope
|
||||||
|
has_access = scopes.include?("read") || scopes.include?("read_write")
|
||||||
|
when "write"
|
||||||
|
# Write access requires "read_write" scope
|
||||||
|
has_access = scopes.include?("read_write")
|
||||||
|
else
|
||||||
|
# For any other scope, check exact match (backward compatibility)
|
||||||
|
has_access = scopes.include?(required_scope.to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless has_access
|
||||||
|
Rails.logger.warn "API Insufficient Scope: User #{current_resource_owner&.email} attempted to access #{required_scope} but only has #{scopes}"
|
||||||
|
render_json({ error: "insufficient_scope", message: "This action requires the '#{required_scope}' scope" }, status: :forbidden)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Consistent JSON response method
|
||||||
|
def render_json(data, status: :ok)
|
||||||
|
render json: data, status: status
|
||||||
|
end
|
||||||
|
|
||||||
|
# Error handlers
|
||||||
|
def handle_not_found(exception)
|
||||||
|
Rails.logger.warn "API Record Not Found: #{exception.message}"
|
||||||
|
render_json({ error: "record_not_found", message: "The requested resource was not found" }, status: :not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_unauthorized(exception)
|
||||||
|
Rails.logger.warn "API Unauthorized: #{exception.message}"
|
||||||
|
render_json({ error: "unauthorized", message: "Access token is invalid or expired" }, status: :unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_bad_request(exception)
|
||||||
|
Rails.logger.warn "API Bad Request: #{exception.message}"
|
||||||
|
render_json({ error: "bad_request", message: "Required parameters are missing or invalid" }, status: :bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Log API access for monitoring and debugging
|
||||||
|
def log_api_access
|
||||||
|
return unless current_resource_owner
|
||||||
|
|
||||||
|
auth_info = case @authentication_method
|
||||||
|
when :oauth
|
||||||
|
"OAuth Token"
|
||||||
|
when :api_key
|
||||||
|
"API Key: #{@api_key.name}"
|
||||||
|
else
|
||||||
|
"Unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "API Request: #{request.method} #{request.path} - User: #{current_resource_owner.email} (Family: #{current_resource_owner.family_id}) - Auth: #{auth_info}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Family-based access control helper (to be used by subcontrollers)
|
||||||
|
def ensure_current_family_access(resource)
|
||||||
|
return unless resource.respond_to?(:family_id)
|
||||||
|
|
||||||
|
unless resource.family_id == current_resource_owner.family_id
|
||||||
|
Rails.logger.warn "API Forbidden: User #{current_resource_owner.email} attempted to access resource from family #{resource.family_id}"
|
||||||
|
render_json({ error: "forbidden", message: "Access denied to this resource" }, status: :forbidden)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Manual doorkeeper_token accessor for compatibility with manual token verification
|
||||||
|
def doorkeeper_token
|
||||||
|
@_doorkeeper_token
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set up Current context for API requests since we don't use session-based auth
|
||||||
|
def setup_current_context_for_api
|
||||||
|
# For API requests, we need to create a minimal session-like object
|
||||||
|
# or find/create an actual session for this user to make Current.user work
|
||||||
|
if @current_user
|
||||||
|
# Try to find an existing session for this user, or create a temporary one
|
||||||
|
session = @current_user.sessions.first
|
||||||
|
if session
|
||||||
|
Current.session = session
|
||||||
|
else
|
||||||
|
# Create a temporary session for this API request
|
||||||
|
# This won't be persisted but will allow Current.user to work
|
||||||
|
session = @current_user.sessions.build(
|
||||||
|
user_agent: request.user_agent,
|
||||||
|
ip_address: request.ip
|
||||||
|
)
|
||||||
|
Current.session = session
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if AI features are enabled for the current user
|
||||||
|
def require_ai_enabled
|
||||||
|
unless current_resource_owner&.ai_enabled?
|
||||||
|
render_json({ error: "feature_disabled", message: "AI features are not enabled for this user" }, status: :forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
84
app/controllers/api/v1/chats_controller.rb
Normal file
84
app/controllers/api/v1/chats_controller.rb
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::ChatsController < Api::V1::BaseController
|
||||||
|
include Pagy::Backend
|
||||||
|
before_action :require_ai_enabled
|
||||||
|
before_action :ensure_read_scope, only: [ :index, :show ]
|
||||||
|
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
|
||||||
|
before_action :set_chat, only: [ :show, :update, :destroy ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@pagy, @chats = pagy(Current.user.chats.ordered, items: 20)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
return unless @chat
|
||||||
|
@pagy, @messages = pagy(@chat.messages.ordered, items: 50)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@chat = Current.user.chats.build(title: chat_params[:title])
|
||||||
|
|
||||||
|
if @chat.save
|
||||||
|
if chat_params[:message].present?
|
||||||
|
@message = @chat.messages.build(
|
||||||
|
content: chat_params[:message],
|
||||||
|
type: "UserMessage",
|
||||||
|
ai_model: chat_params[:model] || "gpt-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
if @message.save
|
||||||
|
AssistantResponseJob.perform_later(@message)
|
||||||
|
render :show, status: :created
|
||||||
|
else
|
||||||
|
@chat.destroy
|
||||||
|
render json: { error: "Failed to create initial message", details: @message.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render :show, status: :created
|
||||||
|
end
|
||||||
|
else
|
||||||
|
render json: { error: "Failed to create chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
return unless @chat
|
||||||
|
|
||||||
|
if @chat.update(update_chat_params)
|
||||||
|
render :show
|
||||||
|
else
|
||||||
|
render json: { error: "Failed to update chat", details: @chat.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
return unless @chat
|
||||||
|
@chat.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_read_scope
|
||||||
|
authorize_scope!(:read)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_write_scope
|
||||||
|
authorize_scope!(:write)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_chat
|
||||||
|
@chat = Current.user.chats.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "Chat not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def chat_params
|
||||||
|
params.permit(:title, :message, :model)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_chat_params
|
||||||
|
params.permit(:title)
|
||||||
|
end
|
||||||
|
end
|
55
app/controllers/api/v1/messages_controller.rb
Normal file
55
app/controllers/api/v1/messages_controller.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::MessagesController < Api::V1::BaseController
|
||||||
|
before_action :require_ai_enabled
|
||||||
|
before_action :ensure_write_scope, only: [ :create, :retry ]
|
||||||
|
before_action :set_chat
|
||||||
|
|
||||||
|
def create
|
||||||
|
@message = @chat.messages.build(
|
||||||
|
content: message_params[:content],
|
||||||
|
type: "UserMessage",
|
||||||
|
ai_model: message_params[:model] || "gpt-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
if @message.save
|
||||||
|
AssistantResponseJob.perform_later(@message)
|
||||||
|
render :show, status: :created
|
||||||
|
else
|
||||||
|
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def retry
|
||||||
|
last_message = @chat.messages.ordered.last
|
||||||
|
|
||||||
|
if last_message&.type == "AssistantMessage"
|
||||||
|
new_message = @chat.messages.create!(
|
||||||
|
type: "AssistantMessage",
|
||||||
|
content: "",
|
||||||
|
ai_model: last_message.ai_model
|
||||||
|
)
|
||||||
|
|
||||||
|
AssistantResponseJob.perform_later(new_message)
|
||||||
|
render json: { message: "Retry initiated", message_id: new_message.id }, status: :accepted
|
||||||
|
else
|
||||||
|
render json: { error: "No assistant message to retry" }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_write_scope
|
||||||
|
authorize_scope!(:write)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_chat
|
||||||
|
@chat = Current.user.chats.find(params[:chat_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "Chat not found" }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_params
|
||||||
|
params.permit(:content, :model)
|
||||||
|
end
|
||||||
|
end
|
47
app/controllers/api/v1/test_controller.rb
Normal file
47
app/controllers/api/v1/test_controller.rb
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Test controller for API V1 Base Controller functionality
|
||||||
|
# This controller is only used for testing the base controller behavior
|
||||||
|
class Api::V1::TestController < Api::V1::BaseController
|
||||||
|
def index
|
||||||
|
render_json({ message: "test_success", user: current_resource_owner&.email })
|
||||||
|
end
|
||||||
|
|
||||||
|
def not_found
|
||||||
|
# Trigger RecordNotFound error for testing error handling
|
||||||
|
raise ActiveRecord::RecordNotFound, "Test record not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
def family_access
|
||||||
|
# Test family-based access control
|
||||||
|
# Create a mock resource that belongs to a different family
|
||||||
|
mock_resource = OpenStruct.new(family_id: 999) # Different family ID
|
||||||
|
|
||||||
|
# Check family access - if it returns false, it already rendered the error
|
||||||
|
if ensure_current_family_access(mock_resource)
|
||||||
|
# If we get here, access was allowed
|
||||||
|
render_json({ family_id: current_resource_owner.family_id })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def scope_required
|
||||||
|
# Test scope authorization - require write scope
|
||||||
|
return unless authorize_scope!("write")
|
||||||
|
|
||||||
|
render_json({
|
||||||
|
message: "scope_authorized",
|
||||||
|
scopes: current_scopes,
|
||||||
|
required_scope: "write"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def multiple_scopes_required
|
||||||
|
# Test read scope requirement
|
||||||
|
return unless authorize_scope!("read")
|
||||||
|
|
||||||
|
render_json({
|
||||||
|
message: "read_scope_authorized",
|
||||||
|
scopes: current_scopes
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
327
app/controllers/api/v1/transactions_controller.rb
Normal file
327
app/controllers/api/v1/transactions_controller.rb
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::TransactionsController < Api::V1::BaseController
|
||||||
|
include Pagy::Backend
|
||||||
|
|
||||||
|
# Ensure proper scope authorization for read vs write access
|
||||||
|
before_action :ensure_read_scope, only: [ :index, :show ]
|
||||||
|
before_action :ensure_write_scope, only: [ :create, :update, :destroy ]
|
||||||
|
before_action :set_transaction, only: [ :show, :update, :destroy ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
family = current_resource_owner.family
|
||||||
|
transactions_query = family.transactions.visible
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
transactions_query = apply_filters(transactions_query)
|
||||||
|
|
||||||
|
# Apply search
|
||||||
|
transactions_query = apply_search(transactions_query) if params[:search].present?
|
||||||
|
|
||||||
|
# Include necessary associations for efficient queries
|
||||||
|
transactions_query = transactions_query.includes(
|
||||||
|
{ entry: :account },
|
||||||
|
:category, :merchant, :tags,
|
||||||
|
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||||
|
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||||
|
).reverse_chronological
|
||||||
|
|
||||||
|
# Handle pagination with Pagy
|
||||||
|
@pagy, @transactions = pagy(
|
||||||
|
transactions_query,
|
||||||
|
page: safe_page_param,
|
||||||
|
limit: safe_per_page_param
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make per_page available to the template
|
||||||
|
@per_page = safe_per_page_param
|
||||||
|
|
||||||
|
# Rails will automatically use app/views/api/v1/transactions/index.json.jbuilder
|
||||||
|
render :index
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "TransactionsController#index error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Rails will automatically use app/views/api/v1/transactions/show.json.jbuilder
|
||||||
|
render :show
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "TransactionsController#show error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
family = current_resource_owner.family
|
||||||
|
|
||||||
|
# Validate account_id is present
|
||||||
|
unless transaction_params[:account_id].present?
|
||||||
|
render json: {
|
||||||
|
error: "validation_failed",
|
||||||
|
message: "Account ID is required",
|
||||||
|
errors: [ "Account ID is required" ]
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
account = family.accounts.find(transaction_params[:account_id])
|
||||||
|
@entry = account.entries.new(entry_params_for_create)
|
||||||
|
|
||||||
|
if @entry.save
|
||||||
|
@entry.sync_account_later
|
||||||
|
@entry.lock_saved_attributes!
|
||||||
|
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||||
|
|
||||||
|
@transaction = @entry.transaction
|
||||||
|
render :show, status: :created
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
error: "validation_failed",
|
||||||
|
message: "Transaction could not be created",
|
||||||
|
errors: @entry.errors.full_messages
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "TransactionsController#create error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @entry.update(entry_params_for_update)
|
||||||
|
@entry.sync_account_later
|
||||||
|
@entry.lock_saved_attributes!
|
||||||
|
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||||
|
|
||||||
|
@transaction = @entry.transaction
|
||||||
|
render :show
|
||||||
|
else
|
||||||
|
render json: {
|
||||||
|
error: "validation_failed",
|
||||||
|
message: "Transaction could not be updated",
|
||||||
|
errors: @entry.errors.full_messages
|
||||||
|
}, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "TransactionsController#update error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@entry.destroy!
|
||||||
|
@entry.sync_account_later
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
message: "Transaction deleted successfully"
|
||||||
|
}, status: :ok
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "TransactionsController#destroy error: #{e.message}"
|
||||||
|
Rails.logger.error e.backtrace.join("\n")
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "internal_server_error",
|
||||||
|
message: "Error: #{e.message}"
|
||||||
|
}, status: :internal_server_error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_transaction
|
||||||
|
family = current_resource_owner.family
|
||||||
|
@transaction = family.transactions.find(params[:id])
|
||||||
|
@entry = @transaction.entry
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: {
|
||||||
|
error: "not_found",
|
||||||
|
message: "Transaction not found"
|
||||||
|
}, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_read_scope
|
||||||
|
authorize_scope!(:read)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_write_scope
|
||||||
|
authorize_scope!(:write)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_filters(query)
|
||||||
|
# Account filtering
|
||||||
|
if params[:account_id].present?
|
||||||
|
query = query.joins(:entry).where(entries: { account_id: params[:account_id] })
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:account_ids].present?
|
||||||
|
account_ids = Array(params[:account_ids])
|
||||||
|
query = query.joins(:entry).where(entries: { account_id: account_ids })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Category filtering
|
||||||
|
if params[:category_id].present?
|
||||||
|
query = query.where(category_id: params[:category_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:category_ids].present?
|
||||||
|
category_ids = Array(params[:category_ids])
|
||||||
|
query = query.where(category_id: category_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merchant filtering
|
||||||
|
if params[:merchant_id].present?
|
||||||
|
query = query.where(merchant_id: params[:merchant_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:merchant_ids].present?
|
||||||
|
merchant_ids = Array(params[:merchant_ids])
|
||||||
|
query = query.where(merchant_id: merchant_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Date range filtering
|
||||||
|
if params[:start_date].present?
|
||||||
|
query = query.joins(:entry).where("entries.date >= ?", Date.parse(params[:start_date]))
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:end_date].present?
|
||||||
|
query = query.joins(:entry).where("entries.date <= ?", Date.parse(params[:end_date]))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Amount filtering
|
||||||
|
if params[:min_amount].present?
|
||||||
|
min_amount = params[:min_amount].to_f
|
||||||
|
query = query.joins(:entry).where("entries.amount >= ?", min_amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:max_amount].present?
|
||||||
|
max_amount = params[:max_amount].to_f
|
||||||
|
query = query.joins(:entry).where("entries.amount <= ?", max_amount)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tag filtering
|
||||||
|
if params[:tag_ids].present?
|
||||||
|
tag_ids = Array(params[:tag_ids])
|
||||||
|
query = query.joins(:tags).where(tags: { id: tag_ids })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction type filtering (income/expense)
|
||||||
|
if params[:type].present?
|
||||||
|
case params[:type].downcase
|
||||||
|
when "income"
|
||||||
|
query = query.joins(:entry).where("entries.amount < 0")
|
||||||
|
when "expense"
|
||||||
|
query = query.joins(:entry).where("entries.amount > 0")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_search(query)
|
||||||
|
search_term = "%#{params[:search]}%"
|
||||||
|
|
||||||
|
query.joins(:entry)
|
||||||
|
.left_joins(:merchant)
|
||||||
|
.where(
|
||||||
|
"entries.name ILIKE ? OR entries.notes ILIKE ? OR merchants.name ILIKE ?",
|
||||||
|
search_term, search_term, search_term
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def transaction_params
|
||||||
|
params.require(:transaction).permit(
|
||||||
|
:account_id, :date, :amount, :name, :description, :notes, :currency,
|
||||||
|
:category_id, :merchant_id, :nature, tag_ids: []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_params_for_create
|
||||||
|
entry_params = {
|
||||||
|
name: transaction_params[:name] || transaction_params[:description],
|
||||||
|
date: transaction_params[:date],
|
||||||
|
amount: calculate_signed_amount,
|
||||||
|
currency: transaction_params[:currency] || current_resource_owner.family.currency,
|
||||||
|
notes: transaction_params[:notes],
|
||||||
|
entryable_type: "Transaction",
|
||||||
|
entryable_attributes: {
|
||||||
|
category_id: transaction_params[:category_id],
|
||||||
|
merchant_id: transaction_params[:merchant_id],
|
||||||
|
tag_ids: transaction_params[:tag_ids] || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry_params.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_params_for_update
|
||||||
|
entry_params = {
|
||||||
|
name: transaction_params[:name] || transaction_params[:description],
|
||||||
|
date: transaction_params[:date],
|
||||||
|
notes: transaction_params[:notes],
|
||||||
|
entryable_attributes: {
|
||||||
|
id: @entry.entryable_id,
|
||||||
|
category_id: transaction_params[:category_id],
|
||||||
|
merchant_id: transaction_params[:merchant_id],
|
||||||
|
tag_ids: transaction_params[:tag_ids]
|
||||||
|
}.compact_blank
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only update amount if provided
|
||||||
|
if transaction_params[:amount].present?
|
||||||
|
entry_params[:amount] = calculate_signed_amount
|
||||||
|
end
|
||||||
|
|
||||||
|
entry_params.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_signed_amount
|
||||||
|
amount = transaction_params[:amount].to_f
|
||||||
|
nature = transaction_params[:nature]
|
||||||
|
|
||||||
|
case nature&.downcase
|
||||||
|
when "income", "inflow"
|
||||||
|
-amount.abs # Income is negative
|
||||||
|
when "expense", "outflow"
|
||||||
|
amount.abs # Expense is positive
|
||||||
|
else
|
||||||
|
amount # Use as provided
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_page_param
|
||||||
|
page = params[:page].to_i
|
||||||
|
page > 0 ? page : 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_per_page_param
|
||||||
|
per_page = params[:per_page].to_i
|
||||||
|
case per_page
|
||||||
|
when 1..100
|
||||||
|
per_page
|
||||||
|
else
|
||||||
|
25 # Default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
38
app/controllers/api/v1/usage_controller.rb
Normal file
38
app/controllers/api/v1/usage_controller.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
class Api::V1::UsageController < Api::V1::BaseController
|
||||||
|
# GET /api/v1/usage
|
||||||
|
def show
|
||||||
|
return unless authorize_scope!(:read)
|
||||||
|
|
||||||
|
case @authentication_method
|
||||||
|
when :api_key
|
||||||
|
usage_info = @rate_limiter.usage_info
|
||||||
|
render_json({
|
||||||
|
api_key: {
|
||||||
|
name: @api_key.name,
|
||||||
|
scopes: @api_key.scopes,
|
||||||
|
last_used_at: @api_key.last_used_at,
|
||||||
|
created_at: @api_key.created_at
|
||||||
|
},
|
||||||
|
rate_limit: {
|
||||||
|
tier: usage_info[:tier],
|
||||||
|
limit: usage_info[:rate_limit],
|
||||||
|
current_count: usage_info[:current_count],
|
||||||
|
remaining: usage_info[:remaining],
|
||||||
|
reset_in_seconds: usage_info[:reset_time],
|
||||||
|
reset_at: Time.current + usage_info[:reset_time].seconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
when :oauth
|
||||||
|
# For OAuth, we don't track detailed usage yet, but we can return basic info
|
||||||
|
render_json({
|
||||||
|
authentication_method: "oauth",
|
||||||
|
message: "Detailed usage tracking is available for API key authentication"
|
||||||
|
})
|
||||||
|
else
|
||||||
|
render_json({
|
||||||
|
error: "invalid_authentication_method",
|
||||||
|
message: "Unable to determine usage information"
|
||||||
|
}, status: :bad_request)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,27 +1,15 @@
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
|
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||||
|
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||||
|
FeatureGuardable, Notifiable
|
||||||
|
|
||||||
include Pagy::Backend
|
include Pagy::Backend
|
||||||
|
|
||||||
helper_method :require_upgrade?, :subscription_pending?
|
|
||||||
|
|
||||||
before_action :detect_os
|
before_action :detect_os
|
||||||
|
before_action :set_default_chat
|
||||||
|
before_action :set_active_storage_url_options
|
||||||
|
|
||||||
private
|
private
|
||||||
def require_upgrade?
|
|
||||||
return false if self_hosted?
|
|
||||||
return false unless Current.session
|
|
||||||
return false if Current.family.subscribed?
|
|
||||||
return false if subscription_pending? || request.path == settings_billing_path
|
|
||||||
return false if Current.family.active_accounts_count <= 3
|
|
||||||
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscription_pending?
|
|
||||||
subscribed_at = Current.session.subscribed_at
|
|
||||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
|
||||||
end
|
|
||||||
|
|
||||||
def detect_os
|
def detect_os
|
||||||
user_agent = request.user_agent
|
user_agent = request.user_agent
|
||||||
@os = case user_agent
|
@os = case user_agent
|
||||||
|
@ -33,4 +21,18 @@ class ApplicationController < ActionController::Base
|
||||||
else ""
|
else ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# By default, we show the user the last chat they interacted with
|
||||||
|
def set_default_chat
|
||||||
|
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||||
|
@chat = @last_viewed_chat
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_active_storage_url_options
|
||||||
|
ActiveStorage::Current.url_options = {
|
||||||
|
protocol: request.protocol,
|
||||||
|
host: request.host,
|
||||||
|
port: request.optional_port
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController
|
||||||
|
|
||||||
if params[:id] == BudgetCategory.uncategorized.id
|
if params[:id] == BudgetCategory.uncategorized.id
|
||||||
@budget_category = @budget.uncategorized_budget_category
|
@budget_category = @budget.uncategorized_budget_category
|
||||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
@recent_transactions = @recent_transactions.where(transactions: { category_id: nil })
|
||||||
else
|
else
|
||||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id")
|
||||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
@recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|
|
@ -25,6 +25,7 @@ class BudgetsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def budget_create_params
|
def budget_create_params
|
||||||
params.require(:budget).permit(:start_date)
|
params.require(:budget).permit(:start_date)
|
||||||
end
|
end
|
||||||
|
|
|
@ -56,8 +56,13 @@ class CategoriesController < ApplicationController
|
||||||
redirect_back_or_to categories_path, notice: t(".success")
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy_all
|
||||||
|
Current.family.categories.destroy_all
|
||||||
|
redirect_back_or_to categories_path, notice: "All categories deleted"
|
||||||
|
end
|
||||||
|
|
||||||
def bootstrap
|
def bootstrap
|
||||||
Current.family.categories.bootstrap_defaults
|
Current.family.categories.bootstrap!
|
||||||
|
|
||||||
redirect_back_or_to categories_path, notice: t(".success")
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
65
app/controllers/chats_controller.rb
Normal file
65
app/controllers/chats_controller.rb
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
class ChatsController < ApplicationController
|
||||||
|
include ActionView::RecordIdentifier
|
||||||
|
|
||||||
|
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@chat = nil # override application_controller default behavior of setting @chat to last viewed chat
|
||||||
|
@chats = Current.user.chats.order(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
set_last_viewed_chat(@chat)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
|
||||||
|
set_last_viewed_chat(@chat)
|
||||||
|
redirect_to chat_path(@chat, thinking: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@chat.update!(chat_params)
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" }
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@chat.destroy
|
||||||
|
clear_last_viewed_chat
|
||||||
|
|
||||||
|
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||||
|
end
|
||||||
|
|
||||||
|
def retry
|
||||||
|
@chat.retry_last_message!
|
||||||
|
redirect_to chat_path(@chat, thinking: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_chat
|
||||||
|
@chat = Current.user.chats.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_last_viewed_chat(chat)
|
||||||
|
Current.user.update!(last_viewed_chat: chat)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_last_viewed_chat
|
||||||
|
Current.user.update!(last_viewed_chat: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def chat_params
|
||||||
|
params.require(:chat).permit(:title, :content, :ai_model)
|
||||||
|
end
|
||||||
|
end
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue