11using System . ComponentModel . DataAnnotations ;
2+ using System . Globalization ;
23using System . Linq . Expressions ;
34using System . Security . Claims ;
45using System . Text ;
@@ -95,6 +96,12 @@ public async Task<IActionResult> ListRecords(
9596 string slug ,
9697 [ FromQuery ] int take = 50 ,
9798 [ FromQuery ] string ? cursor = null ,
99+ [ FromQuery ] string ? date = null ,
100+ [ FromQuery ] string ? fromDate = null ,
101+ [ FromQuery ] string ? toDate = null ,
102+ [ FromQuery ] string ? week = null ,
103+ [ FromQuery ] string ? month = null ,
104+ [ FromQuery ] string ? timeZone = null ,
98105 [ FromQuery ] DateTime ? fromUtc = null ,
99106 [ FromQuery ] DateTime ? toUtc = null ,
100107 [ FromQuery ] DateTime ? beforeUtc = null ,
@@ -112,6 +119,12 @@ public async Task<IActionResult> ListRecords(
112119 {
113120 Take = take ,
114121 Cursor = cursor ,
122+ Date = date ,
123+ FromDate = fromDate ,
124+ ToDate = toDate ,
125+ Week = week ,
126+ Month = month ,
127+ TimeZone = timeZone ,
115128 FromUtc = fromUtc ,
116129 ToUtc = toUtc ,
117130 BeforeUtc = beforeUtc ,
@@ -148,10 +161,25 @@ public async Task<IActionResult> ListRecords(
148161 return BadRequest ( new { message = cursorError } ) ;
149162 }
150163
164+ if ( ! TryBuildCalendarCreatedUtcRange ( query , out var calendarFromUtc , out var calendarToUtc , out var calendarError ) )
165+ {
166+ return BadRequest ( new { message = calendarError } ) ;
167+ }
168+
151169 var recordsQuery = _db . BucketRecords
152170 . AsNoTracking ( )
153171 . Where ( x => x . BucketId == bucket . Id ) ;
154172
173+ if ( calendarFromUtc . HasValue )
174+ {
175+ recordsQuery = recordsQuery . Where ( x => x . CreatedUtc >= calendarFromUtc . Value ) ;
176+ }
177+
178+ if ( calendarToUtc . HasValue )
179+ {
180+ recordsQuery = recordsQuery . Where ( x => x . CreatedUtc < calendarToUtc . Value ) ;
181+ }
182+
155183 if ( query . FromUtc . HasValue )
156184 {
157185 var normalizedFromUtc = NormalizeUtc ( query . FromUtc . Value ) ;
@@ -561,6 +589,299 @@ private static DateTime NormalizeUtc(DateTime value)
561589 _ => DateTime . SpecifyKind ( value , DateTimeKind . Utc )
562590 } ;
563591
592+ private static bool TryBuildCalendarCreatedUtcRange (
593+ ListBucketRecordsQuery query ,
594+ out DateTime ? fromUtc ,
595+ out DateTime ? toUtc ,
596+ out string errorMessage )
597+ {
598+ fromUtc = null ;
599+ toUtc = null ;
600+ errorMessage = string . Empty ;
601+
602+ var hasDate = ! string . IsNullOrWhiteSpace ( query . Date ) ;
603+ var hasDateRange = ! string . IsNullOrWhiteSpace ( query . FromDate ) || ! string . IsNullOrWhiteSpace ( query . ToDate ) ;
604+ var hasWeek = ! string . IsNullOrWhiteSpace ( query . Week ) ;
605+ var hasMonth = ! string . IsNullOrWhiteSpace ( query . Month ) ;
606+ var calendarFilterCount = Convert . ToInt32 ( hasDate )
607+ + Convert . ToInt32 ( hasDateRange )
608+ + Convert . ToInt32 ( hasWeek )
609+ + Convert . ToInt32 ( hasMonth ) ;
610+
611+ if ( calendarFilterCount == 0 )
612+ {
613+ return true ;
614+ }
615+
616+ if ( calendarFilterCount > 1 )
617+ {
618+ errorMessage = "Use only one calendar filter: date, fromDate/toDate, week, or month." ;
619+ return false ;
620+ }
621+
622+ if ( ! TryFindTimeZone ( query . TimeZone , out var timeZone , out errorMessage ) )
623+ {
624+ return false ;
625+ }
626+
627+ if ( hasDate )
628+ {
629+ if ( ! TryParseDate ( query . Date , "date" , out var date , out errorMessage ) )
630+ {
631+ return false ;
632+ }
633+
634+ if ( ! TryAddDays ( date , 1 , "date" , out var nextDate , out errorMessage ) )
635+ {
636+ return false ;
637+ }
638+
639+ return TryConvertLocalRangeToUtc ( date , nextDate , timeZone , out fromUtc , out toUtc , out errorMessage ) ;
640+ }
641+
642+ if ( hasDateRange )
643+ {
644+ DateOnly ? fromDate = null ;
645+ DateOnly ? toDate = null ;
646+
647+ if ( ! string . IsNullOrWhiteSpace ( query . FromDate ) )
648+ {
649+ if ( ! TryParseDate ( query . FromDate , "fromDate" , out var parsedFromDate , out errorMessage ) )
650+ {
651+ return false ;
652+ }
653+
654+ fromDate = parsedFromDate ;
655+ }
656+
657+ if ( ! string . IsNullOrWhiteSpace ( query . ToDate ) )
658+ {
659+ if ( ! TryParseDate ( query . ToDate , "toDate" , out var parsedToDate , out errorMessage ) )
660+ {
661+ return false ;
662+ }
663+
664+ toDate = parsedToDate ;
665+ }
666+
667+ if ( fromDate . HasValue && toDate . HasValue && fromDate . Value > toDate . Value )
668+ {
669+ errorMessage = "fromDate must be before or equal to toDate." ;
670+ return false ;
671+ }
672+
673+ if ( fromDate . HasValue &&
674+ ! TryConvertLocalDateBoundaryToUtc ( fromDate . Value , timeZone , out fromUtc , out errorMessage ) )
675+ {
676+ return false ;
677+ }
678+
679+ if ( toDate . HasValue &&
680+ ( ! TryAddDays ( toDate . Value , 1 , "toDate" , out var nextToDate , out errorMessage ) ||
681+ ! TryConvertLocalDateBoundaryToUtc ( nextToDate , timeZone , out toUtc , out errorMessage ) ) )
682+ {
683+ return false ;
684+ }
685+
686+ return true ;
687+ }
688+
689+ if ( hasWeek )
690+ {
691+ if ( ! TryParseIsoWeek ( query . Week , out var weekStart , out errorMessage ) )
692+ {
693+ return false ;
694+ }
695+
696+ if ( ! TryAddDays ( weekStart , 7 , "week" , out var weekEnd , out errorMessage ) )
697+ {
698+ return false ;
699+ }
700+
701+ return TryConvertLocalRangeToUtc ( weekStart , weekEnd , timeZone , out fromUtc , out toUtc , out errorMessage ) ;
702+ }
703+
704+ if ( ! TryParseMonth ( query . Month , out var monthStart , out errorMessage ) )
705+ {
706+ return false ;
707+ }
708+
709+ if ( ! TryAddMonths ( monthStart , 1 , "month" , out var nextMonth , out errorMessage ) )
710+ {
711+ return false ;
712+ }
713+
714+ return TryConvertLocalRangeToUtc ( monthStart , nextMonth , timeZone , out fromUtc , out toUtc , out errorMessage ) ;
715+ }
716+
717+ private static bool TryFindTimeZone ( string ? value , out TimeZoneInfo timeZone , out string errorMessage )
718+ {
719+ errorMessage = string . Empty ;
720+
721+ if ( string . IsNullOrWhiteSpace ( value ) )
722+ {
723+ timeZone = TimeZoneInfo . Utc ;
724+ return true ;
725+ }
726+
727+ try
728+ {
729+ timeZone = TimeZoneInfo . FindSystemTimeZoneById ( value . Trim ( ) ) ;
730+ return true ;
731+ }
732+ catch ( TimeZoneNotFoundException )
733+ {
734+ }
735+ catch ( InvalidTimeZoneException )
736+ {
737+ }
738+
739+ timeZone = TimeZoneInfo . Utc ;
740+ errorMessage = $ "Unknown timeZone '{ value } '. Use a system time zone ID such as 'UTC' or 'Europe/Stockholm'.";
741+ return false ;
742+ }
743+
744+ private static bool TryParseDate ( string ? value , string parameterName , out DateOnly date , out string errorMessage )
745+ {
746+ if ( DateOnly . TryParseExact ( value , "yyyy-MM-dd" , CultureInfo . InvariantCulture , DateTimeStyles . None , out date ) )
747+ {
748+ errorMessage = string . Empty ;
749+ return true ;
750+ }
751+
752+ errorMessage = $ "{ parameterName } must use yyyy-MM-dd.";
753+ return false ;
754+ }
755+
756+ private static bool TryParseIsoWeek ( string ? value , out DateOnly weekStart , out string errorMessage )
757+ {
758+ weekStart = default ;
759+ var parts = value ? . Trim ( ) . Split ( '-' , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries ) ;
760+ if ( parts is null ||
761+ parts . Length != 2 ||
762+ ! int . TryParse ( parts [ 0 ] , NumberStyles . None , CultureInfo . InvariantCulture , out var year ) ||
763+ year is < 1 or > 9999 )
764+ {
765+ errorMessage = "week must use yyyy-Www, for example 2026-W21." ;
766+ return false ;
767+ }
768+
769+ var weekPart = parts [ 1 ] . StartsWith ( 'W' ) || parts [ 1 ] . StartsWith ( 'w' )
770+ ? parts [ 1 ] [ 1 ..]
771+ : parts [ 1 ] ;
772+ if ( ! int . TryParse ( weekPart , NumberStyles . None , CultureInfo . InvariantCulture , out var week ) ||
773+ week < 1 ||
774+ week > ISOWeek . GetWeeksInYear ( year ) )
775+ {
776+ errorMessage = "week must use a valid ISO week, for example 2026-W21." ;
777+ return false ;
778+ }
779+
780+ weekStart = DateOnly . FromDateTime ( ISOWeek . ToDateTime ( year , week , DayOfWeek . Monday ) ) ;
781+ errorMessage = string . Empty ;
782+ return true ;
783+ }
784+
785+ private static bool TryParseMonth ( string ? value , out DateOnly monthStart , out string errorMessage )
786+ {
787+ monthStart = default ;
788+ var parts = value ? . Trim ( ) . Split ( '-' , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries ) ;
789+ if ( parts is not [ var yearPart , var monthPart ] ||
790+ ! int . TryParse ( yearPart , NumberStyles . None , CultureInfo . InvariantCulture , out var year ) ||
791+ year is < 1 or > 9999 ||
792+ ! int . TryParse ( monthPart , NumberStyles . None , CultureInfo . InvariantCulture , out var month ) ||
793+ month is < 1 or > 12 )
794+ {
795+ errorMessage = "month must use yyyy-MM." ;
796+ return false ;
797+ }
798+
799+ monthStart = new DateOnly ( year , month , 1 ) ;
800+ errorMessage = string . Empty ;
801+ return true ;
802+ }
803+
804+ private static bool TryAddDays (
805+ DateOnly date ,
806+ int days ,
807+ string parameterName ,
808+ out DateOnly result ,
809+ out string errorMessage )
810+ {
811+ try
812+ {
813+ result = date . AddDays ( days ) ;
814+ errorMessage = string . Empty ;
815+ return true ;
816+ }
817+ catch ( ArgumentOutOfRangeException )
818+ {
819+ result = default ;
820+ errorMessage = $ "{ parameterName } is outside the supported date range.";
821+ return false ;
822+ }
823+ }
824+
825+ private static bool TryAddMonths (
826+ DateOnly date ,
827+ int months ,
828+ string parameterName ,
829+ out DateOnly result ,
830+ out string errorMessage )
831+ {
832+ try
833+ {
834+ result = date . AddMonths ( months ) ;
835+ errorMessage = string . Empty ;
836+ return true ;
837+ }
838+ catch ( ArgumentOutOfRangeException )
839+ {
840+ result = default ;
841+ errorMessage = $ "{ parameterName } is outside the supported date range.";
842+ return false ;
843+ }
844+ }
845+
846+ private static bool TryConvertLocalRangeToUtc (
847+ DateOnly startDate ,
848+ DateOnly endDate ,
849+ TimeZoneInfo timeZone ,
850+ out DateTime ? fromUtc ,
851+ out DateTime ? toUtc ,
852+ out string errorMessage )
853+ {
854+ if ( ! TryConvertLocalDateBoundaryToUtc ( startDate , timeZone , out fromUtc , out errorMessage ) )
855+ {
856+ toUtc = null ;
857+ return false ;
858+ }
859+
860+ return TryConvertLocalDateBoundaryToUtc ( endDate , timeZone , out toUtc , out errorMessage ) ;
861+ }
862+
863+ private static bool TryConvertLocalDateBoundaryToUtc (
864+ DateOnly date ,
865+ TimeZoneInfo timeZone ,
866+ out DateTime ? utc ,
867+ out string errorMessage )
868+ {
869+ var localBoundary = DateTime . SpecifyKind ( date . ToDateTime ( TimeOnly . MinValue ) , DateTimeKind . Unspecified ) ;
870+
871+ try
872+ {
873+ utc = TimeZoneInfo . ConvertTimeToUtc ( localBoundary , timeZone ) ;
874+ errorMessage = string . Empty ;
875+ return true ;
876+ }
877+ catch ( ArgumentException )
878+ {
879+ utc = null ;
880+ errorMessage = $ "The date boundary { date : yyyy-MM-dd} is invalid in timeZone '{ timeZone . Id } '.";
881+ return false ;
882+ }
883+ }
884+
564885 private static List < string > NormalizeValuePrefixes ( string ? valuePrefix , string [ ] ? valuePrefixes )
565886 {
566887 var prefixes = new List < string > ( ) ;
@@ -691,6 +1012,18 @@ public sealed class ListBucketRecordsQuery
6911012
6921013 public string ? Cursor { get ; set ; }
6931014
1015+ public string ? Date { get ; set ; }
1016+
1017+ public string ? FromDate { get ; set ; }
1018+
1019+ public string ? ToDate { get ; set ; }
1020+
1021+ public string ? Week { get ; set ; }
1022+
1023+ public string ? Month { get ; set ; }
1024+
1025+ public string ? TimeZone { get ; set ; }
1026+
6941027 public DateTime ? FromUtc { get ; set ; }
6951028
6961029 public DateTime ? ToUtc { get ; set ; }
0 commit comments