Merge pull request 'Use Partman intervals, chart improvements' (#121) from partmanIntvl115 into trunk

Closes #115

Reviewed-on: #121
This commit is contained in:
Daniel Ponte 2025-02-22 18:02:04 -05:00
commit 8fd0c85c1e
21 changed files with 331 additions and 156 deletions

View file

@ -4,6 +4,7 @@ export interface CallRecord {
audioURL: string | null;
duration: number;
systemId: number;
talkerAlias: string | null;
tgid: number;
incidents: number; // in incident
}

View file

@ -168,6 +168,10 @@
>
</td>
</ng-container>
<ng-container matColumnDef="talker">
<th mat-header-cell *matHeaderCellDef>Source</th>
<td mat-cell *matCellDef="let call" [innerHTML]="call | talker"></td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
<td mat-cell *matCellDef="let call" class="duration">

View file

@ -1,7 +1,7 @@
import { Component, ElementRef, inject, ViewChild } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTable, MatTableModule } from '@angular/material/table';
import { MatTableModule } from '@angular/material/table';
import {
MatPaginator,
MatPaginatorModule,
@ -19,6 +19,7 @@ import {
DatePipe,
DownloadURLPipe,
FixedPointPipe,
TalkerPipe,
TalkgroupPipe,
TimePipe,
} from './calls.service';
@ -57,6 +58,7 @@ const reqPageSize = 200;
MatIconModule,
FixedPointPipe,
TalkgroupPipe,
TalkerPipe,
TimePipe,
DatePipe,
MatPaginatorModule,
@ -95,6 +97,7 @@ export class CallsComponent {
'system',
'group',
'talkgroup',
'talker',
'duration',
];
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };

View file

