diff --git a/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.html b/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.html
index 077a3fa..c461b5e 100644
--- a/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.html
+++ b/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.html
@@ -3,7 +3,7 @@
-
+
No spray activity for this period
diff --git a/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.ts b/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.ts
index 030925b..5ef303b 100644
--- a/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.ts
+++ b/Development/client/src/app/dashboard/components/hectares-chart/hectares-chart.component.ts
@@ -1,10 +1,34 @@
-import { Component, Input, OnChanges, AfterViewInit, ChangeDetectionStrategy } from '@angular/core';
-import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders';
+import { AfterViewInit, ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
+import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
import { UnitUtils } from '../../../shared/utils';
export { TrendDataPoint as HectaresTrendDataPoint };
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 {
if (roundedBarsPatched) { return; }
@@ -63,6 +87,10 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
@Input() isLoading = false;
@Input() hasError = 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;
chartOptions: any;
@@ -76,6 +104,7 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
ngAfterViewInit(): void {
patchRoundedBars();
+ patchTooltipPositioner();
}
ngOnChanges(): void {
@@ -87,8 +116,25 @@ export class HectaresChartComponent implements OnChanges, AfterViewInit {
}));
this.hasData = convertedData.length > 0 && convertedData.some(d => d.value > 0);
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.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 });
+ }
}
diff --git a/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.html b/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.html
index 43a4267..b0a39d9 100644
--- a/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.html
+++ b/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.html
@@ -3,7 +3,7 @@
Hours Flown (Week History)
-
+
No flight activity for this period
diff --git a/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.ts b/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.ts
index 36782db..7cd23af 100644
--- a/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.ts
+++ b/Development/client/src/app/dashboard/components/hours-chart/hours-chart.component.ts
@@ -1,5 +1,5 @@
-import { Component, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core';
-import { TrendDataPoint, ChartBuilderUtils } from '../../utils/chart-builders';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
+import { ChartBuilderUtils, TrendDataPoint } from '../../utils/chart-builders';
export { TrendDataPoint };
@@ -13,6 +13,10 @@ export class HoursChartComponent implements OnChanges {
@Input() trendData: TrendDataPoint[] = [];
@Input() isLoading = 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;
chartOptions: any;
@@ -22,8 +26,26 @@ export class HoursChartComponent implements OnChanges {
if (!this.isLoading && !this.hasError) {
this.hasData = this.trendData.length > 0 && this.trendData.some(d => d.value > 0);
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.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 });
+ }
}
diff --git a/Development/client/src/app/dashboard/pilot-dashboard/pilot-dashboard.component.html b/Development/client/src/app/dashboard/pilot-dashboard/pilot-dashboard.component.html
index 5e207b9..c30f712 100644
--- a/Development/client/src/app/dashboard/pilot-dashboard/pilot-dashboard.component.html
+++ b/Development/client/src/app/dashboard/pilot-dashboard/pilot-dashboard.component.html
@@ -11,6 +11,17 @@
+
+
+
+
+ Viewing data for:
+ {{ selectedDayLabel }}
+
+
-
-
+
+
+
+
();
@@ -81,8 +90,49 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
this.settings = this.appConf.settings; // keep BaseComp clone in sync
this.appConf.save(null, true);
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.fetchSummaryData();
+ this.fetchActiveJobsData();
+ this.fetchPerformanceData();
}
ngOnDestroy(): void {
@@ -91,18 +141,22 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
super.ngOnDestroy();
}
- private fetchKpiData(): void {
- this.pilotDashboardService.getKpi().pipe(
+ /** Converts a historical metric's area values from hectares to the current unit system. */
+ private convertHaHistory(h: PilotHistoricalMetric): PilotHistoricalMetric {
+ return {
+ day: UnitUtils.haToArea(h.day, this.isUS),
+ week: UnitUtils.haToArea(h.week, this.isUS),
+ month: UnitUtils.haToArea(h.month, 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 convertHaHistory = (h: PilotHistoricalMetric): PilotHistoricalMetric => ({
- day: UnitUtils.haToArea(h.day, this.isUS),
- week: UnitUtils.haToArea(h.week, this.isUS),
- month: UnitUtils.haToArea(h.month, this.isUS),
- year: UnitUtils.haToArea(h.year, this.isUS),
- });
const assignedAreaLabel = this.isUS
? $localize`:KPI label US@@kpiAssignedAcres:Assigned Acres`
: $localize`:KPI label@@kpiAssignedHectares:Assigned Hectares`;
@@ -120,13 +174,13 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
label: assignedAreaLabel,
value: UnitUtils.haToArea(res.assignedHectares, this.isUS),
unit: areaUnit,
- historical: convertHaHistory(res.historical.hectares)
+ historical: this.convertHaHistory(res.historical.hectares)
},
{
label: sprayedAreaLabel,
value: UnitUtils.haToArea(res.sprayedToday, this.isUS),
unit: areaUnit,
- historical: convertHaHistory(res.historical.hectares)
+ historical: this.convertHaHistory(res.historical.hectares)
},
{
label: $localize`:KPI label@@kpiFlightHours:Flight Hours Today`,
@@ -146,8 +200,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
});
}
- private fetchSummaryData(): void {
- this.pilotDashboardService.getSummary().pipe(
+ private fetchSummaryData(date?: string): void {
+ this.pilotDashboardService.getSummary(date).pipe(
takeUntil(this.destroy$)
).subscribe({
next: (res: PilotSummaryResponse) => {
@@ -197,8 +251,8 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
});
}
- private fetchActiveJobsData(): void {
- this.pilotDashboardService.getActiveJobs().pipe(
+ private fetchActiveJobsData(date?: string): void {
+ this.pilotDashboardService.getActiveJobs(date).pipe(
takeUntil(this.destroy$)
).subscribe({
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.pilotDashboardService.getPerformance().pipe(
+ this.pilotDashboardService.getPerformance(date).pipe(
takeUntil(this.destroy$)
).subscribe({
next: (res: PilotPerformanceResponse) => {
@@ -262,13 +316,20 @@ export class PilotDashboardComponent extends BaseComp implements OnInit, OnDestr
const endDate = DateUtils.startOfDay(selection.endDate);
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.hasTrendError = false;
this.cdRef?.markForCheck();
this.pilotDashboardService.getTrend(
- this.toIsoDate(normalizedStart),
- this.toIsoDate(normalizedEnd)
+ DateUtils.toIsoDate(normalizedStart),
+ DateUtils.toIsoDate(normalizedEnd)
).pipe(takeUntil(this.destroy$)).subscribe({
next: (res: PilotTrendResponse) => {
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 {
return index;
}
diff --git a/Development/client/src/app/dashboard/utils/chart-builders.ts b/Development/client/src/app/dashboard/utils/chart-builders.ts
index a93e2d1..b2e3e3b 100644
--- a/Development/client/src/app/dashboard/utils/chart-builders.ts
+++ b/Development/client/src/app/dashboard/utils/chart-builders.ts
@@ -12,6 +12,8 @@ interface DatasetOverrides {
label: string;
backgroundColor: string | string[];
datalabels?: object;
+ /** Decimal places shown in the tooltip. Defaults to 0 (integer). Use 1 for continuous values like hours. */
+ tooltipDecimals?: number;
}
interface YAxisTicks {
@@ -34,13 +36,44 @@ export class ChartBuilderUtils {
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.
* `preferredMax` acts as both the zero-data fallback and a minimum floor —
* 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 {
- const dataMax = values.length > 0 ? Math.max(...values) : 0;
+ private static niceYTicks(values: number[], preferredMax: number, scaleValues?: number[]): YAxisTicks {
+ const ref = scaleValues ?? values;
+ const dataMax = ref.length > 0 ? Math.max(...ref) : 0;
// Use whichever is larger: data-driven headroom or the preferred floor
const raw = Math.max(dataMax * 1.2, preferredMax);
const magnitude = Math.pow(10, Math.floor(Math.log10(raw / 5)));
@@ -82,7 +115,15 @@ export class ChartBuilderUtils {
yAxes: ChartBuilderUtils.buildYAxis(yAxisTicks)
},
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 {
const values = data.map(d => d.value);
return ChartBuilderUtils.buildBaseChartConfig(
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),
$localize`:Hours unit abbreviation@@unitHrs:hrs`
);
@@ -111,17 +165,29 @@ export class ChartBuilderUtils {
static hectaresChart(data: TrendDataPoint[], areaUnit = 'ha'): ChartConfig {
const values = data.map(d => d.value);
- const preferredMax = areaUnit === 'ac' ? 2000 : 500;
- const datalabels = { anchor: 'end', align: 'end', color: ChartBuilderUtils.DATA_LABEL, font: { weight: 'bold' }, formatter: (v: number) => v };
+ // 500 ha ≈ 1235 ac; keep both unit floors proportional so the empty-headroom
+ // 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'
? $localize`:Acres sprayed chart dataset label@@acresChartLabel:Acres 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,
{ label: datasetLabel, backgroundColor: ChartBuilderUtils.interpolateGreenByValue(values), datalabels },
- ChartBuilderUtils.niceYTicks(values, preferredMax),
+ ChartBuilderUtils.niceYTicks(values, preferredMax, scaleValues),
areaUnit,
{ 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;
}
}
diff --git a/Development/client/src/app/domain/services/pilot-dashboard.service.ts b/Development/client/src/app/domain/services/pilot-dashboard.service.ts
index d926eae..afb7255 100644
--- a/Development/client/src/app/domain/services/pilot-dashboard.service.ts
+++ b/Development/client/src/app/domain/services/pilot-dashboard.service.ts
@@ -26,20 +26,20 @@ export class PilotDashboardService {
return params;
}
- getKpi(): Observable {
- return this.http.get(`${this.baseUrl}/kpi`, { params: this.tzParams() }).pipe(
+ getKpi(selectedDate?: string): Observable {
+ return this.http.get(`${this.baseUrl}/kpi`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError)
);
}
- getSummary(): Observable {
- return this.http.get(`${this.baseUrl}/summary`, { params: this.tzParams() }).pipe(
+ getSummary(selectedDate?: string): Observable {
+ return this.http.get(`${this.baseUrl}/summary`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError)
);
}
- getActiveJobs(): Observable {
- return this.http.get(`${this.baseUrl}/activeJobs`, { params: this.tzParams() }).pipe(
+ getActiveJobs(selectedDate?: string): Observable {
+ return this.http.get(`${this.baseUrl}/activeJobs`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError)
);
}
@@ -50,8 +50,8 @@ export class PilotDashboardService {
);
}
- getPerformance(): Observable {
- return this.http.get(`${this.baseUrl}/performance`, { params: this.tzParams() }).pipe(
+ getPerformance(selectedDate?: string): Observable {
+ return this.http.get(`${this.baseUrl}/performance`, { params: this.tzParams(selectedDate ? { selectedDate } : undefined) }).pipe(
catchError(this.handleError)
);
}
diff --git a/Development/client/src/app/shared/date-range-control/date-range-control.component.html b/Development/client/src/app/shared/date-range-control/date-range-control.component.html
index b47dcbe..1f36002 100644
--- a/Development/client/src/app/shared/date-range-control/date-range-control.component.html
+++ b/Development/client/src/app/shared/date-range-control/date-range-control.component.html
@@ -14,5 +14,6 @@
panelStyleClass="week-picker"
appendTo="body"
(onSelect)="onRangeSelected(dateRangeControl.value)"
+ (onMonthChange)="onMonthChange($event)"
>
diff --git a/Development/client/src/app/shared/date-range-control/date-range-control.component.scss b/Development/client/src/app/shared/date-range-control/date-range-control.component.scss
index 5d6686d..abe57dc 100644
--- a/Development/client/src/app/shared/date-range-control/date-range-control.component.scss
+++ b/Development/client/src/app/shared/date-range-control/date-range-control.component.scss
@@ -14,4 +14,4 @@
color: #1a211c;
letter-spacing: 0.02em;
text-transform: uppercase;
-}
+}
\ No newline at end of file
diff --git a/Development/client/src/app/shared/date-range-control/date-range-control.component.ts b/Development/client/src/app/shared/date-range-control/date-range-control.component.ts
index 0463abb..64b24e5 100644
--- a/Development/client/src/app/shared/date-range-control/date-range-control.component.ts
+++ b/Development/client/src/app/shared/date-range-control/date-range-control.component.ts
@@ -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 { DateUtils } from '../utils';
@@ -12,7 +12,7 @@ export interface DateRangeSelection {
templateUrl: './date-range-control.component.html',
styleUrls: ['./date-range-control.component.scss']
})
-export class DateRangeControlComponent implements OnInit {
+export class DateRangeControlComponent implements OnInit, AfterViewInit, OnDestroy {
@Output() rangeChange = new EventEmitter();
readonly maxRangeDays = 90;
@@ -21,12 +21,104 @@ export class DateRangeControlComponent implements OnInit {
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 {
const defaultRange = this.getCurrentWeekRange();
this.dateRangeControl.setValue(defaultRange);
this.emitIfRangeComplete(defaultRange);
}
+ ngAfterViewInit(): void {
+ // The calendar panel is appended to (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 ; 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 {
if (!Array.isArray(range) || range.length < 2 || !range[0] || !range[1]) {
return;
diff --git a/Development/client/src/app/shared/utils.ts b/Development/client/src/app/shared/utils.ts
index 08e7696..988646c 100644
--- a/Development/client/src/app/shared/utils.ts
+++ b/Development/client/src/app/shared/utils.ts
@@ -739,6 +739,11 @@ export class DateUtils {
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) {
if (!ms) return "00:00:00.0";
let secs, min, hrs;
diff --git a/Development/client/src/styles.scss b/Development/client/src/styles.scss
index be6410c..a0166d2 100644
--- a/Development/client/src/styles.scss
+++ b/Development/client/src/styles.scss
@@ -1966,6 +1966,8 @@ body .ui-datepicker.week-picker .ui-datepicker-calendar tbody tr:hover td:last-c
border-radius: 0 50% 50% 0;
}
+
+
// Form calendar internals — PrimeNG v9 renders p-calendar > span.ui-calendar.ui-calendar-w-btn > [input + button]
body span.form-calendar.ui-calendar,
body .form-calendar.ui-calendar,