Skip to content

Commit

Permalink
Merge pull request #214 from e-picsa/feat/climate-dashboard
Browse files Browse the repository at this point in the history
Feat(dashboard); climate summaries
  • Loading branch information
chrismclarke authored Jan 16, 2024
2 parents d56cace + 60e4e7d commit 2dcbe04
Show file tree
Hide file tree
Showing 34 changed files with 755 additions and 64 deletions.
14 changes: 2 additions & 12 deletions apps/picsa-apps/dashboard/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,15 @@
<mat-sidenav #sidenav mode="side" opened [fixedInViewport]="true" fixedTopGap="64">
<mat-nav-list>
@for (link of navLinks; track link.href) {
<a
mat-list-item
[routerLink]="link.href"
routerLinkActive="mdc-list-item--activated active-link"
[routerLinkActiveOptions]="{ exact: true }"
>
<a mat-list-item [routerLink]="link.href" routerLinkActive="mdc-list-item--activated active-link">
{{ link.label }}
</a>
}
<mat-divider style="margin-top: auto"></mat-divider>
<div mat-subheader>Global Admin</div>
<mat-divider></mat-divider>
@for (link of globalLinks; track link.href) {
<a
mat-list-item
[routerLink]="link.href"
routerLinkActive="mdc-list-item--activated active-link"
[routerLinkActiveOptions]="{ exact: true }"
>
<a mat-list-item [routerLink]="link.href" routerLinkActive="mdc-list-item--activated active-link">
{{ link.label }}
</a>
}
Expand Down
8 changes: 4 additions & 4 deletions apps/picsa-apps/dashboard/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export class AppComponent implements AfterViewInit {
title = 'picsa-apps-dashboard';

navLinks: INavLink[] = [
{
label: 'Home',
href: '/',
},
// {
// label: 'Home',
// href: '',
// },
{
label: 'Resources',
href: '/resources',
Expand Down
5 changes: 5 additions & 0 deletions apps/picsa-apps/dashboard/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ export const appRoutes: Route[] = [
path: 'translations',
loadChildren: () => import('./modules/translations/translations.module').then((m) => m.TranslationsPageModule),
},
{
path: '',
redirectTo: 'resources',
pathMatch: 'full',
},
];
Original file line number Diff line number Diff line change
@@ -1,18 +1,99 @@
import { Injectable } from '@angular/core';
import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service';
import createClient from 'openapi-fetch';

import { paths } from './types/api';

const API_ENDPOINT = 'https://api.epicsa.idems.international';

/** Service to interact with external PICSA Climate API */
/** Custom client which tracks responses by callback id */
type ICallbackClient = (id:string)=>ReturnType<typeof createClient<paths>>

/** Type-safe http client with added support for callbacks */
type IClient = ReturnType<typeof createClient<paths>> & {useMeta:ICallbackClient}



interface IMetaEntry{
status:'pending' | 'success' | 'error' | 'unknown',
rawResponse?:Response,
}


/**
* Service to interact with external PICSA Climate API
* All methods are exposed through a type-safe `client` property, or can additionally use
* a custom client that includes status notification updates via the `useMeta` method
* @example
* Use custom callback that will show user notifications on error and record to service
* ```ts
* const {response, data, error} = await api.useMeta('myRequestId').POST(...)
* ```
* Use default client without additional callbacks
* ```ts
* const {response, data, error} = await api.client.POST(...)
* ```
* */
@Injectable({ providedIn: 'root' })
export class ClimateDataApiService {

/** Request additional meta by id */
public meta:Record<string ,IMetaEntry>={}

/** Http client with type-definitions for API endpoints */
public client:ReturnType<typeof createClient<paths>>

constructor() {
this.client = createClient<paths>({ baseUrl: API_ENDPOINT,mode:'cors' });
public client:IClient

constructor(private notificationService:PicsaNotificationService) {
const client = createClient<paths>({ baseUrl: API_ENDPOINT,mode:'cors' });
this.client = {...client,useMeta:()=>{
return client
}}
}


/**
* Provide an id which which will be updated alongside requests.
* The cache will also include interceptors to provide user notification on error
**/
public useMeta(id:string){
const customFetch = this.createCustomFetchClient(id)
const customClient = createClient<paths>({ baseUrl: API_ENDPOINT,mode:'cors',fetch:customFetch });
return customClient
}

/** Create a custom implementation of fetch client to handle status updates and notifications */
private createCustomFetchClient(id:string){
return async (...args:Parameters<typeof window['fetch']>)=>{
this.meta[id]={status:'pending'}
const response = await window.fetch(...args);
this.meta[id].status = this.getCallbackStatus(response.status)
this.meta[id].rawResponse = response
if(this.meta[id].status ==='error' ){
await this.showCustomFetchErrorMessage(id,response)
}
return response
}
}

/** Show error message when using custom fetch with callbacks */
private async showCustomFetchErrorMessage(id:string,response:Response){
// clone body so that open-api can still consume when constructing full fetch response
const clone = response.clone()
try {
const json = await clone.json()
const errorText = json.detail || 'failed, see console logs for details'
this.notificationService.showUserNotification({matIcon:'error',message:`[${id}] ${errorText}`})
} catch (error) {
console.error(error)
console.error('Fetch Error',error)
this.notificationService.showUserNotification({matIcon:'error',message:`[${id}] 'failed, see console logs for details'`})
}
}

private getCallbackStatus(statusCode:number):IMetaEntry['status']{
if(200 <= statusCode && statusCode <=299) return 'success'
if(400 <= statusCode && statusCode <=499) return 'error'
if(500 <= statusCode && statusCode <=599) return 'error'
return 'unknown'
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { Database } from '@picsa/server-types';
import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service';
import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service';
import { SupabaseService } from '@picsa/shared/services/core/supabase';
import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service';
import { ngRouterMergedSnapshot$ } from '@picsa/utils';

import { ClimateDataApiService } from './climate-data-api.service';

Expand All @@ -20,21 +23,46 @@ export type IResourceEntry = Database['public']['Tables']['resources']['Row'];
export class ClimateDataDashboardService extends PicsaAsyncService {
public apiStatus: number;
public stations: IStationRow[] = [];
public activeStation: IStationRow;

constructor(private supabaseService: SupabaseService, private api: ClimateDataApiService) {
constructor(
private supabaseService: SupabaseService,
private api: ClimateDataApiService,
private notificationService: PicsaNotificationService,
private router: Router
) {
super();
this.ready();
}

public override async init() {
await this.supabaseService.ready();
await this.checkStatus();
await this.listStations();
this.subscribeToRouteChanges();
}

private setActiveStation(id: number) {
const station = this.stations.find((station) => station.station_id === id);
if (station) {
this.activeStation = station;
} else {
this.activeStation = undefined as any;
this.notificationService.showUserNotification({ matIcon: 'error', message: `Station data not found` });
}
}

private subscribeToRouteChanges() {
// Use merged router as service cannot access route params directly like component
ngRouterMergedSnapshot$(this.router).subscribe(({ params }) => {
if (params.stationId) {
this.setActiveStation(parseInt(params.stationId));
}
});
}

private async checkStatus() {
const { client } = this.api;
const { response } = await client.GET('/v1/status/');
this.apiStatus = response.status;
await this.api.useMeta('serverStatus').GET('/v1/status/');
}

private async listStations() {
Expand All @@ -44,6 +72,12 @@ export class ClimateDataDashboardService extends PicsaAsyncService {
if (error) {
throw error;
}
if (data.length === 0) {
this.notificationService.showUserNotification({
matIcon: 'warning',
message: 'climate_stations_rows must be imported into database for this feature to work',
});
}
this.stations = data || [];
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<div class="page-content">
<div style="display: flex; align-items: center">
<h2 style="flex: 1">Climate Data</h2>
@if(service.apiStatus; as status){
@if(api.meta.serverStatus; as meta){
<div class="server-status">
Server Status <span class="status-code" [attr.data-status]="status">{{ status }}</span>
Server Status
<span class="status-code" [attr.data-status]="meta.rawResponse?.status">{{ meta.rawResponse?.status }}</span>
</div>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router';
import { IMapMarker, PicsaMapComponent } from '@picsa/shared/features/map/map';

import { ClimateDataDashboardService, IStationRow } from '../../climate-data.service';
import { ClimateDataApiService } from '../../climate-data-api.service';

@Component({
selector: 'dashboard-climate-data-home',
Expand All @@ -18,7 +19,7 @@ export class ClimateDataHomeComponent implements OnInit {

public mapMarkers: IMapMarker[];

constructor(public service: ClimateDataDashboardService) {}
constructor(public service: ClimateDataDashboardService, public api: ClimateDataApiService) {}

async ngOnInit() {
await this.service.ready();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div style="display: flex; align-items: center">
<h3 style="flex: 1">Rainfall Summary</h3>
<button mat-stroked-button (click)="refreshData()" [disabled]="res.status==='pending'">
<mat-icon [class.spin]="res.status==='pending'">autorenew</mat-icon>
Refresh Data
</button>
</div>
<mat-tab-group preserveContent>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>view_list</mat-icon>
Table
</ng-template>
<picsa-data-table [data]="summary.data" [options]="tableOptions"></picsa-data-table>
</mat-tab>
<!-- <mat-tab>
<ng-template mat-tab-label>
<mat-icon>show_chart</mat-icon>
Chart
</ng-template>
</mat-tab> -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>description</mat-icon>
Definition
</ng-template>
<pre>{{summary.metadata | json}}</pre>
</mat-tab>
</mat-tab-group>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
:host {
display: block;
}

mat-icon.spin {
animation: spin 2s linear infinite;
}

@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
Loading

0 comments on commit 2dcbe04

Please sign in to comment.