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 @@

{{ areaLabel }}

- +
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,