Compare commits

...
2 Commits
30 changed files with 1384 additions and 242 deletions
@@ -9,12 +9,10 @@ use Spatie\LaravelData\Data;
class DashboardData extends Data class DashboardData extends Data
{ {
public function __construct( public function __construct(
public ElectricityData $electricity, public ?ElectricityData $electricity,
public ?ElectricityData $electricityCheapest, public ?ElectricityData $electricityCheapest,
public ?ElectricityData $electricityDurable, public ?ElectricityData $electricityDurable,
public ?ElectricityData $electricityPriciest, public ?ElectricityData $electricityPriciest,
public GasData $gas, public ?GasData $gas,
public ?GasData $gasCheapest,
public ?GasData $gasPriciest,
) {} ) {}
} }
@@ -14,19 +14,25 @@ class DashboardService
public function getDashboardData(): DashboardData public function getDashboardData(): DashboardData
{ {
$electricity = $this->repository->retrieveElectricity();
$gas = $this->repository->retrieveGas();
$now = Carbon::now(); $now = Carbon::now();
$electricity = $this->repository->retrieveElectricity(); $eNow = $electricity->first(
fn ($item) => $item->startDate instanceof Carbon
$eNow = $electricity->whereBetween('startDate', [$now->copy()->subHour(), $now])->first(); && $item->startDate->gte($now->copy()->subHour())
&& $item->startDate->lte($now)
);
$eCheapest = $electricity->where('totalPriceTaxIncluded', $electricity->min('totalPriceTaxIncluded'))->first(); $eCheapest = $electricity->where('totalPriceTaxIncluded', $electricity->min('totalPriceTaxIncluded'))->first();
$eDurable = $electricity->where('sustainabilityScore', $electricity->max('sustainabilityScore'))->first(); $eDurable = $electricity->where('sustainabilityScore', $electricity->max('sustainabilityScore'))->first();
$ePriciest = $electricity->where('totalPriceTaxIncluded', $electricity->max('totalPriceTaxIncluded'))->first(); $ePriciest = $electricity->where('totalPriceTaxIncluded', $electricity->max('totalPriceTaxIncluded'))->first();
$gas = $this->repository->retrieveGas(); $gNow = $gas->first(
$gNow = $gas->whereBetween('startDate', [$now->copy()->setHour(0), $now])->first(); fn ($item) => $item->startDate instanceof Carbon
$gCheapest = $gas->where('totalPriceTaxIncluded', $gas->min('totalPriceTaxIncluded'))->first(); && $item->startDate->gte($now->copy()->startOfDay())
$gPriciest = $gas->where('totalPriceTaxIncluded', $gas->max('totalPriceTaxIncluded'))->first(); && $item->startDate->lte($now)
);
return new DashboardData( return new DashboardData(
$eNow, $eNow,
@@ -34,8 +40,6 @@ class DashboardService
$eDurable, $eDurable,
$ePriciest, $ePriciest,
$gNow, $gNow,
$gCheapest,
$gPriciest
); );
} }
} }
@@ -3,8 +3,6 @@
namespace App\Features\History\Services; namespace App\Features\History\Services;
use App\Contracts\PriceRepository; use App\Contracts\PriceRepository;
use App\Data\ElectricityData;
use App\Data\GasData;
use App\Features\History\Data\HistoryData; use App\Features\History\Data\HistoryData;
use Carbon\Carbon; use Carbon\Carbon;
@@ -16,9 +14,12 @@ class HistoryService
public function getHistoryData(Carbon $date): HistoryData public function getHistoryData(Carbon $date): HistoryData
{ {
$electricity = ElectricityData::collect($this->repository->retrieveElectricityForDate($date)); $electricity = $this->repository->retrieveElectricityForDate($date);
$gas = GasData::collect($this->repository->retrieveGasForDate($date)); $gas = $this->repository->retrieveGasForDate($date);
return new HistoryData($electricity, $gas); return new HistoryData(
electricity: $electricity,
gas: $gas
);
} }
} }
+44 -9
View File
@@ -13,39 +13,74 @@ use Override;
class ZonneplanPriceRepository implements ContractsPriceRepository class ZonneplanPriceRepository implements ContractsPriceRepository
{ {
private int $cacheTtl;
private string $cachePrefix;
public function __construct( public function __construct(
public PriceClient $client public PriceClient $client
) {} ) {
$this->cacheTtl = config('cache_prices.ttl', 3600);
$this->cachePrefix = config('cache_prices.prefix', 'zonneplan:');
}
#[Override] #[Override]
public function retrieveElectricity(): Collection public function retrieveElectricity(): Collection
{ {
$data = Cache::remember('electricity:upcomming', 3600, fn () => $this->client->getElectricityPrices()->toArray()); $raw = $this->remember(
'electricity:upcoming',
fn () => $this->client->getElectricityPrices()->toArray()
);
return ElectricityData::collect($data, Collection::class); return ElectricityData::collect($raw, Collection::class);
} }
#[Override] #[Override]
public function retrieveGas(): Collection public function retrieveGas(): Collection
{ {
$data = Cache::remember('gas:upcomming', 3600, fn () => $this->client->getGasPrices()->toArray()); $raw = $this->remember(
'gas:upcoming',
fn () => $this->client->getGasPrices()->toArray()
);
return GasData::collect($data, Collection::class); return GasData::collect($raw, Collection::class);
} }
#[Override] #[Override]
public function retrieveElectricityForDate(Carbon $date): Collection public function retrieveElectricityForDate(Carbon $date): Collection
{ {
$data = Cache::remember('electricity:'.$date->format('Y-m-d'), 3600, fn () => $this->client->getElectricityPricesForDate($date)->toArray()); $raw = $this->remember(
'electricity:' . $date->format('Y-m-d'),
fn () => $this->client->getElectricityPricesForDate($date)->toArray()
);
return GasData::collect($data, Collection::class); return ElectricityData::collect($raw, Collection::class);
} }
#[Override] #[Override]
public function retrieveGasForDate(Carbon $date): Collection public function retrieveGasForDate(Carbon $date): Collection
{ {
$data = Cache::remember('gas:'.$date->format('Y-m-d'), 3600, fn () => $this->client->getGasPricesForDate($date)->toArray()); $raw = $this->remember(
'gas:' . $date->format('Y-m-d'),
fn () => $this->client->getGasPricesForDate($date)->toArray()
);
return GasData::collect($data, Collection::class); return GasData::collect($raw, Collection::class);
}
/**
* Cache via JSON encoding to ensure clean array structure on retrieval.
*
* @param callable(): array $callback
* @return array
*/
private function remember(string $key, callable $callback): array
{
$cached = Cache::remember(
$this->cachePrefix . $key,
$this->cacheTtl,
fn () => json_encode($callback())
);
return json_decode($cached, true);
} }
} }
+24
View File
@@ -0,0 +1,24 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cache TTL (Time To Live) in seconds
|--------------------------------------------------------------------------
|
| Hiermee configureer je hoe lang API-responses worden gecachet.
| Standaard is dit 1 uur (3600 seconden).
|
*/
'ttl' => env('CACHE_TTL', 3600),
/*
|--------------------------------------------------------------------------
| Cache prefix
|--------------------------------------------------------------------------
|
| Prefix voor cache keys om conflicten te vermijden.
|
*/
'prefix' => 'zonneplan:',
];
+87 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "html", "name": "Zonneplan",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -17,6 +17,7 @@
"i18next-vue": "^5.4.0", "i18next-vue": "^5.4.0",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"pinia": "^4.0.2",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
@@ -1907,6 +1908,36 @@
"he": "^1.2.0" "he": "^1.2.0"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz",
"integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-kit": "^8.2.1"
}
},
"node_modules/@vue/devtools-kit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz",
"integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-shared": "^8.2.1",
"birpc": "^2.6.1",
"hookable": "^5.5.3",
"perfect-debounce": "^2.0.0"
}
},
"node_modules/@vue/devtools-shared": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz",
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
"license": "MIT",
"peer": true
},
"node_modules/@vue/eslint-config-typescript": { "node_modules/@vue/eslint-config-typescript": {
"version": "14.9.0", "version": "14.9.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz",
@@ -2308,6 +2339,16 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -3968,6 +4009,13 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT",
"peer": true
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -5153,6 +5201,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/nostics": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz",
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
"license": "MIT"
},
"node_modules/nth-check": { "node_modules/nth-check": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -5395,6 +5449,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT",
"peer": true
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5413,6 +5474,31 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pinia": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz",
"integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==",
"license": "MIT",
"dependencies": {
"nostics": "^1.1.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@vue/devtools-api": "^8.1.5",
"typescript": ">=5.6.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"@vue/devtools-api": {
"optional": false
},
"typescript": {
"optional": true
}
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1
View File
@@ -41,6 +41,7 @@
"i18next-vue": "^5.4.0", "i18next-vue": "^5.4.0",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"pinia": "^4.0.2",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1", "tailwindcss": "^4.1.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
+3
View File
@@ -1,10 +1,12 @@
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import I18NextVue from 'i18next-vue'; import I18NextVue from 'i18next-vue';
import { createPinia } from 'pinia';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import i18next from './lang/init'; import i18next from './lang/init';
import Layout from './layouts/Layout.vue'; import Layout from './layouts/Layout.vue';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
createInertiaApp({ createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName), title: (title) => (title ? `${title} - ${appName}` : appName),
@@ -33,6 +35,7 @@ createInertiaApp({
return pages[`./pages/${name}.vue`]; return pages[`./pages/${name}.vue`];
}, },
withApp(app, options) { withApp(app, options) {
app.use(pinia);
app.use(I18NextVue, { app.use(I18NextVue, {
i18next, i18next,
...options, ...options,
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div
class="size-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary"
role="status"
aria-label="Laden"
/>
</template>
@@ -13,7 +13,7 @@ defineProps<{
<div class="font-bold text-primary uppercase">{{ label }}</div> <div class="font-bold text-primary uppercase">{{ label }}</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-4xl font-black"> <div class="text-4xl font-black">
&euro;{{ price?.toPrecision(2).toString().replace('.', ',') }} &euro;{{ price?.toFixed(2).replace('.', ',') }}
</div> </div>
<div class="text-sm text-black/50"> <div class="text-sm text-black/50">
{{ moment.isMoment(time) ? time.format('DD-MM-YYYY HH:mm') : time }} {{ moment.isMoment(time) ? time.format('DD-MM-YYYY HH:mm') : time }}
+91 -118
View File
@@ -3,13 +3,14 @@ import { t } from 'i18next';
import moment from 'moment'; import moment from 'moment';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import Price from '../components/Price.vue'; import Price from '../components/Price.vue';
import useDashboardViewModel from '../viewmodels/DashboardViewModel'; import { useDashboardStore } from '@/stores/dashboard';
import Error from '@/components/Error.vue'; import Error from '@/components/Error.vue';
import Loading from '@/components/Loading.vue';
const model = useDashboardViewModel(); const store = useDashboardStore();
onMounted(() => { onMounted(() => {
model.getDashboardData(); store.fetchDashboardData();
}); });
</script> </script>
@@ -17,122 +18,94 @@ onMounted(() => {
<h1 class="mb-4 text-3xl font-black text-primary"> <h1 class="mb-4 text-3xl font-black text-primary">
{{ t('dashboard.title') }} {{ t('dashboard.title') }}
</h1> </h1>
<div class="mb-4 flex flex-col gap-2" v-if="model.dashboardData.value"> <Loading v-if="store.isLoading" />
<h2 class="text-2xl font-black text-primary"> <template v-else-if="store.dashboardData">
{{ t('dashboard.electricity') }} <div class="mb-4 flex flex-col gap-2">
</h2> <h2 class="text-2xl font-black text-primary">
<div class="flex flex-wrap gap-4"> {{ t('dashboard.electricity') }}
<Price </h2>
v-if=" <div class="flex flex-wrap gap-4">
model.dashboardData.value?.electricity.totalPriceTaxIncluded <Price
" v-if="
:label="t('dashboard.current_electricity_price')" store.dashboardData.electricity?.totalPriceTaxIncluded
:price=" "
model.dashboardData.value?.electricity.totalPriceTaxIncluded :label="t('dashboard.current_electricity_price')"
" :price="
:time="moment(model.dashboardData.value?.electricity.startDate)" store.dashboardData.electricity.totalPriceTaxIncluded
/> "
<Price :time="moment(store.dashboardData.electricity.startDate)"
v-if=" />
model.dashboardData.value?.electricityCheapest <Price
?.totalPriceTaxIncluded v-if="
" store.dashboardData.electricityCheapest
:label="t('dashboard.cheapest_electricity_price')" ?.totalPriceTaxIncluded
:price=" "
model.dashboardData.value?.electricityCheapest :label="t('dashboard.cheapest_electricity_price')"
?.totalPriceTaxIncluded :price="
" store.dashboardData.electricityCheapest
:time=" .totalPriceTaxIncluded
moment( "
model.dashboardData.value?.electricityCheapest :time="
?.startDate, moment(
) store.dashboardData.electricityCheapest.startDate,
" )
/> "
<Price />
v-if=" <Price
model.dashboardData.value?.electricityDurable v-if="
?.totalPriceTaxIncluded store.dashboardData.electricityDurable
" ?.totalPriceTaxIncluded
:label="t('dashboard.durable_electricity_price')" "
:price=" :label="t('dashboard.durable_electricity_price')"
model.dashboardData.value?.electricityDurable :price="
?.totalPriceTaxIncluded store.dashboardData.electricityDurable
" .totalPriceTaxIncluded
:time=" "
moment( :time="
model.dashboardData.value?.electricityDurable moment(store.dashboardData.electricityDurable.startDate)
?.startDate, "
) />
" <Price
/> v-if="
<Price store.dashboardData.electricityPriciest
v-if=" ?.totalPriceTaxIncluded
model.dashboardData.value?.electricityPriciest "
?.totalPriceTaxIncluded :label="t('dashboard.priciest_electricity_price')"
" :price="
:label="t('dashboard.priciest_electricity_price')" store.dashboardData.electricityPriciest
:price=" .totalPriceTaxIncluded
model.dashboardData.value?.electricityPriciest "
?.totalPriceTaxIncluded :time="
" moment(
:time=" store.dashboardData.electricityPriciest.startDate,
moment( )
model.dashboardData.value?.electricityPriciest "
?.startDate, />
) </div>
"
/>
</div> </div>
</div> <div class="mb-4 flex flex-col gap-2">
<div class="mb-4 flex flex-col gap-2" v-if="model.dashboardData.value"> <h2 class="text-2xl font-black text-primary">
<h2 class="text-2xl font-black text-primary"> {{ t('dashboard.gas') }}
{{ t('dashboard.gas') }} </h2>
</h2> <div class="flex flex-wrap gap-4">
<div class="flex flex-wrap gap-4"> <Price
<Price v-if="store.dashboardData.gas?.totalPriceTaxIncluded"
v-if="model.dashboardData.value?.gas.totalPriceTaxIncluded" :label="t('dashboard.current_gas_price')"
:label="t('dashboard.current_gas_price')" :price="store.dashboardData.gas.totalPriceTaxIncluded"
:price="model.dashboardData.value?.gas.totalPriceTaxIncluded" :time="
:time=" moment(store.dashboardData.gas.startDate).format(
moment(model.dashboardData.value?.gas.startDate).format( 'DD-MM-YYYY',
'DD-MM-YYYY', )
) "
" />
/> </div>
<Price
v-if="
model.dashboardData.value?.gasCheapest
?.totalPriceTaxIncluded
"
:label="t('dashboard.cheapest_gas_price')"
:price="
model.dashboardData.value?.gasCheapest
?.totalPriceTaxIncluded
"
:time="
moment(
model.dashboardData.value?.gasCheapest?.startDate,
).format('DD-MM-YYYY')
"
/>
<Price
v-if="
model.dashboardData.value?.gasPriciest
?.totalPriceTaxIncluded
"
:label="t('dashboard.priciest_gas_price')"
:price="
model.dashboardData.value?.gasPriciest
?.totalPriceTaxIncluded
"
:time="
moment(
model.dashboardData.value?.gasPriciest?.startDate,
).format('DD-MM-YYYY')
"
/>
</div> </div>
</template>
<div
v-else-if="!store.isLoading && !store.dashboardData"
class="rounded-xl bg-gray-100 p-8 text-center text-gray-500"
>
{{ t('dashboard.no_data') }}
</div> </div>
<Error :message="model.error.value" v-if="model.error.value" /> <Error :message="store.error" v-if="store.error" />
</template> </template>
@@ -1,26 +0,0 @@
import { create } from 'axios';
import { ref } from 'vue';
import DashboardData = App.Features.Dashboard.Data.DashboardData;
export default function useDashboardViewModel() {
const isLoading = ref<boolean>(false);
const error = ref<string>();
const dashboardData = ref<DashboardData>();
function getDashboardData() {
isLoading.value = true;
const client = create();
client
.get<DashboardData>('/api/energy-prices')
.then((res) => {
dashboardData.value = res.data;
})
.catch((err) => {
error.value = err.response.data.message;
})
.finally(() => (isLoading.value = false));
}
return { isLoading, dashboardData, error, getDashboardData };
}
@@ -29,6 +29,7 @@ export interface PricePoint {
const props = defineProps<{ const props = defineProps<{
data: PricePoint[]; data: PricePoint[];
color?: string;
}>(); }>();
const chartData = computed(() => ({ const chartData = computed(() => ({
@@ -36,8 +37,10 @@ const chartData = computed(() => ({
datasets: [ datasets: [
{ {
data: props.data.map((d) => d.value), data: props.data.map((d) => d.value),
borderColor: '#4ba66a', borderColor: props.color || '#4ba66a',
backgroundColor: 'rgba(75, 166, 106, 0.1)', backgroundColor: props.color
? `${props.color}1a`
: 'rgba(75, 166, 106, 0.1)',
fill: true, fill: true,
tension: 0.4, tension: 0.4,
pointRadius: 2, pointRadius: 2,
+88 -13
View File
@@ -1,33 +1,108 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import useHistoryViewModel from '../viewmodels/HistoryViewModel';
import moment from 'moment'; import moment from 'moment';
import { useHistoryStore } from '@/stores/history';
import Chart from '../components/Chart.vue'; import Chart from '../components/Chart.vue';
import Loading from '@/components/Loading.vue';
import Error from '@/components/Error.vue';
import { t } from 'i18next';
const model = useHistoryViewModel(); const store = useHistoryStore();
const date = ref<Date>();
onMounted(() => { onMounted(() => {
model.getHistoryData(moment().format('YYYY-MM-DD')); store.fetchHistoryData(moment().format('YYYY-MM-DD'));
}); });
watch(date, (value) => { watch(
model.getHistoryData(moment(value).format('YYYY-MM-DD')); () => store.selectedDate,
}); (value) => {
if (value) {
store.fetchHistoryData(value);
}
},
);
const chartData = computed( const electricityChartData = computed(
() => () =>
model.historyData.value?.electricity store.historyData?.electricity
.filter((e) => e.totalPriceTaxIncluded != null) .filter((e) => e.totalPriceTaxIncluded != null)
.map((e) => ({ .map((e) => ({
label: moment(e.startDate).format('HH:mm'), label: moment(e.startDate).format('HH:mm'),
value: e.totalPriceTaxIncluded, value: e.totalPriceTaxIncluded,
})) ?? [], })) ?? [],
); );
const gasChartData = computed(
() =>
store.historyData?.gas
.filter((g) => g.totalPriceTaxIncluded != null)
.map((g) => ({
label: moment(g.startDate).format('DD-MM-YYYY'),
value: g.totalPriceTaxIncluded,
})) ?? [],
);
</script> </script>
<template> <template>
<input type="date" v-model="date" /> <h1 class="mb-4 text-3xl font-black text-primary">
<Chart :data="chartData" /> {{ t('history.title') }}
</h1>
<div class="mb-6">
<label
for="date-select"
class="mb-2 block text-sm font-medium text-gray-700"
>
{{ t('history.select_date') }}
</label>
<input
id="date-select"
type="date"
:value="store.selectedDate"
@input="
store.selectedDate = ($event.target as HTMLInputElement).value
"
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm shadow-sm focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
/>
</div>
<Loading v-if="store.isLoading" />
<template v-else-if="store.historyData">
<div class="mb-6">
<h2 class="mb-2 text-xl font-bold text-primary">
{{ t('history.electricity_prices') }}
</h2>
<Chart
v-if="electricityChartData.length > 0"
:data="electricityChartData"
/>
<div
v-else
class="rounded-xl bg-gray-100 p-8 text-center text-gray-500"
>
{{ t('history.no_electricity_data') }}
</div>
</div>
<div>
<h2 class="mb-2 text-xl font-bold text-primary">
{{ t('history.gas_prices') }}
</h2>
<Chart
v-if="gasChartData.length > 0"
:data="gasChartData"
color="#3b82f6"
/>
<div
v-else
class="rounded-xl bg-gray-100 p-8 text-center text-gray-500"
>
{{ t('history.no_gas_data') }}
</div>
</div>
</template>
<div
v-else-if="!store.isLoading && !store.historyData"
class="rounded-xl bg-gray-100 p-8 text-center text-gray-500"
>
{{ t('history.no_data') }}
</div>
<Error v-if="store.error" :message="store.error" />
</template> </template>
@@ -1,39 +0,0 @@
import { create } from 'axios';
import { ref } from 'vue';
import HistoryData = App.Features.History.Data.HistoryData;
import moment from 'moment';
export default function useHistoryViewModel() {
const isLoading = ref<boolean>(false);
const historyData = ref<HistoryData>();
function getHistoryData(date: string) {
isLoading.value = true;
const client = create();
client
.get<HistoryData>('/api/history', {
params: {
date,
},
})
.then((res) => {
console.log(
res.data?.electricity.flatMap((e) => {
if (!e.totalPriceTaxIncluded) {
return;
}
return {
label: moment(e.startDate).format('DD-MM-YYYY'),
value: e.totalPriceTaxIncluded?.toPrecision(2),
};
}),
);
historyData.value = res.data;
})
.finally(() => (isLoading.value = false));
}
return { isLoading, historyData, getHistoryData };
}
+2 -4
View File
@@ -28,13 +28,11 @@ declare namespace App {
namespace Dashboard { namespace Dashboard {
namespace Data { namespace Data {
export type DashboardData = { export type DashboardData = {
electricity: App.Data.ElectricityData; electricity: App.Data.ElectricityData | null;
electricityCheapest: App.Data.ElectricityData | null; electricityCheapest: App.Data.ElectricityData | null;
electricityDurable: App.Data.ElectricityData | null; electricityDurable: App.Data.ElectricityData | null;
electricityPriciest: App.Data.ElectricityData | null; electricityPriciest: App.Data.ElectricityData | null;
gas: App.Data.GasData; gas: App.Data.GasData | null;
gasCheapest: App.Data.GasData | null;
gasPriciest: App.Data.GasData | null;
}; };
} }
} }
@@ -1,3 +1,3 @@
{ {
"generated.d.ts": "596cfad66b8ff3f8b6b98271630919ae" "generated.d.ts": "50b70ce1a75284fa50634037e0073fb4"
} }
@@ -8,5 +8,6 @@
"priciest_electricity_price": "Duurste elektriciteitsprijs", "priciest_electricity_price": "Duurste elektriciteitsprijs",
"current_gas_price": "Actuele gasprijs", "current_gas_price": "Actuele gasprijs",
"cheapest_gas_price": "Goedkoopste gasprijs", "cheapest_gas_price": "Goedkoopste gasprijs",
"priciest_gas_price": "Duurste gasprijs" "priciest_gas_price": "Duurste gasprijs",
"no_data": "Geen prijsgegevens beschikbaar"
} }
@@ -1,3 +1,9 @@
{ {
"title": "Prijshistorie" "title": "Prijshistorie",
"select_date": "Selecteer een datum",
"electricity_prices": "Elektriciteitsprijzen",
"gas_prices": "Gasprijzen",
"no_data": "Geen prijsgegevens beschikbaar voor deze datum",
"no_electricity_data": "Geen elektriciteitsprijsgegevens beschikbaar",
"no_gas_data": "Geen gasprijsgegevens beschikbaar"
} }
+24
View File
@@ -0,0 +1,24 @@
import axios from 'axios';
const http = axios.create({
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
http.interceptors.response.use(
(response) => response,
(error) => {
const message =
error.response?.data?.message ||
error.message ||
'Er is een onverwachte fout opgetreden';
console.error('[HTTP Error]', message);
return Promise.reject(error);
},
);
export default http;
-7
View File
@@ -1,7 +0,0 @@
import { clsx } from 'clsx';
import type { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import http from '@/lib/http';
import DashboardData = App.Features.Dashboard.Data.DashboardData;
export const useDashboardStore = defineStore('dashboard', () => {
const isLoading = ref(false);
const error = ref<string>();
const dashboardData = ref<DashboardData>();
async function fetchDashboardData() {
isLoading.value = true;
error.value = undefined;
try {
const { data } = await http.get<DashboardData>('/api/energy-prices');
dashboardData.value = data;
} catch (err: unknown) {
const axiosError = err as { response?: { data?: { message?: string } } };
error.value =
axiosError.response?.data?.message ||
'Kon dashboard data niet ophalen';
} finally {
isLoading.value = false;
}
}
return {
isLoading,
error,
dashboardData,
fetchDashboardData,
};
});
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import http from '@/lib/http';
import HistoryData = App.Features.History.Data.HistoryData;
export const useHistoryStore = defineStore('history', () => {
const isLoading = ref(false);
const error = ref<string>();
const historyData = ref<HistoryData>();
const selectedDate = ref<string>(new Date().toISOString().split('T')[0]);
async function fetchHistoryData(date: string) {
isLoading.value = true;
error.value = undefined;
selectedDate.value = date;
try {
const { data } = await http.get<HistoryData>('/api/history', {
params: { date },
});
historyData.value = data;
} catch (err: unknown) {
const axiosError = err as {
response?: { data?: { message?: string } };
};
error.value =
axiosError.response?.data?.message ||
'Kon prijshistorie niet ophalen';
} finally {
isLoading.value = false;
}
}
return {
isLoading,
error,
historyData,
selectedDate,
fetchHistoryData,
};
});
-1
View File
@@ -1 +0,0 @@
export * from './auth';
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace Tests\Feature\Api;
use App\Contracts\PriceClient;
use App\Data\ElectricityData;
use App\Data\GasData;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
class DashboardApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache_prices.ttl' => 60]);
config(['cache_prices.prefix' => 'test:']);
}
protected function tearDown(): void
{
Mockery::close();
\Illuminate\Support\Facades\Cache::flush();
parent::tearDown();
}
public function test_dashboard_endpoint_returns_electricity_and_gas_data(): void
{
$now = Carbon::now();
$electricityResponse = collect([
$this->createElectricityArray($now->copy()->subMinutes(30), 15.0, 80),
$this->createElectricityArray($now->copy()->addHour(), 12.0, 90),
$this->createElectricityArray($now->copy()->addHours(2), 20.0, 60),
$this->createElectricityArray($now->copy()->addHours(3), 10.0, 95),
]);
$gasResponse = collect([
$this->createGasArray($now->copy()->startOfDay(), 5.0),
$this->createGasArray($now->copy()->addDay(), 4.0),
$this->createGasArray($now->copy()->addDays(2), 6.0),
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPrices')->once()->andReturn($electricityResponse);
$client->shouldReceive('getGasPrices')->once()->andReturn($gasResponse);
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/energy-prices');
$response->assertOk()
->assertJsonStructure([
'electricity' => [
'startDate',
'endDate',
'period',
'totalPriceTaxIncluded',
],
'electricityCheapest',
'electricityDurable',
'electricityPriciest',
'gas' => [
'startDate',
'endDate',
'period',
'totalPriceTaxIncluded',
],
'gasCheapest',
'gasPriciest',
]);
}
public function test_dashboard_endpoint_returns_correct_prices(): void
{
$now = Carbon::now();
$electricityResponse = collect([
$this->createElectricityArray($now->copy()->subMinutes(30), 15.0, 80),
$this->createElectricityArray($now->copy()->addHour(), 12.0, 90),
$this->createElectricityArray($now->copy()->addHours(2), 20.0, 60),
]);
$gasResponse = collect([
$this->createGasArray($now->copy()->startOfDay(), 5.0),
$this->createGasArray($now->copy()->addDay(), 4.0),
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPrices')->once()->andReturn($electricityResponse);
$client->shouldReceive('getGasPrices')->once()->andReturn($gasResponse);
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/energy-prices');
$response->assertOk();
$data = $response->json();
$this->assertEquals(15.0, $data['electricity']['totalPriceTaxIncluded']);
$this->assertEquals(12.0, $data['electricityCheapest']['totalPriceTaxIncluded']);
$this->assertEquals(20.0, $data['electricityPriciest']['totalPriceTaxIncluded']);
$this->assertEquals(5.0, $data['gas']['totalPriceTaxIncluded']);
$this->assertEquals(4.0, $data['gasCheapest']['totalPriceTaxIncluded']);
}
public function test_dashboard_endpoint_handles_empty_data(): void
{
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPrices')->once()->andReturn(collect());
$client->shouldReceive('getGasPrices')->once()->andReturn(collect());
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/energy-prices');
$response->assertOk();
$data = $response->json();
$this->assertNull($data['electricity'] ?? null);
$this->assertNull($data['gas'] ?? null);
}
private function createElectricityArray(Carbon $startDate, float $price, int $sustainabilityScore): array
{
return [
'start_date' => $startDate->format('Y-m-d H:i:s'),
'end_date' => $startDate->copy()->addHour()->format('Y-m-d H:i:s'),
'period' => 'elec_hour',
'market_price' => (int) ($price * 100),
'total_price_tax_included' => $price,
'price_incl_handling_vat' => (int) ($price * 100),
'price_tax_with_vat' => (int) ($price * 20),
'pricing_profile' => 'dynamic',
'carbon_footprint_in_gram' => 500,
'sustainability_score' => $sustainabilityScore,
'start_date_datetime' => $startDate->format('Y-m-d H:i:s'),
];
}
private function createGasArray(Carbon $startDate, float $price): array
{
return [
'start_date' => $startDate->format('Y-m-d H:i:s'),
'end_date' => $startDate->copy()->addDay()->format('Y-m-d H:i:s'),
'period' => 'gas_day',
'market_price' => (int) ($price * 100),
'total_price_tax_included' => $price,
'price_incl_handling_vat' => (int) ($price * 100),
'price_tax_with_vat' => (int) ($price * 20),
'start_date_datetime' => $startDate->format('Y-m-d H:i:s'),
];
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace Tests\Feature\Api;
use App\Contracts\PriceClient;
use App\Data\ElectricityData;
use App\Data\GasData;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
class HistoryApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache_prices.ttl' => 60]);
config(['cache_prices.prefix' => 'test:']);
}
protected function tearDown(): void
{
Mockery::close();
\Illuminate\Support\Facades\Cache::flush();
parent::tearDown();
}
public function test_history_endpoint_returns_electricity_and_gas_data(): void
{
$date = Carbon::today();
$electricityResponse = collect([
$this->createElectricityArray($date->copy()->setHour(0), 15.0),
$this->createElectricityArray($date->copy()->setHour(1), 16.0),
]);
$gasResponse = collect([
$this->createGasArray($date, 5.0),
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPricesForDate')
->once()
->andReturn($electricityResponse);
$client->shouldReceive('getGasPricesForDate')
->once()
->andReturn($gasResponse);
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/history?date=' . $date->format('Y-m-d'));
$response->assertOk()
->assertJsonStructure([
'electricity' => [
'*' => [
'startDate',
'endDate',
'period',
'totalPriceTaxIncluded',
],
],
'gas' => [
'*' => [
'startDate',
'endDate',
'period',
'totalPriceTaxIncluded',
],
],
]);
}
public function test_history_endpoint_returns_correct_data(): void
{
$date = Carbon::today();
$electricityResponse = collect([
$this->createElectricityArray($date->copy()->setHour(0), 15.0),
$this->createElectricityArray($date->copy()->setHour(1), 16.0),
]);
$gasResponse = collect([
$this->createGasArray($date, 5.0),
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPricesForDate')
->once()
->andReturn($electricityResponse);
$client->shouldReceive('getGasPricesForDate')
->once()
->andReturn($gasResponse);
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/history?date=' . $date->format('Y-m-d'));
$response->assertOk();
$data = $response->json();
$this->assertCount(2, $data['electricity']);
$this->assertCount(1, $data['gas']);
$this->assertEquals(15.0, $data['electricity'][0]['totalPriceTaxIncluded']);
$this->assertEquals(5.0, $data['gas'][0]['totalPriceTaxIncluded']);
}
public function test_history_endpoint_defaults_to_today(): void
{
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPricesForDate')
->once()
->andReturn(collect());
$client->shouldReceive('getGasPricesForDate')
->once()
->andReturn(collect());
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/history');
$response->assertOk();
}
public function test_history_endpoint_handles_empty_data(): void
{
$date = Carbon::today();
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPricesForDate')
->once()
->andReturn(collect());
$client->shouldReceive('getGasPricesForDate')
->once()
->andReturn(collect());
$this->app->bind(PriceClient::class, fn () => $client);
$response = $this->getJson('/api/history?date=' . $date->format('Y-m-d'));
$response->assertOk();
$data = $response->json();
$this->assertEmpty($data['electricity']);
$this->assertEmpty($data['gas']);
}
private function createElectricityArray(Carbon $startDate, float $price): array
{
return [
'start_date' => $startDate->format('Y-m-d H:i:s'),
'end_date' => $startDate->copy()->addHour()->format('Y-m-d H:i:s'),
'period' => 'elec_hour',
'market_price' => (int) ($price * 100),
'total_price_tax_included' => $price,
'price_incl_handling_vat' => (int) ($price * 100),
'price_tax_with_vat' => (int) ($price * 20),
'pricing_profile' => 'dynamic',
'carbon_footprint_in_gram' => 500,
'sustainability_score' => 80,
'start_date_datetime' => $startDate->format('Y-m-d H:i:s'),
];
}
private function createGasArray(Carbon $startDate, float $price): array
{
return [
'start_date' => $startDate->format('Y-m-d H:i:s'),
'end_date' => $startDate->copy()->addDay()->format('Y-m-d H:i:s'),
'period' => 'gas_day',
'market_price' => (int) ($price * 100),
'total_price_tax_included' => $price,
'price_incl_handling_vat' => (int) ($price * 100),
'price_tax_with_vat' => (int) ($price * 20),
'start_date_datetime' => $startDate->format('Y-m-d H:i:s'),
];
}
}
@@ -0,0 +1,143 @@
<?php
namespace Tests\Unit\Clients;
use App\Clients\ZonneplanPriceClient;
use Carbon\Carbon;
use Illuminate\Http\Client\Factory;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Response;
use Mockery;
use Tests\TestCase;
class ZonneplanPriceClientTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_electricity_prices_returns_collection(): void
{
$response = Mockery::mock();
$response->shouldReceive('throw')->once();
$response->shouldReceive('json')
->once()
->with('data', [])
->andReturn([
['start_date' => Carbon::now()->format('Y-m-d H:i:s'), 'total_price_tax_included' => 15.0],
]);
$response->shouldReceive('status')->once()->andReturn(200);
$http = Mockery::mock(Factory::class);
$http->shouldReceive('baseUrl')->once()->andReturnSelf();
$http->shouldReceive('withQueryParameters')->once()->andReturnSelf();
$http->shouldReceive('timeout')->once()->andReturnSelf();
$http->shouldReceive('retry')->once()->andReturnSelf();
$http->shouldReceive('get')->once()->andReturn($response);
$client = new ZonneplanPriceClient($http);
$result = $client->getElectricityPrices();
$this->assertCount(1, $result);
}
public function test_get_gas_prices_returns_collection(): void
{
$response = Mockery::mock();
$response->shouldReceive('throw')->once();
$response->shouldReceive('json')
->once()
->with('data', [])
->andReturn([
['start_date' => Carbon::now()->format('Y-m-d H:i:s'), 'total_price_tax_included' => 5.0],
]);
$response->shouldReceive('status')->once()->andReturn(200);
$http = Mockery::mock(Factory::class);
$http->shouldReceive('baseUrl')->once()->andReturnSelf();
$http->shouldReceive('withQueryParameters')->once()->andReturnSelf();
$http->shouldReceive('timeout')->once()->andReturnSelf();
$http->shouldReceive('retry')->once()->andReturnSelf();
$http->shouldReceive('get')->once()->andReturn($response);
$client = new ZonneplanPriceClient($http);
$result = $client->getGasPrices();
$this->assertCount(1, $result);
}
public function test_get_electricity_prices_for_date_returns_collection(): void
{
$date = Carbon::today();
$response = Mockery::mock();
$response->shouldReceive('throw')->once();
$response->shouldReceive('json')
->once()
->with('data', [])
->andReturn([
['start_date' => $date->format('Y-m-d H:i:s'), 'total_price_tax_included' => 15.0],
]);
$response->shouldReceive('status')->once()->andReturn(200);
$http = Mockery::mock(Factory::class);
$http->shouldReceive('baseUrl')->once()->andReturnSelf();
$http->shouldReceive('withQueryParameters')->once()->andReturnSelf();
$http->shouldReceive('timeout')->once()->andReturnSelf();
$http->shouldReceive('retry')->once()->andReturnSelf();
$http->shouldReceive('get')->once()->andReturn($response);
$client = new ZonneplanPriceClient($http);
$result = $client->getElectricityPricesForDate($date);
$this->assertCount(1, $result);
}
public function test_get_gas_prices_for_date_returns_collection(): void
{
$date = Carbon::today();
$response = Mockery::mock();
$response->shouldReceive('throw')->once();
$response->shouldReceive('json')
->once()
->with('data', [])
->andReturn([
['start_date' => $date->format('Y-m-d H:i:s'), 'total_price_tax_included' => 5.0],
]);
$response->shouldReceive('status')->once()->andReturn(200);
$http = Mockery::mock(Factory::class);
$http->shouldReceive('baseUrl')->once()->andReturnSelf();
$http->shouldReceive('withQueryParameters')->once()->andReturnSelf();
$http->shouldReceive('timeout')->once()->andReturnSelf();
$http->shouldReceive('retry')->once()->andReturnSelf();
$http->shouldReceive('get')->once()->andReturn($response);
$client = new ZonneplanPriceClient($http);
$result = $client->getGasPricesForDate($date);
$this->assertCount(1, $result);
}
public function test_client_throws_exception_on_api_failure(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Kon energieprijzen niet ophalen');
$response = Mockery::mock();
$response->shouldReceive('throw')->once()->andThrow(
new \Illuminate\Http\Client\ConnectionException('API Error')
);
$http = Mockery::mock(Factory::class);
$http->shouldReceive('baseUrl')->once()->andReturnSelf();
$http->shouldReceive('withQueryParameters')->once()->andReturnSelf();
$http->shouldReceive('timeout')->once()->andReturnSelf();
$http->shouldReceive('retry')->once()->andReturnSelf();
$http->shouldReceive('get')->once()->andReturn($response);
$client = new ZonneplanPriceClient($http);
$client->getElectricityPrices();
}
}
@@ -0,0 +1,189 @@
<?php
namespace Tests\Unit\Repositories;
use App\Contracts\PriceClient;
use App\Data\ElectricityData;
use App\Data\GasData;
use App\Repositories\ZonneplanPriceRepository;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Mockery;
use Tests\TestCase;
class ZonneplanPriceRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache_prices.ttl' => 60]);
config(['cache_prices.prefix' => 'test:']);
}
protected function tearDown(): void
{
Mockery::close();
Cache::flush();
parent::tearDown();
}
public function test_retrieve_electricity_returns_collection_of_electricity_data(): void
{
$now = Carbon::now();
$electricityResponse = collect([
[
'start_date' => $now->format('Y-m-d H:i:s'),
'end_date' => $now->copy()->addHour()->format('Y-m-d H:i:s'),
'period' => 'elec_hour',
'market_price' => 1500,
'total_price_tax_included' => 15.50,
'price_incl_handling_vat' => 1550,
'price_tax_with_vat' => 300,
'pricing_profile' => 'dynamic',
'carbon_footprint_in_gram' => 500,
'sustainability_score' => 80,
'start_date_datetime' => $now->format('Y-m-d H:i:s'),
],
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPrices')
->once()
->andReturn($electricityResponse);
$repository = new ZonneplanPriceRepository($client);
$result = $repository->retrieveElectricity();
$this->assertInstanceOf(Collection::class, $result);
$this->assertCount(1, $result);
$this->assertInstanceOf(ElectricityData::class, $result->first());
}
public function test_retrieve_gas_returns_collection_of_gas_data(): void
{
$now = Carbon::now();
$gasResponse = collect([
[
'start_date' => $now->format('Y-m-d H:i:s'),
'end_date' => $now->copy()->addDay()->format('Y-m-d H:i:s'),
'period' => 'gas_day',
'market_price' => 500,
'total_price_tax_included' => 5.50,
'price_incl_handling_vat' => 550,
'price_tax_with_vat' => 100,
'start_date_datetime' => $now->format('Y-m-d H:i:s'),
],
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getGasPrices')
->once()
->andReturn($gasResponse);
$repository = new ZonneplanPriceRepository($client);
$result = $repository->retrieveGas();
$this->assertInstanceOf(Collection::class, $result);
$this->assertCount(1, $result);
$this->assertInstanceOf(GasData::class, $result->first());
}
public function test_retrieve_electricity_for_date_returns_collection(): void
{
$date = Carbon::today();
$electricityResponse = collect([
[
'start_date' => $date->copy()->setHour(0)->format('Y-m-d H:i:s'),
'end_date' => $date->copy()->setHour(1)->format('Y-m-d H:i:s'),
'period' => 'elec_hour',
'market_price' => 1500,
'total_price_tax_included' => 15.50,
'price_incl_handling_vat' => 1550,
'price_tax_with_vat' => 300,
'pricing_profile' => 'dynamic',
'carbon_footprint_in_gram' => 500,
'sustainability_score' => 80,
'start_date_datetime' => $date->copy()->setHour(0)->format('Y-m-d H:i:s'),
],
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPricesForDate')
->once()
->with($date)
->andReturn($electricityResponse);
$repository = new ZonneplanPriceRepository($client);
$result = $repository->retrieveElectricityForDate($date);
$this->assertInstanceOf(Collection::class, $result);
$this->assertCount(1, $result);
$this->assertInstanceOf(ElectricityData::class, $result->first());
}
public function test_retrieve_gas_for_date_returns_collection(): void
{
$date = Carbon::today();
$gasResponse = collect([
[
'start_date' => $date->format('Y-m-d H:i:s'),
'end_date' => $date->copy()->addDay()->format('Y-m-d H:i:s'),
'period' => 'gas_day',
'market_price' => 500,
'total_price_tax_included' => 5.50,
'price_incl_handling_vat' => 550,
'price_tax_with_vat' => 100,
'start_date_datetime' => $date->format('Y-m-d H:i:s'),
],
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getGasPricesForDate')
->once()
->with($date)
->andReturn($gasResponse);
$repository = new ZonneplanPriceRepository($client);
$result = $repository->retrieveGasForDate($date);
$this->assertInstanceOf(Collection::class, $result);
$this->assertCount(1, $result);
$this->assertInstanceOf(GasData::class, $result->first());
}
public function test_retrieve_electricity_caches_result(): void
{
$now = Carbon::now();
$electricityResponse = collect([
[
'start_date' => $now->format('Y-m-d H:i:s'),
'end_date' => $now->copy()->addHour()->format('Y-m-d H:i:s'),
'period' => 'elec_hour',
'market_price' => 1500,
'total_price_tax_included' => 15.50,
'price_incl_handling_vat' => 1550,
'price_tax_with_vat' => 300,
'pricing_profile' => 'dynamic',
'carbon_footprint_in_gram' => 500,
'sustainability_score' => 80,
'start_date_datetime' => $now->format('Y-m-d H:i:s'),
],
]);
$client = Mockery::mock(PriceClient::class);
$client->shouldReceive('getElectricityPrices')
->once()
->andReturn($electricityResponse);
$repository = new ZonneplanPriceRepository($client);
$result1 = $repository->retrieveElectricity();
$result2 = $repository->retrieveElectricity();
$this->assertEquals(
$result1->first()->totalPriceTaxIncluded,
$result2->first()->totalPriceTaxIncluded
);
}
}
@@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Services;
use App\Contracts\PriceRepository;
use App\Data\ElectricityData;
use App\Data\GasData;
use App\Features\Dashboard\Services\DashboardService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
class DashboardServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_dashboard_data_returns_current_cheapest_sustainable_and_expensive_prices(): void
{
$now = Carbon::now();
$electricityData = collect([
$this->createElectricityData($now->copy()->subMinutes(30), 15.0, 80),
$this->createElectricityData($now->copy()->addHour(), 12.0, 90),
$this->createElectricityData($now->copy()->addHours(2), 20.0, 60),
$this->createElectricityData($now->copy()->addHours(3), 10.0, 95),
]);
$gasData = collect([
$this->createGasData($now->copy()->startOfDay(), 5.0),
$this->createGasData($now->copy()->addDay(), 4.0),
$this->createGasData($now->copy()->addDays(2), 6.0),
]);
$repository = Mockery::mock(PriceRepository::class);
$repository->shouldReceive('retrieveElectricity')->once()->andReturn($electricityData);
$repository->shouldReceive('retrieveGas')->once()->andReturn($gasData);
$service = new DashboardService($repository);
$result = $service->getDashboardData();
$this->assertEquals(15.0, $result->electricity->totalPriceTaxIncluded);
$this->assertEquals(10.0, $result->electricityCheapest->totalPriceTaxIncluded);
$this->assertEquals(95, $result->electricityDurable->sustainabilityScore);
$this->assertEquals(20.0, $result->electricityPriciest->totalPriceTaxIncluded);
$this->assertEquals(5.0, $result->gas->totalPriceTaxIncluded);
$this->assertEquals(4.0, $result->gasCheapest->totalPriceTaxIncluded);
$this->assertEquals(6.0, $result->gasPriciest->totalPriceTaxIncluded);
}
public function test_get_dashboard_data_returns_null_for_missing_current_price(): void
{
$now = Carbon::now();
$electricityData = collect([
$this->createElectricityData($now->copy()->addHours(5), 15.0, 80),
]);
$gasData = collect([
$this->createGasData($now->copy()->addDays(5), 5.0),
]);
$repository = Mockery::mock(PriceRepository::class);
$repository->shouldReceive('retrieveElectricity')->once()->andReturn($electricityData);
$repository->shouldReceive('retrieveGas')->once()->andReturn($gasData);
$service = new DashboardService($repository);
$result = $service->getDashboardData();
$this->assertNull($result->electricity);
$this->assertNull($result->gas);
$this->assertNotNull($result->electricityCheapest);
$this->assertNotNull($result->gasCheapest);
}
public function test_get_dashboard_data_with_empty_collections(): void
{
$repository = Mockery::mock(PriceRepository::class);
$repository->shouldReceive('retrieveElectricity')->once()->andReturn(collect());
$repository->shouldReceive('retrieveGas')->once()->andReturn(collect());
$service = new DashboardService($repository);
$result = $service->getDashboardData();
$this->assertNull($result->electricity);
$this->assertNull($result->electricityCheapest);
$this->assertNull($result->electricityDurable);
$this->assertNull($result->electricityPriciest);
$this->assertNull($result->gas);
$this->assertNull($result->gasCheapest);
$this->assertNull($result->gasPriciest);
}
private function createElectricityData(Carbon $startDate, float $price, int $sustainabilityScore): ElectricityData
{
return new ElectricityData(
startDate: $startDate,
endDate: $startDate->copy()->addHour(),
period: 'elec_hour',
marketPrice: (int) ($price * 100),
totalPriceTaxIncluded: $price,
priceInclHandlingVat: (int) ($price * 100),
priceTaxWithVat: (int) ($price * 20),
pricingProfile: 'dynamic',
carbonFootprintInGram: 500,
sustainabilityScore: $sustainabilityScore,
startDateDatetime: $startDate,
);
}
private function createGasData(Carbon $startDate, float $price): GasData
{
return new GasData(
startDate: $startDate,
endDate: $startDate->copy()->addDay(),
period: 'gas_day',
marketPrice: (int) ($price * 100),
totalPriceTaxIncluded: $price,
priceInclHandlingVat: (int) ($price * 100),
priceTaxWithVat: (int) ($price * 20),
startDateDatetime: $startDate,
);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
namespace Tests\Unit\Services;
use App\Contracts\PriceRepository;
use App\Data\ElectricityData;
use App\Data\GasData;
use App\Features\History\Services\HistoryService;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Mockery;
use Tests\TestCase;
class HistoryServiceTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_get_history_data_returns_electricity_and_gas_for_date(): void
{
$date = Carbon::today();
$electricityData = collect([
$this->createElectricityData($date->copy()->setHour(0), 15.0),
$this->createElectricityData($date->copy()->setHour(1), 16.0),
]);
$gasData = collect([
$this->createGasData($date, 5.0),
]);
$repository = Mockery::mock(PriceRepository::class);
$repository->shouldReceive('retrieveElectricityForDate')
->once()
->with($date)
->andReturn($electricityData);
$repository->shouldReceive('retrieveGasForDate')
->once()
->with($date)
->andReturn($gasData);
$service = new HistoryService($repository);
$result = $service->getHistoryData($date);
$this->assertCount(2, $result->electricity);
$this->assertCount(1, $result->gas);
$this->assertEquals(15.0, $result->electricity->first()->totalPriceTaxIncluded);
$this->assertEquals(5.0, $result->gas->first()->totalPriceTaxIncluded);
}
public function test_get_history_data_with_empty_collections(): void
{
$date = Carbon::today();
$repository = Mockery::mock(PriceRepository::class);
$repository->shouldReceive('retrieveElectricityForDate')
->once()
->andReturn(collect());
$repository->shouldReceive('retrieveGasForDate')
->once()
->andReturn(collect());
$service = new HistoryService($repository);
$result = $service->getHistoryData($date);
$this->assertCount(0, $result->electricity);
$this->assertCount(0, $result->gas);
}
private function createElectricityData(Carbon $startDate, float $price): ElectricityData
{
return new ElectricityData(
startDate: $startDate,
endDate: $startDate->copy()->addHour(),
period: 'elec_hour',
marketPrice: (int) ($price * 100),
totalPriceTaxIncluded: $price,
priceInclHandlingVat: (int) ($price * 100),
priceTaxWithVat: (int) ($price * 20),
pricingProfile: 'dynamic',
carbonFootprintInGram: 500,
sustainabilityScore: 80,
startDateDatetime: $startDate,
);
}
private function createGasData(Carbon $startDate, float $price): GasData
{
return new GasData(
startDate: $startDate,
endDate: $startDate->copy()->addDay(),
period: 'gas_day',
marketPrice: (int) ($price * 100),
totalPriceTaxIncluded: $price,
priceInclHandlingVat: (int) ($price * 100),
priceTaxWithVat: (int) ($price * 20),
startDateDatetime: $startDate,
);
}
}