Skip to content

Commit b1be269

Browse files
author
Daniel Medin
committed
weeks in api
1 parent 174a405 commit b1be269

2 files changed

Lines changed: 340 additions & 1 deletion

File tree

BitStoreWeb/BitStoreWeb.Net9/Controllers/BucketApiController.cs

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.ComponentModel.DataAnnotations;
2+
using System.Globalization;
23
using System.Linq.Expressions;
34
using System.Security.Claims;
45
using 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; }

BitStoreWeb/BitStoreWeb.Net9/Views/HowTo/Index.cshtml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,17 @@
6060
<span class="tok-comment"># Created date range. fromUtc is inclusive; toUtc is exclusive.</span>
6161
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?fromUtc=2026-05-11&amp;toUtc=2026-05-18"</span>
6262

63+
<span class="tok-comment"># Calendar filters. Defaults to UTC; add timeZone for local calendar days/weeks/months.</span>
64+
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?date=2026-05-11&amp;timeZone=Europe/Stockholm"</span>
65+
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?fromDate=2026-05-11&amp;toDate=2026-05-18&amp;timeZone=Europe/Stockholm"</span>
66+
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?week=2026-W21&amp;timeZone=Europe/Stockholm"</span>
67+
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?month=2026-05&amp;timeZone=Europe/Stockholm"</span>
68+
6369
<span class="tok-comment"># Value filters for app-encoded keys like g4c:1200</span>
6470
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?valuePrefix=g4c:"</span>
6571
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?valuePrefixes=g4c:,g4d:,g4e:,g4f:,g4g:,g4h:,g4i:"</span>
6672
<span class="tok-command">curl</span> <span class="tok-string">"@baseUrl/api/buckets/your-slug/records?valuePrefixFrom=g4c:&amp;valuePrefixTo=g4i:"</span></code></pre>
67-
<p class="small text-muted mt-2 mb-0">Record list responses include <code>hasMore</code> and <code>nextCursor</code>. Extra filters include <code>beforeUtc</code>, <code>updatedFromUtc</code>, <code>updatedToUtc</code>, <code>value</code>, and <code>valueContains</code>.</p>
73+
<p class="small text-muted mt-2 mb-0">Record list responses include <code>hasMore</code> and <code>nextCursor</code>. Extra filters include <code>beforeUtc</code>, <code>updatedFromUtc</code>, <code>updatedToUtc</code>, <code>date</code>, <code>fromDate</code>, <code>toDate</code>, <code>week</code>, <code>month</code>, <code>timeZone</code>, <code>value</code>, and <code>valueContains</code>. Calendar <code>toDate</code> includes that whole local day.</p>
6874
</div>
6975
</div>
7076

0 commit comments

Comments
 (0)