29 Commits
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
63 changed files with 3082 additions and 541 deletions
+536 -49
View File
@@ -43,8 +43,7 @@ paths:
"400": "400":
description: |- description: |-
Returned when: Returned when:
- a binding references a ledger the caller does not own - a ledger scope reference is not one of the caller's own main ledgers
- a binding references a ledger that is not a main ledger
/ledgers/main/{ledgerId}: /ledgers/main/{ledgerId}:
put: put:
tags: tags:
@@ -147,7 +146,7 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/ReprocessingResultResponse" $ref: "#/components/schemas/ReprocessingResultResponse"
/reprocessing/raw: /reprocessing/paste:
post: post:
tags: tags:
- reprocessing - reprocessing
@@ -205,7 +204,7 @@ paths:
'*/*': '*/*':
schema: schema:
$ref: "#/components/schemas/RefreshResponse" $ref: "#/components/schemas/RefreshResponse"
/market/types/parse: /market/types/paste:
post: post:
tags: tags:
- market - market
@@ -233,6 +232,44 @@ paths:
"400": "400":
description: Returned when a line carries a quantity that is not a positive description: Returned when a line carries a quantity that is not a positive
whole number whole number
/market/prices/paste:
post:
tags:
- market
summary: "Parse a pasted EVE inventory block (item name + quantity, tab/newline\
\ separated) and return the current valuation of each stack at the given market\
\ location (Jita by default)"
operationId: pastePrices
parameters:
- name: locationId
in: query
description: Station or structure id of the market to price against; defaults
to Jita when omitted
required: false
schema:
type: integer
format: int64
requestBody:
content:
text/plain:
schema:
type: string
description: "Raw inventory block copied from the EVE client, e.g. \"\
Tritanium\\t1000\""
required: true
responses:
"200":
description: "One stack appraisal per resolved market type, valued at current\
\ buy and sell prices"
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/MarketTypeStackAppraisalResponse"
"400":
description: Returned when the request body is not text/plain or locationId
is not a market location id
/market/history/refresh: /market/history/refresh:
post: post:
tags: tags:
@@ -321,35 +358,23 @@ paths:
description: The link is seeded; follow with a full-page nav to /oauth2/authorization/esi description: The link is seeded; follow with a full-page nav to /oauth2/authorization/esi
"401": "401":
description: Missing or expired access token description: Missing or expired access token
/activity/fetch: /balances/rebuild:
post: post:
tags: tags:
- activity - balance-rebuild-admin-controller
summary: Fetch new activities for the authenticated user's characters from the operationId: rebuild
EVE API
operationId: fetchAllNewActivities
responses:
"200":
description: New activities fetched and stored
/activity/fetch/{characterId}:
post:
tags:
- activity
summary: Fetch new activities for a character from the EVE API
operationId: fetchNewActivitiesForCharacter
parameters: parameters:
- name: characterId - name: ledgerIds
in: path in: query
description: Id of the character
required: true required: true
schema: schema:
type: integer type: array
format: int64 items:
type: string
format: uuid
responses: responses:
"200": "204":
description: New activities fetched and stored description: No Content
"400":
description: No character with this id
/activity/consume: /activity/consume:
post: post:
tags: tags:
@@ -396,18 +421,54 @@ paths:
- activity - activity
summary: Process new activities for the authenticated user's characters summary: Process new activities for the authenticated user's characters
operationId: processNewActivities operationId: processNewActivities
parameters:
- name: from
in: query
description: Reprocess by walking the processed tail back to this UTC datetime
(ISO-8601); omit to only process new activities
required: false
schema:
type: string
format: date-time
responses: responses:
"200": "202":
description: New activities processed description: Processing started in the background
/rule-scripts/definitions: /rule-scripts/definitions:
get: get:
tags: tags:
- rule-script - rule-script
summary: Download the TypeScript definitions for the rule script runtime summary: List the available rule script type definition files
operationId: getScriptDefinitions operationId: listScriptDefinitions
responses: responses:
"200": "200":
description: The rule-runner.d.ts type definitions description: The names of the available .d.ts type definitions
content:
'*/*':
schema:
type: array
items:
type: string
/rule-scripts/definitions/{name}:
get:
tags:
- rule-script
summary: Download a rule script type definition file by name
operationId: getScriptDefinition
parameters:
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: The requested .d.ts type definitions
content:
text/plain:
schema:
type: string
"404":
description: No type definition with that name exists
content: content:
text/plain: text/plain:
schema: schema:
@@ -670,8 +731,9 @@ paths:
get: get:
tags: tags:
- market - market
summary: "Return the current Jita order book (highest buy, lowest sell, order\ summary: "Return the current order book (highest buy, lowest sell, order count)\
\ count) for each requested market type" \ for each requested market type, priced at the given market location (Jita\
\ by default)"
operationId: currentPrices operationId: currentPrices
parameters: parameters:
- name: types - name: types
@@ -683,6 +745,14 @@ paths:
items: items:
type: integer type: integer
format: int64 format: int64
- name: locationId
in: query
description: Station or structure id of the market to price against; defaults
to Jita when omitted
required: false
schema:
type: integer
format: int64
responses: responses:
"200": "200":
description: "The order book for each requested type, one entry per type" description: "The order book for each requested type, one entry per type"
@@ -697,6 +767,7 @@ paths:
Returned when: Returned when:
- the types parameter is missing - the types parameter is missing
- a types value is not a numeric id - a types value is not a numeric id
- locationId is not a market location id
/market/orders: /market/orders:
get: get:
tags: tags:
@@ -755,6 +826,71 @@ paths:
- the types parameter is missing - the types parameter is missing
- a types value is not a numeric id - a types value is not a numeric id
- the days parameter is not greater than 0 - the days parameter is not greater than 0
/locations/{id}:
get:
tags:
- location
summary: "Resolve a market location id (NPC station or public structure, or\
\ a private structure reachable by the authenticated caller's characters)\
\ to its name and containing solar system and region"
operationId: findLocationById
parameters:
- name: id
in: path
description: "Market location id (station id) to resolve, e.g. 60003760"
required: true
schema:
type: integer
format: int64
responses:
"200":
description: The resolved market location
content:
'*/*':
schema:
$ref: "#/components/schemas/DockableLocationResponse"
"400":
description: Returned when the id is not a market location id
"404":
description: Returned when no market location has the given id
/locations/search:
get:
tags:
- location
summary: "Search market locations (NPC stations and public structures, plus\
\ private structures reachable by the authenticated caller's characters) whose\
\ name contains the given text, case-insensitively"
operationId: searchLocations
parameters:
- name: name
in: query
description: "Text to match against the location name, e.g. name=jita"
required: true
schema:
type: string
- name: limit
in: query
description: "Maximum number of results to return, defaults to 50"
required: false
schema:
type: integer
format: int32
default: 50
responses:
"200":
description: "The market locations matching the search, ordered by name\
\ and capped at the requested limit"
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/DockableLocationResponse"
"400":
description: |-
Returned when:
- the name parameter is missing
- the limit value is not a number
/ledgers: /ledgers:
get: get:
tags: tags:
@@ -820,6 +956,64 @@ paths:
$ref: "#/components/schemas/TransactionResponse" $ref: "#/components/schemas/TransactionResponse"
"404": "404":
description: No ledger with this id description: No ledger with this id
/ledgers/{ledgerId}/profit:
get:
tags:
- transaction
summary: Get the daily profit series for a single ledger
operationId: profitPerDayInLedger
parameters:
- name: ledgerId
in: path
description: Id of the ledger
required: true
schema:
type: string
format: uuid
responses:
"200":
description: "Daily profit for the ledger, ascending by date, only for days\
\ with ISK activity"
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/DailyProfitResponse"
"400":
description: The ledger is the system ledger or a combined ledger
"404":
description: No ledger with this id
/ledgers/{ledgerId}/margin:
get:
tags:
- transaction
summary: Get the daily FIFO-realized profit series for a single ledger
operationId: marginPerDayInLedger
parameters:
- name: ledgerId
in: path
description: Id of the ledger
required: true
schema:
type: string
format: uuid
responses:
"200":
description: "Daily FIFO-realized profit for the ledger, ascending by date,\
\ only for days with a sale. iskIn is the sum of that day's profitable\
\ trades, iskOut is the sum of that day's losing trades (as a positive\
\ deficit); profit is iskIn minus iskOut."
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/DailyProfitResponse"
"400":
description: The ledger is the system ledger or a combined ledger
"404":
description: No ledger with this id
/ledgers/{ledgerId}/balance: /ledgers/{ledgerId}/balance:
get: get:
tags: tags:
@@ -843,6 +1037,56 @@ paths:
$ref: "#/components/schemas/BalanceResponse" $ref: "#/components/schemas/BalanceResponse"
"404": "404":
description: No ledger with this id description: No ledger with this id
/corporations:
get:
tags:
- corporation
summary: "Resolve public information for several corporation ids at once, silently\
\ omitting ids ESI cannot resolve"
operationId: findCorporations
parameters:
- name: ids
in: query
description: "Corporation ids to resolve, e.g. ids=98000001,98000002"
required: true
schema:
type: array
items:
type: integer
format: int64
responses:
"200":
description: The corporations that resolved; unknown ids are omitted
content:
'*/*':
schema:
type: array
items:
$ref: "#/components/schemas/CorporationResponse"
/corporations/{corporationId}:
get:
tags:
- corporation
summary: "Resolve public information (name, ticker, alliance) for a corporation\
\ id"
operationId: findCorporation
parameters:
- name: corporationId
in: path
description: "Corporation id to resolve, e.g. 98000001"
required: true
schema:
type: integer
format: int64
responses:
"200":
description: The resolved corporation
content:
'*/*':
schema:
$ref: "#/components/schemas/CorporationResponse"
"404":
description: Returned when ESI has no corporation with the given id
/characters: /characters:
get: get:
tags: tags:
@@ -858,6 +1102,19 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/CharacterResponse" $ref: "#/components/schemas/CharacterResponse"
/activities/processed:
get:
tags:
- activity
summary: Stream a notification when the user's activity processing completes
operationId: streamProcessing
responses:
"200":
description: SSE stream opened
content:
text/event-stream:
schema:
$ref: "#/components/schemas/SseEmitter"
/acquisitions: /acquisitions:
get: get:
tags: tags:
@@ -898,50 +1155,180 @@ paths:
$ref: "#/components/schemas/AcquisitionResponse" $ref: "#/components/schemas/AcquisitionResponse"
components: components:
schemas: schemas:
UpdateRuleBookRequest: RuleScopeRequest:
type: object type: object
description: Request to update the caller's rule book; replaces its mutable description: The ledgers and other resources to bind to a rule script.
fields.
properties: properties:
bindings: ledgers:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
format: uuid format: uuid
description: "Ledger references the script writes to, each bound to one\ description: "Ledger references the script writes to, each bound to one\
\ of the user's ledgers." \ of the user's own main ledgers."
characters:
type: object
additionalProperties:
type: integer
format: int64
description: "Character references the script can read, each bound to one\
\ of the user's own characters."
corporations:
type: object
additionalProperties:
type: integer
format: int64
description: "Corporation references the script can read, each bound to\
\ a corporation of one of the user's own characters."
walletDivisions:
type: object
additionalProperties:
$ref: "#/components/schemas/WalletDivisionTargetRequest"
description: "Corporation wallet division references the script can read,\
\ each bound to a corporation of one of the user's own characters."
required:
- characters
- corporations
- ledgers
- walletDivisions
RuleScriptRequest:
type: object
description: "A named rule script to store in the book, together with its scope."
properties:
name:
type: string
description: Unique script name within the book; a lowercase alphanumeric
identifier.
example: sales
scope:
$ref: "#/components/schemas/RuleScopeRequest"
description: The ledgers and other resources bound to this script.
script: script:
type: string type: string
description: The classification script source. description: The classification script source.
example: transfer.to('sales') example: transfer.to('wallet')
required: required:
- bindings - name
- scope
- script - script
UpdateRuleBookRequest:
type: object
description: Request to update the caller's rule book; replaces its rule scripts.
properties:
scripts:
type: array
description: The rule scripts making up the book.
items:
$ref: "#/components/schemas/RuleScriptRequest"
required:
- scripts
WalletDivisionTargetRequest:
type: object
description: A corporation wallet division to bind.
properties:
corporationId:
type: integer
format: int64
description: The corporation the wallet division belongs to.
example: 98000001
division:
type: integer
format: int32
description: "The wallet division number, from 1 to 7."
example: 1
required:
- corporationId
- division
RuleBookResponse: RuleBookResponse:
type: object type: object
description: "The caller's rule book: a script that classifies transactions,\ description: "The caller's rule book: the named rule scripts that classify transactions,\
\ together with its ledger bindings." \ each with its scope."
properties: properties:
userId: userId:
type: string type: string
format: uuid format: uuid
description: Owning user identifier. description: Owning user identifier.
example: 3fa85f64-5717-4562-b3fc-2c963f66afa6 example: 3fa85f64-5717-4562-b3fc-2c963f66afa6
bindings: scripts:
type: array
description: The rule scripts making up the book.
items:
$ref: "#/components/schemas/RuleScriptResponse"
required:
- scripts
- userId
RuleScopeResponse:
type: object
description: The ledgers and other resources bound to a rule script.
properties:
ledgers:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
format: uuid format: uuid
description: "Ledger references the script writes to, each bound to one\ description: "Ledger references the script writes to, each bound to one\
\ of the user's ledgers." \ of the user's own main ledgers."
characters:
type: object
additionalProperties:
type: integer
format: int64
description: "Character references the script can read, each bound to one\
\ of the user's own characters."
corporations:
type: object
additionalProperties:
type: integer
format: int64
description: "Corporation references the script can read, each bound to\
\ a corporation of one of the user's own characters."
walletDivisions:
type: object
additionalProperties:
$ref: "#/components/schemas/WalletDivisionTargetResponse"
description: "Corporation wallet division references the script can read,\
\ each bound to a corporation of one of the user's own characters."
required:
- characters
- corporations
- ledgers
- walletDivisions
RuleScriptResponse:
type: object
description: "A named rule script within the book, together with its scope."
properties:
name:
type: string
description: Unique script name within the book.
example: sales
scope:
$ref: "#/components/schemas/RuleScopeResponse"
description: The ledgers and other resources bound to this script.
script: script:
type: string type: string
description: The classification script source. description: The classification script source.
example: transfer.to('sales') example: transfer.to('wallet')
required: required:
- bindings - name
- scope
- script - script
- userId WalletDivisionTargetResponse:
type: object
description: A corporation wallet division bound to the script.
properties:
corporationId:
type: integer
format: int64
description: The corporation the wallet division belongs to.
example: 98000001
division:
type: integer
format: int32
description: "The wallet division number, from 1 to 7."
example: 1
required:
- corporationId
- division
UpdateMainLedgerRequest: UpdateMainLedgerRequest:
type: object type: object
description: Request to update an existing main ledger. description: Request to update an existing main ledger.
@@ -1585,7 +1972,7 @@ components:
type: string type: string
format: date-time format: date-time
description: When the order was issued. description: When the order was issued.
example: 2026-06-22T05:12:25Z example: 2026-06-22T05:12:25
escrow: escrow:
type: number type: number
description: ISK held in escrow for a buy order; null for sell orders. description: ISK held in escrow for a buy order; null for sell orders.
@@ -1606,6 +1993,45 @@ components:
- regionId - regionId
- volumeRemain - volumeRemain
- volumeTotal - volumeTotal
DockableLocationResponse:
type: object
description: A market location (NPC station) with its containing solar system
and region.
properties:
id:
type: integer
format: int64
description: EVE market location id (station id).
example: 60003760
name:
type: string
description: Location name.
example: Jita IV - Moon 4 - Caldari Navy Assembly Plant
systemId:
type: integer
format: int64
description: Id of the solar system the location is in.
example: 30000142
systemName:
type: string
description: Name of the solar system the location is in.
example: Jita
regionId:
type: integer
format: int64
description: Id of the region the location is in.
example: 10000002
regionName:
type: string
description: Name of the region the location is in.
example: The Forge
required:
- id
- name
- regionId
- regionName
- systemId
- systemName
LedgerResponse: LedgerResponse:
description: "A ledger, either a standalone main ledger or a combined ledger\ description: "A ledger, either a standalone main ledger or a combined ledger\
\ aggregating others." \ aggregating others."
@@ -1774,6 +2200,32 @@ components:
type: string type: string
required: required:
- type - type
DailyProfitResponse:
type: object
description: "Profit for a single day: ISK in, ISK out, and their difference."
properties:
date:
type: string
format: date
description: The UTC (EVE) day this profit is for.
example: 2026-06-22
iskIn:
type: number
description: Total ISK that entered the ledger on this day.
example: 1500000.0
iskOut:
type: number
description: Total ISK that left the ledger on this day.
example: 500000.0
profit:
type: number
description: "Profit for the day: iskIn minus iskOut."
example: 1000000.0
required:
- date
- iskIn
- iskOut
- profit
BalanceResponse: BalanceResponse:
type: object type: object
description: "The balance of a ledger: its ISK total plus the quantity of each\ description: "The balance of a ledger: its ISK total plus the quantity of each\
@@ -1808,6 +2260,41 @@ components:
required: required:
- quantity - quantity
- typeId - typeId
CorporationResponse:
type: object
description: An EVE Online corporation.
properties:
corporationId:
type: integer
format: int64
description: EVE corporation id.
example: 98000001
name:
type: string
description: Corporation name.
example: Center for Advanced Studies
ticker:
type: string
description: Corporation ticker.
example: CAS
allianceId:
type: integer
format: int64
description: "EVE alliance id, null when the corporation is not in an alliance."
example: 99000001
required:
- allianceId
- corporationId
- name
- ticker
SseEmitter:
type: object
properties:
timeout:
type: integer
format: int64
required:
- timeout
AcquisitionResponse: AcquisitionResponse:
type: object type: object
description: "A single acquisition of a market type, tracking the remaining\ description: "A single acquisition of a market type, tracking the remaining\
+30
View File
@@ -14,6 +14,7 @@
"@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",
"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",
@@ -21,6 +22,7 @@
"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,6 +231,12 @@
"@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.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1483,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",
@@ -2975,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",
+2
View File
@@ -16,6 +16,7 @@
"@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",
"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",
@@ -23,6 +24,7 @@
"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": {
+16 -1
View File
@@ -1,8 +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 {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();
@@ -10,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>
@@ -23,6 +37,7 @@ const hideSidebar = computed(() => {
</div> </div>
</template> </template>
<ConfirmModal /> <ConfirmModal />
<ToastContainer />
</template> </template>
<style scoped> <style scoped>
+19 -4
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed} from "vue";
import {computedAsync} from "@vueuse/core"; import {computedAsync} from "@vueuse/core";
import {ActivitySourceResponse, ActivitySourceResponseTypeEnum} from "@/generated/mammon"; import {ActivitySourceResponse, ActivitySourceResponseTypeEnum} from "@/generated/mammon";
import {CharacterLabel, useCharactersStore} from "@/characters"; import {CharacterLabel, useCharactersStore} from "@/characters";
import {CorporationLabel} from "@/corporations"; import {CorporationLabel, useCorporationsStore} from "@/corporations";
import {LedgerLabel, useLedgersStore} from "@/ledger";
interface Props { interface Props {
source: ActivitySourceResponse; source: ActivitySourceResponse;
@@ -15,6 +17,8 @@ const props = withDefaults(defineProps<Props>(), {
}); });
const charactersStore = useCharactersStore(); const charactersStore = useCharactersStore();
const corporationsStore = useCorporationsStore();
const ledgersStore = useLedgersStore();
const character = computedAsync(async () => { const character = computedAsync(async () => {
if (props.source.type === ActivitySourceResponseTypeEnum.Character && props.source.characterId) { if (props.source.type === ActivitySourceResponseTypeEnum.Character && props.source.characterId) {
@@ -22,11 +26,22 @@ const character = computedAsync(async () => {
} }
return undefined; return undefined;
}, 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> </script>
<template> <template>
<CharacterLabel v-if="source.type === ActivitySourceResponseTypeEnum.Character && character" :character="character" :size="size" /> <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" :division="source.division" :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 />
<span v-else class="text-slate-400">&mdash;</span>
</template> </template>
+6 -21
View File
@@ -1,8 +1,6 @@
import log from "loglevel"; import log from "loglevel";
import {getAccessToken} from "@/auth/token"; import {activityApi} from "@/mammon";
import {mammonUrl, refreshAccessToken} from "@/mammon";
const STREAM_URL = mammonUrl + "activities/processed";
const RECONNECT_DELAY_MILLIS = 3_000; const RECONNECT_DELAY_MILLIS = 3_000;
const FRAME_SEPARATOR = "\n\n"; const FRAME_SEPARATOR = "\n\n";
const PROCESSED_EVENT = "event:processed"; const PROCESSED_EVENT = "event:processed";
@@ -48,33 +46,20 @@ const consume = async (body: ReadableStream<Uint8Array>) => {
} }
}; };
const connect = async (retried = false): Promise<void> => { const connect = async (): Promise<void> => {
if (listeners.size === 0) { if (listeners.size === 0) {
return; return;
} }
const signal = controller?.signal; const signal = controller?.signal;
const token = getAccessToken();
try { try {
const response = await fetch(STREAM_URL, { const response = await activityApi.streamProcessing({
headers: { responseType: "stream",
Accept: "text/event-stream", adapter: "fetch",
...(token ? {Authorization: `Bearer ${token}`} : {}),
},
signal, signal,
}); });
if (response.status === 401) { await consume(response.data as unknown as ReadableStream<Uint8Array>);
if (!retried && await refreshAccessToken() && listeners.size > 0) {
return connect(true);
}
return;
}
if (!response.ok || !response.body) {
throw new Error(`Activity processing SSE stream failed with ${response.status}`);
}
await consume(response.body);
scheduleReconnect(); scheduleReconnect();
} catch (error) { } catch (error) {
if (listeners.size === 0 || signal?.aborted) { if (listeners.size === 0 || signal?.aborted) {
+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}; 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" />
+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';
+29 -16
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']; ]);
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`;
} }
return ['top', 'right']; if (positions.value.includes('left')) {
style.left = `${left.value}px`;
} else {
style.right = `${width.value - right.value}px`;
} }
if (left.value < width.value / 2) { return style;
return ['bottom', 'left']; });
}
return ['bottom', 'right'];
})
</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> </Teleport>
</div>
</div> </div>
</template> </template>
<style scoped>
@reference "@/style.css";
.tooltip-floating {
@apply fixed z-30;
}
</style>
+4 -1
View File
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {Corporation} from "./corporations.ts";
interface Props { interface Props {
corporationId: number; corporationId: number;
corporation?: Corporation | null;
division?: number | null; division?: number | null;
size?: number; size?: number;
} }
@@ -14,6 +17,6 @@ const props = withDefaults(defineProps<Props>(), {
<template> <template>
<div class="flex"> <div class="flex">
<img class="me-2" :src="`https://images.evetech.net/corporations/${corporationId}/logo?size=${size}`" /> <img class="me-2" :src="`https://images.evetech.net/corporations/${corporationId}/logo?size=${size}`" />
<span>Corporation<template v-if="division"> &middot; Division {{ division }}</template></span> <span>{{ corporation?.name ?? 'Corporation' }}<template v-if="division"> &middot; Division {{ division }}</template></span>
</div> </div>
</template> </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};
})
+2
View File
@@ -1 +1,3 @@
export * from './corporations.ts'
export {default as CorporationLabel} from './CorporationLabel.vue'; export {default as CorporationLabel} from './CorporationLabel.vue';
+1020 -168
View File
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -6,6 +6,7 @@ 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;
@@ -59,18 +60,16 @@ const title = computed(() => {
const create = () => { const create = () => {
if (type.value === LedgerTypes.Main) { if (type.value === LedgerTypes.Main) {
ledgersStore.createMain({name: name.value}) return ledgersStore.createMain({name: name.value})
} else {
ledgersStore.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) {
ledgersStore.updateMain(props.ledgerId, {name: name.value}) return ledgersStore.updateMain(props.ledgerId, {name: name.value})
} else {
ledgersStore.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 = () => {
@@ -78,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 });
+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';
+11
View File
@@ -1,12 +1,15 @@
import {logResource} from "@/service"; import {logResource} from "@/service";
import {getAccessToken, setAccessToken} from "@/auth/token"; import {getAccessToken, setAccessToken} from "@/auth/token";
import {toast} from "@/toast";
import axios, {InternalAxiosRequestConfig} from "axios"; import axios, {InternalAxiosRequestConfig} from "axios";
import { import {
AcquisitionApi, AcquisitionApi,
ActivityApi, ActivityApi,
AuthApi, AuthApi,
CharacterApi, CharacterApi,
CorporationApi,
LedgerApi, LedgerApi,
LocationApi,
MarketApi, MarketApi,
MeResponse, MeResponse,
ReprocessingApi, ReprocessingApi,
@@ -85,6 +88,12 @@ mammonAxiosInstance.interceptors.response.use(response => response, async error
} }
onAuthExpired(); onAuthExpired();
} }
const status = error.response?.status;
if (status !== 401 && status !== 403) {
toast.error('Something went wrong');
}
return Promise.reject(error); return Promise.reject(error);
}) })
@@ -100,9 +109,11 @@ export const postLogout = (): Promise<void> =>
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 ruleScriptApi = new RuleScriptApi(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 acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance); export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance); export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance); export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance);
export const locationApi = new LocationApi(undefined, mammonUrl, mammonAxiosInstance);
-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;
}
};
@@ -43,7 +43,7 @@ const lineColor = computed(() => {
<MarketTrendIcon v-else :trend="quartiles.trend" /> <MarketTrendIcon v-else :trend="quartiles.trend" />
</template> </template>
<template #default> <template #default>
<div class="bg-slate-500 -left-1/2 relative tooltip-content" v-if="quartiles"> <div class="bg-slate-500 tooltip-content" v-if="quartiles">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -74,9 +74,6 @@ const lineColor = computed(() => {
&.tooltip-top>:deep(div.header) { &.tooltip-top>:deep(div.header) {
@apply rounded-t-md bg-slate-600; @apply rounded-t-md bg-slate-600;
} }
&.tooltip-bottom .tooltip-content {
bottom: 75px;
}
&.tooltip-bottom>:deep(div.header) { &.tooltip-bottom>:deep(div.header) {
@apply rounded-b-md bg-slate-600; @apply rounded-b-md bg-slate-600;
} }
@@ -3,8 +3,8 @@ import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table'; import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {getListedSellTypeIds, 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 {computedAsync, 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 AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue'; import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts"; import {formatEveDate, formatIsk, percentFormater} from "@/formaters.ts";
@@ -67,7 +67,10 @@ const columnsToIgnore = computed(() => {
const marketTaxStore = useMarketTaxStore(); const marketTaxStore = useMarketTaxStore();
const ledgersStore = useLedgersStore(); const ledgersStore = useLedgersStore();
const listedTypeIds = computedAsync(getListedSellTypeIds, new Set<number>()); 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("");
@@ -75,7 +78,7 @@ const unlistedOnly = ref(false);
const matchesFilter = (r: AcquiredType) => const matchesFilter = (r: AcquiredType) =>
r.type.name.toLowerCase().includes(filter.value.toLowerCase()) r.type.name.toLowerCase().includes(filter.value.toLowerCase())
&& (!unlistedOnly.value || !listedTypeIds.value.has(r.type.id)); && (!unlistedOnly.value || !listedTypeIds.value.has(r.type.id));
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => { const results = computed<Result[]>(() => {
const filteredItems = props.items.filter(matchesFilter); const filteredItems = props.items.filter(matchesFilter);
if (props.showAll) { if (props.showAll) {
@@ -133,7 +136,8 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
}); });
}); });
return list; return list;
}), { });
const { sortedArray, headerProps, showColumn } = useSort<Result>(results, {
defaultSortKey: props.defaultSortKey, defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc', defaultSortDirection: 'desc',
ignoredColumns: columnsToIgnore ignoredColumns: columnsToIgnore
+19 -5
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import {getMarketTypes, MarketTypePrice, useAppraisalStore} from "@/market"; import {getMarketTypes, getPrices, MarketTypePrice} from "@/market";
import {ref, watch} from 'vue'; 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 {AcquiredType} from './AcquiredType';
import {RawAcquiredType} from './acquisition'; import {RawAcquiredType} from './acquisition';
import AcquisitionResultTable from './AcquisitionResultTable.vue'; import AcquisitionResultTable from './AcquisitionResultTable.vue';
@@ -18,7 +21,7 @@ const props = defineProps<Props>();
const buyModal = ref<typeof BuyModal>(); const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>(); const sellModal = ref<typeof SellModal>();
const appraisalStore = useAppraisalStore(); const marketLocationStore = useMarketLocationStore();
const enriched = ref<AcquiredType[]>([]); const enriched = ref<AcquiredType[]>([]);
const refresh = async (itms: RawAcquiredType[] = props.items) => { const refresh = async (itms: RawAcquiredType[] = props.items) => {
@@ -28,7 +31,7 @@ const refresh = async (itms: RawAcquiredType[] = props.items) => {
} }
const types = await getMarketTypes([...new Set(itms.map(i => i.type))]); const types = await getMarketTypes([...new Set(itms.map(i => i.type))]);
const prices = await appraisalStore.getPrices(types); const prices = await getPrices(types, marketLocationStore.location.id);
enriched.value = itms.map(i => { enriched.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice; const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
@@ -42,12 +45,23 @@ const refresh = async (itms: RawAcquiredType[] = props.items) => {
}); });
}; };
watch(() => props.items, refresh, {immediate: true}); watch([() => props.items, () => marketLocationStore.location], ([itms]) => refresh(itms), {immediate: true});
defineExpose({refresh}); const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(() => refresh(), 5 * 60 * 1000);
</script> </script>
<template> <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"> <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)" /> <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" /> <BuyModal ref="buyModal" />
+2 -1
View File
@@ -6,6 +6,7 @@ import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
import {computed, ref} from 'vue'; import {computed, ref} from 'vue';
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType'; 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 ledgersStore = useLedgersStore();
@@ -57,8 +58,8 @@ const add = async () => {
} }
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value)); await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
await acquiredTypesStore.processNewActivities();
modalOpen.value = false; modalOpen.value = false;
toast.success('Acquisition added');
} }
defineExpose({ open }); defineExpose({ open });
+2 -1
View File
@@ -5,6 +5,7 @@ import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType'; 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();
@@ -60,8 +61,8 @@ const remove = async () => {
await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime); await acquiredTypesStore.removeAcquiredType(t.id, quantity, lid, datetime);
remaining -= quantity; remaining -= quantity;
} }
await acquiredTypesStore.processNewActivities();
modalOpen.value = false; modalOpen.value = false;
toast.success('Consumption added');
} }
defineExpose({ open }); defineExpose({ open });
+1 -3
View File
@@ -60,8 +60,6 @@ export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
}); });
}; };
const processNewActivities = () => activityApi.processNewActivities();
const refresh = () => acquisitionApi.findAllAcquisitions() const refresh = () => acquisitionApi.findAllAcquisitions()
.then(response => acquiredTypes.value = response.data.map(toAcquiredType)); .then(response => acquiredTypes.value = response.data.map(toAcquiredType));
@@ -69,5 +67,5 @@ export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
onActivitiesProcessed(refresh); onActivitiesProcessed(refresh);
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, processNewActivities }; return { acquiredTypes: types, addAcquiredType, removeAcquiredType };
}); });
@@ -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[]>;
+6 -36
View File
@@ -1,47 +1,17 @@
import {defineStore} from 'pinia';
import {RegionalMarketCache} from '../RegionalMarketCache';
import {jitaId} from '../market';
import {MarketType} from "../type"; import {MarketType} from "../type";
import {MarketTypePrice} from './MarketTypePrice'; import {MarketTypePrice} from './MarketTypePrice';
import {getMammonPrices} from './mammon'; import {getMammonPrices} from './mammon';
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
export const useAppraisalStore = defineStore('appraisal', () => { export const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
const getPricesUncached = getMammonPrices; export const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
const cached: MarketTypePrice[] = [];
const uncached: MarketType[] = [];
const rId = regionId ?? jitaId;
types.forEach(t => {
const cachedPrice = cache.get(rId, t.id);
if (cachedPrice) {
cached.push(cachedPrice);
} else {
uncached.push(t);
}
});
if (uncached.length > 0) {
const batches: Promise<MarketTypePrice[]>[] = []; const batches: Promise<MarketTypePrice[]>[] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) { for (let i = 0; i < types.length; i += BATCH_SIZE) {
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE))); batches.push(getMammonPrices(types.slice(i, i + BATCH_SIZE), locationId));
} }
const prices = (await Promise.all(batches)).flat(); return (await Promise.all(batches)).flat();
};
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()];
};
-11
View File
@@ -1,11 +0,0 @@
import {logResource} from '@/service';
import axios from 'axios';
export const evepraisalAxiosInstance = axios.create({
baseURL: import.meta.env.VITE_EVEPRAISAL_URL,
headers: {
'accept': 'application/json',
"Content-Type": "application/json"
},
})
logResource(evepraisalAxiosInstance)
+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';
+2 -2
View File
@@ -1,13 +1,13 @@
import {marketApi} from '@/mammon/mammonService'; import {marketApi} from '@/mammon/mammonService';
import {MarketTypePrice, PriceGetter} from './MarketTypePrice'; import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
export const getMammonPrices: PriceGetter = async types => { export const getMammonPrices: PriceGetter = async (types, locationId) => {
if (types.length === 0) { if (types.length === 0) {
return []; return [];
} }
const typesById = new Map(types.map(t => [t.id, t])); const typesById = new Map(types.map(t => [t.id, t]));
const response = await marketApi.currentPrices(types.map(t => t.id)); const response = await marketApi.currentPrices(types.map(t => t.id), locationId);
return response.data.reduce<MarketTypePrice[]>((prices, p) => { return response.data.reduce<MarketTypePrice[]>((prices, p) => {
const type = typesById.get(p.marketTypeId); const type = typesById.get(p.marketTypeId);
+1 -2
View File
@@ -1,11 +1,10 @@
export * from './RegionalMarketCache';
export * from './history'; export * from './history';
export * from './location';
export * from './order'; 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;
-2
View File
@@ -2,7 +2,6 @@
import {CharacterLabel, useCharactersStore} from "@/characters"; import {CharacterLabel, useCharactersStore} from "@/characters";
import {useAuthStore} from "@/auth"; import {useAuthStore} from "@/auth";
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
const charactersStore = useCharactersStore() const charactersStore = useCharactersStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -17,7 +16,6 @@ const addCharacter = () => authStore.addCharacter();
</div> </div>
<div v-for="character in charactersStore.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="charactersStore.reloadActivities(character.characterId)"><ArrowPathIcon /></button>
</div> </div>
</div> </div>
</template> </template>
+6
View File
@@ -15,6 +15,12 @@ import {routeNames} from '@/routes';
<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>
+2 -1
View File
@@ -6,13 +6,14 @@ 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 {activityApi} from "@/mammon";
import {toast} from "@/toast";
const ledgersStore = useLedgersStore(); const ledgersStore = useLedgersStore();
const editLedgerModal = ref<typeof EditLedgerModal>(); const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => { const processActivities = async () => {
await activityApi.fetchAllNewActivities(); toast.info('Processing started');
await activityApi.processNewActivities(); await activityApi.processNewActivities();
} }
+33 -5
View File
@@ -1,10 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import {RouterLink, RouterView} from 'vue-router'; import {RouterLink, RouterView} from 'vue-router';
import {LedgerLabel, useLedgerParam} from "@/ledger"; import {appraiseItemBalances, getLedgerBalance, isMain, LedgerLabel, useLedgerParam} from "@/ledger";
import {routeNames} from "@/routes.ts"; import {routeNames} from "@/routes.ts";
import {IskLabel} from "@/market"; import {getMarketTypes, getPrices, IskLabel} from "@/market";
import {useMarketLocationStore} from '@/market/location';
import {computedAsync} from "@vueuse/core";
const {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>
@@ -14,8 +35,12 @@ const {ledger} = useLedgerParam();
<div class="flex w-full"> <div class="flex w-full">
<LedgerLabel :ledger="ledger" :link="false" /> <LedgerLabel :ledger="ledger" :link="false" />
</div> </div>
<div class="flex justify-end w-full"> <div class="flex justify-end items-baseline gap-3 w-full mb-2">
<IskLabel class="mb-2" :amount="ledger.balance" /> <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> </div>
<div class="flex border-b-2 border-emerald-500 mt-4"> <div class="flex border-b-2 border-emerald-500 mt-4">
@@ -28,6 +53,9 @@ const {ledger} = useLedgerParam();
<RouterLink :to="{name: routeNames.listLedgerAcquisitions}" class="tab"> <RouterLink :to="{name: routeNames.listLedgerAcquisitions}" class="tab">
<span>Acquisitions</span> <span>Acquisitions</span>
</RouterLink> </RouterLink>
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.viewLedgerStatistics}" class="tab">
<span>Statistics</span>
</RouterLink>
</div> </div>
<RouterView /> <RouterView />
</div> </div>
+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>
+1 -19
View File
@@ -1,29 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {AcquisitionsPanel, useAcquiredTypesStore} from '@/market/acquisition'; import {AcquisitionsPanel, useAcquiredTypesStore} from '@/market/acquisition';
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
import {useAutoRefresh} from '@/composables';
import {ref} from 'vue';
const acquiredTypesStore = useAcquiredTypesStore(); const acquiredTypesStore = useAcquiredTypesStore();
const panel = ref<InstanceType<typeof AcquisitionsPanel>>();
const refresh = async () => {
await panel.value?.refresh();
}
const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(refresh, 5 * 60 * 1000);
</script> </script>
<template> <template>
<div class="mt-4"> <div class="mt-4">
<div class="flex"> <AcquisitionsPanel :items="acquiredTypesStore.acquiredTypes" ignoredColumns="date" />
<div class="ms-auto 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>
<AcquisitionsPanel ref="panel" :items="acquiredTypesStore.acquiredTypes" ignoredColumns="date" />
</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>
+6 -4
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import {ClipboardButton} from '@/components'; import {ClipboardButton} from '@/components';
import {getMarketType, MarketType, MarketTypeInput, useAppraisalStore, useMarketTaxStore} from "@/market"; import {getMarketType, getPrice, MarketType, MarketTypeInput, useMarketTaxStore} from "@/market";
import {AcquisitionResultTable, BuyModal} from '@/market/acquisition'; import {AcquisitionResultTable, BuyModal} from '@/market/acquisition';
import {MarketLocationInput, useMarketLocationStore} from '@/market/location';
import {ScanResultTable, toScanResult} from '@/market/scan'; import {ScanResultTable, toScanResult} from '@/market/scan';
import {acquisitionApi, marketApi} from "@/mammon"; import {acquisitionApi, marketApi} from "@/mammon";
import {ShoppingCartIcon} from '@heroicons/vue/24/outline'; import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
@@ -18,10 +19,10 @@ const router = useRouter();
const item = ref<MarketType>(); const item = ref<MarketType>();
const inputItem = ref<MarketType>(); const inputItem = ref<MarketType>();
const appraisalStore = useAppraisalStore();
const marketTaxStore = useMarketTaxStore(); const marketTaxStore = useMarketTaxStore();
const marketLocationStore = useMarketLocationStore();
const days = useStorage('market-scan-days', 365); const days = useStorage('market-scan-days', 365);
const price = computedAsync(() => item.value ? appraisalStore.getPrice(item.value) : undefined); const price = computedAsync(() => item.value ? getPrice(item.value, marketLocationStore.location.id) : undefined);
const result = computedAsync(async () => { const result = computedAsync(async () => {
if (!item.value) { if (!item.value) {
return undefined; return undefined;
@@ -84,10 +85,11 @@ watch(useRoute(), async route => {
<template> <template>
<div class="grid mb-2 mt-4"> <div class="grid mb-2 mt-4">
<div class="w-auto flex"> <div class="w-auto flex items-center">
<span>Item: </span> <span>Item: </span>
<MarketTypeInput class="ms-2" v-model="inputItem" @submit="view"/> <MarketTypeInput class="ms-2" v-model="inputItem" @submit="view"/>
<button class="justify-self-end ms-2" @click="view">View</button> <button class="justify-self-end ms-2" @click="view">View</button>
<MarketLocationInput class="ms-auto" v-model="marketLocationStore.location" />
</div> </div>
</div> </div>
<template v-if="item"> <template v-if="item">
+195 -34
View File
@@ -4,46 +4,134 @@ import {useEventListener} from "@vueuse/core";
import log from "loglevel"; import log from "loglevel";
import {ScriptEditor, useRuleBookStore} from "@/rules"; import {ScriptEditor, useRuleBookStore} from "@/rules";
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline"; import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
import {Dropdown} from "@/components";
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger"; import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
import {useCharactersStore} from "@/characters";
import {CorporationLabel, useCorporationsStore} from "@/corporations";
import {RuleScriptRequest} from "@/generated/mammon";
import {toast} from "@/toast";
import {confirm} from "@/confirm";
type Binding = { ref: string; ledger: Ledger }; type LedgerScopeEntry = { ref: string; ledger: Ledger };
type CharacterScopeEntry = { ref: string; characterId: number };
type CorporationScopeEntry = { ref: string; corporationId: number };
type WalletDivisionScopeEntry = { ref: string; corporationId: number; division: number };
interface ScriptDraft {
name: string;
script: string;
ledgers: LedgerScopeEntry[];
characters: CharacterScopeEntry[];
corporations: CorporationScopeEntry[];
walletDivisions: WalletDivisionScopeEntry[];
}
const ruleBookStore = useRuleBookStore(); const ruleBookStore = useRuleBookStore();
const ledgersStore = useLedgersStore(); const ledgersStore = useLedgersStore();
const charactersStore = useCharactersStore();
const corporationsStore = useCorporationsStore();
const ledgersToUse = computed(() => [systemLedger, ...ledgersStore.ledgers.filter(isMain)]); const ledgersToUse = computed(() => [systemLedger, ...ledgersStore.ledgers.filter(isMain)]);
const bindings = ref<Binding[]>([]); const scripts = ref<ScriptDraft[]>([]);
const script = ref<string>(''); const selectedIndex = ref(0);
const scopesExpanded = ref(true);
const ledgerRefs = computed<string[]>(() => bindings.value.map(b => b.ref)); const ledgersExpanded = ref(true);
const charactersExpanded = ref(true);
const corporationsExpanded = ref(true);
const walletDivisionsExpanded = ref(true);
const selected = computed<ScriptDraft | undefined>(() => scripts.value[selectedIndex.value]);
watchEffect(() => { watchEffect(() => {
const ruleBook = ruleBookStore.ruleBook; const ruleBook = ruleBookStore.ruleBook;
script.value = ruleBook?.script ?? ''; scripts.value = (ruleBook?.scripts ?? []).map(script => ({
bindings.value = Object.entries(ruleBook?.bindings ?? {}) name: script.name,
.map(([ref, id]) => ({ref, ledger: ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger})); script: script.script,
ledgers: Object.entries(script.scope.ledgers).map(([ref, id]) => ({ref, ledger: ledgersToUse.value.find(l => l.ledgerId === id) ?? systemLedger})),
characters: Object.entries(script.scope.characters).map(([ref, characterId]) => ({ref, characterId})),
corporations: Object.entries(script.scope.corporations).map(([ref, corporationId]) => ({ref, corporationId})),
walletDivisions: Object.entries(script.scope.walletDivisions).map(([ref, target]) => ({ref, corporationId: target.corporationId, division: target.division})),
}));
selectedIndex.value = 0;
log.info('Loaded rule book:', ruleBook); log.info('Loaded rule book:', ruleBook);
}); });
const addBinding = () => { const editorScope = computed(() => selected.value ? {
bindings.value = [...bindings.value, {ref: '', ledger: systemLedger}]; ledgers: selected.value.ledgers.map(entry => entry.ref),
}; characters: selected.value.characters.map(entry => entry.ref),
corporations: selected.value.corporations.map(entry => entry.ref),
walletDivisions: selected.value.walletDivisions.map(entry => entry.ref),
} : undefined);
const removeBinding = (index: number) => { const scriptText = computed<string>({
bindings.value = bindings.value.toSpliced(index, 1); get: () => selected.value?.script ?? '',
}; set: value => {
if (selected.value) {
const save = () => ruleBookStore.update({ selected.value.script = value;
bindings: Object.fromEntries( }
bindings.value },
.filter(b => b.ref)
.map(b => [b.ref, b.ledger.ledgerId])
),
script: script.value
}); });
const addScript = () => {
scripts.value = [...scripts.value, {name: '', script: '', ledgers: [], characters: [], corporations: [], walletDivisions: []}];
selectedIndex.value = scripts.value.length - 1;
};
const removeScript = async (index: number) => {
const script = scripts.value[index];
if (script?.script.trim() && !await confirm({message: `Delete script "${script.name || '(unnamed)'}"? It still has content.`, danger: true})) {
return;
}
scripts.value = scripts.value.toSpliced(index, 1);
if (selectedIndex.value >= scripts.value.length) {
selectedIndex.value = Math.max(0, scripts.value.length - 1);
}
};
const addLedgerScope = () => selected.value?.ledgers.push({ref: '', ledger: systemLedger});
const removeLedgerScope = (index: number) => { if (selected.value) selected.value.ledgers = selected.value.ledgers.toSpliced(index, 1); };
const addCharacterScope = () => selected.value?.characters.push({ref: '', characterId: charactersStore.characters[0]?.characterId ?? 0});
const removeCharacterScope = (index: number) => { if (selected.value) selected.value.characters = selected.value.characters.toSpliced(index, 1); };
const addCorporationScope = () => selected.value?.corporations.push({ref: '', corporationId: 0});
const removeCorporationScope = (index: number) => { if (selected.value) selected.value.corporations = selected.value.corporations.toSpliced(index, 1); };
const addWalletDivisionScope = () => selected.value?.walletDivisions.push({ref: '', corporationId: 0, division: 1});
const removeWalletDivisionScope = (index: number) => { if (selected.value) selected.value.walletDivisions = selected.value.walletDivisions.toSpliced(index, 1); };
watchEffect(() => {
const corporationIds = new Set([
...(selected.value?.corporations.map(entry => entry.corporationId) ?? []),
...(selected.value?.walletDivisions.map(entry => entry.corporationId) ?? []),
]);
for (const corporationId of corporationIds) {
if (corporationId) {
corporationsStore.findById(corporationId);
}
}
});
const save = () => ruleBookStore.update(
scripts.value
.filter(s => s.name)
.map((s): RuleScriptRequest => ({
name: s.name,
script: s.script,
scope: {
ledgers: Object.fromEntries(s.ledgers.filter(entry => entry.ref).map(entry => [entry.ref, entry.ledger.ledgerId])),
characters: Object.fromEntries(s.characters.filter(entry => entry.ref).map(entry => [entry.ref, entry.characterId])),
corporations: Object.fromEntries(s.corporations.filter(entry => entry.ref).map(entry => [entry.ref, entry.corporationId])),
walletDivisions: Object.fromEntries(s.walletDivisions.filter(entry => entry.ref).map(entry => [entry.ref, {corporationId: entry.corporationId, division: entry.division}])),
},
}))
)
.then(() => toast.success('Rule book saved'));
useEventListener(window, 'keydown', (event: KeyboardEvent) => { useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault(); event.preventDefault();
@@ -53,31 +141,104 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
</script> </script>
<template> <template>
<div class="flex flex-col mb-2 mt-4 h-[calc(100vh-4.5rem)]"> <div class="flex mb-2 mt-4 h-[calc(100vh-1.5rem)]">
<div class="flex flex-col grow min-h-0"> <div class="flex flex-col me-4 w-48 shrink-0">
<div class="border-b-1"> <div class="grow overflow-y-auto">
Ledger Bindings: <div v-for="(script, index) in scripts" :key="index"
class="flex items-center mb-1 px-1 py-0.5 border-l-4 cursor-pointer"
:class="index === selectedIndex ? 'border-emerald-500' : 'border-transparent'"
@click="selectedIndex = index">
<span class="grow truncate">{{ script.name || '(unnamed)' }}</span>
<button class="btn-icon text-amber-700 hover:text-amber-600" @click.stop="removeScript(index)"><TrashIcon /></button>
</div>
<button class="btn-icon flex items-center" @click="addScript"><PlusIcon /> Add script</button>
</div>
</div>
<div v-if="selected" class="flex flex-col grow min-h-0">
<div class="border-b-1 mb-2 pb-2">
Name: <input class="me-1" type="text" v-model="selected.name" />
</div>
<Dropdown inline :auto-close="false" v-model:open="scopesExpanded" class="mb-2">
<template #button>Scopes</template>
<Dropdown inline :auto-close="false" v-model:open="ledgersExpanded" class="ms-4 mt-2">
<template #button>Ledgers</template>
<div class="flex flex-wrap items-center mt-2"> <div class="flex flex-wrap items-center mt-2">
<div class="flex items-center mb-2 me-2" v-for="(binding, index) in bindings" :key="index"> <div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.ledgers" :key="index">
<input class="me-1" type="text" v-model="binding.ref" /> <input class="me-1" type="text" v-model="entry.ref" />
<span class="me-1">:</span> <span class="me-1">:</span>
<LedgerSelect class="me-1" :ledgers="ledgersToUse" v-model="binding.ledger" /> <LedgerSelect class="me-1" :ledgers="ledgersToUse" v-model="entry.ledger" />
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeBinding(index)"><TrashIcon /></button> <button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeLedgerScope(index)"><TrashIcon /></button>
</div> </div>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<button class="btn-icon" @click="addBinding"><PlusIcon /></button> <button class="btn-icon" @click="addLedgerScope"><PlusIcon /></button>
</div> </div>
</div> </div>
</Dropdown>
<Dropdown inline :auto-close="false" v-model:open="charactersExpanded" class="ms-4 mt-2">
<template #button>Characters</template>
<div class="flex flex-wrap items-center mt-2">
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.characters" :key="index">
<input class="me-1" type="text" v-model="entry.ref" />
<span class="me-1">:</span>
<select class="me-1" v-model="entry.characterId">
<option v-for="character in charactersStore.characters" :key="character.characterId" :value="character.characterId">{{ character.name }}</option>
</select>
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeCharacterScope(index)"><TrashIcon /></button>
</div> </div>
<div class="flex items-center mb-2">
<button class="btn-icon" @click="addCharacterScope"><PlusIcon /></button>
</div>
</div>
</Dropdown>
<Dropdown inline :auto-close="false" v-model:open="corporationsExpanded" class="ms-4 mt-2">
<template #button>Corporations</template>
<div class="flex flex-wrap items-center mt-2">
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.corporations" :key="index">
<input class="me-1" type="text" v-model="entry.ref" />
<span class="me-1">:</span>
<input class="me-1 w-32" type="number" v-model.number="entry.corporationId" placeholder="Corporation ID" />
<CorporationLabel class="me-1" :corporation-id="entry.corporationId" :corporation="corporationsStore.corporations[entry.corporationId]" :size="16" v-if="entry.corporationId" />
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeCorporationScope(index)"><TrashIcon /></button>
</div>
<div class="flex items-center mb-2">
<button class="btn-icon" @click="addCorporationScope"><PlusIcon /></button>
</div>
</div>
</Dropdown>
<Dropdown inline :auto-close="false" v-model:open="walletDivisionsExpanded" class="ms-4 mt-2 mb-2">
<template #button>Wallet Divisions</template>
<div class="flex flex-wrap items-center mt-2">
<div class="flex items-center mb-2 me-2" v-for="(entry, index) in selected.walletDivisions" :key="index">
<input class="me-1" type="text" v-model="entry.ref" />
<span class="me-1">:</span>
<input class="me-1 w-32" type="number" v-model.number="entry.corporationId" placeholder="Corporation ID" />
<input class="me-1 w-16" type="number" min="1" max="7" v-model.number="entry.division" placeholder="Division" />
<CorporationLabel class="me-1" :corporation-id="entry.corporationId" :corporation="corporationsStore.corporations[entry.corporationId]" :division="entry.division" :size="16" v-if="entry.corporationId" />
<button class="btn-icon text-amber-700 hover:text-amber-600" @click="removeWalletDivisionScope(index)"><TrashIcon /></button>
</div>
<div class="flex items-center mb-2">
<button class="btn-icon" @click="addWalletDivisionScope"><PlusIcon /></button>
</div>
</div>
</Dropdown>
</Dropdown>
<div class="flex flex-col grow min-h-0 border-b-1"> <div class="flex flex-col grow min-h-0 border-b-1">
Script: Script:
<ScriptEditor class="mt-2 mb-2" v-model="script" :ledgerRefs="ledgerRefs" /> <ScriptEditor class="mt-2 mb-2" v-model="scriptText" :scope="editorScope" />
</div>
</div> </div>
<div class="mt-2 justify-end flex"> <div class="mt-2 justify-end flex">
<div>
<button @click="save">Save</button> <button @click="save">Save</button>
</div> </div>
</div> </div>
<div v-else class="flex grow items-center justify-center text-neutral-500">
No scripts yet
</div>
</div> </div>
</template> </template>
+6
View File
@@ -13,8 +13,11 @@ export const routeNames = {
viewLedgerBalance: 'view-ledger-balance', viewLedgerBalance: 'view-ledger-balance',
listLedgerTransactions: 'list-ledger-transactions', listLedgerTransactions: 'list-ledger-transactions',
listLedgerAcquisitions: 'list-ledger-acquisitions', listLedgerAcquisitions: 'list-ledger-acquisitions',
viewLedgerStatistics: 'view-ledger-statistics',
editRuleBook: 'edit-rule-book', editRuleBook: 'edit-rule-book',
marketTypes: 'market-types', marketTypes: 'market-types',
appraise: 'appraise',
compareAppraisal: 'compare-appraisal',
about: 'about', about: 'about',
} as const; } as const;
@@ -29,6 +32,7 @@ export const routes: RouteRecordRaw[] = [
{path: 'balance', name: routeNames.viewLedgerBalance, component: () => import('@/pages/ledger/ViewLedgerBalance.vue')}, {path: 'balance', name: routeNames.viewLedgerBalance, component: () => import('@/pages/ledger/ViewLedgerBalance.vue')},
{path: 'transactions', name: routeNames.listLedgerTransactions, component: () => import('@/pages/ledger/ListLedgerTransactions.vue')}, {path: 'transactions', name: routeNames.listLedgerTransactions, component: () => import('@/pages/ledger/ListLedgerTransactions.vue')},
{path: 'acquisitions', name: routeNames.listLedgerAcquisitions, component: () => import('@/pages/ledger/ViewLedgerAcquisitions.vue')}, {path: 'acquisitions', name: routeNames.listLedgerAcquisitions, component: () => import('@/pages/ledger/ViewLedgerAcquisitions.vue')},
{path: 'statistics', name: routeNames.viewLedgerStatistics, component: () => import('@/pages/ledger/ViewLedgerStatistics.vue')},
]}, ]},
]}, ]},
@@ -39,6 +43,8 @@ export const routes: RouteRecordRaw[] = [
{path: 'types/:type?', name: routeNames.marketTypes, component: () => import('@/pages/market/TypeInfo.vue')}, {path: 'types/:type?', name: routeNames.marketTypes, component: () => import('@/pages/market/TypeInfo.vue')},
{path: 'scan', component: () => import('@/pages/market/Scan.vue')}, {path: 'scan', component: () => import('@/pages/market/Scan.vue')},
{path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')}, {path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')},
{path: 'appraisal', name: routeNames.appraise, component: () => import('@/pages/market/Appraise.vue')},
{path: 'appraisal/compare', name: routeNames.compareAppraisal, component: () => import('@/pages/market/CompareAppraisal.vue')},
]}, ]},
{path: '/reprocess', component: () => import('@/pages/Reprocess.vue')}, {path: '/reprocess', component: () => import('@/pages/Reprocess.vue')},
+27 -13
View File
@@ -22,27 +22,41 @@ const loadScriptDefinitions = async () => {
} }
try { try {
const definitions = await fetchScriptDefinitions(); const definitions = await fetchScriptDefinitions();
monaco.typescript.javascriptDefaults.addExtraLib(definitions, 'ts:rule-runner.d.ts'); monaco.typescript.javascriptDefaults.addExtraLib(definitions, 'file:///node_modules/@eveal/runtime/index.d.ts');
extraLibLoaded = true; extraLibLoaded = true;
} catch { } catch {
// type definitions are optional — the editor still works without autocomplete // type definitions are optional — the editor still works without autocomplete
} }
}; };
const props = defineProps<{ ledgerRefs?: string[] }>(); export interface ScriptEditorScope {
ledgers?: string[];
characters?: string[];
corporations?: string[];
walletDivisions?: string[];
}
let ledgersLib: monaco.IDisposable | undefined; const props = defineProps<{ scope?: ScriptEditorScope }>();
const updateLedgerRefs = (refs: readonly string[]) => { let scopeLib: monaco.IDisposable | undefined;
ledgersLib?.dispose();
const declareInterface = (name: string, type: string, refs: readonly string[]) => {
const members = refs const members = refs
.filter(ref => ref && ref !== 'system') .filter(ref => ref && ref !== 'system')
.map(ref => ` readonly ${JSON.stringify(ref)}: Ledger;`) .map(ref => ` readonly ${JSON.stringify(ref)}: ${type};`)
.join('\n'); .join('\n');
ledgersLib = monaco.typescript.javascriptDefaults.addExtraLib( return ` interface ${name} {\n${members}\n }\n`;
`declare interface Ledgers {\n${members}\n}\n`, };
'ts:rule-runner.ledgers.d.ts'
); const updateScope = (scope: ScriptEditorScope) => {
scopeLib?.dispose();
const source = `declare module '@eveal/runtime' {\n${[
declareInterface('Ledgers', 'Ledger', scope.ledgers ?? []),
declareInterface('Characters', 'Character', scope.characters ?? []),
declareInterface('Corporations', 'Corporation', scope.corporations ?? []),
declareInterface('WalletDivisions', 'WalletDivision', scope.walletDivisions ?? []),
].join('\n')}}\n`;
scopeLib = monaco.typescript.javascriptDefaults.addExtraLib(source, 'ts:rule-runner.scope.d.ts');
}; };
const model = defineModel<string>({default: ''}); const model = defineModel<string>({default: ''});
@@ -51,7 +65,7 @@ let editor: monaco.editor.IStandaloneCodeEditor | undefined;
onMounted(async () => { onMounted(async () => {
await loadScriptDefinitions(); await loadScriptDefinitions();
updateLedgerRefs(props.ledgerRefs ?? []); updateScope(props.scope ?? {});
if (!container.value) { if (!container.value) {
return; return;
@@ -83,11 +97,11 @@ watch(model, value => {
} }
}); });
watch(() => props.ledgerRefs, refs => updateLedgerRefs(refs ?? []), {deep: true}); watch(() => props.scope, scope => updateScope(scope ?? {}), {deep: true});
onBeforeUnmount(() => { onBeforeUnmount(() => {
editor?.dispose(); editor?.dispose();
ledgersLib?.dispose(); scopeLib?.dispose();
}); });
</script> </script>
+16 -3
View File
@@ -1,10 +1,19 @@
import {ruleBookApi, ruleScriptApi} from "@/mammon"; import {ruleBookApi, ruleScriptApi} from "@/mammon";
import {RuleBookResponse, UpdateRuleBookRequest} from "@/generated/mammon"; import {RuleBookResponse, RuleScopeRequest, RuleScriptRequest, RuleScriptResponse, UpdateRuleBookRequest} from "@/generated/mammon";
import {isAxiosError} from "axios"; import {isAxiosError} from "axios";
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {ref} from "vue"; import {ref} from "vue";
export type RuleBook = RuleBookResponse; export type RuleBook = RuleBookResponse;
export type RuleScript = RuleScriptResponse;
export type RuleScope = RuleScriptResponse['scope'];
export const emptyScope = (): RuleScopeRequest => ({
ledgers: {},
characters: {},
corporations: {},
walletDivisions: {},
});
export const useRuleBookStore = defineStore('rule-book', () => { export const useRuleBookStore = defineStore('rule-book', () => {
const ruleBook = ref<RuleBook | null>(null); const ruleBook = ref<RuleBook | null>(null);
@@ -19,7 +28,7 @@ export const useRuleBookStore = defineStore('rule-book', () => {
throw error; throw error;
}); });
const update = (request: UpdateRuleBookRequest) => ruleBookApi.updateRuleBook(request) const update = (scripts: RuleScriptRequest[]) => ruleBookApi.updateRuleBook({scripts} satisfies UpdateRuleBookRequest)
.then(response => ruleBook.value = response.data); .then(response => ruleBook.value = response.data);
refresh(); refresh();
@@ -28,4 +37,8 @@ export const useRuleBookStore = defineStore('rule-book', () => {
}) })
export const fetchScriptDefinitions = (): Promise<string> => export const fetchScriptDefinitions = (): Promise<string> =>
ruleScriptApi.getScriptDefinitions({responseType: 'text'}).then(response => response.data); ruleScriptApi.listScriptDefinitions()
.then(response => Promise.all(
response.data.map(name => ruleScriptApi.getScriptDefinition(name, {responseType: 'text'}).then(r => r.data))
))
.then(definitions => definitions.join('\n'));
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import {
CheckCircleIcon,
InformationCircleIcon,
XCircleIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline';
import type {ToastType} from './useToast';
import {useToastStore} from './useToast';
const toastStore = useToastStore();
const icons = {
success: CheckCircleIcon,
error: XCircleIcon,
info: InformationCircleIcon,
} as const;
const accent = (type: ToastType) => `accent-${type}`;
</script>
<template>
<div class="toast-container">
<TransitionGroup name="toast">
<div v-for="toast in toastStore.toasts" :key="toast.id" class="toast" :class="accent(toast.type)">
<component :is="icons[toast.type]" class="icon" />
<div class="body">
<span v-if="toast.title" class="title">{{ toast.title }}</span>
<span class="message">{{ toast.message }}</span>
</div>
<button class="btn-icon close" @click="toastStore.dismiss(toast.id)">
<XMarkIcon />
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
@reference "@/style.css";
div.toast-container {
@apply fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)];
}
div.toast {
@apply flex items-start gap-2 p-3 rounded bg-slate-700 border-l-4 shadow-lg;
&.accent-success {
@apply border-emerald-500;
> .icon { @apply text-emerald-500; }
}
&.accent-error {
@apply border-amber-700;
> .icon { @apply text-amber-700; }
}
&.accent-info {
@apply border-sky-600;
> .icon { @apply text-sky-600; }
}
> .icon {
@apply w-6 h-6 shrink-0;
}
> .body {
@apply flex flex-col grow min-w-0;
> .title {
@apply font-semibold;
}
> .message {
@apply break-words;
}
}
> .close {
@apply shrink-0;
}
}
.toast-enter-active,
.toast-leave-active,
.toast-move {
@apply transition-all duration-300;
}
.toast-enter-from {
@apply opacity-0 translate-x-8;
}
.toast-leave-to {
@apply opacity-0 -translate-y-8;
}
.toast-leave-active {
@apply absolute w-full;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as ToastContainer } from './ToastContainer.vue';
export { toast, useToastStore } from './useToast';
export type { ToastOptions, ToastType } from './useToast';
+63
View File
@@ -0,0 +1,63 @@
import {defineStore} from "pinia";
import {ref} from "vue";
export type ToastType = 'success' | 'error' | 'info';
export interface ToastOptions {
message: string;
title?: string;
type?: ToastType;
duration?: number;
}
export interface Toast {
id: number;
message: string;
title?: string;
type: ToastType;
}
const DEFAULT_DURATION = 5000;
export const useToastStore = defineStore('toast', () => {
const toasts = ref<Toast[]>([]);
let nextId = 0;
const dismiss = (id: number) => {
toasts.value = toasts.value.filter(toast => toast.id !== id);
};
const push = (opts: ToastOptions | string): number => {
const options = typeof opts === "string" ? {message: opts} : opts;
const id = nextId++;
toasts.value.push({
id,
message: options.message,
title: options.title,
type: options.type ?? 'info',
});
const duration = options.duration ?? DEFAULT_DURATION;
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
return id;
};
return {toasts, push, dismiss};
});
interface ToastHelper {
(opts: ToastOptions | string): number;
success: (message: string) => number;
error: (message: string) => number;
info: (message: string) => number;
}
export const toast: ToastHelper = Object.assign(
(opts: ToastOptions | string): number => useToastStore().push(opts),
{
success: (message: string): number => useToastStore().push({message, type: 'success'}),
error: (message: string): number => useToastStore().push({message, type: 'error'}),
info: (message: string): number => useToastStore().push({message, type: 'info'}),
},
);
+4 -1
View File
@@ -1 +1,4 @@
export const copyToClipboard = (s: string) => navigator.clipboard.writeText(s); import {toast} from '@/toast';
export const copyToClipboard = (s: string) => navigator.clipboard.writeText(s)
.then(() => toast({message: 'Copied', type: 'info', duration: 500}));
-3
View File
@@ -20,8 +20,5 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
strictPort: true, strictPort: true,
watch: {
usePolling: true
}
} }
}); });