Compare commits

...
10 Commits
37 changed files with 1359 additions and 188 deletions
+186 -1
View File
@@ -359,6 +359,23 @@ paths:
description: The link is seeded; follow with a full-page nav to /oauth2/authorization/esi
"401":
description: Missing or expired access token
/balances/rebuild:
post:
tags:
- balance-rebuild-admin-controller
operationId: rebuild
parameters:
- name: ledgerIds
in: query
required: true
schema:
type: array
items:
type: string
format: uuid
responses:
"204":
description: No Content
/activity/consume:
post:
tags:
@@ -405,6 +422,15 @@ paths:
- activity
summary: Process new activities for the authenticated user's characters
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:
"202":
description: Processing started in the background
@@ -904,6 +930,62 @@ paths:
$ref: "#/components/schemas/TransactionResponse"
"404":
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 realized profit for the ledger, ascending by date, only\
\ for days with a sale"
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:
get:
tags:
@@ -927,6 +1009,56 @@ paths:
$ref: "#/components/schemas/BalanceResponse"
"404":
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:
get:
tags:
@@ -1682,7 +1814,7 @@ components:
type: string
format: date-time
description: When the order was issued.
example: 2026-06-22T05:12:25Z
example: 2026-06-22T05:12:25
escrow:
type: number
description: ISK held in escrow for a buy order; null for sell orders.
@@ -1910,6 +2042,32 @@ components:
type: string
required:
- 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:
type: object
description: "The balance of a ledger: its ISK total plus the quantity of each\
@@ -1944,6 +2102,33 @@ components:
required:
- quantity
- 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:
+30
View File
@@ -14,6 +14,7 @@
"@vueuse/integrations": "^14.3.0",
"@vueuse/router": "^14.3.0",
"axios": "^1.4.0",
"chart.js": "^4.5.1",
"gemory": "file:",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
@@ -21,6 +22,7 @@
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"vue": "^3.3.4",
"vue-chartjs": "^5.3.4",
"vue-router": "^5.0.7"
},
"devDependencies": {
@@ -229,6 +231,12 @@
"@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": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1483,6 +1491,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": {
"version": "5.0.0",
"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": {
"version": "5.1.0",
"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/router": "^14.3.0",
"axios": "^1.4.0",
"chart.js": "^4.5.1",
"gemory": "file:",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
@@ -23,6 +24,7 @@
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"vue": "^3.3.4",
"vue-chartjs": "^5.3.4",
"vue-router": "^5.0.7"
},
"devDependencies": {
+16 -1
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import {computed} from 'vue';
import {computed, watchEffect} from 'vue';
import {RouterView, useRoute} from 'vue-router';
import {Sidebar} from './sidebar';
import {ConfirmModal} from '@/confirm';
import {toast, ToastContainer} from '@/toast';
import {onActivitiesProcessed} from '@/activity';
import {useAuthStore} from '@/auth';
import {routeNames} from '@/routes';
const route = useRoute();
@@ -10,6 +13,17 @@ const route = useRoute();
const hideSidebar = computed(() => {
return route.name === routeNames.callback || route.name === routeNames.about;
});
const authStore = useAuthStore();
watchEffect(onCleanup => {
if (!authStore.isAuthenticated) {
return;
}
const unsubscribe = onActivitiesProcessed(() => toast.info('New activities have been processed'));
onCleanup(unsubscribe);
});
</script>
<template>
@@ -23,6 +37,7 @@ const hideSidebar = computed(() => {
</div>
</template>
<ConfirmModal />
<ToastContainer />
</template>
<style scoped>
+10 -2
View File
@@ -4,7 +4,7 @@ import {computed} from "vue";
import {computedAsync} from "@vueuse/core";
import {ActivitySourceResponse, ActivitySourceResponseTypeEnum} from "@/generated/mammon";
import {CharacterLabel, useCharactersStore} from "@/characters";
import {CorporationLabel} from "@/corporations";
import {CorporationLabel, useCorporationsStore} from "@/corporations";
import {LedgerLabel, useLedgersStore} from "@/ledger";
interface Props {
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const charactersStore = useCharactersStore();
const corporationsStore = useCorporationsStore();
const ledgersStore = useLedgersStore();
const character = computedAsync(async () => {
@@ -26,6 +27,13 @@ const character = computedAsync(async () => {
return undefined;
}, undefined);
const corporation = computedAsync(async () => {
if (props.source.type === ActivitySourceResponseTypeEnum.Corporation && props.source.corporationId) {
return await corporationsStore.findById(props.source.corporationId);
}
return undefined;
}, undefined);
const ledger = computed(() =>
props.source.type === ActivitySourceResponseTypeEnum.Ledger && props.source.ledgerId
? ledgersStore.findById(props.source.ledgerId)
@@ -34,6 +42,6 @@ const ledger = computed(() =>
<template>
<CharacterLabel v-if="source.type === ActivitySourceResponseTypeEnum.Character && character" :character="character" :size="size" />
<CorporationLabel v-else-if="source.type === ActivitySourceResponseTypeEnum.Corporation && source.corporationId" :corporation-id="source.corporationId" :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 />
</template>
+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>
+1
View File
@@ -1,5 +1,6 @@
export { default as ClipboardButton } from './ClipboardButton.vue';
export { default as Dropdown } from './Dropdown.vue';
export { default as DualRangeSlider } from './DualRangeSlider.vue';
export { default as LoadingSpinner } from './LoadingSpinner.vue';
export { default as Modal } from './Modal.vue';
export { default as ProgressBar } from './ProgressBar.vue';
+29 -16
View File
@@ -8,20 +8,27 @@ const open = defineModel('open', { default: false });
const { width, height } = useSharedWindowSize();
const mainDiv = ref<HTMLDivElement | null>(null);
const { top, left } = useElementBounding(mainDiv);
const { top, bottom, left, right } = useElementBounding(mainDiv);
const positions = computed(() => {
if (top.value < height.value / 2) {
if (left.value < width.value / 2) {
return ['top', 'left'];
const positions = computed(() => [
top.value < height.value / 2 ? 'top' : 'bottom',
left.value < width.value / 2 ? 'left' : 'right',
]);
const floatingStyle = computed(() => {
const style: Record<string, string> = {};
if (positions.value.includes('top')) {
style.top = `${bottom.value}px`;
} else {
style.bottom = `${height.value - top.value}px`;
}
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 ['bottom', 'left'];
}
return ['bottom', 'right'];
})
return style;
});
</script>
<template>
@@ -35,12 +42,18 @@ const positions = computed(() => {
<div v-element-hover="(h: boolean) => open = h" class="m-auto header">
<slot name="header" />
</div>
<div v-if="open" class="m-auto">
<div class="z-10 relative">
<div class="absolute">
<Teleport to="body">
<div v-if="open" class="tooltip-floating" :style="floatingStyle">
<slot />
</div>
</div>
</div>
</Teleport>
</div>
</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">
import {Corporation} from "./corporations.ts";
interface Props {
corporationId: number;
corporation?: Corporation | null;
division?: number | null;
size?: number;
}
@@ -14,6 +17,6 @@ const props = withDefaults(defineProps<Props>(), {
<template>
<div class="flex">
<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>
</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';
+466 -7
View File
@@ -168,6 +168,27 @@ export interface CombinedLedgerResponse extends LedgerResponse {
*/
'memberLedgerIds': Array<string>;
}
/**
* An EVE Online corporation.
*/
export interface CorporationResponse {
/**
* EVE corporation id.
*/
'corporationId': number;
/**
* Corporation name.
*/
'name': string;
/**
* Corporation ticker.
*/
'ticker': string;
/**
* EVE alliance id, null when the corporation is not in an alliance.
*/
'allianceId': number;
}
/**
* Request to create a new combined ledger aggregating the given member ledgers.
*/
@@ -190,6 +211,27 @@ export interface CreateMainLedgerRequest {
*/
'name': string;
}
/**
* Profit for a single day: ISK in, ISK out, and their difference.
*/
export interface DailyProfitResponse {
/**
* The UTC (EVE) day this profit is for.
*/
'date': string;
/**
* Total ISK that entered the ledger on this day.
*/
'iskIn': number;
/**
* Total ISK that left the ledger on this day.
*/
'iskOut': number;
/**
* Profit for the day: iskIn minus iskOut.
*/
'profit': number;
}
/**
* A market location (NPC station) with its containing solar system and region.
*/
@@ -970,10 +1012,11 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
/**
*
* @summary Process new activities for the authenticated user\'s characters
* @param {string} [from] Reprocess by walking the processed tail back to this UTC datetime (ISO-8601); omit to only process new activities
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
processNewActivities: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
processNewActivities: async (from?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/activities/process`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -986,6 +1029,12 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (from !== undefined) {
localVarQueryParameter['from'] = (from as any instanceof Date) ?
(from as any).toISOString() :
from;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
@@ -1064,11 +1113,12 @@ export const ActivityApiFp = function(configuration?: Configuration) {
/**
*
* @summary Process new activities for the authenticated user\'s characters
* @param {string} [from] Reprocess by walking the processed tail back to this UTC datetime (ISO-8601); omit to only process new activities
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async processNewActivities(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.processNewActivities(options);
async processNewActivities(from?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.processNewActivities(from, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ActivityApi.processNewActivities']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@@ -1117,11 +1167,12 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP
/**
*
* @summary Process new activities for the authenticated user\'s characters
* @param {string} [from] Reprocess by walking the processed tail back to this UTC datetime (ISO-8601); omit to only process new activities
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
processNewActivities(options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.processNewActivities(options).then((request) => request(axios, basePath));
processNewActivities(from?: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.processNewActivities(from, options).then((request) => request(axios, basePath));
},
/**
*
@@ -1164,11 +1215,12 @@ export class ActivityApi extends BaseAPI {
/**
*
* @summary Process new activities for the authenticated user\'s characters
* @param {string} [from] Reprocess by walking the processed tail back to this UTC datetime (ISO-8601); omit to only process new activities
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public processNewActivities(options?: RawAxiosRequestConfig) {
return ActivityApiFp(this.configuration).processNewActivities(options).then((request) => request(this.axios, this.basePath));
public processNewActivities(from?: string, options?: RawAxiosRequestConfig) {
return ActivityApiFp(this.configuration).processNewActivities(from, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -1459,6 +1511,105 @@ export class AuthApi extends BaseAPI {
/**
* BalanceRebuildAdminControllerApi - axios parameter creator
*/
export const BalanceRebuildAdminControllerApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {Array<string>} ledgerIds
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
rebuild: async (ledgerIds: Array<string>, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ledgerIds' is not null or undefined
assertParamExists('rebuild', 'ledgerIds', ledgerIds)
const localVarPath = `/balances/rebuild`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (ledgerIds) {
localVarQueryParameter['ledgerIds'] = ledgerIds;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* BalanceRebuildAdminControllerApi - functional programming interface
*/
export const BalanceRebuildAdminControllerApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = BalanceRebuildAdminControllerApiAxiosParamCreator(configuration)
return {
/**
*
* @param {Array<string>} ledgerIds
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async rebuild(ledgerIds: Array<string>, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.rebuild(ledgerIds, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['BalanceRebuildAdminControllerApi.rebuild']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* BalanceRebuildAdminControllerApi - factory interface
*/
export const BalanceRebuildAdminControllerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = BalanceRebuildAdminControllerApiFp(configuration)
return {
/**
*
* @param {Array<string>} ledgerIds
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
rebuild(ledgerIds: Array<string>, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.rebuild(ledgerIds, options).then((request) => request(axios, basePath));
},
};
};
/**
* BalanceRebuildAdminControllerApi - object-oriented interface
*/
export class BalanceRebuildAdminControllerApi extends BaseAPI {
/**
*
* @param {Array<string>} ledgerIds
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public rebuild(ledgerIds: Array<string>, options?: RawAxiosRequestConfig) {
return BalanceRebuildAdminControllerApiFp(this.configuration).rebuild(ledgerIds, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* CharacterApi - axios parameter creator
*/
@@ -1553,6 +1704,178 @@ export class CharacterApi extends BaseAPI {
/**
* CorporationApi - axios parameter creator
*/
export const CorporationApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @summary Resolve public information (name, ticker, alliance) for a corporation id
* @param {number} corporationId Corporation id to resolve, e.g. 98000001
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
findCorporation: async (corporationId: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'corporationId' is not null or undefined
assertParamExists('findCorporation', 'corporationId', corporationId)
const localVarPath = `/corporations/{corporationId}`
.replace('{corporationId}', encodeURIComponent(String(corporationId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Accept'] = '*/*';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Resolve public information for several corporation ids at once, silently omitting ids ESI cannot resolve
* @param {Array<number>} ids Corporation ids to resolve, e.g. ids&#x3D;98000001,98000002
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
findCorporations: async (ids: Array<number>, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ids' is not null or undefined
assertParamExists('findCorporations', 'ids', ids)
const localVarPath = `/corporations`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (ids) {
localVarQueryParameter['ids'] = ids;
}
localVarHeaderParameter['Accept'] = '*/*';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* CorporationApi - functional programming interface
*/
export const CorporationApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = CorporationApiAxiosParamCreator(configuration)
return {
/**
*
* @summary Resolve public information (name, ticker, alliance) for a corporation id
* @param {number} corporationId Corporation id to resolve, e.g. 98000001
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async findCorporation(corporationId: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<CorporationResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.findCorporation(corporationId, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['CorporationApi.findCorporation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Resolve public information for several corporation ids at once, silently omitting ids ESI cannot resolve
* @param {Array<number>} ids Corporation ids to resolve, e.g. ids&#x3D;98000001,98000002
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async findCorporations(ids: Array<number>, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<CorporationResponse>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.findCorporations(ids, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['CorporationApi.findCorporations']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* CorporationApi - factory interface
*/
export const CorporationApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = CorporationApiFp(configuration)
return {
/**
*
* @summary Resolve public information (name, ticker, alliance) for a corporation id
* @param {number} corporationId Corporation id to resolve, e.g. 98000001
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
findCorporation(corporationId: number, options?: RawAxiosRequestConfig): AxiosPromise<CorporationResponse> {
return localVarFp.findCorporation(corporationId, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Resolve public information for several corporation ids at once, silently omitting ids ESI cannot resolve
* @param {Array<number>} ids Corporation ids to resolve, e.g. ids&#x3D;98000001,98000002
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
findCorporations(ids: Array<number>, options?: RawAxiosRequestConfig): AxiosPromise<Array<CorporationResponse>> {
return localVarFp.findCorporations(ids, options).then((request) => request(axios, basePath));
},
};
};
/**
* CorporationApi - object-oriented interface
*/
export class CorporationApi extends BaseAPI {
/**
*
* @summary Resolve public information (name, ticker, alliance) for a corporation id
* @param {number} corporationId Corporation id to resolve, e.g. 98000001
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public findCorporation(corporationId: number, options?: RawAxiosRequestConfig) {
return CorporationApiFp(this.configuration).findCorporation(corporationId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Resolve public information for several corporation ids at once, silently omitting ids ESI cannot resolve
* @param {Array<number>} ids Corporation ids to resolve, e.g. ids&#x3D;98000001,98000002
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public findCorporations(ids: Array<number>, options?: RawAxiosRequestConfig) {
return CorporationApiFp(this.configuration).findCorporations(ids, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* LedgerApi - axios parameter creator
*/
@@ -3707,6 +4030,74 @@ export const TransactionApiAxiosParamCreator = function (configuration?: Configu
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get the daily FIFO-realized profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
marginPerDayInLedger: async (ledgerId: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ledgerId' is not null or undefined
assertParamExists('marginPerDayInLedger', 'ledgerId', ledgerId)
const localVarPath = `/ledgers/{ledgerId}/margin`
.replace('{ledgerId}', encodeURIComponent(String(ledgerId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Accept'] = '*/*';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get the daily profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
profitPerDayInLedger: async (ledgerId: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ledgerId' is not null or undefined
assertParamExists('profitPerDayInLedger', 'ledgerId', ledgerId)
const localVarPath = `/ledgers/{ledgerId}/profit`
.replace('{ledgerId}', encodeURIComponent(String(ledgerId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Accept'] = '*/*';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@@ -3734,6 +4125,32 @@ export const TransactionApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['TransactionApi.finAllTransactionsInLedger']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Get the daily FIFO-realized profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async marginPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<DailyProfitResponse>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.marginPerDayInLedger(ledgerId, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['TransactionApi.marginPerDayInLedger']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Get the daily profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async profitPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<DailyProfitResponse>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.profitPerDayInLedger(ledgerId, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['TransactionApi.profitPerDayInLedger']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@@ -3753,6 +4170,26 @@ export const TransactionApiFactory = function (configuration?: Configuration, ba
finAllTransactionsInLedger(ledgerId: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<TransactionResponse>> {
return localVarFp.finAllTransactionsInLedger(ledgerId, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get the daily FIFO-realized profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
marginPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<DailyProfitResponse>> {
return localVarFp.marginPerDayInLedger(ledgerId, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get the daily profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
profitPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<DailyProfitResponse>> {
return localVarFp.profitPerDayInLedger(ledgerId, options).then((request) => request(axios, basePath));
},
};
};
@@ -3770,6 +4207,28 @@ export class TransactionApi extends BaseAPI {
public finAllTransactionsInLedger(ledgerId: string, options?: RawAxiosRequestConfig) {
return TransactionApiFp(this.configuration).finAllTransactionsInLedger(ledgerId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get the daily FIFO-realized profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public marginPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig) {
return TransactionApiFp(this.configuration).marginPerDayInLedger(ledgerId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get the daily profit series for a single ledger
* @param {string} ledgerId Id of the ledger
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public profitPerDayInLedger(ledgerId: string, options?: RawAxiosRequestConfig) {
return TransactionApiFp(this.configuration).profitPerDayInLedger(ledgerId, options).then((request) => request(this.axios, this.basePath));
}
}
+9 -11
View File
@@ -6,6 +6,7 @@ import {Modal} from "@/components";
import LedgerLabel from "./LedgerLabel.vue";
import {PlusIcon, TrashIcon} from '@heroicons/vue/24/outline';
import LedgerSelect from "@/ledger/LedgerSelect.vue";
import {toast} from "@/toast";
interface Props {
ledgerId?: string;
@@ -59,18 +60,16 @@ const title = computed(() => {
const create = () => {
if (type.value === LedgerTypes.Main) {
ledgersStore.createMain({name: name.value})
} else {
ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
return ledgersStore.createMain({name: name.value})
}
return ledgersStore.createCombined({name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
}
const update = () => {
if (type.value === LedgerTypes.Main) {
ledgersStore.updateMain(props.ledgerId, {name: name.value})
} else {
ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
return ledgersStore.updateMain(props.ledgerId, {name: name.value})
}
return ledgersStore.updateCombined(props.ledgerId, {name: name.value, memberLedgerIds: members.value.map(l => l.ledgerId)})
}
const save = () => {
@@ -78,12 +77,11 @@ const save = () => {
return;
}
if (isCreating.value) {
create();
} else {
update();
}
const creating = isCreating.value;
const action = creating ? create() : update();
modalOpen.value = false;
action.then(() => toast.success(creating ? 'Ledger created' : 'Ledger updated'));
}
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 './balanceAppraisal';
export {default as LedgerLabel} from './LedgerLabel.vue';
export {default as LedgerSelect} from './LedgerSelect.vue';
+9
View File
@@ -1,11 +1,13 @@
import {logResource} from "@/service";
import {getAccessToken, setAccessToken} from "@/auth/token";
import {toast} from "@/toast";
import axios, {InternalAxiosRequestConfig} from "axios";
import {
AcquisitionApi,
ActivityApi,
AuthApi,
CharacterApi,
CorporationApi,
LedgerApi,
LocationApi,
MarketApi,
@@ -86,6 +88,12 @@ mammonAxiosInstance.interceptors.response.use(response => response, async error
}
onAuthExpired();
}
const status = error.response?.status;
if (status !== 401 && status !== 403) {
toast.error('Something went wrong');
}
return Promise.reject(error);
})
@@ -101,6 +109,7 @@ export const postLogout = (): Promise<void> =>
export const ledgerApi = new LedgerApi(undefined, mammonUrl, mammonAxiosInstance);
export const transactionApi = new TransactionApi(undefined, mammonUrl, mammonAxiosInstance);
export const characterApi = new CharacterApi(undefined, mammonUrl, mammonAxiosInstance);
export const corporationApi = new CorporationApi(undefined, mammonUrl, mammonAxiosInstance);
export const ruleBookApi = new RuleBookApi(undefined, mammonUrl, mammonAxiosInstance);
export const ruleScriptApi = new RuleScriptApi(undefined, mammonUrl, mammonAxiosInstance);
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
-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" />
</template>
<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>
<thead>
<tr>
@@ -74,9 +74,6 @@ const lineColor = computed(() => {
&.tooltip-top>:deep(div.header) {
@apply rounded-t-md bg-slate-600;
}
&.tooltip-bottom .tooltip-content {
bottom: 75px;
}
&.tooltip-bottom>:deep(div.header) {
@apply rounded-b-md bg-slate-600;
}
+2 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {getMarketTypes, MarketTypePrice, useAppraisalStore} from "@/market";
import {getMarketTypes, getPrices, MarketTypePrice} from "@/market";
import {ref, watch} from 'vue';
import {ArrowPathIcon} from '@heroicons/vue/24/outline';
import {useAutoRefresh} from '@/composables';
@@ -20,7 +20,6 @@ const props = defineProps<Props>();
const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>();
const appraisalStore = useAppraisalStore();
const enriched = ref<AcquiredType[]>([]);
const refresh = async (itms: RawAcquiredType[] = props.items) => {
@@ -30,7 +29,7 @@ const refresh = async (itms: RawAcquiredType[] = props.items) => {
}
const types = await getMarketTypes([...new Set(itms.map(i => i.type))]);
const prices = await appraisalStore.getPrices(types);
const prices = await getPrices(types);
enriched.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
+2
View File
@@ -6,6 +6,7 @@ import {Ledger, LedgerSelect, isMain, useLedgersStore} from '@/ledger';
import {computed, ref} from 'vue';
import {fromEveDatetimeInput, toEveDatetimeInput} from './AcquiredType';
import {useAcquiredTypesStore} from './acquisition';
import {toast} from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore();
const ledgersStore = useLedgersStore();
@@ -58,6 +59,7 @@ const add = async () => {
await acquiredTypesStore.addAcquiredType(id, count.value, price.value, lid, fromEveDatetimeInput(date.value));
modalOpen.value = false;
toast.success('Acquisition added');
}
defineExpose({ open });
+2
View File
@@ -5,6 +5,7 @@ import { Ledger, LedgerSelect, isMain, useLedgersStore } from '@/ledger';
import { computed, ref } from 'vue';
import { AcquiredType, acquiredTypesToSorted, fromEveDatetimeInput, toEveDatetimeInput } from './AcquiredType';
import { useAcquiredTypesStore } from './acquisition';
import { toast } from '@/toast';
const acquiredTypesStore = useAcquiredTypesStore();
@@ -61,6 +62,7 @@ const remove = async () => {
remaining -= quantity;
}
modalOpen.value = false;
toast.success('Consumption added');
}
defineExpose({ open });
+5 -35
View File
@@ -1,47 +1,17 @@
import {defineStore} from 'pinia';
import {RegionalMarketCache} from '../RegionalMarketCache';
import {jitaId} from '../market';
import {MarketType} from "../type";
import {MarketTypePrice} from './MarketTypePrice';
import {getMammonPrices} from './mammon';
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
const BATCH_SIZE = 100;
export const useAppraisalStore = defineStore('appraisal', () => {
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
export const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
const getPricesUncached = getMammonPrices;
const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const cached: MarketTypePrice[] = [];
const uncached: MarketType[] = [];
const lId = locationId ?? jitaId;
types.forEach(t => {
const cachedPrice = cache.get(lId, t.id);
if (cachedPrice) {
cached.push(cachedPrice);
} else {
uncached.push(t);
}
});
if (uncached.length > 0) {
export const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const batches: Promise<MarketTypePrice[]>[] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE), lId));
for (let i = 0; i < types.length; i += BATCH_SIZE) {
batches.push(getMammonPrices(types.slice(i, i + BATCH_SIZE), locationId));
}
const prices = (await Promise.all(batches)).flat();
prices.forEach(p => cache.set(lId, p.type.id, p));
return [ ...cached, ...prices ];
}
return cached;
return (await Promise.all(batches)).flat();
};
return { getPrice, getPrices };
});
-2
View File
@@ -1,4 +1,3 @@
export * from './RegionalMarketCache';
export * from './history';
export * from './location';
export * from './order';
@@ -6,7 +5,6 @@ export * from './tax';
export * from './type';
export * from './appraisal';
export * from './market';
export { default as IskLabel } from './IskLabel.vue';
-1
View File
@@ -1 +0,0 @@
export const jitaId = 60003760;
+2
View File
@@ -6,12 +6,14 @@ import {PencilSquareIcon} from "@heroicons/vue/24/outline";
import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {activityApi} from "@/mammon";
import {toast} from "@/toast";
const ledgersStore = useLedgersStore();
const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => {
toast.info('Processing started');
await activityApi.processNewActivities();
}
+31 -5
View File
@@ -1,10 +1,29 @@
<script setup lang="ts">
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 {IskLabel} from "@/market";
import {getMarketTypes, getPrices, IskLabel} from "@/market";
import {computedAsync} from "@vueuse/core";
const {ledger} = useLedgerParam();
const {ledgerId, ledger} = useLedgerParam();
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);
const appraisal = appraiseItemBalances(itemBalances, prices);
return appraisal.buy === 0 && appraisal.sell === 0 ? undefined : appraisal;
}, undefined);
</script>
@@ -14,8 +33,12 @@ const {ledger} = useLedgerParam();
<div class="flex w-full">
<LedgerLabel :ledger="ledger" :link="false" />
</div>
<div class="flex justify-end w-full">
<IskLabel class="mb-2" :amount="ledger.balance" />
<div class="flex justify-end items-baseline gap-3 w-full mb-2">
<span v-if="itemAppraisal" class="text-sm text-gray-400">
~<IskLabel :amount="itemAppraisal.buy" :colored="false" /> buy /
~<IskLabel :amount="itemAppraisal.sell" :colored="false" /> sell
</span>
<IskLabel :amount="ledger.balance" />
</div>
</div>
<div class="flex border-b-2 border-emerald-500 mt-4">
@@ -28,6 +51,9 @@ const {ledger} = useLedgerParam();
<RouterLink :to="{name: routeNames.listLedgerAcquisitions}" class="tab">
<span>Acquisitions</span>
</RouterLink>
<RouterLink v-if="isMain(ledger)" :to="{name: routeNames.viewLedgerStatistics}" class="tab">
<span>Statistics</span>
</RouterLink>
</div>
<RouterView />
</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>
+2 -4
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import {ref} from 'vue';
import {Dropdown} from '@/components';
import {compareAppraisal, ComparedItem, CompareResultTable, useAppraisalStore} from '@/market/appraisal';
import {compareAppraisal, ComparedItem, CompareResultTable, getPrices} from '@/market/appraisal';
import {MarketLocation, MarketLocationInput} from '@/market/location';
const store = useAppraisalStore();
const items = ref('');
const from = ref<MarketLocation>();
const to = ref<MarketLocation>();
@@ -19,7 +17,7 @@ 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), store.getPrices);
result.value = await compareAppraisal(items.value, selected.map(l => l.id), getPrices);
resultLocations.value = selected;
} finally {
loading.value = false;
+2 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
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 {ScanResultTable, toScanResult} from '@/market/scan';
import {acquisitionApi, marketApi} from "@/mammon";
@@ -18,10 +18,9 @@ const router = useRouter();
const item = ref<MarketType>();
const inputItem = ref<MarketType>();
const appraisalStore = useAppraisalStore();
const marketTaxStore = useMarketTaxStore();
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) : undefined);
const result = computedAsync(async () => {
if (!item.value) {
return undefined;
+3 -1
View File
@@ -5,6 +5,7 @@ import log from "loglevel";
import {ScriptEditor, useRuleBookStore} from "@/rules";
import {PlusIcon, TrashIcon} from "@heroicons/vue/24/outline";
import {isMain, Ledger, LedgerSelect, systemLedger, useLedgersStore} from "@/ledger";
import {toast} from "@/toast";
type Binding = { ref: string; ledger: Ledger };
@@ -42,7 +43,8 @@ const save = () => ruleBookStore.update({
.map(b => [b.ref, b.ledger.ledgerId])
),
script: script.value
});
})
.then(() => toast.success('Rule book saved'));
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
+2
View File
@@ -13,6 +13,7 @@ export const routeNames = {
viewLedgerBalance: 'view-ledger-balance',
listLedgerTransactions: 'list-ledger-transactions',
listLedgerAcquisitions: 'list-ledger-acquisitions',
viewLedgerStatistics: 'view-ledger-statistics',
editRuleBook: 'edit-rule-book',
marketTypes: 'market-types',
appraise: 'appraise',
@@ -31,6 +32,7 @@ export const routes: RouteRecordRaw[] = [
{path: 'balance', name: routeNames.viewLedgerBalance, component: () => import('@/pages/ledger/ViewLedgerBalance.vue')},
{path: 'transactions', name: routeNames.listLedgerTransactions, component: () => import('@/pages/ledger/ListLedgerTransactions.vue')},
{path: 'acquisitions', name: routeNames.listLedgerAcquisitions, component: () => import('@/pages/ledger/ViewLedgerAcquisitions.vue')},
{path: 'statistics', name: routeNames.viewLedgerStatistics, component: () => import('@/pages/ledger/ViewLedgerStatistics.vue')},
]},
]},
+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}));