update to analytics dashboard (EoD May 8th 2026)
This commit is contained in:
parent
f5670712f6
commit
e41ea6c8b3
@ -3,7 +3,7 @@
|
|||||||
<h3 class="card-title">{{ areaLabel }}</h3>
|
<h3 class="card-title">{{ areaLabel }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
||||||
<p-chart id="hectaresBarChart" type="bar" [data]="chartData" [options]="chartOptions" height="180"></p-chart>
|
<p-chart id="hectaresBarChart" type="bar" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
|
||||||
<div class="no-data-overlay" *ngIf="!hasData">
|
<div class="no-data-overlay" *ngIf="!hasData">
|
||||||
<i class="pi pi-calendar-times"></i>
|
<i class="pi pi-calendar-times"></i>
|
||||||
<span i18n="Hectares chart no data@@pilotHectaresChartNoData">No spray activity for this period</span>
|
<span i18n="Hectares chart no data@@pilotHectaresChartNoData">No spray activity for this period</span>
|
||||||
|
|||||||
@ -1,10 +1,34 @@
|
|||||||
import { Component, Input, OnChanges, AfterViewInit, ChangeDetectionStrategy } from '@angular/core';
|
import { AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||||
import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders';
|
import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
|
||||||
import { UnitUtils } from '../../../shared/utils';
|
import { UnitUtils } from '../../../shared/utils';
|
||||||
|
|
||||||
export { TrendDataPoint as HectaresTrendDataPoint };
|
export { TrendDataPoint as HectaresTrendDataPoint };
|
||||||
|
|
||||||
let roundedBarsPatched = false;
|
let roundedBarsPatched = false;
|
||||||
|
let tooltipPositionerPatched = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a custom Chart.js 2.x tooltip positioner that clamps the tooltip
|
||||||
|
* inside the chart area. The default 'average' positioner anchors the tooltip
|
||||||
|
* above the bar top — for truncated bars (value > axis max) this falls outside
|
||||||
|
* the canvas and becomes invisible. This positioner ensures it always stays inside.
|
||||||
|
*/
|
||||||
|
function patchTooltipPositioner(): void {
|
||||||
|
if (tooltipPositionerPatched) { return; }
|
||||||
|
const C: any = (window as any).Chart;
|
||||||
|
if (!C?.Tooltip?.positioners) { return; }
|
||||||
|
tooltipPositionerPatched = true;
|
||||||
|
|
||||||
|
C.Tooltip.positioners.clampedTop = function(elements: any[], eventPosition: any) {
|
||||||
|
const pos = C.Tooltip.positioners.average.call(this, elements, eventPosition);
|
||||||
|
if (!pos) { return false; }
|
||||||
|
const chartArea = this._chart?.chartArea;
|
||||||
|
if (!chartArea) { return pos; }
|
||||||
|
// Keep the tooltip anchor at least 8px below the chart top so the tooltip
|
||||||
|
// box always renders inside the canvas instead of above it.
|
||||||
|
return { x: pos.x, y: Math.max(chartArea.top + 8, pos.y) };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function patchRoundedBars(): void {
|
function patchRoundedBars(): void {
|
||||||
if (roundedBarsPatched) { return; }
|
if (roundedBarsPatched) { return; }
|
||||||
@ -63,6 +87,10 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
|
|||||||
@Input() isLoading = false;
|
@Input() isLoading = false;
|
||||||
@Input() hasError = false;
|
@Input() hasError = false;
|
||||||
@Input() isUS = false;
|
@Input() isUS = false;
|
||||||
|
/** The data-point index currently selected by a drill-down click. null = no selection. */
|
||||||
|
@Input() selectedIndex: number | null = null;
|
||||||
|
|
||||||
|
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
|
||||||
|
|
||||||
chartData: any;
|
chartData: any;
|
||||||
chartOptions: any;
|
chartOptions: any;
|
||||||
@ -76,6 +104,7 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
|
|||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
patchRoundedBars();
|
patchRoundedBars();
|
||||||
|
patchTooltipPositioner();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(): void {
|
||||||
@ -87,8 +116,25 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
|
|||||||
}));
|
}));
|
||||||
this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0);
|
this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0);
|
||||||
const config = ChartBuilderUtils.hectaresChart(convertedData, areaUnit);
|
const config = ChartBuilderUtils.hectaresChart(convertedData, areaUnit);
|
||||||
|
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
|
||||||
|
const count: number = config.chartData.datasets[0].data.length;
|
||||||
|
config.chartData = {
|
||||||
|
...config.chartData,
|
||||||
|
datasets: [{
|
||||||
|
...config.chartData.datasets[0],
|
||||||
|
backgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
this.chartData = config.chartData;
|
this.chartData = config.chartData;
|
||||||
this.chartOptions = config.chartOptions;
|
this.chartOptions = config.chartOptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
|
||||||
|
onDataSelect(event: any): void {
|
||||||
|
const index: number = event?.element?._index;
|
||||||
|
if (index == null || isNaN(index)) { return; }
|
||||||
|
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<h3 class="card-title" i18n="Hours flown chart title@@pilotHoursChartTitle">Hours Flown (Week History)</h3>
|
<h3 class="card-title" i18n="Hours flown chart title@@pilotHoursChartTitle">Hours Flown (Week History)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
<div class="chart-wrap" *ngIf="!isLoading && !hasError">
|
||||||
<p-chart type="line" [data]="chartData" [options]="chartOptions" height="180"></p-chart>
|
<p-chart type="line" [data]="chartData" [options]="chartOptions" height="180" (onDataSelect)="onDataSelect($event)"></p-chart>
|
||||||
<div class="no-data-overlay" *ngIf="!hasData">
|
<div class="no-data-overlay" *ngIf="!hasData">
|
||||||
<i class="pi pi-calendar-times"></i>
|
<i class="pi pi-calendar-times"></i>
|
||||||
<span i18n="Hours chart no data@@pilotHoursChartNoData">No flight activity for this period</span>
|
<span i18n="Hours chart no data@@pilotHoursChartNoData">No flight activity for this period</span>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Component, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||||
import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders';
|
import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
|
||||||
|
|
||||||
export { TrendDataPoint };
|
export { TrendDataPoint };
|
||||||
|
|
||||||
@ -13,6 +13,10 @@ export class HoursChartComponent implements OnChanges {
|
|||||||
@Input() trendData: TrendDataPoint[] = [];
|
@Input() trendData: TrendDataPoint[] = [];
|
||||||
@Input() isLoading = false;
|
@Input() isLoading = false;
|
||||||
@Input() hasError = false;
|
@Input() hasError = false;
|
||||||
|
/** The data-point index currently selected by a drill-down click. null = no selection. */
|
||||||
|
@Input() selectedIndex: number | null = null;
|
||||||
|
|
||||||
|
@Output() daySelected = new EventEmitter<{ label: string; index: number }>();
|
||||||
|
|
||||||
chartData: any;
|
chartData: any;
|
||||||
chartOptions: any;
|
chartOptions: any;
|
||||||
@ -22,8 +26,26 @@ export class HoursChartComponent implements OnChanges {
|
|||||||
if (!this.isLoading && !this.hasError) {
|
if (!this.isLoading && !this.hasError) {
|
||||||
this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0);
|
this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0);
|
||||||
const config = ChartBuilderUtils.hoursChart(this.trendData);
|
const config = ChartBuilderUtils.hoursChart(this.trendData);
|
||||||
|
if (this.selectedIndex !== null && config.chartData?.datasets?.[0]) {
|
||||||
|
const count: number = config.chartData.datasets[0].data.length;
|
||||||
|
config.chartData = {
|
||||||
|
...config.chartData,
|
||||||
|
datasets: [{
|
||||||
|
...config.chartData.datasets[0],
|
||||||
|
pointBackgroundColor: ChartBuilderUtils.buildSelectionColors(count, this.selectedIndex, '#2E7D32', '#c8e6c9'),
|
||||||
|
pointRadius: Array.from({ length: count }, (_, i) => i === this.selectedIndex ? 7 : 4),
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
this.chartData = config.chartData;
|
this.chartData = config.chartData;
|
||||||
this.chartOptions = config.chartOptions;
|
this.chartOptions = config.chartOptions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fired by p-chart (onDataSelect) when the user clicks a data point. */
|
||||||
|
onDataSelect(event: any): void {
|
||||||
|
const index: number = event?.element?._index;
|
||||||
|
if (index == null || isNaN(index)) { return; }
|
||||||
|
this.daySelected.emit({ label: this.trendData[index]?.day ?? '', index });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,17 @@
|
|||||||
<label for="dashUnitToggle" class="unit-toggle-label" i18n="@@unitToggleUSLabel">US / Imperial</label>
|
<label for="dashUnitToggle" class="unit-toggle-label" i18n="@@unitToggleUSLabel">US / Imperial</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drill-down filter pill: visible when a chart bar/point is selected -->
|
||||||
|
<div class="drill-down-filter" *ngIf="selectedDayLabel">
|
||||||
|
<i class="pi pi-filter-fill"></i>
|
||||||
|
<span i18n="Drill-down filter label@@drillDownFilterLabel">Viewing data for:</span>
|
||||||
|
<strong class="drill-down-day">{{ selectedDayLabel }}</strong>
|
||||||
|
<button class="drill-down-clear" (click)="clearDaySelection()"
|
||||||
|
pTooltip="Clear day filter" i18n-pTooltip="@@drillDownClearTooltip">
|
||||||
|
<i class="pi pi-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<agm-kpi-card
|
<agm-kpi-card
|
||||||
*ngFor="let kpi of kpiData; let i = index; trackBy: trackByIndex"
|
*ngFor="let kpi of kpiData; let i = index; trackBy: trackByIndex"
|
||||||
@ -29,8 +40,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="right-col">
|
<div class="right-col">
|
||||||
<agm-date-range-control (rangeChange)="onTrendRangeChange($event)"></agm-date-range-control>
|
<agm-date-range-control (rangeChange)="onTrendRangeChange($event)"></agm-date-range-control>
|
||||||
<agm-hours-chart [trendData]="trendData" [isLoading]="trendLoading" [hasError]="hasTrendError"></agm-hours-chart>
|
<agm-hours-chart
|
||||||
<agm-hectares-chart [trendData]="hectaresTrendData" [isLoading]="trendLoading" [hasError]="hasTrendError" [isUS]="isUS"></agm-hectares-chart>
|
[trendData]="trendData"
|
||||||
|
[isLoading]="trendLoading"
|
||||||
|
[hasError]="hasTrendError"
|
||||||
|
[selectedIndex]="selectedIndex"
|
||||||
|
(daySelected)="onDaySelected($event)">
|
||||||
|
</agm-hours-chart>
|
||||||
|
<agm-hectares-chart
|
||||||
|
[trendData]="hectaresTrendData"
|
||||||
|
[isLoading]="trendLoading"
|
||||||
|
[hasError]="hasTrendError"
|
||||||
|
[isUS]="isUS"
|
||||||
|
[selectedIndex]="selectedIndex"
|
||||||
|
(daySelected)="onDaySelected($event)">
|
||||||
|
</agm-hectares-chart>
|
||||||
<agm-xt-error-indicator
|
<agm-xt-error-indicator
|
||||||
[value]="xtErrorValue"
|
[value]="xtErrorValue"
|
||||||
[threshold]="xtThreshold"
|
[threshold]="xtThreshold"
|
||||||
|
|||||||
@ -42,6 +42,41 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drill-down filter pill — shown when a chart day is selected
|
||||||
|
.drill-down-filter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #e8f5e9;
|
||||||
|
border: 1px solid #a5d6a7;
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 0.35rem 0.75rem 0.35rem 0.65rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #1b5e20;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
.pi-filter-fill { font-size: 0.85rem; color: #2e7d32; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.drill-down-day {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drill-down-clear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2e7d32;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover { background: #c8e6c9; }
|
||||||
|
.pi-times { font-size: 0.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
.kpi-row {
|
.kpi-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -55,6 +55,15 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
trendLoading = false;
|
trendLoading = false;
|
||||||
hasTrendError = false;
|
hasTrendError = false;
|
||||||
|
|
||||||
|
/** Index of the currently drill-down-selected chart bar/point. null = no selection. */
|
||||||
|
selectedIndex: number | null = null;
|
||||||
|
/** Human-readable label of the selected day (e.g. "Mon Apr 7"), shown in the filter pill. */
|
||||||
|
selectedDayLabel: string | null = null;
|
||||||
|
/** ISO date string of the selected day, passed as ?selectedDate= query param to API calls. */
|
||||||
|
private selectedDayDate: string | undefined = undefined;
|
||||||
|
/** Start date of the current trend range, used to map bar index → calendar date. */
|
||||||
|
private trendRangeStart: Date | null = null;
|
||||||
|
|
||||||
private readonly destroy$ = new Subject<void>();
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
|
||||||
@ -81,8 +90,49 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
this.settings = this.appConf.settings; // keep BaseComp clone in sync
|
this.settings = this.appConf.settings; // keep BaseComp clone in sync
|
||||||
this.appConf.save(null, true);
|
this.appConf.save(null, true);
|
||||||
this.cdRef?.markForCheck(); // re-evaluate [isUS]="isUS" bindings on child components
|
this.cdRef?.markForCheck(); // re-evaluate [isUS]="isUS" bindings on child components
|
||||||
|
// Re-fetch with current drill-down filter (if any) so unit-toggled labels are consistent.
|
||||||
|
this.fetchKpiData(this.selectedDayDate);
|
||||||
|
this.fetchSummaryData(this.selectedDayDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PowerBI-style drill-down: clicking a chart bar/point filters all dashboard sections
|
||||||
|
* to show data for that specific day. Clicking the same day again clears the filter.
|
||||||
|
*
|
||||||
|
* All four data sections (KPI, Summary, Active Jobs, Performance) are re-fetched
|
||||||
|
* from the API with ?selectedDate= so every metric reflects the chosen day.
|
||||||
|
* detectChanges() is called immediately after updating the selection state so the
|
||||||
|
* filter pill and chart highlights render before the API responses arrive.
|
||||||
|
*/
|
||||||
|
onDaySelected(event: { label: string; index: number }): void {
|
||||||
|
if (this.selectedIndex === event.index) {
|
||||||
|
this.clearDaySelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.trendRangeStart) { return; }
|
||||||
|
this.selectedDayDate = DateUtils.toIsoDate(DateUtils.addDays(this.trendRangeStart, event.index));
|
||||||
|
this.selectedDayLabel = event.label;
|
||||||
|
this.selectedIndex = event.index;
|
||||||
|
// Immediately propagate state to OnPush children (filter pill + chart highlights)
|
||||||
|
// before the API responses arrive.
|
||||||
|
this.cdRef?.detectChanges();
|
||||||
|
this.fetchKpiData(this.selectedDayDate);
|
||||||
|
this.fetchSummaryData(this.selectedDayDate);
|
||||||
|
this.fetchActiveJobsData(this.selectedDayDate);
|
||||||
|
this.fetchPerformanceData(this.selectedDayDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the active drill-down filter and restores the full-range data. */
|
||||||
|
clearDaySelection(): void {
|
||||||
|
this.selectedIndex = null;
|
||||||
|
this.selectedDayLabel = null;
|
||||||
|
this.selectedDayDate = undefined;
|
||||||
|
this.cdRef?.detectChanges();
|
||||||
|
// Restore full-range values from the API for all sections.
|
||||||
this.fetchKpiData();
|
this.fetchKpiData();
|
||||||
this.fetchSummaryData();
|
this.fetchSummaryData();
|
||||||
|
this.fetchActiveJobsData();
|
||||||
|
this.fetchPerformanceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@ -91,18 +141,22 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
super.ngOnDestroy();
|
super.ngOnDestroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchKpiData(): void {
|
/** Converts a historical metric's area values from hectares to the current unit system. */
|
||||||
this.pilotDashboardService.getKpi().pipe(
|
private convertHaHistory(h: PilotHistoricalMetric): PilotHistoricalMetric {
|
||||||
takeUntil(this.destroy$)
|
return {
|
||||||
).subscribe({
|
|
||||||
next: (res: PilotKpiResponse) => {
|
|
||||||
const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
|
|
||||||
const convertHaHistory = (h: PilotHistoricalMetric): PilotHistoricalMetric => ({
|
|
||||||
day: UnitUtils.haToArea(h.day, this.isUS),
|
day: UnitUtils.haToArea(h.day, this.isUS),
|
||||||
week: UnitUtils.haToArea(h.week, this.isUS),
|
week: UnitUtils.haToArea(h.week, this.isUS),
|
||||||
month: UnitUtils.haToArea(h.month, this.isUS),
|
month: UnitUtils.haToArea(h.month, this.isUS),
|
||||||
year: UnitUtils.haToArea(h.year, this.isUS),
|
year: UnitUtils.haToArea(h.year, this.isUS),
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchKpiData(date?: string): void {
|
||||||
|
this.pilotDashboardService.getKpi(date).pipe(
|
||||||
|
takeUntil(this.destroy$)
|
||||||
|
).subscribe({
|
||||||
|
next: (res: PilotKpiResponse) => {
|
||||||
|
const areaUnit = UnitUtils.areaUnitLabel(this.isUS);
|
||||||
const assignedAreaLabel = this.isUS
|
const assignedAreaLabel = this.isUS
|
||||||
? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres`
|
? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres`
|
||||||
: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`;
|
: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`;
|
||||||
@ -120,13 +174,13 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
label: assignedAreaLabel,
|
label: assignedAreaLabel,
|
||||||
value: UnitUtils.haToArea(res.assignedHectares, this.isUS),
|
value: UnitUtils.haToArea(res.assignedHectares, this.isUS),
|
||||||
unit: areaUnit,
|
unit: areaUnit,
|
||||||
historical: convertHaHistory(res.historical.hectares)
|
historical: this.convertHaHistory(res.historical.hectares)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: sprayedAreaLabel,
|
label: sprayedAreaLabel,
|
||||||
value: UnitUtils.haToArea(res.sprayedToday, this.isUS),
|
value: UnitUtils.haToArea(res.sprayedToday, this.isUS),
|
||||||
unit: areaUnit,
|
unit: areaUnit,
|
||||||
historical: convertHaHistory(res.historical.hectares)
|
historical: this.convertHaHistory(res.historical.hectares)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`,
|
label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`,
|
||||||
@ -146,8 +200,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchSummaryData(): void {
|
private fetchSummaryData(date?: string): void {
|
||||||
this.pilotDashboardService.getSummary().pipe(
|
this.pilotDashboardService.getSummary(date).pipe(
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (res: PilotSummaryResponse) => {
|
next: (res: PilotSummaryResponse) => {
|
||||||
@ -197,8 +251,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchActiveJobsData(): void {
|
private fetchActiveJobsData(date?: string): void {
|
||||||
this.pilotDashboardService.getActiveJobs().pipe(
|
this.pilotDashboardService.getActiveJobs(date).pipe(
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (res: PilotActiveJobsResponse) => {
|
next: (res: PilotActiveJobsResponse) => {
|
||||||
@ -214,9 +268,9 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchPerformanceData(): void {
|
private fetchPerformanceData(date?: string): void {
|
||||||
this.performanceLoading = true;
|
this.performanceLoading = true;
|
||||||
this.pilotDashboardService.getPerformance().pipe(
|
this.pilotDashboardService.getPerformance(date).pipe(
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (res: PilotPerformanceResponse) => {
|
next: (res: PilotPerformanceResponse) => {
|
||||||
@ -262,13 +316,20 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
const endDate = DateUtils.startOfDay(selection.endDate);
|
const endDate = DateUtils.startOfDay(selection.endDate);
|
||||||
const [normalizedStart, normalizedEnd] = startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
|
const [normalizedStart, normalizedEnd] = startDate <= endDate ? [startDate, endDate] : [endDate, startDate];
|
||||||
|
|
||||||
|
// Store range start so drill-down can map bar index → calendar date.
|
||||||
|
this.trendRangeStart = normalizedStart;
|
||||||
|
// Changing the date range resets any active single-day filter.
|
||||||
|
this.selectedIndex = null;
|
||||||
|
this.selectedDayLabel = null;
|
||||||
|
this.selectedDayDate = undefined;
|
||||||
|
|
||||||
this.trendLoading = true;
|
this.trendLoading = true;
|
||||||
this.hasTrendError = false;
|
this.hasTrendError = false;
|
||||||
this.cdRef?.markForCheck();
|
this.cdRef?.markForCheck();
|
||||||
|
|
||||||
this.pilotDashboardService.getTrend(
|
this.pilotDashboardService.getTrend(
|
||||||
this.toIsoDate(normalizedStart),
|
DateUtils.toIsoDate(normalizedStart),
|
||||||
this.toIsoDate(normalizedEnd)
|
DateUtils.toIsoDate(normalizedEnd)
|
||||||
).pipe(takeUntil(this.destroy$)).subscribe({
|
).pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
next: (res: PilotTrendResponse) => {
|
next: (res: PilotTrendResponse) => {
|
||||||
this.trendData = res.labels.map((label, i) => ({ day: label, value: res.hoursFlown[i] }));
|
this.trendData = res.labels.map((label, i) => ({ day: label, value: res.hoursFlown[i] }));
|
||||||
@ -284,13 +345,6 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private toIsoDate(d: Date): string {
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
trackByIndex(index: number): number {
|
trackByIndex(index: number): number {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ interface DatasetOverrides {
|
|||||||
label: string;
|
label: string;
|
||||||
backgroundColor: string | string[];
|
backgroundColor: string | string[];
|
||||||
datalabels?: object;
|
datalabels?: object;
|
||||||
|
/** Decimal places shown in the tooltip. Defaults to 0 (integer). Use 1 for continuous values like hours. */
|
||||||
|
tooltipDecimals?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface YAxisTicks {
|
interface YAxisTicks {
|
||||||
@ -34,13 +36,44 @@ export class ChartBuilderUtils {
|
|||||||
return [{ gridLines: { color: ChartBuilderUtils.GRID_LINE }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL, beginAtZero: true, ...ticks } }];
|
return [{ gridLines: { color: ChartBuilderUtils.GRID_LINE }, ticks: { fontColor: ChartBuilderUtils.AXIS_LABEL, beginAtZero: true, ...ticks } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the dataset has a high-variance distribution: the maximum
|
||||||
|
* value is more than 5× the median of non-zero values. Used to detect when
|
||||||
|
* a single outlier would compress the remaining bars into near-invisibility.
|
||||||
|
*/
|
||||||
|
private static isHighVariance(values: number[]): boolean {
|
||||||
|
const nonZero = values.filter(v => v > 0);
|
||||||
|
if (nonZero.length < 2) { return false; }
|
||||||
|
const sorted = [...nonZero].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)];
|
||||||
|
return Math.max(...nonZero) > 5 * median;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every value that is NOT an outlier, using the same 5× median threshold
|
||||||
|
* as isHighVariance. Unlike a simple slice(0,-1), this correctly handles multiple
|
||||||
|
* outliers so the Y-axis scale accommodates every non-outlier bar.
|
||||||
|
*/
|
||||||
|
private static inlierValues(values: number[]): number[] {
|
||||||
|
const nonZero = values.filter(v => v > 0);
|
||||||
|
if (nonZero.length < 2) { return values; }
|
||||||
|
const sorted = [...nonZero].sort((a, b) => a - b);
|
||||||
|
const median = sorted[Math.floor(sorted.length / 2)];
|
||||||
|
const threshold = 5 * median;
|
||||||
|
return values.filter(v => v <= threshold);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes a nice Y-axis scale from the actual data values.
|
* Computes a nice Y-axis scale from the actual data values.
|
||||||
* `preferredMax` acts as both the zero-data fallback and a minimum floor —
|
* `preferredMax` acts as both the zero-data fallback and a minimum floor —
|
||||||
* the computed max will never be less than `preferredMax`.
|
* the computed max will never be less than `preferredMax`.
|
||||||
|
* `scaleValues` overrides which values are used for the scale calculation;
|
||||||
|
* pass a subset (e.g. values without the outlier) to prevent a single spike
|
||||||
|
* from collapsing the rest of the bars.
|
||||||
*/
|
*/
|
||||||
private static niceYTicks(values: number[], preferredMax: number): YAxisTicks {
|
private static niceYTicks(values: number[], preferredMax: number, scaleValues?: number[]): YAxisTicks {
|
||||||
const dataMax = values.length > 0 ? Math.max(...values) : 0;
|
const ref = scaleValues ?? values;
|
||||||
|
const dataMax = ref.length > 0 ? Math.max(...ref) : 0;
|
||||||
// Use whichever is larger: data-driven headroom or the preferred floor
|
// Use whichever is larger: data-driven headroom or the preferred floor
|
||||||
const raw = Math.max(dataMax * 1.2, preferredMax);
|
const raw = Math.max(dataMax * 1.2, preferredMax);
|
||||||
const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5)));
|
const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5)));
|
||||||
@ -82,7 +115,15 @@ export class ChartBuilderUtils {
|
|||||||
yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks)
|
yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks)
|
||||||
},
|
},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
callbacks: { label: (item: any) => ` ${item.yLabel} ${tooltipUnit}` }
|
// Use the raw dataset value instead of item.yLabel — yLabel reflects the
|
||||||
|
// bar's rendered position which is clamped to the axis max when a bar is
|
||||||
|
// truncated. dataset.data[index] always holds the original value.
|
||||||
|
callbacks: {
|
||||||
|
label: (item: any, data: any) => {
|
||||||
|
const raw = data?.datasets?.[item.datasetIndex]?.data?.[item.index] ?? item.yLabel;
|
||||||
|
return ` ${Number(raw).toFixed(dataset.tooltipDecimals ?? 0)} ${tooltipUnit}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -99,11 +140,24 @@ export class ChartBuilderUtils {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a per-index color array for drill-down selection highlighting.
|
||||||
|
* The bar/point at `selectedIndex` receives `selectedColor`; all others get `dimmedColor`.
|
||||||
|
*/
|
||||||
|
static buildSelectionColors(
|
||||||
|
count: number,
|
||||||
|
selectedIndex: number,
|
||||||
|
selectedColor: string,
|
||||||
|
dimmedColor: string
|
||||||
|
): string[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => i === selectedIndex ? selectedColor : dimmedColor);
|
||||||
|
}
|
||||||
|
|
||||||
static hoursChart(data: TrendDataPoint[]): ChartConfig {
|
static hoursChart(data: TrendDataPoint[]): ChartConfig {
|
||||||
const values = data.map(d => d.value);
|
const values = data.map(d => d.value);
|
||||||
return ChartBuilderUtils.buildBaseChartConfig(
|
return ChartBuilderUtils.buildBaseChartConfig(
|
||||||
data,
|
data,
|
||||||
{ label: $localize`:Hours flown chart dataset label@@hoursChartLabel:Hours Flown`, backgroundColor: ChartBuilderUtils.GREEN_DARK },
|
{ label: $localize`:Hours flown chart dataset label@@hoursChartLabel:Hours Flown`, backgroundColor: ChartBuilderUtils.GREEN_DARK, tooltipDecimals: 1 },
|
||||||
ChartBuilderUtils.niceYTicks(values, 5),
|
ChartBuilderUtils.niceYTicks(values, 5),
|
||||||
$localize`:Hours unit abbreviation@@unitHrs:hrs`
|
$localize`:Hours unit abbreviation@@unitHrs:hrs`
|
||||||
);
|
);
|
||||||
@ -111,17 +165,29 @@ export class ChartBuilderUtils {
|
|||||||
|
|
||||||
static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig {
|
static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig {
|
||||||
const values = data.map(d => d.value);
|
const values = data.map(d => d.value);
|
||||||
const preferredMax = areaUnit === 'ac' ? 2000 : 500;
|
// 500 ha ≈ 1235 ac; keep both unit floors proportional so the empty-headroom
|
||||||
const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => v };
|
// is the same fraction of typical values regardless of unit selection.
|
||||||
|
const preferredMax = areaUnit === 'ac' ? 1200 : 500;
|
||||||
|
const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => Math.round(v) };
|
||||||
const datasetLabel = areaUnit === 'ac'
|
const datasetLabel = areaUnit === 'ac'
|
||||||
? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres Sprayed`
|
? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres Sprayed`
|
||||||
: $localize`:Hectares sprayed chart dataset label@@hectaresChartLabel:Hectares Sprayed`;
|
: $localize`:Hectares sprayed chart dataset label@@hectaresChartLabel:Hectares Sprayed`;
|
||||||
return ChartBuilderUtils.buildBaseChartConfig(
|
// When one day dwarfs the rest, compute the Y-axis scale from the inlier
|
||||||
|
// values only (≤ 5× median). Unlike slice(0,-1), this handles multiple
|
||||||
|
// outliers so every non-outlier bar fits cleanly within the axis.
|
||||||
|
const scaleValues = ChartBuilderUtils.isHighVariance(values)
|
||||||
|
? ChartBuilderUtils.inlierValues(values)
|
||||||
|
: undefined;
|
||||||
|
const config = ChartBuilderUtils.buildBaseChartConfig(
|
||||||
data,
|
data,
|
||||||
{ label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels },
|
{ label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels },
|
||||||
ChartBuilderUtils.niceYTicks(values, preferredMax),
|
ChartBuilderUtils.niceYTicks(values, preferredMax, scaleValues),
|
||||||
areaUnit,
|
areaUnit,
|
||||||
{ datalabels }
|
{ datalabels }
|
||||||
);
|
);
|
||||||
|
// clampedTop is registered by HectaresChartComponent.ngAfterViewInit() — apply it
|
||||||
|
// here only, not in buildBaseChartConfig, so HoursChartComponent is unaffected.
|
||||||
|
config.chartOptions.tooltips.position = 'clampedTop';
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,20 +26,20 @@ export class PilotDashboardService {
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
getKpi(): Observable<PilotKpiResponse> {
|
getKpi(selectedDate?: string): Observable<PilotKpiResponse> {
|
||||||
return this.http.get<PilotKpiResponse>(`${this.baseUrl}/kpi`, { params: this.tzParams() }).pipe(
|
return this.http.get<PilotKpiResponse>(`${this.baseUrl}/kpi`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSummary(): Observable<PilotSummaryResponse> {
|
getSummary(selectedDate?: string): Observable<PilotSummaryResponse> {
|
||||||
return this.http.get<PilotSummaryResponse>(`${this.baseUrl}/summary`, { params: this.tzParams() }).pipe(
|
return this.http.get<PilotSummaryResponse>(`${this.baseUrl}/summary`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveJobs(): Observable<PilotActiveJobsResponse> {
|
getActiveJobs(selectedDate?: string): Observable<PilotActiveJobsResponse> {
|
||||||
return this.http.get<PilotActiveJobsResponse>(`${this.baseUrl}/activeJobs`, { params: this.tzParams() }).pipe(
|
return this.http.get<PilotActiveJobsResponse>(`${this.baseUrl}/activeJobs`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -50,8 +50,8 @@ export class PilotDashboardService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPerformance(): Observable<PilotPerformanceResponse> {
|
getPerformance(selectedDate?: string): Observable<PilotPerformanceResponse> {
|
||||||
return this.http.get<PilotPerformanceResponse>(`${this.baseUrl}/performance`, { params: this.tzParams() }).pipe(
|
return this.http.get<PilotPerformanceResponse>(`${this.baseUrl}/performance`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
|
||||||
catchError(this.handleError)
|
catchError(this.handleError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,5 +14,6 @@
|
|||||||
panelStyleClass="week-picker"
|
panelStyleClass="week-picker"
|
||||||
appendTo="body"
|
appendTo="body"
|
||||||
(onSelect)="onRangeSelected(dateRangeControl.value)"
|
(onSelect)="onRangeSelected(dateRangeControl.value)"
|
||||||
|
(onMonthChange)="onMonthChange($event)"
|
||||||
></p-calendar>
|
></p-calendar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, OnInit, OnDestroy, AfterViewInit, Output } from '@angular/core';
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { DateUtils } from '../utils';
|
import { DateUtils } from '../utils';
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ export interface DateRangeSelection {
|
|||||||
templateUrl: './date-range-control.component.html',
|
templateUrl: './date-range-control.component.html',
|
||||||
styleUrls: ['./date-range-control.component.scss']
|
styleUrls: ['./date-range-control.component.scss']
|
||||||
})
|
})
|
||||||
export class DateRangeControlComponent implements OnInit {
|
export class DateRangeControlComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
@Output() rangeChange = new EventEmitter<DateRangeSelection>();
|
@Output() rangeChange = new EventEmitter<DateRangeSelection>();
|
||||||
|
|
||||||
readonly maxRangeDays = 90;
|
readonly maxRangeDays = 90;
|
||||||
@ -21,12 +21,104 @@ export class DateRangeControlComponent implements OnInit {
|
|||||||
|
|
||||||
readonly dateRangeControl = new FormControl([]);
|
readonly dateRangeControl = new FormControl([]);
|
||||||
|
|
||||||
|
// Tracks which month/year is currently displayed in the calendar panel.
|
||||||
|
// Used to resolve day-cell numbers into full Date objects on week-row click.
|
||||||
|
private viewMonth = new Date().getMonth(); // 0-based
|
||||||
|
private viewYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Bound document-level click handler — kept as a reference so it can be removed in ngOnDestroy.
|
||||||
|
private readonly boundWeekClick = (e: MouseEvent) => this.onCalendarClick(e);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const defaultRange = this.getCurrentWeekRange();
|
const defaultRange = this.getCurrentWeekRange();
|
||||||
this.dateRangeControl.setValue(defaultRange);
|
this.dateRangeControl.setValue(defaultRange);
|
||||||
this.emitIfRangeComplete(defaultRange);
|
this.emitIfRangeComplete(defaultRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
// The calendar panel is appended to <body> (appendTo="body"), so the click
|
||||||
|
// handler must live on document — not on the component's host element.
|
||||||
|
document.addEventListener('click', this.boundWeekClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
document.removeEventListener('click', this.boundWeekClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps viewMonth/viewYear in sync when the user navigates the calendar. */
|
||||||
|
onMonthChange(event: { month: number; year: number }): void {
|
||||||
|
this.viewMonth = event.month - 1; // PrimeNG emits 1-based month
|
||||||
|
this.viewYear = event.year;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicks anywhere inside the calendar panel.
|
||||||
|
* When the click lands on a week-number cell, the entire displayed week row
|
||||||
|
* is selected as the date range (clamped to maxDate / minDate).
|
||||||
|
*/
|
||||||
|
onCalendarClick(event: MouseEvent): void {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
// Guard: only act on clicks inside a week-picker calendar panel.
|
||||||
|
// appendTo="body" renders multiple panels in <body>; other calendars must be ignored.
|
||||||
|
if (!target.closest('.ui-datepicker.week-picker')) { return; }
|
||||||
|
const weekCell = target.closest('.ui-datepicker-weeknumber') as HTMLElement | null;
|
||||||
|
if (!weekCell) { return; }
|
||||||
|
|
||||||
|
const row = weekCell.closest('tr') as HTMLTableRowElement | null;
|
||||||
|
if (!row) { return; }
|
||||||
|
|
||||||
|
// All TD cells in the row except the week-number cell itself
|
||||||
|
const dayCells = Array.from(
|
||||||
|
row.querySelectorAll('td:not(.ui-datepicker-weeknumber)')
|
||||||
|
) as HTMLElement[];
|
||||||
|
|
||||||
|
const dates: Date[] = [];
|
||||||
|
// Track transition: prev-month overflow → current month → next-month overflow
|
||||||
|
let phase: 'prev' | 'current' | 'next' = 'prev';
|
||||||
|
|
||||||
|
for (const cell of dayCells) {
|
||||||
|
const isOther = cell.classList.contains('ui-datepicker-other-month');
|
||||||
|
|
||||||
|
// Detect transition from prev overflow to current month
|
||||||
|
if (!isOther && phase === 'prev') { phase = 'current'; }
|
||||||
|
// Detect transition from current month to next overflow
|
||||||
|
if (isOther && phase === 'current') { phase = 'next'; }
|
||||||
|
|
||||||
|
const anchor = cell.querySelector('a, span') as HTMLElement | null;
|
||||||
|
if (!anchor) { continue; }
|
||||||
|
const dayNum = parseInt(anchor.textContent?.trim() ?? '', 10);
|
||||||
|
if (isNaN(dayNum)) { continue; }
|
||||||
|
|
||||||
|
let month = this.viewMonth;
|
||||||
|
let year = this.viewYear;
|
||||||
|
|
||||||
|
if (phase === 'prev') {
|
||||||
|
month--;
|
||||||
|
if (month < 0) { month = 11; year--; }
|
||||||
|
} else if (phase === 'next') {
|
||||||
|
month++;
|
||||||
|
if (month > 11) { month = 0; year++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.push(new Date(year, month, dayNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dates.length === 0) { return; }
|
||||||
|
|
||||||
|
const today = this.maxDate;
|
||||||
|
let startDate = DateUtils.startOfDay(dates[0]);
|
||||||
|
let endDate = DateUtils.startOfDay(dates[dates.length - 1]);
|
||||||
|
|
||||||
|
// Clamp to allowed bounds
|
||||||
|
if (startDate < this.minDate) { startDate = this.minDate; }
|
||||||
|
if (endDate > today) { endDate = today; }
|
||||||
|
if (startDate > today) { return; } // entire week is in the future
|
||||||
|
|
||||||
|
const range = [startDate, endDate];
|
||||||
|
this.dateRangeControl.setValue(range);
|
||||||
|
this.onRangeSelected(range);
|
||||||
|
}
|
||||||
|
|
||||||
onRangeSelected(range: Date[]): void {
|
onRangeSelected(range: Date[]): void {
|
||||||
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
|
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -739,6 +739,11 @@ export class DateUtils {
|
|||||||
return date instanceof Date ? `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}` : '';
|
return date instanceof Date ? `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a Date as an ISO date string (YYYY-MM-DD) in local time. */
|
||||||
|
static toIsoDate(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${NumUtils.padZero(date.getMonth() + 1, 2)}-${NumUtils.padZero(date.getDate(), 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
static msToTime(ms, tz = undefined) {
|
static msToTime(ms, tz = undefined) {
|
||||||
if (!ms) return "00:00:00.0";
|
if (!ms) return "00:00:00.0";
|
||||||
let secs, min, hrs;
|
let secs, min, hrs;
|
||||||
|
|||||||
@ -1966,6 +1966,8 @@ body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:last-c
|
|||||||
border-radius: 0 50% 50% 0;
|
border-radius: 0 50% 50% 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Form calendar internals — PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button]
|
// Form calendar internals — PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button]
|
||||||
body span.form-calendar.ui-calendar,
|
body span.form-calendar.ui-calendar,
|
||||||
body .form-calendar.ui-calendar,
|
body .form-calendar.ui-calendar,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user