feat(#39): Add a Statistics tab to the ledger view

This commit is contained in:
Sirttas
2026-07-26 12:07:49 +02:00
parent 04a9f197e8
commit 38e1063ac5
9 changed files with 425 additions and 2 deletions
+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';
+89
View File
@@ -190,6 +190,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.
*/
@@ -3707,6 +3728,40 @@ 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 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 +3789,19 @@ 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 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 +3821,16 @@ 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 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 +3848,17 @@ 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 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));
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {RouterLink, RouterView} from 'vue-router';
import {LedgerLabel, useLedgerParam} from "@/ledger";
import {isMain, LedgerLabel, useLedgerParam} from "@/ledger";
import {routeNames} from "@/routes.ts";
import {IskLabel} from "@/market";
@@ -28,6 +28,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>
+155
View File
@@ -0,0 +1,155 @@
<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 profits = useProcessedResource<DailyProfitResponse[]>(async () => {
if (!ledgerId.value) {
return [];
}
const {data} = 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-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>
+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')},
]},
]},