Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: dashboard climate admin page #333

Merged
merged 10 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
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
Loading