Skip to content

Commit

Permalink
Merge pull request #333 from e-picsa/feat/dashboard-climate-admin
Browse files Browse the repository at this point in the history
feat: dashboard climate admin page
  • Loading branch information
chrismclarke authored Oct 14, 2024
2 parents 13ad0e3 + 869a20c commit 283911e
Show file tree
Hide file tree
Showing 20 changed files with 508 additions and 100 deletions.
1 change: 0 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"cSpell.enabled": false,
"eslint.runtime": "node",
// Deno configuration for supabase functions
// NOTE - could be refactored to separate code-workspace
// https://supabase.com/docs/guides/functions/local-development#setting-up-your-environment
Expand Down
6 changes: 6 additions & 0 deletions apps/picsa-apps/dashboard/src/app/data/navLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export const DASHBOARD_NAV_LINKS: INavLink[] = [
label: 'Forecasts',
href: '/forecast',
},
{
label: 'Admin',
href: '/admin',
// TODO - auth role
// TODO - import from module?
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@ import { IDeploymentRow } from '../deployment/types';
import { ClimateService } from './climate.service';
import type { ClimateApiService } from './climate-api.service';
import {
IClimateProductInsert,
IClimateProductRow,
IAPICountryCode,
IClimateSummaryRainfallInsert,
IClimateSummaryRainfallRow,
IForecastInsert,
IForecastRow,
IStationInsert,
IStationRow,
} from './types';
import type { components as ApiComponents } from './types/api';

export type IApiMapping = ReturnType<typeof ApiMapping>;
export type IApiMappingName = keyof IApiMapping;

export type IAPICountryCode = ApiComponents['schemas']['StationAndDefintionResponce']['country_code'];

// 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
Expand All @@ -38,7 +36,7 @@ export const ApiMapping = (
rainfallSummaries: async (station: IStationRow) => {
const { country_code, station_id, station_name, id } = station;
// TODO - add model type definitions for server rainfall summary response body
const { data, error } = await api
const { data: apiData, error } = await api
.getObservableClient(`rainfallSummary_${id}`)
.POST('/v1/annual_rainfall_summaries/', {
body: {
Expand All @@ -51,20 +49,22 @@ export const ApiMapping = (
});
if (error) throw error;
// HACK - API issue returning huge data for some stations
if (data.data.length > 1000) {
console.error({ country_code, station_id, station_name, total_rows: data.data.length });
throw new Error(`[rainfallSummary] Too many rows | ${station_name} ${data.data.length}`);
const { data, metadata } = apiData;
if (data.length > 1000) {
console.error({ country_code, station_id, station_name, total_rows: data.length });
throw new Error(`[rainfallSummary] Too many rows | ${station_name} ${data.length}`);
}
// TODO - gen types and handle mapping
const entry: IClimateProductInsert = {
data: data as any,
const entry: IClimateSummaryRainfallInsert = {
data: data as any[],
metadata,
station_id: id as string,
type: 'rainfallSummary',
country_code: country_code as any,
};
const { data: dbData, error: dbError } = await db
.table('climate_products')
.upsert<IClimateProductInsert>(entry)
.select<'*', IClimateProductRow>('*');
.table('climate_summary_rainfall')
.upsert<IClimateSummaryRainfallInsert>(entry)
.select<'*', IClimateSummaryRainfallRow>('*');
if (dbError) throw dbError;
return dbData || [];
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { StationDetailsPageComponent } from './pages/station-details/station-det
redirectTo: 'station',
pathMatch: 'full',
},
{
path: 'admin',
loadComponent: () => import('./pages/admin/admin.component').then((m) => m.ClimateAdminPageComponent),
// TODO - add auth route guards
},
{
path: 'station',
component: ClimateStationPageComponent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { map } from 'rxjs';

import { DeploymentDashboardService } from '../deployment/deployment.service';
import { IDeploymentRow } from '../deployment/types';
import { ApiMapping, IAPICountryCode } from './climate-api.mapping';
import { ApiMapping } from './climate-api.mapping';
import { ClimateApiService } from './climate-api.service';
import { IStationRow } from './types';
import { IAPICountryCode, IStationRow } from './types';

@Injectable({ providedIn: 'root' })
export class ClimateService extends PicsaAsyncService {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div class="page-content">
<h2>Admin</h2>
<div style="display: flex; align-items: center; gap: 2em">
<p style="flex: 1">See the table below for rainfall summary data for all stations</p>
<!-- Data Refresh -->
<button mat-stroked-button color="primary" (click)="refreshAllStations()" [disabled]="refreshCount() > -1">
<mat-icon>refresh</mat-icon>
<span>
@if(refreshCount() > -1){
{{ refreshCount() }} / {{ tableData().length }} } @else { Refresh All}
</span>
</button>
<!-- Data Download -->
<button mat-stroked-button color="primary" (click)="downloadAllStationsCSV()">
<mat-icon>download</mat-icon>Download All
</button>
</div>
<picsa-data-table
style="margin-top: 1em"
[data]="tableData()"
[options]="tableOptions"
[valueTemplates]="{ csv: csvTemplate, updated_at: updatedAtTemplate }"
>
<ng-template #csvTemplate let-row="row" let-value="value">
<button mat-button (click)="downloadStationCSV(row)" [disabled]="!value"><mat-icon>download</mat-icon></button>
</ng-template>
<ng-template #updatedAtTemplate let-value>
{{ value | date: 'mediumDate' }}
</ng-template>
</picsa-data-table>
</div>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ClimateAdminPageComponent } from './admin.component';

describe('ClimateAdminPageComponent', () => {
let component: ClimateAdminPageComponent;
let fixture: ComponentFixture<ClimateAdminPageComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ClimateAdminPageComponent],
}).compileComponents();

fixture = TestBed.createComponent(ClimateAdminPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { CommonModule, DatePipe } from '@angular/common';
import { Component, computed, effect, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { IDataTableOptions, PicsaDataTableComponent } from '@picsa/shared/features';
import { SupabaseService } from '@picsa/shared/services/core/supabase';
import { _wait, arrayToHashmap } from '@picsa/utils';
import download from 'downloadjs';
import JSZip from 'jszip';
import { unparse } from 'papaparse';

import { DeploymentDashboardService } from '../../../deployment/deployment.service';
import { ClimateService } from '../../climate.service';
import type { IAnnualRainfallSummariesData, IClimateSummaryRainfallRow, IStationRow } from '../../types';
import { hackConvertAPIDataToLegacyFormat } from '../station-details/components/rainfall-summary/rainfall-summary.utils';

interface IStationAdminSummary {
station_id: string;
row: IStationRow;
updated_at?: string;
rainfall_summary?: IClimateSummaryRainfallRow;
start_year?: number;
end_year?: number;
}

const DISPLAY_COLUMNS: (keyof IStationAdminSummary)[] = ['station_id', 'updated_at', 'start_year', 'end_year'];

/**
* TODOs - See #333
*/

@Component({
selector: 'dashboard-climate-admin-page',
standalone: true,
imports: [CommonModule, DatePipe, MatButtonModule, MatIconModule, PicsaDataTableComponent],
templateUrl: './admin.component.html',
styleUrl: './admin.component.scss',
})
export class ClimateAdminPageComponent {
public tableData = computed(() => {
const stations = this.service.stations();
const rainfallSummaries = this.rainfallSummaries();
return this.generateTableSummaryData(stations, rainfallSummaries);
});
public tableOptions: IDataTableOptions = {
displayColumns: DISPLAY_COLUMNS,
handleRowClick: ({ station_id }: IStationAdminSummary) =>
this.router.navigate(['../', 'station', station_id], { relativeTo: this.route }),
};
public refreshCount = signal(-1);

private rainfallSummaries = signal<IClimateSummaryRainfallRow[]>([]);

constructor(
private service: ClimateService,
private deploymentService: DeploymentDashboardService,
private supabase: SupabaseService,
private router: Router,
private route: ActivatedRoute
) {
effect(
() => {
const country_code = this.deploymentService.activeDeployment()?.country_code;
if (country_code) {
this.loadRainfallSummaries(country_code);
}
},
{ allowSignalWrites: true }
);
}
private get db() {
return this.supabase.db.table('climate_summary_rainfall');
}

public async downloadAllStationsCSV() {
const zip = new JSZip();
for (const summary of this.tableData()) {
const csvData = this.generateStationCSVDownload(summary);
if (csvData) {
zip.file(`${summary.row.station_id}.csv`, csvData);
}
}
const blob = await zip.generateAsync({ type: 'blob' });
const country_code = this.deploymentService.activeDeployment()?.country_code;
download(blob, `${country_code}_rainfall_summaries.zip`);
}

public downloadStationCSV(station: IStationAdminSummary) {
const csv = this.generateStationCSVDownload(station);
if (csv) {
download(csv, station.row.station_id, 'text/csv');
}
}

public async refreshAllStations() {
this.refreshCount.set(0);
const promises = this.tableData().map(async (station, i) => {
// hack - instead of queueing apply small offset between requests to reduce blocking
await _wait(200 * i);
await this.service.loadFromAPI.rainfallSummaries(station.row);
this.refreshCount.update((v) => v + 1);
});
await Promise.all(promises);
await this.loadRainfallSummaries(this.deploymentService.activeDeployment()?.country_code as string);
}

private generateStationCSVDownload(summary: IStationAdminSummary) {
const { rainfall_summary } = summary;
if (rainfall_summary && rainfall_summary.data) {
const data = rainfall_summary.data as any[];
const csvData = hackConvertAPIDataToLegacyFormat(data);
const columns = Object.keys(csvData[0]);
const csv = unparse(csvData, { columns });
return csv;
}
return undefined;
}

private generateTableSummaryData(stations: IStationRow[], rainfallSummaries: IClimateSummaryRainfallRow[]) {
// NOTE - only single entry for rainfallSummary (not hashmapArray)
const rainfallSummariesHashmap = arrayToHashmap(rainfallSummaries, 'station_id');
return stations.map((row) => {
const { station_id, id } = row;
const summary: IStationAdminSummary = { station_id, row };
const rainfallSummary = rainfallSummariesHashmap[station_id];
if (rainfallSummary) {
const { data, updated_at } = rainfallSummary;
summary.updated_at = updated_at;
summary.rainfall_summary = rainfallSummary;
const entries = data as IAnnualRainfallSummariesData[];
summary.start_year = entries[0]?.year;
summary.end_year = entries[entries.length - 1]?.year;
}
return summary;
});
}

private async loadRainfallSummaries(country_code: string) {
const { data, error } = await this.db.select<'*', IClimateSummaryRainfallRow>('*').eq('country_code', country_code);
if (error) {
throw error;
}
this.rainfallSummaries.set(
data.map((el) => {
// HACK - to keep parent ref id includes full country prefix, remove for lookup
el.station_id = el.station_id.replace(`${country_code}/`, '');
return el;
})
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h3 style="flex: 1">Rainfall Summary</h3>
<mat-icon>view_list</mat-icon>
Table
</ng-template>
<picsa-data-table [data]="summaryData" [options]="tableOptions"></picsa-data-table>
<picsa-data-table [data]="summaryData" [options]="tableOptions"> </picsa-data-table>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
Expand Down
Loading

0 comments on commit 283911e

Please sign in to comment.