4 Commits
21 changed files with 209 additions and 78 deletions
+110
View File
@@ -0,0 +1,110 @@
import log from "loglevel";
import {getAccessToken} from "@/auth/token";
import {mammonUrl, refreshAccessToken} from "@/mammon";
const STREAM_URL = mammonUrl + "activities/processed";
const RECONNECT_DELAY_MILLIS = 3_000;
const FRAME_SEPARATOR = "\n\n";
const PROCESSED_EVENT = "event:processed";
const listeners = new Set<() => void>();
let controller: AbortController | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
const notifyProcessed = () => listeners.forEach(listener => listener());
const scheduleReconnect = () => {
if (listeners.size === 0) {
return;
}
reconnectTimer = setTimeout(() => connect(), RECONNECT_DELAY_MILLIS);
};
const consume = async (body: ReadableStream<Uint8Array>) => {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (; ;) {
const {value, done} = await reader.read();
if (done) {
return;
}
buffer += decoder.decode(value, {stream: true});
let boundary = buffer.indexOf(FRAME_SEPARATOR);
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + FRAME_SEPARATOR.length);
if (frame.split("\n").some(line => line.trim() === PROCESSED_EVENT)) {
notifyProcessed();
}
boundary = buffer.indexOf(FRAME_SEPARATOR);
}
}
};
const connect = async (retried = false): Promise<void> => {
if (listeners.size === 0) {
return;
}
const signal = controller?.signal;
const token = getAccessToken();
try {
const response = await fetch(STREAM_URL, {
headers: {
Accept: "text/event-stream",
...(token ? {Authorization: `Bearer ${token}`} : {}),
},
signal,
});
if (response.status === 401) {
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();
} catch (error) {
if (listeners.size === 0 || signal?.aborted) {
return;
}
log.debug("Activity processing SSE error, reconnecting", error);
scheduleReconnect();
}
};
const close = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
controller?.abort();
controller = undefined;
};
export const onActivitiesProcessed = (listener: () => void): (() => void) => {
listeners.add(listener);
if (!controller) {
controller = new AbortController();
connect();
}
return () => {
if (listeners.delete(listener) && listeners.size === 0) {
close();
}
};
};
+3
View File
@@ -1 +1,4 @@
export {default as SourceLabel} from './SourceLabel.vue';
export {onActivitiesProcessed} from './activitiesProcessed';
export {useActivitiesProcessed} from './useActivitiesProcessed';
export {useProcessedResource} from './useProcessedResource';
+8
View File
@@ -0,0 +1,8 @@
import {onUnmounted} from "vue";
import {onActivitiesProcessed} from "./activitiesProcessed";
export const useActivitiesProcessed = (listener: () => void): (() => void) => {
const unsubscribe = onActivitiesProcessed(listener);
onUnmounted(unsubscribe);
return unsubscribe;
};
+15
View File
@@ -0,0 +1,15 @@
import {ref, type Ref, watchEffect} from "vue";
import {useActivitiesProcessed} from "./useActivitiesProcessed";
export const useProcessedResource = <T>(loader: () => Promise<T>, initial: T): Ref<T> => {
const data = ref(initial) as Ref<T>;
const load = async () => {
data.value = await loader();
};
watchEffect(load);
useActivitiesProcessed(load);
return data;
};
+1 -1
View File
@@ -24,5 +24,5 @@ export const useCharactersStore = defineStore('characters', () => {
refresh();
return {characters, findById, reloadActivities, refresh};
return {characters, findById, reloadActivities};
})
+1 -1
View File
@@ -37,7 +37,7 @@ describe('useSort', () => {
})
test('Hides ignored columns', () => {
const { showColumn } = useSort(array, { ignoredColums: ['key1'] })
const { showColumn } = useSort(array, { ignoredColumns: ['key1'] })
expect(showColumn('key1')).toBe(false)
})
+2 -2
View File
@@ -5,7 +5,7 @@ export type SortDirection = "asc" | "desc";
export type UseSortOptions = {
defaultSortKey?: string;
defaultSortDirection?: SortDirection;
ignoredColums?: MaybeRefOrGetter<string[]>;
ignoredColumns?: MaybeRefOrGetter<string[]>;
headerComponent?: HeaderComponent;
};
@@ -16,7 +16,7 @@ export const useSort = <T>(array: MaybeRefOrGetter<T[]>, options?: UseSortOption
sortKey.value = key;
sortDirection.value = direction;
};
const showColumn = (sortKey: string) => !toValue(options?.ignoredColums)?.includes(sortKey);
const showColumn = (sortKey: string) => !toValue(options?.ignoredColumns)?.includes(sortKey);
const headerProps = computed(() => ({
onSort: sortBy,
showColumn,
+4 -1
View File
@@ -13,6 +13,7 @@ import {defineStore} from "pinia";
import {computed, ref, triggerRef} from "vue";
import {ledgerApi, transactionApi} from "@/mammon";
import {useRouteParams} from "@vueuse/router";
import {onActivitiesProcessed} from "@/activity";
export const LedgerTypes = {
Main: 'MAIN',
@@ -71,7 +72,9 @@ export const useLedgersStore = defineStore('ledgers', () => {
refresh();
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined, refresh};
onActivitiesProcessed(refresh);
return {ledgers, findById, findAllById, createMain, createCombined, updateMain, updateCombined};
})
const getLedgerId = (ledger: Ledger | string): string => typeof ledger == 'string' ? ledger : ledger.ledgerId;
@@ -1,9 +1,9 @@
<script setup lang="ts">
import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketType, MarketTypeLabel, TaxInput, useMarketOrdersStore, useMarketTaxStore} from "@/market";
import {getListedSellTypeIds, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
import {MinusIcon, PlusIcon} from '@heroicons/vue/24/outline';
import {useStorage} from '@vueuse/core';
import {computedAsync, useStorage} from '@vueuse/core';
import {computed, ref} from 'vue';
import {AcquiredType} from './AcquiredType';
import AcquisitionQuartilesTooltip from './AcquisitionQuartilesTooltip.vue';
@@ -31,7 +31,7 @@ interface Props {
items?: AcquiredType[];
infoOnly?: boolean;
showAll?: boolean;
ignoredColums?: string[] | string;
ignoredColumns?: string[] | string;
defaultSortKey?: string;
}
@@ -44,36 +44,37 @@ const props = withDefaults(defineProps<Props>(), {
items: () => [],
infoOnly: false,
showAll: false,
ignoredColums: () => [],
ignoredColumns: () => [],
defaultSortKey: 'precentProfit',
});
defineEmits<Emits>();
const columnsToIgnore = computed(() => {
const ignoredColums = typeof props.ignoredColums === 'string' ? [props.ignoredColums] : [...props.ignoredColums];
const ignoredColumns = typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : [...props.ignoredColumns];
if (props.infoOnly && !ignoredColums.includes('buttons')) {
ignoredColums.push('buttons');
if (props.infoOnly && !ignoredColumns.includes('buttons')) {
ignoredColumns.push('buttons');
}
if (!props.showAll && !ignoredColums.includes('ledger')) {
ignoredColums.push('ledger');
if (!props.showAll && !ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledger');
}
if (ignoredColums.includes('ledger')) {
ignoredColums.push('ledgerName');
if (ignoredColumns.includes('ledger')) {
ignoredColumns.push('ledgerName');
}
return ignoredColums;
return ignoredColumns;
});
const marketTaxStore = useMarketTaxStore();
const ledgersStore = useLedgersStore();
const marketOrdersStore = useMarketOrdersStore();
const listedTypeIds = computedAsync(getListedSellTypeIds, new Set<number>());
const threshold = useStorage('market-acquisition-threshold', 10);
const filter = ref("");
const unlistedOnly = ref(false);
const matchesFilter = (r: AcquiredType) =>
r.type.name.toLowerCase().includes(filter.value.toLowerCase())
&& (!unlistedOnly.value || !marketOrdersStore.listedTypeIds.has(r.type.id));
&& (!unlistedOnly.value || !listedTypeIds.value.has(r.type.id));
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => {
const filteredItems = props.items.filter(matchesFilter);
@@ -135,7 +136,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
}), {
defaultSortKey: props.defaultSortKey,
defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore
ignoredColumns: columnsToIgnore
})
const getLineColor = (result: Result) => {
if (result.precentProfit >= (threshold.value / 100)) {
+11 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {getMarketTypes, MarketTypePrice, useApraisalStore} from "@/market";
import {getMarketTypes, MarketTypePrice, useAppraisalStore} from "@/market";
import {ref, watch} from 'vue';
import {AcquiredType} from './AcquiredType';
import {RawAcquiredType} from './acquisition';
@@ -9,7 +9,7 @@ import SellModal from './SellModal.vue';
interface Props {
items: RawAcquiredType[];
ignoredColums?: string[] | string;
ignoredColumns?: string[] | string;
ledgerId?: string;
}
@@ -18,17 +18,17 @@ const props = defineProps<Props>();
const buyModal = ref<typeof BuyModal>();
const sellModal = ref<typeof SellModal>();
const apraisalStore = useApraisalStore();
const appraisalStore = useAppraisalStore();
const enriched = ref<AcquiredType[]>([]);
watch(() => props.items, async itms => {
const refresh = async (itms: RawAcquiredType[] = props.items) => {
if (itms.length === 0) {
enriched.value = [];
return;
}
const types = await getMarketTypes([...new Set(itms.map(i => i.type))]);
const prices = await apraisalStore.getPrices(types);
const prices = await appraisalStore.getPrices(types);
enriched.value = itms.map(i => {
const price = prices.find(p => p.type.id === i.type) as MarketTypePrice;
@@ -40,12 +40,16 @@ watch(() => props.items, async itms => {
sell: price.sell
};
});
}, {immediate: true});
};
watch(() => props.items, refresh, {immediate: true});
defineExpose({refresh});
</script>
<template>
<template v-if="enriched.length > 0">
<AcquisitionResultTable :items="enriched" :ignoredColums="ignoredColums" @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" />
<SellModal ref="sellModal" />
</template>
+4 -1
View File
@@ -2,6 +2,7 @@ import {defineStore} from "pinia";
import {computed, ref} from "vue";
import {acquisitionApi, activityApi} from "@/mammon";
import {AcquisitionResponse, ActivitySourceResponse} from "@/generated/mammon";
import {onActivitiesProcessed} from "@/activity";
export type RawAcquiredType = {
id: string;
@@ -66,5 +67,7 @@ export const useAcquiredTypesStore = defineStore('market-acquisition', () => {
refresh();
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, processNewActivities, refresh };
onActivitiesProcessed(refresh);
return { acquiredTypes: types, addAcquiredType, removeAcquiredType, processNewActivities };
});
+1 -1
View File
@@ -8,7 +8,7 @@ import {getMammonPrices} from './mammon';
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
const BATCH_SIZE = 100;
export const useApraisalStore = defineStore('appraisal', () => {
export const useAppraisalStore = defineStore('appraisal', () => {
const cache: RegionalMarketCache<MarketTypePrice> = new RegionalMarketCache(CACHE_DURATION);
const getPricesUncached = getMammonPrices;
+5 -19
View File
@@ -1,21 +1,7 @@
import {defineStore} from "pinia";
import {computed, ref} from "vue";
import {marketApi} from "@/mammon";
import {MarketOrderResponse, MarketOrderResponseDirectionEnum} from "@/generated/mammon";
import {MarketOrderResponseDirectionEnum} from "@/generated/mammon";
export const useMarketOrdersStore = defineStore('market-orders', () => {
const orders = ref<MarketOrderResponse[]>([]);
const listedTypeIds = computed(() => new Set(
orders.value
.filter(o => o.direction === MarketOrderResponseDirectionEnum.Sell)
.map(o => o.marketTypeId)
));
const refresh = () => marketApi.findAllMarketOrders()
.then(response => orders.value = response.data);
refresh();
return {orders, listedTypeIds, refresh};
});
export const getListedSellTypeIds = (): Promise<Set<number>> => marketApi.findAllMarketOrders()
.then(response => new Set(response.data
.filter(o => o.direction === MarketOrderResponseDirectionEnum.Sell)
.map(o => o.marketTypeId)));
+4 -4
View File
@@ -27,7 +27,7 @@ type Result = {
interface Props {
items?: ScanResult[];
infoOnly?: boolean;
ignoredColums?: string[] | string;
ignoredColumns?: string[] | string;
}
const scoreFormater = new Intl.NumberFormat("en-US", {
@@ -40,7 +40,7 @@ const volumeFormater = new Intl.NumberFormat("en-US", {
const props = withDefaults(defineProps<Props>(), {
items: () => [],
infoOnly: false,
ignoredColums: () => []
ignoredColumns: () => []
});
const acquiredTypesStore = useAcquiredTypesStore();
@@ -54,7 +54,7 @@ const trackedTrends = useStorage<MarketTrend[]>('market-scan-trends', [
MarketTrends.Down,
]);
const columnsToIgnore = computed(() =>
typeof props.ignoredColums === 'string' ? [props.ignoredColums] : props.ignoredColums
typeof props.ignoredColumns === 'string' ? [props.ignoredColumns] : props.ignoredColumns
);
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
@@ -82,7 +82,7 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'score',
defaultSortDirection: 'desc',
ignoredColums: columnsToIgnore
ignoredColumns: columnsToIgnore
})
const getLineColor = (result: Result) => {
if (props.infoOnly) {
+5 -7
View File
@@ -7,19 +7,17 @@ import {IskLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {TransferList, TransferTypes} from "@/transaction";
import {Dropdown} from "@/components";
import {SourceLabel} from "@/activity";
import {SourceLabel, useProcessedResource} from "@/activity";
import {useCharactersStore} from "@/characters";
import {formatEveDate} from "@/formaters.ts";
const {ledgerId} = useLedgerParam();
const charactersStore = useCharactersStore();
const transactions = computedAsync<TransactionResponse[]>(async () => {
if (ledgerId.value) {
return await findAllTransactionInLeger(ledgerId.value);
}
return [];
}, []);
const transactions = useProcessedResource<TransactionResponse[]>(
() => ledgerId.value ? findAllTransactionInLeger(ledgerId.value) : Promise.resolve([]),
[],
);
const { sortedArray, headerProps } = useSort(computedAsync(() => Promise.all(transactions.value.map(async transaction => {
const source = transaction.source;
-1
View File
@@ -14,7 +14,6 @@ const editLedgerModal = ref<typeof EditLedgerModal>();
const processActivities = async () => {
await activityApi.fetchAllNewActivities();
await activityApi.processNewActivities();
await ledgersStore.refresh();
}
const { sortedArray, headerProps } = useSort<Ledger>(() => ledgersStore.ledgers);
+4 -4
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import {AcquisitionsPanel, toAcquiredType} from '@/market/acquisition';
import {AcquisitionsPanel, RawAcquiredType, toAcquiredType} from '@/market/acquisition';
import {useLedgerParam} from "@/ledger";
import {acquisitionApi} from "@/mammon";
import {computedAsync} from "@vueuse/core";
import {useProcessedResource} from "@/activity";
const {ledgerId} = useLedgerParam();
const items = computedAsync(async () => {
const items = useProcessedResource<RawAcquiredType[]>(async () => {
if (!ledgerId.value) {
return [];
}
@@ -19,6 +19,6 @@ const items = computedAsync(async () => {
<template>
<div class="mt-4">
<AcquisitionsPanel :items="items" :ledgerId="ledgerId" :ignoredColums="['date', 'ledger']" />
<AcquisitionsPanel :items="items" :ledgerId="ledgerId" :ignoredColumns="['date', 'ledger']" />
</div>
</template>
+5 -6
View File
@@ -4,15 +4,14 @@ import {computedAsync} from "@vueuse/core";
import {BalanceResponse} from "@/generated/mammon";
import {getMarketType, MarketTypeLabel} from "@/market";
import {SortableHeader, useSort, VirtualScrollTable} from "@/components/table";
import {useProcessedResource} from "@/activity";
const {ledgerId} = useLedgerParam();
const balance = computedAsync<BalanceResponse>(async () => {
if (ledgerId.value) {
return await getLedgerBalance(ledgerId.value);
}
return undefined;
});
const balance = useProcessedResource<BalanceResponse | undefined>(
() => ledgerId.value ? getLedgerBalance(ledgerId.value) : Promise.resolve(undefined),
undefined,
);
const { sortedArray, headerProps } = useSort(computedAsync(async () => {
const itemBalances = balance.value?.itemBalances;
+4 -2
View File
@@ -2,11 +2,13 @@
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 panel = ref<InstanceType<typeof AcquisitionsPanel>>();
const refresh = async () => {
await acquiredTypesStore.refresh();
await panel.value?.refresh();
}
const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(refresh, 5 * 60 * 1000);
@@ -22,6 +24,6 @@ const {active: autoRefresh, toggle: toggleAutoRefresh} = useAutoRefresh(refresh,
</button>
</div>
</div>
<AcquisitionsPanel :items="acquiredTypesStore.acquiredTypes" ignoredColums="date" />
<AcquisitionsPanel ref="panel" :items="acquiredTypesStore.acquiredTypes" ignoredColumns="date" />
</div>
</template>
+5 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {ClipboardButton} from '@/components';
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore, useMarketTaxStore} from "@/market";
import {getMarketType, MarketType, MarketTypeInput, useAppraisalStore, useMarketTaxStore} from "@/market";
import {AcquisitionResultTable, BuyModal} from '@/market/acquisition';
import {ScanResultTable, toScanResult} from '@/market/scan';
import {acquisitionApi, marketApi} from "@/mammon";
@@ -18,10 +18,10 @@ const router = useRouter();
const item = ref<MarketType>();
const inputItem = ref<MarketType>();
const apraisalStore = useApraisalStore();
const appraisalStore = useAppraisalStore();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
const price = computedAsync(() => item.value ? appraisalStore.getPrice(item.value) : undefined);
const result = computedAsync(async () => {
if (!item.value) {
return undefined;
@@ -107,11 +107,11 @@ watch(useRoute(), async route => {
</div>
<div v-if="result" class="mb-4">
<span>Market Info:</span>
<ScanResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
<ScanResultTable :items="[result]" infoOnly :ignoredColumns="['name', 'acquisitions']" />
</div>
<div v-if="acquisitions && acquisitions.length > 0">
<span>Acquisitions:</span>
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColums="['name', 'buy', 'sell']" defaultSortKey="date"/>
<AcquisitionResultTable :items="acquisitions" infoOnly showAll :ignoredColumns="['name', 'buy', 'sell']" defaultSortKey="date"/>
</div>
</template>
<BuyModal ref="buyModal" />
+1 -1
View File
@@ -24,7 +24,7 @@ export const useRuleBookStore = defineStore('rule-book', () => {
refresh();
return {ruleBook, refresh, update};
return {ruleBook, update};
})
export const fetchScriptDefinitions = (): Promise<string> =>