From af273889d83f91d0a219751da15723da1c093b50 Mon Sep 17 00:00:00 2001 From: sebastian Date: Tue, 30 Jun 2026 15:46:06 +0200 Subject: [PATCH] ADD: Temperatur and Weather Overview --- package-lock.json | 33 ++++ package.json | 2 + src/app/app.html | 5 +- src/app/app.ts | 4 +- .../diagrams/temperature/temperature.css | 0 .../diagrams/temperature/temperature.html | 21 +++ .../diagrams/temperature/temperature.spec.ts | 22 +++ .../diagrams/temperature/temperature.ts | 120 ++++++++++++++ .../wettter-overview/wettter-overview.css | 85 ++++++++++ .../wettter-overview/wettter-overview.html | 85 ++++++++++ .../wettter-overview/wettter-overview.spec.ts | 22 +++ .../wettter-overview/wettter-overview.ts | 147 ++++++++++++++++++ 12 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 src/app/components/diagrams/temperature/temperature.css create mode 100644 src/app/components/diagrams/temperature/temperature.html create mode 100644 src/app/components/diagrams/temperature/temperature.spec.ts create mode 100644 src/app/components/diagrams/temperature/temperature.ts create mode 100644 src/app/components/wettter-overview/wettter-overview.css create mode 100644 src/app/components/wettter-overview/wettter-overview.html create mode 100644 src/app/components/wettter-overview/wettter-overview.spec.ts create mode 100644 src/app/components/wettter-overview/wettter-overview.ts diff --git a/package-lock.json b/package-lock.json index c7804c3..3dc9830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", + "date-fns": "^4.4.0", + "ng-apexcharts": "^2.4.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -4533,6 +4535,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/apexcharts": { + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.2.tgz", + "integrity": "sha512-qbd+ehDiRxo++wAqaGLmJcd683ktulTqoku4WYYyQ3XOgJIn4yhOQGV27KcdK7c2yhwxblyh4mgMm8ukI0wC0Q==", + "license": "SEE LICENSE IN LICENSE", + "peer": true + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5308,6 +5317,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7349,6 +7368,20 @@ "node": ">= 0.4.0" } }, + "node_modules/ng-apexcharts": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ng-apexcharts/-/ng-apexcharts-2.4.0.tgz", + "integrity": "sha512-bNXIOQrux9ZXnpm4kxbW8M6AA4ENAEOpR31ZoaeBkc0SrfOMw1nxRN5ORkStOQFVQF5Wf5qivAA9KIgU7Ol26A==", + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=20.0.0", + "@angular/core": ">=20.0.0", + "apexcharts": "^5.10.3", + "rxjs": "^7.8.2" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", diff --git a/package.json b/package.json index 8fa99d0..1982299 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", + "date-fns": "^4.4.0", + "ng-apexcharts": "^2.4.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/src/app/app.html b/src/app/app.html index 27b099f..390d15d 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -25,7 +25,10 @@ - Main Cointent + + + + diff --git a/src/app/app.ts b/src/app/app.ts index 81da418..f03e610 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -5,10 +5,12 @@ import {MatIcon} from '@angular/material/icon'; import {MatButton, MatIconButton} from '@angular/material/button'; import {MatDrawer, MatDrawerContainer, MatDrawerContent} from '@angular/material/sidenav'; import {StationList} from './station-list/station-list'; +import {WettterOverview} from './components/wettter-overview/wettter-overview'; +import {TemperatureComponent} from './components/diagrams/temperature/temperature'; @Component({ selector: 'app-root', - imports: [RouterOutlet, MatToolbar, MatIcon, MatIconButton, RouterLink, MatButton, MatDrawerContainer, MatDrawer, MatDrawerContent, StationList], + imports: [RouterOutlet, MatToolbar, MatIcon, MatIconButton, RouterLink, MatButton, MatDrawerContainer, MatDrawer, MatDrawerContent, StationList, WettterOverview, TemperatureComponent], templateUrl: './app.html', styleUrl: './app.css' }) diff --git a/src/app/components/diagrams/temperature/temperature.css b/src/app/components/diagrams/temperature/temperature.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/diagrams/temperature/temperature.html b/src/app/components/diagrams/temperature/temperature.html new file mode 100644 index 0000000..6da1947 --- /dev/null +++ b/src/app/components/diagrams/temperature/temperature.html @@ -0,0 +1,21 @@ +@if(measurements) { + + + Temperaturverlauf + + + + + + +} diff --git a/src/app/components/diagrams/temperature/temperature.spec.ts b/src/app/components/diagrams/temperature/temperature.spec.ts new file mode 100644 index 0000000..64c7196 --- /dev/null +++ b/src/app/components/diagrams/temperature/temperature.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Temperature } from './temperature'; + +describe('Temperature', () => { + let component: Temperature; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Temperature], + }).compileComponents(); + + fixture = TestBed.createComponent(Temperature); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/diagrams/temperature/temperature.ts b/src/app/components/diagrams/temperature/temperature.ts new file mode 100644 index 0000000..d4fc348 --- /dev/null +++ b/src/app/components/diagrams/temperature/temperature.ts @@ -0,0 +1,120 @@ +import {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core'; +import {DefaultService, MeasurementListResponse, MeasurementResolution, StationListResponse} from '../../../api'; +import {addDays, addHours, addMonths, addYears, startOfDay} from 'date-fns'; +import {ApexOptions, ChartComponent} from 'ng-apexcharts'; +import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; + +export type TimeResolution = '24h' | '7d' | '30d' | '1Y' | 'All' + +@Component({ + selector: 'app-temperature', + imports: [ + ChartComponent, + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + ], + templateUrl: './temperature.html', + styleUrl: './temperature.css', +}) +export class TemperatureComponent implements OnInit { + @Input() timeResolution: TimeResolution = '7d'; + @Input() activeStations: StationListResponse[] = []; + + resolution: MeasurementResolution = MeasurementResolution.Daily; + measurements: MeasurementListResponse | undefined; + chartOptions: ApexOptions = {}; + + constructor(private api: DefaultService, private cdr: ChangeDetectorRef) {} + + ngOnInit() { + if (this.activeStations.length === 0) { + this.api.listStationsStationsListGet().subscribe(stations => { + this.activeStations = stations; + this.fetchMeasurements(); + }); + } else { + this.fetchMeasurements(); + } + } + + get timeIntervalStart(): string | undefined { + const now = new Date(); + const map: Record = { + '24h': addHours(now, -24), + '7d': startOfDay(addDays(now, -7)), + '30d': startOfDay(addMonths(now, -1)), + '1Y': startOfDay(addYears(now, -1)), + 'All': undefined + }; + return map[this.timeResolution]?.toISOString(); + } + + fetchMeasurements() { + const stationIds = this.activeStations.map(s => s.id); + const now = new Date().toISOString(); + + this.api.getMeasurementsMeasurementsGet( + stationIds, undefined, this.timeIntervalStart, now, this.resolution + ).subscribe(data => { + this.measurements = data; + this.buildChart(); + }); + } + + buildChart() { + if (!this.measurements) return; + + this.chartOptions = { + chart: { + type: 'line', + height: 280, + toolbar: { show: false }, + animations: { enabled: true }, + background: 'transparent', + }, + theme: { mode: 'dark' }, + stroke: { + curve: 'smooth', + width: 2, + }, + series: this.measurements.stations.map(s => ({ + name: `${s.station.name} ${s.indoor ? '(innen)' : '(außen)'}`, + data: s.measurements.map(m => ({ + x: new Date(m.timestamp).getTime(), + y: m.temperature + })) + })), + xaxis: { + type: 'datetime', + labels: { + datetimeUTC: false, + style: { colors: '#9e9e9e', fontSize: '11px' } + }, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + yaxis: { + labels: { + style: { colors: '#9e9e9e', fontSize: '11px' }, + formatter: (val: number) => `${val.toFixed(1)}°` + } + }, + grid: { + borderColor: 'rgba(255,255,255,0.06)', + strokeDashArray: 4, + }, + tooltip: { + theme: 'dark', + x: { format: 'dd.MM.yyyy HH:mm' }, + y: { formatter: (val: number) => `${val.toFixed(1)} °C` } + }, + legend: { + labels: { colors: '#9e9e9e' } + }, + }; + this.cdr.detectChanges() + } +} diff --git a/src/app/components/wettter-overview/wettter-overview.css b/src/app/components/wettter-overview/wettter-overview.css new file mode 100644 index 0000000..d65cd18 --- /dev/null +++ b/src/app/components/wettter-overview/wettter-overview.css @@ -0,0 +1,85 @@ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin: 2em; +} + +.stat-card { + background: #1e1e1e !important; + color: #fff; + border-radius: 14px; + padding: 18px; + box-shadow: + 0 4px 10px rgba(0, 0, 0, .35), + inset 0 1px 0 rgba(255,255,255,.04); + transition: all .2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: + 0 8px 18px rgba(0,0,0,.45), + inset 0 1px 0 rgba(255,255,255,.05); + } +} + +.label { + display: flex; + align-items: center; + gap: 6px; + + color: #9e9e9e; + font-size: 0.85rem; + font-weight: 500; +} + +.label mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #8a8a8a; +} + +.value { + margin-top: 8px; + font-size: 2rem; + font-weight: 600; + line-height: 1; + letter-spacing: -0.5px; +} + +.change { + margin-top: 10px; + + display: flex; + align-items: center; + gap: 4px; + + font-size: .82rem; + font-weight: 500; +} + +.change mat-icon { + font-size: 15px; + width: 15px; + height: 15px; +} + +.positive { + color: #37d67a; +} + +.negative { + color: #ff6b6b; +} + +.neutral { + color: #9e9e9e; +} + +.forecast { + margin-top: 6px; + font-size: .78rem; + color: #9e9e9e; + font-style: italic; +} diff --git a/src/app/components/wettter-overview/wettter-overview.html b/src/app/components/wettter-overview/wettter-overview.html new file mode 100644 index 0000000..7ef4a8e --- /dev/null +++ b/src/app/components/wettter-overview/wettter-overview.html @@ -0,0 +1,85 @@ +
+ +
+ thermostat + Außen +
+ +
{{out_temperature}} °C
+ + @if (outer_temperature_diff) { + @if (outer_temperature_diff > 0) { +
+ arrow_upward + {{outer_temperature_diff | number : '1.1-2'}}° vs. gestern +
+ } @else { +
+ arrow_downward + {{outer_temperature_diff | number : '1.1-2'}}° vs. gestern +
+ } + } + +
+ + +
+ home + Innen +
+ +
{{inner_temperature | number: '1.1-2'}} °C
+ + @if (inner_temperature_diff) { + @if (inner_temperature_diff > 0) { +
+ arrow_upward + {{inner_temperature_diff | number : '1.1-2'}}° vs. gestern +
+ } @else { +
+ arrow_downward + {{inner_temperature_diff | number : '1.1-2'}}° vs. gestern +
+ } + } +
+ + +
+ water_drop + Luftfeucht. +
+ +
{{humidity | number: '1.1-2'}} %
+ + @if (humidity_diff) { + @if (humidity_diff > 0) { +
+ arrow_upward + {{humidity_diff | number : '1.1-2'}}% vs. gestern +
+ } @else { +
+ arrow_downward + {{humidity_diff | number : '1.1-2'}}% vs. gestern +
+ } + } +
+ + +
+ speed + Luftdruck +
+
{{pressure}} hPa
+ @if(pressure) { +
+ {{pressureTrendIcon}} +
{{weatherForecast}}
+
+ } +
+
diff --git a/src/app/components/wettter-overview/wettter-overview.spec.ts b/src/app/components/wettter-overview/wettter-overview.spec.ts new file mode 100644 index 0000000..bfb18af --- /dev/null +++ b/src/app/components/wettter-overview/wettter-overview.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WettterOverview } from './wettter-overview'; + +describe('WettterOverview', () => { + let component: WettterOverview; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WettterOverview], + }).compileComponents(); + + fixture = TestBed.createComponent(WettterOverview); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/wettter-overview/wettter-overview.ts b/src/app/components/wettter-overview/wettter-overview.ts new file mode 100644 index 0000000..4ab478b --- /dev/null +++ b/src/app/components/wettter-overview/wettter-overview.ts @@ -0,0 +1,147 @@ +import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; +import {DefaultService, MeasurementListResponse, MeasurementResolution, StationMeasurementResponse} from '../../api'; +import {MatCard} from '@angular/material/card'; +import {MatIcon} from '@angular/material/icon'; +import {addDays, endOfDay, startOfDay} from 'date-fns'; +import {DecimalPipe} from '@angular/common'; + +@Component({ + selector: 'app-wettter-overview', + imports: [ + MatCard, + MatIcon, + DecimalPipe + ], + templateUrl: './wettter-overview.html', + styleUrl: './wettter-overview.css', +}) +export class WettterOverview implements OnInit { + + last_measurement: MeasurementListResponse | undefined + out_temperature: number | undefined + inner_temperature: number | undefined + humidity: number | undefined + pressure: number | undefined + + outer_temperature_diff: number | undefined + inner_temperature_diff: number | undefined + humidity_diff: number | undefined + pressureDiff: number = 0 + + change_class(yesterday_val: number | undefined, today_val: number | undefined) { + if (yesterday_val && today_val) { + return yesterday_val <= today_val ? 'negative' : 'positive' + } + return 'neutral' + } + + constructor(private apiService: DefaultService, private cdr: ChangeDetectorRef) {} + + ngOnInit() { + const today = startOfDay(new Date()); + const yesterdayStart = addDays(today, -1); + const yesterdayEnd = endOfDay(yesterdayStart); + + this.apiService.getMeasurementsMeasurementsGet(undefined, undefined, undefined, undefined, MeasurementResolution.Raw, 1).subscribe(measurements => { + const outdoor_stations = measurements.stations.filter(s => !s.indoor) + this.out_temperature = this.computeAvgTemperature(outdoor_stations, false) + this.inner_temperature = this.computeAvgTemperature(measurements.stations, true) + + this.humidity = this.computeAvgHumidity(measurements.stations, true) + this.pressure = this.computeAvgPressure(measurements.stations, false) + + this.apiService.getMeasurementsMeasurementsGet(undefined, undefined, yesterdayStart.toISOString(), yesterdayEnd.toISOString(), MeasurementResolution.Daily, 1).subscribe(measurements => { + const outdoor_stations = measurements.stations.filter(s => !s.indoor) + + const yesterday_outer_temperature = this.computeAvgTemperature(outdoor_stations, false) + this.outer_temperature_diff = yesterday_outer_temperature - this.out_temperature! + + const yesterday_inner_temperature = this.computeAvgTemperature(measurements.stations, true) + this.inner_temperature_diff = yesterday_inner_temperature - this.inner_temperature! + + const yesterday_humidity = this.computeAvgHumidity(measurements.stations, true) + this.humidity_diff = yesterday_humidity - this.humidity! + + this.cdr.detectChanges(); + }) + }) + + this.apiService.listStationsStationsListGet().subscribe(stations => { + const outer_stations = stations.find(s => !s.indoor_only) + if (outer_stations) { + const now = new Date() + const threeHoursAgo = addDays(now, -3) + + this.apiService.getMeasurementsMeasurementsGet([outer_stations.id], false, threeHoursAgo.toISOString(), now.toISOString(), MeasurementResolution.Hourly).subscribe(measurements => { + const station_measurements = measurements.stations[0].measurements + station_measurements.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + + const recent = station_measurements[station_measurements.length - 1].pressure; // neueste + const older = station_measurements[0].pressure; // älteste + + if (recent == null || older == null) { + this.pressureDiff = 0 + } else { + this.pressureDiff = recent - older + } + + }) + } + }) + } + + get pressureTrendIcon(): string { + if (this.pressureDiff > 1.5) return 'trending_up'; + if (this.pressureDiff < -1.5) return 'trending_down'; + return 'trending_flat'; + } + + get pressureTrend(): string { + if (this.pressureDiff > 1.5) return `+${this.pressureDiff.toFixed(1)} hPa · steigend`; + if (this.pressureDiff < -1.5) return `${this.pressureDiff.toFixed(1)} hPa · fallend`; + return 'stabil'; + } + + get pressureTrendClass(): string { + if (this.pressureDiff > 1.5) return 'positive'; + if (this.pressureDiff < -1.5) return 'negative'; + return 'neutral'; + } + + get weatherForecast(): string { + return this.getWeatherForecast(this.pressure!, this.pressureDiff); + } + + getWeatherForecast(current: number, diff: number): string { + const absDiff = Math.abs(diff); + const rising = diff > 0; + + if (absDiff <= 1.5) { + return current > 1015 ? '☀️ Beständig schön' : + current < 1000 ? '🌧️ Beständig trüb' : '🌤️ Gleichbleibendes Wetter'; + } + if (absDiff <= 3) { + return rising ? '🌤️ Wetterbesserung möglich' : '🌦️ Wetterverschlechterung möglich'; + } + if (absDiff <= 6) { + return rising ? '☀️ Wetterbesserung wahrscheinlich' : '🌧️ Regen wahrscheinlich'; + } + // > 6 hPa + return rising ? '☀️ Markante Wetterbesserung' : '⛈️ Sturm möglich'; + } + + computeAvgTemperature(stations: StationMeasurementResponse[], inner_measurements: boolean) { + const temperatures = stations.filter(s => s.indoor === inner_measurements).map(s => s.measurements[0].temperature); + return temperatures.reduce((sum, t) => sum + t, 0) / temperatures.length; + } + + computeAvgHumidity(stations: StationMeasurementResponse[], inner_measurements: boolean) { + const humidity = stations.filter(s => s.indoor === inner_measurements).map(s => s.measurements[0].humidity ? s.measurements[0].humidity : 0); + return humidity.reduce((sum, t) => sum + t, 0) / humidity.length; + } + + computeAvgPressure(stations: StationMeasurementResponse[], inner_measurements: boolean) { + const pressure = stations.filter(s=> s.indoor === inner_measurements).map(s => s.measurements[0].pressure ? s.measurements[0].pressure : 0) + return pressure.reduce((sum, t) => sum + t, 0) / pressure.length; + } +}