@ -27,6 +27,21 @@ export class DatePipe implements PipeTransform {
}
}
@Pipe({
name: 'talker',
standalone: true,
pure: true,
})
export class TalkerPipe implements PipeTransform {
transform(call: CallRecord, args?: any): string {
if (call.talkerAlias != null) {
return call.talkerAlias;
}
return '&mdash;';
}
}
@Pipe({
name: 'time',
standalone: true,

View file

@ -1 +1,2 @@
<div class="chart"></div>
<div class="spinner" *ngIf="loading"><mat-spinner></mat-spinner></div>

View file

@ -1,16 +1,18 @@
import { Component, ElementRef, Input } from '@angular/core';
import * as d3 from 'd3';
import { CallsService } from '../calls/calls.service';
import { CallStatsRecord } from '../calls';
import { Observable, switchMap } from 'rxjs';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { NgIf } from '@angular/common';
@Component({
selector: 'chart',
imports: [],
imports: [NgIf, MatProgressSpinnerModule],
templateUrl: './charts.component.html',
styleUrl: './charts.component.scss',
})
export class ChartsComponent {
@Input() interval!: string;
@Input() interval!: Observable<string>;
loading = true;
// I hate javascript so much
months = [
@ -32,8 +34,8 @@ export class ChartsComponent {
private callsSvc: CallsService,
) {}
dateFormat(d: Date): string {
switch (this.interval) {
dateFormat(d: Date, interval: string): string {
switch (interval) {
case 'month':
return `${this.months[d.getMonth()]} ${d.getFullYear()}`;
case 'day':
@ -45,7 +47,15 @@ export class ChartsComponent {
}
ngOnInit() {
this.callsSvc.getCallStats(this.interval).subscribe((stats) => {
this.interval
.pipe(
switchMap((intv) => {
d3.select(this.elementRef.nativeElement).select('.chart').html('');
this.loading = true;
return this.callsSvc.getCallStats(intv);
}),
)
.subscribe((stats) => {
let cMax = 0;
var cMin = 0;
let data = stats.stats.map((rec) => {
@ -58,12 +68,17 @@ export class ChartsComponent {
if (rec.count > cMax) {
cMax = rec.count;
}
return { count: rec.count, time: this.dateFormat(new Date(rec.time)) };
return {
count: rec.count,
time: this.dateFormat(new Date(rec.time), stats.interval),
};
});
// set the dimensions and margins of the graph
var margin = { top: 30, right: 30, bottom: 70, left: 60 },
width = 460 - margin.left - margin.right,
height = 400 - margin.top - margin.bottom;
// clear the old one
d3.select(this.elementRef.nativeElement).select('.chart').html('');
const svg = d3
.select(this.elementRef.nativeElement)
.select('.chart')
@ -71,7 +86,10 @@ export class ChartsComponent {
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
.attr(
'transform',
'translate(' + margin.left + ',' + margin.top + ')',
);
// X axis
var x = d3
.scaleBand()
@ -109,6 +127,7 @@ export class ChartsComponent {
.attr('fill', function (d) {
return d3.interpolateTurbo((d.count - cMin) / (cMax - cMin));
});
this.loading = false;
});
}
}

View file

@ -1,4 +1,14 @@
<mat-card class="chart" appearance="outlined">
<div class="chartTitle">Calls By Week</div>
<chart interval="week"></chart>
<form [formGroup]="form">
<mat-form-field>
<label>Calls By</label>
<mat-select formControlName="callsPer">
<mat-option value="hour">Hour</mat-option>
<mat-option value="day">Day</mat-option>
<mat-option value="week">Week</mat-option>
<mat-option value="month">Month</mat-option>
</mat-select>
</mat-form-field>
</form>
<chart #chart [interval]="callsOb"></chart>
</mat-card>

View file

@ -1,6 +1,6 @@
mat-card.chart {
width: 500px;
height: 400px;
height: 500px;
margin: 30px 30px 40px 40px;
}

View file

@ -1,11 +1,50 @@
import { Component } from '@angular/core';
import { Component, computed, signal, ViewChild } from '@angular/core';
import { ChartsComponent } from '../charts/charts.component';
import { MatCardModule } from '@angular/material/card';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
debounceTime,
filter,
Observable,
startWith,
switchAll,
switchMap,
} from 'rxjs';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'app-home',
imports: [ChartsComponent, MatCardModule],
imports: [
ChartsComponent,
MatCardModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
FormsModule,
ReactiveFormsModule,
],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {}
export class HomeComponent {
form = new FormGroup({
callsPer: new FormControl(),
});
callsOb!: Observable<string>;
ngOnInit() {
this.form.controls['callsPer'].setValue('week');
this.callsOb = this.form.controls['callsPer'].valueChanges.pipe(
startWith('week'),
debounceTime(300),
filter((val) => val !== null),
);
}
}

View file

@ -0,0 +1,84 @@
package common
import (
"time"
)
const (
DaysInWeek = 7
MonthsInQuarter = 3
)
type TimeBounder interface {
GetDailyBounds(date time.Time) (lowerBound, upperBound time.Time)
GetWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time)
GetMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time)
GetQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time)
GetYearlyBounds(date time.Time) (lowerBound, upperBound time.Time)
}
type tbOpt func(*timeBounder)
func WithLocation(l *time.Location) tbOpt {
return func(tb *timeBounder) {
tb.loc = l
}
}
func NewTimeBounder(opts ...tbOpt) timeBounder {
tb := timeBounder{}
for _, opt := range opts {
opt(&tb)
}
if tb.loc == nil {
tb.loc = time.UTC
}
return tb
}
type timeBounder struct {
loc *time.Location
}
func (tb timeBounder) GetDailyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tb.loc)
upperBound = lowerBound.AddDate(0, 0, 1)
return
}
func (tb timeBounder) GetWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, tb.loc).AddDate(0, 0, -int(date.Weekday()-time.Monday))
upperBound = lowerBound.AddDate(0, 0, DaysInWeek)
return
}
func (tb timeBounder) GetMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, tb.loc)
upperBound = lowerBound.AddDate(0, 1, 0)
return
}
func (tb *timeBounder) GetQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
year, _, _ := date.Date()
quarter := (int(date.Month()) - 1) / MonthsInQuarter
firstMonthOfTheQuarter := time.Month(quarter*MonthsInQuarter + 1)
lowerBound = time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, tb.loc)
upperBound = lowerBound.AddDate(0, MonthsInQuarter, 0)
return
}
func (tb timeBounder) GetYearlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), 1, 1, 0, 0, 0, 0, tb.loc)
upperBound = lowerBound.AddDate(1, 0, 0)
return
}

