Skip to content

Commit c92967a

Browse files
guguclaude
andcommitted
Add preset time intervals and custom comparator to datetime filter
Enhance DateTimeFilterComponent to be a self-contained "smart" filter with preset intervals (last hour/day/week/month/year) and custom comparator selection (equal/after/before/on or after/on or before), following the pattern established by email and phone filter components. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1ef4082 commit c92967a

4 files changed

Lines changed: 210 additions & 33 deletions

File tree

frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
.filter-row {
2+
display: flex;
3+
gap: 8px;
4+
align-items: flex-start;
5+
width: 100%;
6+
}
7+
8+
.comparator-field {
9+
flex: 0 0 auto;
10+
min-width: 150px;
11+
}
12+
13+
.value-field {
14+
flex: 1;
15+
}
16+
117
.field-couple {
218
display: grid;
319
grid-template-columns: 1fr 1fr;
Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
<div class="field-couple">
2-
<mat-form-field class="" appearance="outline">
3-
<mat-label>{{normalizedLabel()}} (date)</mat-label>
4-
<input type="date" matInput name="{{label()}}-{{key()}}-date"
5-
#inputElement
6-
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
7-
attr.data-testid="record-{{label()}}-date"
8-
[(ngModel)]="date" (change)="onDateChange()">
1+
<div class="filter-row">
2+
<mat-form-field class="comparator-field" appearance="outline">
3+
<mat-select [(ngModel)]="filterMode" (ngModelChange)="onFilterModeChange($event)">
4+
<mat-option value="last_hour">last hour</mat-option>
5+
<mat-option value="last_day">last day</mat-option>
6+
<mat-option value="last_week">last week</mat-option>
7+
<mat-option value="last_month">last month</mat-option>
8+
<mat-option value="last_year">last year</mat-option>
9+
<mat-option value="eq">equal</mat-option>
10+
<mat-option value="gt">after</mat-option>
11+
<mat-option value="lt">before</mat-option>
12+
<mat-option value="gte">on or after</mat-option>
13+
<mat-option value="lte">on or before</mat-option>
14+
</mat-select>
915
</mat-form-field>
1016

11-
<mat-form-field class="" appearance="outline">
12-
<mat-label>{{normalizedLabel()}} (time)</mat-label>
13-
<input type="time" matInput name="{{label()}}-{{key()}}-time"
14-
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
15-
attr.data-testid="record-{{label()}}-time"
16-
[(ngModel)]="time" (change)="onTimeChange()">
17-
</mat-form-field>
17+
@if (!isPresetMode()) {
18+
<div class="value-field field-couple">
19+
<mat-form-field appearance="outline">
20+
<mat-label>{{normalizedLabel()}} (date)</mat-label>
21+
<input type="date" matInput name="{{label()}}-{{key()}}-date"
22+
#inputElement
23+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
24+
attr.data-testid="record-{{label()}}-date"
25+
[(ngModel)]="date" (change)="onDateChange()">
26+
</mat-form-field>
27+
28+
<mat-form-field appearance="outline">
29+
<mat-label>{{normalizedLabel()}} (time)</mat-label>
30+
<input type="time" matInput name="{{label()}}-{{key()}}-time"
31+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
32+
attr.data-testid="record-{{label()}}-time"
33+
[(ngModel)]="time" (change)="onTimeChange()">
34+
</mat-form-field>
35+
</div>
36+
}
1837
</div>

frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,116 @@ describe('DateTimeFilterComponent', () => {
1515
beforeEach(() => {
1616
fixture = TestBed.createComponent(DateTimeFilterComponent);
1717
component = fixture.componentInstance;
18-
fixture.detectChanges();
1918
});
2019

2120
it('should create', () => {
21+
fixture.detectChanges();
2222
expect(component).toBeTruthy();
2323
});
2424

25-
it('should prepare date and time for date and time inputs', () => {
25+
it('should default to last_day filter mode', () => {
26+
expect(component.filterMode).toEqual('last_day');
27+
});
28+
29+
it('should prepare date and time for date and time inputs when value is provided', () => {
2630
component.value = '2021-06-26T07:22:00.603';
2731
component.ngOnInit();
2832

2933
expect(component.date).toEqual('2021-06-26');
3034
expect(component.time).toEqual('07:22:00');
35+
expect(component.filterMode).toEqual('gte');
3136
});
3237

3338
it('should send onChange event with new date value', () => {
39+
component.filterMode = 'gte';
3440
component.date = '2021-08-26';
3541
component.time = '07:22:00';
36-
const event = vi.spyOn(component.onFieldChange, 'emit');
42+
const fieldEvent = vi.spyOn(component.onFieldChange, 'emit');
43+
const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit');
3744
component.onDateChange();
3845

39-
expect(event).toHaveBeenCalledWith('2021-08-26T07:22:00Z');
46+
expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T07:22:00Z');
47+
expect(comparatorEvent).toHaveBeenCalledWith('gte');
4048
});
4149

4250
it('should send onChange event with new time value', () => {
51+
component.filterMode = 'lt';
4352
component.date = '2021-07-26';
4453
component.time = '07:20:00';
45-
const event = vi.spyOn(component.onFieldChange, 'emit');
54+
const fieldEvent = vi.spyOn(component.onFieldChange, 'emit');
55+
const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit');
4656
component.onTimeChange();
4757

48-
expect(event).toHaveBeenCalledWith('2021-07-26T07:20:00Z');
58+
expect(fieldEvent).toHaveBeenCalledWith('2021-07-26T07:20:00Z');
59+
expect(comparatorEvent).toHaveBeenCalledWith('lt');
60+
});
61+
62+
it('should identify preset modes correctly', () => {
63+
component.filterMode = 'last_hour';
64+
expect(component.isPresetMode()).toBe(true);
65+
66+
component.filterMode = 'last_day';
67+
expect(component.isPresetMode()).toBe(true);
68+
69+
component.filterMode = 'last_week';
70+
expect(component.isPresetMode()).toBe(true);
71+
72+
component.filterMode = 'last_month';
73+
expect(component.isPresetMode()).toBe(true);
74+
75+
component.filterMode = 'last_year';
76+
expect(component.isPresetMode()).toBe(true);
77+
78+
component.filterMode = 'eq';
79+
expect(component.isPresetMode()).toBe(false);
80+
81+
component.filterMode = 'gt';
82+
expect(component.isPresetMode()).toBe(false);
83+
});
84+
85+
it('should emit gte comparator and computed value for preset modes', () => {
86+
const fieldEvent = vi.spyOn(component.onFieldChange, 'emit');
87+
const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit');
88+
89+
component.onFilterModeChange('last_hour');
90+
91+
expect(comparatorEvent).toHaveBeenCalledWith('gte');
92+
expect(fieldEvent).toHaveBeenCalled();
93+
const emittedValue = fieldEvent.mock.calls[0][0] as string;
94+
expect(emittedValue).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
95+
});
96+
97+
it('should emit correct comparator for custom modes', () => {
98+
const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit');
99+
100+
component.onFilterModeChange('gt');
101+
expect(comparatorEvent).toHaveBeenCalledWith('gt');
102+
103+
component.onFilterModeChange('lt');
104+
expect(comparatorEvent).toHaveBeenCalledWith('lt');
105+
106+
component.onFilterModeChange('eq');
107+
expect(comparatorEvent).toHaveBeenCalledWith('eq');
108+
});
109+
110+
it('should emit datetime value when switching to custom mode with existing date', () => {
111+
component.date = '2021-08-26';
112+
component.time = '10:00:00';
113+
const fieldEvent = vi.spyOn(component.onFieldChange, 'emit');
114+
115+
component.onFilterModeChange('gt');
116+
117+
expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T10:00:00Z');
118+
});
119+
120+
it('should default time to 00:00 on date change if time is not set', () => {
121+
component.filterMode = 'eq';
122+
component.date = '2021-08-26';
123+
component.time = undefined;
124+
const fieldEvent = vi.spyOn(component.onFieldChange, 'emit');
125+
component.onDateChange();
126+
127+
expect(component.time).toEqual('00:00');
128+
expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T00:00Z');
49129
});
50130
});
Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,113 @@
11
import { CommonModule } from '@angular/common';
2-
import { AfterViewInit, Component, ElementRef, Input, output, ViewChild } from '@angular/core';
2+
import { AfterViewInit, Component, ElementRef, Input, OnInit, output, ViewChild } from '@angular/core';
33
import { FormsModule } from '@angular/forms';
44
import { MatFormFieldModule } from '@angular/material/form-field';
55
import { MatInputModule } from '@angular/material/input';
6-
import { format } from 'date-fns';
6+
import { MatSelectModule } from '@angular/material/select';
7+
import { format, subDays, subHours, subMonths, subYears } from 'date-fns';
78
import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component';
89

910
@Component({
1011
selector: 'app-filter-date-time',
1112
templateUrl: './date-time.component.html',
1213
styleUrls: ['./date-time.component.css'],
13-
imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule],
14+
imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule],
1415
})
15-
export class DateTimeFilterComponent extends BaseFilterFieldComponent implements AfterViewInit {
16+
export class DateTimeFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit {
1617
@Input() value: string;
1718
@ViewChild('inputElement') inputElement: ElementRef<HTMLInputElement>;
1819

1920
override readonly onFieldChange = output<any>();
2021

21-
static type = 'datetime';
22+
public filterMode: string = 'last_day';
2223
public date: string;
2324
public time: string;
2425

26+
private _presetModes = ['last_hour', 'last_day', 'last_week', 'last_month', 'last_year'];
27+
2528
ngOnInit(): void {
2629
if (this.value) {
2730
const datetime = new Date(this.value);
2831
this.date = format(datetime, 'yyyy-MM-dd');
2932
this.time = format(datetime, 'HH:mm:ss');
33+
this.filterMode = 'gte';
3034
}
3135
}
3236

33-
onDateChange() {
37+
ngAfterViewInit(): void {
38+
if (this.value) {
39+
this.onComparatorChange.emit(this.filterMode);
40+
} else {
41+
const value = this._computePresetValue(this.filterMode);
42+
this.onFieldChange.emit(value);
43+
this.onComparatorChange.emit('gte');
44+
}
45+
46+
if (this.autofocus() && this.inputElement) {
47+
setTimeout(() => {
48+
this.inputElement.nativeElement.focus();
49+
}, 100);
50+
}
51+
}
52+
53+
onFilterModeChange(mode: string): void {
54+
this.filterMode = mode;
55+
56+
if (this._presetModes.includes(mode)) {
57+
const value = this._computePresetValue(mode);
58+
this.onFieldChange.emit(value);
59+
this.onComparatorChange.emit('gte');
60+
} else {
61+
this.onComparatorChange.emit(mode);
62+
if (this.date) {
63+
const time = this.time || '00:00';
64+
const datetime = `${this.date}T${time}Z`;
65+
this.onFieldChange.emit(datetime);
66+
}
67+
}
68+
}
69+
70+
onDateChange(): void {
3471
if (!this.time) this.time = '00:00';
3572
const datetime = `${this.date}T${this.time}Z`;
3673
this.onFieldChange.emit(datetime);
74+
this.onComparatorChange.emit(this.filterMode);
3775
}
3876

39-
onTimeChange() {
77+
onTimeChange(): void {
4078
const datetime = `${this.date}T${this.time}Z`;
4179
this.onFieldChange.emit(datetime);
80+
this.onComparatorChange.emit(this.filterMode);
4281
}
4382

44-
ngAfterViewInit(): void {
45-
if (this.autofocus() && this.inputElement) {
46-
setTimeout(() => {
47-
this.inputElement.nativeElement.focus();
48-
}, 100);
83+
isPresetMode(): boolean {
84+
return this._presetModes.includes(this.filterMode);
85+
}
86+
87+
private _computePresetValue(mode: string): string {
88+
const now = new Date();
89+
let targetDate: Date;
90+
91+
switch (mode) {
92+
case 'last_hour':
93+
targetDate = subHours(now, 1);
94+
break;
95+
case 'last_day':
96+
targetDate = subDays(now, 1);
97+
break;
98+
case 'last_week':
99+
targetDate = subDays(now, 7);
100+
break;
101+
case 'last_month':
102+
targetDate = subMonths(now, 1);
103+
break;
104+
case 'last_year':
105+
targetDate = subYears(now, 1);
106+
break;
107+
default:
108+
targetDate = now;
49109
}
110+
111+
return format(targetDate, "yyyy-MM-dd'T'HH:mm:ss'Z'");
50112
}
51113
}

0 commit comments

Comments
 (0)