Author SHA1 Message Date
Sirttas 617650929a Merge pull request 'New runner' (#49) from new-runner into main
Reviewed-on: #49
2026-08-08 23:13:12 +02:00
Sirttas b70585f01d fix: Fix remaining bugs in new runner 2026-08-08 23:13:01 +02:00
Sirttas f7a193d1c5 feat: New runner UI 2026-08-08 21:28:23 +02:00
Sirttas 0040a5c33b feat: regenerate open api 2026-08-08 20:47:13 +02:00
Sirttas fb9567eee5 feat(#48): Add location picker for acquisition, type info and appraisal pages 2026-08-06 08:02:23 +02:00
Sirttas 3419f3d4c9 feat(#47): Cash Flow / Margin toggle on the ledger Statistics profit chart 2026-07-30 22:21:28 +02:00
Sirttas d1237bf1d2 feat(#41): Resolve corporation names in CorporationLabel 2026-07-26 20:57:05 +02:00
Sirttas d4b48b05ee feat(#40): Show an appraised estimate of held items on the ledger 2026-07-26 17:40:30 +02:00
Sirttas 38e1063ac5 feat(#39): Add a Statistics tab to the ledger view 2026-07-26 12:07:49 +02:00
Sirttas 04a9f197e8 fix(#2): Tooltip popup is clipped/hidden behind table content 2026-07-20 10:04:10 +02:00
Sirttas 27b7cecfdc feat: better processing toast message 2026-07-19 11:55:57 +02:00
Sirttas e814aabcc8 feat(#13): Toast the user when a processing run completes 2026-07-16 14:55:05 +02:00
Sirttas 4c8a3d5dfc feat(#35): Wire toasts into existing user actions 2026-07-16 14:43:28 +02:00
Sirttas 187c6b0f99 feat(#33): Add a global toast/notification component 2026-07-16 14:20:38 +02:00
Sirttas 24c7ec4917 refactor(#27): Simplify appraisal 2026-07-15 18:39:40 +02:00
Sirttas 0d61826c70 fix(#20): Open-order (listed/unlisted) state never refreshes in the acquisitions view 2026-07-14 18:38:18 +02:00
Sirttas 364f711a97 Merge pull request 'New eveal' (#32) from new-eveal into main
Reviewed-on: #32
2026-07-14 17:15:11 +02:00
Sirttas 777cb74574 feat(#31): RFollow mammon location rename in the generated client 2026-07-14 17:14:07 +02:00
Sirttas 52addba909 feat(#19): Sort by hubs 2026-07-09 19:16:06 +02:00
Sirttas 81250953eb feat(#17): Add an appraisal compare-by-location page 2026-07-09 19:06:53 +02:00
Sirttas c4803ef650 feat(#16): Appraisal page 2026-07-09 17:43:28 +02:00
Sirttas 0f97a49e26 feat(#15): Regen api 2026-07-09 17:30:27 +02:00
Sirttas 5a974bac8f refactor use axios for fetch 2026-07-07 21:56:44 +02:00
Sirttas 98380a06b9 feat: reload on ledger acquisitions 2026-07-07 18:57:42 +02:00
Sirttas 10f01a9346 fix: no longer call fetch activities 2026-07-07 18:48:40 +02:00
Sirttas 7caa45a1fb feat: ledger source label 2026-07-07 18:40:28 +02:00
Sophie-Gaëlle CALLOCH a22845add8 refactor: no longer triggers an activity processing automatically on acquisition added 2026-07-07 17:05:27 +02:00
Sophie-Gaëlle CALLOCH e8e7c4f642 refactor: user can no longer fetch activities manually 2026-07-07 17:02:14 +02:00
Sophie-Gaëlle CALLOCH da7a729321 refactor: clean gemory dead code 2026-07-07 16:00:40 +02:00
Sophie-Gaëlle CALLOCH 55c2628c75 fix: fix a few typos 2026-07-07 15:51:53 +02:00
Sophie-Gaëlle CALLOCH 75d1d5f7fa refactor: remove refresh from stores 2026-07-07 15:41:01 +02:00
Sophie-Gaëlle CALLOCH 2622d9f01e feat: refresh current data on activities processed 2026-07-07 15:12:59 +02:00
Sophie-Gaëlle CALLOCH a7407b8e06 feat: consume activity processed SSE 2026-07-07 14:42:42 +02:00
Sophie-Gaëlle CALLOCH dadcf97bec fix: manual activities source on main ledgers only 2026-07-07 11:54:24 +02:00
Sirttas d2a3a22ddf feat(#14): Wire manual buy/sell to the mammon acquire/consume endpoints 2026-07-06 22:02:27 +02:00
Sirttas 2a145bbbe7 feat(#9): Add button to filter acquisitions to unlisted stock 2026-07-05 16:39:34 +02:00
Sirttas fbe0861c29 fix: ledger label getting wrapped 2026-07-05 16:28:42 +02:00
Sirttas c1f62f66e1 feat: reorganize ledger views 2026-07-05 13:10:18 +02:00
Sirttas 698156cf00 feat(#10): Add per-ledger acquisitions view + ledger column 2026-07-05 00:55:13 +02:00
Sirttas 8d73fe3ed1 feat(#8): Handle polymorphic activity source 2026-07-03 14:00:42 +02:00
Sirttas 052a0a5d03 feat(#7): Rework rule-book UI for one-rule-book-per-user model 2026-07-02 19:28:37 +02:00
Sirttas e66fe9b40e combined ledger tranasctions 2026-07-02 09:20:45 +02:00
Sirttas 8a71dad977 fix a couple of things 2026-06-28 22:14:29 +02:00
Sirttas 45ed773119 fix a couple of things 2026-06-28 22:10:34 +02:00
Sirttas a0cc120fec Merge pull request 'feat(#5): use auth' (#6) from users into new-eveal
Reviewed-on: #6
2026-06-28 14:19:20 +02:00
Sirttas 61fbc8e16b feat(#5): use auth 2026-06-28 14:18:09 +02:00
Sirttas c1993d27b2 trends filter 2026-06-22 17:31:49 +02:00
Sirttas e7249df23f market scan trends 2026-06-22 12:42:22 +02:00
Sirttas 925d9eef73 rework market trends 2026-06-22 12:30:28 +02:00
Sirttas 35b2c9539a default transaction tax is 3.375 2026-06-21 14:34:08 +02:00
Sirttas 9f6cc616ad auto refresh 2026-06-21 12:41:32 +02:00
Sirttas 9edb0e6ce8 hadful of fixes 2026-06-21 10:41:48 +02:00
Sirttas 5e7af075cb acquisition in type info 2026-06-20 13:06:43 +02:00
Sirttas 6dac42bbbe reprocess rework 2026-06-19 23:05:42 +02:00
Sirttas 981045da2f raw reprocess endpoint 2026-06-19 22:43:25 +02:00
Sirttas 35779d2b3d update activity call 2026-06-18 15:57:42 +02:00
Sirttas 4b24f433a5 fix acquisition api spam 2026-06-14 16:37:36 +02:00
Sirttas dcf50fb8af item search 2026-06-13 18:10:26 +02:00
Sirttas cc4d56ae4c update dependancies 2026-06-13 17:23:31 +02:00
Sirttas 697a08e481 removed esi 2026-06-13 16:33:37 +02:00
Sirttas 16078cc62b market types from mammon 2026-06-13 16:30:31 +02:00
Sirttas 3a35d2181d cleanup and fix 2026-06-13 14:36:00 +02:00
Sirttas 6d2b5926bb cleanup and fix 2026-06-13 13:07:49 +02:00
Sirttas cc3bdccd9a call mammon for single scan 2026-06-13 12:31:01 +02:00
Sirttas 3348b9f668 fix scan 2026-06-13 00:06:55 +02:00
Sirttas d0c198118d apraisal endpoint 2026-06-12 21:20:44 +02:00
Sirttas 2ab3f01d89 mammon market history 2026-06-11 20:46:40 +02:00
Sirttas 3981475c55 fix maket endpoints 2026-06-11 20:41:38 +02:00
Sirttas acde42b406 fix maket endpoints 2026-06-11 20:39:50 +02:00
Sirttas 7ca38aee70 scan front 2026-06-11 20:32:46 +02:00
Sirttas dd031551ca rework store usage 2026-06-11 20:02:15 +02:00
Sirttas f3cb4798d5 character rule book store 2026-06-11 19:42:56 +02:00
Sirttas 9cd0d5fb5e rule book dup/delete 2026-06-09 23:44:16 +02:00
Sirttas 5ac369a643 fix 2026-06-09 21:56:12 +02:00
Sophie-Gaëlle CALLOCH b2c97c1327 rulebook name 2026-06-09 18:00:25 +02:00
Sirttas 4ae044dace win size 2026-06-08 18:54:53 +02:00
Sirttas c444f51423 js editor + fix js runner 2026-06-08 08:27:42 +02:00
Sirttas a201a95756 js editor + fix js runner 2026-06-07 23:18:53 +02:00
Sirttas b32169f433 js editor 2026-06-07 22:37:18 +02:00
Sirttas 023693c4c8 text area 2026-06-07 22:06:53 +02:00
Sirttas 47bd728530 cleanup 2026-06-06 23:52:50 +02:00
Sirttas 7e0ea10d68 corporation transactions + cleanup 2026-06-06 23:44:09 +02:00
Sirttas cd1965acc4 transfer list order 2026-06-06 20:30:58 +02:00
Sirttas 653f7dbeeb acquisition ui 2026-06-06 15:53:54 +02:00
Sirttas 5506125b2e cleanup 2026-06-06 14:56:03 +02:00
Sirttas e6ee697508 acquisition presentation 2026-06-06 14:49:28 +02:00
Sirttas bef14bcdcc character in transaction list 2026-06-05 18:41:59 +02:00
Sophie-Gaëlle CALLOCH 8d0e5ffc1a cleanup 2026-06-05 16:41:08 +02:00
Sophie-Gaëlle CALLOCH 680e8d8b95 system ledger entity 2026-06-05 16:23:57 +02:00
Sirttas 13ba8556a4 rename 2026-06-05 08:15:03 +02:00
Sirttas 46a2538bef no color in transfers 2026-06-04 19:51:04 +02:00
Sirttas 57b9ec17de fix things 2026-06-04 11:24:55 +02:00
Sirttas af3b26a273 fix things 2026-06-04 11:12:26 +02:00
Sophie-Gaëlle CALLOCH 5da9003b14 bounty activities 2026-06-02 17:54:42 +02:00
Sirttas ba548583ba ledger label system green 2026-06-01 23:05:23 +02:00
Sirttas 5c12a8af43 dropdown floating 2026-06-01 22:29:06 +02:00
Sirttas 05210fea4b transfer list 2026-06-01 22:19:26 +02:00
Sirttas f28201e711 tables display 2026-06-01 21:04:39 +02:00
Sirttas c23ec0cb53 ledger balance 2026-06-01 19:22:27 +02:00
Sirttas 42c7e59d63 balance endpoint 2026-06-01 17:39:14 +02:00
Sirttas 192cf7d9cb ledger view page structure 2026-06-01 16:24:35 +02:00
Sirttas 3235cf21ba transaction list display 2026-05-31 23:10:50 +02:00
Sirttas 47ee14319d transaction list api 2026-05-31 21:54:56 +02:00
Sirttas 457d2a5161 cleanup rules 2026-05-31 18:57:10 +02:00
Sirttas 1358aaa705 cleanup routes names 2026-05-31 18:33:35 +02:00
Sirttas d7bae268da character rule book front 2026-05-31 18:20:46 +02:00
Sirttas 9310397320 character rule book front 2026-05-31 18:19:45 +02:00
Sirttas ba81d7b6a8 rule book front 2026-05-31 17:00:49 +02:00
Sirttas 676ff961ed rename rule set to rule 2026-05-28 13:15:15 +02:00
Sirttas b40b58f866 rename rule to rule clause 2026-05-28 11:37:09 +02:00
Sirttas 9acbc101e1 post processing call from front 2026-05-26 22:32:43 +02:00
Sirttas ccc6b827f0 upate dependancies 2026-05-25 18:37:12 +02:00
Sirttas 0a2894b378 sortable 2026-05-25 18:36:18 +02:00
Sirttas 2d6930d38d from to at 2026-05-24 17:04:02 +02:00
Sirttas 11fbe847f2 pretyness 2026-05-24 17:00:34 +02:00
Sirttas 72933ada6e fix rules bugs 2026-05-24 16:22:39 +02:00
Sirttas e10d58d231 rule book ui 2026-05-24 14:28:12 +02:00
Sirttas 4b39d491d2 rule book ui 2026-05-24 13:39:35 +02:00
Sirttas a1dbe41b6c character rules 2026-05-23 23:47:06 +02:00
Sirttas e233e609e6 character endpoint 2026-05-23 23:19:45 +02:00
Sirttas d64b718573 character endpoint 2026-05-23 21:33:23 +02:00
Sirttas 153dff6bc7 rules endpoint 2026-05-23 16:03:48 +02:00
Sirttas 4fbced2c70 ledger balance 2026-05-23 14:29:25 +02:00
Sophie-Gaëlle CALLOCH 2970f48e65 rename combining ledger to combined 2026-05-22 14:53:33 +02:00
Sirttas e137bec8dd ledger uuid 2026-05-20 00:18:27 +02:00
Sirttas 3ca0cf23f1 edit ledger 2026-05-18 21:19:01 +02:00
Sirttas 02466eea14 ledger empty name 2026-05-17 21:26:23 +02:00
Sirttas f4b590bc3b rename leger label 2026-05-17 21:14:04 +02:00
Sirttas 65bb13aa3b ledger list clean + fix 2026-05-17 21:03:56 +02:00
Sirttas 2332ad2216 ledger list and modal 2026-05-17 19:53:55 +02:00
Sirttas 8005e7a45b ledger list and modal 2026-05-17 19:19:01 +02:00
Sirttas f9ae0d142a ledger gemory draft 2026-05-17 12:42:25 +02:00
Sirttas e81fdc24bb fix is logged in check 2025-05-10 09:33:43 +02:00
Sirttas 778de8ca14 update an fix login 2025-05-10 09:30:16 +02:00
Sirttas 00c37c0a37 update an cleanup 2025-03-08 14:38:05 +01:00
Sirttas a56580ce27 refresh acquisitions 2024-06-18 12:02:56 +02:00
Sirttas 11f886cd71 Add total if name is missing 2024-06-04 16:18:52 +02:00
Sirttas ac07236936 total in acquisition table 2024-06-04 14:39:48 +02:00
Sirttas 9aa37b355e tracking progress bar 2024-06-02 16:51:06 +02:00
Sirttas 12ad7d36ff cleanup acquisitions 2024-06-02 15:50:17 +02:00
Sirttas c77a6ff811 acquisitions in tracking 2024-06-02 08:16:20 +02:00
Sirttas 0a82fca6d3 open in a new tab 2024-05-28 12:54:21 +02:00
Sirttas 1e57e7c33e fix cache 2024-05-27 19:21:38 +02:00
Sirttas c484948a5e tracking item by item 2024-05-27 17:52:47 +02:00
Sirttas 4748b15cc4 cleanup formater 2024-05-27 12:16:50 +02:00
Sirttas 9ccba70ede fix acquisition creeation 2024-05-27 12:16:15 +02:00
Sirttas 1868b3e248 test instead of it 2024-05-24 21:28:23 +02:00
Sirttas 9f2627faf8 cache 2024-05-24 21:22:28 +02:00
Sirttas a7b1fb902c cleanup 2024-05-24 18:00:59 +02:00
Sirttas 6afce2ef58 use define model 2024-05-24 15:07:47 +02:00
Sirttas fff01ff30f remove export 2024-05-22 12:44:02 +02:00
Sirttas a576a93a0b fix date format 2024-05-22 11:22:30 +02:00
Sirttas a33426f3c2 cleanup 2024-05-22 11:08:40 +02:00
Sirttas 0dc309642c Flip tooltips 2024-05-22 11:06:27 +02:00
Sirttas 8dc1a2dc3c update 2024-05-22 10:24:35 +02:00
Sirttas e477242f16 esi rate limit 2024-05-22 10:12:50 +02:00
Sirttas f75156bc62 history cache 2024-05-22 09:59:13 +02:00
Sirttas e379f490a4 fix z index 2024-05-22 09:41:38 +02:00
Sirttas c210ed7fac add tests 2024-05-22 01:39:01 +02:00
Sirttas 92b7f60c75 fix null date 2024-05-22 01:28:13 +02:00
Sirttas 7e7c638ef1 finally table height 2024-05-21 21:55:52 +02:00
Sirttas b19ef017d6 fix css 2024-05-21 18:52:35 +02:00
Sirttas 8bcbf3bd1d date + fix sort 2024-05-21 17:00:56 +02:00
Sirttas 540d4814d9 reprocess virtual scroll 2024-05-21 15:56:47 +02:00
Sirttas 884412f5a9 cleanup css 2024-05-21 15:54:20 +02:00
Sirttas 4814d24efb fix z index 2024-05-21 15:50:14 +02:00
Sirttas 34095e0d38 cleanup css 2024-05-21 15:15:29 +02:00
Sirttas bbad25b55b cleanup style 2024-05-21 14:57:13 +02:00
Sirttas c76f4be928 fix css 2024-05-21 14:55:43 +02:00
Sirttas d89ff4ea7f fix line height 2024-05-21 14:10:48 +02:00
Sirttas 7a7dba010e virtual scroll table 2024-05-21 13:29:09 +02:00
Sirttas b81282b42e all button 2024-05-20 13:21:15 +02:00
Sirttas 617d3b281e fi next 2024-05-19 19:32:55 +02:00
Sirttas c52e92e3ce fix regex 2024-05-19 19:24:16 +02:00
Sirttas a9e981baa0 prefix 2024-05-19 19:09:04 +02:00
Sirttas 8fdcc75826 hix https 2024-05-19 19:08:22 +02:00
Sirttas d82f6b6965 lighter type info 2024-05-19 16:22:02 +02:00
Sirttas 3a3711b713 log level prop 2024-05-19 16:01:08 +02:00
Sirttas c1778b3d49 sort aquired tyes 2024-05-19 13:33:41 +02:00
Sirttas 514c28b900 cleanup 2024-05-19 12:41:59 +02:00
Sirttas 09a3295920 Fix compilation error 2024-05-19 12:37:09 +02:00
Sirttas 400737dab8 Fix remove from multiple acquisitions 2024-05-19 12:19:43 +02:00
Sirttas 27f146b945 perfs 2024-05-19 11:58:36 +02:00
Sirttas fb9a2f11fe fix potential issue with grouping 2024-05-19 10:28:07 +02:00
Sirttas 4e211c8834 cleanup 2024-05-19 00:54:05 +02:00
Sirttas 79bef2775c #default instead of v-slot 2024-05-18 22:40:25 +02:00
Sirttas 3fd4f5080d show item info button 2024-05-18 22:38:55 +02:00
Sirttas f677a1d61b cleanup acquisitions 2024-05-18 21:12:11 +02:00
Sirttas d5aafc88a9 show all acquisitions in typeinfo 2024-05-18 20:12:30 +02:00
Sirttas 52a4b99214 use oldest instead of group[0] 2024-05-18 20:00:33 +02:00
Sirttas ff4c9c6bf0 group acquisitions 2024-05-18 19:55:48 +02:00
Sirttas 2756bbb2c2 fix acquisition list key 2024-05-18 19:13:14 +02:00
Sirttas 6c99fa0401 draft character page 2024-05-18 18:55:37 +02:00
Sirttas c38f44c182 <Misc acquisitions 2024-05-18 18:36:27 +02:00
Sirttas 167788ac15 titles 2024-05-18 18:23:54 +02:00
Sirttas 2c64cca921 fix typeinfo acquiisition table alwayse present 2024-05-18 14:38:15 +02:00
Sirttas 894b23166c Remaining amount over total amont 2024-05-18 14:18:44 +02:00
Sirttas e3a5eeb50d Fix marbas integration 2024-05-18 14:14:57 +02:00
Sirttas 5887ecb638 Acquisitions in typeinfo 2024-05-18 00:15:02 +02:00
Sirttas 78c96f8bce Fix tracking id 2024-05-17 23:57:26 +02:00
Sirttas 5ddee59227 Fix acquisition list 2024-05-17 23:46:28 +02:00
Sirttas 94992afbe3 Fix remove acquisiton 2024-05-17 23:44:11 +02:00
Sirttas 717eaa6ed8 Rework to use marbas and authentik instead of poketbase (#1)
Reviewed-on: #1
2024-05-17 23:00:52 +02:00
Sirttas 9fb78329cc add redirects to nginx config 2023-12-03 16:34:48 +01:00
Sirttas af9465c127 fix nginix 2023-11-16 20:42:34 +01:00
Sirttas 5c0b83a0a3 fix showColumn 2023-11-16 18:48:48 +01:00
Sirttas b1da083557 fix crash 2023-11-16 17:49:20 +01:00
Sirttas a1bffa1cdb fuzzwork 2023-11-16 11:26:17 +01:00
Sirttas 98ce81dfb2 extract evepraisal from apraisal 2023-11-16 09:54:23 +01:00
Sirttas f115381955 style 2023-11-08 11:05:14 +01:00
Sirttas 088ea5d929 ignorable columns 2023-11-07 13:47:42 +01:00
Sirttas ee6bbfd442 type info 2023-11-07 11:00:08 +01:00
Sirttas 75f70cfd25 fix marbas proxy 2023-10-30 10:50:47 +01:00
Sirttas 1f1821d607 fix proxy 2023-10-30 09:35:08 +01:00
Sirttas 98c818f028 fix non pageables 2023-10-29 18:35:37 +01:00
Sirttas 0ea65867a8 update to mabras rework 2023-10-29 18:28:05 +01:00
Sirttas 2b59f8719a cleanup 2023-10-29 12:57:14 +01:00
Sirttas 4e4a700ced better label 2023-10-28 16:06:18 +02:00
Sirttas c432450455 arrow trending 2023-10-26 14:48:16 +02:00
Sirttas 5082cfaac9 fix position 2023-10-26 14:23:31 +02:00
Sirttas 9dd60ae054 filter market groups 2023-10-24 10:21:10 +02:00
Sirttas ac6c51a714 cleanup 2023-10-23 19:21:55 +02:00
Sirttas 7d608c19e7 margin 2023-10-23 16:23:23 +02:00
Sirttas a48e49ab9c fix click outside 2023-10-23 16:08:40 +02:00
Sirttas 9bd1ced9d4 search item 2023-10-18 17:59:30 +02:00
Sirttas 4fca2712bf fix sell at 0 2023-10-16 09:01:16 +02:00
Sirttas c2a09f1c2a fix format 2023-10-13 20:58:31 +02:00
Sirttas c2c8f2a65b buy/sel double slider 2023-10-13 20:11:20 +02:00
Sirttas 4cb3de356f score weight + quantils tooltip 2023-10-12 10:22:40 +02:00
Sirttas 0e883dd688 q1 based score 2023-10-07 18:09:44 +02:00
Sirttas 4536d34b92 fix score 2023-10-06 09:05:33 +02:00
Sirttas a9cd258af8 fix score 2023-10-02 22:07:09 +02:00
Sirttas e78f59b78a fix 2023-10-02 21:59:01 +02:00
Sirttas 7167640e43 cleanup 2023-10-02 21:48:36 +02:00
Sirttas 866ff0a42b fix 2023-10-02 21:41:50 +02:00
Sirttas b2304916d6 score formater 2023-10-02 21:31:21 +02:00
Sirttas 7f83ee2ee2 fix score 2023-10-02 19:49:25 +02:00
Sirttas 7d33b77410 score + filter 2023-10-02 19:38:46 +02:00
Sirttas 66f88ef1b1 about page 2023-10-02 10:08:04 +02:00
Sirttas 2b513a91b0 add taxes 2023-10-02 09:52:11 +02:00
Sirttas 0026cba23d postcss 2023-10-02 09:30:26 +02:00
Sirttas c587fb75f2 cleanup ui 2023-10-01 22:04:01 +02:00
Sirttas b9eedf4f07 rename to gemory 2023-10-01 18:17:48 +02:00
Sirttas a5e365328c batch size 2023-10-01 11:54:20 +02:00
Sirttas 7253b864d9 update 2023-10-01 11:50:52 +02:00
Sirttas 0ce205a4a0 batching 2023-10-01 11:49:02 +02:00
Sirttas c3205a3e74 axios logs 2023-09-23 12:21:02 +02:00
Sirttas 575d4dc5ab remove scan 2023-09-23 11:50:28 +02:00
Sirttas 2c728c7037 fix 2023-09-23 11:44:04 +02:00
156 changed files with 14863 additions and 2971 deletions
+2
View File
@@ -23,3 +23,5 @@ docker-compose.yml
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
generated/mammon/
+4 -1
View File
@@ -6,5 +6,8 @@ COPY . ./
RUN npm run build RUN npm run build
FROM nginx:alpine FROM nginx:alpine
ENV NGINX_ENVSUBST_OUTPUT_DIR=/usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template RUN mkdir etc/nginx/templates && \
mv /usr/share/nginx/html/index.html /etc/nginx/templates/index.html.template
COPY nginx.conf /etc/nginx/conf.d/default.conf
+2349
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
server {
listen 80 http2;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html /index.html;
}
}
-43
View File
@@ -1,43 +0,0 @@
server {
listen 80 http2;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html /index.html;
}
location /api/ {
proxy_pass https://${API_URL}/;
proxy_http_version 1.1;
rewrite /api/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${API_URL}";
proxy_set_header X-Forwarded-Proto https;
}
location /pocketbase/ {
proxy_pass https://${POCKET_BASE_URL}/;
proxy_http_version 1.1;
rewrite /pocketbase/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${POCKET_BASE_URL}";
proxy_set_header X-Forwarded-Proto https;
}
location /appraisal/ {
proxy_pass https://${EVEPRAISAL_URL}/;
proxy_http_version 1.1;
rewrite /appraisal/(.*) /$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "${EVEPRAISAL_URL}";
proxy_set_header X-Forwarded-Proto https;
}
location /esi/ {
proxy_pass https://esi.evetech.net/;
proxy_http_version 1.1;
rewrite /esi/(.*) /latest/$1 break;
proxy_ssl_server_name on;
proxy_set_header Host "esi.evetech.net";
proxy_set_header X-Forwarded-Proto https;
proxy_set_header User-Agent "${ESI_USER_AGENT}";
}
}
+2593 -1786
View File
File diff suppressed because it is too large Load Diff
+26 -17
View File
@@ -1,32 +1,41 @@
{ {
"name": "eveal-frontend", "name": "gemory",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host --debug",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@heroicons/vue": "^2.0.18", "@heroicons/vue": "^2.0.18",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e", "@vueuse/components": "^14.3.0",
"@vueuse/core": "^10.2.1", "@vueuse/core": "^14.3.0",
"@vueuse/integrations": "^10.2.1", "@vueuse/integrations": "^14.3.0",
"@vueuse/router": "^14.3.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"pinia": "^2.1.6", "chart.js": "^4.5.1",
"pocketbase": "^0.18.0", "gemory": "file:",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4" "vue-chartjs": "^5.3.4",
"vue-router": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.4.5", "@tailwindcss/vite": "^4.3.0",
"@vitejs/plugin-vue": "^4.2.3", "@types/node": "^25.8.0",
"autoprefixer": "^10.4.14", "@vitejs/plugin-vue": "^6.0.7",
"postcss": "^8.4.27", "tailwindcss": "^4.3.0",
"tailwindcss": "^3.3.3", "typescript": "^6.0.3",
"typescript": "^5.0.2", "vite": "^8.0.13",
"vite": "^4.4.5", "vite-plugin-runtime-env": "^1.0.0",
"vue-tsc": "^1.8.5" "vitest": "^4.1.6",
"vue-tsc": "^3.2.9"
} }
} }
-8
View File
@@ -1,8 +0,0 @@
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
},
}
+25 -1
View File
@@ -1,13 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, watchEffect} from 'vue';
import {RouterView, useRoute} from 'vue-router'; import {RouterView, useRoute} from 'vue-router';
import {Sidebar} from './sidebar'; import {Sidebar} from './sidebar';
import {ConfirmModal} from '@/confirm';
import {toast, ToastContainer} from '@/toast';
import {onActivitiesProcessed} from '@/activity';
import {useAuthStore} from '@/auth';
import {routeNames} from '@/routes';
const route = useRoute(); const route = useRoute();
const hideSidebar = computed(() => {
return route.name === routeNames.callback || route.name === routeNames.about;
});
const authStore = useAuthStore();
watchEffect(onCleanup => {
if (!authStore.isAuthenticated) {
return;
}
const unsubscribe = onActivitiesProcessed(() => toast.info('New activities have been processed'));
onCleanup(unsubscribe);
});
</script> </script>
<template> <template>
<template v-if="route.name === 'login'"> <template v-if="hideSidebar">
<RouterView /> <RouterView />
</template> </template>
<template v-else> <template v-else>
@@ -16,9 +36,13 @@ const route = useRoute();
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<ConfirmModal />
<ToastContainer />
</template> </template>
<style scoped> <style scoped>
@reference "@/style.css";
div.main-container { div.main-container {
@apply px-4 sm:ml-64; @apply px-4 sm:ml-64;
} }
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import {computed} from "vue";
import {computedAsync} from "@vueuse/core";
import {ActivitySourceResponse, ActivitySourceResponseTypeEnum} from "@/generated/mammon";
import {CharacterLabel, useCharactersStore} from "@/characters";
import {CorporationLabel, useCorporationsStore} from "@/corporations";
import {LedgerLabel, useLedgersStore} from "@/ledger";
interface Props {
source: ActivitySourceResponse;
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 32
});
const charactersStore = useCharactersStore();
const corporationsStore = useCorporationsStore();
const ledgersStore = useLedgersStore();
const character = computedAsync(async () => {
if (props.source.type === ActivitySourceResponseTypeEnum.Character && props.source.characterId) {
return await charactersStore.findById(props.source.characterId);
}
return undefined;
}, undefined);
const corporation = computedAsync(async () => {
if (props.source.type === ActivitySourceResponseTypeEnum.Corporation && props.source.corporationId) {
return await corporationsStore.findById(props.source.corporationId);
}
return undefined;
}, undefined);
const ledger = computed(() =>
props.source.type === ActivitySourceResponseTypeEnum.Ledger && props.source.ledgerId
? ledgersStore.findById(props.source.ledgerId)
: undefined);
</script>
<template>
<CharacterLabel v-if="source.type === ActivitySourceResponseTypeEnum.Character && character" :character="character" :size="size" />
<CorporationLabel v-else-if="source.type === ActivitySourceResponseTypeEnum.Corporation && source.corporationId" :corporation-id="source.corporationId" :corporation="corporation" :division="source.division" :size="size" />
<LedgerLabel v-else-if="ledger" :ledger="ledger" link />
</template>
+95
View File
@@ -0,0 +1,95 @@
import log from "loglevel";
import {activityApi} from "@/mammon";
const RECONNECT_DELAY_MILLIS = 3_000;
const FRAME_SEPARATOR = "\n\n";
const PROCESSED_EVENT = "event:processed";
const listeners = new Set<() => void>();
let controller: AbortController | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const notifyProcessed = () => listeners.forEach(listener => listener());
const scheduleReconnect = () => {
if (listeners.size === 0) {
return;
}
reconnectTimer = setTimeout(() => connect(), RECONNECT_DELAY_MILLIS);
};
const consume = async (body: ReadableStream<Uint8Array>) => {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (; ;) {
const {value, done} = await reader.read();
if (done) {
return;
}
buffer += decoder.decode(value, {stream: true});
let boundary = buffer.indexOf(FRAME_SEPARATOR);
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + FRAME_SEPARATOR.length);
if (frame.split("\n").some(line => line.trim() === PROCESSED_EVENT)) {
notifyProcessed();
}
boundary = buffer.indexOf(FRAME_SEPARATOR);
}
}
};
const connect = async (): Promise<void> => {
if (listeners.size === 0) {
return;
}
const signal = controller?.signal;
try {
const response = await activityApi.streamProcessing({
responseType: "stream",
adapter: "fetch",
signal,
});
await consume(response.data as unknown as ReadableStream<Uint8Array>);
scheduleReconnect();
} catch (error) {
if (listeners.size === 0 || signal?.aborted) {
return;
}
log.debug("Activity processing SSE error, reconnecting", error);
scheduleReconnect();
}
};
const close = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
controller?.abort();
controller = undefined;
};
export const onActivitiesProcessed = (listener: () => void): (() => void) => {
listeners.add(listener);
if (!controller) {
controller = new AbortController();
connect();
}
return () => {
if (listeners.delete(listener) && listeners.size === 0) {
close();
}
};
};
+4
View File
@@ -0,0 +1,4 @@
export {default as SourceLabel} from './SourceLabel.vue';
export {onActivitiesProcessed} from './activitiesProcessed';
export {useActivitiesProcessed} from './useActivitiesProcessed';
export {useProcessedResource} from './useProcessedResource';
+8
View File
@@ -0,0 +1,8 @@
import {onUnmounted} from "vue";
import {onActivitiesProcessed} from "./activitiesProcessed";
export const useActivitiesProcessed = (listener: () => void): (() => void) => {
const unsubscribe = onActivitiesProcessed(listener);
onUnmounted(unsubscribe);
return unsubscribe;
};
+15
View File
@@ -0,0 +1,15 @@
import {ref, type Ref, watchEffect} from "vue";
import {useActivitiesProcessed} from "./useActivitiesProcessed";
export const useProcessedResource = <T>(loader: () => Promise<T>, initial: T): Ref<T> => {
const data = ref(initial) as Ref<T>;
const load = async () => {
data.value = await loader();
};
watchEffect(load);
useActivitiesProcessed(load);
return data;
};
+74
View File
@@ -0,0 +1,74 @@
import {defineStore} from "pinia";
import {ref} from "vue";
import {CharacterResponse} from "@/generated/mammon";
import {
addCharacter as addCharacterRequest,
fetchMe,
mammonLoginUrl,
postLogout,
refreshAccessToken,
setOnAuthExpired
} from "@/mammon";
import {setAccessToken} from "./token";
export const useAuthStore = defineStore('auth', () => {
const userId = ref<string | null>(null);
const characters = ref<CharacterResponse[]>([]);
const isAuthenticated = ref(false);
const clear = () => {
setAccessToken(null);
userId.value = null;
characters.value = [];
isAuthenticated.value = false;
}
const refresh = async (): Promise<boolean> => {
const token = await refreshAccessToken();
if (!token) {
clear();
return false;
}
isAuthenticated.value = true;
return true;
}
const fetch = async (): Promise<void> => {
const me = await fetchMe();
userId.value = me.userId;
characters.value = me.characters;
isAuthenticated.value = true;
}
const login = (): void => {
window.location.assign(mammonLoginUrl);
}
const addCharacter = async (): Promise<void> => {
await addCharacterRequest();
window.location.assign(mammonLoginUrl);
}
const logout = async (): Promise<void> => {
try {
await postLogout();
} finally {
clear();
}
}
const bootstrap = async (): Promise<void> => {
setOnAuthExpired(() => {
clear();
login();
});
if (await refresh()) {
await fetch().catch(() => {});
}
}
return {userId, characters, isAuthenticated, refresh, fetch, login, addCharacter, logout, bootstrap};
})
+2
View File
@@ -0,0 +1,2 @@
export * from './auth';
export * from './token';
+7
View File
@@ -0,0 +1,7 @@
let accessToken: string | null = null;
export const getAccessToken = (): string | null => accessToken;
export const setAccessToken = (token: string | null): void => {
accessToken = token;
};
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import {Character} from "./chartacters.ts";
interface Props {
character: Character;
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 32
})
</script>
<template>
<div class="flex">
<img class="me-2" :src="`https://images.evetech.net/characters/${character.characterId}/portrait?size=${size}`" />
<span>{{ character.name }}</span>
</div>
</template>
+26
View File
@@ -0,0 +1,26 @@
import {characterApi} from "@/mammon";
import {defineStore} from "pinia";
import {ref} from "vue";
import {CharacterResponse} from "@/generated/mammon";
export type Character = CharacterResponse
export const useCharactersStore = defineStore('characters', () => {
const characters = ref<Character[]>([]);
const findById = async (characterId: number): Promise<Character | undefined> => {
let character = characters.value.find(c => c.characterId === characterId);
if (!character) {
await refresh(); // TODO call api instead of refresh
character = characters.value.find(c => c.characterId === characterId);
}
return character;
}
const refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data);
refresh();
return {characters, findById};
})
+3
View File
@@ -0,0 +1,3 @@
export * from './chartacters.ts'
export {default as CharacterLabel} from './CharacterLabel.vue';
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { copyToClipboard } from '@/utils';
import { ClipboardIcon } from '@heroicons/vue/24/outline';
interface Props {
value?: string;
}
const props = defineProps<Props>();
const doCopy = () => {
if (!props.value) {
return;
}
copyToClipboard(props.value);
}
</script>
<template>
<button class="btn-icon" title="Copy to clipboard" @click="doCopy">
<ClipboardIcon />
</button>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/vue/24/outline';
import {vOnClickOutside} from '@vueuse/components';
import {useElementBounding, useEventListener} from '@vueuse/core';
import {computed, ref} from 'vue';
interface Props {
inline?: boolean;
autoClose?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
inline: false,
autoClose: true
})
const isOpen = defineModel<boolean>('open', {default: false});
const root = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null);
const { left, bottom, width } = useElementBounding(root);
const floatingStyle = computed(() => ({
left: `${left.value}px`,
top: `${bottom.value}px`,
minWidth: `${width.value}px`,
}));
const doAutoClose = () => {
if (props.autoClose) {
isOpen.value = false;
}
}
useEventListener('keyup', e => {
if (e.key === 'Escape') {
doAutoClose();
}
});
</script>
<template>
<div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]">
<button @click="isOpen = !isOpen" class="flex items-center cursor-pointer border-none bg-transparent hover:bg-transparent p-0">
<Transition
mode="out-in"
enter-active-class="transition-transform"
enter-from-class="rotate-180">
<ChevronDownIcon v-if="!isOpen" class="chevron" />
<ChevronUpIcon v-else class="chevron" />
</Transition>
<slot name="button" />
</button>
<Transition
enter-active-class="transition-opacity"
enter-from-class="opacity-0"
leave-from-class="transition-opacity"
leave-to-class="opacity-0">
<div v-if="inline && isOpen">
<slot />
</div>
</Transition>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity"
enter-from-class="opacity-0"
leave-from-class="transition-opacity"
leave-to-class="opacity-0">
<div v-if="!inline && isOpen" ref="floating" class="dropdown-floating" :style="floatingStyle">
<div class="divide-y rounded-b-md">
<slot />
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
@reference "@/style.css";
.chevron {
@apply w-4 h-4 me-1;
}
.dropdown-floating {
@apply fixed z-10;
}
.dropdown-floating > div {
@apply bg-slate-800 rounded-b-md shadow-lg;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import {computed, ref, watch} from "vue";
const props = withDefaults(defineProps<{ min?: number; max?: number }>(), {
min: 0,
max: 100,
});
const from = defineModel<number>('from', {default: 0});
const to = defineModel<number>('to', {default: 0});
const container = ref<HTMLElement>();
const dragFill = (event: PointerEvent) => {
const rect = container.value?.getBoundingClientRect();
if (!rect) {
return;
}
const span = (props.max - props.min) || 1;
const width = to.value - from.value;
const startX = event.clientX;
const startFrom = from.value;
const onMove = (moveEvent: PointerEvent) => {
const delta = Math.round(((moveEvent.clientX - startX) / rect.width) * span);
const newFrom = Math.min(Math.max(startFrom + delta, props.min), props.max - width);
from.value = newFrom;
to.value = newFrom + width;
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
watch(from, value => {
if (value > to.value) {
to.value = value;
}
});
watch(to, value => {
if (value < from.value) {
from.value = value;
}
});
const fillStyle = computed(() => {
const span = (props.max - props.min) || 1;
return {
left: `${((from.value - props.min) / span) * 100}%`,
width: `${((to.value - from.value) / span) * 100}%`,
};
});
</script>
<template>
<div ref="container" class="dual-range relative h-6 select-none">
<div class="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-gray-700 pointer-events-none"></div>
<div class="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-emerald-500 cursor-grab active:cursor-grabbing"
:style="fillStyle" @pointerdown="dragFill"></div>
<input type="range" :min="min" :max="max" v-model.number="from"
class="absolute inset-0 w-full h-full m-0 bg-transparent appearance-none pointer-events-none" />
<input type="range" :min="min" :max="max" v-model.number="to"
class="absolute inset-0 w-full h-full m-0 bg-transparent appearance-none pointer-events-none" />
</div>
</template>
<style scoped>
@reference "@/style.css";
.dual-range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
@apply w-4 h-4 rounded-full bg-emerald-500 border-2 border-gray-200 cursor-pointer pointer-events-auto;
}
.dual-range input[type="range"]::-moz-range-thumb {
@apply w-4 h-4 rounded-full bg-emerald-500 border-2 border-gray-200 cursor-pointer pointer-events-auto;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<template>
<div role="status">
<svg aria-hidden="true" class="inline w-6 h-6 text-slate-500 animate-spin fill-slate-100" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</template>
+11 -24
View File
@@ -1,23 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEventListener, useVModel } from '@vueuse/core'; import {vOnClickOutside} from '@vueuse/components';
import {useEventListener} from '@vueuse/core';
import {watch} from 'vue'; import {watch} from 'vue';
interface Props { const open = defineModel('open', { default: false });
open: boolean;
}
interface Emit { watch(open, value => {
(e: 'update:open', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
const emit = defineEmits<Emit>();
const isOpen = useVModel(props, 'open', emit, {passive: true});
watch(isOpen, value => {
if (value) { if (value) {
document.body.classList.add('overflow-hidden'); document.body.classList.add('overflow-hidden');
} else { } else {
@@ -26,18 +14,18 @@ watch(isOpen, value => {
}); });
useEventListener('keyup', e => { useEventListener('keyup', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
isOpen.value = false; open.value = false;
} }
}); });
</script> </script>
<template> <template>
<Transition name="fade"> <Transition name="fade">
<template v-if="isOpen"> <template v-if="open">
<div class="fixed inset-0" @click="isOpen = false"> <div class="fixed inset-0 z-10">
<div class="absolute bg-black opacity-80 inset-0 z-0" /> <div class="absolute bg-black opacity-80 inset-0 z-0" />
<div class="absolute grid inset-0"> <div class="absolute grid inset-0">
<div class="justify-self-center m-auto" @click.stop> <div class="justify-self-center m-auto" v-on-click-outside="() => open = false">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -47,13 +35,12 @@ useEventListener('keyup', e => {
</template> </template>
<style scoped> <style scoped>
@reference "@/style.css";
.fade-enter-from, .fade-leave-to { .fade-enter-from, .fade-leave-to {
opacity: 0; @apply opacity-0;
} }
.fade-enter-active, .fade-leave-active { .fade-enter-active, .fade-leave-active {
transition: opacity 100ms ease-out; @apply transition-opacity;
} }
</style> </style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
value: number;
total: number;
}
const props = defineProps<Props>();
const percentage = computed(() => (props.value / props.total) * 100);
</script>
<template>
<div class="w-full bg-gray-600 rounded-full h-2.5">
<div class="bg-emerald-600 h-2.5 rounded-full" :style="{ width: percentage + '%'}" />
</div>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts" generic="T">
import {vOnClickOutside} from '@vueuse/components';
import {useVirtualList} from '@vueuse/core';
import {computed, ref, watch} from 'vue';
interface Props {
items: T[];
itemHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
itemHeight: 24,
});
const modelValue = defineModel<T>();
const isOpen = ref(false);
watch(() => props.items, items => {
if (items.length > 0) {
isOpen.value = true;
}
});
const currentIndex = ref(-1);
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), {
itemHeight: () => props.itemHeight,
overscan: 3,
});
const moveDown = () => {
currentIndex.value = currentIndex.value >= props.items.length - 1 ? 0 : currentIndex.value + 1;
scrollTo(currentIndex.value);
};
const moveUp = () => {
currentIndex.value = currentIndex.value <= 0 ? props.items.length - 1 : currentIndex.value - 1;
scrollTo(currentIndex.value);
};
const select = (item?: T) => {
modelValue.value = item;
currentIndex.value = -1;
isOpen.value = false;
};
const submit = () => {
if (currentIndex.value >= 0 && currentIndex.value < props.items.length) {
select(props.items[currentIndex.value]);
} else if (modelValue.value === undefined && props.items.length > 0) {
select(props.items[0]);
}
};
</script>
<template>
<div class="relative" @click="isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp">
<slot name="input" :value="modelValue" />
</div>
<div v-if="isOpen && items.length" class="z-20 absolute w-full">
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index"
class="hover:bg-slate-700 cursor-pointer overflow-hidden"
:class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'"
@click.stop="select(s.data)">
<slot name="item" :item="s.data" />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "@/style.css";
.fake-input {
@apply flex border bg-slate-500 rounded px-1 py-0.5;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
const modelValue = defineModel({ default: false });
</script>
<template>
<label class="flex items-center relative w-max cursor-pointer select-none">
<input type="checkbox" class="appearance-none transition-colors cursor-pointer w-10 h-5 rounded-full checked:bg-emerald-500 peer" v-model="modelValue" />
<span class="w-5 h-5 right-5 absolute rounded-full transform transition-transform bg-slate-100 peer-checked:bg-emerald-200" />
</label>
</template>
<style scoped>
@reference "@/style.css";
input:checked ~ span:last-child {
transform: translateX(1.25rem);
}
</style>
+10
View File
@@ -0,0 +1,10 @@
export { default as ClipboardButton } from './ClipboardButton.vue';
export { default as Dropdown } from './Dropdown.vue';
export { default as DualRangeSlider } from './DualRangeSlider.vue';
export { default as LoadingSpinner } from './LoadingSpinner.vue';
export { default as Modal } from './Modal.vue';
export { default as ProgressBar } from './ProgressBar.vue';
export { default as SelectInput } from './SelectInput.vue';
export { default as SliderCheckbox } from './SliderCheckbox.vue';
export { default as Tooltip } from './tooltip/Tooltip.vue';
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import {HeaderComponent, SortDirection} from './sort';
interface Props {
currentSortKey: string | null;
sortDirection?: SortDirection | null;
showColumn?: (k: string) => boolean;
unsortable?: boolean;
sortKey: string;
headerComponent?: HeaderComponent;
}
interface Emit {
(e: 'sort', key: string, direction: SortDirection): void;
}
withDefaults(defineProps<Props>(), {
showColumn: () => () => true,
unsortable: false,
headerComponent: 'th',
});
const emit = defineEmits<Emit>();
</script>
<template>
<component v-if="showColumn(sortKey)" :is="headerComponent" class="sort-header">
<slot />
<template v-if="!unsortable">
<span class="asc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'asc')}" @click="emit('sort', sortKey, 'asc')"></span>
<span class="desc" :class="{'opacity-20': (currentSortKey != sortKey || sortDirection != 'desc')}" @click="emit('sort', sortKey, 'desc')"></span>
</template>
</component>
</template>
<style scoped>
@reference "@/style.css";
.sort-header {
@apply relative h-8 pe-3;
}
span.asc, span.desc {
@apply absolute end-1 cursor-pointer text-xs transition-opacity;
}
span.asc {
@apply top-0.5;
}
span.desc {
@apply bottom-0.5;
}
</style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {useElementBounding, useVirtualList} from '@vueuse/core';
import {computed, ref} from 'vue';
interface Props {
list?: any[];
itemHeight: number;
headerHeight?: number;
footerHeight?: number;
bottom?: string; // FIXME: use css variable
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
});
const { list: values, containerProps, wrapperProps } = useVirtualList(computed(() => props.list), {
itemHeight: () => props.itemHeight,
overscan: 3
})
const tableTop = ref<HTMLSpanElement | null>(null);
const { bottom: offset } = useElementBounding(tableTop);
const ypx = computed(() => {
let y = (offset.value ?? 0) + 'px';
if (props.bottom) {
y = `calc(${y} + ${props.bottom})`;
}
return y;
})
const computedHeaderHeight = computed(() => {
const h = props.headerHeight ?? props.itemHeight ?? 0;
return h + 'px';
})
const computedFooterHeight = computed(() => {
const h = props.footerHeight ?? 0;
return h + 'px';
})
const computedWrapperProps = computed(() => ({
...wrapperProps.value,
style: {
...wrapperProps.value.style,
height: `calc(${wrapperProps.value.style.height} + ${computedHeaderHeight.value} + ${computedFooterHeight.value} + 1px)`
}
}))
const itemHeightStyle = computed(() => {
const h = props.itemHeight ?? 0;
return h + 'px';
})
</script>
<template>
<span ref="tableTop" class="h-0" />
<div v-if="list.length > 0" v-bind="containerProps" class="table-container">
<div v-bind="computedWrapperProps">
<table>
<slot :list="values" />
</table>
</div>
</div>
<slot v-else name="empty" />
</template>
<style scoped>
@reference "@/style.css";
div.table-container {
@apply bg-slate-600;
max-height: calc(100vh - v-bind(ypx));
&::-webkit-scrollbar-track {
margin-top: v-bind(computedHeaderHeight);
margin-bottom: v-bind(computedFooterHeight);
}
}
div.table-container:deep(>div) {
@apply bg-slate-800;
>table {
>thead {
@apply sticky z-10;
top: -1px;
}
>tfoot {
@apply bg-slate-600 sticky z-10;
bottom: -1px;
}
>*>tr, >*>tr>td {
height: v-bind(itemHeightStyle);
}
}
}
</style>
@@ -1,3 +1,6 @@
export { default as SortableHeader } from './SortableHeader.vue';
export * from './sort'; export * from './sort';
export { default as SortableHeader } from './SortableHeader.vue';
export { default as VirtualScrollTable } from './VirtualScrollTable.vue';
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'vitest'
import { ref } from 'vue'
import { useSort } from './sort'
describe('useSort', () => {
const array = ref([{ key1: 'b', key2: 'a' }, { key1: 'a', key2: 'b' }])
test('Returns expected properties with default options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array)
expect(sortedArray).toBeDefined()
expect(headerProps).toBeDefined()
expect(showColumn).toBeDefined()
})
test('Returns expected properties with custom options', () => {
const { sortedArray, headerProps, showColumn } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
expect(sortedArray.value[0].key1).toBe('a')
expect(headerProps.value.currentSortKey).toBe('key1')
expect(headerProps.value.sortDirection).toBe('asc')
expect(showColumn('key1')).toBe(true)
})
test('Sorts array in ascending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'asc' })
headerProps.value.onSort('key1', 'asc')
expect(sortedArray.value[0].key1).toBe('a')
})
test('Sorts array in descending order', () => {
const { sortedArray, headerProps } = useSort(array, { defaultSortKey: 'key1', defaultSortDirection: 'desc' })
headerProps.value.onSort('key1', 'desc')
expect(sortedArray.value[0].key1).toBe('b')
})
test('Hides ignored columns', () => {
const { showColumn } = useSort(array, { ignoredColumns: ['key1'] })
expect(showColumn('key1')).toBe(false)
})
})
@@ -1,9 +1,12 @@
import { MaybeRefOrGetter, computed, ref, toValue } from "vue"; import { Component, DefineComponent, MaybeRefOrGetter, computed, ref, toValue } from "vue";
export type HeaderComponent = Component | DefineComponent | string;
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type UseSortOptions = { export type UseSortOptions = {
defaultSortKey?: string; defaultSortKey?: string;
defaultSortDirection?: SortDirection; defaultSortDirection?: SortDirection;
ignoredColumns?: MaybeRefOrGetter<string[]>;
headerComponent?: HeaderComponent;
}; };
export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => { export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOptions) => {
@@ -13,13 +16,16 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
sortKey.value = key; sortKey.value = key;
sortDirection.value = direction; sortDirection.value = direction;
}; };
const showColumn = (sortKey: string) => !toValue(options?.ignoredColumns)?.includes(sortKey);
const headerProps = computed(() => ({ const headerProps = computed(() => ({
onSort: sortBy, onSort: sortBy,
showColumn,
currentSortKey: sortKey.value, currentSortKey: sortKey.value,
sortDirection: sortDirection.value, sortDirection: sortDirection.value,
headerComponent: options?.headerComponent,
})); }));
const sortedArray = computed(() => toValue(array).sort((a, b) => { const sortedArray = computed(() => toValue(array).toSorted((a, b) => {
if (sortKey.value === null || sortDirection.value === null) { if (sortKey.value === null || sortDirection.value === null) {
return 0; return 0;
} }
@@ -38,5 +44,5 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
} }
})); }));
return { sortedArray, headerProps }; return { sortedArray, headerProps, showColumn };
} }
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import {vElementHover} from '@vueuse/components';
import {useElementBounding} from '@vueuse/core';
import {computed, ref} from 'vue';
import {useSharedWindowSize} from './tooltip';
const open = defineModel('open', { default: false });
const { width, height } = useSharedWindowSize();
const mainDiv = ref<HTMLDivElement | null>(null);
const { top, bottom, left, right } = useElementBounding(mainDiv);
const positions = computed(() => [
top.value < height.value / 2 ? 'top' : 'bottom',
left.value < width.value / 2 ? 'left' : 'right',
]);
const floatingStyle = computed(() => {
const style: Record<string, string> = {};
if (positions.value.includes('top')) {
style.top = `${bottom.value}px`;
} else {
style.bottom = `${height.value - top.value}px`;
}
if (positions.value.includes('left')) {
style.left = `${left.value}px`;
} else {
style.right = `${width.value - right.value}px`;
}
return style;
});
</script>
<template>
<div ref="mainDiv" class="flex flex-col items-center justify-center" :class="{
'open': open,
'tooltip-top': positions.includes('top'),
'tooltip-bottom': positions.includes('bottom'),
'tooltip-left': positions.includes('left'),
'tooltip-right': positions.includes('right')
}">
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
<slot name="header" />
</div>
<Teleport to="body">
<div v-if="open" class="tooltip-floating" :style="floatingStyle">
<slot />
</div>
</Teleport>
</div>
</template>
<style scoped>
@reference "@/style.css";
.tooltip-floating {
@apply fixed z-30;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
import { createSharedComposable, useWindowSize } from "@vueuse/core";
export const useSharedWindowSize = createSharedComposable(useWindowSize);
+1
View File
@@ -0,0 +1 @@
export { useAutoRefresh } from './useAutoRefresh';
+30
View File
@@ -0,0 +1,30 @@
import {onUnmounted, ref} from "vue";
const DEFAULT_INTERVAL = 1_000;
export const useAutoRefresh = (callback: () => void | Promise<void>, interval: number = DEFAULT_INTERVAL) => {
const active = ref(false);
let timer: ReturnType<typeof setInterval> | undefined;
const stop = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
active.value = false;
};
const start = () => {
if (active.value) {
return;
}
active.value = true;
timer = setInterval(callback, interval);
};
const toggle = () => active.value ? stop() : start();
onUnmounted(stop);
return {active, start, stop, toggle};
};
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import {computed} from "vue";
import {Modal} from "@/components";
import {useConfirmStore} from "./useConfirm";
const confirmStore = useConfirmStore();
const modalOpen = computed({
get: () => confirmStore.open,
set: value => {
if (!value) {
confirmStore.cancel();
}
},
});
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded pb-4 w-96">
<span class="m-2">{{ confirmStore.options.title ?? "Confirm" }}</span>
<hr />
<div class="m-4">{{ confirmStore.options.message }}</div>
<div class="flex justify-end">
<button class="me-2" @click="confirmStore.cancel">{{ confirmStore.options.cancelLabel ?? "Cancel" }}</button>
<button class="confirm me-4" :class="confirmStore.options.danger ? 'danger' : ''" @click="confirmStore.accept">{{ confirmStore.options.confirmLabel ?? "Confirm" }}</button>
</div>
</div>
</Modal>
</template>
<style scoped>
@reference "@/style.css";
button.confirm {
@apply border-emerald-500 bg-emerald-500 hover:bg-emerald-600;
&.danger {
@apply border-amber-900 bg-amber-900 hover:bg-amber-800;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as ConfirmModal } from './ConfirmModal.vue';
export { confirm, useConfirmStore } from './useConfirm';
export type { ConfirmOptions } from './useConfirm';
+37
View File
@@ -0,0 +1,37 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
}
export const useConfirmStore = defineStore('confirm', () => {
const open = ref(false);
const options = ref<ConfirmOptions>({message: ""});
let resolver: ((value: boolean) => void) | undefined;
const settle = (value: boolean) => {
open.value = false;
resolver?.(value);
resolver = undefined;
};
const confirm = (opts: ConfirmOptions | string): Promise<boolean> => {
options.value = typeof opts === "string" ? {message: opts} : opts;
open.value = true;
return new Promise<boolean>(resolve => {
resolver = resolve;
});
};
const accept = () => settle(true);
const cancel = () => settle(false);
return {open, options, confirm, accept, cancel};
});
export const confirm = (opts: ConfirmOptions | string): Promise<boolean> => useConfirmStore().confirm(opts);
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import {Corporation} from "./corporations.ts";
interface Props {
corporationId: number;
corporation?: Corporation | null;
division?: number | null;
size?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 32
})
</script>
<template>
<div class="flex">
<img class="me-2" :src="`https://images.evetech.net/corporations/${corporationId}/logo?size=${size}`" />
<span>{{ corporation?.name ?? 'Corporation' }}<template v-if="division"> &middot; Division {{ division }}</template></span>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
import {corporationApi} from "@/mammon";
import {defineStore} from "pinia";
import {ref} from "vue";
import {CorporationResponse} from "@/generated/mammon";
export type Corporation = CorporationResponse
export const useCorporationsStore = defineStore('corporations', () => {
const corporations = ref<Record<number, Corporation | null>>({});
const inFlight = new Map<number, Promise<Corporation | undefined>>();
const findById = async (corporationId: number): Promise<Corporation | undefined> => {
const cached = corporations.value[corporationId];
if (cached !== undefined) {
return cached ?? undefined;
}
let request = inFlight.get(corporationId);
if (!request) {
request = corporationApi.findCorporation(corporationId)
.then(response => {
corporations.value[corporationId] = response.data;
return response.data;
})
.catch(() => {
corporations.value[corporationId] = null;
return undefined;
})
.finally(() => inFlight.delete(corporationId));
inFlight.set(corporationId, request);
}
return request;
}
return {corporations, findById};
})
+3
View File
@@ -0,0 +1,3 @@
export * from './corporations.ts'
export {default as CorporationLabel} from './CorporationLabel.vue';
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from 'vitest'
import { formatEveDate, formatIsk } from './formaters'
describe('formatIsk', () => {
test('Formats ISK correctly', () => {
expect(formatIsk(123456789)).toBe('123.456.789,00 ISK')
})
})
describe('formatEveDate', () => {
test('Formats EVE date correctly', () => {
const date = new Date(Date.UTC(2022, 0, 1, 0, 0))
expect(formatEveDate(date)).toBe('2022.01.01 00:00')
})
test('Returns empty string for undefined date', () => {
expect(formatEveDate()).toBe('')
})
test('Returns empty string for null date', () => {
expect(formatEveDate(null)).toBe('')
})
})
+11 -1
View File
@@ -1,10 +1,20 @@
const iskFormater = new Intl.NumberFormat("is-IS", { const iskFormater = new Intl.NumberFormat("is-IS", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2
}); });
export const formatIsk = (value: number | bigint) => iskFormater.format(value) + " ISK"; export const formatIsk = (value: number | bigint) => iskFormater.format(value) + " ISK";
export const percentFormater = new Intl.NumberFormat("en-US", { export const percentFormater = new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
minimumFractionDigits: 0 minimumFractionDigits: 0,
maximumFractionDigits: 0
}); });
const timeFormat = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
minimumIntegerDigits: 2
});
export const formatEveDate = (date?: Date | null) => !date ? '' : `${date.getUTCFullYear()}.${timeFormat.format(date.getUTCMonth() + 1)}.${timeFormat.format(date.getUTCDate())} ${timeFormat.format(date.getUTCHours())}:${timeFormat.format(date.getUTCMinutes())}`;
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
/* tslint:disable */
/* eslint-disable */
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost:8080".replace(/\/+$/, "");
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
export interface RequestArgs {
url: string;
options: RawAxiosRequestConfig;
}
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
export const operationServerMap: ServerMap = {
}
+127
View File
@@ -0,0 +1,127 @@
/* tslint:disable */
/* eslint-disable */
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter) || parameter instanceof Set) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
* JSON serialization helper function which replaces instances of unserializable types with serializable ones.
* This function will run for every key-value pair encountered by JSON.stringify while traversing an object.
* Converting a set to a string will return an empty object, so an intermediate conversion to an array is required.
*/
// @ts-ignore
export const replaceWithSerializableTypeIfNeeded = function(key: string, value: any) {
if (value instanceof Set) {
return Array.from(value);
} else {
return value;
}
}
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {}, replaceWithSerializableTypeIfNeeded)
: (value || "");
}
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}
+121
View File
@@ -0,0 +1,121 @@
/* tslint:disable */
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
interface AWSv4Configuration {
options?: {
region?: string
service?: string
}
credentials?: {
accessKeyId?: string
secretAccessKey?: string,
sessionToken?: string
}
}
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
awsv4?: AWSv4Configuration;
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*/
username?: string;
/**
* parameter for basic security
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* parameter for aws4 signature security
* @param {Object} AWS4Signature - AWS4 Signature security
* @param {string} options.region - aws region
* @param {string} options.service - name of the service.
* @param {string} credentials.accessKeyId - aws access key id
* @param {string} credentials.secretAccessKey - aws access key
* @param {string} credentials.sessionToken - aws session token
* @memberof Configuration
*/
awsv4?: AWSv4Configuration;
/**
* override base path
*/
basePath?: string;
/**
* override server index
*/
serverIndex?: number;
/**
* base options for axios calls
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.awsv4 = param.awsv4;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = {
...param.baseOptions,
headers: {
...param.baseOptions?.headers,
},
};
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = /^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$/i;
return mime !== null && jsonMime.test(mime);
}
}
+18
View File
@@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* OpenAPI definition
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: v0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import {computed, ref} from "vue";
import {isCombined, Ledger, LedgerType, LedgerTypes, useLedgersStore} from "./ledger";
import {Modal} from "@/components";
import LedgerLabel from "./LedgerLabel.vue";
import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline';
import LedgerSelect from "@/ledger/LedgerSelect.vue";
import {toast} from "@/toast";
interface Props {
ledgerId?: string;
}
const props = defineProps<Props>();
const ledgersStore = useLedgersStore();
const modalOpen = ref<boolean>(false);
const type = ref<LedgerType>(LedgerTypes.Main);
const name = ref("");
const members = ref<Ledger[]>([]);
const selectedLedger = ref<Ledger>();
const availableLedgers = computed(() => ledgersStore.ledgers
.filter(l => l.ledgerId !== props.ledgerId)
.filter(l => !members.value.includes(l)));
const addMember = () => {
if (selectedLedger.value && !members.value.includes(selectedLedger.value)) {
members.value = [...members.value, selectedLedger.value];
selectedLedger.value = undefined;
}
}
const open = () => {
const ledger = isCreating.value ? undefined : ledgersStore.findById(props.ledgerId);
if (ledger) {
type.value = ledger.type;
name.value = ledger.name;
members.value = isCombined(ledger) ? ledgersStore.findAllById(ledger.memberLedgerIds) : [];
} else {
type.value = LedgerTypes.Main;
name.value = "";
members.value = [];
}
modalOpen.value = true;
}
const canSave = computed(() => name.value.trim().length > 0);
const isCreating = computed(() => props.ledgerId === undefined || props.ledgerId.length === 0);
const title = computed(() => {
if (isCreating.value) {
return `Creating ${type.value === LedgerTypes.Main ? 'Main' : 'Combined'} Ledger`
}
return `Updating ${name.value}`
})
const create = () => {
if (type.value === LedgerTypes.Main) {
return ledgersStore.createMain({name: name.value})
}
return ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
}
const update = () => {
if (type.value === LedgerTypes.Main) {
return ledgersStore.updateMain(props.ledgerId, {name: name.value})
}
return ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
}
const save = () => {
if (!canSave.value) {
return;
}
const creating = isCreating.value;
const action = creating ? create() : update();
modalOpen.value = false;
action.then(() => toast.success(creating ? 'Ledger created' : 'Ledger updated'));
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded pb-4 w-96">
<span class="m-2">{{ title }}</span>
<hr />
<div class="mt-4">
<div v-if="!ledgerId" class="flex justify-center">
<div class="flex bg-slate-600 rounded-s-md p-1">
<button class="switch" :class="{active: type === LedgerTypes.Main}" @click="type = LedgerTypes.Main">Main</button>
</div>
<div class="switch flex bg-slate-600 rounded-e-md p-1">
<button class="switch" :class="{active: type === LedgerTypes.Combined}" @click="type = LedgerTypes.Combined">Combined</button>
</div>
</div>
<div class="m-4">
Name:
<div class="flex">
<input type="text" class="flex grow" v-model="name" />
</div>
</div>
</div>
<div v-if="type === LedgerTypes.Combined" class="ms-4 mb-4">
Member Ledgers:
<div v-for="ledger in members" :key="ledger.ledgerId" class="flex">
<LedgerLabel class="flex grow mb-2" :ledger="ledger" />
<div class="flex justify-end me-4 ms-2">
<button class="btn-icon" @click="members = members.filter(m => m !== ledger)"><TrashIcon /></button>
</div>
</div>
<div v-if="availableLedgers.length" class="flex">
<LedgerSelect v-model="selectedLedger" class="grow" :ledgers="availableLedgers" />
<div class="flex justify-end me-4 ms-2">
<button class="btn-icon" @click="addMember"><PlusIcon /></button>
</div>
</div>
</div>
<div class="flex justify-end">
<button class="me-4" @click="save" :disabled="!canSave">Save</button>
</div>
</div>
</Modal>
</template>
<style scoped>
@reference "@/style.css";
button.switch {
@apply flex items-center px-4 rounded-md bg-slate-600;
&.active {
@apply bg-emerald-500;
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import {isCombined, Ledger, systemLedger} from "@/ledger/ledger.ts";
import {FolderOpenIcon} from '@heroicons/vue/24/outline';
import {RouterLink} from "vue-router";
import {routeNames} from "@/routes";
interface Props {
ledger: Ledger;
link?: boolean;
}
const props = defineProps<Props>();
</script>
<template>
<div class="flex">
<FolderOpenIcon v-if="isCombined(ledger)" class="w-4 me-1" />
<div v-else class="w-4 me-1"/>
<RouterLink v-if="link" :to="{name: routeNames.viewLedger, params: {ledgerId: ledger.ledgerId}}">{{ ledger.name }}</RouterLink>
<span v-else :class="{'system-ledger': ledger === systemLedger}">{{ ledger.name }}</span>
</div>
</template>
<style scoped>
@reference "@/style.css";
.system-ledger {
@apply text-emerald-400;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import {Ledger, systemLedger, useLedgersStore} from "@/ledger/ledger.ts";
import {computed} from "vue";
interface Props {
ledgers?: Ledger[];
}
const props = defineProps<Props>()
const ledger = defineModel<Ledger>();
const ledgersStore = useLedgersStore();
const ledgersToUse = computed(() => props.ledgers || ledgersStore.ledgers);
const ledgerId = computed({
get: () => ledger.value?.ledgerId,
set: value => ledger.value = ledgersToUse.value.find(l => l.ledgerId === value)
})
</script>
<template>
<select v-model="ledgerId" :class="{'system-ledger': ledger === systemLedger}">
<option v-for="l in ledgersToUse" :key="l.ledgerId" :value="l.ledgerId" :class="{'system-ledger': l === systemLedger}">{{ l.name }}</option>
</select>
</template>
<style scoped>
@reference "@/style.css";
.system-ledger {
@apply text-emerald-400;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import {describe, expect, test} from 'vitest';
import {appraiseItemBalances} from './balanceAppraisal';
import type {MarketTypePrice} from '@/market';
const price = (id: number, buy: number, sell: number): MarketTypePrice => ({
type: {id, name: `type-${id}`} as MarketTypePrice['type'],
buy,
sell,
orderCount: 1,
});
describe('appraiseItemBalances', () => {
test('sums quantity times buy and sell across stacks', () => {
const result = appraiseItemBalances(
[{typeId: 1, quantity: 2}, {typeId: 2, quantity: 3}],
[price(1, 10, 12), price(2, 100, 110)],
);
expect(result).toEqual({buy: 2 * 10 + 3 * 100, sell: 2 * 12 + 3 * 110});
});
test('ignores stacks with no resolved price', () => {
const result = appraiseItemBalances(
[{typeId: 1, quantity: 2}, {typeId: 99, quantity: 5}],
[price(1, 10, 12)],
);
expect(result).toEqual({buy: 20, sell: 24});
});
test('returns zero totals for no items', () => {
expect(appraiseItemBalances([], [])).toEqual({buy: 0, sell: 0});
});
});
+25
View File
@@ -0,0 +1,25 @@
import {ItemBalanceResponse} from "@/generated/mammon";
import {MarketTypePrice} from "@/market";
export interface ItemBalanceAppraisal {
buy: number;
sell: number;
}
export const appraiseItemBalances = (
itemBalances: ItemBalanceResponse[],
prices: MarketTypePrice[],
): ItemBalanceAppraisal => {
const priceByTypeId = new Map(prices.map(p => [p.type.id, p]));
return itemBalances.reduce<ItemBalanceAppraisal>((totals, {typeId, quantity}) => {
const price = priceByTypeId.get(typeId);
if (price) {
totals.buy += quantity * price.buy;
totals.sell += quantity * price.sell;
}
return totals;
}, {buy: 0, sell: 0});
};
+6
View File
@@ -0,0 +1,6 @@
export * from './ledger';
export * from './balanceAppraisal';
export {default as LedgerLabel} from './LedgerLabel.vue';
export {default as LedgerSelect} from './LedgerSelect.vue';
export {default as EditLedgerModal} from './EditLedgerModal.vue';
+91
View File
@@ -0,0 +1,91 @@
import {
BalanceResponse,
CombinedLedgerResponse,
CreateCombinedLedgerRequest,
CreateMainLedgerRequest,
LedgerResponse,
MainLedgerResponse,
TransactionResponse,
UpdateCombinedLedgerRequest,
UpdateMainLedgerRequest
} from "@/generated/mammon";
import {defineStore} from "pinia";
import {computed, ref, triggerRef} from "vue";
import {ledgerApi, transactionApi} from "@/mammon";
import {useRouteParams} from "@vueuse/router";
import {onActivitiesProcessed} from "@/activity";
export const LedgerTypes = {
Main: 'MAIN',
Combined: 'COMBINED',
};
export type LedgerType = LedgerResponse['type'];
export type MainLedger = MainLedgerResponse
export type CombinedLedger = CombinedLedgerResponse
export type Ledger = MainLedger | CombinedLedger;
export const systemLedgerRef = 'system';
export const systemLedger = {
type: LedgerTypes.Main,
ledgerId: "00000000-0000-0000-0000-000000000001",
name: "Eve Economy",
balance: 0,
_system: true
} as MainLedger;
export const isMain = (ledger: Ledger): ledger is MainLedger => {
return ledger.type === LedgerTypes.Main;
}
export const isCombined = (ledger: Ledger): ledger is CombinedLedger => {
return ledger.type === LedgerTypes.Combined;
}
export const useLedgersStore = defineStore('ledgers', () => {
const ledgers = ref<Ledger[]>([]);
const addLedger = (ledger: Ledger) => {
ledgers.value.push(ledger);
triggerRef(ledgers);
return ledger;
};
const replaceLedger = (ledger: Ledger) => {
const index = ledgers.value.findIndex(l => l.ledgerId === ledger.ledgerId);
if (index !== -1) {
ledgers.value[index] = ledger;
}
triggerRef(ledgers);
return ledger;
};
const findById = (ledgerId: string): Ledger | undefined => ledgers.value.find(l => l.ledgerId === ledgerId);
const findAllById = (ledgerIds: string[]): Ledger[] => ledgerIds.map(findById).filter((x): x is Ledger => x !== undefined)
const createMain = (ledger: CreateMainLedgerRequest) => ledgerApi.createMainLedger(ledger).then(response => addLedger(response.data as Ledger));
const createCombined = (ledger: CreateCombinedLedgerRequest) => ledgerApi.createCombinedLedger(ledger).then(response => addLedger(response.data as Ledger));
const updateMain = (ledgerId: string, ledger: UpdateMainLedgerRequest) => ledgerApi.updateMainLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
const updateCombined = (ledgerId: string, ledger: UpdateCombinedLedgerRequest) => ledgerApi.updateCombinedLedger(ledgerId, ledger).then(response => replaceLedger(response.data as Ledger));
const refresh = () => ledgerApi.findAllLedgers().then(response => ledgers.value = response.data as Ledger[]);
refresh();
onActivitiesProcessed(refresh);
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined};
})
const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId;
export const findAllTransactionInLeger = (ledger: Ledger | string): Promise<TransactionResponse[]> => transactionApi.finAllTransactionsInLedger(getLedgerId(ledger)).then(response => response.data)
export const getLedgerBalance = (ledger: Ledger | string): Promise<BalanceResponse> => ledgerApi.findBalanceByLedgerId(getLedgerId(ledger)).then(response => response.data)
export const useLedgerParam = () => {
const ledgersStore = useLedgersStore();
const ledgerId = useRouteParams<string, string>('ledgerId', '', { transform: v => typeof v === 'string' ? v : v[0]});
const ledger = computed(() => ledgersStore.findById(ledgerId.value))
return {ledgerId, ledger};
}
+8
View File
@@ -0,0 +1,8 @@
import log from "loglevel";
import { apply, reg } from "loglevel-plugin-prefix";
export function initLogger() {
log.setLevel(import.meta.env.VITE_LOG_LEVEL);
reg(log);
apply(log, {template: '[%t] %l:'});
}
+15 -10
View File
@@ -1,28 +1,33 @@
import { providePocketBase } from '@/pocketbase';
import {createPinia} from 'pinia'; import {createPinia} from 'pinia';
import {createApp} from 'vue'; import {createApp} from 'vue';
import {createRouter, createWebHistory} from 'vue-router'; import {createRouter, createWebHistory} from 'vue-router';
import App from './App.vue'; import App from './App.vue';
import { routes } from './routes'; import {useAuthStore} from './auth';
import {initLogger} from './logger';
import {routeNames, routes} from './routes';
import './style.css'; import './style.css';
initLogger();
const app = createApp(App); const app = createApp(App);
const pb = providePocketBase(app);
const pinia = createPinia(); const pinia = createPinia();
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes, routes,
}); });
router.beforeEach(async to => { app.use(pinia);
if (!pb.authStore.isValid && to.name !== 'login') {
return { name: 'login' }; const authStore = useAuthStore();
} else if (pb.authStore.isValid && to.name === 'login') {
return { name: 'home' }; await authStore.bootstrap();
router.beforeEach(to => {
if (!to.meta.public && !authStore.isAuthenticated) {
return {name: routeNames.home};
} }
return true;
}); });
app.use(pinia);
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
+1
View File
@@ -0,0 +1 @@
export * from './mammonService'
+119
View File
@@ -0,0 +1,119 @@
import {logResource} from "@/service";
import {getAccessToken, setAccessToken} from "@/auth/token";
import {toast} from "@/toast";
import axios, {InternalAxiosRequestConfig} from "axios";
import {
AcquisitionApi,
ActivityApi,
AuthApi,
CharacterApi,
CorporationApi,
LedgerApi,
LocationApi,
MarketApi,
MeResponse,
ReprocessingApi,
RuleBookApi,
RuleScriptApi,
TransactionApi
} from "@/generated/mammon";
export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
export const mammonLoginUrl = mammonUrl + "oauth2/authorization/esi"
const mammonAxiosInstance = axios.create({
baseURL: mammonUrl,
headers: {
'Accept': 'application/json',
"Content-Type": "application/json",
},
})
logResource(mammonAxiosInstance)
const credentialedClient = axios.create({
baseURL: mammonUrl,
withCredentials: true,
})
logResource(credentialedClient)
mammonAxiosInstance.interceptors.request.use(config => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
})
const authApi = new AuthApi(undefined, mammonUrl, mammonAxiosInstance);
const credentialedAuthApi = new AuthApi(undefined, mammonUrl, credentialedClient);
let onAuthExpired: () => void = () => {};
export const setOnAuthExpired = (callback: () => void): void => {
onAuthExpired = callback;
}
let refreshing: Promise<string | null> | null = null;
export const refreshAccessToken = (): Promise<string | null> => {
if (!refreshing) {
refreshing = credentialedAuthApi.refresh()
.then(response => {
setAccessToken(response.data.accessToken);
return response.data.accessToken;
})
.catch(() => {
setAccessToken(null);
return null;
})
.finally(() => {
refreshing = null;
});
}
return refreshing;
}
mammonAxiosInstance.interceptors.response.use(response => response, async error => {
const original = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
if (error.response?.status === 401 && original && !original._retried) {
original._retried = true;
const token = await refreshAccessToken();
if (token) {
original.headers.Authorization = `Bearer ${token}`;
return mammonAxiosInstance(original);
}
onAuthExpired();
}
const status = error.response?.status;
if (status !== 401 && status !== 403) {
toast.error('Something went wrong');
}
return Promise.reject(error);
})
export const fetchMe = (): Promise<MeResponse> =>
authApi.me().then(response => response.data);
export const addCharacter = (): Promise<void> =>
authApi.addCharacter({withCredentials: true}).then(() => undefined);
export const postLogout = (): Promise<void> =>
credentialedAuthApi.logout().then(() => undefined);
export const ledgerApi = new LedgerApi(undefined, mammonUrl, mammonAxiosInstance);
export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterApi = new CharacterApi(undefined, mammonUrl, mammonAxiosInstance);
export const corporationApi = new CorporationApi(undefined, mammonUrl, mammonAxiosInstance);
export const ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
export const ruleScriptApi = new RuleScriptApi(undefined, mammonUrl, mammonAxiosInstance);
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance);
export const locationApi = new LocationApi(undefined, mammonUrl, mammonAxiosInstance);
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import {formatIsk} from "@/formaters";
import {computed} from "vue";
interface Props {
amount: number;
colored?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
colored: true,
});
const computedClass = computed(() => {
if (!props.colored) {
return "";
}
return props.amount >= 0 ? 'text-emerald-400' : 'text-amber-700';
})
</script>
<template>
<span :class="computedClass">{{ formatIsk(amount) }}</span>
</template>
-13
View File
@@ -1,13 +0,0 @@
import { esiAxiosInstance } from "@/service";
export type MarketOrderHistory = {
average: number;
date: string;
highest: number;
lowest: number;
order_count: number;
volume: number;
}
export const getHistory = async (regionId: number, tyeId: number): Promise<MarketOrderHistory[]> => (await esiAxiosInstance.get(`/markets/${regionId}/history/`, { params: { type_id: tyeId } })).data;
+13
View File
@@ -0,0 +1,13 @@
import { MarketType } from "..";
import { RawAcquiredType } from "./acquisition";
export type AcquiredType = Omit<RawAcquiredType, 'type'> & {
type: MarketType,
buy: number,
sell: number
}
export const acquiredTypesToSorted = <T extends {date: Date} = AcquiredType>(array: T[], reverse?: boolean) => array.toSorted((a, b) => reverse ? b.date.getTime() - a.date.getTime() : a.date.getTime() - b.date.getTime())
export const toEveDatetimeInput = (date: Date) => date.toISOString().slice(0, 16)
export const fromEveDatetimeInput = (value: string) => new Date(`${value}Z`)
@@ -0,0 +1,86 @@
<script setup lang="ts">
import {LoadingSpinner, Tooltip} from '@/components';
import {formatIsk} from '@/formaters';
import {getQuartiles, type HistoryQuartiles, MarketTrendIcon} from '@/market';
import {computedAsync} from '@vueuse/core';
import {computed, ref} from 'vue';
interface Props {
id: number;
buy: number;
sell: number;
}
const props = defineProps<Props>();
const open = ref(false);
const loading = ref(false);
const quartiles = computedAsync<HistoryQuartiles | null>(
() => props.id ? getQuartiles(props.id) : null,
null,
loading,
);
const lineColor = computed(() => {
if (!quartiles.value) {
return '';
}
if (props.buy >= quartiles.value.q3) {
return 'line-blue';
}
if (props.sell >= quartiles.value.q3) {
return 'line-green';
}
return '';
});
</script>
<template>
<Tooltip v-model:open="open" class="tooltip">
<template #header>
<LoadingSpinner v-if="loading || !quartiles" />
<MarketTrendIcon v-else :trend="quartiles.trend" />
</template>
<template #default>
<div class="bg-slate-500 tooltip-content" v-if="quartiles">
<table>
<thead>
<tr>
<th>Q1</th>
<th>Median</th>
<th>Q3</th>
</tr>
</thead>
<tbody>
<tr :class="lineColor">
<td class="text-right text-nowrap">{{ formatIsk(quartiles.q1) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(quartiles.median) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(quartiles.q3) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</Tooltip>
</template>
<style scoped>
@reference "@/style.css";
.tooltip {
@apply ms-auto;
&.open {
&.tooltip-top>:deep(div.header) {
@apply rounded-t-md bg-slate-600;
}
&.tooltip-bottom>:deep(div.header) {
@apply rounded-b-md bg-slate-600;
}
}
}
.tooltip>:deep(div.header) {
@apply btn-icon px-2;
}
</style>
@@ -0,0 +1,287 @@
<script setup lang="ts">
import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {getListedSellTypeIds, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
import {useStorage} from '@vueuse/core';
import {computed, ref, watch} from 'vue';
import {AcquiredType} from './AcquiredType';
import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
import {Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
type Result = {
id: string;
type: MarketType;
name: string;
buy: number;
sell: number;
price: number;
remaining: number;
quantity: number;
precentProfit: number;
iskProfit: number;
date: Date;
ledger?: Ledger;
ledgerName: string;
acquisitions: AcquiredType[];
}
interface Props {
items?: AcquiredType[];
infoOnly?: boolean;
showAll?: boolean;
ignoredColumns?: string[] | string;
defaultSortKey?: string;
}
interface Emits {
(e: 'buy', type: AcquiredType[], price: number, buy: number, sell: number): void;
(e: 'sell', type: AcquiredType[]): void;
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
infoOnly: false,
showAll: false,
ignoredColumns: () => [],
defaultSortKey: 'precentProfit',
});
defineEmits<Emits>();
const columnsToIgnore = computed(() => {
const ignoredColumns = typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : [...props.ignoredColumns];
if (props.infoOnly && !ignoredColumns.includes('buttons')) {
ignoredColumns.push('buttons');
}
if (!props.showAll && !ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledger');
}
if (ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledgerName');
}
return ignoredColumns;
});
const marketTaxStore = useMarketTaxStore();
const ledgersStore = useLedgersStore();
const listedTypeIds = ref(new Set<number>());
watch(() => props.items, async () => {
listedTypeIds.value = await getListedSellTypeIds();
}, { immediate: true });
const threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref("");
const unlistedOnly = ref(false);
const matchesFilter = (r: AcquiredType) =>
r.type.name.toLowerCase().includes(filter.value.toLowerCase())
&& (!unlistedOnly.value || !listedTypeIds.value.has(r.type.id));
const results = computed<Result[]>(() => {
const filteredItems = props.items.filter(matchesFilter);
if (props.showAll) {
return filteredItems.map(r => {
const precentProfit = marketTaxStore.calculateProfit(r.price, r.sell);
return {
id: r.id,
type: r.type,
name: r.type.name,
buy: r.buy,
sell: r.sell,
price: r.price,
remaining: r.remaining,
quantity: r.quantity,
precentProfit,
iskProfit: r.price * precentProfit * r.remaining,
date: r.date,
ledger: ledgersStore.findById(r.ledgerId),
ledgerName: ledgersStore.findById(r.ledgerId)?.name ?? '',
acquisitions: [r]
};
});
}
const list: Result[] = [];
const groups = Map.groupBy(filteredItems, r => r.type.id);
groups.forEach((group, typeID) => {
const first = group[0];
if (!first) {
return;
}
const total = group.reduce((acc, r) => acc + r.quantity, 0);
const totalRemaining = group.reduce((acc, r) => acc + r.remaining, 0);
const price = group.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
list.push({
id: typeID.toString(),
type: first.type,
name: first.type.name,
buy: first.buy,
sell: first.sell,
price: price,
remaining: totalRemaining,
quantity: total,
precentProfit,
iskProfit: price * precentProfit * totalRemaining,
date: first.date,
ledgerName: '',
acquisitions: group
});
});
return list;
});
const { sortedArray, headerProps, showColumn } = useSort<Result>(results, {
defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc',
ignoredColumns: columnsToIgnore
})
const getLineColor = (result: Result) => {
if (result.precentProfit >= (threshold.value / 100)) {
return 'line-green';
} else if (result.precentProfit < 0) {
return 'line-red';
}
return '';
}
const total = computed(() => {
if (sortedArray.value.length <= 1) {
return null;
}
const first = sortedArray.value[0];
if (!first) {
return null;
}
const sameItem = sortedArray.value.every(r => r.type.id === first.type.id);
const quantity = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.quantity, 0) : 0;
const totalRemaining = sameItem ? sortedArray.value.reduce((acc, r) => acc + r.remaining, 0) : 0;
const price = sortedArray.value.reduce((acc, r) => acc + r.price * r.remaining, 0) / totalRemaining;
const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
const iskProfit = sortedArray.value.reduce((acc, r) => acc + r.iskProfit, 0);
return {
sameItem,
price,
remaining: totalRemaining,
quantity,
precentProfit,
iskProfit
};
});
</script>
<template>
<div class="flex" v-if="!infoOnly">
<div class="flex justify-self-end mb-2 mt-4 ms-auto">
<TaxInput />
<div class="end flex items-center" title="Show only acquisitions with no open sell order">
<SliderCheckbox class="me-1" v-model="unlistedOnly" /> Unlisted only
</div>
<div class="end">
<span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div>
<div class="end">
<span>Filter: </span>
<input type="search" class="w-96" v-model="filter" />
</div>
</div>
</div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="!!total ? 33 : 0" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="ledgerName">Ledger</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="date">Bought at</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="precentProfit">Profit (%)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="iskProfit">Profit (ISK)</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.index" :class="getLineColor(r.data)">
<td v-if="showColumn('name')">
<div class="flex">
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
<AcquisitionQuartilesTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
</div>
</td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
<td v-if="showColumn('ledger')">
<LedgerLabel v-if="r.data.ledger" :ledger="r.data.ledger" link />
</td>
<td v-if="showColumn('date')" class="text-right">{{ formatEveDate(r.data.date) }}</td>
<td v-if="showColumn('price')" class="text-right">{{ formatIsk(r.data.price) }}</td>
<td v-if="showColumn('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
<td v-if="showColumn('precentProfit')" class="text-right">{{ percentFormater.format(r.data.precentProfit) }}</td>
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(r.data.iskProfit) }}</td>
<td v-if="showColumn('buttons')" class="text-right">
<button class="btn-icon me-1" @click="$emit('buy', r.data.acquisitions, r.data.price, r.data.buy, r.data.sell)"><PlusIcon /></button>
<button class="btn-icon me-1" @click="$emit('sell', r.data.acquisitions)"><MinusIcon /></button>
</td>
</tr>
</tbody>
<tfoot v-if="!!total">
<tr>
<td v-if="showColumn('name')">Total</td>
<td v-if="showColumn('buy')">
<template v-if="!showColumn('name')">Total</template>
</td>
<td v-if="showColumn('sell')">
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
</td>
<td v-if="showColumn('ledger')">
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
</td>
<td v-if="showColumn('date')">
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell') && !showColumn('ledger')">Total</template>
</td>
<td v-if="showColumn('price')" class="text-right">
<template v-if="total.sameItem">
{{ formatIsk(total.price) }}
</template>
</td>
<td v-if="showColumn('remaining')" class="text-right">
<template v-if="total.sameItem">
{{ total.remaining }}/{{ total.quantity }}
</template>
</td>
<td v-if="showColumn('precentProfit')" class="text-right">
<template v-if="total.sameItem">
{{ percentFormater.format(total.precentProfit) }}
</template>
</td>
<td v-if="showColumn('iskProfit')" class="text-right">{{ formatIsk(total.iskProfit) }}</td>
<td v-if="showColumn('buttons')" />
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
<style scoped>
@reference "@/style.css";
div.end {
@apply justify-self-end ms-2;
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import {getMarketTypes, getPrices, MarketTypePrice} from "@/market";
import {ref, watch} from 'vue';
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
import {useAutoRefresh} from '@/composables';
import {MarketLocationInput, useMarketLocationStore} from '@/market/location';
import {AcquiredType} from './AcquiredType';
import {RawAcquiredType} from './acquisition';
import AcquisitionResultTable from './AcquisitionResultTable.vue';
import BuyModal from './BuyModal.vue';
import SellModal from './SellModal.vue';
interface Props {
items: RawAcquiredType[];
ignoredColumns?: string[] | string;
ledgerId?: string;
}
const props = defineProps<Props>();
const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>();
const marketLocationStore = useMarketLocationStore();
const enriched = ref<AcquiredType[]>([]);
const refresh = async (itms: RawAcquiredType[] = props.items) => {
if (itms.length === 0) {
enriched.value = [];
return;
}
const types = await getMarketTypes([...new Set(itms.map(i => i.type))]);
const prices = await getPrices(types, marketLocationStore.location.id);
enriched.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
return {
...i,
type: price.type,
buy: price.buy,
sell: price.sell
};
});
};
watch([() => props.items, () => marketLocationStore.location], ([itms]) => refresh(itms), {immediate: true});
const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(() => refresh(), 5 * 60 * 1000);
</script>
<template>
<div class="flex">
<div class="ms-auto flex items-center gap-2">
<MarketLocationInput v-model="marketLocationStore.location" />
<div class="flex">
<button class="rounded-e-none" @click="refresh()">Refresh</button>
<button class="rounded-s-none border-s-0 flex items-center" :class="{ 'bg-slate-700': autoRefresh }" title="Auto refresh" @click="toggleAutoRefresh">
<ArrowPathIcon class="w-5 h-5" :class="{ 'animate-spin': autoRefresh }" />
</button>
</div>
</div>
</div>
<template v-if="enriched.length > 0">
<AcquisitionResultTable :items="enriched" :ignoredColumns="ignoredColumns" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell }, props.ledgerId)" @sell="types => sellModal?.open(types, props.ledgerId)" />
<BuyModal ref="buyModal" />
<SellModal ref="sellModal" />
</template>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import {Modal} from '@/components';
import {formatIsk} from '@/formaters';
import {MarketType, MarketTypeLabel} from '@/market';
import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
import {computed, ref} from 'vue';
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType';
import {useAcquiredTypesStore} from './acquisition';
import {toast} from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore();
const ledgersStore = useLedgersStore();
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
const suggestions = ref<Record<string, number>>({});
const price = ref(1000000);
const count = ref(1);
const date = ref('');
const contextLedgerId = ref<string>();
const ledger = ref<Ledger>();
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
const open = (t: MarketType, s?: Record<string, number> | number, ledgerId?: string) => {
type.value = t;
count.value = 1;
date.value = toEveDatetimeInput(new Date());
contextLedgerId.value = ledgerId;
ledger.value = undefined;
if (typeof s === 'number') {
suggestions.value = {};
price.value = s;
} else if (s) {
suggestions.value = s;
price.value = Object.values(s)[0];
} else {
suggestions.value = {};
price.value = 1000000;
}
modalOpen.value = true;
}
const add = async () => {
const id = type.value?.id;
if (!id) {
modalOpen.value = false;
return;
}
const lid = ledgerId.value;
if (!lid) {
return;
}
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
modalOpen.value = false;
toast.success('Acquisition added');
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded">
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
<hr />
<div class="flex p-4">
<div class="flex me-2">
<span>Price: </span>
<div class="ms-2">
<input type="number" min="0" step="1" v-model="price" @keyup.enter="add" />
<div class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" v-for="(p, n) of suggestions" :key="n" @click="price = p">{{ n }}: {{ formatIsk(p) }}</div>
</div>
</div>
<div class="flex me-2 mb-auto">
<span>Count: </span>
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
</div>
<div class="flex me-2 mb-auto">
<span>Eve Time: </span>
<input class="ms-2" type="datetime-local" v-model="date" />
</div>
<div v-if="!contextLedgerId" class="flex me-2 mb-auto">
<span>Ledger: </span>
<LedgerSelect class="ms-2" v-model="ledger" :ledgers="mainLedgers" />
</div>
<button class="mb-auto" :disabled="!ledgerId" @click="add">Add</button>
</div>
</div>
</Modal>
</template>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { Modal } from '@/components';
import { MarketType, MarketTypeLabel } from '@/market';
import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
import { computed, ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType';
import { useAcquiredTypesStore } from './acquisition';
import { toast } from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore();
const ledgersStore = useLedgersStore();
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
const modalOpen = ref<boolean>(false);
const type = ref<MarketType>();
const count = ref(1);
const date = ref('');
const types = ref<AcquiredType[]>([]);
const contextLedgerId = ref<string>();
const ledger = ref<Ledger>();
const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
const open = (t: AcquiredType[], ledgerId?: string) => {
if (t.length === 0) {
return;
}
types.value = acquiredTypesToSorted(t);
type.value = t[0].type;
count.value = 1;
date.value = toEveDatetimeInput(new Date());
contextLedgerId.value = ledgerId;
ledger.value = undefined;
modalOpen.value = true;
}
const remove = async () => {
if (types.value.length === 0) {
modalOpen.value = false;
return;
}
const lid = ledgerId.value;
if (!lid) {
return;
}
const datetime = fromEveDatetimeInput(date.value);
let remaining = count.value;
for (const t of types.value) {
if (remaining <= 0) {
break;
}
const quantity = Math.min(remaining, t.remaining);
await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime);
remaining -= quantity;
}
modalOpen.value = false;
toast.success('Consumption added');
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="modalOpen">
<div class="bg-slate-800 rounded">
<MarketTypeLabel v-if="type" class="m-1" :id="type.id" :name="type.name" hideCopy />
<hr />
<div class="flex p-4">
<div class="flex me-2 mb-auto">
<span>Count: </span>
<div class="ms-2">
<input type="number" min="0" step="1" v-model="count" @keyup.enter="remove" />
<div>
<button class="px-2 mt-2 bg-slate-600 hover:bg-slate-700 border rounded cursor-pointer" @click="count = types.reduce((acc, t) => acc + t.remaining, 0)">All</button>
</div>
</div>
</div>
<div class="flex me-2 mb-auto">
<span>Eve Time: </span>
<input class="ms-2" type="datetime-local" v-model="date" />
</div>
<div v-if="!contextLedgerId" class="flex me-2 mb-auto">
<span>Ledger: </span>
<LedgerSelect class="ms-2" v-model="ledger" :ledgers="mainLedgers" />
</div>
<button class="mb-auto" :disabled="!ledgerId" @click="remove">Remove</button>
</div>
</div>
</Modal>
</template>
+71
View File
@@ -0,0 +1,71 @@
import {defineStore} from "pinia";
import {computed, ref} from "vue";
import {acquisitionApi, activityApi} from "@/mammon";
import {AcquisitionResponse, ActivitySourceResponse} from "@/generated/mammon";
import {onActivitiesProcessed} from "@/activity";
export type RawAcquiredType = {
id: string;
ledgerId: string;
type: number;
quantity: number;
remaining: number;
price: number;
date: Date;
source: ActivitySourceResponse;
}
export const toAcquiredType = (a: AcquisitionResponse): RawAcquiredType => ({
id: a.acquisitionId,
ledgerId: a.ledgerId,
type: a.marketTypeId,
quantity: a.quantity,
remaining: a.remaining,
price: a.unitCost,
date: new Date(a.datetime),
source: a.source,
});
export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
const acquiredTypes = ref<RawAcquiredType[]>([]);
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
const addAcquiredType = (type: number, quantity: number, price: number, ledgerId: string, datetime: Date) =>
activityApi.acquire({
source: { type: 'LEDGER', characterId: null, ledgerId },
marketTypeId: type,
quantity,
unitPrice: price,
datetime: datetime.toISOString(),
taxes: null,
description: null,
});
const removeAcquiredType = (id: string, quantity: number, ledgerId: string, datetime: Date) => {
const acquisition = acquiredTypes.value.find(a => a.id === id);
if (!acquisition) {
return Promise.resolve();
}
return activityApi.consume({
source: { type: 'LEDGER', characterId: null, ledgerId },
marketTypeId: acquisition.type,
quantity,
unitPrice: 0,
datetime: datetime.toISOString(),
taxes: null,
description: null,
});
};
const refresh = () => acquisitionApi.findAllAcquisitions()
.then(response => acquiredTypes.value = response.data.map(toAcquiredType));
refresh();
onActivitiesProcessed(refresh);
return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
});
+8
View File
@@ -0,0 +1,8 @@
export * from './AcquiredType';
export * from './acquisition';
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
export { default as AcquisitionsPanel } from './AcquisitionsPanel.vue';
export { default as BuyModal } from './BuyModal.vue';
export { default as SellModal } from './SellModal.vue';
-43
View File
@@ -1,43 +0,0 @@
import { evepraisalAxiosInstance } from '@/service';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { MarketType } from "./type";
export type MarketTypePrice = {
type: MarketType;
buy: number,
sell: number
}
type MarketTypePriceCache = {
price: MarketTypePrice,
date: Date
}
const cacheDuration = 1000 * 60 * 5; // 5 minutes
export const useApraisalStore = defineStore('appraisal', () => {
const cache = ref<Record<number, MarketTypePriceCache>>({});
const getPricesUncached = async (types: MarketType[]): Promise<MarketTypePrice[]> => (await evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.map(t => t.name).join("%0A")}`)).data.appraisal.items.map((item: any) => ({
type: types.find(t => t.name === item.typeName),
buy: item.prices.buy.max,
sell: item.prices.sell.min
}));
const getPrice = async (type: MarketType): Promise<MarketTypePrice> => (await getPrices([type]))[0];
const getPrices = async (types: MarketType[]): Promise<MarketTypePrice[]> => {
const now = new Date().getTime();
const cached = types.map(t => cache.value[t.id]).filter(c => c && now - c.date.getTime() < cacheDuration);
const uncached = types.filter(t => !cached.find(c => c.price.type.id === t.id));
if (uncached.length > 0) {
const prices = await getPricesUncached(uncached);
prices.forEach(p => cache.value[p.type.id] = { price: p, date: new Date() });
return [...cached.map(c => c.price), ...prices];
}
return cached.map(c => c.price);
};
return { getPrice, getPrices };
});
@@ -0,0 +1,64 @@
<script setup lang="ts">
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketTypeLabel} from '@/market/type';
import {computed} from 'vue';
import IskLabel from '../IskLabel.vue';
import {AppraisalItemValue} from './appraise';
interface Props {
result?: AppraisalItemValue[];
}
const props = withDefaults(defineProps<Props>(), {
result: () => []
});
const { sortedArray, headerProps } = useSort(computed(() => props.result), {
defaultSortKey: 'sell',
defaultSortDirection: 'desc'
});
const totalBuy = computed(() => props.result.reduce((sum, r) => sum + r.buy, 0));
const totalSell = computed(() => props.result.reduce((sum, r) => sum + r.sell, 0));
</script>
<template>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="quantity">Qty</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buyUnit">Buy/unit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sellUnit">Sell/unit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy total</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell total</SortableHeader>
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.data.typeID">
<td>
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
</td>
<td class="text-right">{{ r.data.quantity.toLocaleString() }}</td>
<td class="text-right"><IskLabel :amount="r.data.buyUnit" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.sellUnit" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.buy" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.sell" :colored="false" /></td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="font-semibold">Total</td>
<td class="text-right"><IskLabel :amount="totalBuy" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="totalSell" :colored="false" /></td>
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketTypeLabel} from '@/market/type';
import {computed} from 'vue';
import {MarketLocation} from '../location';
import IskLabel from '../IskLabel.vue';
import {ComparedItem} from './compare';
interface Props {
result?: ComparedItem[];
locations?: MarketLocation[];
}
const props = withDefaults(defineProps<Props>(), {
result: () => [],
locations: () => []
});
const buyKey = (id: number): `buy:${number}` => `buy:${id}`;
const sellKey = (id: number): `sell:${number}` => `sell:${id}`;
type SortableItem = ComparedItem & Record<`buy:${number}` | `sell:${number}`, number>;
const rows = computed<SortableItem[]>(() => props.result.map(item => {
const sortable = {...item} as SortableItem;
props.locations.forEach(l => {
const price = item.prices[l.id];
sortable[buyKey(l.id)] = price?.buy ?? 0;
sortable[sellKey(l.id)] = price?.sell ?? 0;
});
return sortable;
}));
const {sortedArray, headerProps} = useSort(rows, {
defaultSortKey: 'name',
defaultSortDirection: 'asc'
});
const bestBuy = (row: ComparedItem): number | undefined => {
let best: number | undefined;
let bestValue = -Infinity;
props.locations.forEach(l => {
const cell = row.prices[l.id];
if (cell && cell.buy > bestValue) {
bestValue = cell.buy;
best = l.id;
}
});
return bestValue > 0 ? best : undefined;
};
const bestSell = (row: ComparedItem): number | undefined => {
let best: number | undefined;
let bestValue = -Infinity;
props.locations.forEach(l => {
const cell = row.prices[l.id];
if (cell && cell.sell > bestValue) {
bestValue = cell.sell;
best = l.id;
}
});
return bestValue > 0 ? best : undefined;
};
const totals = computed(() => new Map(props.locations.map(l => [l.id, props.result.reduce((acc, row) => {
const cell = row.prices[l.id];
return {
buy: acc.buy + (cell?.buy ?? 0),
sell: acc.sell + (cell?.sell ?? 0),
};
}, {buy: 0, sell: 0})])));
</script>
<template>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :headerHeight="66" :footerHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name" rowspan="2">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="quantity" rowspan="2">Qty</SortableHeader>
<th v-for="location in locations" :key="location.id" colspan="2" class="text-center border-l border-slate-500" :title="location.name">
{{ location.systemName }}
</th>
</tr>
<tr>
<template v-for="location in locations" :key="location.id">
<SortableHeader v-bind="headerProps" :sortKey="buyKey(location.id)" class="border-l border-slate-500">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" :sortKey="sellKey(location.id)">Sell</SortableHeader>
</template>
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.data.typeId">
<td>
<MarketTypeLabel :id="r.data.typeId" :name="r.data.name" />
</td>
<td class="text-right">{{ r.data.quantity.toLocaleString() }}</td>
<template v-for="location in locations" :key="location.id">
<td class="text-right border-l border-slate-500" :class="r.data.prices[location.id] ? (bestBuy(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].buy" :colored="false" />
<span v-else>-</span>
</td>
<td class="text-right" :class="r.data.prices[location.id] ? (bestSell(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].sell" :colored="false" />
<span v-else>-</span>
</td>
</template>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="font-semibold">Total</td>
<template v-for="location in locations" :key="location.id">
<td class="text-right border-l border-slate-500"><IskLabel :amount="totals.get(location.id)?.buy ?? 0" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="totals.get(location.id)?.sell ?? 0" :colored="false" /></td>
</template>
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
+11
View File
@@ -0,0 +1,11 @@
import { MarketType } from "../type";
export type MarketTypePrice = {
type: MarketType;
buy: number;
sell: number;
orderCount: number;
};
export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
+17
View File
@@ -0,0 +1,17 @@
import {MarketType} from "../type";
import {MarketTypePrice} from './MarketTypePrice';
import {getMammonPrices} from './mammon';
const BATCH_SIZE = 100;
export const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
export const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const batches: Promise<MarketTypePrice[]>[] = [];
for (let i = 0; i < types.length; i += BATCH_SIZE) {
batches.push(getMammonPrices(types.slice(i, i + BATCH_SIZE), locationId));
}
return (await Promise.all(batches)).flat();
};
+32
View File
@@ -0,0 +1,32 @@
import {marketApi} from '@/mammon';
import {getMarketTypes} from '../type';
export type AppraisalItemValue = {
typeID: number;
name: string;
quantity: number;
buy: number;
sell: number;
buyUnit: number;
sellUnit: number;
};
export const appraise = async (raw?: string, locationId?: number): Promise<AppraisalItemValue[]> => {
if (!raw?.trim()) {
return [];
}
const results = await marketApi.pastePrices(raw, locationId).then(r => r.data);
const names = await getMarketTypes(results.map(r => r.marketTypeId))
.then(types => new Map(types.map(t => [t.id, t.name])));
return results.map(r => ({
typeID: r.marketTypeId,
name: names.get(r.marketTypeId) ?? '',
quantity: r.quantity,
buy: r.buy,
sell: r.sell,
buyUnit: r.quantity ? r.buy / r.quantity : 0,
sellUnit: r.quantity ? r.sell / r.quantity : 0,
}));
};
+66
View File
@@ -0,0 +1,66 @@
import {marketApi} from '@/mammon';
import {getMarketTypes, MarketType} from '../type';
import {MarketTypePrice} from './MarketTypePrice';
export type LocationPrice = {
buyUnit: number;
sellUnit: number;
buy: number;
sell: number;
};
export type ComparedItem = {
typeId: number;
name: string;
quantity: number;
volume: number;
prices: Record<number, LocationPrice>;
};
export type PricesForLocation = (types: MarketType[], locationId: number) => Promise<MarketTypePrice[]>;
export const compareAppraisal = async (
raw: string,
locationIds: number[],
getPrices: PricesForLocation
): Promise<ComparedItem[]> => {
if (!raw?.trim() || locationIds.length === 0) {
return [];
}
const stacks = await marketApi.parseStacks(raw).then(r => r.data);
if (stacks.length === 0) {
return [];
}
const quantities = new Map(stacks.map(s => [s.marketTypeId, s.quantity]));
const types = await getMarketTypes(stacks.map(s => s.marketTypeId));
const rows = new Map<number, ComparedItem>(types.map(t => [t.id, {
typeId: t.id,
name: t.name,
quantity: quantities.get(t.id) ?? 0,
volume: t.volume,
prices: {},
}]));
const pricesByLocation = await Promise.all(
locationIds.map(async locationId => [locationId, await getPrices(types, locationId)] as const)
);
pricesByLocation.forEach(([locationId, prices]) => prices.forEach(p => {
const row = rows.get(p.type.id);
if (row) {
row.prices[locationId] = {
buyUnit: p.buy,
sellUnit: p.sell,
buy: p.buy * row.quantity,
sell: p.sell * row.quantity,
};
}
}));
return [...rows.values()];
};
+7
View File
@@ -0,0 +1,7 @@
export * from './MarketTypePrice';
export * from './appraisal';
export * from './appraise';
export * from './compare';
export { default as AppraisalResultTable } from './AppraisalResultTable.vue';
export { default as CompareResultTable } from './CompareResultTable.vue';
+20
View File
@@ -0,0 +1,20 @@
import {marketApi} from '@/mammon/mammonService';
import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
export const getMammonPrices: PriceGetter = async (types, locationId) => {
if (types.length === 0) {
return [];
}
const typesById = new Map(types.map(t => [t.id, t]));
const response = await marketApi.currentPrices(types.map(t => t.id), locationId);
return response.data.reduce<MarketTypePrice[]>((prices, p) => {
const type = typesById.get(p.marketTypeId);
if (type) {
prices.push({ type, buy: p.buy, sell: p.sell, orderCount: p.orderCount });
}
return prices;
}, []);
};
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import {type MarketTrend, MarketTrends} from '@/market';
import {ArrowLongRightIcon, ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
interface Props {
trend: MarketTrend;
}
defineProps<Props>();
</script>
<template>
<ArrowTrendingUpIcon v-if="trend === MarketTrends.Up" />
<ArrowTrendingDownIcon v-else-if="trend === MarketTrends.Down" />
<ArrowLongRightIcon v-else />
</template>
<style scoped>
@reference "@/style.css";
svg {
@apply w-6 h-6;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import {type MarketTrend, MarketTrends} from '@/market';
import {ArrowLongRightIcon, ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
const model = defineModel<MarketTrend[]>({ required: true });
const trends = [
{ trend: MarketTrends.Up, icon: ArrowTrendingUpIcon, title: 'Show items trending up' },
{ trend: MarketTrends.Flat, icon: ArrowLongRightIcon, title: 'Show items with a flat trend' },
{ trend: MarketTrends.Down, icon: ArrowTrendingDownIcon, title: 'Show items trending down' },
] as const;
const toggle = (trend: MarketTrend) => {
model.value = model.value.includes(trend)
? model.value.filter(t => t !== trend)
: [...model.value, trend];
};
</script>
<template>
<div class="trend-filter">
<button
v-for="{ trend, icon, title } in trends"
:key="trend"
type="button"
:title="title"
:class="{ active: model.includes(trend) }"
@click="toggle(trend)"
>
<component :is="icon" />
</button>
</div>
</template>
<style scoped>
@reference "@/style.css";
div.trend-filter {
@apply inline-flex rounded overflow-hidden border border-slate-600;
}
div.trend-filter button {
@apply p-0.5 border-none rounded-none bg-slate-600 text-slate-100 hover:bg-slate-700 cursor-pointer;
}
div.trend-filter button.active {
@apply bg-emerald-500 hover:bg-emerald-600;
}
div.trend-filter button :deep(svg) {
@apply w-6 h-6;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import {
HistoryQuartilesResponse,
HistoryQuartilesResponseTrendEnum,
MarketScanResponseTrendEnum
} from "@/generated/mammon";
import {marketApi} from "@/mammon";
export type HistoryQuartiles = HistoryQuartilesResponse;
export type MarketTrend = MarketScanResponseTrendEnum | HistoryQuartilesResponseTrendEnum;
export const MarketTrends = MarketScanResponseTrendEnum as const;
export const getQuartiles = async (typeId: number, days?: number): Promise<HistoryQuartiles> => (await marketApi.findQuartiles(typeId, days)).data;
+4
View File
@@ -0,0 +1,4 @@
export * from './history.ts';
export { default as MarketTrendIcon } from './MarketTrendIcon.vue';
export { default as TrendFilter } from './TrendFilter.vue';
+6 -2
View File
@@ -1,6 +1,10 @@
export * from './history';
export * from './location';
export * from './order';
export * from './tax';
export * from './type'; export * from './type';
export * from './MarketOrderHistory';
export * from './appraisal'; export * from './appraisal';
export * from './market';
export { default as IskLabel } from './IskLabel.vue';
+30
View File
@@ -0,0 +1,30 @@
import {locationApi} from '@/mammon/mammonService';
import type {DockableLocationResponse} from '@/generated/mammon';
export type MarketLocation = DockableLocationResponse;
const cache = new Map<number, MarketLocation>();
const defaultHubIds = [
60003760, // Jita IV - Moon 4 - Caldari Navy Assembly Plant
60008494, // Amarr VIII (Oris) - Emperor Family Academy
60011866, // Dodixie IX - Moon 20 - Federation Navy Assembly Plant
60004588, // Rens VI - Moon 8 - Brutor Tribe Treasury
60005686, // Hek VIII - Moon 12 - Boundless Creation Factory
];
const hubRank = (id: number) => {
const rank = defaultHubIds.indexOf(id);
return rank < 0 ? defaultHubIds.length : rank;
};
export const isDefaultHub = (id: number): boolean => defaultHubIds.includes(id);
export const searchMarketLocations = async (search: string): Promise<MarketLocation[]> => {
if (search.length === 0) {
return [];
}
const locations = await locationApi.searchLocations(search).then(r => r.data);
locations.forEach(l => cache.set(l.id, l));
return locations.toSorted((a, b) => hubRank(a.id) - hubRank(b.id));
};
@@ -0,0 +1,46 @@
<script setup lang="ts">
import {watchDebounced} from '@vueuse/core';
import {ref, watch} from 'vue';
import {SelectInput} from '@/components';
import {isDefaultHub, MarketLocation, searchMarketLocations} from './MarketLocation';
const modelValue = defineModel<MarketLocation>();
const search = ref(modelValue.value?.name ?? '');
const suggestions = ref<MarketLocation[]>([]);
watch(() => modelValue.value, v => {
search.value = v?.name ?? '';
});
watchDebounced(search, async value => {
const term = value.trim();
if (term.length < 3 || term === modelValue.value?.name) {
suggestions.value = [];
} else {
suggestions.value = await searchMarketLocations(term);
}
}, {debounce: 300});
</script>
<template>
<SelectInput v-model="modelValue" :items="suggestions" class="w-96">
<template #input>
<input type="text" v-model="search" placeholder="Search a market location…" />
</template>
<template #item="{ item }">
<div class="px-1 whitespace-nowrap overflow-hidden text-ellipsis" :class="{'text-emerald-400': isDefaultHub(item.id)}">
{{ item.name }} <span class="text-slate-300">({{ item.regionName }})</span>
</div>
</template>
</SelectInput>
</template>
<style scoped>
@reference "@/style.css";
input {
@apply w-full border-none bg-transparent block focus-visible:outline-none;
box-sizing: border-box;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export * from './location';
export * from './MarketLocation';
export { default as MarketLocationInput } from './MarketLocationInput.vue';
+18
View File
@@ -0,0 +1,18 @@
import {useLocalStorage} from "@vueuse/core";
import {defineStore} from "pinia";
import type {MarketLocation} from "./MarketLocation";
const defaultLocation: MarketLocation = {
id: 60003760,
name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant',
systemId: 30000142,
systemName: 'Jita',
regionId: 10000002,
regionName: 'The Forge',
};
export const useMarketLocationStore = defineStore("marketLocation", () => {
const location = useLocalStorage<MarketLocation>("market-location", defaultLocation);
return {location};
});
-3
View File
@@ -1,3 +0,0 @@
export const jitaId = 10000002;
+1
View File
@@ -0,0 +1 @@
export * from './order';
+7
View File
@@ -0,0 +1,7 @@
import {marketApi} from "@/mammon";
import {MarketOrderResponseDirectionEnum} from "@/generated/mammon";
export const getListedSellTypeIds = (): Promise<Set<number>> => marketApi.findAllMarketOrders()
.then(response => new Set(response.data
.filter(o => o.direction === MarketOrderResponseDirectionEnum.Sell)
.map(o => o.marketTypeId)));
-59
View File
@@ -1,59 +0,0 @@
import { MarketOrderHistory } from "@/market";
export type HistoryQuartils = {
totalVolume: number,
q1: number,
median: number,
q3: number,
}
export const getHistoryQuartils = (history: MarketOrderHistory[], days?: number): HistoryQuartils => {
const now = Date.now();
const volumes = history
.flatMap(h => {
const volume = h.volume;
if (volume === 0 || (days && new Date(h.date).getTime() < now - days * 24 * 60 * 60 * 1000)) {
return [];
}
const e = estimateVolume(h);
return [[h.highest, e], [h.lowest, volume - e]];
})
.filter(h => h[1] > 0)
.sort((a, b) => a[0] - b[0]);
const totalVolume = volumes.reduce((acc, [_, v]) => acc + v, 0);
const quartilVolume = totalVolume / 4;
const quartils: [number, number, number] = [0, 0, 0];
let currentVolume = 0;
let quartil = 0;
for (const [price, volume] of volumes) {
currentVolume += volume;
if (currentVolume >= quartilVolume * (quartil + 1)) {
quartils[quartil] = price;
if (quartil === 2) {
break;
}
quartil++;
}
}
return {
totalVolume,
q1: quartils[0],
median: quartils[1],
q3: quartils[2],
};
}
const estimateVolume = (history: MarketOrderHistory): number => {
if (history.volume === 0) {
return 0;
}
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
}
+90 -44
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatIsk, percentFormater } from '@/formaters'; import {SliderCheckbox} from '@/components';
import { MarketType, MarketTypeLabel } from "@/market"; import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import { SortableHeader, useSort } from '@/table'; import {formatIsk, percentFormater} from "@/formaters";
import { ShoppingCartIcon } from '@heroicons/vue/24/outline'; import {MarketTrend, MarketTrendIcon, MarketTrends, MarketType, MarketTypeLabel, TrendFilter} from "@/market";
import {useStorage} from '@vueuse/core'; import {useStorage} from '@vueuse/core';
import {computed, ref} from 'vue'; import {computed, ref} from 'vue';
import { ScanResult, getHistoryQuartils } from '.'; import {useAcquiredTypesStore} from '../acquisition';
import {ScanResult} from './scan';
type Result = { type Result = {
type: MarketType; type: MarketType;
@@ -14,31 +15,54 @@ type Result = {
buy: number; buy: number;
sell: number; sell: number;
q1: number; q1: number;
mmedian: number; median: number;
q3: number; q3: number;
totalVolume: number;
acquisitions: number;
profit: number; profit: number;
score: number;
trend: MarketTrend;
} }
interface Props { interface Props {
items?: ScanResult[]; items?: ScanResult[];
infoOnly?: boolean;
ignoredColumns?: string[] | string;
} }
interface Emits { const scoreFormater = new Intl.NumberFormat("en-US", {
(e: 'buy', type: MarketType, buy: number, sell: number): void; maximumFractionDigits: 0
} });
const volumeFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0
});
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
items: () => [] items: () => [],
infoOnly: false,
ignoredColumns: () => []
}); });
defineEmits<Emits>();
const days = useStorage('market-scan-days', 365); const acquiredTypesStore = useAcquiredTypesStore();
const threshold = useStorage('market-scan-threshold', 10); const threshold = useStorage('market-scan-threshold', 10);
const filter = ref(""); const filter = ref("");
const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items const onlyCheap = ref(false);
const trackedTrends = useStorage<MarketTrend[]>('market-scan-trends', [
MarketTrends.Up,
MarketTrends.Flat,
MarketTrends.Down,
]);
const columnsToIgnore = computed(() =>
typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : props.ignoredColumns
);
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase())) .filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.filter(r => trackedTrends.value.includes(r.trend))
.map(r => { .map(r => {
const quartils = getHistoryQuartils(r.history, days.value); const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
.filter(t => t.type === r.type.id)
.reduce((a, b) => a + b.remaining, 0);
return { return {
type: r.type, type: r.type,
@@ -46,19 +70,26 @@ const { sortedArray, headerProps } = useSort<Result>(computed(() => props.items
name: r.type.name, name: r.type.name,
buy: r.buy, buy: r.buy,
sell: r.sell, sell: r.sell,
q1: quartils.q1, q1: r.q1,
mmedian: quartils.median, median: r.median,
q3: quartils.q3, q3: r.q3,
profit: (quartils.q3 / quartils.q1) -1 totalVolume: r.totalVolume,
acquisitions,
profit: r.profit,
score: r.score,
trend: r.trend,
}; };
})), { }).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'name', defaultSortKey: 'score',
defaultSortDirection: 'asc' defaultSortDirection: 'desc',
ignoredColumns: columnsToIgnore
}) })
const getLineColor = (result: Result) => { const getLineColor = (result: Result) => {
if (result.profit < (threshold.value / 100)) { if (props.infoOnly) {
return '';
} else if (result.profit < (threshold.value / 100)) {
return 'line-red'; return 'line-red';
} else if (result.sell <= result.q1) { } else if (result.sell > 0 && result.sell <= result.q1) {
return 'line-blue'; return 'line-blue';
} else if (result.buy <= result.q1) { } else if (result.buy <= result.q1) {
return 'line-green'; return 'line-green';
@@ -68,23 +99,26 @@ const getLineColor = (result: Result) => {
</script> </script>
<template> <template>
<div class="flex"> <div v-if="!infoOnly" class="flex mb-2 mt-4">
<div class="flex justify-self-end mb-2 mt-4 ms-auto"> <div class="flex justify-self-end ms-auto">
<div class="end"> <div class="end">
<span>Profit Threshold: </span> <span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" /> <input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div> </div>
<div class="end"> <div class="end flex">
<span>Days: </span> <SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
<input type="number" min="1" max="365" step="1" v-model="days" /> </div>
<div class="end flex items-center">
<TrendFilter v-model="trackedTrends" />
</div> </div>
<div class="end"> <div class="end">
<span>Filter: </span> <span>Filter: </span>
<input type="search" class="w-96" v-model="filter" > <input type="search" class="w-96" v-model="filter" />
</div> </div>
</div> </div>
</div> </div>
<table> <VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }">
<thead> <thead>
<tr> <tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
@@ -93,30 +127,42 @@ const getLineColor = (result: Result) => {
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="totalVolume">Volume</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
<th></th> <SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in sortedArray" :key="r.typeID" :class="getLineColor(r)"> <tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
<td> <td v-if="showColumn('name')">
<MarketTypeLabel :id="r.typeID" :name="r.name" /> <div class="flex">
</td> <MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
<td class="text-right">{{ formatIsk(r.buy) }}</td> <MarketTrendIcon class="ms-auto" :trend="r.data.trend" />
<td class="text-right">{{ formatIsk(r.sell) }}</td> </div>
<td class="text-right">{{ formatIsk(r.q1) }}</td>
<td class="text-right">{{ formatIsk(r.mmedian) }}</td>
<td class="text-right">{{ formatIsk(r.q3) }}</td>
<td class="text-right">{{ percentFormater.format(r.profit) }}</td>
<td class="text-right">
<button class="btn-icon-stroke me-1" @click="$emit('buy', r.type, r.buy, r.sell)"><ShoppingCartIcon /></button>
</td> </td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</td>
<td v-if="showColumn('sell')" class="text-right">{{ formatIsk(r.data.sell) }}</td>
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
<td v-if="showColumn('totalVolume')" class="text-right">{{ volumeFormater.format(r.data.totalVolume) }}</td>
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td>
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template> </template>
<style scoped> <style scoped>
@reference "@/style.css";
div.end { div.end {
@apply justify-self-end ms-2; @apply justify-self-end ms-2;
} }
-2
View File
@@ -1,5 +1,3 @@
export * from './HistoryQuartils';
export * from './scan'; export * from './scan';
export { default as ScanResultTable } from './ScanResultTable.vue'; export { default as ScanResultTable } from './ScanResultTable.vue';
+22 -37
View File
@@ -1,43 +1,28 @@
import { MarketOrderHistory, MarketType } from "@/market"; import {MarketTrend, MarketType} from "@/market";
import { usePocketBase, watchCollection } from "@/pocketbase"; import {MarketScanResponse} from "@/generated/mammon";
import { defineStore } from "pinia";
import { RecordModel } from "pocketbase";
import { computed, onMounted, ref } from "vue";
export type ScanResult = { export type ScanResult = {
type: MarketType; type: MarketType;
history: MarketOrderHistory[]; buy: number;
buy: number, sell: number;
sell: number q1: number;
median: number;
q3: number;
totalVolume: number;
profit: number;
score: number;
trend: MarketTrend;
} }
interface MarketScan extends RecordModel { export const toScanResult = (res: MarketScanResponse, type: MarketType): ScanResult => ({
owner: string; type,
types: number[]; buy: res.buy,
}; sell: res.sell,
q1: res.q1,
const marketScans = 'marketScans'; median: res.median,
q3: res.q3,
export const useMarkeyScanStore = defineStore(marketScans, () => { totalVolume: res.totalVolume,
const pb = usePocketBase(); profit: res.profit,
const marketScan = ref<MarketScan>(); score: res.score,
trend: res.trend,
const types = computed(() => marketScan.value?.types ?? []);
const setTypes = async (types: number[]) => {
if (marketScan.value?.id) {
marketScan.value = await pb.collection(marketScans).update(marketScan.value.id, { owner: pb.authStore.model!.id, types });
} else {
marketScan.value = await pb.collection(marketScans).create({ owner: pb.authStore.model!.id, types });
}
}
watchCollection<MarketScan>(marketScans, '*', data => {
if (data.action === 'delete') {
marketScan.value = undefined;
} else if (!marketScan.value || data.record.id === marketScan.value.id) {
marketScan.value = data.record;
}
});
onMounted(async () => marketScan.value = await pb.collection(marketScans).getFirstListItem<MarketScan>('').catch(() => undefined));
return { types, setTypes };
}); });
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import {useMarketTaxStore} from "./tax";
const marketTaxStore = useMarketTaxStore();
</script>
<template>
<div class="end">
<span>Broker Fee: </span>
<input type="number" min="1" max="3" step="0.01" v-model="marketTaxStore.brokerFee" />
</div>
<div class="end">
<span>SCC: </span>
<input type="number" min="3.375" max="8" step="0.01" v-model="marketTaxStore.scc" >
</div>
</template>
<style scoped>
@reference "@/style.css";
div.end {
@apply justify-self-end ms-2;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
export * from './tax';
export { default as TaxInput } from './TaxInput.vue';
+12
View File
@@ -0,0 +1,12 @@
import { useLocalStorage } from "@vueuse/core";
import { defineStore } from "pinia";
export const useMarketTaxStore = defineStore("marketTax", () => {
const brokerFee = useLocalStorage("market-brokerFee", 1.5);
const scc = useLocalStorage("market-scc", 3.375);
const applyTaxes = (price: number, sellOrder?: boolean) => sellOrder ? price * (1 - (brokerFee.value + scc.value) / 100) : price * (1 + brokerFee.value / 100);
const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
return { brokerFee, scc, applyTaxes, calculateProfit };
});

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