View file

@ -182,7 +182,7 @@ func (as *alerter) eval(ctx context.Context, now time.Time, testMode bool) ([]al
origScore := s.Score
tgr, err := as.tgCache.TG(ctx, s.ID)
if err != nil {
log.Error().Err(err).Msg("alerting eval tg get")
log.Debug().Str("tg", s.ID.String()).Err(err).Msg("alerting eval tg get")
continue
}

View file

@ -91,7 +91,17 @@ func (c *Call) GetResourceName() string {
}
func (c *Call) String() string {
return fmt.Sprintf("%s to %d from %d", c.AudioName, c.Talkgroup, c.Source)
var from string
switch {
case c.Source != 0 && c.TalkerAlias != nil:
from = fmt.Sprintf(" from %s (%d)", *c.TalkerAlias, c.Source)
case c.Source != 0:
from = fmt.Sprintf(" from %d", c.Source)
case c.TalkerAlias != nil:
from = fmt.Sprintf(" from %s", *c.TalkerAlias)
}
return fmt.Sprintf("%s to %d%s", c.AudioName, c.Talkgroup, from)
}
func (c *Call) ShouldStore() bool {

View file

@ -204,6 +204,7 @@ type CallsParams struct {
TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tgFilter"`
AtLeastSeconds *float32 `json:"atLeastSeconds"`
UnknownTG bool `json:"unknownTG"`
}
func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
@ -224,6 +225,7 @@ func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []databa
PerPage: perPage,
Direction: p.Direction.DirString(common.DirAsc),
TGFilter: p.TGFilter,
UnknownTG: p.UnknownTG,
}
if p.AtLeastSeconds != nil {
@ -245,6 +247,7 @@ func (s *postgresStore) Calls(ctx context.Context, p CallsParams) (rows []databa
TagsNot: par.TagsNot,
TGFilter: par.TGFilter,
LongerThan: par.LongerThan,
UnknownTG: par.UnknownTG,
})
if err != nil {
return err

View file

@ -319,6 +319,9 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN
) ELSE TRUE END) AND
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
c.duration > $6
) ELSE TRUE END) AND
(CASE WHEN $7::BOOLEAN = TRUE THEN (
tgs.tgid IS NULL
) ELSE TRUE END)
`
@ -329,6 +332,7 @@ type ListCallsCountParams struct {
TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tgFilter"`
LongerThan pgtype.Numeric `json:"longerThan"`
UnknownTG bool `json:"unknownTg"`
}
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
@ -339,6 +343,7 @@ func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams)
arg.TagsNot,
arg.TGFilter,
arg.LongerThan,
arg.UnknownTG,
)
var count int64
err := row.Scan(&count)
@ -374,13 +379,16 @@ CASE WHEN $4::TEXT[] IS NOT NULL THEN
) ELSE TRUE END) AND
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
c.duration > $6
) ELSE TRUE END) AND
(CASE WHEN $7::BOOLEAN = TRUE THEN (
tgs.tgid IS NULL
) ELSE TRUE END)
GROUP BY c.id, c.call_date
ORDER BY
CASE WHEN $7::TEXT = 'asc' THEN c.call_date END ASC,
CASE WHEN $7 = 'desc' THEN c.call_date END DESC
OFFSET $8 ROWS
FETCH NEXT $9 ROWS ONLY
CASE WHEN $8::TEXT = 'asc' THEN c.call_date END ASC,
CASE WHEN $8 = 'desc' THEN c.call_date END DESC
OFFSET $9 ROWS
FETCH NEXT $10 ROWS ONLY
`
type ListCallsPParams struct {
@ -390,6 +398,7 @@ type ListCallsPParams struct {
TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tgFilter"`
LongerThan pgtype.Numeric `json:"longerThan"`
UnknownTG bool `json:"unknownTg"`
Direction string `json:"direction"`
Offset int32 `json:"offset"`
PerPage int32 `json:"perPage"`
@ -413,6 +422,7 @@ func (q *Queries) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListC
arg.TagsNot,
arg.TGFilter,
arg.LongerThan,
arg.UnknownTG,
arg.Direction,
arg.Offset,
arg.PerPage,

View file

@ -3,66 +3,23 @@ package partman
import (
"fmt"
"time"
"dynatron.me/x/stillbox/internal/common"
)
const (
daysInWeek = 7
monthsInQuarter = 3
)
func getDailyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
upperBound = lowerBound.AddDate(0, 0, 1)
return
}
func getWeeklyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -int(date.Weekday()-time.Monday))
upperBound = lowerBound.AddDate(0, 0, daysInWeek)
return
}
func getMonthlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, time.UTC)
upperBound = lowerBound.AddDate(0, 1, 0)
return
}
func getQuarterlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
year, _, _ := date.Date()
quarter := (int(date.Month()) - 1) / monthsInQuarter
firstMonthOfTheQuarter := time.Month(quarter*monthsInQuarter + 1)
lowerBound = time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, time.UTC)
upperBound = lowerBound.AddDate(0, monthsInQuarter, 0)
return
}
func getYearlyBounds(date time.Time) (lowerBound, upperBound time.Time) {
lowerBound = time.Date(date.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
upperBound = lowerBound.AddDate(1, 0, 0)
return
}
func (p Partition) Next(i int) Partition {
var t time.Time
switch p.Interval {
case Daily:
t = p.Time.AddDate(0, 0, i)
case Weekly:
t = p.Time.AddDate(0, 0, i*daysInWeek)
t = p.Time.AddDate(0, 0, i*common.DaysInWeek)
case Monthly:
year, month, _ := p.Time.Date()
t = time.Date(year, month+time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
case Quarterly:
t = p.Time.AddDate(0, i*monthsInQuarter, 0)
t = p.Time.AddDate(0, i*common.MonthsInQuarter, 0)
case Yearly:
year, _, _ := p.Time.Date()
@ -125,13 +82,13 @@ func (p Partition) Prev(i int) Partition {
case Daily:
t = p.Time.AddDate(0, 0, -i)
case Weekly:
t = p.Time.AddDate(0, 0, -i*daysInWeek)
t = p.Time.AddDate(0, 0, -i*common.DaysInWeek)
case Monthly:
year, month, _ := p.Time.Date()
t = time.Date(year, month-time.Month(i), 1, 0, 0, 0, 0, p.Time.Location())
case Quarterly:
t = p.Time.AddDate(0, -i*monthsInQuarter, 0)
t = p.Time.AddDate(0, -i*common.MonthsInQuarter, 0)
case Yearly:
year, _, _ := p.Time.Date()
@ -150,3 +107,21 @@ func (p Partition) Prev(i int) Partition {
return pp
}
func (p Partition) Range() (time.Time, time.Time) {
b := common.NewTimeBounder()
switch p.Interval {
case Daily:
return b.GetDailyBounds(p.Time)
case Weekly:
return b.GetWeeklyBounds(p.Time)
case Monthly:
return b.GetMonthlyBounds(p.Time)
case Quarterly:
return b.GetQuarterlyBounds(p.Time)
case Yearly:
return b.GetYearlyBounds(p.Time)
}
panic("unknown interval!")
}

View file

@ -10,6 +10,7 @@ import (
"strings"
"time"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/isoweek"
"dynatron.me/x/stillbox/pkg/config"
"dynatron.me/x/stillbox/pkg/database"
@ -325,23 +326,6 @@ func (pm *partman) Check(ctx context.Context, now time.Time) error {
}, pgx.TxOptions{})
}
func (p Partition) Range() (time.Time, time.Time) {
switch p.Interval {
case Daily:
return getDailyBounds(p.Time)
case Weekly:
return getWeeklyBounds(p.Time)
case Monthly:
return getMonthlyBounds(p.Time)
case Quarterly:
return getQuarterlyBounds(p.Time)
case Yearly:
return getYearlyBounds(p.Time)
}
panic("unknown interval!")
}
func (p Partition) PartitionName() string {
return p.Name
}
@ -423,7 +407,7 @@ func (pm *partman) verifyPartName(pr database.PartitionResult) (p Partition, err
if quarterNum > 4 {
return p, PartitionError(pn, errors.New("invalid quarter"))
}
firstMonthOfTheQuarter := time.Month((quarterNum-1)*monthsInQuarter + 1)
firstMonthOfTheQuarter := time.Month((quarterNum-1)*common.MonthsInQuarter + 1)
parsed := time.Date(year, firstMonthOfTheQuarter, 1, 0, 0, 0, 0, time.UTC)
if parsed != p.Time {
return p, PartitionError(pn, ParsedIntvlErr{parsed: parsed, start: p.Time})

View file

@ -20,7 +20,7 @@ WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
c.call_date < $3 ELSE TRUE END
GROUP BY date
ORDER BY date DESC
`
@ -61,7 +61,7 @@ WHERE
CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= $2 ELSE TRUE END AND
CASE WHEN $3::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= $3 ELSE TRUE END
c.call_date < $3 ELSE TRUE END
GROUP BY 2, 3, 4
ORDER BY 4 DESC
`

View file

@ -229,8 +229,13 @@ func (ia *incidentsAPI) getCallsM3U(id ID, w http.ResponseWriter, r *http.Reques
return
}
var from string
if c.Source != 0 {
switch {
case c.Source != 0 && c.TalkerAlias != nil:
from = fmt.Sprintf(" from %s (%d)", *c.TalkerAlias, c.Source)
case c.Source != 0:
from = fmt.Sprintf(" from %d", c.Source)
case c.TalkerAlias != nil:
from = fmt.Sprintf(" from %s", *c.TalkerAlias)
}
callUrl.Path = urlRoot + c.ID.String()

View file

@ -5,6 +5,7 @@ import (
"time"
"dynatron.me/x/stillbox/internal/cache"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
"dynatron.me/x/stillbox/pkg/calls"
"dynatron.me/x/stillbox/pkg/calls/callstore"
@ -56,20 +57,25 @@ func (s *stats) GetCallStats(ctx context.Context, interval calls.StatsInterval)
var start time.Time
now := time.Now()
end := now
bnd := common.NewTimeBounder(common.WithLocation(now.Location()))
switch interval {
case calls.IntervalHour:
start = now.Add(-24 * time.Hour) // one day
case calls.IntervalDay:
start = now.Add(-7 * 24 * time.Hour) // one week
case calls.IntervalWeek:
start = now.Add(-30 * 24 * time.Hour) // one month
start, end = bnd.GetMonthlyBounds(now)
start, _ = bnd.GetWeeklyBounds(start)
_, end = bnd.GetWeeklyBounds(end)
case calls.IntervalMonth:
start = now.Add(-365 * 24 * time.Hour) // one year
default:
return nil, calls.ErrInvalidInterval
}
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(now))
st, err := s.cs.CallStats(ctx, interval, jsontypes.Time(start), jsontypes.Time(end))
if err != nil {
return nil, err
}

View file

@ -127,6 +127,9 @@ CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
) ELSE TRUE END) AND
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
c.duration > @longer_than
) ELSE TRUE END) AND
(CASE WHEN @unknown_tg::BOOLEAN = TRUE THEN (
tgs.tgid IS NULL
) ELSE TRUE END)
GROUP BY c.id, c.call_date
ORDER BY
@ -157,6 +160,9 @@ CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
) ELSE TRUE END) AND
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
c.duration > @longer_than
) ELSE TRUE END) AND
(CASE WHEN @unknown_tg::BOOLEAN = TRUE THEN (
tgs.tgid IS NULL
) ELSE TRUE END)
;

View file

@ -9,7 +9,7 @@ WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
c.call_date < sqlc.narg('end') ELSE TRUE END
GROUP BY 2, 3, 4
ORDER BY 4 DESC;
@ -22,6 +22,6 @@ WHERE
CASE WHEN sqlc.narg('start')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date >= @start ELSE TRUE END AND
CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
c.call_date <= sqlc.narg('end') ELSE TRUE END
c.call_date < sqlc.narg('end') ELSE TRUE END
GROUP BY date
ORDER BY date DESC;