Added ability to download entire profile SQLite file.
This commit is contained in:
parent
2d99e96df3
commit
bed9562d6b
|
|
@ -47,6 +47,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile);
|
a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
||||||
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/download", &handleDownloadProfile);
|
||||||
import attachment.api;
|
import attachment.api;
|
||||||
// Note: the download endpoint is public! We authenticate via token in query params instead of header here.
|
// Note: the download endpoint is public! We authenticate via token in query params instead of header here.
|
||||||
h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment);
|
h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment);
|
||||||
|
|
@ -161,6 +162,7 @@ private class CorsFilter : HttpRequestFilter {
|
||||||
response.headers.add("Access-Control-Allow-Origin", webOrigin);
|
response.headers.add("Access-Control-Allow-Origin", webOrigin);
|
||||||
response.headers.add("Access-Control-Allow-Methods", "*");
|
response.headers.add("Access-Control-Allow-Methods", "*");
|
||||||
response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
||||||
|
response.headers.add("Access-Control-Expose-Headers", "Content-Disposition");
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +202,11 @@ private class ExceptionHandlingFilter : HttpRequestFilter {
|
||||||
error(e);
|
error(e);
|
||||||
response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
response.writeBodyString("An error occurred: " ~ e.msg);
|
response.writeBodyString("An error occurred: " ~ e.msg);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
errorF!"A throwable was caught! %s %s"(e.msg, e.info);
|
||||||
|
response.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
response.writeBodyString("An error occurred.");
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import asdf;
|
||||||
import handy_http_primitives;
|
import handy_http_primitives;
|
||||||
import handy_http_data.json;
|
import handy_http_data.json;
|
||||||
import handy_http_handlers.path_handler : getPathParamAs;
|
import handy_http_handlers.path_handler : getPathParamAs;
|
||||||
|
import slf4d;
|
||||||
|
|
||||||
import profile.model;
|
import profile.model;
|
||||||
import profile.service;
|
import profile.service;
|
||||||
|
|
@ -63,3 +64,40 @@ void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
ProfileProperty[] props = propsRepo.findAll();
|
ProfileProperty[] props = propsRepo.findAll();
|
||||||
writeJsonBody(response, props);
|
writeJsonBody(response, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ProfileContext profileCtx = getProfileContextOrThrow(request);
|
||||||
|
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
|
||||||
|
Optional!ProfileDownloadData data = profileRepo.getProfileData(profileCtx.profile.name);
|
||||||
|
if (data.isNull) {
|
||||||
|
response.status = HttpStatus.NOT_FOUND;
|
||||||
|
response.writeBodyString("Profile data not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
import streams : StreamResult;
|
||||||
|
import std.conv : to;
|
||||||
|
response.headers.add("Content-Type", data.value.contentType);
|
||||||
|
response.headers.add("Content-Disposition", "attachment; filename=" ~ data.value.filename);
|
||||||
|
response.headers.add("Content-Length", data.value.size.to!string);
|
||||||
|
// Transfer the file:
|
||||||
|
StreamResult result;
|
||||||
|
ubyte[4096] buffer;
|
||||||
|
ulong bytesWritten = 0;
|
||||||
|
while (bytesWritten < data.value.size) {
|
||||||
|
result = data.value.inputStream.readFromStream(buffer);
|
||||||
|
if (result.hasError) {
|
||||||
|
errorF!"Failed to read from stream: %s"(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.count == 0) {
|
||||||
|
return; // Done!
|
||||||
|
}
|
||||||
|
result = response.outputStream.writeToStream(buffer[0 .. result.count]);
|
||||||
|
if (result.hasError) {
|
||||||
|
errorF!"Failed to write to stream: %s"(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bytesWritten += result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
module profile.data;
|
module profile.data;
|
||||||
|
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
|
import streams.interfaces : InputStream;
|
||||||
|
|
||||||
import profile.model;
|
import profile.model;
|
||||||
|
|
||||||
|
struct ProfileDownloadData {
|
||||||
|
string filename;
|
||||||
|
string contentType;
|
||||||
|
ulong size;
|
||||||
|
InputStream!ubyte inputStream;
|
||||||
|
}
|
||||||
|
|
||||||
/// Repository for interacting with the set of profiles belonging to a user.
|
/// Repository for interacting with the set of profiles belonging to a user.
|
||||||
interface ProfileRepository {
|
interface ProfileRepository {
|
||||||
Optional!Profile findByName(string name);
|
Optional!Profile findByName(string name);
|
||||||
|
|
@ -12,6 +20,7 @@ interface ProfileRepository {
|
||||||
void deleteByName(string name);
|
void deleteByName(string name);
|
||||||
ProfileDataSource getDataSource(in Profile profile);
|
ProfileDataSource getDataSource(in Profile profile);
|
||||||
string getFilesPath(in Profile profile);
|
string getFilesPath(in Profile profile);
|
||||||
|
Optional!ProfileDownloadData getProfileData(string name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Repository for accessing the properties of a profile.
|
/// Repository for accessing the properties of a profile.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ module profile.data_impl_sqlite;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import d2sqlite3;
|
import d2sqlite3;
|
||||||
import handy_http_primitives;
|
import handy_http_primitives;
|
||||||
|
import streams.interfaces : InputStream, inputStreamObjectFor;
|
||||||
|
import streams.types : FileInputStream;
|
||||||
|
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import profile.model;
|
import profile.model;
|
||||||
|
|
@ -78,6 +80,25 @@ class FileSystemProfileRepository : ProfileRepository {
|
||||||
return buildPath(getProfilesDir(), profile.name ~ "_files");
|
return buildPath(getProfilesDir(), profile.name ~ "_files");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Optional!ProfileDownloadData getProfileData(string name) {
|
||||||
|
import std.string : toStringz, format;
|
||||||
|
import std.datetime;
|
||||||
|
|
||||||
|
string path = getProfilePath(name);
|
||||||
|
if (!exists(path)) return Optional!ProfileDownloadData.empty;
|
||||||
|
ProfileDownloadData data;
|
||||||
|
const now = Clock.currTime(UTC());
|
||||||
|
data.filename = format!"%s_%02d-%02d-%02d_%02d-%02d-%02dz.sqlite"(
|
||||||
|
name,
|
||||||
|
now.year, now.month, now.day,
|
||||||
|
now.hour, now.minute, now.second
|
||||||
|
);
|
||||||
|
data.contentType = "application/vnd.sqlite3";
|
||||||
|
data.size = std.file.getSize(path);
|
||||||
|
data.inputStream = inputStreamObjectFor(FileInputStream(toStringz(path)));
|
||||||
|
return Optional!ProfileDownloadData.of(data);
|
||||||
|
}
|
||||||
|
|
||||||
private string getProfilesDir() {
|
private string getProfilesDir() {
|
||||||
return buildPath(this.usersDir, username, "profiles");
|
return buildPath(this.usersDir, username, "profiles");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,28 @@ export abstract class ApiClient {
|
||||||
return await r.text()
|
return await r.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async getFile(path: string): Promise<void> {
|
||||||
|
const r = await this.doRequest('GET', path)
|
||||||
|
const blob = await r.blob()
|
||||||
|
const objURL = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = objURL
|
||||||
|
a.download = this.extractFileName(r) ?? 'file.dat'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click() // Trigger the download.
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(objURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFileName(response: Response): string | null {
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition')
|
||||||
|
if (!contentDisposition) return null
|
||||||
|
const chunks = contentDisposition.split(';').map((c) => c.trim())
|
||||||
|
const filenameChunk = chunks.find((c) => c.startsWith('filename='))
|
||||||
|
if (!filenameChunk) return null
|
||||||
|
return filenameChunk.split('=')[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
protected async postJson<T>(path: string, body: object | undefined = undefined): Promise<T> {
|
protected async postJson<T>(path: string, body: object | undefined = undefined): Promise<T> {
|
||||||
const r = await this.doRequest('POST', path, body)
|
const r = await this.doRequest('POST', path, body)
|
||||||
return await r.json()
|
return await r.json()
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ export class ProfileApiClient extends ApiClient {
|
||||||
getProperties(profileName: string): Promise<ProfileProperty[]> {
|
getProperties(profileName: string): Promise<ProfileProperty[]> {
|
||||||
return super.getJson(`/profiles/${profileName}/properties`)
|
return super.getJson(`/profiles/${profileName}/properties`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadData(profileName: string): Promise<void> {
|
||||||
|
return super.getFile(`/profiles/${profileName}/download`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Account, AccountApiClient } from '@/api/account'
|
import { AccountApiClient, type Account } from '@/api/account'
|
||||||
import HomeModule from '@/components/HomeModule.vue'
|
import HomeModule from '@/components/HomeModule.vue'
|
||||||
import { computed, onMounted, ref, type ComputedRef } from 'vue'
|
import { computed, onMounted, ref, type ComputedRef } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import 'chartjs-adapter-date-fns'
|
import 'chartjs-adapter-date-fns'
|
||||||
import type { Currency } from '@/api/data'
|
import type { Currency } from '@/api/data'
|
||||||
import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics'
|
import { AnalyticsApiClient, type BalanceTimeSeriesAnalytics } from '@/api/analytics'
|
||||||
|
|
@ -11,6 +10,7 @@ import type { TimeFrame, BalanceTimeSeries } from './analytics/util'
|
||||||
import FormGroup from '@/components/common/form/FormGroup.vue'
|
import FormGroup from '@/components/common/form/FormGroup.vue'
|
||||||
import FormControl from '@/components/common/form/FormControl.vue'
|
import FormControl from '@/components/common/form/FormControl.vue'
|
||||||
import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns'
|
import { isAfter, isBefore, startOfMonth, startOfYear, sub } from 'date-fns'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
|
@ -25,7 +25,8 @@ interface AnalyticsChartType {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnalyticsChartTypes: AnalyticsChartType[] = [
|
const AnalyticsChartTypes: AnalyticsChartType[] = [
|
||||||
{ id: 'balance-time-series', name: 'Account Balances' },
|
{ id: 'account-balances', name: 'Account Balances' },
|
||||||
|
{ id: 'total-balances', name: 'Total Balances' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
|
const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
|
||||||
|
|
@ -33,20 +34,21 @@ const selectedChart = ref<AnalyticsChartType>(AnalyticsChartTypes[0])
|
||||||
const availableCurrencies = computed(() => {
|
const availableCurrencies = computed(() => {
|
||||||
const currencies: Currency[] = []
|
const currencies: Currency[] = []
|
||||||
for (const acc of accounts.value) {
|
for (const acc of accounts.value) {
|
||||||
if (currencies.findIndex(c => c.code === acc.currency.code) === -1) {
|
if (currencies.findIndex((c) => c.code === acc.currency.code) === -1) {
|
||||||
currencies.push(acc.currency)
|
currencies.push(acc.currency)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return currencies
|
return currencies
|
||||||
})
|
})
|
||||||
|
|
||||||
const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
|
const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
|
||||||
if (!balanceTimeSeriesData.value) return []
|
if (!balanceTimeSeriesData.value) return []
|
||||||
const eligibleAccounts = accounts.value.filter(a => a.currency.code === currency.value?.code)
|
const eligibleAccounts = accounts.value.filter((a) => a.currency.code === currency.value?.code)
|
||||||
const series: BalanceTimeSeries[] = []
|
const series: BalanceTimeSeries[] = []
|
||||||
for (const accountData of balanceTimeSeriesData.value.accounts) {
|
for (const accountData of balanceTimeSeriesData.value.accounts) {
|
||||||
const account = eligibleAccounts.find(a => a.id === accountData.accountId)
|
const account = eligibleAccounts.find((a) => a.id === accountData.accountId)
|
||||||
if (account !== undefined) {
|
if (account !== undefined) {
|
||||||
const filteredTimeSeries = accountData.balanceTimeSeries.filter(s => {
|
const filteredTimeSeries = accountData.balanceTimeSeries.filter((s) => {
|
||||||
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
|
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -57,13 +59,31 @@ const accountBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
|
||||||
})
|
})
|
||||||
series.push({
|
series.push({
|
||||||
label: account.name,
|
label: account.name,
|
||||||
snapshots: filteredTimeSeries
|
snapshots: filteredTimeSeries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return series
|
return series
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totalBalancesData: ComputedRef<BalanceTimeSeries[]> = computed(() => {
|
||||||
|
if (!balanceTimeSeriesData.value) return []
|
||||||
|
const totalsData = balanceTimeSeriesData.value.totals.find(
|
||||||
|
(t) => t.currencyCode === currency.value?.code,
|
||||||
|
)
|
||||||
|
if (!totalsData) return []
|
||||||
|
const filteredTimeSeries = totalsData.balanceTimeSeries.filter((s) => {
|
||||||
|
if (timeFrame.value.start && !isAfter(s.timestamp, timeFrame.value.start)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (timeFrame.value.end && !isBefore(s.timestamp, timeFrame.value.end)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return [{ label: totalsData.currencyCode, snapshots: filteredTimeSeries }]
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const api = new AccountApiClient(route)
|
const api = new AccountApiClient(route)
|
||||||
const analyticsApi = new AnalyticsApiClient(route)
|
const analyticsApi = new AnalyticsApiClient(route)
|
||||||
|
|
@ -75,31 +95,62 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<HomeModule title="Analytics" style="max-width: 800px;">
|
<HomeModule
|
||||||
|
title="Analytics"
|
||||||
|
style="max-width: 800px; min-height: 200px"
|
||||||
|
>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControl label="Chart">
|
<FormControl label="Chart">
|
||||||
<select v-model="selectedChart">
|
<select v-model="selectedChart">
|
||||||
<option v-for="ct in AnalyticsChartTypes" :key="ct.id" :value="ct">
|
<option
|
||||||
|
v-for="ct in AnalyticsChartTypes"
|
||||||
|
:key="ct.id"
|
||||||
|
:value="ct"
|
||||||
|
>
|
||||||
{{ ct.name }}
|
{{ ct.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<BalanceTimeSeriesChart v-if="currency && balanceTimeSeriesData && selectedChart.id === 'balance-time-series'"
|
<BalanceTimeSeriesChart
|
||||||
title="Account Balances" :currency="currency" :time-frame="timeFrame" :data="accountBalancesData" />
|
v-if="currency && balanceTimeSeriesData && selectedChart.id === 'account-balances'"
|
||||||
|
title="Account Balances"
|
||||||
|
:currency="currency"
|
||||||
|
:time-frame="timeFrame"
|
||||||
|
:data="accountBalancesData"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BalanceTimeSeriesChart
|
||||||
|
v-if="currency && balanceTimeSeriesData && selectedChart.id === 'total-balances'"
|
||||||
|
title="Total Balances"
|
||||||
|
:currency="currency"
|
||||||
|
:time-frame="timeFrame"
|
||||||
|
:data="totalBalancesData"
|
||||||
|
/>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormControl label="Currency">
|
<FormControl label="Currency">
|
||||||
<select v-model="currency" :disabled="availableCurrencies.length < 2">
|
<select
|
||||||
<option v-for="currency in availableCurrencies" :key="currency.code" :value="currency">
|
v-model="currency"
|
||||||
|
:disabled="availableCurrencies.length < 2"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="currency in availableCurrencies"
|
||||||
|
:key="currency.code"
|
||||||
|
:value="currency"
|
||||||
|
>
|
||||||
{{ currency.code }}
|
{{ currency.code }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl label="Time Frame">
|
<FormControl label="Time Frame">
|
||||||
<select v-model="timeFrame">
|
<select v-model="timeFrame">
|
||||||
<option :value="{}" selected>All Time</option>
|
<option
|
||||||
|
:value="{}"
|
||||||
|
selected
|
||||||
|
>
|
||||||
|
All Time
|
||||||
|
</option>
|
||||||
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
|
<option :value="{ start: sub(new Date(), { days: 30 }) }">Last 30 days</option>
|
||||||
<option :value="{ start: startOfMonth(new Date()) }">This Month</option>
|
<option :value="{ start: startOfMonth(new Date()) }">This Month</option>
|
||||||
<option :value="{ start: startOfYear(new Date()) }">Year to Date</option>
|
<option :value="{ start: startOfYear(new Date()) }">Year to Date</option>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ async function deleteProfile() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadData() {
|
||||||
|
if (!profile.value) return
|
||||||
|
const api = new ProfileApiClient()
|
||||||
|
await api.downloadData(profile.value.name)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<HomeModule
|
<HomeModule
|
||||||
|
|
@ -66,10 +72,17 @@ async function deleteProfile() {
|
||||||
@click="router.push('/profiles')"
|
@click="router.push('/profiles')"
|
||||||
>Choose another profile</AppButton
|
>Choose another profile</AppButton
|
||||||
>
|
>
|
||||||
|
<AppButton
|
||||||
|
icon="download"
|
||||||
|
@click="downloadData()"
|
||||||
|
size="sm"
|
||||||
|
>Download Data</AppButton
|
||||||
|
>
|
||||||
<AppButton
|
<AppButton
|
||||||
button-style="secondary"
|
button-style="secondary"
|
||||||
icon="trash"
|
icon="trash"
|
||||||
@click="deleteProfile()"
|
@click="deleteProfile()"
|
||||||
|
size="sm"
|
||||||
>Delete</AppButton
|
>Delete</AppButton
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CategoryScale, Chart, type ChartData, type ChartDataset, type ChartOptions, Filler, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip } from 'chart.js';
|
import {
|
||||||
import { computed, onMounted, ref } from 'vue';
|
CategoryScale,
|
||||||
import type { BalanceTimeSeries, TimeFrame } from './util';
|
Chart,
|
||||||
import { integerMoneyToFloat, type Currency } from '@/api/data';
|
type ChartData,
|
||||||
import { getTime } from 'date-fns';
|
type ChartDataset,
|
||||||
import { Line } from 'vue-chartjs';
|
type ChartOptions,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { BalanceTimeSeries, TimeFrame } from './util'
|
||||||
|
import { integerMoneyToFloat, type Currency } from '@/api/data'
|
||||||
|
import { getTime } from 'date-fns'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string,
|
title: string
|
||||||
timeFrame: TimeFrame,
|
timeFrame: TimeFrame
|
||||||
currency: Currency,
|
currency: Currency
|
||||||
data: BalanceTimeSeries[]
|
data: BalanceTimeSeries[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -25,7 +39,7 @@ Chart.register(
|
||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
TimeScale,
|
TimeScale,
|
||||||
Filler
|
Filler,
|
||||||
)
|
)
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
|
|
@ -46,17 +60,17 @@ onMounted(() => {
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: props.title
|
text: props.title,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
time: {
|
time: {
|
||||||
unit: 'day'
|
unit: 'day',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -71,7 +85,7 @@ function buildChartData(): ChartData<'line'> {
|
||||||
const points = series.snapshots.map((p) => {
|
const points = series.snapshots.map((p) => {
|
||||||
return {
|
return {
|
||||||
x: getTime(p.timestamp),
|
x: getTime(p.timestamp),
|
||||||
y: integerMoneyToFloat(p.balance, props.currency)
|
y: integerMoneyToFloat(p.balance, props.currency),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
datasets.push({
|
datasets.push({
|
||||||
|
|
@ -91,5 +105,9 @@ function buildChartData(): ChartData<'line'> {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
<Line
|
||||||
|
v-if="chartData && chartOptions"
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue