diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts index 7afac7a26..dfd623835 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DateDisplayComponent } from './date.component'; import { format } from 'date-fns'; +import { DateDisplayComponent } from './date.component'; describe('DateDisplayComponent', () => { let component: DateDisplayComponent; @@ -40,9 +40,8 @@ describe('DateDisplayComponent', () => { expect(component.formattedDate).toBeUndefined(); }); - it('should show relative date when formatDistanceWithinHours configured', () => { - const now = new Date(); - const recentDate = new Date(now.getTime() - 1000 * 60 * 60); // 1 hour ago + it('should display "today" instead of relative time for same-day date', () => { + const recentDate = new Date(Date.now() - 1000 * 60); // 1 minute ago — same calendar day fixture.componentRef.setInput('value', recentDate.toISOString()); fixture.componentRef.setInput('widgetStructure', { widget_params: { formatDistanceWithinHours: 48 }, @@ -50,6 +49,42 @@ describe('DateDisplayComponent', () => { component.ngOnInit(); fixture.detectChanges(); + expect(component.formattedDate).toBe('today'); + }); + + it('should show relative date for non-today date within formatDistanceWithinHours', () => { + const yesterdayNoon = new Date(); + yesterdayNoon.setDate(yesterdayNoon.getDate() - 1); + yesterdayNoon.setHours(12, 0, 0, 0); + fixture.componentRef.setInput('value', yesterdayNoon.toISOString()); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { formatDistanceWithinHours: 48 }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.formattedDate).toContain('ago'); }); + + it('should expose exact date as tooltip via fullDate', () => { + const dateStr = '2023-04-29'; + fixture.componentRef.setInput('value', dateStr); + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.fullDate).toBe(format(new Date(dateStr), 'PPP')); + }); + + it('should set fullDate tooltip even when displaying "today"', () => { + const recentDate = new Date(Date.now() - 1000 * 60); + fixture.componentRef.setInput('value', recentDate.toISOString()); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { formatDistanceWithinHours: 48 }, + }); + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.formattedDate).toBe('today'); + expect(component.fullDate).toBe(format(recentDate, 'PPP')); + }); }); diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts index 66b489073..9f30525be 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts @@ -1,71 +1,73 @@ -import { Component, OnInit } from '@angular/core'; -import { format, formatDistanceToNow, differenceInHours } from 'date-fns'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { differenceInHours, format, formatDistanceStrict, isToday, startOfToday } from 'date-fns'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; @Component({ - selector: 'app-date-display', - templateUrl: './date.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './date.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] + selector: 'app-date-display', + templateUrl: './date.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './date.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], }) export class DateDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'date'; + static type = 'date'; + + public formattedDate: string; + public formatDistanceWithinHours: number = 48; + public fullDate: string; + + ngOnInit(): void { + this.parseWidgetParams(); - public formattedDate: string; - public formatDistanceWithinHours: number = 48; - public fullDate: string; + if (this.value()) { + try { + const date = new Date(this.value()); + if (!Number.isNaN(date.getTime())) { + // Always store the full date format for tooltip + this.fullDate = format(date, 'PPP'); // e.g., "April 29th, 2023" - ngOnInit(): void { - this.parseWidgetParams(); - - if (this.value()) { - try { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - // Always store the full date format for tooltip - this.fullDate = format(date, "PPP"); // e.g., "April 29th, 2023" + // Check if formatDistanceWithinHours is enabled and date is within specified hours from now + if (this.formatDistanceWithinHours > 0 && this.isWithinHours(date, this.formatDistanceWithinHours)) { + this.formattedDate = isToday(date) + ? 'today' + : formatDistanceStrict(date, startOfToday(), { addSuffix: true }); + } else { + this.formattedDate = format(date, 'P'); + } + } else { + this.formattedDate = this.value(); + this.fullDate = this.value(); + } + } catch (_error) { + this.formattedDate = this.value(); + this.fullDate = this.value(); + } + } + } - // Check if formatDistanceWithinHours is enabled and date is within specified hours from now - if (this.formatDistanceWithinHours > 0 && this.isWithinHours(date, this.formatDistanceWithinHours)) { - this.formattedDate = formatDistanceToNow(date, { addSuffix: true }); - } else { - this.formattedDate = format(date, "P"); - } - } else { - this.formattedDate = this.value(); - this.fullDate = this.value(); - } - } catch (_error) { - this.formattedDate = this.value(); - this.fullDate = this.value(); - } - } - } + private parseWidgetParams(): void { + if (this.widgetStructure()?.widget_params) { + try { + const params = + typeof this.widgetStructure().widget_params === 'string' + ? JSON.parse(this.widgetStructure().widget_params as unknown as string) + : this.widgetStructure().widget_params; - private parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - if (params.formatDistanceWithinHours !== undefined) { - this.formatDistanceWithinHours = Number(params.formatDistanceWithinHours) || 48; - } - } catch (e) { - console.error('Error parsing date widget params:', e); - } - } - } + if (params.formatDistanceWithinHours !== undefined) { + this.formatDistanceWithinHours = Number(params.formatDistanceWithinHours) || 48; + } + } catch (e) { + console.error('Error parsing date widget params:', e); + } + } + } - private isWithinHours(date: Date, hours: number): boolean { - const now = new Date(); - const hoursDifference = Math.abs(differenceInHours(date, now)); - return hoursDifference <= hours; - } + private isWithinHours(date: Date, hours: number): boolean { + const now = new Date(); + const hoursDifference = Math.abs(differenceInHours(date, now)); + return hoursDifference <= hours; + } } diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts index b926c3793..295e9c41a 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts @@ -38,15 +38,54 @@ describe('SelectDisplayComponent', () => { it('should display raw value when no options match', () => { fixture.componentRef.setInput('value', 'unknown'); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { + options: [{ value: 'opt1', label: 'Option One' }], + }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe('unknown'); + }); + + it('should display option label when value is 0', () => { + fixture.componentRef.setInput('value', 0); fixture.componentRef.setInput('widgetStructure', { widget_params: { options: [ - { value: 'opt1', label: 'Option One' }, + { value: 0, label: 'Zero' }, + { value: 1, label: 'One' }, ], }, }); component.ngOnInit(); fixture.detectChanges(); - expect(component.displayValue).toBe('unknown'); + expect(component.displayValue).toBe('Zero'); + }); + + it('should display em dash when value is null', () => { + fixture.componentRef.setInput('value', null); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe('—'); + }); + + it('should display em dash when value is undefined', () => { + fixture.componentRef.setInput('value', undefined); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe('—'); + }); + + it('should display empty string value as raw, not dash', () => { + fixture.componentRef.setInput('value', ''); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { + options: [{ value: 'opt1', label: 'Option One' }], + }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe(''); }); }); diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts index 461dea151..4e3a14fb5 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts @@ -1,43 +1,42 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; import { ClipboardModule } from '@angular/cdk/clipboard'; +import { Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; @Component({ - selector: 'app-select-display', - templateUrl: './select.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './select.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] + selector: 'app-select-display', + templateUrl: './select.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './select.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], }) export class SelectDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public displayValue: string; - public backgroundColor: string; + public displayValue: string; + public backgroundColor: string; - ngOnInit(): void { - this.setDisplayValue(); - } + ngOnInit(): void { + this.setDisplayValue(); + } - private setDisplayValue(): void { - if (!this.value()) { - this.displayValue = '—'; - return; - } + private setDisplayValue(): void { + if (this.value() == null) { + this.displayValue = '—'; + return; + } - if (this.widgetStructure()?.widget_params?.options) { - // Find the matching option based on value and use its label - const option = this.widgetStructure().widget_params.options.find( - (opt: { value: any, label: string }) => opt.value === this.value() - ); - this.displayValue = option ? option.label : this.value(); - this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; - } else if (this.structure()?.data_type_params) { - // If no widget structure but we have data_type_params, just use the value - this.displayValue = this.value(); - } else { - this.displayValue = this.value(); - } - } + if (this.widgetStructure()?.widget_params?.options) { + // Find the matching option based on value and use its label + const option = this.widgetStructure().widget_params.options.find( + (opt: { value: any; label: string }) => opt.value === this.value(), + ); + this.displayValue = option ? option.label : this.value(); + this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; + } else if (this.structure()?.data_type_params) { + // If no widget structure but we have data_type_params, just use the value + this.displayValue = this.value(); + } else { + this.displayValue = this.value(); + } + } }