Compare commits

...

90 Commits

Author SHA1 Message Date
Zach Gollwitzer
56ab092f6b Bump to v0.2.0-alpha.2
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-11-08 14:55:56 -05:00
Tony Vincent
b3ef995d1f Fix duplicate invites (#1437)
Co-authored-by: Josh Pigford <josh@joshpigford.com>
2024-11-08 09:58:35 -06:00
bruno costanzo
a113d573d6 Skip account valuation on entry balance_after_entry (#1435) 2024-11-08 09:17:55 -05:00
Tony Vincent
3b928775a8 Fix timeframe dropdown next to Portfolio (#1434) 2024-11-08 09:10:05 -05:00
Josh Pigford
31d9d926f7 Fix for certain securities returning incorrect prices 2024-11-07 11:52:16 -06:00
Tony Vincent
154a1a971b Exclude inactive accounts from networth calculation and from sidebar (#1432) 2024-11-07 10:14:12 -05:00
Tony Vincent
e434ed0e1f Add text-overflow: ellipsis property for account name display (#1431) 2024-11-07 08:42:51 -06:00
Zach Gollwitzer
2722254be9 Sync account after balance deletion
- Fixes #1416
- Fixes timezone bugs in forms
2024-11-05 19:31:24 -05:00
Zach Gollwitzer
455257bf51 Show onboarding unless invitation present
Fixes #1421
2024-11-05 19:08:45 -05:00
Zach Gollwitzer
f2739b79fb Improve password reset flow, normalize translations 2024-11-05 17:15:29 -05:00
Zach Gollwitzer
cee9692b35 Update Ruby version note
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 11:06:36 -05:00
Luis Ezcurdia
18266c3352 Bump ruby version to 3.3.5 (#1402) 2024-11-05 11:05:08 -05:00
dependabot[bot]
c3400856c7 Bump rails from 7.2.1.2 to 7.2.2 (#1410)
Bumps [rails](https://github.com/rails/rails) from 7.2.1.2 to 7.2.2.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1.2...v7.2.2)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:17:40 -05:00
dependabot[bot]
a0ad33e47c Bump stripe from 13.0.2 to 13.1.0 (#1411)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.2 to 13.1.0.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.2...v13.1.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:16:11 -05:00
dependabot[bot]
65d46397d7 Bump pagy from 9.1.0 to 9.1.1 (#1409)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.1.0 to 9.1.1.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.1.0...9.1.1)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:15:52 -05:00
dependabot[bot]
905eb7bbe8 Bump selenium-webdriver from 4.25.0 to 4.26.0 (#1412)
Bumps [selenium-webdriver](https://github.com/SeleniumHQ/selenium) from 4.25.0 to 4.26.0.
- [Release notes](https://github.com/SeleniumHQ/selenium/releases)
- [Changelog](https://github.com/SeleniumHQ/selenium/blob/trunk/rb/CHANGES)
- [Commits](https://github.com/SeleniumHQ/selenium/compare/selenium-4.25.0...selenium-4.26.0)

---
updated-dependencies:
- dependency-name: selenium-webdriver
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-11-05 08:15:40 -05:00
Zach Gollwitzer
65db49273c Account Activity View + Account Forms (#1406)
* Remove balance mode, sketch out refactor

* Activity view checkpoint

* Entry partials, checkpoint

* Finish txn partial

* Give entries context when editing for different turbo responses

* Calculate change of balance for each entry

* Account tabs consolidation

* Translations, linting, brakeman updates

* Account actions concern

* Finalize forms, get account system tests passing

* Get tests passing

* Lint, rubocop, schema updates

* Improve routing and stream responses

* Fix broken routes

* Add import option for adding accounts

* Fix system test

* Fix test specificity

* Fix sparklines

* Improve account redirects
2024-11-04 20:27:31 -05:00
alekseyp
12e4f1067d Update en.yml (#1401)
Signed-off-by: alekseyp <aleksey.potaneyko@gmail.com>
2024-11-01 15:01:47 -05:00
Josh Pigford
85779b4038 super_admin should be valid for admin 2024-11-01 10:30:30 -05:00
Josh Pigford
793bd852a0 Family invites (#1397)
* Initial pass at household invites

* Invitee setup

* Clean up add member form

* Lint and other tweaks

* Security cleanup

* Lint

* i18n fixes

* More i18n cleanup

* Show pending invites

* Don't use turbo on the form

* Improved email design

* Basic tests

* Lint

* Update onboardings_controller.rb

* Registration + invite cleanup

* Lint

* Update brakeman.ignore

* Update brakeman.ignore

* Self host invite links

* Test tweaks

* Address missing param error
2024-11-01 10:23:27 -05:00
Zach Gollwitzer
09b269273a Safe load yaml files 2024-11-01 09:42:00 -04:00
Harshit Chaudhary
47288a1629 Auto naming of Transfer Transaction (#1393)
* Remove Description field

* Auto naming of tranfer transaction

* Fix transfer test

* Improve Transfer entries names
2024-11-01 08:58:19 -04:00
Tony Vincent
2b61821336 Do not include income transactions in liability accounts for savings rate (#1385)
* Do not include income transactions in liability accounts for savings rate

* Do not include income in liability accounts in savings rate chart
2024-10-31 09:05:01 -04:00
Nico
7946cd7819 Adds condition to skip link to transaction form if it's not editable (#1394) 2024-10-31 08:57:06 -04:00
Josh Pigford
e7f09e6f71 Groundwork for security info (#1396)
* Groundwork for security info

* Lint
2024-10-30 18:08:19 -04:00
Josh Pigford
5e2b932648 Use Synth logo for holdings 2024-10-30 12:14:11 -04:00
Josh Pigford
5533b84895 Always include US stocks 2024-10-30 10:42:57 -04:00
Zach Gollwitzer
c9917674aa Add validation to security price model 2024-10-30 09:51:05 -04:00
Josh Pigford
cd91e66618 Initial pass at Synth-based ticker selection (#1392)
* Initial pass at Synth-based ticker selection

* Update _tickers.turbo_stream.erb

* Functional combobox display

* A few cleanup steps

* Linter

* Prevent long strings

* Another step towards functional combobox

* Deprecated files

* Custom Combobox implementation

* Lint

* Test suite fixes

* Lint

* Make direct use of mic codes

* Update splits

* Update trades_test.rb
2024-10-30 09:23:44 -04:00
Josh Pigford
490f44589e First pass at security price reference (#1388)
* First pass at security price reference

* Data cleanup

* Synth security fetching does better with a mic_code

* Update test suite

😭

* Update schema.rb

* Update generator.rb
2024-10-29 15:37:59 -04:00
Zach Gollwitzer
bf695972e4 Remove missing prices issue (#1390) 2024-10-29 14:55:46 -04:00
Josh Pigford
7d8028b505 Stock filter (#1376)
* Initial pass at stock filtering

* Rough in filter

* Cleaning up security listing

* Tweak to search function

* Combobox tweaks

* Clean up search query

* Update trades test with combobox

* Update securities.yml
2024-10-28 15:49:19 -04:00
Josh Pigford
c2561b5fb4 Handle manually entered securities 2024-10-28 13:33:27 -04:00
Zach Gollwitzer
e5eb69bdc7 Fix hidden selection bars on account views 2024-10-28 11:29:52 -04:00
Guillem Arias Fauste
3cd364af09 fix bulk action bar positioning (#1370)
* fix bulk action bar positioning

* remove extra space
2024-10-28 08:02:49 -04:00
dependabot[bot]
277fb3dc39 Bump mocha from 2.4.5 to 2.5.0 (#1378)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.5 to 2.5.0.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.5...v2.5.0)

---
updated-dependencies:
- dependency-name: mocha
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:44 -04:00
dependabot[bot]
439e50bb3e Bump pg from 1.5.8 to 1.5.9 (#1379)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.8 to 1.5.9.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.8...v1.5.9)

---
updated-dependencies:
- dependency-name: pg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:36 -04:00
dependabot[bot]
2141cbb041 Bump rails from 7.2.1.1 to 7.2.1.2 (#1380)
Bumps [rails](https://github.com/rails/rails) from 7.2.1.1 to 7.2.1.2.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1.1...v7.2.1.2)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:25 -04:00
dependabot[bot]
d78f582af2 Bump ruby-lsp-rails from 0.3.20 to 0.3.21 (#1381)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.20 to 0.3.21.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.20...v0.3.21)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:56:01 -04:00
dependabot[bot]
2adb54da99 Bump stripe from 13.0.1 to 13.0.2 (#1382)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.1 to 13.0.2.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.1...v13.0.2)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-28 07:55:50 -04:00
Josh Pigford
45935db5f3 Remove dependency on stock exchange table (#1368) 2024-10-25 13:09:02 -05:00
Zach Gollwitzer
b75b41a5e2 Bump to v0.2.0-alpha.1
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-10-25 09:37:50 -04:00
bruno costanzo
2cc89195bf Feature | Filter on uncategorized transactions (#1359)
* allow filtering uncategorized transactions

* user can filter uncategorized transactions test

* rubocop linting
2024-10-25 09:37:05 -04:00
Josh Pigford
aa3342b0dc Stock imports (#1363)
* Initial pass

* Marketstack data provider

* Marketstack data provider

* Refactor a bit
2024-10-24 16:36:50 -05:00
Zach Gollwitzer
b611dfdf37 Add back good job dashboard with auth (#1364) 2024-10-24 17:28:29 -04:00
Zach Gollwitzer
ba49fea89a Locale updates 2024-10-24 16:43:59 -04:00
Zach Gollwitzer
89e107e36c Remove good job dashboard route 2024-10-24 15:56:58 -04:00
Zach Gollwitzer
d93fbbcaa8 Fix tooltip on charts 2024-10-24 12:06:42 -04:00
Josh Pigford
e6403fab70 Modal design tweaks 2024-10-24 10:16:24 -05:00
Zach Gollwitzer
6baffe7539 Beta Testing Round 3 Bug Fixes (#1357)
* Clean up env example files

* Fix duplicate category creations

* Fix duplicate tag and merchant creation

* Add initial valuation to imported accounts

* Add upgrade modal prompt

* Don't hide content on billing page

* Add temporary session for new customers

* Lint fixes

* Fix unused translations

* Fix system tests
2024-10-24 11:02:27 -04:00
Zach Gollwitzer
1d20de770f User Onboarding + Bug Fixes (#1352)
* Bump min supported date to 20 years

* Add basic onboarding

* User onboarding

* Complete onboarding flow

* Cleanup, add user profile update test
2024-10-23 11:20:55 -04:00
Josh Pigford
73e184ad3d Stock Exchanges with seed (#1351)
* Stock Exchanges with seed

* Run the seed file on migration

* Fix for enum column
2024-10-22 14:30:57 -05:00
dependabot[bot]
d3a6f7e0f0 Bump tailwindcss-rails from 2.7.9 to 3.0.0 (#1341)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.9 to 3.0.0.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.9...v3.0.0)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-22 15:11:26 -04:00
Josh Pigford
9313620968 Updated Synth env variable description 2024-10-22 13:10:51 -05:00
Josh Pigford
a4e87ffb4d Delete extensions.json 2024-10-21 20:20:52 -05:00
Zach Gollwitzer
728b10d08e Fix trade import mapping bug 2024-10-21 12:26:39 -04:00
dependabot[bot]
a27b17deae Bump ruby-lsp-rails from 0.3.19 to 0.3.20 (#1339)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.19 to 0.3.20.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.19...v0.3.20)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 10:14:05 -04:00
Nico
1b654faf9a Fixes issue with mapping values during the transactions import (#1327)
* Adds custom debounce timeout to autosubmit form controller

- There's a default debounce timeout based on element type
- You can parameterize debounce timeout on a data-attribute

* Adds corrections based on js_lint

* Restores sleep on test

---------

Co-authored-by: Nicolás Galdámez <nicolas.galdamez@unagisoftware.com>
2024-10-21 10:13:55 -04:00
dependabot[bot]
9b6a2cce56 Bump turbo-rails from 2.0.10 to 2.0.11 (#1343)
Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.10 to 2.0.11.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.10...v2.0.11)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 10:12:35 -04:00
dependabot[bot]
5ff9012d3e Bump rails from 7.2.1 to 7.2.1.1 (#1340)
Bumps [rails](https://github.com/rails/rails) from 7.2.1 to 7.2.1.1.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](https://github.com/rails/rails/compare/v7.2.1...v7.2.1.1)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 10:10:15 -04:00
dependabot[bot]
da7f19d5ab Bump erb_lint from 0.6.0 to 0.7.0 (#1337)
Bumps [erb_lint](https://github.com/Shopify/erb-lint) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/Shopify/erb-lint/releases)
- [Commits](https://github.com/Shopify/erb-lint/compare/v0.6.0...v0.7.0)

---
updated-dependencies:
- dependency-name: erb_lint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:53:51 -04:00
dependabot[bot]
a2e8fb5ce1 Bump good_job from 4.4.1 to 4.4.2 (#1336)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.4.1 to 4.4.2.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.4.1...v4.4.2)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:53:41 -04:00
dependabot[bot]
b074762809 Bump faker from 3.4.2 to 3.5.1 (#1338)
Bumps [faker](https://github.com/faker-ruby/faker) from 3.4.2 to 3.5.1.
- [Release notes](https://github.com/faker-ruby/faker/releases)
- [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/faker-ruby/faker/compare/v3.4.2...v3.5.1)

---
updated-dependencies:
- dependency-name: faker
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:53:33 -04:00
dependabot[bot]
3cc4cba2b3 Bump octokit from 9.1.0 to 9.2.0 (#1342)
Bumps [octokit](https://github.com/octokit/octokit.rb) from 9.1.0 to 9.2.0.
- [Release notes](https://github.com/octokit/octokit.rb/releases)
- [Changelog](https://github.com/octokit/octokit.rb/blob/main/RELEASE.md)
- [Commits](https://github.com/octokit/octokit.rb/compare/v9.1.0...v9.2.0)

---
updated-dependencies:
- dependency-name: octokit
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:52:49 -04:00
dependabot[bot]
cb752370cb Bump aws-sdk-s3 from 1.167.0 to 1.169.0 (#1344)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.167.0 to 1.169.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:52:37 -04:00
dependabot[bot]
720d7aedaf Bump stripe from 13.0.0 to 13.0.1 (#1345)
Bumps [stripe](https://github.com/stripe/stripe-ruby) from 13.0.0 to 13.0.1.
- [Release notes](https://github.com/stripe/stripe-ruby/releases)
- [Changelog](https://github.com/stripe/stripe-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-ruby/compare/v13.0.0...v13.0.1)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-21 09:52:27 -04:00
Josh Pigford
07264e86cb Add accounts count to Intercom 2024-10-19 14:54:51 -05:00
Zach Gollwitzer
3c0fdd84ee Fix mode bug 2024-10-18 18:25:17 -04:00
Zach Gollwitzer
263d65ea7e Basic account onboarding (#1328)
* Basic account onboarding

* Cleanup
2024-10-18 17:18:54 -04:00
Zach Gollwitzer
e8e100e1d8 Rework account views and addition flow (#1324)
* Move accountable partials

* Split accountables into separate view partials

* Fix test

* Add form to permitted partials

* Fix failing system tests

* Update new account modal views

* New sync algorithm implementation

* Update account system test assertions to match new behavior

* Fix off by 1 date error

* Revert new balance sync algorithm

* Add missing account overviews
2024-10-18 14:37:42 -04:00
Josh Pigford
c7c281073f Impersonation (#1325)
* Initial impersonation

* Impersonation audit

* Keep super admin separate

* Remove vscode settings

* Comment cleanup

* Comment out impersonation fixtures for now

* Remove unused controlelr

* Add impersonation testing (#1326)

* Add impersonation testing

* Remove unused method

* Update schema.rb

* Update brakeman

---------

Co-authored-by: Zach Gollwitzer <zach@maybe.co>
2024-10-18 11:26:58 -05:00
Ender Ahmet Yurt
4a3685f503 Redirect upload step (#1323)
* Redirect upload step

* Change redirect page regarding state of the import
2024-10-18 08:10:18 -05:00
Josh Pigford
75a390f03e Account indexes to address some performance issues 2024-10-17 15:45:13 -05:00
Josh Pigford
d4bfcfb6f4 Fix for transaction drawer securities missing prices
Fixes #1321
2024-10-17 10:52:04 -05:00
Josh Pigford
b98f35af0e Another tweak to the bug template 2024-10-17 10:39:56 -05:00
Josh Pigford
629565f7d8 Updated bug report template 2024-10-17 10:20:42 -05:00
Josh Pigford
4118cc8a31 Fix for scrollbars on alerts
Fixes #1320
2024-10-17 10:16:34 -05:00
Josh Pigford
61bf53f233 Rescue RecordNotUnique
Fixes #1319
2024-10-17 09:52:06 -05:00
Guillem Arias Fauste
7f4c1755ef add dashboard account pill tooltips (#1315)
* add dashboard account pill tooltips

* Update app/views/shared/_text_tooltip.erb

Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>

---------

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
Co-authored-by: Zach Gollwitzer <zach.gollwitzer@gmail.com>
2024-10-16 13:14:43 -04:00
Tony Vincent
76decc06c3 Maintain order (#1318) 2024-10-16 12:09:52 -04:00
Zach Gollwitzer
f3bb80dde6 Fix pie chart 2024-10-14 17:21:51 -04:00
oxdev03
4ad28d6eff Add BiomeJS for Linting and Formatting JavaScript relates to #1295 (#1299)
* chore: add formatting and linting for javascript code relates to #1295

* use spaces instaed

* add to recommended extensions

* only enforce lint

* auto save
2024-10-14 17:09:27 -04:00
dependabot[bot]
fa3b8b078c Bump good_job from 4.3.0 to 4.4.1 (#1302)
Bumps [good_job](https://github.com/bensheldon/good_job) from 4.3.0 to 4.4.1.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v4.3.0...v4.4.1)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:44:13 -04:00
dependabot[bot]
d4e7a983f4 Bump tailwindcss-rails from 2.7.7 to 2.7.9 (#1304)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.7.7 to 2.7.9.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/v2.7.9/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.7.7...v2.7.9)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:02:10 -04:00
dependabot[bot]
7f7140b1cc Bump ruby-lsp-rails from 0.3.18 to 0.3.19 (#1300)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.18 to 0.3.19.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.18...v0.3.19)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:01:56 -04:00
dependabot[bot]
437aa4bd39 Bump importmap-rails from 2.0.2 to 2.0.3 (#1301)
Bumps [importmap-rails](https://github.com/rails/importmap-rails) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/rails/importmap-rails/releases)
- [Commits](https://github.com/rails/importmap-rails/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: importmap-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:01:44 -04:00
dependabot[bot]
eabec71f70 Bump rails-settings-cached from 2.9.4 to 2.9.5 (#1305)
Bumps [rails-settings-cached](https://github.com/huacnlee/rails-settings-cached) from 2.9.4 to 2.9.5.
- [Release notes](https://github.com/huacnlee/rails-settings-cached/releases)
- [Changelog](https://github.com/huacnlee/rails-settings-cached/blob/main/CHANGELOG.md)
- [Commits](https://github.com/huacnlee/rails-settings-cached/compare/v2.9.4...v2.9.5)

---
updated-dependencies:
- dependency-name: rails-settings-cached
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:01:35 -04:00
dependabot[bot]
3bc960e6c1 Bump sentry-ruby from 5.20.1 to 5.21.0 (#1306)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.20.1 to 5.21.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.20.1...5.21.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-14 11:01:17 -04:00
Alex Hatzenbuhler
57a81e44ef Add period to value delete modal (#1297) 2024-10-14 10:19:33 -04:00
Zach Gollwitzer
e357c0485f Temp fix for Stimulus charts 2024-10-11 14:40:13 -04:00
417 changed files with 10166 additions and 2867 deletions

View File

@@ -1,4 +1,4 @@
ARG RUBY_VERSION=3.3.4
ARG RUBY_VERSION=3.3.5
FROM ruby:${RUBY_VERSION}-slim-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
@@ -17,4 +17,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
RUN gem install bundler
RUN gem install foreman
# Install Node.js 20
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
WORKDIR /workspace

View File

@@ -10,5 +10,13 @@
"remoteEnv": {
"PATH": "/workspace/bin:${containerEnv:PATH}"
},
"postCreateCommand": "bundle install"
"postCreateCommand": "bundle install && npm install",
"customizations": {
"vscode": {
"extensions": [
"biomejs.biome",
"EditorConfig.EditorConfig"
]
}
}
}

View File

@@ -1,9 +1,18 @@
# ================================ PLEASE READ ==========================================
# This file outlines all the possible environment variables supported by the Maybe app.
#
# 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.
# =======================================================================================
# Custom port config
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
PORT=3000
# Exchange Rate API
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
# 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

5
.env.local.example Normal file
View File

@@ -0,0 +1,5 @@
# To enable / disable self-hosting features.
SELF_HOSTED=false
# Enable Synth market data (careful, this will use your API credits)
SYNTH_API_KEY=yourapikeyhere

8
.env.test Normal file
View File

@@ -0,0 +1,8 @@
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

View File

@@ -20,6 +20,12 @@ Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**What version of Maybe are you using?**
This could be "Hosted" (i.e. app.maybe.co) or "Self-hosted". If "Self-hosted", please include the version you're currently on.
**What operating system and browser are you using?**
The more info the better.
**Screenshots / Recordings**
If applicable, add screenshots or short video recordings to help show the bug in more detail.

View File

@@ -52,6 +52,26 @@ jobs:
- name: Lint code for consistent style
run: bin/rubocop -f github
lint_js:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
shell: bash
- name: Lint/Format js code
run: npm run lint
test:
runs-on: ubuntu-latest
timeout-minutes: 10

13
.gitignore vendored
View File

@@ -10,8 +10,8 @@
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
!.env.example
!.env.test.example
!.env.test
!.env*.example
# Ignore all logfiles and tempfiles.
/log/*
@@ -43,7 +43,9 @@
.idea
# Ignore VS Code
.vscode
.vscode/*
!.vscode/extensions.json
!.vscode/*.code-snippets
# Ignore macOS specific files
*/.DS_Store
@@ -59,4 +61,7 @@ compose-dev.yaml
gcp-storage-keyfile.json
coverage
.cursorrules
.cursorrules
# Ignore node related files
node_modules

View File

@@ -1 +1 @@
3.3.4
3.3.5

View File

@@ -1,7 +1,7 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.3.4
ARG RUBY_VERSION=3.3.5
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Rails app lives here

View File

@@ -3,7 +3,7 @@ source "https://rubygems.org"
ruby file: ".ruby-version"
# Rails
gem "rails", "~> 7.2.1"
gem "rails", "~> 7.2.2"
# Drivers
gem "pg", "~> 1.5"
@@ -21,6 +21,7 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
# Hotwire
gem "stimulus-rails"
gem "turbo-rails"
gem "hotwire_combobox"
# Background Jobs
gem "good_job"

View File

@@ -8,29 +8,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
actioncable (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actionmailbox (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
actionmailer (7.2.2)
actionpack (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activesupport (= 7.2.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
actionpack (7.2.2)
actionview (= 7.2.2)
activesupport (= 7.2.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
@@ -39,36 +39,37 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
actiontext (7.2.2)
actionpack (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.1)
activesupport (= 7.2.1)
actionview (7.2.2)
activesupport (= 7.2.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.1)
activesupport (= 7.2.1)
activejob (7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.3.6)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
activemodel (7.2.2)
activesupport (= 7.2.2)
activerecord (7.2.2)
activemodel (= 7.2.2)
activesupport (= 7.2.2)
timeout (>= 0.4.0)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
activestorage (7.2.2)
actionpack (= 7.2.2)
activejob (= 7.2.2)
activerecord (= 7.2.2)
activesupport (= 7.2.2)
marcel (~> 1.0)
activesupport (7.2.1)
activesupport (7.2.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
@@ -82,23 +83,24 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.985.0)
aws-sdk-core (3.209.1)
aws-partitions (1.992.0)
aws-sdk-core (3.210.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.94.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.167.0)
aws-sdk-core (~> 3, >= 3.207.0)
aws-sdk-s3 (1.169.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.3.0)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
@@ -110,7 +112,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (6.2.1)
brakeman (6.2.2)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -131,7 +133,7 @@ GEM
rexml
crass (1.0.6)
csv (3.3.0)
date (3.3.4)
date (3.4.0)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
@@ -141,7 +143,7 @@ GEM
dotenv (= 3.1.4)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.6.0)
erb_lint (0.7.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -151,7 +153,7 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.2)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.12.0)
faraday-net_http (>= 2.0, < 3.4)
@@ -174,7 +176,7 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (4.3.0)
good_job (4.4.2)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
@@ -188,6 +190,10 @@ GEM
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
hotwire_combobox (0.3.2)
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14)
@@ -203,7 +209,7 @@ GEM
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.2)
importmap-rails (2.0.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
@@ -228,7 +234,7 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.1)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -241,13 +247,13 @@ GEM
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.1)
mocha (2.4.5)
mocha (2.5.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
multipart-post (2.4.1)
net-http (0.4.1)
uri
net-imap (0.4.14)
net-imap (0.5.0)
date
net-protocol
net-pop (0.1.2)
@@ -256,7 +262,7 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nio4r (2.7.4)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-arm-linux)
@@ -269,16 +275,16 @@ GEM
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
octokit (9.1.0)
octokit (9.2.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.1.0)
parallel (1.25.1)
parser (3.3.4.0)
pagy (9.1.1)
parallel (1.26.3)
parser (3.3.5.0)
ast (~> 2.4.1)
racc
pg (1.5.8)
prism (1.1.0)
pg (1.5.9)
prism (1.2.0)
propshaft (1.1.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -291,28 +297,27 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.7)
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.2.0)
rack (>= 3)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
rails (7.2.2)
actioncable (= 7.2.2)
actionmailbox (= 7.2.2)
actionmailer (= 7.2.2)
actionpack (= 7.2.2)
actiontext (= 7.2.2)
actionview (= 7.2.2)
activejob (= 7.2.2)
activemodel (= 7.2.2)
activerecord (= 7.2.2)
activestorage (= 7.2.2)
activesupport (= 7.2.2)
bundler (>= 1.15.0)
railties (= 7.2.1)
railties (= 7.2.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -323,12 +328,12 @@ GEM
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-settings-cached (2.9.4)
rails-settings-cached (2.9.5)
activerecord (>= 5.0.0)
railties (>= 5.0.0)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -347,19 +352,18 @@ GEM
regexp_parser (2.9.2)
reline (0.5.10)
io-console (~> 0.5)
rexml (3.3.8)
rubocop (1.65.1)
rexml (3.3.9)
rubocop (1.67.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-ast (>= 1.32.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
rubocop-ast (1.32.3)
parser (>= 3.3.1.0)
rubocop-minitest (0.35.0)
rubocop (>= 1.61, < 2.0)
@@ -377,13 +381,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.19.1)
ruby-lsp (0.20.1)
language_server-protocol (~> 3.17.0)
prism (>= 1.1, < 2.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.18)
ruby-lsp (>= 0.19.0, < 0.20.0)
ruby-lsp-rails (0.3.21)
ruby-lsp (>= 0.20.0, < 0.21.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
@@ -394,16 +398,16 @@ GEM
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.3.1)
selenium-webdriver (4.25.0)
selenium-webdriver (4.26.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.20.1)
sentry-rails (5.21.0)
railties (>= 5.0)
sentry-ruby (~> 5.20.1)
sentry-ruby (5.20.1)
sentry-ruby (~> 5.21.0)
sentry-ruby (5.21.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -413,34 +417,31 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11597)
sorbet-runtime (0.5.11618)
stackprof (0.2.26)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
stripe (13.0.0)
tailwindcss-rails (2.7.7)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-x86_64-linux)
stripe (13.1.0)
tailwindcss-rails (3.0.0)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.14)
tailwindcss-ruby (3.4.14-aarch64-linux)
tailwindcss-ruby (3.4.14-arm-linux)
tailwindcss-ruby (3.4.14-arm64-darwin)
tailwindcss-ruby (3.4.14-x86_64-darwin)
tailwindcss-ruby (3.4.14-x86_64-linux)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.2)
timeout (0.4.1)
turbo-rails (2.0.10)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
unicode-display_width (2.6.0)
uri (0.13.1)
useragent (0.16.10)
vcr (6.3.1)
@@ -454,14 +455,13 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.18)
zeitwerk (2.7.1)
PLATFORMS
aarch64-linux
@@ -489,6 +489,7 @@ DEPENDENCIES
good_job
holidays
hotwire-livereload
hotwire_combobox
i18n-tasks
image_processing (>= 1.2)
importmap-rails
@@ -502,7 +503,7 @@ DEPENDENCIES
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails (~> 7.2.1)
rails (~> 7.2.2)
rails-settings-cached
redcarpet
rubocop-rails-omakase
@@ -522,7 +523,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.3.4p94
ruby 3.3.5p100
BUNDLED WITH
2.5.9
2.5.22

View File

@@ -42,14 +42,14 @@ The instructions below are for developers to get started with contributing to th
### Requirements
- Ruby 3.3.4
- See `.ruby-version` file for required Ruby version
- PostgreSQL >9.3 (ideally, latest stable version)
After cloning the repo, the basic setup commands are:
```sh
cd maybe
cp .env.example .env
cp .env.local.example .env.local
bin/setup
bin/dev

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 1321315963">
<rect width="20" height="20" rx="10" fill="#635BFF"/>
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd" d="M9.35663 7.69056C9.35663 7.20077 9.75747 7.01238 10.4214 7.01238C11.3734 7.01238 12.5759 7.30124 13.5279 7.81615V4.86482C12.4882 4.45037 11.461 4.28711 10.4214 4.28711C7.87854 4.28711 6.1875 5.61835 6.1875 7.84127C6.1875 11.3075 10.9475 10.7549 10.9475 12.2494C10.9475 12.8271 10.4464 13.0155 9.74495 13.0155C8.70527 13.0155 7.37749 12.5885 6.32529 12.0108V14.9998C7.49023 15.5022 8.66769 15.7157 9.74495 15.7157C12.3504 15.7157 14.1416 14.4221 14.1416 12.1741C14.1291 8.43154 9.35663 9.09716 9.35663 7.69056Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 775 B

View File

@@ -19,7 +19,8 @@
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
}
.form-field__label {
.form-field__label, .hw-combobox__label {
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
}
@@ -100,7 +101,7 @@
}
.btn {
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer focus:outline-gray-500;
}
.btn--primary {
@@ -120,6 +121,33 @@
}
}
.combobox {
.hw-combobox__main__wrapper, .hw-combobox__input {
@apply w-full;
}
.hw-combobox__main__wrapper {
@apply border-0 p-0 focus:border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none focus-within:shadow-none;
}
.hw-combobox__listbox {
@apply absolute top-[160%] right-0 w-full bg-transparent rounded z-30;
}
.hw_combobox__pagination__wrapper {
@apply h-px;
&:only-child {
@apply bg-transparent;
}
}
--hw-border-color: rgba(0, 0, 0, 0.2);
--hw-handle-width: 20px;
--hw-handle-height: 20px;
--hw-handle-offset-right: 0px;
}
/* Small, single purpose classes that should take precedence over other styles */
@layer utilities {
.scrollbar::-webkit-scrollbar {

View File

@@ -4,13 +4,21 @@ class Account::EntriesController < ApplicationController
before_action :set_account
before_action :set_entry, only: %i[edit update show destroy]
def index
@q = search_params
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
def edit
render entryable_view_path(:edit)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params)
@entry.sync_account_later
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
@@ -43,6 +51,11 @@ class Account::EntriesController < ApplicationController
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
end
def search_params
params.fetch(:q, {})
.permit(:search)
end
end

View File

@@ -1,10 +0,0 @@
class Account::LogosController < ApplicationController
def show
@account = Current.family.accounts.find(params[:account_id])
render_placeholder
end
def render_placeholder
render formats: :svg
end
end

View File

@@ -17,10 +17,10 @@ class Account::TradesController < ApplicationController
if entry = @builder.save
entry.sync_account_later
redirect_to account_path(@account), notice: t(".success")
redirect_to @account, notice: t(".success")
else
flash[:alert] = t(".failure")
redirect_back_or_to account_path(@account)
redirect_back_or_to @account
end
end
@@ -33,6 +33,13 @@ class Account::TradesController < ApplicationController
end
end
def securities
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security::SynthComboboxOption.find_in_synth(query)
end
private
def set_account

View File

@@ -12,16 +12,25 @@ class Account::TransactionsController < ApplicationController
end
def update
@entry.update!(entry_params)
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params.except(:origin))
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
@entry,
partial: "account/entries/entry",
locals: entry_locals.merge(entry: @entry)
)
end
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
@@ -30,10 +39,18 @@ class Account::TransactionsController < ApplicationController
@entry = @account.entries.find(params[:id])
end
def entry_locals
{
selectable: entry_params[:origin].present?,
show_balance: entry_params[:origin] == "account",
origin: entry_params[:origin]
}
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature,
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
entryable_attributes: [
:id,
:category_id,

View File

@@ -1,12 +1,15 @@
class Account::TransfersController < ApplicationController
layout :with_sidebar
before_action :set_transfer, only: :destroy
before_action :set_transfer, only: %i[destroy show update]
def new
@transfer = Account::Transfer.new
end
def show
end
def create
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
@@ -14,8 +17,7 @@ class Account::TransfersController < ApplicationController
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
date: transfer_params[:date],
amount: transfer_params[:amount].to_d,
currency: transfer_params[:currency],
name: transfer_params[:name]
currency: transfer_params[:currency]
if @transfer.save
@transfer.entries.each(&:sync_account_later)
@@ -28,18 +30,33 @@ class Account::TransfersController < ApplicationController
end
end
def update
@transfer.update_entries!(transfer_update_params)
redirect_back_or_to transactions_url, notice: t(".success")
end
def destroy
@transfer.destroy_and_remove_marks!
@transfer.destroy!
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_transfer
@transfer = Account::Transfer.find(params[:id])
record = Account::Transfer.find(params[:id])
unless record.entries.all? { |entry| Current.family.accounts.include?(entry.account) }
raise ActiveRecord::RecordNotFound
end
@transfer = record
end
def transfer_params
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name)
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :date, :name, :excluded)
end
def transfer_update_params
params.require(:account_transfer).permit(:excluded, :notes)
end
end

View File

@@ -15,7 +15,7 @@ class Account::ValuationsController < ApplicationController
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to account_path(@account)
redirect_to @account
end
end

View File

@@ -1,8 +1,7 @@
class AccountsController < ApplicationController
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[edit show destroy sync update]
before_action :set_account, only: %i[sync]
def index
@institutions = Current.family.institutions
@@ -10,6 +9,7 @@ class AccountsController < ApplicationController
end
def summary
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@@ -19,52 +19,10 @@ class AccountsController < ApplicationController
end
def list
@period = Period.from_param(params[:period])
render layout: false
end
def new
@account = Account.new(
accountable: Accountable.from_type(params[:type])&.new,
currency: Current.family.currency
)
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
if params[:institution_id]
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
end
end
def show
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit
@account.accountable.build_address if @account.accountable.is_a?(Property) && @account.accountable.address.blank?
end
def update
@account.update_with_sync!(account_params)
redirect_back_or_to account_path(@account), notice: t(".success")
end
def create
@account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
end
def destroy
@account.destroy!
redirect_to accounts_path, notice: t(".success")
end
def sync
unless @account.syncing?
@account.sync_later
@@ -77,12 +35,7 @@ class AccountsController < ApplicationController
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
end

View File

@@ -1,8 +1,23 @@
class ApplicationController < ActionController::Base
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend
helper_method :require_upgrade?, :subscription_pending?
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
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 with_sidebar
return "turbo_rails/frame" if turbo_frame_request?

View File

@@ -1,7 +1,7 @@
class CategoriesController < ApplicationController
layout :with_sidebar
before_action :set_category, only: %i[edit update]
before_action :set_category, only: %i[edit update destroy]
before_action :set_transaction, only: :create
def index
@@ -13,12 +13,14 @@ class CategoriesController < ApplicationController
end
def create
Category.transaction do
category = Current.family.categories.create!(category_params)
@transaction.update!(category_id: category.id) if @transaction
end
@category = Current.family.categories.new(category_params)
redirect_back_or_to transactions_path, notice: t(".success")
if @category.save
@transaction.update(category_id: @category.id) if @transaction
redirect_back_or_to transactions_path, notice: t(".success")
else
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
end
end
def edit
@@ -30,6 +32,12 @@ class CategoriesController < ApplicationController
redirect_back_or_to transactions_path, notice: t(".success")
end
def destroy
@category.destroy
redirect_back_or_to categories_path, notice: t(".success")
end
private
def set_category
@category = Current.family.categories.find(params[:id])

View File

@@ -0,0 +1,60 @@
module AccountableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
end
class_methods do
def permitted_accountable_attributes(*attrs)
@permitted_accountable_attributes = attrs if attrs.any?
@permitted_accountable_attributes ||= [ :id ]
end
end
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: accountable_type.new,
institution_id: params[:institution_id]
)
end
def show
end
def edit
end
def create
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
redirect_to account_params[:return_to].presence || @account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params.except(:return_to))
redirect_back_or_to @account, notice: t(".success")
end
def destroy
@account.destroy!
redirect_to accounts_path, notice: t(".success")
end
private
def accountable_type
controller_name.classify.constantize
end
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account).permit(
:name, :is_active, :balance, :subtype, :currency, :institution_id, :accountable_type, :return_to,
accountable_attributes: self.class.permitted_accountable_attributes
)
end
end

View File

@@ -14,7 +14,7 @@ module Authentication
private
def authenticate_user!
if session_record = Session.find_by_id(cookies.signed[:session_token])
if session_record = find_session_by_cookie
Current.session = session_record
else
if self_hosted_first_login?
@@ -25,6 +25,10 @@ module Authentication
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_token])
end
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }

View File

@@ -1,23 +0,0 @@
module Filterable
extend ActiveSupport::Concern
included do
before_action :set_period
end
private
def set_period
@period = Period.find_by_name(params[:period])
if @period.nil?
start_date = params[:start_date].presence&.to_date
end_date = params[:end_date].presence&.to_date
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
@period = Period.new(name: "custom", date_range: start_date..end_date)
else
params[:period] = "last_30_days"
@period = Period.find_by_name(params[:period])
end
end
end
end

View File

@@ -0,0 +1,21 @@
module Impersonatable
extend ActiveSupport::Concern
included do
after_action :create_impersonation_session_log
end
private
def create_impersonation_session_log
return unless Current.session&.active_impersonator_session.present?
Current.session.active_impersonator_session.logs.create!(
controller: controller_name,
action: action_name,
path: request.fullpath,
method: request.method,
ip_address: request.ip,
user_agent: request.user_agent
)
end
end

View File

@@ -7,6 +7,7 @@ module Invitable
private
def invite_code_required?
return false if @invitation.present?
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
end

View File

@@ -0,0 +1,17 @@
module Onboardable
extend ActiveSupport::Concern
included do
before_action :redirect_to_onboarding, if: :needs_onboarding?
end
private
def redirect_to_onboarding
redirect_to onboarding_path
end
def needs_onboarding?
Current.user && Current.user.onboarded_at.blank? &&
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
end
end

View File

@@ -1,41 +1,12 @@
class CreditCardsController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:available_credit,
:minimum_payment,
:apr,
:annual_fee,
:expiration_date
]
)
end
permitted_accountable_attributes(
:id,
:available_credit,
:minimum_payment,
:apr,
:annual_fee,
:expiration_date
)
end

View File

@@ -0,0 +1,3 @@
class CryptosController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,3 @@
class DepositoriesController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,58 @@
class ImpersonationSessionsController < ApplicationController
before_action :require_super_admin!, only: [ :create, :join, :leave ]
before_action :set_impersonation_session, only: [ :approve, :reject, :complete ]
def create
Current.true_user.request_impersonation_for(session_params[:impersonated_id])
redirect_to root_path, notice: t(".success")
end
def join
@impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id])
Current.session.update!(active_impersonator_session: @impersonation_session)
redirect_to root_path, notice: t(".success")
end
def leave
Current.session.update!(active_impersonator_session: nil)
redirect_to root_path, notice: t(".success")
end
def approve
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.approve!
redirect_to root_path, notice: t(".success")
end
def reject
raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
@impersonation_session.reject!
redirect_to root_path, notice: t(".success")
end
def complete
@impersonation_session.complete!
redirect_to root_path, notice: t(".success")
end
private
def session_params
params.require(:impersonation_session).permit(:impersonated_id)
end
def set_impersonation_session
@impersonation_session =
Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) ||
Current.true_user.impersonator_support_sessions.find_by(id: params[:id])
end
def require_super_admin!
raise_unauthorized! unless Current.true_user&.super_admin?
end
def raise_unauthorized!
raise ActionController::RoutingError.new("Not Found")
end
end

View File

@@ -20,6 +20,21 @@ class Import::ConfigurationsController < ApplicationController
end
def import_params
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
params.require(:import).permit(
:date_col_label,
:amount_col_label,
:name_col_label,
:category_col_label,
:tags_col_label,
:account_col_label,
:qty_col_label,
:ticker_col_label,
:price_col_label,
:entity_type_col_label,
:notes_col_label,
:currency_col_label,
:date_format,
:signage_convention
)
end
end

View File

@@ -24,7 +24,11 @@ class ImportsController < ApplicationController
end
def show
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
if !@import.uploaded?
redirect_to import_upload_path(@import), alert: "Please finalize your file upload."
elsif !@import.publishable?
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding."
end
end
def destroy

View File

@@ -0,0 +1,3 @@
class InvestmentsController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,42 @@
class InvitationsController < ApplicationController
skip_authentication only: :accept
def new
@invitation = Invitation.new
end
def create
unless Current.user.admin?
flash[:alert] = t(".failure")
redirect_to settings_profile_path
return
end
@invitation = Current.family.invitations.build(invitation_params)
@invitation.inviter = Current.user
if @invitation.save
InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?
flash[:notice] = t(".success")
else
flash[:alert] = t(".failure")
end
redirect_to settings_profile_path
end
def accept
@invitation = Invitation.find_by!(token: params[:id])
if @invitation.pending?
redirect_to new_registration_path(invitation: @invitation.token)
else
raise ActiveRecord::RecordNotFound
end
end
private
def invitation_params
params.require(:invitation).permit(:email, :role)
end
end

View File

@@ -3,8 +3,9 @@ class Issue::ExchangeRateProviderMissingsController < ApplicationController
def update
Setting.synth_api_key = exchange_rate_params[:synth_api_key]
@issue.issuable.sync_later
redirect_back_or_to account_path(@issue.issuable)
account = @issue.issuable
account.sync_later
redirect_back_or_to account
end
private

View File

@@ -1,39 +1,7 @@
class LoansController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:rate_type,
:interest_rate,
:term_months
]
)
end
permitted_accountable_attributes(
:id, :rate_type, :interest_rate, :term_months
)
end

View File

@@ -12,8 +12,13 @@ class MerchantsController < ApplicationController
end
def create
Current.family.merchants.create!(merchant_params)
redirect_to merchants_path, notice: t(".success")
@merchant = Current.family.merchants.new(merchant_params)
if @merchant.save
redirect_to merchants_path, notice: t(".success")
else
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
end
end
def edit

View File

@@ -0,0 +1,24 @@
class OnboardingsController < ApplicationController
layout "application"
before_action :set_user
before_action :load_invitation
def show
end
def profile
end
def preferences
end
private
def set_user
@user = Current.user
end
def load_invitation
@invitation = Current.family.invitations.accepted.find_by(email: Current.user.email)
end
end

View File

@@ -0,0 +1,3 @@
class OtherAssetsController < ApplicationController
include AccountableResource
end

View File

@@ -0,0 +1,3 @@
class OtherLiabilitiesController < ApplicationController
include AccountableResource
end

View File

@@ -2,9 +2,8 @@ class PagesController < ApplicationController
skip_before_action :authenticate_user!, only: %i[early_access]
layout :with_sidebar, except: %i[early_access]
include Filterable
def dashboard
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@@ -20,7 +19,7 @@ class PagesController < ApplicationController
@top_earners = snapshot_account_transactions[:top_earners]
@top_savers = snapshot_account_transactions[:top_savers]
@accounts = Current.family.accounts
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological

View File

@@ -16,7 +16,7 @@ class PasswordResetsController < ApplicationController
).password_reset.deliver_later
end
redirect_to root_path, notice: t(".requested")
redirect_to new_password_reset_path(step: "pending")
end
def edit

View File

@@ -1,40 +1,22 @@
class PropertiesController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
permitted_accountable_attributes(
:id, :year_built, :area_unit, :area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
)
account.sync_later
redirect_to account, notice: t(".success")
def new
@account = Current.family.accounts.build(
currency: Current.family.currency,
accountable: Property.new(
address: Address.new
),
institution_id: params[:institution_id]
)
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
def edit
@account.accountable.address ||= Address.new
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:year_built,
:area_unit,
:area_value,
address_attributes: [ :line1, :line2, :locality, :region, :country, :postal_code ]
]
)
end
end

View File

@@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
layout "auth"
before_action :set_user, only: :create
before_action :set_invitation
before_action :claim_invite_code, only: :create, if: :invite_code_required?
def new
@user = User.new
@user = User.new(email: @invitation&.email)
end
def create
family = Family.new
@user.family = family
@user.role = :admin
if @invitation
@user.family = @invitation.family
@user.role = @invitation.role
@user.email = @invitation.email
else
family = Family.new
@user.family = family
@user.role = :admin
end
if @user.save
Category.create_default_categories(@user.family)
@invitation&.update!(accepted_at: Time.current)
Category.create_default_categories(@user.family) unless @invitation
@session = create_session_for(@user)
flash[:notice] = t(".success")
redirect_to root_path
redirect_to root_path, notice: t(".success")
else
flash[:alert] = t(".failure")
render :new, status: :unprocessable_entity
end
end
private
def set_user
@user = User.new user_params.except(:invite_code)
def set_invitation
token = params[:invitation]
token ||= params[:user][:invitation] if params[:user].present?
@invitation = Invitation.pending.find_by(token: token)
end
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
def set_user
@user = User.new user_params.except(:invite_code, :invitation)
end
def user_params(specific_param = nil)
params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
specific_param ? params[specific_param] : params
end
def claim_invite_code

View File

@@ -0,0 +1,5 @@
class SecuritiesController < ApplicationController
def import
SecuritiesImportJob.perform_later(params[:exchange_mic])
end
end

View File

@@ -19,7 +19,7 @@ class SessionsController < ApplicationController
def destroy
@session.destroy
redirect_to root_path, notice: t(".logout_successful")
redirect_to new_session_path, notice: t(".logout_successful")
end
private

View File

@@ -1,2 +1,5 @@
class Settings::BillingsController < SettingsController
def show
@user = Current.user
end
end

View File

@@ -1,26 +1,5 @@
class Settings::PreferencesController < SettingsController
def edit
def show
@user = Current.user
end
def update
preference_params_with_family = preference_params
if Current.family && preference_params[:family_attributes]
family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id })
preference_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(preference_params_with_family)
redirect_to settings_preferences_path, notice: t(".success")
else
redirect_to settings_preferences_path, notice: t(".success")
render :show, status: :unprocessable_entity
end
end
private
def preference_params
params.require(:user).permit(family_attributes: [ :id, :currency, :locale ])
end
end

View File

@@ -1,38 +1,7 @@
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
end
def update
user_params_with_family = user_params
if params[:user][:delete_profile_image] == "true"
Current.user.profile_image.purge
end
if Current.family && user_params_with_family[:family_attributes]
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
user_params_with_family[:family_attributes] = family_attributes
end
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end
def destroy
if Current.user.deactivate
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
end
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
end

View File

@@ -1,16 +1,14 @@
class SubscriptionsController < ApplicationController
def new
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
if Current.family.stripe_customer_id.blank?
customer = client.v1.customers.create(
customer = stripe_client.v1.customers.create(
email: Current.family.primary_user.email,
metadata: { family_id: Current.family.id }
)
Current.family.update(stripe_customer_id: customer.id)
end
session = client.v1.checkout.sessions.create({
session = stripe_client.v1.checkout.sessions.create({
customer: Current.family.stripe_customer_id,
line_items: [ {
price: ENV["STRIPE_PLAN_ID"],
@@ -18,7 +16,7 @@ class SubscriptionsController < ApplicationController
} ],
mode: "subscription",
allow_promotion_codes: true,
success_url: settings_billing_url,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: settings_billing_url
})
@@ -26,12 +24,24 @@ class SubscriptionsController < ApplicationController
end
def show
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
portal_session = client.v1.billing_portal.sessions.create(
portal_session = stripe_client.v1.billing_portal.sessions.create(
customer: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
redirect_to portal_session.url, allow_other_host: true, status: :see_other
end
def success
checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id])
Current.session.update(subscribed_at: Time.at(checkout_session.created))
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
rescue Stripe::InvalidRequestError
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
end
private
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
end
end

View File

@@ -1,7 +1,7 @@
class TagsController < ApplicationController
layout :with_sidebar
before_action :set_tag, only: %i[edit update]
before_action :set_tag, only: %i[edit update destroy]
def index
@tags = Current.family.tags.alphabetically
@@ -12,8 +12,13 @@ class TagsController < ApplicationController
end
def create
Current.family.tags.create!(tag_params)
redirect_to tags_path, notice: t(".created")
@tag = Current.family.tags.new(tag_params)
if @tag.save
redirect_to tags_path, notice: t(".created")
else
redirect_to tags_path, alert: t(".error", error: @tag.errors.full_messages.to_sentence)
end
end
def edit
@@ -24,6 +29,11 @@ class TagsController < ApplicationController
redirect_to tags_path, notice: t(".updated")
end
def destroy
@tag.destroy!
redirect_to tags_path, notice: t(".deleted")
end
private
def set_tag

View File

@@ -32,11 +32,12 @@ class TransactionsController < ApplicationController
.create!(transaction_entry_params.merge(amount: amount))
@entry.sync_account_later
redirect_back_or_to account_path(@entry.account), notice: t(".success")
redirect_back_or_to @entry.account, notice: t(".success")
end
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

View File

@@ -0,0 +1,51 @@
class UsersController < ApplicationController
before_action :set_user
def update
@user = Current.user
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
handle_redirect(t(".success"))
end
def destroy
if @user.deactivate
Current.session.destroy
redirect_to root_path, notice: t(".success")
else
redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence
end
end
private
def handle_redirect(notice)
case user_params[:redirect_to]
when "onboarding_preferences"
redirect_to preferences_onboarding_path
when "home"
redirect_to root_path
when "preferences"
redirect_to settings_preferences_path, notice: notice
else
redirect_to settings_profile_path, notice: notice
end
end
def should_purge_profile_image?
user_params[:delete_profile_image] == "1" &&
user_params[:profile_image].blank?
end
def user_params
params.require(:user).permit(
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ]
)
end
def set_user
@user = Current.user
end
end

View File

@@ -1,41 +1,7 @@
class VehiclesController < ApplicationController
before_action :set_account, only: :update
include AccountableResource
def create
account = Current.family
.accounts
.create_with_optional_start_balance! \
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
account.sync_later
redirect_to account, notice: t(".success")
end
def update
@account.update_with_sync!(account_params)
redirect_to @account, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:id])
end
def account_params
params.require(:account)
.permit(
:name, :balance, :institution_id, :start_date, :start_balance, :currency, :accountable_type,
accountable_attributes: [
:id,
:make,
:model,
:year,
:mileage_value,
:mileage_unit
]
)
end
permitted_accountable_attributes(
:id, :make, :model, :year, :mileage_value, :mileage_unit
)
end

View File

@@ -12,43 +12,18 @@ module Account::EntriesHelper
transfers.map(&:transfer).uniq
end
def entry_icon(entry, is_oldest: false)
if is_oldest
"keyboard"
elsif entry.trend.direction.up?
"arrow-up"
elsif entry.trend.direction.down?
"arrow-down"
else
"minus"
end
end
def entry_style(entry, is_oldest: false)
color = is_oldest ? "#D444F1" : entry.trend.color
mixed_hex_styles(color)
end
def entry_name(entry)
if entry.account_trade?
trade = entry.account_trade
prefix = trade.sell? ? "Sell " : "Buy "
generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}"
name = entry.name || generated
name
else
entry.name || "Transaction"
end
end
def entries_by_date(entries, selectable: true)
def entries_by_date(entries, selectable: true, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield grouped_entries
# Valuations always go first, then sort by created_at desc
sorted_entries = grouped_entries.sort_by do |entry|
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
end
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: }
content = capture do
yield sorted_entries
end
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
end.join.html_safe
end

View File

@@ -1,4 +1,27 @@
module AccountsHelper
def period_label(period)
return "since account creation" if period.date_range.begin.nil?
start_date, end_date = period.date_range.first, period.date_range.last
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
days_apart = (end_date - start_date).to_i
case days_apart
when 1
"vs. yesterday"
when 7
"vs. last week"
when 30, 31
"vs. last month"
when 365, 366
"vs. last year"
else
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
end
end
def summary_card(title:, &block)
content = capture(&block)
render "accounts/summary_card", title: title, content: content
@@ -28,65 +51,9 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
# Eventually, we'll have an accountable form for each type of accountable, so
# this helper is a convenience for now to reuse common logic in the accounts controller
def new_account_form_url(account)
case account.accountable_type
when "Property"
properties_path
when "Vehicle"
vehicles_path
when "Loan"
loans_path
when "CreditCard"
credit_cards_path
else
accounts_path
end
end
def edit_account_form_url(account)
case account.accountable_type
when "Property"
property_path(account)
when "Vehicle"
vehicle_path(account)
when "Loan"
loan_path(account)
when "CreditCard"
credit_card_path(account)
else
account_path(account)
end
end
def account_tabs(account)
overview_tab = { key: "overview", label: t("accounts.show.overview"), path: account_path(account, tab: "overview"), partial_path: "accounts/overview" }
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), route: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), route: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), route: account_valuations_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), route: account_transactions_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), route: account_trades_path(account) }
return [ value_tab ] if account.other_asset? || account.other_liability?
return [ overview_tab, value_tab ] if account.property? || account.vehicle?
return [ holdings_tab, cash_tab, trades_tab, value_tab ] if account.investment?
return [ overview_tab, value_tab, transactions_tab ] if account.loan? || account.credit_card?
[ value_tab, transactions_tab ]
end
def selected_account_tab(account)
available_tabs = account_tabs(account)
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
tab || available_tabs.first
end
def account_groups(period: nil)
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children, liabilities.children ].flatten
assets, liabilities = Current.family.accounts.active.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
[ assets.children.sort_by(&:name), liabilities.children.sort_by(&:name) ].flatten
end
private

View File

@@ -1,6 +1,19 @@
module ApplicationHelper
include Pagy::Frontend
def date_format_options
[
[ "DD-MM-YYYY", "%d-%m-%Y" ],
[ "MM-DD-YYYY", "%m-%d-%Y" ],
[ "YYYY-MM-DD", "%Y-%m-%d" ],
[ "DD/MM/YYYY", "%d/%m/%Y" ],
[ "YYYY/MM/DD", "%Y/%m/%d" ],
[ "MM/DD/YYYY", "%m/%d/%Y" ],
[ "D/MM/YYYY", "%e/%m/%Y" ],
[ "YYYY.MM.DD", "%Y.%m.%d" ]
]
end
def title(page_title)
content_for(:title) { page_title }
end
@@ -9,10 +22,6 @@ module ApplicationHelper
content_for(:header_title) { page_title }
end
def permitted_accountable_partial(name)
name.underscore
end
def family_notifications_stream
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
@@ -80,8 +89,8 @@ module ApplicationHelper
color = hex || "#1570EF" # blue-600
<<-STYLE.strip
background-color: color-mix(in srgb, #{color} 5%, white);
border-color: color-mix(in srgb, #{color} 10%, white);
background-color: color-mix(in srgb, #{color} 10%, white);
border-color: color-mix(in srgb, #{color} 30%, white);
color: #{color};
STYLE
end
@@ -113,26 +122,16 @@ module ApplicationHelper
{ bg_class: bg_class, text_class: text_class, symbol: symbol, icon: icon }
end
def period_label(period)
return "since account creation" if period.date_range.begin.nil?
start_date, end_date = period.date_range.first, period.date_range.last
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
return "Ending at #{end_date.strftime('%b %d, %Y')}" if start_date.nil?
format_code = options[:format_code] || Current.family&.date_format
days_apart = (end_date - start_date).to_i
case days_apart
when 1
"vs. yesterday"
when 7
"vs. last week"
when 30, 31
"vs. last month"
when 365, 366
"vs. last year"
if format_code.present?
date.strftime(format_code)
else
"from #{start_date.strftime('%b %d, %Y')} to #{end_date.strftime('%b %d, %Y')}"
I18n.l(date, format: format, **options)
end
end

View File

@@ -4,4 +4,8 @@ module CategoriesHelper
name: "Uncategorized",
color: Category::UNCATEGORIZED_COLOR
end
def family_categories
[ null_category ].concat(Current.family.categories.alphabetically)
end
end

View File

@@ -0,0 +1,2 @@
module ImpersonationSessionsHelper
end

View File

@@ -0,0 +1,2 @@
module InvitationsHelper
end

View File

@@ -0,0 +1,366 @@
module LanguagesHelper
LANGUAGE_MAPPING = {
en: "English",
ru: "Russian",
ar: "Arabic",
bg: "Bulgarian",
'ca-CAT': "Catalan (Catalonia)",
ca: "Catalan",
'da-DK': "Danish (Denmark)",
'de-AT': "German (Austria)",
'de-CH': "German (Switzerland)",
de: "German",
ee: "Ewe",
'en-AU': "English (Australia)",
'en-BORK': "English (Bork)",
'en-CA': "English (Canada)",
'en-GB': "English (United Kingdom)",
'en-IND': "English (India)",
'en-KE': "English (Kenya)",
'en-MS': "English (Malaysia)",
'en-NEP': "English (Nepal)",
'en-NG': "English (Nigeria)",
'en-NZ': "English (New Zealand)",
'en-PAK': "English (Pakistan)",
'en-SG': "English (Singapore)",
'en-TH': "English (Thailand)",
'en-UG': "English (Uganda)",
'en-US': "English (United States)",
'en-ZA': "English (South Africa)",
'en-au-ocker': "English (Australian Ocker)",
'es-AR': "Spanish (Argentina)",
'es-MX': "Spanish (Mexico)",
es: "Spanish",
fa: "Persian",
'fi-FI': "Finnish (Finland)",
fr: "French",
'fr-CA': "French (Canada)",
'fr-CH': "French (Switzerland)",
he: "Hebrew",
hy: "Armenian",
id: "Indonesian",
it: "Italian",
ja: "Japanese",
ko: "Korean",
lt: "Lithuanian",
lv: "Latvian",
'mi-NZ': "Maori (New Zealand)",
'nb-NO': "Norwegian Bokmål (Norway)",
nl: "Dutch",
'no-NO': "Norwegian (Norway)",
pl: "Polish",
'pt-BR': "Portuguese (Brazil)",
pt: "Portuguese",
sk: "Slovak",
sv: "Swedish",
th: "Thai",
tr: "Turkish",
uk: "Ukrainian",
vi: "Vietnamese",
'zh-CN': "Chinese (Simplified)",
'zh-TW': "Chinese (Traditional)",
af: "Afrikaans",
az: "Azerbaijani",
be: "Belarusian",
bn: "Bengali",
bs: "Bosnian",
cs: "Czech",
cy: "Welsh",
da: "Danish",
'de-DE': "German (Germany)",
dz: "Dzongkha",
'el-CY': "Greek (Cyprus)",
el: "Greek",
'en-CY': "English (Cyprus)",
'en-IE': "English (Ireland)",
'en-IN': "English (India)",
'en-TT': "English (Trinidad and Tobago)",
eo: "Esperanto",
'es-419': "Spanish (Latin America)",
'es-CL': "Spanish (Chile)",
'es-CO': "Spanish (Colombia)",
'es-CR': "Spanish (Costa Rica)",
'es-EC': "Spanish (Ecuador)",
'es-ES': "Spanish (Spain)",
'es-NI': "Spanish (Nicaragua)",
'es-PA': "Spanish (Panama)",
'es-PE': "Spanish (Peru)",
'es-US': "Spanish (United States)",
'es-VE': "Spanish (Venezuela)",
et: "Estonian",
eu: "Basque",
fi: "Finnish",
'fr-FR': "French (France)",
fy: "Western Frisian",
gd: "Scottish Gaelic",
gl: "Galician",
'hi-IN': "Hindi (India)",
hi: "Hindi",
hr: "Croatian",
hu: "Hungarian",
is: "Icelandic",
'it-CH': "Italian (Switzerland)",
ka: "Georgian",
kk: "Kazakh",
km: "Khmer",
kn: "Kannada",
lb: "Luxembourgish",
lo: "Lao",
mg: "Malagasy",
mk: "Macedonian",
ml: "Malayalam",
mn: "Mongolian",
'mr-IN': "Marathi (India)",
ms: "Malay",
nb: "Norwegian Bokmål",
ne: "Nepali",
nn: "Norwegian Nynorsk",
oc: "Occitan",
or: "Odia",
pa: "Punjabi",
rm: "Romansh",
ro: "Romanian",
sc: "Sardinian",
sl: "Slovenian",
sq: "Albanian",
sr: "Serbian",
st: "Southern Sotho",
'sv-FI': "Swedish (Finland)",
'sv-SE': "Swedish (Sweden)",
sw: "Swahili",
ta: "Tamil",
te: "Telugu",
tl: "Tagalog",
tt: "Tatar",
ug: "Uyghur",
ur: "Urdu",
uz: "Uzbek",
wo: "Wolof"
}.freeze
EXCLUDED_LOCALES = [
# Test locales
"en-BORK",
"en-au-ocker",
# Duplicate locales
"fr-FR",
"de-DE",
"hi-IN",
"sv-SE",
"ca-CAT",
"en-US",
"fi-FI",
"en-IND"
].freeze
COUNTRY_MAPPING = {
AF: "Afghanistan",
AL: "Albania",
DZ: "Algeria",
AD: "Andorra",
AO: "Angola",
AG: "Antigua and Barbuda",
AR: "Argentina",
AM: "Armenia",
AU: "Australia",
AT: "Austria",
AZ: "Azerbaijan",
BS: "Bahamas",
BH: "Bahrain",
BD: "Bangladesh",
BB: "Barbados",
BY: "Belarus",
BE: "Belgium",
BZ: "Belize",
BJ: "Benin",
BT: "Bhutan",
BO: "Bolivia",
BA: "Bosnia and Herzegovina",
BW: "Botswana",
BR: "Brazil",
BN: "Brunei",
BG: "Bulgaria",
BF: "Burkina Faso",
BI: "Burundi",
KH: "Cambodia",
CM: "Cameroon",
CA: "Canada",
CV: "Cape Verde",
CF: "Central African Republic",
TD: "Chad",
CL: "Chile",
CN: "China",
CO: "Colombia",
KM: "Comoros",
CG: "Congo",
CD: "Congo, Democratic Republic of the",
CR: "Costa Rica",
CI: "Côte d'Ivoire",
HR: "Croatia",
CU: "Cuba",
CY: "Cyprus",
CZ: "Czech Republic",
DK: "Denmark",
DJ: "Djibouti",
DM: "Dominica",
DO: "Dominican Republic",
EC: "Ecuador",
EG: "Egypt",
SV: "El Salvador",
GQ: "Equatorial Guinea",
ER: "Eritrea",
EE: "Estonia",
ET: "Ethiopia",
FJ: "Fiji",
FI: "Finland",
FR: "France",
GA: "Gabon",
GM: "Gambia",
GE: "Georgia",
DE: "Germany",
GH: "Ghana",
GR: "Greece",
GD: "Grenada",
GT: "Guatemala",
GN: "Guinea",
GW: "Guinea-Bissau",
GY: "Guyana",
HT: "Haiti",
HN: "Honduras",
HU: "Hungary",
IS: "Iceland",
IN: "India",
ID: "Indonesia",
IR: "Iran",
IQ: "Iraq",
IE: "Ireland",
IL: "Israel",
IT: "Italy",
JM: "Jamaica",
JP: "Japan",
JO: "Jordan",
KZ: "Kazakhstan",
KE: "Kenya",
KI: "Kiribati",
KP: "North Korea",
KR: "South Korea",
KW: "Kuwait",
KG: "Kyrgyzstan",
LA: "Laos",
LV: "Latvia",
LB: "Lebanon",
LS: "Lesotho",
LR: "Liberia",
LY: "Libya",
LI: "Liechtenstein",
LT: "Lithuania",
LU: "Luxembourg",
MK: "North Macedonia",
MG: "Madagascar",
MW: "Malawi",
MY: "Malaysia",
MV: "Maldives",
ML: "Mali",
MT: "Malta",
MH: "Marshall Islands",
MR: "Mauritania",
MU: "Mauritius",
MX: "Mexico",
FM: "Micronesia",
MD: "Moldova",
MC: "Monaco",
MN: "Mongolia",
ME: "Montenegro",
MA: "Morocco",
MZ: "Mozambique",
MM: "Myanmar",
NA: "Namibia",
NR: "Nauru",
NP: "Nepal",
NL: "Netherlands",
NZ: "New Zealand",
NI: "Nicaragua",
NE: "Niger",
NG: "Nigeria",
NO: "Norway",
OM: "Oman",
PK: "Pakistan",
PW: "Palau",
PA: "Panama",
PG: "Papua New Guinea",
PY: "Paraguay",
PE: "Peru",
PH: "Philippines",
PL: "Poland",
PT: "Portugal",
QA: "Qatar",
RO: "Romania",
RU: "Russia",
RW: "Rwanda",
KN: "Saint Kitts and Nevis",
LC: "Saint Lucia",
VC: "Saint Vincent and the Grenadines",
WS: "Samoa",
SM: "San Marino",
ST: "Sao Tome and Principe",
SA: "Saudi Arabia",
SN: "Senegal",
RS: "Serbia",
SC: "Seychelles",
SL: "Sierra Leone",
SG: "Singapore",
SK: "Slovakia",
SI: "Slovenia",
SB: "Solomon Islands",
SO: "Somalia",
ZA: "South Africa",
SS: "South Sudan",
ES: "Spain",
LK: "Sri Lanka",
SD: "Sudan",
SR: "Suriname",
SE: "Sweden",
CH: "Switzerland",
SY: "Syria",
TW: "Taiwan",
TJ: "Tajikistan",
TZ: "Tanzania",
TH: "Thailand",
TL: "Timor-Leste",
TG: "Togo",
TO: "Tonga",
TT: "Trinidad and Tobago",
TN: "Tunisia",
TR: "Turkey",
TM: "Turkmenistan",
TV: "Tuvalu",
UG: "Uganda",
UA: "Ukraine",
AE: "United Arab Emirates",
GB: "United Kingdom",
US: "United States",
UY: "Uruguay",
UZ: "Uzbekistan",
VU: "Vanuatu",
VA: "Vatican City",
VE: "Venezuela",
VN: "Vietnam",
YE: "Yemen",
ZM: "Zambia",
ZW: "Zimbabwe"
}.freeze
def country_options
COUNTRY_MAPPING.keys.map { |key| [ COUNTRY_MAPPING[key], key ] }
end
def language_options
I18n.available_locales
.reject { |locale| EXCLUDED_LOCALES.include?(locale.to_s) }
.map do |locale|
label = LANGUAGE_MAPPING[locale.to_sym] || locale.to_s.humanize
[ "#{label} (#{locale})", locale ]
end
.sort_by { |label, locale| label }
end
end

View File

@@ -0,0 +1,2 @@
module SecuritiesHelper
end

View File

@@ -5,10 +5,10 @@ module SettingsHelper
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
{ name: I18n.t("settings.nav.merchants_label"), path: :merchants_path },
{ name: I18n.t("settings.nav.imports_label"), path: :imports_path },
{ name: I18n.t("settings.nav.whats_new_label"), path: :changelog_path },
{ name: I18n.t("settings.nav.feedback_label"), path: :feedback_path }
]

View File

@@ -24,7 +24,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def select(method, choices, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, choices, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
@@ -33,7 +33,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
merged_html_options = { class: "form-field__input" }.merge(html_options)
label = build_label(method, options)
label = build_label(method, options.merge(required: merged_html_options[:required]))
field = super(method, collection, value_method, text_method, options, merged_html_options)
build_styled_field(label, field, options, remove_padding_right: true)
@@ -49,7 +49,12 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def submit(value = nil, options = {})
merged_options = { class: "btn btn--primary w-full" }.merge(options)
default_options = {
data: { turbo_submits_with: "Submitting..." },
class: "btn btn--primary w-full"
}
merged_options = default_options.merge(options)
value, options = nil, value if value.is_a?(Hash)
super(value, merged_options)
end
@@ -68,7 +73,17 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
def build_label(method, options)
return "".html_safe unless options[:label]
return label(method, class: "form-field__label") if options[:label] == true
label(method, options[:label], class: "form-field__label")
label_text = options[:label]
if options[:required]
label_text = @template.safe_join([
label_text == true ? method.to_s.humanize : label_text,
@template.tag.span("*", class: "text-red-500 ml-0.5")
])
end
return label(method, class: "form-field__label") if label_text == true
label(method, label_text, class: "form-field__label")
end
end

View File

@@ -1,3 +1,3 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "@hotwired/turbo-rails";
import "controllers";

View File

@@ -1,51 +1,51 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="account-collapse"
export default class extends Controller {
static values = { type: String }
initialToggle = false
STORAGE_NAME = "accountCollapseStates"
static values = { type: String };
initialToggle = false;
STORAGE_NAME = "accountCollapseStates";
connect() {
this.element.addEventListener("toggle", this.onToggle)
this.updateFromLocalStorage()
this.element.addEventListener("toggle", this.onToggle);
this.updateFromLocalStorage();
}
disconnect() {
this.element.removeEventListener("toggle", this.onToggle)
this.element.removeEventListener("toggle", this.onToggle);
}
onToggle = () => {
if (this.initialToggle) {
this.initialToggle = false
return
this.initialToggle = false;
return;
}
const items = this.getItemsFromLocalStorage()
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
items.delete(this.typeValue)
items.delete(this.typeValue);
} else {
items.add(this.typeValue)
items.add(this.typeValue);
}
localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]))
}
localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]));
};
updateFromLocalStorage() {
const items = this.getItemsFromLocalStorage()
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
this.initialToggle = true
this.element.setAttribute("open", "")
this.initialToggle = true;
this.element.setAttribute("open", "");
}
}
getItemsFromLocalStorage() {
try {
const items = localStorage.getItem(this.STORAGE_NAME)
return new Set(items ? JSON.parse(items) : [])
const items = localStorage.getItem(this.STORAGE_NAME);
return new Set(items ? JSON.parse(items) : []);
} catch (error) {
console.error("Error parsing items from localStorage:", error)
return new Set()
console.error("Error parsing items from localStorage:", error);
return new Set();
}
}
}

View File

@@ -1,10 +1,10 @@
import { Application } from "@hotwired/stimulus"
import { Application } from "@hotwired/stimulus";
const application = Application.start()
const application = Application.start();
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
application.debug = false;
window.Stimulus = application;
Turbo.setConfirmMethod((message) => {
const dialog = document.getElementById("turbo-confirm");
@@ -34,10 +34,14 @@ Turbo.setConfirmMethod((message) => {
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener("close", () => {
resolve(dialog.returnValue == "confirm")
}, { once: true })
})
})
dialog.addEventListener(
"close",
() => {
resolve(dialog.returnValue === "confirm");
},
{ once: true },
);
});
});
export { application }
export { application };

View File

@@ -24,10 +24,31 @@ export default class extends Controller {
});
}
handleInput = () => {
handleInput = (event) => {
const target = event.target
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.element.requestSubmit();
}, 500);
}, this.#debounceTimeout(target));
};
#debounceTimeout(element) {
if(element.dataset.autosubmitDebounceTimeout) {
return Number.parseInt(element.dataset.autosubmitDebounceTimeout);
}
const type = element.type || element.tagName;
switch (type.toLowerCase()) {
case 'input':
case 'textarea':
return 500;
case 'select-one':
case 'select-multiple':
return 0;
default:
return 500;
}
}
}

View File

@@ -1,134 +1,158 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="bulk-select"
export default class extends Controller {
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
static targets = [
"row",
"group",
"selectionBar",
"selectionBarText",
"bulkEditDrawerTitle",
];
static values = {
resource: String,
selectedIds: { type: Array, default: [] }
}
singularLabel: String,
pluralLabel: String,
selectedIds: { type: Array, default: [] },
};
connect() {
document.addEventListener("turbo:load", this._updateView)
document.addEventListener("turbo:load", this._updateView);
this._updateView()
this._updateView();
}
disconnect() {
document.removeEventListener("turbo:load", this._updateView)
document.removeEventListener("turbo:load", this._updateView);
}
bulkEditDrawerTitleTargetConnected(element) {
element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}`
element.innerText = `Edit ${
this.selectedIdsValue.length
} ${this._pluralizedResourceName()}`;
}
submitBulkRequest(e) {
const form = e.target.closest("form");
const scope = e.params.scope
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[entry_ids][]`, this.selectedIdsValue)
form.requestSubmit()
const scope = e.params.scope;
this._addHiddenFormInputsForSelectedIds(
form,
`${scope}[entry_ids][]`,
this.selectedIdsValue,
);
form.requestSubmit();
}
togglePageSelection(e) {
if (e.target.checked) {
this.#selectAll()
this._selectAll();
} else {
this.deselectAll()
this.deselectAll();
}
}
toggleGroupSelection(e) {
const group = this.groupTargets.find(group => group.contains(e.target))
const group = this.groupTargets.find((group) => group.contains(e.target));
this.#rowsForGroup(group).forEach(row => {
this._rowsForGroup(group).forEach((row) => {
if (e.target.checked) {
this.#addToSelection(row.dataset.id)
this._addToSelection(row.dataset.id);
} else {
this.#removeFromSelection(row.dataset.id)
this._removeFromSelection(row.dataset.id);
}
})
});
}
toggleRowSelection(e) {
if (e.target.checked) {
this.#addToSelection(e.target.dataset.id)
this._addToSelection(e.target.dataset.id);
} else {
this.#removeFromSelection(e.target.dataset.id)
this._removeFromSelection(e.target.dataset.id);
}
}
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
this.selectedIdsValue = [];
this.element.querySelectorAll('input[type="checkbox"]').forEach((el) => {
el.checked = false;
});
}
selectedIdsValueChanged() {
this._updateView()
this._updateView();
}
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
this.#resetFormInputs(form, paramName);
_addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
this._resetFormInputs(form, paramName);
transactionIds.forEach(id => {
transactionIds.forEach((id) => {
const input = document.createElement("input");
input.type = 'hidden'
input.name = paramName
input.value = id
form.appendChild(input)
})
input.type = "hidden";
input.name = paramName;
input.value = id;
form.appendChild(input);
});
}
#resetFormInputs(form, paramName) {
_resetFormInputs(form, paramName) {
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
existingInputs.forEach((input) => input.remove());
}
#rowsForGroup(group) {
return this.rowTargets.filter(row => group.contains(row))
_rowsForGroup(group) {
return this.rowTargets.filter((row) => group.contains(row));
}
#addToSelection(idToAdd) {
_addToSelection(idToAdd) {
this.selectedIdsValue = Array.from(
new Set([...this.selectedIdsValue, idToAdd])
)
new Set([...this.selectedIdsValue, idToAdd]),
);
}
#removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove)
_removeFromSelection(idToRemove) {
this.selectedIdsValue = this.selectedIdsValue.filter(
(id) => id !== idToRemove,
);
}
#selectAll() {
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
_selectAll() {
this.selectedIdsValue = this.rowTargets.map((t) => t.dataset.id);
}
_updateView = () => {
this.#updateSelectionBar()
this.#updateGroups()
this.#updateRows()
this._updateSelectionBar();
this._updateGroups();
this._updateRows();
};
_updateSelectionBar() {
const count = this.selectedIdsValue.length;
this.selectionBarTextTarget.innerText = `${count} ${this._pluralizedResourceName()} selected`;
this.selectionBarTarget.classList.toggle("hidden", count === 0);
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
}
#updateSelectionBar() {
const count = this.selectedIdsValue.length
this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected`
this.selectionBarTarget.hidden = count === 0
this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0
_pluralizedResourceName() {
if (this.selectedIdsValue.length === 1) {
return this.singularLabelValue;
}
return this.pluralLabelValue;
}
#pluralizedResourceName() {
return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}`
_updateGroups() {
this.groupTargets.forEach((group) => {
const rows = this.rowTargets.filter((row) => group.contains(row));
const groupSelected =
rows.length > 0 &&
rows.every((row) => this.selectedIdsValue.includes(row.dataset.id));
group.querySelector("input[type='checkbox']").checked = groupSelected;
});
}
#updateGroups() {
this.groupTargets.forEach(group => {
const rows = this.rowTargets.filter(row => group.contains(row))
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
group.querySelector("input[type='checkbox']").checked = groupSelected
})
}
#updateRows() {
this.rowTargets.forEach(row => {
row.checked = this.selectedIdsValue.includes(row.dataset.id)
})
_updateRows() {
this.rowTargets.forEach((row) => {
row.checked = this.selectedIdsValue.includes(row.dataset.id);
});
}
}

View File

@@ -1,28 +1,28 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["source", "iconDefault", "iconSuccess"]
static targets = ["source", "iconDefault", "iconSuccess"];
copy(event) {
event.preventDefault();
if (this.sourceTarget && this.sourceTarget.textContent) {
navigator.clipboard.writeText(this.sourceTarget.textContent)
if (this.sourceTarget?.textContent) {
navigator.clipboard
.writeText(this.sourceTarget.textContent)
.then(() => {
this.showSuccess();
})
.catch((error) => {
console.error('Failed to copy text: ', error);
console.error("Failed to copy text: ", error);
});
}
}
showSuccess() {
this.iconDefaultTarget.classList.add('hidden');
this.iconSuccessTarget.classList.remove('hidden');
this.iconDefaultTarget.classList.add("hidden");
this.iconSuccessTarget.classList.remove("hidden");
setTimeout(() => {
this.iconDefaultTarget.classList.remove('hidden');
this.iconSuccessTarget.classList.add('hidden');
this.iconDefaultTarget.classList.remove("hidden");
this.iconSuccessTarget.classList.add("hidden");
}, 3000);
}
}

View File

@@ -3,10 +3,7 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="color-avatar"
// Used by the transaction merchant form to show a preview of what the avatar will look like
export default class extends Controller {
static targets = [
"name",
"avatar"
];
static targets = ["name", "avatar"];
connect() {
this.nameTarget.addEventListener("input", this.handleNameChange);
@@ -17,8 +14,10 @@ export default class extends Controller {
}
handleNameChange = (e) => {
this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase();
}
this.avatarTarget.textContent = (
e.currentTarget.value?.[0] || "?"
).toUpperCase();
};
handleColorChange(e) {
const color = e.currentTarget.value;
@@ -26,4 +25,4 @@ export default class extends Controller {
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.color = color;
}
}
}

View File

@@ -1,59 +1,65 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [ "input", "decoration" ]
static values = { selection: String }
static targets = ["input", "decoration"];
static values = { selection: String };
connect() {
this.#renderOptions()
this.#renderOptions();
}
select({ target }) {
this.selectionValue = target.dataset.value
this.selectionValue = target.dataset.value;
}
selectionValueChanged() {
this.#options.forEach(option => {
this.#options.forEach((option) => {
if (option.dataset.value === this.selectionValue) {
this.#check(option)
this.inputTarget.value = this.selectionValue
this.#check(option);
this.inputTarget.value = this.selectionValue;
} else {
this.#uncheck(option)
this.#uncheck(option);
}
})
});
}
#renderOptions() {
this.#options.forEach(option => option.style.backgroundColor = option.dataset.value)
this.#options.forEach((option) => {
option.style.backgroundColor = option.dataset.value;
});
}
#check(option) {
option.setAttribute("aria-checked", "true")
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}`
this.decorationTarget.style.backgroundColor = option.dataset.value
option.setAttribute("aria-checked", "true");
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(
option.dataset.value,
0.2,
)}`;
this.decorationTarget.style.backgroundColor = option.dataset.value;
}
#uncheck(option) {
option.setAttribute("aria-checked", "false")
option.style.boxShadow = "none"
option.setAttribute("aria-checked", "false");
option.style.boxShadow = "none";
}
get #options() {
return Array.from(this.element.querySelectorAll("[role='radio']"))
return Array.from(this.element.querySelectorAll("[role='radio']"));
}
}
function hexToRGBA(hex, alpha = 1) {
hex = hex.replace(/^#/, '');
let hexCode = hex.replace(/^#/, "");
let calculatedAlpha = alpha;
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16) / 255;
hex = hex.slice(0, 6);
if (hexCode.length === 8) {
calculatedAlpha = Number.parseInt(hexCode.slice(6, 8), 16) / 255;
hexCode = hexCode.slice(0, 6);
}
let r = parseInt(hex.slice(0, 2), 16);
let g = parseInt(hex.slice(2, 4), 16);
let b = parseInt(hex.slice(4, 6), 16);
const r = Number.parseInt(hexCode.slice(0, 2), 16);
const g = Number.parseInt(hexCode.slice(2, 4), 16);
const b = Number.parseInt(hexCode.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
return `rgba(${r}, ${g}, ${b}, ${calculatedAlpha})`;
}

View File

@@ -1,30 +1,30 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["replacementField", "submitButton"]
static classes = [ "dangerousAction", "safeAction" ]
static targets = ["replacementField", "submitButton"];
static classes = ["dangerousAction", "safeAction"];
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String
}
submitTextWhenNotReplacing: String,
};
updateSubmitButton() {
if (this.replacementFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
this.#markSafe()
this.submitButtonTarget.value = this.submitTextWhenReplacingValue;
this.#markSafe();
} else {
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue
this.#markDangerous()
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue;
this.#markDangerous();
}
}
#markSafe() {
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses)
this.submitButtonTarget.classList.add(...this.safeActionClasses)
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses);
this.submitButtonTarget.classList.add(...this.safeActionClasses);
}
#markDangerous() {
this.submitButtonTarget.classList.remove(...this.safeActionClasses)
this.submitButtonTarget.classList.add(...this.dangerousActionClasses)
this.submitButtonTarget.classList.remove(...this.safeActionClasses);
this.submitButtonTarget.classList.add(...this.dangerousActionClasses);
}
}

View File

@@ -1,9 +1,8 @@
import { Controller } from '@hotwired/stimulus'
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="element-removal"
export default class extends Controller {
remove() {
this.element.remove()
this.element.remove();
}
}

View File

@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus";
import { install, uninstall } from "@github/hotkey";
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="hotkey"
export default class extends Controller {

View File

@@ -1,10 +1,10 @@
// Import and register all your controllers from the importmap under controllers/*
import { application } from "controllers/application"
import { application } from "controllers/application";
// Eager load all controllers defined in the import map under controllers/**/*_controller
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
eagerLoadControllersFrom("controllers", application);
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"

View File

@@ -1,39 +1,40 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="list-keyboard-navigation"
export default class extends Controller {
focusPrevious() {
this.focusLinkTargetInDirection(-1)
this.focusLinkTargetInDirection(-1);
}
focusNext() {
this.focusLinkTargetInDirection(1)
this.focusLinkTargetInDirection(1);
}
focusLinkTargetInDirection(direction) {
const element = this.getLinkTargetInDirection(direction)
element?.focus()
const element = this.getLinkTargetInDirection(direction);
element?.focus();
}
getLinkTargetInDirection(direction) {
const indexOfLastFocus = this.indexOfLastFocus()
let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length
if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1
return this.focusableLinks[nextIndex]
const indexOfLastFocus = this.indexOfLastFocus();
let nextIndex = (indexOfLastFocus + direction) % this.focusableLinks.length;
if (nextIndex < 0) nextIndex = this.focusableLinks.length - 1;
return this.focusableLinks[nextIndex];
}
indexOfLastFocus(targets = this.focusableLinks) {
const indexOfActiveElement = targets.indexOf(document.activeElement)
const indexOfActiveElement = targets.indexOf(document.activeElement);
if (indexOfActiveElement !== -1) {
return indexOfActiveElement
} else {
return targets.findIndex(target => target.getAttribute("tabindex") === "0")
return indexOfActiveElement;
}
return targets.findIndex(
(target) => target.getAttribute("tabindex") === "0",
);
}
get focusableLinks() {
return Array.from(this.element.querySelectorAll("a[href]"))
return Array.from(this.element.querySelectorAll("a[href]"));
}
}

View File

@@ -1,5 +1,11 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
@@ -70,8 +76,10 @@ export default class extends Controller {
}
focusFirstElement() {
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement = this.contentTarget.querySelectorAll(focusableElements)[0];
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
@@ -79,7 +87,11 @@ export default class extends Controller {
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(this.buttonTarget, this.contentTarget, this.boundUpdate);
this._cleanup = autoUpdate(
this.buttonTarget,
this.contentTarget,
this.boundUpdate,
);
}
}
@@ -93,14 +105,10 @@ export default class extends Controller {
update() {
computePosition(this.buttonTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [
offset(this.offsetValue),
flip(),
shift({ padding: 5 })
],
middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
position: 'fixed',
position: "fixed",
left: `${x}px`,
top: `${y}px`,
});

View File

@@ -1,10 +1,10 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
connect() {
if (this.element.open) return
else this.element.showModal()
if (this.element.open) return;
this.element.showModal();
}
// Hide the dialog when the user clicks outside of it

View File

@@ -12,14 +12,16 @@ export default class extends Controller {
}
updateAmount(currency) {
(new CurrenciesService).get(currency).then((currency) => {
new CurrenciesService().get(currency).then((currency) => {
this.amountTarget.step = currency.step;
if (isFinite(this.amountTarget.value)) {
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
if (Number.isFinite(this.amountTarget.value)) {
this.amountTarget.value = Number.parseFloat(
this.amountTarget.value,
).toFixed(currency.default_precision);
}
this.symbolTarget.innerText = currency.symbol;
});
}
}
}

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="onboarding"
export default class extends Controller {
setLocale(event) {
this.refreshWithParam("locale", event.target.value);
}
setDateFormat(event) {
this.refreshWithParam("date_format", event.target.value);
}
setCurrency(event) {
this.refreshWithParam("currency", event.target.value);
}
refreshWithParam(key, value) {
const url = new URL(window.location);
url.searchParams.set(key, value);
// Preserve existing params by getting the current search string
// and appending our new param to it
const currentParams = new URLSearchParams(window.location.search);
currentParams.set(key, value);
// Refresh the page with all params
window.location.search = currentParams.toString();
}
}

View File

@@ -104,27 +104,25 @@ export default class extends Controller {
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo;
} else {
return (this.#d3SvgMemo = this.#createMainSvg());
if (!this.#d3SvgMemo) {
this.#d3SvgMemo = this.#createMainSvg();
}
return this.#d3SvgMemo;
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo;
} else {
return (this.#d3GroupMemo = this.#createMainGroup());
if (!this.#d3GroupMemo) {
this.#d3GroupMemo = this.#createMainGroup();
}
return this.#d3GroupMemo;
}
get #d3Content() {
if (this.#d3ContentMemo) {
return this.#d3ContentMemo;
} else {
return (this.#d3ContentMemo = this.#createContent());
if (!this.#d3ContentMemo) {
this.#d3ContentMemo = this.#createContent();
}
return this.#d3ContentMemo;
}
#createMainSvg() {

View File

@@ -1,27 +1,35 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
static targets = [
"attachedImage",
"previewImage",
"placeholderImage",
"deleteProfileImage",
"input",
"clearBtn",
];
preview(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
this.templateTarget.classList.add("hidden");
this.clearBtnTarget.classList.remove("hidden");
};
reader.readAsDataURL(file);
}
clearFileInput() {
this.inputTarget.value = null;
this.clearBtnTarget.classList.add("hidden");
this.placeholderImageTarget.classList.remove("hidden");
this.attachedImageTarget.classList.add("hidden");
this.previewImageTarget.classList.add("hidden");
this.deleteProfileImageTarget.value = "1";
}
clear() {
this.deleteFieldTarget.value = true;
this.fileFieldTarget.value = null;
this.templateTarget.classList.remove("hidden");
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
this.clearBtnTarget.classList.add("hidden");
this.element.submit();
showFileInputPreview(event) {
const file = event.target.files[0];
if (!file) return;
this.placeholderImageTarget.classList.add("hidden");
this.attachedImageTarget.classList.add("hidden");
this.previewImageTarget.classList.remove("hidden");
this.clearBtnTarget.classList.remove("hidden");
this.deleteProfileImageTarget.value = "0";
this.previewImageTarget.querySelector("img").src =
URL.createObjectURL(file);
}
}

View File

@@ -28,7 +28,7 @@ export default class extends Controller {
updateClasses = (selectedId) => {
this.btnTargets.forEach((btn) =>
btn.classList.remove(...this.activeClasses)
btn.classList.remove(...this.activeClasses),
);
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));

View File

@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import tailwindColors from "@maybe/tailwindcolors"
import * as d3 from "d3"
import { Controller } from "@hotwired/stimulus";
import tailwindColors from "@maybe/tailwindcolors";
import * as d3 from "d3";
export default class extends Controller {
static values = {
@@ -8,247 +8,259 @@ export default class extends Controller {
strokeWidth: { type: Number, default: 2 },
useLabels: { type: Boolean, default: true },
useTooltip: { type: Boolean, default: true },
usePercentSign: Boolean
}
usePercentSign: Boolean,
};
#d3SvgMemo = null;
#d3GroupMemo = null;
#d3Tooltip = null;
#d3InitialContainerWidth = 0;
#d3InitialContainerHeight = 0;
#normalDataPoints = [];
_d3SvgMemo = null;
_d3GroupMemo = null;
_d3Tooltip = null;
_d3InitialContainerWidth = 0;
_d3InitialContainerHeight = 0;
_normalDataPoints = [];
connect() {
this.#install()
document.addEventListener("turbo:load", this.#reinstall)
this._install();
document.addEventListener("turbo:load", this._reinstall);
}
disconnect() {
this.#teardown()
document.removeEventListener("turbo:load", this.#reinstall)
this._teardown();
document.removeEventListener("turbo:load", this._reinstall);
}
_reinstall = () => {
this._teardown();
this._install();
};
#reinstall = () => {
this.#teardown()
this.#install()
_teardown() {
this._d3SvgMemo = null;
this._d3GroupMemo = null;
this._d3Tooltip = null;
this._normalDataPoints = [];
this._d3Container.selectAll("*").remove();
}
#teardown() {
this.#d3SvgMemo = null
this.#d3GroupMemo = null
this.#d3Tooltip = null
this.#normalDataPoints = []
this.#d3Container.selectAll("*").remove()
_install() {
this._normalizeDataPoints();
this._rememberInitialContainerSize();
this._draw();
}
#install() {
this.#normalizeDataPoints()
this.#rememberInitialContainerSize()
this.#draw()
}
#normalizeDataPoints() {
this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({
_normalizeDataPoints() {
this._normalDataPoints = (this.dataValue.values || []).map((d) => ({
...d,
date: new Date(d.date),
date: this._parseDate(d.date),
value: d.value.amount ? +d.value.amount : +d.value,
currency: d.value.currency
}))
currency: d.value.currency,
}));
}
#rememberInitialContainerSize() {
this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
_parseDate(dateString) {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(year, month - 1, day);
}
_rememberInitialContainerSize() {
this._d3InitialContainerWidth = this._d3Container.node().clientWidth;
this._d3InitialContainerHeight = this._d3Container.node().clientHeight;
}
#draw() {
if (this.#normalDataPoints.length < 2) {
this.#drawEmpty()
_draw() {
if (this._normalDataPoints.length < 2) {
this._drawEmpty();
} else {
this.#drawChart()
this._drawChart();
}
}
_drawEmpty() {
this._d3Svg.selectAll(".tick").remove();
this._d3Svg.selectAll(".domain").remove();
#drawEmpty() {
this.#d3Svg.selectAll(".tick").remove()
this.#d3Svg.selectAll(".domain").remove()
this.#drawDashedLineEmptyState()
this.#drawCenteredCircleEmptyState()
this._drawDashedLineEmptyState();
this._drawCenteredCircleEmptyState();
}
#drawDashedLineEmptyState() {
this.#d3Svg
_drawDashedLineEmptyState() {
this._d3Svg
.append("line")
.attr("x1", this.#d3InitialContainerWidth / 2)
.attr("x1", this._d3InitialContainerWidth / 2)
.attr("y1", 0)
.attr("x2", this.#d3InitialContainerWidth / 2)
.attr("y2", this.#d3InitialContainerHeight)
.attr("x2", this._d3InitialContainerWidth / 2)
.attr("y2", this._d3InitialContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
.attr("stroke-dasharray", "4, 4");
}
#drawCenteredCircleEmptyState() {
this.#d3Svg
_drawCenteredCircleEmptyState() {
this._d3Svg
.append("circle")
.attr("cx", this.#d3InitialContainerWidth / 2)
.attr("cy", this.#d3InitialContainerHeight / 2)
.attr("cx", this._d3InitialContainerWidth / 2)
.attr("cy", this._d3InitialContainerHeight / 2)
.attr("r", 4)
.style("fill", tailwindColors.gray[400])
.style("fill", tailwindColors.gray[400]);
}
#drawChart() {
this.#drawTrendline()
_drawChart() {
this._drawTrendline();
if (this.useLabelsValue) {
this.#drawXAxisLabels()
this.#drawGradientBelowTrendline()
this._drawXAxisLabels();
this._drawGradientBelowTrendline();
}
if (this.useTooltipValue) {
this.#drawTooltip()
this.#trackMouseForShowingTooltip()
this._drawTooltip();
this._trackMouseForShowingTooltip();
}
}
#drawTrendline() {
this.#installTrendlineSplit()
_drawTrendline() {
this._installTrendlineSplit();
this.#d3Group
this._d3Group
.append("path")
.datum(this.#normalDataPoints)
.datum(this._normalDataPoints)
.attr("fill", "none")
.attr("stroke", `url(#${this.element.id}-split-gradient)`)
.attr("d", this.#d3Line)
.attr("d", this._d3Line)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", this.strokeWidthValue)
.attr("stroke-width", this.strokeWidthValue);
}
#installTrendlineSplit() {
const gradient = this.#d3Svg
_installTrendlineSplit() {
const gradient = this._d3Svg
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-split-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", this.#d3XScale.range()[0])
.attr("x2", this.#d3XScale.range()[1])
.attr("x1", this._d3XScale.range()[0])
.attr("x2", this._d3XScale.range()[1]);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "start-color")
.attr("offset", "0%")
.attr("stop-color", this.#trendColor)
.attr("stop-color", this._trendColor);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "middle-color")
.attr("offset", "100%")
.attr("stop-color", this.#trendColor)
.attr("stop-color", this._trendColor);
gradient.append("stop")
gradient
.append("stop")
.attr("class", "end-color")
.attr("offset", "100%")
.attr("stop-color", tailwindColors.gray[300])
.attr("stop-color", tailwindColors.gray[300]);
}
#setTrendlineSplitAt(percent) {
this.#d3Svg
_setTrendlineSplitAt(percent) {
this._d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".middle-color")
.attr("offset", `${percent * 100}%`)
.attr("offset", `${percent * 100}%`);
this.#d3Svg
this._d3Svg
.select(`#${this.element.id}-split-gradient`)
.select(".end-color")
.attr("offset", `${percent * 100}%`)
.attr("offset", `${percent * 100}%`);
this.#d3Svg
this._d3Svg
.select(`#${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth * percent)
.attr("width", this._d3ContainerWidth * percent);
}
#drawXAxisLabels() {
_drawXAxisLabels() {
// Add ticks
this.#d3Group
this._d3Group
.append("g")
.attr("transform", `translate(0,${this.#d3ContainerHeight})`)
.attr("transform", `translate(0,${this._d3ContainerHeight})`)
.call(
d3
.axisBottom(this.#d3XScale)
.tickValues([this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date])
.axisBottom(this._d3XScale)
.tickValues([
this._normalDataPoints[0].date,
this._normalDataPoints[this._normalDataPoints.length - 1].date,
])
.tickSize(0)
.tickFormat(d3.timeFormat("%d %b %Y"))
.tickFormat(d3.timeFormat("%d %b %Y")),
)
.select(".domain")
.remove()
.remove();
// Style ticks
this.#d3Group.selectAll(".tick text")
this._d3Group
.selectAll(".tick text")
.style("fill", tailwindColors.gray[500])
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
.attr("dx", (_d, i) => {
// We know we only have 2 values
return i === 0 ? "5em" : "-5em"
return i === 0 ? "5em" : "-5em";
})
.attr("dy", "0em")
.attr("dy", "0em");
}
#drawGradientBelowTrendline() {
_drawGradientBelowTrendline() {
// Define gradient
const gradient = this.#d3Group
const gradient = this._d3Group
.append("defs")
.append("linearGradient")
.attr("id", `${this.element.id}-trendline-gradient`)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value)))
.attr("y2", this.#d3ContainerHeight)
.attr(
"y1",
this._d3YScale(d3.max(this._normalDataPoints, (d) => d.value)),
)
.attr("y2", this._d3ContainerHeight);
gradient
.append("stop")
.attr("offset", 0)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0.06)
.attr("stop-color", this._trendColor)
.attr("stop-opacity", 0.06);
gradient
.append("stop")
.attr("offset", 0.5)
.attr("stop-color", this.#trendColor)
.attr("stop-opacity", 0)
.attr("stop-color", this._trendColor)
.attr("stop-opacity", 0);
// Clip path makes gradient start at the trendline
this.#d3Group
this._d3Group
.append("clipPath")
.attr("id", `${this.element.id}-clip-below-trendline`)
.append("path")
.datum(this.#normalDataPoints)
.attr("d", d3.area()
.x(d => this.#d3XScale(d.date))
.y0(this.#d3ContainerHeight)
.y1(d => this.#d3YScale(d.value))
)
.datum(this._normalDataPoints)
.attr(
"d",
d3
.area()
.x((d) => this._d3XScale(d.date))
.y0(this._d3ContainerHeight)
.y1((d) => this._d3YScale(d.value)),
);
// Apply the gradient + clip path
this.#d3Group
this._d3Group
.append("rect")
.attr("id", `${this.element.id}-trendline-gradient-rect`)
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`)
.style("fill", `url(#${this.element.id}-trendline-gradient)`)
.style("fill", `url(#${this.element.id}-trendline-gradient)`);
}
#drawTooltip() {
this.#d3Tooltip = d3
_drawTooltip() {
this._d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
.style("position", "absolute")
@@ -258,93 +270,102 @@ export default class extends Controller {
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
.style("border-radius", "10px")
.style("pointer-events", "none")
.style("opacity", 0) // Starts as hidden
.style("opacity", 0); // Starts as hidden
}
#trackMouseForShowingTooltip() {
const bisectDate = d3.bisector(d => d.date).left
_trackMouseForShowingTooltip() {
const bisectDate = d3.bisector((d) => d.date).left;
this.#d3Group
this._d3Group
.append("rect")
.attr("width", this.#d3ContainerWidth)
.attr("height", this.#d3ContainerHeight)
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("mousemove", (event) => {
const estimatedTooltipWidth = 250
const pageWidth = document.body.clientWidth
const tooltipX = event.pageX + 10
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth
const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX
const estimatedTooltipWidth = 250;
const pageWidth = document.body.clientWidth;
const tooltipX = event.pageX + 10;
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth;
const adjustedX =
overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
const [xPos] = d3.pointer(event)
const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1)
const d0 = this.#normalDataPoints[x0 - 1]
const d1 = this.#normalDataPoints[x0]
const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0
const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth
const [xPos] = d3.pointer(event);
const x0 = bisectDate(
this._normalDataPoints,
this._d3XScale.invert(xPos),
1,
);
const d0 = this._normalDataPoints[x0 - 1];
const d1 = this._normalDataPoints[x0];
const d =
xPos - this._d3XScale(d0.date) > this._d3XScale(d1.date) - xPos
? d1
: d0;
const xPercent = this._d3XScale(d.date) / this._d3ContainerWidth;
this.#setTrendlineSplitAt(xPercent)
this._setTrendlineSplitAt(xPercent);
// Reset
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Group.selectAll(".guideline").remove()
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Group.selectAll(".guideline").remove();
// Guideline
this.#d3Group
this._d3Group
.append("line")
.attr("class", "guideline")
.attr("x1", this.#d3XScale(d.date))
.attr("x1", this._d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this.#d3XScale(d.date))
.attr("y2", this.#d3ContainerHeight)
.attr("x2", this._d3XScale(d.date))
.attr("y2", this._d3ContainerHeight)
.attr("stroke", tailwindColors.gray[300])
.attr("stroke-dasharray", "4, 4")
.attr("stroke-dasharray", "4, 4");
// Big circle
this.#d3Group
this._d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("cx", this._d3XScale(d.date))
.attr("cy", this._d3YScale(d.value))
.attr("r", 8)
.attr("fill", this.#trendColor)
.attr("fill", this._trendColor)
.attr("fill-opacity", "0.1")
.attr("pointer-events", "none")
.attr("pointer-events", "none");
// Small circle
this.#d3Group
this._d3Group
.append("circle")
.attr("class", "data-point-circle")
.attr("cx", this.#d3XScale(d.date))
.attr("cy", this.#d3YScale(d.value))
.attr("cx", this._d3XScale(d.date))
.attr("cy", this._d3YScale(d.value))
.attr("r", 3)
.attr("fill", this.#trendColor)
.attr("pointer-events", "none")
.attr("fill", this._trendColor)
.attr("pointer-events", "none");
// Render tooltip
this.#d3Tooltip
.html(this.#tooltipTemplate(d))
this._d3Tooltip
.html(this._tooltipTemplate(d))
.style("opacity", 1)
.style("z-index", 999)
.style("left", adjustedX + "px")
.style("top", event.pageY - 10 + "px")
.style("left", `${adjustedX}px`)
.style("top", `${event.pageY - 10}px`);
})
.on("mouseout", (event) => {
const hoveringOnGuideline = event.toElement?.classList.contains("guideline")
const hoveringOnGuideline =
event.toElement?.classList.contains("guideline");
if (!hoveringOnGuideline) {
this.#d3Group.selectAll(".guideline").remove()
this.#d3Group.selectAll(".data-point-circle").remove()
this.#d3Tooltip.style("opacity", 0)
this._d3Group.selectAll(".guideline").remove();
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Tooltip.style("opacity", 0);
this.#setTrendlineSplitAt(1)
this._setTrendlineSplitAt(1);
}
})
});
}
#tooltipTemplate(datum) {
return (`
_tooltipTemplate(datum) {
return `
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
${d3.timeFormat("%b %d, %Y")(datum.date)}
</div>
@@ -356,164 +377,172 @@ export default class extends Controller {
cx="5"
cy="5"
r="4"
stroke="${this.#tooltipTrendColor(datum)}"
stroke="${this._tooltipTrendColor(datum)}"
fill="transparent"
stroke-width="1"></circle>
</svg>
${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
${this._tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
</div>
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
${
this.usePercentSignValue ||
datum.trend.value === 0 ||
datum.trend.value.amount === 0
? `
<span style="width: 80px;"></span>
` : `
<span style="color: ${this.#tooltipTrendColor(datum)};">
${this.#tooltipChange(datum)} (${datum.trend.percent}%)
`
: `
<span style="color: ${this._tooltipTrendColor(datum)};">
${this._tooltipChange(datum)} (${datum.trend.percent}%)
</span>
`}
`
}
</div>
`)
`;
}
#tooltipTrendColor(datum) {
_tooltipTrendColor(datum) {
return {
up: tailwindColors.success,
down: tailwindColors.error,
flat: tailwindColors.gray[500],
}[datum.trend.direction]
}[datum.trend.direction];
}
#tooltipValue(datum) {
_tooltipValue(datum) {
if (datum.currency) {
return this.#currencyValue(datum)
} else {
return datum.value
return this._currencyValue(datum);
}
return datum.value;
}
#tooltipChange(datum) {
_tooltipChange(datum) {
if (datum.currency) {
return this.#currencyChange(datum)
} else {
return this.#decimalChange(datum)
return this._currencyChange(datum);
}
return this._decimalChange(datum);
}
#currencyValue(datum) {
_currencyValue(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
}).format(datum.value)
}).format(datum.value);
}
#currencyChange(datum) {
_currencyChange(datum) {
return Intl.NumberFormat(undefined, {
style: "currency",
currency: datum.currency,
signDisplay: "always",
}).format(datum.trend.value.amount)
}).format(datum.trend.value.amount);
}
#decimalChange(datum) {
_decimalChange(datum) {
return Intl.NumberFormat(undefined, {
style: "decimal",
signDisplay: "always",
}).format(datum.trend.value)
}).format(datum.trend.value);
}
#createMainSvg() {
return this.#d3Container
_createMainSvg() {
return this._d3Container
.append("svg")
.attr("width", this.#d3InitialContainerWidth)
.attr("height", this.#d3InitialContainerHeight)
.attr("viewBox", [0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight])
.attr("width", this._d3InitialContainerWidth)
.attr("height", this._d3InitialContainerHeight)
.attr("viewBox", [
0,
0,
this._d3InitialContainerWidth,
this._d3InitialContainerHeight,
]);
}
#createMainGroup() {
return this.#d3Svg
_createMainGroup() {
return this._d3Svg
.append("g")
.attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
.attr("transform", `translate(${this._margin.left},${this._margin.top})`);
}
get #d3Svg() {
if (this.#d3SvgMemo) {
return this.#d3SvgMemo
} else {
return this.#d3SvgMemo = this.#createMainSvg()
get _d3Svg() {
if (!this._d3SvgMemo) {
this._d3SvgMemo = this._createMainSvg();
}
return this._d3SvgMemo;
}
get #d3Group() {
if (this.#d3GroupMemo) {
return this.#d3GroupMemo
} else {
return this.#d3GroupMemo = this.#createMainGroup()
get _d3Group() {
if (!this._d3GroupMemo) {
this._d3GroupMemo = this._createMainGroup();
}
return this._d3GroupMemo;
}
get #margin() {
get _margin() {
if (this.useLabelsValue) {
return { top: 20, right: 0, bottom: 30, left: 0 }
} else {
return { top: 0, right: 0, bottom: 0, left: 0 }
return { top: 20, right: 0, bottom: 30, left: 0 };
}
return { top: 0, right: 0, bottom: 0, left: 0 };
}
get #d3ContainerWidth() {
return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
get _d3ContainerWidth() {
return (
this._d3InitialContainerWidth - this._margin.left - this._margin.right
);
}
get #d3ContainerHeight() {
return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom
get _d3ContainerHeight() {
return (
this._d3InitialContainerHeight - this._margin.top - this._margin.bottom
);
}
get #d3Container() {
return d3.select(this.element)
get _d3Container() {
return d3.select(this.element);
}
get #trendColor() {
if (this.#trendDirection === "flat") {
return tailwindColors.gray[500]
} else if (this.#trendDirection === this.#favorableDirection) {
return tailwindColors.green[500]
} else {
return tailwindColors.error
get _trendColor() {
if (this._trendDirection === "flat") {
return tailwindColors.gray[500];
}
if (this._trendDirection === this._favorableDirection) {
return tailwindColors.green[500];
}
return tailwindColors.error;
}
get #trendDirection() {
return this.dataValue.trend.direction
get _trendDirection() {
return this.dataValue.trend.direction;
}
get #favorableDirection() {
return this.dataValue.trend.favorable_direction
get _favorableDirection() {
return this.dataValue.trend.favorable_direction;
}
get #d3Line() {
get _d3Line() {
return d3
.line()
.x(d => this.#d3XScale(d.date))
.y(d => this.#d3YScale(d.value))
.x((d) => this._d3XScale(d.date))
.y((d) => this._d3YScale(d.value));
}
get #d3XScale() {
get _d3XScale() {
return d3
.scaleTime()
.rangeRound([0, this.#d3ContainerWidth])
.domain(d3.extent(this.#normalDataPoints, d => d.date))
.rangeRound([0, this._d3ContainerWidth])
.domain(d3.extent(this._normalDataPoints, (d) => d.date));
}
get #d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.15 : 0.05
const dataMin = d3.min(this.#normalDataPoints, d => d.value)
const dataMax = d3.max(this.#normalDataPoints, d => d.value)
const padding = (dataMax - dataMin) * reductionPercent
get _d3YScale() {
const reductionPercent = this.useLabelsValue ? 0.3 : 0.05;
const dataMin = d3.min(this._normalDataPoints, (d) => d.value);
const dataMax = d3.max(this._normalDataPoints, (d) => d.value);
const padding = (dataMax - dataMin) * reductionPercent;
return d3
.scaleLinear()
.rangeRound([this.#d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding])
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
}
}

View File

@@ -1,11 +1,11 @@
import { Controller } from '@hotwired/stimulus'
import {
autoUpdate,
computePosition,
flip,
shift,
offset,
autoUpdate
} from '@floating-ui/dom';
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["tooltip"];
@@ -39,20 +39,20 @@ export default class extends Controller {
}
show = () => {
this.tooltipTarget.style.display = 'block';
this.tooltipTarget.style.display = "block";
this.update(); // Ensure immediate update when shown
}
};
hide = () => {
this.tooltipTarget.style.display = 'none';
}
this.tooltipTarget.style.display = "none";
};
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.element,
this.tooltipTarget,
this.boundUpdate
this.boundUpdate,
);
}
}
@@ -69,9 +69,13 @@ export default class extends Controller {
computePosition(this.element, this.tooltipTarget, {
placement: this.placementValue,
middleware: [
offset({ mainAxis: this.offsetValue, crossAxis: this.crossAxisValue, alignmentAxis: this.alignmentAxisValue }),
offset({
mainAxis: this.offsetValue,
crossAxis: this.crossAxisValue,
alignmentAxis: this.alignmentAxisValue,
}),
flip(),
shift({ padding: 5 })
shift({ padding: 5 }),
],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.tooltipTarget.style, {
@@ -80,4 +84,4 @@ export default class extends Controller {
});
});
}
}
}

View File

@@ -1,55 +1,62 @@
import {Controller} from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest"
}
INTEREST: "interest",
};
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
[TRADE_TYPES.INTEREST]: {amount: true}
}
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
[TRADE_TYPES.INTEREST]: { amount: true },
};
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
static targets = [
"typeInput",
"tickerInput",
"amountInput",
"transferAccountInput",
"qtyInput",
"priceInput",
];
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this)
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
this.handleTypeChange = this.handleTypeChange.bind(this);
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
}
handleTypeChange(event) {
this.updateFields(event.target.value)
this.updateFields(event.target.value);
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {}
const visibleFields = FIELD_VISIBILITY[type] || {};
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false
const isVisible = visibleFields[field] || false;
// Update visibility
target.hidden = !isVisible
target.hidden = !isVisible;
// Update required status based on visibility
if (isVisible) {
target.setAttribute('required', '')
target.setAttribute("required", "");
} else {
target.removeAttribute('required')
target.removeAttribute("required");
}
})
});
}
get fieldTargets() {
@@ -58,7 +65,7 @@ export default class extends Controller {
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget
}
price: this.priceInputTarget,
};
}
}
}

View File

@@ -0,0 +1,18 @@
class FetchSecurityInfoJob < ApplicationJob
queue_as :default
def perform(security_id)
return unless Security.security_info_provider.present?
security = Security.find(security_id)
security_info_response = Security.security_info_provider.fetch_security_info(
ticker: security.ticker,
mic_code: security.exchange_mic
)
security.update(
name: security_info_response.info.dig("name")
)
end
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV["EMAIL_SENDER"] if ENV["EMAIL_SENDER"].present?
default from: email_address_with_name(ENV.fetch("EMAIL_SENDER", "sender@maybe.local"), "Maybe Finance")
layout "mailer"
end

View File

@@ -0,0 +1,11 @@
class InvitationMailer < ApplicationMailer
def invite_email(invitation)
@invitation = invitation
@accept_url = accept_invitation_url(@invitation.token)
mail(
to: @invitation.email,
subject: t(".subject", inviter: @invitation.inviter.display_name)
)
end
end

Some files were not shown because too many files have changed in this diff Show More