diff --git a/apps/picsa-apps/dashboard/src/app/app.component.html b/apps/picsa-apps/dashboard/src/app/app.component.html index 64c995e17..c9bca6e1d 100644 --- a/apps/picsa-apps/dashboard/src/app/app.component.html +++ b/apps/picsa-apps/dashboard/src/app/app.component.html @@ -26,11 +26,36 @@ - @for (link of navLinks; track link.href) { + @for (link of navLinks; track link.href) { @if(link.children){ + + + + + {{ link.label }} + + + @for(child of link.children || []; track $index){ + + {{ child.label }} + } + + } @else { + {{ link.label }} - } + } }
Global Admin
diff --git a/apps/picsa-apps/dashboard/src/app/app.component.ts b/apps/picsa-apps/dashboard/src/app/app.component.ts index 8e795648e..76af98c55 100644 --- a/apps/picsa-apps/dashboard/src/app/app.component.ts +++ b/apps/picsa-apps/dashboard/src/app/app.component.ts @@ -8,7 +8,7 @@ import { DashboardMaterialModule } from './material.module'; interface INavLink { label: string; href: string; - isActive?: boolean; + children?: INavLink[]; } @Component({ @@ -22,17 +22,23 @@ export class AppComponent implements AfterViewInit { title = 'picsa-apps-dashboard'; navLinks: INavLink[] = [ - // { - // label: 'Home', - // href: '', - // }, { label: 'Resources', href: '/resources', }, { - label: 'Climate Data', - href: '/climate-data', + label: 'Climate', + href: '/climate', + children: [ + { + label: 'Station Data', + href: '/station', + }, + { + label: 'Forecasts', + href: '/forecast', + }, + ], }, // { // label: 'Crop Information', diff --git a/apps/picsa-apps/dashboard/src/app/app.routes.ts b/apps/picsa-apps/dashboard/src/app/app.routes.ts index c49395830..c4f1845db 100644 --- a/apps/picsa-apps/dashboard/src/app/app.routes.ts +++ b/apps/picsa-apps/dashboard/src/app/app.routes.ts @@ -6,8 +6,8 @@ export const appRoutes: Route[] = [ loadChildren: () => import('./modules/resources/resources.module').then((m) => m.ResourcesPageModule), }, { - path: 'climate-data', - loadChildren: () => import('./modules/climate-data/climate-data.module').then((m) => m.ClimateDataModule), + path: 'climate', + loadChildren: () => import('./modules/climate/climate.module').then((m) => m.ClimateModule), }, { path: 'translations', diff --git a/apps/picsa-apps/dashboard/src/app/material.module.ts b/apps/picsa-apps/dashboard/src/app/material.module.ts index 8a3c8b562..80c145a1f 100644 --- a/apps/picsa-apps/dashboard/src/app/material.module.ts +++ b/apps/picsa-apps/dashboard/src/app/material.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; +import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -15,6 +16,7 @@ import { MatToolbarModule } from '@angular/material/toolbar'; const matModules = [ MatButtonModule, MatChipsModule, + MatExpansionModule, MatFormFieldModule, MatIconModule, MatInputModule, @@ -24,7 +26,7 @@ const matModules = [ MatStepperModule, MatTableModule, MatTabsModule, - MatToolbarModule + MatToolbarModule, ]; @NgModule({ diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts deleted file mode 100644 index 414381324..000000000 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts +++ /dev/null @@ -1,99 +0,0 @@ -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'; - -/** Custom client which tracks responses by callback id */ -type ICallbackClient = (id:string)=>ReturnType> - -/** Type-safe http client with added support for callbacks */ -type IClient = ReturnType> & {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={} - - /** Http client with type-definitions for API endpoints */ - public client:IClient - - constructor(private notificationService:PicsaNotificationService) { - const client = createClient({ 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({ 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)=>{ - 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' - } -} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.module.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.module.ts deleted file mode 100644 index 9b5eda51d..000000000 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; - -import { ClimateDataHomeComponent } from './pages/home/climate-data-home.component'; -import { StationPageComponent } from './pages/station/station-page.component'; - -@NgModule({ - declarations: [], - imports: [ - CommonModule, - RouterModule.forChild([ - { - path: '', - component: ClimateDataHomeComponent, - }, - { - path: ':stationId', - component: StationPageComponent, - }, - ]), - ], -}) -export class ClimateDataModule {} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.spec.ts deleted file mode 100644 index d643e4000..000000000 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ClimateDataHomeComponent } from './climate-data-home.component'; - -describe('ClimateDataHomeComponent', () => { - let component: ClimateDataHomeComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ClimateDataHomeComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ClimateDataHomeComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts deleted file mode 100644 index 094aa4b0c..000000000 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { MatTableModule } from '@angular/material/table'; -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', - standalone: true, - imports: [CommonModule, MatTableModule, RouterModule, PicsaMapComponent], - templateUrl: './climate-data-home.component.html', - styleUrls: ['./climate-data-home.component.scss'], -}) -export class ClimateDataHomeComponent implements OnInit { - public displayedColumns: (keyof IStationRow)[] = ['station_id', 'station_name']; - - public mapMarkers: IMapMarker[]; - - constructor(public service: ClimateDataDashboardService, public api: ClimateDataApiService) {} - - async ngOnInit() { - await this.service.ready(); - this.mapMarkers = this.service.stations.map((m) => ({ - latlng: [m.latitude as number, m.longitude as number], - number: m.station_id, - })); - } -} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.mapping.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.mapping.ts new file mode 100644 index 000000000..03161f453 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.mapping.ts @@ -0,0 +1,105 @@ +import type { SupabaseService } from '@picsa/shared/services/core/supabase'; +import { SupabaseStorageService } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; + +import type { ClimateApiService } from './climate-api.service'; +import { IClimateProductInsert, IClimateProductRow, IForecastInsert, IForecastRow, IStationRow } from './types'; + +export type IApiMapping = ReturnType; +export type IApiMappingName = keyof IApiMapping; + +// TODO - certain amount of boilerplate could be reduced +// TODO - depends on climate api updates +// TODO - most of these should be run on server as server functions + +/** + * Mapping functions that handle processing of data loaded from API server endpoints, + * and populating entries to supabase DB + */ +export const ApiMapping = (api: ClimateApiService, db: SupabaseService['db'], storage: SupabaseStorageService) => { + return { + rainfallSummaries: async (country_code: string, station_id: number) => { + // TODO - add model type definitions for server rainfall summary response body + const { data, error } = await api + .getObservableClient(`rainfallSummary_${country_code}_${station_id}`) + .POST('/v1/annual_rainfall_summaries/', { + body: { + country: `${country_code}` as any, + station_id: `${station_id}`, + summaries: ['annual_rain', 'start_rains', 'end_rains', 'end_season', 'seasonal_rain', 'seasonal_length'], + }, + }); + if (error) throw error; + console.log('summary data', data); + // TODO - gen types and handle mapping + const mappedData = data as any; + const { data: dbData, error: dbError } = await db + .table('climate_products') + .upsert({ + data: mappedData, + station_id, + type: 'rainfallSummary', + }) + .select<'*', IClimateProductRow>('*'); + if (dbError) throw dbError; + return dbData || []; + }, + // + station: async () => { + const { data, error } = await api.getObservableClient('station').GET('/v1/station/'); + if (error) throw error; + // TODO - fix climate api bindigns to avoid data.data + console.log('station data', data); + const dbData = data.map( + (d): IStationRow => ({ + ...d, + }) + ); + const { error: dbError } = await db.table('climate_stations').upsert(dbData); + if (dbError) throw dbError; + return dbData; + }, + // + forecasts: async (country_code: 'zm' | 'mw') => { + const { data, error } = await api + .getObservableClient(`forecasts/${country_code}`) + .GET(`/v1/forecasts/{country_code}`, { params: { path: { country_code } } }); + if (error) throw error; + const forecasts = data.map((d): IForecastInsert => ({ ...d, country_code })); + const { error: dbError, data: dbData } = await db + .table('climate_forecasts') + .upsert(forecasts) + .select<'*', IForecastRow>('*'); + if (dbError) throw dbError; + return dbData || []; + }, + forecast_file: async (row: IForecastRow) => { + const { country_code, filename } = row; + const { data, error } = await api + .getObservableClient(`forecasts/${filename}`) + .GET(`/v1/forecasts/{country_code}/{file_name}`, { + params: { path: { country_code: country_code as any, file_name: filename } }, + parseAs: 'blob', + }); + if (error) throw error; + // setup metadata + const fileBlob = data as Blob; + const bucketId = country_code as string; + const folderPath = 'climate/forecasts'; + // upload to storage + await storage.putFile({ bucketId, fileBlob, filename, folderPath }); + // TODO - handle error if filename already exists + const storageEntry = await storage.getFile({ bucketId, filename, folderPath }); + if (storageEntry) { + const { error: dbError } = await db + .table('climate_forecasts') + .upsert({ ...row, storage_file: storageEntry.id }) + .select('*'); + if (dbError) { + throw dbError; + } + return; + } + throw new Error('Storage file not found'); + }, + }; +}; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.service.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.service.ts new file mode 100644 index 000000000..24cc3a130 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/climate-api.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import createClient from 'openapi-fetch'; +import { BehaviorSubject, Subject } from 'rxjs'; + +import { paths } from './types/api'; + +const API_ENDPOINT = 'https://api.epicsa.idems.international'; + +/** Type-safe http client with added support for callbacks */ +export type IApiClient = ReturnType> & { $:Subject} + +/** + * 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 observable client that can also be accessed by api-status component + * ```ts + * await api.getObservableClient('myRequestId').POST(...) + * ``` + * ```html + * + * ``` + * + * Use default client without additional callbacks + * ```ts + * const {response, data, error} = await api.client.POST(...) + * ``` + * */ +@Injectable({ providedIn: 'root' }) +export class ClimateApiService { + + /** Http client with type-definitions for API endpoints */ + public client:IApiClient + + /** List of custom clients generated to be shared across observers */ + private observableClients:Record = {} + + constructor() { + this.client = this.createObservableClient('_default') + } + + /** + * Retrive an instance of the api client with a specific ID so that request updates can be subscribed to + * by any other services or components accessing via the same id. Creates new client if not existing + */ + public getObservableClient(clientId?:string):IApiClient{ + if(clientId){ + return this.observableClients[clientId] || this.createObservableClient(clientId) + } + return this.client + } + + private createObservableClient(clientId:string){ + const $ = new BehaviorSubject(undefined) + const customFetch = async (...args:Parameters)=>{ + // send a custom response with 102 status code to inform that request has been sent but is pending + $.next({status:102} as Response) + try { + const response = await window.fetch(...args); + $.next(response) + return response + } catch (error:any) { + // Likely internal server error thrown + console.error(args) + console.error(error) + const message = error.message + const blob = new Blob([JSON.stringify({message}, null, 2)], {type : 'application/json'}); + const errorRes = new Response(blob,{status:500,statusText:message}) + $.next(errorRes) + return errorRes + } + + } + const baseClient = createClient({ baseUrl: API_ENDPOINT,mode:'cors',fetch:customFetch }); + const client:IApiClient ={...baseClient, $} + this.observableClients[clientId] = client + return client + } + + + + + +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/climate.module.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/climate.module.ts new file mode 100644 index 000000000..8430cb47f --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/climate.module.ts @@ -0,0 +1,34 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { ClimateForecastPageComponent } from './pages/forecast/forecast.component'; +import { ClimateStationPageComponent } from './pages/station/station.component'; +import { StationDetailsPageComponent } from './pages/station-details/station-details.component'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + RouterModule.forChild([ + { + path: '', + redirectTo: 'station', + pathMatch: 'full', + }, + { + path: 'station', + component: ClimateStationPageComponent, + }, + { + path: 'forecast', + component: ClimateForecastPageComponent, + }, + { + path: 'station/:stationId', + component: StationDetailsPageComponent, + }, + ]), + ], +}) +export class ClimateModule {} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/climate.service.ts similarity index 68% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/climate.service.ts index e71e8c69e..d573e4536 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/climate.service.ts @@ -1,33 +1,32 @@ 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/angular'; -import { ClimateDataApiService } from './climate-data-api.service'; - -export type IStationRow = Database['public']['Tables']['climate_stations']['Row']; +import { ApiMapping } from './climate-api.mapping'; +import { ClimateApiService } from './climate-api.service'; +import { IStationRow } from './types'; export interface IResourceStorageEntry extends IStorageEntry { /** Url generated when upload to public bucket (will always be populated, even if bucket not public) */ publicUrl: string; } -export type IResourceEntry = Database['public']['Tables']['resources']['Row']; - @Injectable({ providedIn: 'root' }) -export class ClimateDataDashboardService extends PicsaAsyncService { +export class ClimateService extends PicsaAsyncService { public apiStatus: number; public stations: IStationRow[] = []; public activeStation: IStationRow; + /** Trigger API request that includes mapping response to local database */ + public loadFromAPI = ApiMapping(this.api, this.supabaseService.db, this.supabaseService.storage); + constructor( private supabaseService: SupabaseService, - private api: ClimateDataApiService, + private api: ClimateApiService, private notificationService: PicsaNotificationService, private router: Router ) { @@ -37,8 +36,9 @@ export class ClimateDataDashboardService extends PicsaAsyncService { public override async init() { await this.supabaseService.ready(); - await this.checkStatus(); await this.listStations(); + // Initialise other services without await to allow parallel requests + // this.checkApiStatus(); this.subscribeToRouteChanges(); } @@ -54,29 +54,23 @@ export class ClimateDataDashboardService extends PicsaAsyncService { private subscribeToRouteChanges() { // Use merged router as service cannot access route params directly like component - ngRouterMergedSnapshot$(this.router).subscribe(({ params }) => { + ngRouterMergedSnapshot$(this.router).subscribe(async ({ params }) => { if (params.stationId) { + await this.ready(); this.setActiveStation(parseInt(params.stationId)); } }); } - private async checkStatus() { - await this.api.useMeta('serverStatus').GET('/v1/status/'); - } - - private async listStations() { - // HACK - endpoint not operational + private async listStations(allowRefresh = true) { // TODO - when running should refresh from server as cron task const { data, error } = await this.supabaseService.db.table('climate_stations').select<'*', IStationRow>('*'); 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', - }); + if (data.length === 0 && allowRefresh) { + await this.loadFromAPI.station(); + return this.listStations(false); } this.stations = data || []; } diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.html b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.html new file mode 100644 index 000000000..468845a64 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.html @@ -0,0 +1,12 @@ + +
+ @if(options.showStatusCode){ + {{code}} + } @if(options.labels?.ready; as readyLabel){ +
{{readyLabel}}
+ } @if(options.events?.refresh; as refreshEvent){ + + } +
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.scss b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.scss new file mode 100644 index 000000000..b1e773d0e --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.scss @@ -0,0 +1,47 @@ +$height: 30px; + +:host { + display: flex; +} + +.status-container { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-light); + border-radius: 4px; +} + +.status-label, +button.status-refresh-button, +.status-code { + height: 30px; + padding: 8px; + display: flex; + justify-content: center; + align-items: center; +} + +.status-code { + height: calc($height + 2px); + margin-top: -2px 0; + width: 24px; + color: white; + background: var(--color-light); + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + &[data-status='success'] { + background: hsl(120, 25%, 50%); + } + &[data-status='error'] { + background: hsl(0, 60%, 65%); + } +} + +button.status-refresh-button { + --mdc-icon-button-state-layer-size: calc($height + 16px) !important; +} + +// mat-progress-spinner.mat-white { +// --mdc-circular-progress-active-indicator-color: white; +// } diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.spec.ts new file mode 100644 index 000000000..31e012139 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DashboardClimateApiStatusComponent } from './api-status'; + +describe('DashboardClimateApiStatusComponent', () => { + let component: DashboardClimateApiStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DashboardClimateApiStatusComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardClimateApiStatusComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.ts new file mode 100644 index 000000000..2ed4fd973 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/components/api-status/api-status.ts @@ -0,0 +1,126 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; +import { Subject, Subscription, takeUntil } from 'rxjs'; + +import { ClimateService } from '../../climate.service'; +import { ClimateApiService } from '../../climate-api.service'; + +export type IStatus = 'pending' | 'success' | 'error' | 'ready'; + +export interface IApiStatusOptions { + labels?: { + ready?: string; + error?: string; + }; + events?: { + refresh?: () => void; + }; + showStatusCode?: boolean; +} +const DEFAULT_OPTIONS: IApiStatusOptions = { + showStatusCode: true, +}; + +/** + * Component used to display status of ongoing API requests + * ``` + * + * ``` + */ +@Component({ + selector: 'dashboard-climate-api-status', + standalone: true, + imports: [CommonModule, MatButtonModule, MatIconModule], + templateUrl: './api-status.html', + styleUrls: ['./api-status.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardClimateApiStatusComponent implements OnInit, OnDestroy { + public status: IStatus = 'pending'; + public code?: number; + + private componentDestroyed$ = new Subject(); + private subscription: Subscription; + + constructor( + public api: ClimateApiService, + public service: ClimateService, + private notificationService: PicsaNotificationService, + private cdr: ChangeDetectorRef + ) {} + + @Input() options: Partial = {}; + + /** Unique id of API request to monitor for status updates */ + @Input() set clientId(id: string) { + // clear any previous subscription + if (this.subscription) this.subscription.unsubscribe(); + // subscribe to any requests sent via client and update UI accordingly + const client = this.api.getObservableClient(id); + this.subscription = client.$.pipe(takeUntil(this.componentDestroyed$)).subscribe(async (response) => { + this.status = this.getCallbackStatus(response?.status); + // only assign success and error codes + if (this.status === 'error' || this.status === 'success') { + this.code = response?.status; + } + this.cdr.markForCheck(); + if (response && this.status === 'error') { + this.showCustomFetchErrorMessage(id, response); + } + // console log response body (debug purposes) + if (response && this.status === 'success') { + const body = await this.parseResponseBody(response); + console.log(`[API] ${id}`, body); + } + }); + } + ngOnInit() { + this.options = { ...DEFAULT_OPTIONS, ...this.options }; + } + ngOnDestroy(): void { + this.componentDestroyed$.next(true); + this.componentDestroyed$.complete(); + } + + private getCallbackStatus(statusCode?: number): IStatus { + if (!statusCode) return 'ready'; + if (100 <= statusCode && statusCode <= 199) return 'pending'; + if (200 <= statusCode && statusCode <= 299) return 'success'; + if (400 <= statusCode && statusCode <= 599) return 'error'; + return 'ready'; + } + + /** Show error message when using custom fetch with callbacks */ + private async showCustomFetchErrorMessage(id: string, response: Response) { + const body = await this.parseResponseBody(response); + const errorText = body.detail || 'failed, see console logs for details'; + console.error(response); + this.notificationService.showUserNotification({ matIcon: 'error', message: `[${id}] ${errorText}` }); + } + + /** + * Parse response body and format as JSON, nesting blob and text content types as custom properties + * in cases where response is not json type + */ + private async parseResponseBody(response: Response): Promise> { + // if (!response.bodyUsed) return {}; + const clone = response.clone(); + const contentType = response.headers.get('content-type'); + switch (contentType) { + case 'application/json': + return clone.json(); + case 'application/pdf': { + const blob = await clone.blob(); + return { blob }; + } + default: { + console.warn('No parser for response content', contentType); + const text = await clone.text(); + return { text }; + } + } + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.html b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.html new file mode 100644 index 000000000..6f72e54f9 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.html @@ -0,0 +1,24 @@ +
+
+

Forecasts

+ +
+ + {{ value | date }} + + + + +
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.scss b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.spec.ts similarity index 51% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.spec.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.spec.ts index dcdb50ac9..603a95581 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.spec.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.spec.ts @@ -1,16 +1,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StationPageComponent } from './station-page.component'; +import { ForecastComponent } from './forecast.component'; -describe('StationPageComponent', () => { - let component: StationPageComponent; - let fixture: ComponentFixture; +describe('ForecastComponent', () => { + let component: ForecastComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [StationPageComponent], + imports: [ForecastComponent], }).compileComponents(); - fixture = TestBed.createComponent(StationPageComponent); + fixture = TestBed.createComponent(ForecastComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.ts new file mode 100644 index 000000000..a2b005ae2 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/forecast/forecast.component.ts @@ -0,0 +1,97 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IDataTableOptions, PicsaDataTableComponent } from '@picsa/shared/features'; +import { SupabaseService } from '@picsa/shared/services/core/supabase'; + +import { DashboardMaterialModule } from '../../../../material.module'; +import { ClimateService } from '../../climate.service'; +import { DashboardClimateApiStatusComponent, IApiStatusOptions } from '../../components/api-status/api-status'; +import { IForecastRow } from '../../types'; + +const DISPLAY_COLUMNS: (keyof IForecastRow)[] = [ + 'country_code', + 'district', + 'type', + 'language_code', + 'filename', + 'date_modified', + 'storage_file', +]; + +@Component({ + selector: 'dashboard-climate-forecast', + standalone: true, + imports: [ + CommonModule, + DashboardClimateApiStatusComponent, + RouterModule, + PicsaDataTableComponent, + DashboardMaterialModule, + ], + templateUrl: './forecast.component.html', + styleUrls: ['./forecast.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClimateForecastPageComponent implements OnInit { + public forecastData: IForecastRow[] = []; + + public tableOptions: IDataTableOptions = { + displayColumns: DISPLAY_COLUMNS, + }; + public apiStatusOptions: IApiStatusOptions = { + events: { refresh: () => this.refreshData() }, + showStatusCode: false, + }; + + public activeDownloads: Record = {}; + + constructor(private service: ClimateService, private supabase: SupabaseService, private cdr: ChangeDetectorRef) {} + + private get db() { + return this.supabase.db.table('climate_forecasts'); + } + + async ngOnInit() { + await this.service.ready(); + // TODO - read from deployment + const country_code = 'mw'; + // Load data stored in supabase db if available. Otherwise load from api + const { data, error } = await this.db.select<'*', IForecastRow>('*').eq('country_code', country_code); + if (error) throw error; + if (data?.length > 0) { + this.loadForecastData(data); + } else { + await this.refreshData(); + } + } + + public async handleStorageClick(row: IForecastRow) { + // handle download if storage file doesn't exist or hasn't been downloaded + if (!row.storage_file && this.activeDownloads[row.filename] !== 'complete') { + await this.downloadStorageFile(row); + } + // handle open + const storagePath = `climate/forecasts/${row.filename}`; + const publicLink = this.supabase.storage.getPublicLink('mw', storagePath); + open(publicLink, '_blank'); + } + + private async downloadStorageFile(row: IForecastRow) { + this.activeDownloads[row.filename] = 'pending'; + this.cdr.markForCheck(); + await this.service.loadFromAPI.forecast_file(row); + this.activeDownloads[row.filename] = 'complete'; + this.cdr.markForCheck(); + } + + private loadForecastData(data: any[] = []) { + this.forecastData = data; + this.cdr.detectChanges(); + } + + public async refreshData() { + const data = await this.service.loadFromAPI.forecasts('mw'); + this.loadForecastData(data); + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.html similarity index 77% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.html index 613ca95f3..0465b6405 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.html @@ -1,9 +1,6 @@

Rainfall Summary

- +
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.scss b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.spec.ts similarity index 100% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.spec.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.spec.ts diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.ts similarity index 53% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.ts index 9fe7655b5..a4459ad40 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/components/rainfall-summary/rainfall-summary.ts @@ -6,10 +6,12 @@ import { MatTabsModule } from '@angular/material/tabs'; import { IDataTableOptions, PicsaDataTableComponent } from '@picsa/shared/features/data-table'; import { SupabaseService } from '@picsa/shared/services/core/supabase'; -import { ClimateDataDashboardService } from '../../../../climate-data.service'; -import { ClimateDataApiService } from '../../../../climate-data-api.service'; +import { ClimateService } from '../../../../climate.service'; +import { DashboardClimateApiStatusComponent } from '../../../../components/api-status/api-status'; +import { IClimateProductRow } from '../../../../types'; interface IRainfallSummary { + // TODO - improve typings data: any[]; metadata: any; } @@ -18,27 +20,26 @@ interface IRainfallSummary { selector: 'dashboard-climate-rainfall-summary', templateUrl: './rainfall-summary.html', standalone: true, - imports: [MatButtonModule, MatIconModule, MatTabsModule, PicsaDataTableComponent, JsonPipe], + imports: [ + DashboardClimateApiStatusComponent, + MatButtonModule, + MatIconModule, + MatTabsModule, + PicsaDataTableComponent, + JsonPipe, + ], styleUrl: './rainfall-summary.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RainfallSummaryComponent implements AfterViewInit { public summary: IRainfallSummary = { data: [], metadata: {} }; - constructor( - public api: ClimateDataApiService, - private service: ClimateDataDashboardService, - private cdr: ChangeDetectorRef, - private supabase: SupabaseService - ) {} + public apiClientId: string; + constructor(private service: ClimateService, private cdr: ChangeDetectorRef, private supabase: SupabaseService) {} public tableOptions: IDataTableOptions = { paginatorSizes: [25, 50], }; - public get res() { - return this.api.meta.rainfallSummary || {}; - } - private get db() { return this.supabase.db.table('climate_products'); } @@ -46,9 +47,14 @@ export class RainfallSummaryComponent implements AfterViewInit { async ngAfterViewInit() { const { station_id } = this.service.activeStation; // Load data stored in supabase db if available. Otherwise load from api - const { data } = await this.db.select('*').eq('station_id', station_id).eq('type', 'rainfallSummary').single(); + // TODO - nicer if could include db lookups as part of mapping doc + const { data } = await this.db + .select<'*', IClimateProductRow>('*') + .eq('station_id', station_id) + .eq('type', 'rainfallSummary') + .single(); if (data) { - this.loadData(data?.data || { data: [], metadata: {} }); + this.loadData((data?.data as any) || { data: [], metadata: {} }); } else { await this.refreshData(); } @@ -56,22 +62,16 @@ export class RainfallSummaryComponent implements AfterViewInit { public async refreshData() { const { station_id, country_code } = this.service.activeStation; - const { response, data, error } = await this.api.useMeta('rainfallSummary').POST('/v1/annual_rainfall_summaries/', { - body: { - country: `${country_code}` as any, - station_id: `${station_id}`, - summaries: ['annual_rain', 'start_rains', 'end_rains', 'end_season', 'seasonal_rain', 'seasonal_length'], - }, - }); - console.log('rainfallSummary', { response, data, error }); - this.loadData(data as any); - // TODO - generalise way to persist db updates from api queries - const dbRes = await this.supabase.db.table('climate_products').upsert({ - data, - station_id, - type: 'rainfallSummary', - }); - console.log('climate data persist', dbRes); + if (station_id && country_code) { + this.apiClientId = `rainfallSummary_${country_code}_${station_id}`; + this.cdr.markForCheck(); + const data = await this.service.loadFromAPI.rainfallSummaries(country_code, station_id); + const summary = data?.[0]; + if (summary) { + this.loadData(summary.data as any); + this.cdr.markForCheck(); + } + } } private loadData(summary: IRainfallSummary) { diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.html similarity index 100% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.html diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.scss b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.scss similarity index 100% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.scss rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.scss diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.spec.ts new file mode 100644 index 000000000..59311ffc2 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StationDetailsPageComponent } from './station-details.component'; + +describe('StationDetailsPageComponent', () => { + let component: StationDetailsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StationDetailsPageComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StationDetailsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.ts similarity index 66% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.ts index 85d6a6439..6fc50d3c5 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station-details/station-details.component.ts @@ -2,17 +2,17 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { ClimateDataDashboardService } from '../../climate-data.service'; +import { ClimateService } from '../../climate.service'; import { RainfallSummaryComponent } from './components/rainfall-summary/rainfall-summary'; @Component({ - selector: 'dashboard-station-page', + selector: 'dashboard-station-details', standalone: true, imports: [CommonModule, MatProgressBarModule, RainfallSummaryComponent], - templateUrl: './station-page.component.html', - styleUrls: ['./station-page.component.scss'], + templateUrl: './station-details.component.html', + styleUrls: ['./station-details.component.scss'], }) -export class StationPageComponent implements OnInit { +export class StationDetailsPageComponent implements OnInit { public get station() { return this.service.activeStation; } @@ -24,7 +24,7 @@ export class StationPageComponent implements OnInit { }; } - constructor(private service: ClimateDataDashboardService) {} + constructor(private service: ClimateService) {} async ngOnInit() { await this.service.ready(); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.html similarity index 83% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.html index 39cc37626..afb4f0eff 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.html @@ -1,12 +1,7 @@

Climate Data

- @if(api.meta.serverStatus; as meta){ -
- Server Status - {{ meta.rawResponse?.status }} -
- } +

Stations

diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.scss b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.scss similarity index 56% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.scss rename to apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.scss index c620acfc0..bfe89e0d1 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.scss +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.scss @@ -1,13 +1,3 @@ -.status-code { - color: white; - padding: 8px; - border-radius: 4px; - background: gray; - &[data-status='200'] { - background: green; - } -} - table.station-table { max-height: 50vh; display: block; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.spec.ts new file mode 100644 index 000000000..c4e61cbcc --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ClimateStationPageComponent } from './station.component'; + +describe('ClimateStationPageComponent', () => { + let component: ClimateStationPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ClimateStationPageComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ClimateStationPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.ts new file mode 100644 index 000000000..7d16483d9 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/pages/station/station.component.ts @@ -0,0 +1,45 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; +import { IMapMarker, PicsaMapComponent } from '@picsa/shared/features/map/map'; + +import { ClimateService } from '../../climate.service'; +import { DashboardClimateApiStatusComponent, IApiStatusOptions } from '../../components/api-status/api-status'; +import { IStationRow } from '../../types'; + +@Component({ + selector: 'dashboard-climate-station-page', + standalone: true, + imports: [ + CommonModule, + DashboardClimateApiStatusComponent, + MatTableModule, + RouterModule, + PicsaMapComponent, + MatProgressSpinnerModule, + ], + templateUrl: './station.component.html', + styleUrls: ['./station.component.scss'], +}) +export class ClimateStationPageComponent implements OnInit { + public displayedColumns: (keyof IStationRow)[] = ['station_id', 'station_name']; + + public mapMarkers: IMapMarker[]; + + public apiStatusOptions: IApiStatusOptions = { + events: { refresh: () => this.service.loadFromAPI.station() }, + showStatusCode: false, + }; + + constructor(public service: ClimateService) {} + + async ngOnInit() { + await this.service.ready(); + this.mapMarkers = this.service.stations.map((m) => ({ + latlng: [m.latitude as number, m.longitude as number], + number: m.station_id, + })); + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/types/README.md b/apps/picsa-apps/dashboard/src/app/modules/climate/types/README.md similarity index 93% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/types/README.md rename to apps/picsa-apps/dashboard/src/app/modules/climate/types/README.md index 4e86aec40..af81a2f9a 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/types/README.md +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/types/README.md @@ -5,5 +5,5 @@ The climate data api runs on a remote server and exports endpoint definitions us These definitions can be converted into typescript types on-demand using the command: ```sh -npx openapi-typescript "https://api.epicsa.idems.international/openapi.json" -o "apps\picsa-apps\dashboard\src\app\modules\climate-data\types\api.d.ts" +npx openapi-typescript "https://api.epicsa.idems.international/openapi.json" -o "apps\picsa-apps\dashboard\src\app\modules\climate\types\api.d.ts" ``` diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/types/api.d.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/types/api.d.ts similarity index 61% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/types/api.d.ts rename to apps/picsa-apps/dashboard/src/app/modules/climate/types/api.d.ts index 52a0bb695..e2f1001ab 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/types/api.d.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/types/api.d.ts @@ -8,7 +8,7 @@ export interface paths { "/v1/status/": { /** * Get Status - * @description Check server up and authorized to access data + * @description Check server up */ get: operations["get_status_v1_status__get"]; }; @@ -32,19 +32,42 @@ export interface paths { /** Get Season Start Probabilities */ post: operations["get_season_start_probabilities_v1_season_start_probabilities__post"]; }; + "/v1/extremes_summaries/": { + /** Get Extremes Summaries */ + post: operations["get_extremes_summaries_v1_extremes_summaries__post"]; + }; "/v1/station/": { + /** Read Stations */ + get: operations["read_stations_v1_station__get"]; + }; + "/v1/station/{country}": { + /** Read Stations */ + get: operations["read_stations_v1_station__country__get"]; + }; + "/v1/station/{country}/{station_id}": { + /** Read Stations */ + get: operations["read_stations_v1_station__country___station_id__get"]; + }; + "/v1/forecasts/": { /** - * Read Stations - * @description Retrieve stations. + * List Endpoints + * @description List available forecast endpoints */ - get: operations["read_stations_v1_station__get"]; + get: operations["list_endpoints_v1_forecasts__get"]; + }; + "/v1/forecasts/{country_code}": { + /** + * List Forecasts + * @description Get available forecasts for country + */ + get: operations["list_forecasts_v1_forecasts__country_code__get"]; }; - "/v1/station/{id}": { + "/v1/forecasts/{country_code}/{file_name}": { /** - * Read Station - * @description Get station by ID. + * Get Forecast + * @description Get forecast file */ - get: operations["read_station_v1_station__id__get"]; + get: operations["get_forecast_v1_forecasts__country_code___file_name__get"]; }; } @@ -122,6 +145,53 @@ export interface components { /** Start Before Season */ start_before_season?: boolean; }; + /** ExtremesSummariesParameters */ + ExtremesSummariesParameters: { + /** + * Country + * @default zm + * @enum {string} + */ + country?: "zm" | "mw"; + /** + * Station Id + * @default test_1 + */ + station_id?: string; + /** + * Summaries + * @default [ + * "extremes_rain", + * "extremes_tmin", + * "extremes_tmax" + * ] + */ + summaries?: ("extremes_rain" | "extremes_tmin" | "extremes_tmax")[]; + }; + /** Forecast */ + Forecast: { + /** + * Date Modified + * Format: date-time + */ + date_modified: string; + /** District */ + district?: string; + /** Filename */ + filename: string; + /** Id */ + id: string; + /** + * Language Code + * @enum {string} + */ + language_code?: "en" | "ny"; + /** + * Type + * @enum {string} + */ + type?: "downscale_forecast" | "annual_forecast"; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -165,6 +235,26 @@ export interface components { /** Start Dates */ start_dates?: number[]; }; + /** Station */ + Station: { + /** + * Country Code + * @enum {string} + */ + country_code: "zm" | "mw"; + /** District */ + district: string; + /** Elevation */ + elevation: number; + /** Latitude */ + latitude: number; + /** Longitude */ + longitude: number; + /** Station Id */ + station_id: number; + /** Station Name */ + station_name: string; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -190,7 +280,7 @@ export interface operations { /** * Get Status - * @description Check server up and authorized to access data + * @description Check server up */ get_status_v1_status__get: { responses: { @@ -312,11 +402,69 @@ export interface operations { }; }; }; - /** - * Read Stations - * @description Retrieve stations. - */ + /** Get Extremes Summaries */ + get_extremes_summaries_v1_extremes_summaries__post: { + requestBody: { + content: { + "application/json": components["schemas"]["ExtremesSummariesParameters"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Read Stations */ read_stations_v1_station__get: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["Station"][]; + }; + }; + }; + }; + /** Read Stations */ + read_stations_v1_station__country__get: { + parameters: { + path: { + country: "zm" | "mw"; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Read Stations */ + read_stations_v1_station__country___station_id__get: { + parameters: { + path: { + country: "zm" | "mw"; + station_id: string; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -324,16 +472,62 @@ export interface operations { "application/json": unknown; }; }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** + * List Endpoints + * @description List available forecast endpoints + */ + list_endpoints_v1_forecasts__get: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": string[]; + }; + }; + }; + }; + /** + * List Forecasts + * @description Get available forecasts for country + */ + list_forecasts_v1_forecasts__country_code__get: { + parameters: { + path: { + country_code: "zm" | "mw"; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["Forecast"][]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; /** - * Read Station - * @description Get station by ID. + * Get Forecast + * @description Get forecast file */ - read_station_v1_station__id__get: { + get_forecast_v1_forecasts__country_code___file_name__get: { parameters: { path: { - id: number; + country_code: "zm" | "mw"; + file_name: string; }; }; responses: { diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/types/db.d.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/types/db.d.ts new file mode 100644 index 000000000..9f2783bc0 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/types/db.d.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line @nx/enforce-module-boundaries +import type { Database } from '@picsa/server-types'; + +export type IClimateProductRow = Database['public']['Tables']['climate_products']['Row']; +export type IClimateProductInsert = Database['public']['Tables']['climate_products']['Insert']; + +export type IForecastRow = Database['public']['Tables']['climate_forecasts']['Row']; +export type IForecastInsert = Database['public']['Tables']['climate_forecasts']['Insert']; + +export type IResourceRow = Database['public']['Tables']['resources']['Row']; + +export type IStationRow = Database['public']['Tables']['climate_stations']['Row']; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate/types/index.ts b/apps/picsa-apps/dashboard/src/app/modules/climate/types/index.ts new file mode 100644 index 000000000..62dd5da1c --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate/types/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export * from './db'; diff --git a/apps/picsa-apps/dashboard/src/app/modules/resources/pages/create/resource-create.component.ts b/apps/picsa-apps/dashboard/src/app/modules/resources/pages/create/resource-create.component.ts index 2d38af24c..268f43a4c 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/resources/pages/create/resource-create.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/resources/pages/create/resource-create.component.ts @@ -3,8 +3,6 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { PICSAFormValidators } from '@picsa/forms'; -// eslint-disable-next-line @nx/enforce-module-boundaries -import type { Database } from '@picsa/server-types'; import { IUploadResult, SupabaseStoragePickerDirective, @@ -13,11 +11,10 @@ import { import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; import { DashboardMaterialModule } from '../../../../material.module'; +import { IResourceRow } from '../../../climate/types'; import { DashboardResourcesStorageLinkComponent } from '../../components/storage-link/storage-link.component'; import { ResourcesDashboardService } from '../../resources.service'; -type IResourceEntry = Database['public']['Tables']['resources']['Row']; - @Component({ selector: 'dashboard-resource-create', standalone: true, @@ -67,7 +64,7 @@ export class ResourceCreateComponent implements OnInit { await this.service.ready(); const { id } = this.route.snapshot.params; if (id) { - const { data } = await this.service.table.select<'*', IResourceEntry>('*').eq('id', id); + const { data } = await this.service.table.select<'*', IResourceRow>('*').eq('id', id); const resource = data?.[0]; if (resource) { this.populateResource(resource); @@ -86,7 +83,7 @@ export class ResourceCreateComponent implements OnInit { console.log({ data, error }); } - private populateResource(resource: IResourceEntry) { + private populateResource(resource: IResourceRow) { this.resourceType = resource.type as any; console.log('populate resource', resource); switch (resource.type) { diff --git a/apps/picsa-apps/dashboard/src/app/modules/resources/resources.service.ts b/apps/picsa-apps/dashboard/src/app/modules/resources/resources.service.ts index 0638c4133..438711585 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/resources/resources.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/resources/resources.service.ts @@ -7,18 +7,18 @@ import { SupabaseService } from '@picsa/shared/services/core/supabase'; import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; import { arrayToHashmap } from '@picsa/utils'; +import { IResourceRow } from '../climate/types'; + export interface IResourceStorageEntry extends IStorageEntry { /** Url generated when upload to public bucket (will always be populated, even if bucket not public) */ publicUrl: string; } -export type IResourceEntry = Database['public']['Tables']['resources']['Row']; - @Injectable({ providedIn: 'root' }) export class ResourcesDashboardService extends PicsaAsyncService { private storageFiles: IResourceStorageEntry[] = []; public storageFilesHashmap: Record = {}; - public readonly resources = signal([]); + public readonly resources = signal([]); public get table() { return this.supabaseService.db.table('resources'); @@ -104,7 +104,7 @@ export class ResourcesDashboardService extends PicsaAsyncService { } private async listResources() { - const { data, error } = await this.supabaseService.db.table('resources').select<'*', IResourceEntry>('*'); + const { data, error } = await this.supabaseService.db.table('resources').select<'*', IResourceRow>('*'); if (error) { throw error; } diff --git a/apps/picsa-apps/dashboard/src/styles.scss b/apps/picsa-apps/dashboard/src/styles.scss index 0029378bb..a06c718d9 100644 --- a/apps/picsa-apps/dashboard/src/styles.scss +++ b/apps/picsa-apps/dashboard/src/styles.scss @@ -12,13 +12,13 @@ Made available from project.json }, ``` */ - -@import 'themes'; -@import 'variables'; +@import 'animations'; @import 'fonts'; @import 'layout'; -@import 'typography'; @import 'overrides'; +@import 'themes'; +@import 'typography'; +@import 'variables'; mat-sidenav-content > * { display: contents; diff --git a/apps/picsa-server/supabase/migrations/20240209065921_climate_forecasts_create.sql b/apps/picsa-server/supabase/migrations/20240209065921_climate_forecasts_create.sql new file mode 100644 index 000000000..cfbd90036 --- /dev/null +++ b/apps/picsa-server/supabase/migrations/20240209065921_climate_forecasts_create.sql @@ -0,0 +1,13 @@ +create table + public.climate_forecasts ( + id character varying not null, + district text null, + filename text not null, + language_code text null, + type text null, + date_modified timestamp with time zone not null, + country_code character varying null, + storage_file uuid null, + constraint climate_forecasts_pkey primary key (id), + constraint climate_forecasts_storage_file_fkey foreign key (storage_file) references storage.objects (id) on update cascade on delete cascade + ) tablespace pg_default; \ No newline at end of file diff --git a/apps/picsa-server/supabase/migrations/20240215194323_country_storage_buckets.sql b/apps/picsa-server/supabase/migrations/20240215194323_country_storage_buckets.sql new file mode 100644 index 000000000..7c1ed0bef --- /dev/null +++ b/apps/picsa-server/supabase/migrations/20240215194323_country_storage_buckets.sql @@ -0,0 +1,59 @@ +-- GLOBAL +insert into + storage.buckets (id, name, public) +values + ('global', 'global', true); + +CREATE POLICY "Storage global public INSERT" ON storage.objects FOR +INSERT + TO public WITH CHECK (bucket_id = 'global'); + +CREATE POLICY "Storage global public SELECT" ON storage.objects FOR +SELECT + TO public USING (bucket_id = 'global'); + +CREATE POLICY "Storage global public UPDATE" ON storage.objects FOR +UPDATE + TO public USING (bucket_id = 'global'); + +CREATE POLICY "Storage global public DELETE" ON storage.objects FOR DELETE TO public USING (bucket_id = 'global'); + +-- MW +insert into + storage.buckets (id, name, public) +values + ('mw', 'mw', true); + +CREATE POLICY "Storage mw public INSERT" ON storage.objects FOR +INSERT + TO public WITH CHECK (bucket_id = 'mw'); + +CREATE POLICY "Storage mw public SELECT" ON storage.objects FOR +SELECT + TO public USING (bucket_id = 'mw'); + +CREATE POLICY "Storage mw public UPDATE" ON storage.objects FOR +UPDATE + TO public USING (bucket_id = 'mw'); + +CREATE POLICY "Storage mw public DELETE" ON storage.objects FOR DELETE TO public USING (bucket_id = 'mw'); + +-- ZM +insert into + storage.buckets (id, name, public) +values + ('zm', 'zm', true); + +CREATE POLICY "Storage zm public INSERT" ON storage.objects FOR +INSERT + TO public WITH CHECK (bucket_id = 'zm'); + +CREATE POLICY "Storage zm public SELECT" ON storage.objects FOR +SELECT + TO public USING (bucket_id = 'zm'); + +CREATE POLICY "Storage zm public UPDATE" ON storage.objects FOR +UPDATE + TO public USING (bucket_id = 'zm'); + +CREATE POLICY "Storage zm public DELETE" ON storage.objects FOR DELETE TO public USING (bucket_id = 'zm'); diff --git a/apps/picsa-server/supabase/types/index.ts b/apps/picsa-server/supabase/types/index.ts index c5486929a..604433bba 100644 --- a/apps/picsa-server/supabase/types/index.ts +++ b/apps/picsa-server/supabase/types/index.ts @@ -34,26 +34,69 @@ export interface Database { } public: { Tables: { + climate_forecasts: { + Row: { + country_code: string | null + date_modified: string + district: string | null + filename: string + id: string + language_code: string | null + storage_file: string | null + type: string | null + } + Insert: { + country_code?: string | null + date_modified: string + district?: string | null + filename: string + id: string + language_code?: string | null + storage_file?: string | null + type?: string | null + } + Update: { + country_code?: string | null + date_modified?: string + district?: string | null + filename?: string + id?: string + language_code?: string | null + storage_file?: string | null + type?: string | null + } + Relationships: [ + { + foreignKeyName: "climate_forecasts_storage_file_fkey" + columns: ["storage_file"] + referencedRelation: "objects" + referencedColumns: ["id"] + }, + { + foreignKeyName: "climate_forecasts_storage_file_fkey" + columns: ["storage_file"] + referencedRelation: "storage_objects" + referencedColumns: ["id"] + } + ] + } climate_products: { Row: { created_at: string data: Json - id: number - station_id: number | null + station_id: number type: string } Insert: { created_at?: string data: Json - id?: number - station_id?: number | null + station_id: number type: string } Update: { created_at?: string data?: Json - id?: number - station_id?: number | null + station_id?: number type?: string } Relationships: [ @@ -275,7 +318,6 @@ export interface Database { metadata: Json | null name: string | null owner: string | null - owner_id: string | null path_tokens: string[] | null updated_at: string | null version: string | null @@ -288,7 +330,6 @@ export interface Database { metadata?: Json | null name?: string | null owner?: string | null - owner_id?: string | null path_tokens?: string[] | null updated_at?: string | null version?: string | null @@ -301,7 +342,6 @@ export interface Database { metadata?: Json | null name?: string | null owner?: string | null - owner_id?: string | null path_tokens?: string[] | null updated_at?: string | null version?: string | null diff --git a/libs/shared/src/features/data-table/data-table.component.html b/libs/shared/src/features/data-table/data-table.component.html index 19cf6b8f5..cee4c71eb 100644 --- a/libs/shared/src/features/data-table/data-table.component.html +++ b/libs/shared/src/features/data-table/data-table.component.html @@ -17,20 +17,20 @@ - @for(column of tableOptions.displayColumns; track column){ + @for(column of tableOptions.displayColumns; track column; let index=$index){ - diff --git a/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts b/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts index 6290aeed8..aecc75557 100644 --- a/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts +++ b/libs/shared/src/services/core/supabase/services/supabase-storage.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; // eslint-disable-next-line @nx/enforce-module-boundaries import { Database } from '@picsa/server-types'; -import { FileObject } from '@supabase/storage-js'; +import { FileObject, FileOptions } from '@supabase/storage-js'; import { SupabaseClient } from '@supabase/supabase-js'; import { PicsaNotificationService } from '../../notification.service'; @@ -81,6 +81,20 @@ export class SupabaseStorageService { return data?.[0] || null; } + public async putFile( + options: { bucketId: string; filename: string; fileBlob: Blob; folderPath?: string }, + fileOptions: FileOptions = { upsert: false } + ) { + const defaults = { folderPath: '' }; + const { bucketId, fileBlob, filename, folderPath } = { ...defaults, ...options }; + const filePath = folderPath ? `${folderPath}/${filename}` : `${filename}`; + const { data, error } = await this.storage.from(bucketId).upload(filePath, fileBlob, fileOptions); + if (error) { + throw error; + } + return data?.[0] || null; + } + /** Return the link to a file in a public bucket */ public getPublicLink(bucketId: string, objectPath: string) { return this.storage.from(bucketId).getPublicUrl(objectPath).data.publicUrl; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss b/libs/theme/src/_animations.scss similarity index 84% rename from apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss rename to libs/theme/src/_animations.scss index 58125726c..bca415c58 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss +++ b/libs/theme/src/_animations.scss @@ -1,7 +1,4 @@ -:host { - display: block; -} - +// Provide perpetual spin animation to any mat-icon mat-icon.spin { animation: spin 2s linear infinite; } diff --git a/libs/theme/src/_index.scss b/libs/theme/src/_index.scss index 0fde0dd35..0689ecb7f 100644 --- a/libs/theme/src/_index.scss +++ b/libs/theme/src/_index.scss @@ -3,9 +3,10 @@ */ // shared variables -@import 'themes'; -@import 'variables'; +@import 'animations'; @import 'fonts'; @import 'layout'; -@import 'typography'; @import 'overrides'; +@import 'themes'; +@import 'typography'; +@import 'variables';
{{ column | formatValue: tableOptions.formatHeader }} + @if(valueTemplates[column]){ } @else { - {{ el[column] }} + {{ row[column] }} }