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
124 changed files with 8384 additions and 2318 deletions
+1929 -300
View File
File diff suppressed because it is too large Load Diff
+229 -170
View File
@@ -14,13 +14,15 @@
"@vueuse/integrations": "^14.3.0", "@vueuse/integrations": "^14.3.0",
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.3.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"axios-rate-limit": "^1.3.1", "chart.js": "^4.5.1",
"gemory": "file:", "gemory": "file:",
"loglevel": "^1.8.1", "loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4", "loglevel-plugin-prefix": "^0.8.4",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-chartjs": "^5.3.4",
"vue-router": "^5.0.7" "vue-router": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
@@ -229,14 +231,20 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -532,49 +540,49 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0", "enhanced-resolve": "5.21.6",
"jiti": "^2.6.1", "jiti": "^2.7.0",
"lightningcss": "1.32.0", "lightningcss": "1.32.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.3.0" "tailwindcss": "4.3.1"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/oxide": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-android-arm64": "4.3.1",
"@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.1",
"@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.1",
"@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0" "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -589,9 +597,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -606,9 +614,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -623,9 +631,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -640,9 +648,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -657,9 +665,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -677,9 +685,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -697,9 +705,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -717,9 +725,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -737,9 +745,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@@ -759,7 +767,7 @@
"@emnapi/runtime": "^1.10.0", "@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1", "@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4", "@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1", "@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
@@ -767,9 +775,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -784,9 +792,9 @@
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -801,15 +809,15 @@
} }
}, },
"node_modules/@tailwindcss/vite": { "node_modules/@tailwindcss/vite": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tailwindcss/node": "4.3.0", "@tailwindcss/node": "4.3.1",
"@tailwindcss/oxide": "4.3.0", "@tailwindcss/oxide": "4.3.1",
"tailwindcss": "4.3.0" "tailwindcss": "4.3.1"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
@@ -857,15 +865,22 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.1", "version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.21", "version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -1059,13 +1074,13 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
"integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.3", "@babel/parser": "^7.29.7",
"@vue/shared": "3.5.35", "@vue/shared": "3.5.38",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
@@ -1078,26 +1093,26 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.35", "@vue/compiler-core": "3.5.38",
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
"integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.3", "@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.35", "@vue/compiler-core": "3.5.38",
"@vue/compiler-dom": "3.5.35", "@vue/compiler-dom": "3.5.38",
"@vue/compiler-ssr": "3.5.35", "@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.35", "@vue/shared": "3.5.38",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.15", "postcss": "^8.5.15",
@@ -1111,13 +1126,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
"integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.35", "@vue/compiler-dom": "3.5.38",
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@@ -1154,9 +1169,9 @@
} }
}, },
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "3.3.3", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.3.tgz", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.5.tgz",
"integrity": "sha512-X6p+7nfY7vVT6dQwUJ+v0Jfq/lwIfhL2jMi91dQ3ln4hnlGXlxsDu/FNkeyHYgvYtyQy18ZX76IZy7X4diDbiQ==", "integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1170,53 +1185,53 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
"integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
"integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.35", "@vue/reactivity": "3.5.38",
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
"integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.35", "@vue/reactivity": "3.5.38",
"@vue/runtime-core": "3.5.35", "@vue/runtime-core": "3.5.38",
"@vue/shared": "3.5.35", "@vue/shared": "3.5.38",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.35", "@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.35" "vue": "3.5.38"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/components": { "node_modules/@vueuse/components": {
@@ -1353,9 +1368,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -1433,9 +1448,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.16.1", "version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
@@ -1444,18 +1459,6 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/axios-rate-limit": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.9.0.tgz",
"integrity": "sha512-p79ltBSUF+8i4uLYr1lu8s28chIaQ8x/oiV3Vb6NDkNAqkKD/AzTAchRPEXK+weRyAkRTIa/GowKcKDn1BHRPQ==",
"license": "MIT",
"dependencies": {
"axios": ">=0.18.0"
},
"peerDependencies": {
"axios": "*"
}
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -1488,6 +1491,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -1585,6 +1600,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1600,9 +1624,9 @@
} }
}, },
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.22.1", "version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1741,16 +1765,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -2265,6 +2289,18 @@
"url": "https://github.com/sponsors/sxzz" "url": "https://github.com/sponsors/sxzz"
} }
}, },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2330,6 +2366,16 @@
"pathe": "^2.0.1" "pathe": "^2.0.1"
} }
}, },
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2361,15 +2407,18 @@
} }
}, },
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.1", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://github.com/sponsors/sxzz", "https://github.com/sponsors/sxzz",
"https://opencollective.com/debug" "https://opencollective.com/debug"
], ],
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=12.20.0"
}
}, },
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
@@ -2610,9 +2659,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2738,9 +2787,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.15", "version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.15.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-qpgllRxrLqwsMAGRdLhsEr9bepaOQk1rxH1xT2coBXLaEB/bfkqQj1j7RMxwMfnYrvO1ZnFMiwX+wBVgnsyn0g==", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2926,16 +2975,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.35", "version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.35", "@vue/compiler-dom": "3.5.38",
"@vue/compiler-sfc": "3.5.35", "@vue/compiler-sfc": "3.5.38",
"@vue/runtime-dom": "3.5.35", "@vue/runtime-dom": "3.5.38",
"@vue/server-renderer": "3.5.35", "@vue/server-renderer": "3.5.38",
"@vue/shared": "3.5.35" "@vue/shared": "3.5.38"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"
@@ -2946,6 +2995,16 @@
} }
} }
}, },
"node_modules/vue-chartjs": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
@@ -3029,14 +3088,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.3.3", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.3.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.5.tgz",
"integrity": "sha512-SWUEG7YRUeDJHT7Xsuhf02elYX2gxPzzAII7OxDAh4KNOr4QHQ0Lls0YfnaO5GNd560CwVa2HTfdqmA5MqvRqQ==", "integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@volar/typescript": "2.4.28", "@volar/typescript": "2.4.28",
"@vue/language-core": "3.3.3" "@vue/language-core": "3.3.5"
}, },
"bin": { "bin": {
"vue-tsc": "bin/vue-tsc.js" "vue-tsc": "bin/vue-tsc.js"
+3 -1
View File
@@ -16,13 +16,15 @@
"@vueuse/integrations": "^14.3.0", "@vueuse/integrations": "^14.3.0",
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.3.0",
"axios": "^1.4.0", "axios": "^1.4.0",
"axios-rate-limit": "^1.3.1", "chart.js": "^4.5.1",
"gemory": "file:", "gemory": "file:",
"loglevel": "^1.8.1", "loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4", "loglevel-plugin-prefix": "^0.8.4",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-chartjs": "^5.3.4",
"vue-router": "^5.0.7" "vue-router": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
+18 -1
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed} from 'vue'; 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'; import {routeNames} from '@/routes';
const route = useRoute(); const route = useRoute();
@@ -9,6 +13,17 @@ const route = useRoute();
const hideSidebar = computed(() => { const hideSidebar = computed(() => {
return route.name === routeNames.callback || route.name === routeNames.about; 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>
@@ -21,6 +36,8 @@ const hideSidebar = computed(() => {
<RouterView /> <RouterView />
</div> </div>
</template> </template>
<ConfirmModal />
<ToastContainer />
</template> </template>
<style scoped> <style scoped>
+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;
};
+2 -4
View File
@@ -1,4 +1,4 @@
import {activityApi, characterApi} from "@/mammon"; import {characterApi} from "@/mammon";
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {ref} from "vue"; import {ref} from "vue";
import {CharacterResponse} from "@/generated/mammon"; import {CharacterResponse} from "@/generated/mammon";
@@ -18,11 +18,9 @@ export const useCharactersStore = defineStore('characters', () => {
return character; return character;
} }
const reloadActivities = (characterId: number): Promise<void> => activityApi.fetchNewActivitiesForCharacter(characterId) as Promise<void>;
const refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data); const refresh = () => characterApi.findAllCharacters().then(response => characters.value = response.data);
refresh(); refresh();
return {characters, findById, reloadActivities, refresh}; return {characters, findById};
}) })
+4 -5
View File
@@ -14,7 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
autoClose: true autoClose: true
}) })
const isOpen = ref(false); const isOpen = defineModel<boolean>('open', {default: false});
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null); const floating = ref<HTMLElement | null>(null);
@@ -41,12 +41,11 @@ useEventListener('keyup', e => {
<template> <template>
<div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]"> <div ref="root" class="dropdown" :class="{'dropdown-open': isOpen, 'dropdown-close': !isOpen}" v-on-click-outside="[doAutoClose, { ignore: [floating] }]">
<button @click="isOpen = !isOpen" class="cursor-pointer"> <button @click="isOpen = !isOpen" class="flex items-center cursor-pointer border-none bg-transparent hover:bg-transparent p-0">
<Transition <Transition
mode="out-in"
enter-active-class="transition-transform" enter-active-class="transition-transform"
enter-from-class="rotate-180" enter-from-class="rotate-180">
leave-active-class="hidden"
leave-to-class="rotate-180">
<ChevronDownIcon v-if="!isOpen" class="chevron" /> <ChevronDownIcon v-if="!isOpen" class="chevron" />
<ChevronUpIcon v-else class="chevron" /> <ChevronUpIcon v-else class="chevron" />
</Transition> </Transition>
+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>
+10 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T"> <script setup lang="ts" generic="T">
import {vOnClickOutside} from '@vueuse/components'; import {vOnClickOutside} from '@vueuse/components';
import {useVirtualList} from '@vueuse/core'; import {useVirtualList} from '@vueuse/core';
import {computed, ref} from 'vue'; import {computed, ref, watch} from 'vue';
interface Props { interface Props {
items: T[]; items: T[];
@@ -15,6 +15,12 @@ const props = withDefaults(defineProps<Props>(), {
const modelValue = defineModel<T>(); const modelValue = defineModel<T>();
const isOpen = ref(false); const isOpen = ref(false);
watch(() => props.items, items => {
if (items.length > 0) {
isOpen.value = true;
}
});
const currentIndex = ref(-1); const currentIndex = ref(-1);
const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), { const {list, scrollTo, containerProps, wrapperProps} = useVirtualList(computed(() => props.items), {
itemHeight: () => props.itemHeight, itemHeight: () => props.itemHeight,
@@ -44,15 +50,15 @@ const submit = () => {
</script> </script>
<template> <template>
<div @click="isOpen = true" v-on-click-outside="() => isOpen = false"> <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"> <div class="fake-input" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp">
<slot name="input" :value="modelValue" /> <slot name="input" :value="modelValue" />
</div> </div>
<div v-if="isOpen && items.length" class="z-20 absolute"> <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="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps"> <div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index" <div v-for="s in list" :key="s.index"
class="hover:bg-slate-700 cursor-pointer" class="hover:bg-slate-700 cursor-pointer overflow-hidden"
:class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'" :class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'"
@click.stop="select(s.data)"> @click.stop="select(s.data)">
<slot name="item" :item="s.data" /> <slot name="item" :item="s.data" />
+4 -3
View File
@@ -12,7 +12,8 @@ const modelValue = defineModel({ default: false });
<style scoped> <style scoped>
@reference "@/style.css"; @reference "@/style.css";
input:checked ~ span:last-child {
--tw-translate-x: 1.25rem; input:checked ~ span:last-child {
} transform: translateX(1.25rem);
}
</style> </style>
+1
View File
@@ -1,5 +1,6 @@
export { default as ClipboardButton } from './ClipboardButton.vue'; export { default as ClipboardButton } from './ClipboardButton.vue';
export { default as Dropdown } from './Dropdown.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 LoadingSpinner } from './LoadingSpinner.vue';
export { default as Modal } from './Modal.vue'; export { default as Modal } from './Modal.vue';
export { default as ProgressBar } from './ProgressBar.vue'; export { default as ProgressBar } from './ProgressBar.vue';
+1 -1
View File
@@ -37,7 +37,7 @@ describe('useSort', () => {
}) })
test('Hides ignored columns', () => { test('Hides ignored columns', () => {
const { showColumn } = useSort(array, { ignoredColums: ['key1'] }) const { showColumn } = useSort(array, { ignoredColumns: ['key1'] })
expect(showColumn('key1')).toBe(false) expect(showColumn('key1')).toBe(false)
}) })
+2 -2
View File
@@ -5,7 +5,7 @@ export type SortDirection = "asc" | "desc";
export type UseSortOptions = { export type UseSortOptions = {
defaultSortKey?: string; defaultSortKey?: string;
defaultSortDirection?: SortDirection; defaultSortDirection?: SortDirection;
ignoredColums?: MaybeRefOrGetter<string[]>; ignoredColumns?: MaybeRefOrGetter<string[]>;
headerComponent?: HeaderComponent; headerComponent?: HeaderComponent;
}; };
@@ -16,7 +16,7 @@ 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?.ignoredColums)?.includes(sortKey); const showColumn = (sortKey: string) => !toValue(options?.ignoredColumns)?.includes(sortKey);
const headerProps = computed(() => ({ const headerProps = computed(() => ({
onSort: sortBy, onSort: sortBy,
showColumn, showColumn,
+31 -18
View File
@@ -8,20 +8,27 @@ const open = defineModel('open', { default: false });
const { width, height } = useSharedWindowSize(); const { width, height } = useSharedWindowSize();
const mainDiv = ref<HTMLDivElement | null>(null); const mainDiv = ref<HTMLDivElement | null>(null);
const { top, left } = useElementBounding(mainDiv); const { top, bottom, left, right } = useElementBounding(mainDiv);
const positions = computed(() => { const positions = computed(() => [
if (top.value < height.value / 2) { top.value < height.value / 2 ? 'top' : 'bottom',
if (left.value < width.value / 2) { left.value < width.value / 2 ? 'left' : 'right',
return ['top', 'left']; ]);
}
return ['top', '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 (left.value < width.value / 2) { if (positions.value.includes('left')) {
return ['bottom', 'left']; style.left = `${left.value}px`;
} else {
style.right = `${width.value - right.value}px`;
} }
return ['bottom', 'right']; return style;
}) });
</script> </script>
<template> <template>
@@ -35,12 +42,18 @@ const positions = computed(() => {
<div v-element-hover="(h: boolean) => open = h" class="m-auto header"> <div v-element-hover="(h: boolean) => open = h" class="m-auto header">
<slot name="header" /> <slot name="header" />
</div> </div>
<div v-if="open" class="m-auto"> <Teleport to="body">
<div class="z-10 relative"> <div v-if="open" class="tooltip-floating" :style="floatingStyle">
<div class="absolute"> <slot />
<slot />
</div>
</div> </div>
</div> </Teleport>
</div> </div>
</template> </template>
<style scoped>
@reference "@/style.css";
.tooltip-floating {
@apply fixed z-30;
}
</style>
+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';
+3275 -390
View File
File diff suppressed because it is too large Load Diff
+14 -19
View File
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, ref} from "vue"; import {computed, ref} from "vue";
import {storeToRefs} from "pinia";
import {isCombined, Ledger, LedgerType, LedgerTypes, useLedgersStore} from "./ledger"; import {isCombined, Ledger, LedgerType, LedgerTypes, useLedgersStore} from "./ledger";
import {Modal} from "@/components"; import {Modal} from "@/components";
import LedgerLabel from "./LedgerLabel.vue"; import LedgerLabel from "./LedgerLabel.vue";
import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline'; import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline';
import LedgerSelect from "@/ledger/LedgerSelect.vue"; import LedgerSelect from "@/ledger/LedgerSelect.vue";
import {toast} from "@/toast";
interface Props { interface Props {
ledgerId?: string; ledgerId?: string;
@@ -15,8 +15,6 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const ledgersStore = useLedgersStore(); const ledgersStore = useLedgersStore();
const {ledgers} = storeToRefs(ledgersStore);
const {findById, findAllById, createMain, createCombined, updateMain, updateCombined} = ledgersStore;
const modalOpen = ref<boolean>(false); const modalOpen = ref<boolean>(false);
@@ -24,8 +22,8 @@ const type = ref<LedgerType>(LedgerTypes.Main);
const name = ref(""); const name = ref("");
const members = ref<Ledger[]>([]); const members = ref<Ledger[]>([]);
const selectedLedger = ref<Ledger>(); const selectedLedger = ref<Ledger>();
const availableLedgers = computed(() => ledgers.value const availableLedgers = computed(() => ledgersStore.ledgers
.filter((ledger) => selectedLedger.value === ledger) .filter(l => l.ledgerId !== props.ledgerId)
.filter(l => !members.value.includes(l))); .filter(l => !members.value.includes(l)));
@@ -37,12 +35,12 @@ const addMember = () => {
} }
const open = () => { const open = () => {
const ledger = isCreating.value ? undefined : findById(props.ledgerId); const ledger = isCreating.value ? undefined : ledgersStore.findById(props.ledgerId);
if (ledger) { if (ledger) {
type.value = ledger.type; type.value = ledger.type;
name.value = ledger.name; name.value = ledger.name;
members.value = isCombined(ledger) ? findAllById(ledger.memberLedgerIds) : []; members.value = isCombined(ledger) ? ledgersStore.findAllById(ledger.memberLedgerIds) : [];
} else { } else {
type.value = LedgerTypes.Main; type.value = LedgerTypes.Main;
name.value = ""; name.value = "";
@@ -62,18 +60,16 @@ const title = computed(() => {
const create = () => { const create = () => {
if (type.value === LedgerTypes.Main) { if (type.value === LedgerTypes.Main) {
createMain({name: name.value}) return ledgersStore.createMain({name: name.value})
} else {
createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
} }
return ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
} }
const update = () => { const update = () => {
if (type.value === LedgerTypes.Main) { if (type.value === LedgerTypes.Main) {
updateMain(props.ledgerId, {name: name.value}) return ledgersStore.updateMain(props.ledgerId, {name: name.value})
} else {
updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
} }
return ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
} }
const save = () => { const save = () => {
@@ -81,12 +77,11 @@ const save = () => {
return; return;
} }
if (isCreating.value) { const creating = isCreating.value;
create(); const action = creating ? create() : update();
} else {
update();
}
modalOpen.value = false; modalOpen.value = false;
action.then(() => toast.success(creating ? 'Ledger created' : 'Ledger updated'));
} }
defineExpose({ open }); defineExpose({ open });
@@ -98,7 +93,7 @@ defineExpose({ open });
<span class="m-2">{{ title }}</span> <span class="m-2">{{ title }}</span>
<hr /> <hr />
<div class="mt-4"> <div class="mt-4">
<div class="flex justify-center"> <div v-if="!ledgerId" class="flex justify-center">
<div class="flex bg-slate-600 rounded-s-md p-1"> <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> <button class="switch" :class="{active: type === LedgerTypes.Main}" @click="type = LedgerTypes.Main">Main</button>
</div> </div>
+10 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import {isCombined, Ledger} from "@/ledger/ledger.ts"; import {isCombined, Ledger, systemLedger} from "@/ledger/ledger.ts";
import {FolderOpenIcon} from '@heroicons/vue/24/outline'; import {FolderOpenIcon} from '@heroicons/vue/24/outline';
import {RouterLink} from "vue-router"; import {RouterLink} from "vue-router";
import {routeNames} from "@/routes"; import {routeNames} from "@/routes";
@@ -18,6 +18,14 @@ const props = defineProps<Props>();
<FolderOpenIcon v-if="isCombined(ledger)" class="w-4 me-1" /> <FolderOpenIcon v-if="isCombined(ledger)" class="w-4 me-1" />
<div v-else 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> <RouterLink v-if="link" :to="{name: routeNames.viewLedger, params: {ledgerId: ledger.ledgerId}}">{{ ledger.name }}</RouterLink>
<span v-else>{{ ledger.name }}</span> <span v-else :class="{'system-ledger': ledger === systemLedger}">{{ ledger.name }}</span>
</div> </div>
</template> </template>
<style scoped>
@reference "@/style.css";
.system-ledger {
@apply text-emerald-400;
}
</style>
+2 -3
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import {Ledger, systemLedger, useLedgersStore} from "@/ledger/ledger.ts"; import {Ledger, systemLedger, useLedgersStore} from "@/ledger/ledger.ts";
import {storeToRefs} from "pinia";
import {computed} from "vue"; import {computed} from "vue";
interface Props { interface Props {
@@ -9,9 +8,9 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const ledger = defineModel<Ledger>(); const ledger = defineModel<Ledger>();
const {ledgers: allLedgers} = storeToRefs(useLedgersStore()); const ledgersStore = useLedgersStore();
const ledgersToUse = computed(() => props.ledgers || allLedgers); const ledgersToUse = computed(() => props.ledgers || ledgersStore.ledgers);
const ledgerId = computed({ const ledgerId = computed({
get: () => ledger.value?.ledgerId, get: () => ledger.value?.ledgerId,
set: value => ledger.value = ledgersToUse.value.find(l => l.ledgerId === value) set: value => ledger.value = ledgersToUse.value.find(l => l.ledgerId === value)
+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});
};
+1
View File
@@ -1,4 +1,5 @@
export * from './ledger'; export * from './ledger';
export * from './balanceAppraisal';
export {default as LedgerLabel} from './LedgerLabel.vue'; export {default as LedgerLabel} from './LedgerLabel.vue';
export {default as LedgerSelect} from './LedgerSelect.vue'; export {default as LedgerSelect} from './LedgerSelect.vue';
+15 -11
View File
@@ -1,12 +1,10 @@
import { import {
BalanceResponse, BalanceResponse,
CombinedLedgerResponse, CombinedLedgerResponse,
CombinedLedgerResponseTypeEnum,
CreateCombinedLedgerRequest, CreateCombinedLedgerRequest,
CreateMainLedgerRequest, CreateMainLedgerRequest,
LedgerResponseTypeEnum, LedgerResponse,
MainLedgerResponse, MainLedgerResponse,
MainLedgerResponseTypeEnum,
TransactionResponse, TransactionResponse,
UpdateCombinedLedgerRequest, UpdateCombinedLedgerRequest,
UpdateMainLedgerRequest UpdateMainLedgerRequest
@@ -15,18 +13,22 @@ import {defineStore} from "pinia";
import {computed, ref, triggerRef} from "vue"; import {computed, ref, triggerRef} from "vue";
import {ledgerApi, transactionApi} from "@/mammon"; import {ledgerApi, transactionApi} from "@/mammon";
import {useRouteParams} from "@vueuse/router"; import {useRouteParams} from "@vueuse/router";
import {onActivitiesProcessed} from "@/activity";
export const LedgerTypes = LedgerResponseTypeEnum; export const LedgerTypes = {
Main: 'MAIN',
Combined: 'COMBINED',
};
export type LedgerType = LedgerResponseTypeEnum; export type LedgerType = LedgerResponse['type'];
export type MainLedger = MainLedgerResponse & {type: MainLedgerResponseTypeEnum} export type MainLedger = MainLedgerResponse
export type CombinedLedger = CombinedLedgerResponse & {type: CombinedLedgerResponseTypeEnum} export type CombinedLedger = CombinedLedgerResponse
export type Ledger = MainLedger | CombinedLedger; export type Ledger = MainLedger | CombinedLedger;
export const systemLedgerRef = 'system'; export const systemLedgerRef = 'system';
export const systemLedger = { export const systemLedger = {
type: LedgerTypes.Main, type: LedgerTypes.Main,
ledgerId: "", ledgerId: "00000000-0000-0000-0000-000000000001",
name: "Eve Economy", name: "Eve Economy",
balance: 0, balance: 0,
_system: true _system: true
@@ -70,7 +72,9 @@ export const useLedgersStore = defineStore('ledgers', () => {
refresh(); refresh();
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined, refresh}; onActivitiesProcessed(refresh);
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined};
}) })
const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId; const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId;
@@ -79,9 +83,9 @@ export const findAllTransactionInLeger = (ledger: Ledger | string): Promise<Tran
export const getLedgerBalance = (ledger: Ledger | string): Promise<BalanceResponse> => ledgerApi.findBalanceByLedgerId(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 = () => { export const useLedgerParam = () => {
const {findById} = useLedgersStore(); const ledgersStore = useLedgersStore();
const ledgerId = useRouteParams<string, string>('ledgerId', '', { transform: v => typeof v === 'string' ? v : v[0]}); const ledgerId = useRouteParams<string, string>('ledgerId', '', { transform: v => typeof v === 'string' ? v : v[0]});
const ledger = computed(() => findById(ledgerId.value)) const ledger = computed(() => ledgersStore.findById(ledgerId.value))
return {ledgerId, ledger}; return {ledgerId, ledger};
} }
+19 -7
View File
@@ -1,9 +1,10 @@
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 { initLogger } from './logger'; import {useAuthStore} from './auth';
import { routes } from './routes'; import {initLogger} from './logger';
import {routeNames, routes} from './routes';
import './style.css'; import './style.css';
initLogger(); initLogger();
@@ -16,6 +17,17 @@ const router = createRouter({
}); });
app.use(pinia); app.use(pinia);
app.use(router);
app.mount('#app'); const authStore = useAuthStore();
await authStore.bootstrap();
router.beforeEach(to => {
if (!to.meta.public && !authStore.isAuthenticated) {
return {name: routeNames.home};
}
return true;
});
app.use(router);
app.mount('#app');
+94 -6
View File
@@ -1,17 +1,25 @@
import {logResource} from "@/service"; import {logResource} from "@/service";
import axios from "axios"; import {getAccessToken, setAccessToken} from "@/auth/token";
import {toast} from "@/toast";
import axios, {InternalAxiosRequestConfig} from "axios";
import { import {
AcquisitionApi,
ActivityApi, ActivityApi,
AuthApi,
CharacterApi, CharacterApi,
CharacterRuleBookApi, CorporationApi,
LedgerApi, LedgerApi,
ProcessingApi, LocationApi,
MarketApi,
MeResponse,
ReprocessingApi,
RuleBookApi, RuleBookApi,
RuleScriptApi,
TransactionApi TransactionApi
} from "@/generated/mammon"; } from "@/generated/mammon";
export const mammonUrl = import.meta.env.VITE_MAMMON_URL; export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
export const mammonAddCharacterUrl = mammonUrl + "oauth2/authorization/esi" export const mammonLoginUrl = mammonUrl + "oauth2/authorization/esi"
const mammonAxiosInstance = axios.create({ const mammonAxiosInstance = axios.create({
baseURL: mammonUrl, baseURL: mammonUrl,
@@ -22,10 +30,90 @@ const mammonAxiosInstance = axios.create({
}) })
logResource(mammonAxiosInstance) 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 ledgerApi = new LedgerApi(undefined, mammonUrl, mammonAxiosInstance);
export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance); export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterApi = new CharacterApi(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 ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterRuleBookApi = new CharacterRuleBookApi(undefined, mammonUrl, mammonAxiosInstance); export const ruleScriptApi = new RuleScriptApi(undefined, mammonUrl, mammonAxiosInstance);
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance); export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
export const processingApi = new ProcessingApi(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);
+14 -2
View File
@@ -1,13 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import {formatIsk} from "@/formaters"; import {formatIsk} from "@/formaters";
import {computed} from "vue";
interface Props { interface Props {
amount: number; amount: number;
colored?: boolean;
} }
const { amount } = defineProps<Props>(); 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> </script>
<template> <template>
<span :class="amount >= 0 ? 'text-emerald-400' : 'text-amber-700'">{{ formatIsk(amount) }}</span> <span :class="computedClass">{{ formatIsk(amount) }}</span>
</template> </template>
-35
View File
@@ -1,35 +0,0 @@
import { describe, expect, test } from 'vitest'
import { RegionalMarketCache } from './RegionalMarketCache'
describe('RegionalMarketCache', () => {
test('should cache and retrieve values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
expect(cache.get(1, 1)).toBe('test')
})
test('should remove values', async () => {
const cache = new RegionalMarketCache<string>(1000)
cache.set(1, 1, 'test')
cache.remove(1, 1)
expect(cache.get(1, 1)).toBeUndefined()
})
test('should compute values if absent', async () => {
const cache = new RegionalMarketCache<string>(1000)
const value = await cache.computeIfAbsent(1, 1, () => Promise.resolve('test'))
expect(value).toBe('test')
expect(cache.get(1, 1)).toBe('test')
})
test('should expire values', async () => {
const cache = new RegionalMarketCache<string>(1)
cache.set(1, 1, 'test')
await new Promise(resolve => setTimeout(resolve, 10))
expect(cache.get(1, 1)).toBeUndefined()
})
})
-50
View File
@@ -1,50 +0,0 @@
class CacheEntry<T> {
public value: T;
public expiration: Date;
constructor(value: T, expiration: Date) {
this.value = value;
this.expiration = expiration;
}
}
export type ExpirationSupplier<T> = (v: T) => Date;
export class RegionalMarketCache<T> {
private cache: Record<number, Record<number, CacheEntry<T>>>;
private expirationSupplier: (v: T) => Date;
constructor(expiration: ExpirationSupplier<T> | number) {
this.cache = {};
this.expirationSupplier = expiration instanceof Function ? expiration : () => new Date(Date.now() + expiration);
}
public get(regionId: number, typeId: number): T | undefined {
const entry = this.cache[regionId]?.[typeId];
if (entry && entry.expiration > new Date()) {
return entry.value;
}
this.remove(regionId, typeId);
return undefined;
}
public set(regionId: number, typeId: number, value: T): void {
this.cache[regionId] = this.cache[regionId] ?? {};
this.cache[regionId][typeId] = new CacheEntry(value, this.expirationSupplier(value));
}
public remove(regionId: number, typeId: number): void {
delete this.cache[regionId]?.[typeId];
}
public async computeIfAbsent(regionId: number, typeId: number, supplier: () => (Promise<T> | T)): Promise<T> {
let value = this.get(regionId, typeId);
if (!value) {
value = await supplier();
this.set(regionId, typeId, value);
}
return value;
}
};
+6 -3
View File
@@ -1,10 +1,13 @@
import { MarketType } from ".."; import { MarketType } from "..";
import { MarbasAcquiredType } from "./acquisition"; import { RawAcquiredType } from "./acquisition";
export type AcquiredType = Omit<MarbasAcquiredType, 'type'> & { export type AcquiredType = Omit<RawAcquiredType, 'type'> & {
type: MarketType, type: MarketType,
buy: number, buy: number,
sell: 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 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`)
@@ -1,99 +0,0 @@
<script setup lang="ts">
import {LoadingSpinner, Tooltip} from '@/components';
import {formatIsk} from '@/formaters';
import {getHistory, getHistoryQuartils} from '@/market';
import {ArrowTrendingDownIcon, ArrowTrendingUpIcon} from '@heroicons/vue/24/outline';
import {computedAsync} from '@vueuse/core';
import {ref, watchEffect} from 'vue';
const trendingScale = 3;
interface Props {
id: number;
buy: number;
sell: number;
}
const props = defineProps<Props>();
const open = ref(false);
const q1 = ref(0);
const median = ref(0);
const q3 = ref(0);
const lineColor = ref('');
const history = computedAsync(() => getHistory(props.id), []);
watchEffect(async () => {
if (!open.value || !props.id) {
return;
}
const quartils = getHistoryQuartils(history.value);
q1.value = quartils.q1;
median.value = quartils.median;
q3.value = quartils.q3;
if (props.buy >= quartils.q3) {
lineColor.value = 'line-blue';
} else if (props.sell >= quartils.q3) {
lineColor.value = 'line-green';
} else {
lineColor.value = '';
}
})
</script>
<template>
<Tooltip v-model:open="open" class="tooltip">
<template #header>
<LoadingSpinner v-if="history.length < trendingScale" />
<ArrowTrendingUpIcon v-else-if="history[0].average > history[trendingScale - 1].average" />
<ArrowTrendingDownIcon v-else />
</template>
<template #default>
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="history.length > 0">
<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(q1) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(median) }}</td>
<td class="text-right text-nowrap">{{ formatIsk(q3) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</Tooltip>
</template>
<style scoped>
@reference "@/style.css";
.tooltip {
@apply ms-auto;
>:deep(div.header) {
@apply btn-icon px-2;
}
&.open {
&.tooltip-top>:deep(div.header) {
@apply rounded-t-md bg-slate-600;
}
&.tooltip-bottom {
.tooltip-content {
bottom: 79px;
}
>:deep(div.header) {
@apply rounded-b-md bg-slate-600;
}
}
}
}
</style>
@@ -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>
@@ -1,14 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table'; import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market"; import {getListedSellTypeIds, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline'; import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
import {useStorage} from '@vueuse/core'; import {useStorage} from '@vueuse/core';
import {computed, ref} from 'vue'; import {computed, ref, watch} from 'vue';
import {AcquiredType} from './AcquiredType'; import {AcquiredType} from './AcquiredType';
import AcquisitionQuantilsTooltip from './AcquisitionQuantilsTooltip.vue'; import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
import {Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
type Result = { type Result = {
id: number; id: string;
type: MarketType; type: MarketType;
name: string; name: string;
buy: number; buy: number;
@@ -19,6 +22,8 @@ type Result = {
precentProfit: number; precentProfit: number;
iskProfit: number; iskProfit: number;
date: Date; date: Date;
ledger?: Ledger;
ledgerName: string;
acquisitions: AcquiredType[]; acquisitions: AcquiredType[];
} }
@@ -26,7 +31,7 @@ interface Props {
items?: AcquiredType[]; items?: AcquiredType[];
infoOnly?: boolean; infoOnly?: boolean;
showAll?: boolean; showAll?: boolean;
ignoredColums?: string[] | string; ignoredColumns?: string[] | string;
defaultSortKey?: string; defaultSortKey?: string;
} }
@@ -39,26 +44,42 @@ const props = withDefaults(defineProps<Props>(), {
items: () => [], items: () => [],
infoOnly: false, infoOnly: false,
showAll: false, showAll: false,
ignoredColums: () => [], ignoredColumns: () => [],
defaultSortKey: 'precentProfit', defaultSortKey: 'precentProfit',
}); });
defineEmits<Emits>(); defineEmits<Emits>();
const columnsToIgnore = computed(() => { const columnsToIgnore = computed(() => {
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums; const ignoredColumns = typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : [...props.ignoredColumns];
if (props.infoOnly && !ic.includes('buttons')) { if (props.infoOnly && !ignoredColumns.includes('buttons')) {
return [...ic, 'buttons']; ignoredColumns.push('buttons');
} }
return ic; if (!props.showAll && !ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledger');
}
if (ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledgerName');
}
return ignoredColumns;
}); });
const marketTaxStore = useMarketTaxStore(); 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 threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref(""); const filter = ref("");
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => { const unlistedOnly = ref(false);
const filteredItems = props.items.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase())); 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) { if (props.showAll) {
return filteredItems.map(r => { return filteredItems.map(r => {
@@ -76,6 +97,8 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
precentProfit, precentProfit,
iskProfit: r.price * precentProfit * r.remaining, iskProfit: r.price * precentProfit * r.remaining,
date: r.date, date: r.date,
ledger: ledgersStore.findById(r.ledgerId),
ledgerName: ledgersStore.findById(r.ledgerId)?.name ?? '',
acquisitions: [r] acquisitions: [r]
}; };
}); });
@@ -97,7 +120,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
const precentProfit = marketTaxStore.calculateProfit(price, first.sell); const precentProfit = marketTaxStore.calculateProfit(price, first.sell);
list.push({ list.push({
id: typeID, id: typeID.toString(),
type: first.type, type: first.type,
name: first.type.name, name: first.type.name,
buy: first.buy, buy: first.buy,
@@ -108,14 +131,16 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
precentProfit, precentProfit,
iskProfit: price * precentProfit * totalRemaining, iskProfit: price * precentProfit * totalRemaining,
date: first.date, date: first.date,
ledgerName: '',
acquisitions: group acquisitions: group
}); });
}); });
return list; return list;
}), { });
const { sortedArray, headerProps, showColumn } = useSort<Result>(results, {
defaultSortKey: props.defaultSortKey, defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc', defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore ignoredColumns: columnsToIgnore
}) })
const getLineColor = (result: Result) => { const getLineColor = (result: Result) => {
if (result.precentProfit >= (threshold.value / 100)) { if (result.precentProfit >= (threshold.value / 100)) {
@@ -158,6 +183,9 @@ const total = computed(() => {
<div class="flex" v-if="!infoOnly"> <div class="flex" v-if="!infoOnly">
<div class="flex justify-self-end mb-2 mt-4 ms-auto"> <div class="flex justify-self-end mb-2 mt-4 ms-auto">
<TaxInput /> <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"> <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" />
@@ -175,6 +203,7 @@ const total = computed(() => {
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="buy">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell</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="date">Bought at</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="price">Bought Price</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="remaining">Remaining Amount</SortableHeader>
@@ -188,11 +217,14 @@ const total = computed(() => {
<td v-if="showColumn('name')"> <td v-if="showColumn('name')">
<div class="flex"> <div class="flex">
<MarketTypeLabel :id="r.data.type.id" :name="r.data.name" /> <MarketTypeLabel :id="r.data.type.id" :name="r.data.name" />
<AcquisitionQuantilsTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" /> <AcquisitionQuartilesTooltip :id="r.data.type.id" :buy="r.data.buy" :sell="r.data.sell" />
</div> </div>
</td> </td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</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('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('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('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('remaining')" class="text-right">{{ r.data.remaining }}/{{ r.data.quantity }}</td>
@@ -213,9 +245,12 @@ const total = computed(() => {
<td v-if="showColumn('sell')"> <td v-if="showColumn('sell')">
<template v-if="!showColumn('name') && !showColumn('buy')">Total</template> <template v-if="!showColumn('name') && !showColumn('buy')">Total</template>
</td> </td>
<td v-if="showColumn('date')"> <td v-if="showColumn('ledger')">
<template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template> <template v-if="!showColumn('name') && !showColumn('buy') && !showColumn('sell')">Total</template>
</td> </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"> <td v-if="showColumn('price')" class="text-right">
<template v-if="total.sameItem"> <template v-if="total.sameItem">
{{ formatIsk(total.price) }} {{ formatIsk(total.price) }}
@@ -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>
+35 -6
View File
@@ -2,21 +2,35 @@
import {Modal} from '@/components'; import {Modal} from '@/components';
import {formatIsk} from '@/formaters'; import {formatIsk} from '@/formaters';
import {MarketType, MarketTypeLabel} from '@/market'; import {MarketType, MarketTypeLabel} from '@/market';
import {ref} from 'vue'; import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
import {computed, ref} from 'vue';
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType';
import {useAcquiredTypesStore} from './acquisition'; import {useAcquiredTypesStore} from './acquisition';
import {toast} from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredTypesStore = useAcquiredTypesStore();
const ledgersStore = useLedgersStore();
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
const modalOpen = ref<boolean>(false); const modalOpen = ref<boolean>(false);
const type = ref<MarketType>(); const type = ref<MarketType>();
const suggestions = ref<Record<string, number>>({}); const suggestions = ref<Record<string, number>>({});
const price = ref(1000000); const price = ref(1000000);
const count = ref(1); const count = ref(1);
const date = ref('');
const contextLedgerId = ref<string>();
const ledger = ref<Ledger>();
const open = (t: MarketType, s?: Record<string, number> | number) => { const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
const open = (t: MarketType, s?: Record<string, number> | number, ledgerId?: string) => {
type.value = t; type.value = t;
count.value = 1; count.value = 1;
date.value = toEveDatetimeInput(new Date());
contextLedgerId.value = ledgerId;
ledger.value = undefined;
if (typeof s === 'number') { if (typeof s === 'number') {
suggestions.value = {}; suggestions.value = {};
price.value = s; price.value = s;
@@ -29,7 +43,7 @@ const open = (t: MarketType, s?: Record<string, number> | number) => {
} }
modalOpen.value = true; modalOpen.value = true;
} }
const add = () => { const add = async () => {
const id = type.value?.id; const id = type.value?.id;
if (!id) { if (!id) {
@@ -37,8 +51,15 @@ const add = () => {
return; return;
} }
acquiredTypesStore.addAcquiredType(id, count.value, price.value); const lid = ledgerId.value;
if (!lid) {
return;
}
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
modalOpen.value = false; modalOpen.value = false;
toast.success('Acquisition added');
} }
defineExpose({ open }); defineExpose({ open });
@@ -61,7 +82,15 @@ defineExpose({ open });
<span>Count: </span> <span>Count: </span>
<input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" /> <input class="ms-2" type="number" min="0" step="1" v-model="count" @keyup.enter="add" />
</div> </div>
<button class="mb-auto" @click="add">Add</button> <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>
</div> </div>
</Modal> </Modal>
+42 -12
View File
@@ -1,19 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { Modal } from '@/components'; import { Modal } from '@/components';
import { MarketType, MarketTypeLabel } from '@/market'; import { MarketType, MarketTypeLabel } from '@/market';
import { ref } from 'vue'; import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
import { AcquiredType, acquiredTypesToSorted } from './AcquiredType'; import { computed, ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType';
import { useAcquiredTypesStore } from './acquisition'; import { useAcquiredTypesStore } from './acquisition';
import { toast } from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredTypesStore = useAcquiredTypesStore();
const ledgersStore = useLedgersStore();
const mainLedgers = computed(() => ledgersStore.ledgers.filter(isMain));
const modalOpen = ref<boolean>(false); const modalOpen = ref<boolean>(false);
const type = ref<MarketType>(); const type = ref<MarketType>();
const count = ref(1); const count = ref(1);
const date = ref('');
const types = ref<AcquiredType[]>([]); const types = ref<AcquiredType[]>([]);
const contextLedgerId = ref<string>();
const ledger = ref<Ledger>();
const open = (t: AcquiredType[]) => { const ledgerId = computed(() => contextLedgerId.value ?? ledger.value?.ledgerId);
const open = (t: AcquiredType[], ledgerId?: string) => {
if (t.length === 0) { if (t.length === 0) {
return; return;
} }
@@ -21,26 +31,38 @@ const open = (t: AcquiredType[]) => {
types.value = acquiredTypesToSorted(t); types.value = acquiredTypesToSorted(t);
type.value = t[0].type; type.value = t[0].type;
count.value = 1; count.value = 1;
date.value = toEveDatetimeInput(new Date());
contextLedgerId.value = ledgerId;
ledger.value = undefined;
modalOpen.value = true; modalOpen.value = true;
} }
const remove = async () => { const remove = async () => {
if (!types.value) { if (types.value.length === 0) {
modalOpen.value = false; modalOpen.value = false;
return; return;
} }
let c = count.value; const lid = ledgerId.value;
for (const type of types.value) { if (!lid) {
const remaining = type.remaining; return;
}
await acquiredTypesStore.removeAcquiredType(type.id, c); const datetime = fromEveDatetimeInput(date.value);
c -= remaining; let remaining = count.value;
if (c <= 0) {
for (const t of types.value) {
if (remaining <= 0) {
break; break;
} }
const quantity = Math.min(remaining, t.remaining);
await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime);
remaining -= quantity;
} }
modalOpen.value = false; modalOpen.value = false;
toast.success('Consumption added');
} }
defineExpose({ open }); defineExpose({ open });
@@ -61,7 +83,15 @@ defineExpose({ open });
</div> </div>
</div> </div>
</div> </div>
<button class="mb-auto" @click="remove">Remove</button> <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>
</div> </div>
</Modal> </Modal>
+56 -26
View File
@@ -1,41 +1,71 @@
import { defineStore } from "pinia"; import {defineStore} from "pinia";
import { computed, ref } from "vue"; import {computed, ref} from "vue";
import {acquisitionApi, activityApi} from "@/mammon";
import {AcquisitionResponse, ActivitySourceResponse} from "@/generated/mammon";
import {onActivitiesProcessed} from "@/activity";
export type AcquiredTypeSource = 'bo' | 'so' | 'prod' | 'misc'; 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', () => { export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
const acquiredTypes = ref<any[]>([]); // TODO const acquiredTypes = ref<RawAcquiredType[]>([]);
const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0)); const types = computed(() => acquiredTypes.value.filter(item => item.remaining > 0));
const addAcquiredType = async (type: number, quantity: number, price: number, source?: AcquiredTypeSource) => {
const newItem = [];
acquiredTypes.value = [...acquiredTypes.value, newItem]; const addAcquiredType = (type: number, quantity: number, price: number, ledgerId: string, datetime: Date) =>
}; activityApi.acquire({
const removeAcquiredType = async (id: number, quantity: number) => { source: { type: 'LEDGER', characterId: null, ledgerId },
const found = acquiredTypes.value.find(t => t.id === id); marketTypeId: type,
quantity,
unitPrice: price,
datetime: datetime.toISOString(),
taxes: null,
description: null,
});
if (!found) { const removeAcquiredType = (id: string, quantity: number, ledgerId: string, datetime: Date) => {
return 0; const acquisition = acquiredTypes.value.find(a => a.id === id);
if (!acquisition) {
return Promise.resolve();
} }
const item = { return activityApi.consume({
...found, source: { type: 'LEDGER', characterId: null, ledgerId },
remaining: Math.max(0, found.remaining - quantity) marketTypeId: acquisition.type,
}; quantity,
unitPrice: 0,
acquiredTypes.value = acquiredTypes.value.map(i => { datetime: datetime.toISOString(),
if (i.id === item.id) { taxes: null,
return item; description: null,
} else {
return i;
}
}); });
}; };
const refresh = () => {} const refresh = () => acquisitionApi.findAllAcquisitions()
.then(response => acquiredTypes.value = response.data.map(toAcquiredType));
refresh(); refresh();
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, refresh }; onActivitiesProcessed(refresh);
});
return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
});
+1
View File
@@ -2,6 +2,7 @@ export * from './AcquiredType';
export * from './acquisition'; export * from './acquisition';
export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue'; export { default as AcquisitionResultTable } from './AcquisitionResultTable.vue';
export { default as AcquisitionsPanel } from './AcquisitionsPanel.vue';
export { default as BuyModal } from './BuyModal.vue'; export { default as BuyModal } from './BuyModal.vue';
export { default as SellModal } from './SellModal.vue'; export { default as SellModal } from './SellModal.vue';
@@ -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>
+1 -1
View File
@@ -8,4 +8,4 @@ export type MarketTypePrice = {
orderCount: number; orderCount: number;
}; };
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>; export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
+12 -40
View File
@@ -1,45 +1,17 @@
import { defineStore } from 'pinia'; import {MarketType} from "../type";
import { RegionalMarketCache } from '../RegionalMarketCache'; import {MarketTypePrice} from './MarketTypePrice';
import { jitaId } from '../market'; import {getMammonPrices} from './mammon';
import { MarketType } from "../type";
import { MarketTypePrice } from './MarketTypePrice';
import { getEvepraisalPrices } from './evepraisal';
import { getfuzzworkPrices } from './fuzzwork';
const cacheDuration = 1000 * 60 * 5; // 5 minutes const BATCH_SIZE = 100;
const priceGetters = {
evepraisal: getEvepraisalPrices,
fuzzwork: getfuzzworkPrices
}
export const useApraisalStore = defineStore('appraisal', () => { export const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(cacheDuration);
const getPricesUncached = priceGetters.fuzzwork; export const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const batches: Promise<MarketTypePrice[]>[] = [];
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0]; for (let i = 0; i < types.length; i += BATCH_SIZE) {
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => { batches.push(getMammonPrices(types.slice(i, i + BATCH_SIZE), locationId));
const cached: MarketTypePrice[] = []; }
const uncached: MarketType[] = [];
const rId = regionId ?? jitaId;
types.forEach(t => { return (await Promise.all(batches)).flat();
const cachedPrice = cache.get(rId, t.id); };
if (cachedPrice) {
cached.push(cachedPrice);
} else {
uncached.push(t);
}
});
if (uncached.length > 0) {
const prices = await getPricesUncached(uncached);
prices.forEach(p => cache.set(rId, p.type.id, p));
return [ ...cached, ...prices ];
}
return cached;
};
return { getPrice, getPrices };
});
+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()];
};
-31
View File
@@ -1,31 +0,0 @@
import { logResource } from '@/service';
import axios from 'axios';
import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const evepraisalAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})
logResource(evepraisalAxiosInstance)
const batchSize = 100;
export const getEvepraisalPrices: PriceGetter = async types => {
const batches = [];
for (let i = 0; i < types.length; i += batchSize) {
batches.push(evepraisalAxiosInstance.post(`/appraisal.json?market=jita&persist=no&raw_textarea=${types.slice(i, i + batchSize).map(t => t.name).join("%0A")}`));
}
return (await Promise.all(batches))
.flatMap(b => b.data.appraisal.items)
.map((item: any) => ({
type: types.find(t => t.name === item.typeName) as MarketType,
buy: item.prices.buy.max,
sell: item.prices.sell.min,
orderCount: item.prices.all.order_count
}));
};
-39
View File
@@ -1,39 +0,0 @@
import { logResource } from '@/service';
import axios from 'axios';
import { MarketType } from "../type";
import { PriceGetter } from './MarketTypePrice';
export const fuzzworkAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_FUZZWORK_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})
logResource(fuzzworkAxiosInstance)
const batchSize = 100;
export const getfuzzworkPrices: PriceGetter = async types => {
const batches = [];
for (let i = 0; i < types.length; i += batchSize) {
batches.push(fuzzworkAxiosInstance.post(`/aggregates/?station=60003760&types=${types.slice(i, i + batchSize).map(t => t.id).join(",")}`));
}
return (await Promise.all(batches))
.flatMap(b => Object.entries(b.data))
.map(entry => {
const id = doParseInt(entry[0]);
const prices = entry[1] as any;
return {
type: types.find(t => t.id === id) as MarketType,
buy: doParseFloat(prices?.buy?.max),
sell: doParseFloat(prices?.sell?.min),
orderCount: doParseInt(prices?.buy?.order_count) + doParseInt(prices?.sell?.order_count)
}
});
};
const doParseInt = (s?: string) => s ? parseInt(s) : 0;
const doParseFloat = (s?: string) => s ? parseFloat(s) : 0;
+5
View File
@@ -1,2 +1,7 @@
export * from './MarketTypePrice'; export * from './MarketTypePrice';
export * from './appraisal'; 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;
}, []);
};
@@ -1,30 +0,0 @@
import { esiAxiosInstance } from "@/service";
import { RegionalMarketCache } from '../RegionalMarketCache';
import { jitaId } from "../market";
export type EsiMarketOrderHistory = {
average: number;
date: string;
highest: number;
lowest: number;
order_count: number;
volume: number;
}
// TODO use pinia store
const historyCache: RegionalMarketCache<EsiMarketOrderHistory[]> = new RegionalMarketCache(() => {
const date = new Date();
if (date.getUTCHours() >= 11) {
date.setUTCDate(date.getUTCDate() + 1);
}
date.setUTCHours(11, 0, 0, 0);
return date;
});
export const getHistory = async (typeId: number, regionId?: number): Promise<EsiMarketOrderHistory[]> => {
const rId = regionId ?? jitaId;
return historyCache.computeIfAbsent(rId, typeId, async () => (await esiAxiosInstance.get(`/markets/${rId}/history/`, { params: { type_id: typeId } })).data);
}
-59
View File
@@ -1,59 +0,0 @@
import { EsiMarketOrderHistory } from "@/market";
export type HistoryQuartils = {
totalVolume: number,
q1: number,
median: number,
q3: number,
}
export const getHistoryQuartils = (history: EsiMarketOrderHistory[], 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: EsiMarketOrderHistory): number => {
if (history.volume === 0) {
return 0;
}
return Math.max(1, Math.round(history.volume * ((history.average - history.lowest) / (history.highest - history.lowest))));
}
+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 -2
View File
@@ -1,2 +1,4 @@
export * from './EsiMarketOrderHistory'; export * from './history.ts';
export * from './HistoryQuartils';
export { default as MarketTrendIcon } from './MarketTrendIcon.vue';
export { default as TrendFilter } from './TrendFilter.vue';
+2 -2
View File
@@ -1,10 +1,10 @@
export * from './RegionalMarketCache';
export * from './history'; export * from './history';
export * from './location';
export * from './order';
export * from './tax'; export * from './tax';
export * from './type'; export * from './type';
export * from './appraisal'; export * from './appraisal';
export * from './market';
export { default as IskLabel } from './IskLabel.vue'; 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};
});
-1
View File
@@ -1 +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)));
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import {SliderCheckbox} from '@/components'; import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table'; import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {getHistoryQuartils, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market"; import {formatIsk, percentFormater} from "@/formaters";
import {BookmarkSlashIcon, 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 {useAcquiredTypesStore} from '../acquisition'; import {useAcquiredTypesStore} from '../acquisition';
import {TrackingResult} from './tracking'; import {ScanResult} from './scan';
type Result = { type Result = {
type: MarketType; type: MarketType;
@@ -17,54 +17,49 @@ type Result = {
q1: number; q1: number;
median: number; median: number;
q3: number; q3: number;
totalVolume: number;
acquisitions: number; acquisitions: number;
profit: number; profit: number;
score: number; score: number;
trend: MarketTrend;
} }
interface Props { interface Props {
items?: TrackingResult[]; items?: ScanResult[];
infoOnly?: boolean; infoOnly?: boolean;
ignoredColums?: string[] | string; ignoredColumns?: string[] | string;
}
interface Emits {
(e: 'buy', type: MarketType, buy: number, sell: number): void;
(e: 'remove', type: MarketType): void;
} }
const scoreFormater = new Intl.NumberFormat("en-US", { const scoreFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0 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, infoOnly: false,
ignoredColums: () => [] ignoredColumns: () => []
}); });
defineEmits<Emits>();
const marketTaxStore = useMarketTaxStore();
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredTypesStore = useAcquiredTypesStore();
const days = useStorage('market-tracking-days', 365); const threshold = useStorage('market-scan-threshold', 10);
const threshold = useStorage('market-tracking-threshold', 10);
const filter = ref(""); const filter = ref("");
const onlyCheap = ref(false); const onlyCheap = ref(false);
const columnsToIgnore = computed(() => { const trackedTrends = useStorage<MarketTrend[]>('market-scan-trends', [
const ic = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums; MarketTrends.Up,
MarketTrends.Flat,
if (props.infoOnly && !ic.includes('buttons')) { MarketTrends.Down,
return [...ic, 'buttons']; ]);
} const columnsToIgnore = computed(() =>
return ic; typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : props.ignoredColumns
}); );
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items 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 profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
.filter(t => t.type === r.type.id) .filter(t => t.type === r.type.id)
.reduce((a, b) => a + b.remaining, 0); .reduce((a, b) => a + b.remaining, 0);
@@ -75,17 +70,19 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
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,
median: quartils.median, median: r.median,
q3: quartils.q3, q3: r.q3,
totalVolume: r.totalVolume,
acquisitions, acquisitions,
profit, profit: r.profit,
score score: r.score,
trend: r.trend,
}; };
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), { }).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'score', defaultSortKey: 'score',
defaultSortDirection: 'desc', defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore ignoredColumns: columnsToIgnore
}) })
const getLineColor = (result: Result) => { const getLineColor = (result: Result) => {
if (props.infoOnly) { if (props.infoOnly) {
@@ -104,18 +101,16 @@ const getLineColor = (result: Result) => {
<template> <template>
<div v-if="!infoOnly" class="flex mb-2 mt-4"> <div v-if="!infoOnly" class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto"> <div class="flex justify-self-end ms-auto">
<TaxInput />
<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">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
<div class="end flex"> <div class="end flex">
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items <SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
</div> </div>
<div class="end flex items-center">
<TrendFilter v-model="trackedTrends" />
</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" />
@@ -132,29 +127,29 @@ 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>
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buttons" unsortable />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)"> <tr v-for="r in list" :key="r.data.typeID" :class="getLineColor(r.data)">
<td v-if="showColumn('name')"> <td v-if="showColumn('name')">
<div class="flex">
<MarketTypeLabel :id="r.data.typeID" :name="r.data.name" /> <MarketTypeLabel :id="r.data.typeID" :name="r.data.name" />
<MarketTrendIcon class="ms-auto" :trend="r.data.trend" />
</div>
</td> </td>
<td v-if="showColumn('buy')" class="text-right">{{ formatIsk(r.data.buy) }}</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('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('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('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('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('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('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td> <td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
<td v-if="showColumn('buttons')" class="text-right">
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
<button class="btn-icon me-1" title="Untrack" @click="$emit('remove', r.data.type)"><BookmarkSlashIcon /></button>
</td>
</tr> </tr>
</tbody> </tbody>
</template> </template>
@@ -171,4 +166,4 @@ const getLineColor = (result: Result) => {
div.end { div.end {
@apply justify-self-end ms-2; @apply justify-self-end ms-2;
} }
</style>../history/HistoryQuartils </style>
+3
View File
@@ -0,0 +1,3 @@
export * from './scan';
export { default as ScanResultTable } from './ScanResultTable.vue';
+28
View File
@@ -0,0 +1,28 @@
import {MarketTrend, MarketType} from "@/market";
import {MarketScanResponse} from "@/generated/mammon";
export type ScanResult = {
type: MarketType;
buy: number;
sell: number;
q1: number;
median: number;
q3: number;
totalVolume: number;
profit: number;
score: number;
trend: MarketTrend;
}
export const toScanResult = (res: MarketScanResponse, type: MarketType): ScanResult => ({
type,
buy: res.buy,
sell: res.sell,
q1: res.q1,
median: res.median,
q3: res.q3,
totalVolume: res.totalVolume,
profit: res.profit,
score: res.score,
trend: res.trend,
});
+3 -4
View File
@@ -1,19 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import {storeToRefs} from "pinia";
import {useMarketTaxStore} from "./tax"; import {useMarketTaxStore} from "./tax";
const { brokerFee, scc } = storeToRefs(useMarketTaxStore()); const marketTaxStore = useMarketTaxStore();
</script> </script>
<template> <template>
<div class="end"> <div class="end">
<span>Broker Fee: </span> <span>Broker Fee: </span>
<input type="number" min="1" max="3" step="0.01" v-model="brokerFee" /> <input type="number" min="1" max="3" step="0.01" v-model="marketTaxStore.brokerFee" />
</div> </div>
<div class="end"> <div class="end">
<span>SCC: </span> <span>SCC: </span>
<input type="number" min="3.6" max="8" step="0.01" v-model="scc" > <input type="number" min="3.375" max="8" step="0.01" v-model="marketTaxStore.scc" >
</div> </div>
</template> </template>
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineStore } from "pinia";
export const useMarketTaxStore = defineStore("marketTax", () => { export const useMarketTaxStore = defineStore("marketTax", () => {
const brokerFee = useLocalStorage("market-brokerFee", 1.5); const brokerFee = useLocalStorage("market-brokerFee", 1.5);
const scc = useLocalStorage("market-scc", 3.6); 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 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; const calculateProfit = (buy: number, sell: number) => (applyTaxes(sell, true) / applyTaxes(buy)) - 1;
-4
View File
@@ -1,4 +0,0 @@
export * from './tracking';
export { default as TrackingResultTable } from './TrackingResultTable.vue';
-40
View File
@@ -1,40 +0,0 @@
import { EsiMarketOrderHistory, getHistory, MarketType, MarketTypePrice } from "@/market";
import log from "loglevel";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type TrackingResult = {
type: MarketType;
history: EsiMarketOrderHistory[];
buy: number,
sell: number,
orderCount: number,
}
const endpoint = '/api/types_tracking/';
export const useMarketTrackingStore = defineStore('marketTracking', () => {
const trackedTypes = ref<any[]>([]); // TODO
const types = computed(() => trackedTypes.value.map(item => item.type) ?? []);
const addType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
log.info(`Tracking type ${type}`);
}
}
const removeType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
return;
}
trackedTypes.value = trackedTypes.value.filter(t => t.id !== found.id);
}
return { types, addType, removeType };
});
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(id), ...price });
+23 -41
View File
@@ -1,28 +1,23 @@
import {esiAxiosInstance} from '@/service'; import {marketApi} from '@/mammon/mammonService';
import type {MarketTypeResponse} from '@/generated/mammon';
export type MarketType = { export type MarketType = MarketTypeResponse;
type_id: number;
group_id: number;
market_group_id: number;
name: string;
published: boolean;
description: string;
base_price: number;
icon_id: number;
volume: number;
portion_size: number;
}
const cache = new Map<number, MarketType>(); // TODO move to pinia store const cache = new Map<number, MarketType>(); // TODO move to pinia store
const fetchType = (id: number): Promise<MarketType> => { const BATCH_SIZE = 100;
if (cache.has(id)) {
return Promise.resolve(cache.get(id)!); const fetchTypes = async (ids: number[]): Promise<void> => {
const missing = ids.filter(id => !cache.has(id));
if (missing.length === 0) {
return;
} }
return esiAxiosInstance.get<MarketType>(`/universe/types/${id}/`).then(r => { const batches: Promise<MarketType[]>[] = [];
cache.set(id, r.data); for (let i = 0; i < missing.length; i += BATCH_SIZE) {
return r.data; batches.push(marketApi.findTypes(missing.slice(i, i + BATCH_SIZE)).then(r => r.data));
}); }
const results = await Promise.all(batches);
results.flat().forEach(t => cache.set(t.id, t));
}; };
export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0]; export const getMarketType = async (type: string | number): Promise<MarketType> => (await getMarketTypes([type]))[0];
@@ -31,28 +26,15 @@ export const getMarketTypes = async (types: (string | number)[]): Promise<Market
return []; return [];
} }
const ids = types.filter((t): t is number => typeof t === 'number'); const ids = types.filter((t): t is number => typeof t === 'number');
return Promise.all(ids.map(fetchType)); await fetchTypes(ids);
return ids.map(id => cache.get(id)).filter((t): t is MarketType => t !== undefined);
} }
const blueprintMarketGroups = [ // TODO add all groups
2,
2157,
2159,
2339,
2160,
211,
1016,
339,
2290,
357,
1530,
359,
1531,
1532,
1533,
358
]
export const searchMarketTypes = async (search: string): Promise<MarketType[]> => { export const searchMarketTypes = async (search: string): Promise<MarketType[]> => {
return [] if (search.length === 0) {
return [];
}
const types = await marketApi.searchTypes(search).then(r => r.data);
types.forEach(t => cache.set(t.id, t));
return types;
} }
+4 -10
View File
@@ -1,17 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import {mammonAddCharacterUrl} from "@/mammon";
import {storeToRefs} from "pinia";
import {CharacterLabel, useCharactersStore} from "@/characters"; import {CharacterLabel, useCharactersStore} from "@/characters";
import {ArrowPathIcon} from '@heroicons/vue/24/outline'; import {useAuthStore} from "@/auth";
const charactersStore = useCharactersStore() const charactersStore = useCharactersStore()
const {characters} = storeToRefs(charactersStore); const authStore = useAuthStore()
const {reloadActivities} = charactersStore;
const addCharacter = () => { const addCharacter = () => authStore.addCharacter();
window.location.replace(mammonAddCharacterUrl);
}
</script> </script>
<template> <template>
@@ -19,9 +14,8 @@ const addCharacter = () => {
<div class="mb-4 border-b-1 flex justify-end"> <div class="mb-4 border-b-1 flex justify-end">
<button class="mb-2" @click="addCharacter">Add chacarcter</button> <button class="mb-2" @click="addCharacter">Add chacarcter</button>
</div> </div>
<div v-for="character in characters" :key="character.characterId" class="flex items-center mb-2"> <div v-for="character in charactersStore.characters" :key="character.characterId" class="flex items-center mb-2">
<CharacterLabel class="grow" :character="character" /> <CharacterLabel class="grow" :character="character" />
<button class="btn-icon" @click="reloadActivities(character.characterId)"><ArrowPathIcon /></button>
</div> </div>
</div> </div>
</template> </template>
+9 -1
View File
@@ -1,6 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import {useAuthStore} from '@/auth';
const authStore = useAuthStore();
</script> </script>
<template> <template>
<div></div> <div class="flex flex-col items-center justify-center h-screen gap-4">
<h1 class="text-2xl">eveal</h1>
<button v-if="!authStore.isAuthenticated" @click="authStore.login()">
Login with EVE Online
</button>
</div>
</template> </template>
+1 -20
View File
@@ -1,26 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {RouterView} from 'vue-router'; import {RouterView} from 'vue-router';
import {EditLedgerModal, useLedgersStore} from "@/ledger";
import {ref} from "vue";
import {processingApi} from "@/mammon";
const {refresh} = useLedgersStore();
const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => {
await processingApi.processNewActivities();
await refresh();
}
</script> </script>
<template> <template>
<div class="mt-4"> <RouterView />
<div class="mb-4 border-b-1 flex justify-end">
<button class="mb-2 ms-2" @click="processActivities">Process Activities</button>
<button class="mb-2 ms-2" @click="editLedgerModal?.open()">New Ledger</button>
</div>
<EditLedgerModal ref="editLedgerModal" />
<RouterView />
</div>
</template> </template>
+8 -2
View File
@@ -9,12 +9,18 @@ import {routeNames} from '@/routes';
<RouterLink :to="{name: routeNames.marketTypes}" class="tab"> <RouterLink :to="{name: routeNames.marketTypes}" class="tab">
<span>Item Info</span> <span>Item Info</span>
</RouterLink> </RouterLink>
<RouterLink to="/market/tracking" class="tab"> <RouterLink to="/market/scan" class="tab">
<span>Tracking</span> <span>Scan</span>
</RouterLink> </RouterLink>
<RouterLink to="/market/acquisitions" class="tab"> <RouterLink to="/market/acquisitions" class="tab">
<span>Acquisitions</span> <span>Acquisitions</span>
</RouterLink> </RouterLink>
<RouterLink :to="{name: routeNames.appraise}" class="tab">
<span>Appraisal</span>
</RouterLink>
<RouterLink :to="{name: routeNames.compareAppraisal}" class="tab">
<span>Compare</span>
</RouterLink>
</div> </div>
<RouterView /> <RouterView />
</div> </div>
+16 -11
View File
@@ -1,31 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReprocessInput, ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess'; import { useCharactersStore } from '@/characters';
import { ReprocessItemValues, ReprocessResultTable, reprocess } from '@/reprocess';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { ref } from 'vue'; import { ref } from 'vue';
const items = ref(""); const charactersStore = useCharactersStore();
const materials = ref("");
const efficiency = useStorage('reprocess-efficiency', 0.55); const characterId = useStorage<number | undefined>('reprocess-character', undefined);
const items = ref('');
const result = ref<ReprocessItemValues[]>([]); const result = ref<ReprocessItemValues[]>([]);
const send = async () => result.value = await reprocess(items.value, materials.value, efficiency.value); const send = async () => result.value = await reprocess(characterId.value, items.value);
</script> </script>
<template> <template>
<div class="grid mb-2 mt-4"> <div class="grid mb-2 mt-4">
<div class="justify-self-end"> <div class="justify-self-end">
<span>Reprocess efficiency: </span> <span>Character: </span>
<input type="number" min="0" max="1" step="0.05" v-model="efficiency" /> <select v-model="characterId">
<option v-for="character in charactersStore.characters" :key="character.characterId" :value="character.characterId">{{ character.name }}</option>
</select>
</div> </div>
</div> </div>
<div class="flex items-stretch"> <div class="flex items-stretch">
<ReprocessInput name="Item JSON" v-model="items" /> <div class="flex-1 mx-1">
<ReprocessInput name="Materials JSON" v-model="materials" /> <span>Items</span>
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
</div>
</div> </div>
<div class="grid my-2"> <div class="grid my-2">
<button class="justify-self-end" @click="send">Send</button> <button class="justify-self-end" @click="send" :disabled="!characterId || !items.trim()">Send</button>
</div> </div>
<template v-if="result.length > 0"> <template v-if="result.length > 0">
<hr /> <hr />
-18
View File
@@ -1,18 +0,0 @@
<script setup lang="ts">
import {RouterLink, RouterView} from "vue-router";
import {routeNames} from '@/routes';
</script>
<template>
<div class="mt-4">
<div class="flex border-b-2 border-emerald-500">
<RouterLink :to="{ name: routeNames.listRuleBooks }" class="tab">
<span>Rule Books</span>
</RouterLink>
<RouterLink to="/characters/rules" class="tab">
<span>Characters Rules</span>
</RouterLink>
</div>
<RouterView />
</div>
</template>
+22 -11
View File
@@ -2,31 +2,38 @@
import {findAllTransactionInLeger, useLedgerParam} from "@/ledger"; import {findAllTransactionInLeger, useLedgerParam} from "@/ledger";
import {computedAsync} from "@vueuse/core"; import {computedAsync} from "@vueuse/core";
import {TransactionResponse} from "@/generated/mammon"; import {ActivitySourceResponseTypeEnum, TransactionResponse} from "@/generated/mammon";
import {formatEveDate} from "@/formaters.ts";
import {IskLabel} from "@/market"; import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table"; import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {TransferList, TransferTypes} from "@/transaction"; import {TransferList, TransferTypes} from "@/transaction";
import {Dropdown} from "@/components"; import {Dropdown} from "@/components";
import {SourceLabel, useProcessedResource} from "@/activity";
import {useCharactersStore} from "@/characters";
import {formatEveDate} from "@/formaters.ts";
const {ledgerId} = useLedgerParam(); const {ledgerId} = useLedgerParam();
const charactersStore = useCharactersStore();
const transactions = computedAsync<TransactionResponse[]>(async () => { const transactions = useProcessedResource<TransactionResponse[]>(
if (ledgerId.value) { () => ledgerId.value ? findAllTransactionInLeger(ledgerId.value) : Promise.resolve([]),
return await findAllTransactionInLeger(ledgerId.value); [],
} );
return [];
}, []);
const { sortedArray, headerProps } = useSort(() => transactions.value.map(transaction => { const { sortedArray, headerProps } = useSort(computedAsync(() => Promise.all(transactions.value.map(async transaction => {
const source = transaction.source;
const character = source.type === ActivitySourceResponseTypeEnum.Character && source.characterId
? await charactersStore.findById(source.characterId)
: undefined;
return { return {
source,
sourceName: character?.name ?? (source.type === ActivitySourceResponseTypeEnum.Corporation ? "Corporation" : ""),
transactionId: transaction.transactionId, transactionId: transaction.transactionId,
description: transaction.description, description: transaction.description,
date: new Date(transaction.datetime), date: new Date(transaction.datetime),
balance: getIskBalance(transaction), balance: getIskBalance(transaction),
transfers: transaction.transfers transfers: transaction.transfers
} }
})); })), []), { defaultSortKey: 'date', defaultSortDirection: 'desc' });
const getIskBalance = (transaction: TransactionResponse) => { const getIskBalance = (transaction: TransactionResponse) => {
if (!ledgerId.value) { if (!ledgerId.value) {
@@ -54,13 +61,17 @@ const getIskBalance = (transaction: TransactionResponse) => {
<template #default="{ list }"> <template #default="{ list }">
<thead> <thead>
<tr> <tr>
<SortableHeader v-bind="headerProps" sortKey="sourceName">Source</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="date">Date</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="date">Date</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="balance">Balance</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="balance">Isk Change</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="description">Description</SortableHeader> <SortableHeader v-bind="headerProps" sortKey="description">Description</SortableHeader>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="t in list" :key="t.data.transactionId"> <tr v-for="t in list" :key="t.data.transactionId">
<td>
<SourceLabel :source="t.data.source" />
</td>
<td> <td>
<Dropdown class="transfer-dropdown"> <Dropdown class="transfer-dropdown">
<template #button> <template #button>
+18 -3
View File
@@ -1,15 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import {EditLedgerModal, Ledger, LedgerLabel, useLedgersStore} from "@/ledger"; import {EditLedgerModal, Ledger, LedgerLabel, useLedgersStore} from "@/ledger";
import {storeToRefs} from "pinia";
import {nextTick, ref} from "vue"; import {nextTick, ref} from "vue";
import {PencilSquareIcon} from "@heroicons/vue/24/outline"; import {PencilSquareIcon} from "@heroicons/vue/24/outline";
import {IskLabel} from "@/market"; import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table"; import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {activityApi} from "@/mammon";
import {toast} from "@/toast";
const {ledgers} = storeToRefs(useLedgersStore()); const ledgersStore = useLedgersStore();
const { sortedArray, headerProps } = useSort<Ledger>(ledgers); const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => {
toast.info('Processing started');
await activityApi.processNewActivities();
}
const { sortedArray, headerProps } = useSort<Ledger>(() => ledgersStore.ledgers);
const editModal = ref<typeof EditLedgerModal>(); const editModal = ref<typeof EditLedgerModal>();
const editingLedgerId = ref(""); const editingLedgerId = ref("");
@@ -23,6 +31,13 @@ const openEdit = async (ledgerId: string) => {
</script> </script>
<template> <template>
<div class="mt-4">
<div class="mb-4 border-b-1 flex justify-end">
<button class="mb-2 ms-2" @click="processActivities">Process Activities</button>
<button class="mb-2 ms-2" @click="editLedgerModal?.open()">New Ledger</button>
</div>
<EditLedgerModal ref="editLedgerModal" />
</div>
<div class="mt-4"> <div class="mt-4">
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem"> <VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }"> <template #default="{ list }">
+43 -3
View File
@@ -1,21 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
import {RouterLink, RouterView} from 'vue-router'; import {RouterLink, RouterView} from 'vue-router';
import {isMain, useLedgerParam} from "@/ledger"; import {appraiseItemBalances, getLedgerBalance, isMain, LedgerLabel, useLedgerParam} from "@/ledger";
import {routeNames} from "@/routes.ts"; import {routeNames} from "@/routes.ts";
import {getMarketTypes, getPrices, IskLabel} from "@/market";
import {useMarketLocationStore} from '@/market/location';
import {computedAsync} from "@vueuse/core";
const {ledgerId, ledger} = useLedgerParam(); const {ledgerId, ledger} = useLedgerParam();
const marketLocationStore = useMarketLocationStore();
const itemAppraisal = computedAsync(async () => {
if (!ledgerId.value) {
return undefined;
}
const {itemBalances} = await getLedgerBalance(ledgerId.value);
if (itemBalances.length === 0) {
return undefined;
}
const types = await getMarketTypes([...new Set(itemBalances.map(i => i.typeId))]);
const prices = await getPrices(types, marketLocationStore.location.id);
const appraisal = appraiseItemBalances(itemBalances, prices);
return appraisal.buy === 0 && appraisal.sell === 0 ? undefined : appraisal;
}, undefined);
</script> </script>
<template> <template>
<div v-if="ledger" class="mt-4"> <div v-if="ledger" class="mt-4">
<div class="flex border-b-2 border-emerald-500"> <div class="flex">
<div class="flex w-full">
<LedgerLabel :ledger="ledger" :link="false" />
</div>
<div class="flex justify-end items-baseline gap-3 w-full mb-2">
<span v-if="itemAppraisal" class="text-sm text-gray-400">
~<IskLabel :amount="itemAppraisal.buy" :colored="false" /> buy /
~<IskLabel :amount="itemAppraisal.sell" :colored="false" /> sell
</span>
<IskLabel :amount="ledger.balance" />
</div>
</div>
<div class="flex border-b-2 border-emerald-500 mt-4">
<RouterLink :to="{name: routeNames.viewLedgerBalance}" class="tab"> <RouterLink :to="{name: routeNames.viewLedgerBalance}" class="tab">
<span>Balance</span> <span>Balance</span>
</RouterLink> </RouterLink>
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.listLedgerTransactions}" class="tab"> <RouterLink :to="{name: routeNames.listLedgerTransactions}" class="tab">
<span>Transactions</span> <span>Transactions</span>
</RouterLink> </RouterLink>
<RouterLink :to="{name: routeNames.listLedgerAcquisitions}" class="tab">
<span>Acquisitions</span>
</RouterLink>
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.viewLedgerStatistics}" class="tab">
<span>Statistics</span>
</RouterLink>
</div> </div>
<RouterView /> <RouterView />
</div> </div>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import {AcquisitionsPanel, RawAcquiredType, toAcquiredType} from '@/market/acquisition';
import {useLedgerParam} from "@/ledger";
import {acquisitionApi} from "@/mammon";
import {useProcessedResource} from "@/activity";
const {ledgerId} = useLedgerParam();
const items = useProcessedResource<RawAcquiredType[]>(async () => {
if (!ledgerId.value) {
return [];
}
const {data} = await acquisitionApi.findAllAcquisitions(ledgerId.value);
return data.map(toAcquiredType).filter(a => a.remaining > 0);
}, []);
</script>
<template>
<div class="mt-4">
<AcquisitionsPanel :items="items" :ledgerId="ledgerId" :ignoredColumns="['date', 'ledger']" />
</div>
</template>
+7 -10
View File
@@ -2,17 +2,16 @@
import {getLedgerBalance, useLedgerParam} from "@/ledger"; import {getLedgerBalance, useLedgerParam} from "@/ledger";
import {computedAsync} from "@vueuse/core"; import {computedAsync} from "@vueuse/core";
import {BalanceResponse} from "@/generated/mammon"; import {BalanceResponse} from "@/generated/mammon";
import {getMarketType, IskLabel, MarketTypeLabel} from "@/market"; import {getMarketType, MarketTypeLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table"; import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {useProcessedResource} from "@/activity";
const {ledgerId} = useLedgerParam(); const {ledgerId} = useLedgerParam();
const balance = computedAsync<BalanceResponse>(async () => { const balance = useProcessedResource<BalanceResponse | undefined>(
if (ledgerId.value) { () => ledgerId.value ? getLedgerBalance(ledgerId.value) : Promise.resolve(undefined),
return await getLedgerBalance(ledgerId.value); undefined,
} );
return undefined;
});
const { sortedArray, headerProps } = useSort(computedAsync(async () => { const { sortedArray, headerProps } = useSort(computedAsync(async () => {
const itemBalances = balance.value?.itemBalances; const itemBalances = balance.value?.itemBalances;
@@ -35,9 +34,7 @@ const { sortedArray, headerProps } = useSort(computedAsync(async () => {
<template> <template>
<div v-if="balance" class="mt-4"> <div v-if="balance" class="mt-4">
<div class="flex justify-end w-full">
<IskLabel class="mb-2" :amount="balance.iskBalance" />
</div>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem"> <VirtualScrollTable :list="sortedArray" :itemHeight="33" bottom="1rem">
<template #default="{ list }"> <template #default="{ list }">
<thead> <thead>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {computed, ref, watch} from "vue";
import {
BarController,
BarElement,
CategoryScale,
Chart as ChartJS,
type ChartData,
type ChartDataset,
type ChartOptions,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from "chart.js";
import {Chart} from "vue-chartjs";
import {useLedgerParam} from "@/ledger";
import {transactionApi} from "@/mammon";
import {useProcessedResource} from "@/activity";
import {DailyProfitResponse} from "@/generated/mammon";
import {IskLabel} from "@/market";
import {formatIsk} from "@/formaters";
import {DualRangeSlider} from "@/components";
ChartJS.register(
CategoryScale, LinearScale,
BarController, BarElement,
LineController, LineElement, PointElement,
Tooltip, Legend, Filler,
);
const {ledgerId} = useLedgerParam();
const ProfitModes = {
CashFlow: 'CASH_FLOW',
Margin: 'MARGIN',
} as const;
type ProfitMode = typeof ProfitModes[keyof typeof ProfitModes];
const mode = ref<ProfitMode>(ProfitModes.CashFlow);
const profits = useProcessedResource<DailyProfitResponse[]>(async () => {
if (!ledgerId.value) {
return [];
}
const {data} = mode.value === ProfitModes.Margin
? await transactionApi.marginPerDayInLedger(ledgerId.value)
: await transactionApi.profitPerDayInLedger(ledgerId.value);
return data;
}, []);
const from = ref(0);
const to = ref(0);
watch(() => profits.value.length, length => {
from.value = 0;
to.value = Math.max(length - 1, 0);
}, {immediate: true});
const maxIndex = computed(() => Math.max(profits.value.length - 1, 0));
const range = computed(() => profits.value.slice(from.value, to.value + 1));
const total = computed(() => range.value.reduce((sum, p) => sum + p.profit, 0));
const cumulativeProfit = computed(() => {
let running = 0;
return range.value.map(p => (running += p.profit));
});
const chartData = computed<ChartData<'bar' | 'line'>>(() => {
const cumulative: ChartDataset<'line'> = {
type: 'line',
label: 'Cumulative profit',
data: cumulativeProfit.value,
borderColor: '#38bdf8',
backgroundColor: 'rgba(56, 189, 248, 0.15)',
pointBackgroundColor: '#38bdf8',
fill: true,
tension: 0.2,
yAxisID: 'y',
order: 0,
};
const iskIn: ChartDataset<'bar'> = {
type: 'bar',
label: 'ISK in',
data: range.value.map(p => p.iskIn),
backgroundColor: 'rgba(16, 185, 129, 0.7)',
yAxisID: 'y',
order: 1,
};
const iskOut: ChartDataset<'bar'> = {
type: 'bar',
label: 'ISK out',
data: range.value.map(p => -p.iskOut),
backgroundColor: 'rgba(180, 83, 9, 0.7)',
yAxisID: 'y',
order: 1,
};
return {
labels: range.value.map(p => p.date),
datasets: [cumulative, iskIn, iskOut],
};
});
const chartOptions = computed<ChartOptions<'bar' | 'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {mode: 'index', intersect: false},
plugins: {
legend: {display: true},
tooltip: {
callbacks: {
label: ctx => `${ctx.dataset.label}: ${formatIsk(Math.abs(ctx.parsed.y ?? 0))}`,
},
},
},
scales: {
x: {
stacked: true,
},
y: {
position: 'left',
stacked: true,
ticks: {
callback: value => formatIsk(Math.abs(Number(value))),
},
}
},
}));
</script>
<template>
<div class="mt-4">
<div class="flex justify-center mb-4">
<div class="flex bg-slate-600 rounded-s-md p-1">
<button class="switch" :class="{active: mode === ProfitModes.CashFlow}" @click="mode = ProfitModes.CashFlow">Cash Flow</button>
</div>
<div class="switch flex bg-slate-600 rounded-e-md p-1">
<button class="switch" :class="{active: mode === ProfitModes.Margin}" @click="mode = ProfitModes.Margin">Margin</button>
</div>
</div>
<div class="flex justify-end mb-4">
<span class="mr-2">Total profit:</span>
<IskLabel :amount="total" />
</div>
<p v-if="profits.length === 0" class="text-center text-gray-400 mt-8">
No trade profit recorded for this ledger
</p>
<template v-else>
<div class="h-96">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
<div class="flex items-center gap-3 mt-4">
<span class="text-sm w-24 text-gray-400">{{ profits[from]?.date }}</span>
<DualRangeSlider class="grow" :min="0" :max="maxIndex" v-model:from="from" v-model:to="to" />
<span class="text-sm w-24 text-right text-gray-400">{{ profits[to]?.date }}</span>
</div>
</template>
</div>
</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>
+3 -39
View File
@@ -1,47 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { MarketTypePrice, getMarketTypes, useApraisalStore } from "@/market"; import {AcquisitionsPanel, useAcquiredTypesStore} from '@/market/acquisition';
import { AcquiredType, AcquisitionResultTable, BuyModal, SellModal, useAcquiredTypesStore } from '@/market/acquisition';
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>();
const apraisalStore = useApraisalStore();
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredTypesStore = useAcquiredTypesStore();
const items = ref<AcquiredType[]>([]);
const refresh = async () => await acquiredTypesStore.refresh();
watch(() => acquiredTypesStore.acquiredTypes, async itms => {
if (itms.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(itms.map(i => i.type)));
items.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
};
});
}, { immediate: true })
</script> </script>
<template> <template>
<div class="mt-4"> <div class="mt-4">
<div class="flex"> <AcquisitionsPanel :items="acquiredTypesStore.acquiredTypes" ignoredColumns="date" />
<button class="ms-auto" @click="refresh">Refresh</button>
</div>
<template v-if="items.length > 0">
<AcquisitionResultTable :items="items" @buy="(types, price, buy, sell) => buyModal?.open(types[0].type, { 'Price': price, 'Buy': buy, 'Sell': sell })" @sell="types => sellModal?.open(types)" ignoredColums="date" />
<BuyModal ref="buyModal" />
<SellModal ref="sellModal" />
</template>
</div> </div>
</template> </template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import {Dropdown} from '@/components';
import {AppraisalItemValue, AppraisalResultTable, appraise} from '@/market/appraisal';
import {MarketLocationInput, useMarketLocationStore} from '@/market/location';
import {ref} from 'vue';
const items = ref('');
const showInputs = ref(true);
const marketLocationStore = useMarketLocationStore();
const result = ref<AppraisalItemValue[]>([]);
const send = async () => result.value = await appraise(items.value, marketLocationStore.location.id);
</script>
<template>
<Dropdown inline :auto-close="false" v-model:open="showInputs" class="mt-4">
<template #button>Items</template>
<div class="flex items-stretch mt-2">
<div class="flex-1 mx-1">
<span>Items</span>
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
</div>
</div>
<div class="mx-1 mt-2">
<span>Location</span>
<div class="mt-1">
<MarketLocationInput v-model="marketLocationStore.location" />
</div>
</div>
<div class="grid my-2">
<button class="justify-self-end" @click="send" :disabled="!items.trim()">Send</button>
</div>
</Dropdown>
<template v-if="result.length > 0">
<hr />
<div class="grid mt-2">
<AppraisalResultTable :result="result" />
</div>
</template>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import {ref} from 'vue';
import {Dropdown} from '@/components';
import {compareAppraisal, ComparedItem, CompareResultTable, getPrices} from '@/market/appraisal';
import {MarketLocation, MarketLocationInput} from '@/market/location';
const items = ref('');
const from = ref<MarketLocation>();
const to = ref<MarketLocation>();
const showInputs = ref(true);
const result = ref<ComparedItem[]>([]);
const resultLocations = ref<MarketLocation[]>([]);
const loading = ref(false);
const send = async () => {
const selected = [from.value, to.value].filter((l): l is MarketLocation => l !== undefined);
loading.value = true;
try {
result.value = await compareAppraisal(items.value, selected.map(l => l.id), getPrices);
resultLocations.value = selected;
} finally {
loading.value = false;
}
};
</script>
<template>
<Dropdown inline :auto-close="false" v-model:open="showInputs" class="mt-4">
<template #button>Items &amp; locations</template>
<div class="flex items-stretch mt-2">
<div class="flex-1 mx-1">
<span>Items</span>
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
</div>
</div>
<div class="flex flex-wrap gap-6 mx-1 mt-2">
<div>
<span>From</span>
<div class="mt-1">
<MarketLocationInput v-model="from" />
</div>
</div>
<div>
<span>To</span>
<div class="mt-1">
<MarketLocationInput v-model="to" />
</div>
</div>
</div>
<div class="grid my-2">
<button class="justify-self-end" @click="send" :disabled="!items.trim() || !from || !to || loading">Send</button>
</div>
</Dropdown>
<template v-if="result.length > 0">
<hr />
<div class="grid mt-2">
<CompareResultTable :result="result" :locations="resultLocations" />
</div>
</template>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import {getMarketTypes, TaxInput, useMarketTaxStore} from "@/market";
import {ScanResult, ScanResultTable, toScanResult} from '@/market/scan';
import {marketApi} from "@/mammon";
import {useStorage} from "@vueuse/core";
import {ref, watch} from 'vue';
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const items = ref<ScanResult[]>([]);
const loading = ref(false);
const scan = async () => {
loading.value = true;
try {
const { data } = await marketApi.scanMarket(
days.value,
marketTaxStore.brokerFee / 100,
marketTaxStore.scc / 100
);
const types = await getMarketTypes(data.map(r => r.marketTypeId));
items.value = data.flatMap(r => {
const type = types.find(t => t.id === r.marketTypeId);
return type ? [toScanResult(r, type)] : [];
});
} finally {
loading.value = false;
}
}
watch([days, () => marketTaxStore.brokerFee, () => marketTaxStore.scc], scan, { immediate: true });
</script>
<template>
<div class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto">
<TaxInput />
<div class="end">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
</div>
</div>
<hr />
<div v-if="loading" class="text-center mt-4">
<span>Scanning market</span>
</div>
<template v-else>
<ScanResultTable :items="items" />
</template>
</template>
<style scoped>
@reference "@/style.css";
div.end {
@apply justify-self-end ms-2;
}
</style>
-82
View File
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { Modal, ProgressBar } from "@/components";
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, useApraisalStore } from "@/market";
import { BuyModal } from '@/market/acquisition';
import { TrackingResult, TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const item = ref<MarketType>();
const apraisalStore = useApraisalStore();
const marketTrackingStore = useMarketTrackingStore();
const items = ref<TrackingResult[]>([]);
const addOrRelaod = async (type: MarketType) => {
const typeID = type.id;
const [history, price] = await Promise.all([
getHistory(typeID),
apraisalStore.getPrice(type)
]);
const itm = {
type,
history,
buy: price.buy,
sell: price.sell,
orderCount: price.orderCount
};
if (items.value.some(i => i.type.id === typeID)) {
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
} else {
items.value = [ ...items.value, itm];
marketTrackingStore.addType(typeID);
}
}
const addItem = async () => {
if (!item.value) {
// TODO error
return;
}
addOrRelaod(item.value);
item.value = undefined;
}
const removeItem = (type: MarketType) => {
items.value = items.value.filter(i => i.type.id !== type.id);
marketTrackingStore.removeType(type.id);
}
watch(() => marketTrackingStore.types, async t => {
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
if (typesToLoad.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
typesToLoad.forEach(async i => items.value.push(await createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice)));
}, { immediate: true });
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="w-auto flex">
<span>Item: </span>
<MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
<button class="justify-self-end ms-2" @click="addItem">Add</button>
</div>
</div>
<template v-if="items.length > 0">
<hr />
<TrackingResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
<BuyModal ref="buyModal" />
<Modal :open="items.length > 0 && items.length < marketTrackingStore.types.length">
<div class="ms-auto me-auto mb-2 w-96">
<ProgressBar :value="items.length" :total="marketTrackingStore.types.length" />
</div>
</Modal>
</template>
</template>

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