Skip to content

Commit ec24d66

Browse files
author
Marcus Markiewicz
committed
feat: Implement conditional fetching for calendar entries and add related tests
1 parent 5de27cd commit ec24d66

5 files changed

Lines changed: 264 additions & 10 deletions

File tree

src/ComingUpNextTray/HoverWindow.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public void UpdateMeeting(CalendarEntry? meeting, DateTime now, string? overlayT
115115
this.Size = new Size(Math.Min(width, 420), height);
116116
}
117117

118-
/// <summary>
118+
/// <summary>
119119
/// Sets the background and foreground to match the specified colors.
120120
/// </summary>
121121
/// <param name="background">Background color.</param>

src/ComingUpNextTray/Services/CalendarService.cs

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,29 @@ internal sealed class CalendarService : IDisposable
1212
private static readonly char[] LineSplitSeparators = new[] { '\r', '\n' };
1313

1414
// HttpClient is intended to be reused; dispose only when this service is disposed (CA1001).
15-
private readonly HttpClient httpClient = new ();
15+
private readonly HttpClient httpClient;
16+
17+
// Lightweight change validators; we intentionally do NOT cache ICS content or parsed entries.
18+
private string? lastEtag;
19+
private DateTimeOffset? lastModified;
1620
private bool disposed;
1721

22+
/// <summary>Initializes a new instance of the <see cref="CalendarService"/> class.</summary>
23+
internal CalendarService()
24+
{
25+
this.httpClient = new HttpClient();
26+
}
27+
28+
/// <summary>Initializes a new instance of the <see cref="CalendarService"/> class using a custom <see cref="HttpMessageHandler"/>. Internal for test injection; production code uses the default constructor.</summary>
29+
/// <param name="handler">Custom HTTP message handler.</param>
30+
internal CalendarService(HttpMessageHandler handler)
31+
{
32+
this.httpClient = new HttpClient(handler, disposeHandler: true);
33+
}
34+
35+
/// <summary>Gets a value indicating whether previously observed change validators (ETag or Last-Modified) are available.</summary>
36+
public bool HasChangeValidators => this.lastEtag != null || this.lastModified != null;
37+
1838
/// <summary>
1939
/// Fetches and parses calendar entries from the specified ICS URL.
2040
/// </summary>
@@ -66,6 +86,55 @@ public async Task<IReadOnlyList<CalendarEntry>> FetchAsync(Uri calendarIcsUri, C
6686
}
6787
}
6888

89+
/// <summary>
90+
/// Performs a conditional GET using previously observed ETag / Last-Modified validators (if any) and
91+
/// returns parsed entries only when the server indicates the resource changed. On 304 Not Modified an empty list is returned
92+
/// allowing callers to decide whether to reuse prior results. This service purposely does not retain prior entries or ICS text.
93+
/// </summary>
94+
/// <param name="calendarIcsUri">ICS feed URI.</param>
95+
/// <param name="ct">Cancellation token.</param>
96+
/// <returns>List of parsed entries when changed; empty list on 304 or errors.</returns>
97+
public async Task<IReadOnlyList<CalendarEntry>> FetchIfChangedAsync(Uri calendarIcsUri, CancellationToken ct = default)
98+
{
99+
try
100+
{
101+
using HttpRequestMessage req = new (HttpMethod.Get, calendarIcsUri);
102+
if (!string.IsNullOrEmpty(this.lastEtag))
103+
{
104+
req.Headers.TryAddWithoutValidation("If-None-Match", this.lastEtag);
105+
}
106+
107+
if (this.lastModified.HasValue)
108+
{
109+
req.Headers.IfModifiedSince = this.lastModified;
110+
}
111+
112+
using HttpResponseMessage resp = await this.httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
113+
if (resp.StatusCode == System.Net.HttpStatusCode.NotModified)
114+
{
115+
return Array.Empty<CalendarEntry>();
116+
}
117+
118+
if (!resp.IsSuccessStatusCode)
119+
{
120+
return Array.Empty<CalendarEntry>();
121+
}
122+
123+
this.lastEtag = resp.Headers.ETag?.ToString();
124+
this.lastModified = resp.Content.Headers.LastModified;
125+
string text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
126+
return ParseIcs(text);
127+
}
128+
catch (HttpRequestException)
129+
{
130+
return Array.Empty<CalendarEntry>();
131+
}
132+
catch (TaskCanceledException)
133+
{
134+
return Array.Empty<CalendarEntry>();
135+
}
136+
}
137+
69138
/// <summary>
70139
/// Similar to <see cref="FetchAsync(Uri, CancellationToken)"/> but propagates failures as exceptions
71140
/// so callers can inspect error messages. Use this when the caller wants to display errors to the user.
@@ -105,6 +174,65 @@ public async Task<IReadOnlyList<CalendarEntry>> FetchWithErrorsAsync(Uri calenda
105174
return ParseIcs(text);
106175
}
107176

177+
/// <summary>
178+
/// Same as <see cref="FetchWithErrorsAsync"/> but uses conditional validators to potentially avoid downloading the body.
179+
/// Throws only when the server returns an error status for a changed resource; 304 yields an empty list.
180+
/// </summary>
181+
/// <param name="calendarIcsUri">ICS feed URI.</param>
182+
/// <param name="ct">Cancellation token.</param>
183+
/// <returns>Parsed entries when changed; empty list when not modified.</returns>
184+
/// <exception cref="HttpRequestException">If HTTP status is non-success (excluding 304).</exception>
185+
public async Task<IReadOnlyList<CalendarEntry>> FetchIfChangedWithErrorsAsync(Uri calendarIcsUri, CancellationToken ct = default)
186+
{
187+
using HttpRequestMessage req = new (HttpMethod.Get, calendarIcsUri);
188+
if (!string.IsNullOrEmpty(this.lastEtag))
189+
{
190+
req.Headers.TryAddWithoutValidation("If-None-Match", this.lastEtag);
191+
}
192+
193+
if (this.lastModified.HasValue)
194+
{
195+
req.Headers.IfModifiedSince = this.lastModified;
196+
}
197+
198+
using HttpResponseMessage resp = await this.httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
199+
if (resp.StatusCode == System.Net.HttpStatusCode.NotModified)
200+
{
201+
return Array.Empty<CalendarEntry>();
202+
}
203+
204+
if (!resp.IsSuccessStatusCode)
205+
{
206+
int code = (int)resp.StatusCode;
207+
string reason = resp.ReasonPhrase ?? resp.StatusCode.ToString();
208+
string loc = resp.Headers.Location?.ToString() ?? string.Empty;
209+
string hint = string.Empty;
210+
if (code >= 300 && code < 400)
211+
{
212+
hint = " Redirected location may require authentication.";
213+
}
214+
else if (code == 401 || code == 403)
215+
{
216+
hint = " Calendar feed appears to require authentication (401/403).";
217+
}
218+
219+
string msg = !string.IsNullOrEmpty(loc)
220+
? $"HTTP {code} {reason} -> {loc}.{hint}"
221+
: $"HTTP {code} {reason}.{hint}";
222+
223+
throw new HttpRequestException(msg);
224+
}
225+
226+
this.lastEtag = resp.Headers.ETag?.ToString();
227+
this.lastModified = resp.Content.Headers.LastModified;
228+
string text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
229+
return ParseIcs(text);
230+
}
231+
232+
/// <summary>
233+
/// Indicates whether conditional request validators were previously observed (ETag or Last-Modified).
234+
/// </summary>
235+
108236
/// <summary>
109237
/// Disposes the underlying <see cref="HttpClient"/> instance used by this service.
110238
/// </summary>

src/ComingUpNextTray/TrayApplication.cs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,30 @@ internal async Task<bool> RefreshAsync(CancellationToken ct = default)
129129
try
130130
{
131131
// Try the error-propagating fetch so we can show users what went wrong.
132-
IReadOnlyList<CalendarEntry> entries;
132+
IReadOnlyList<CalendarEntry> newEntries = Array.Empty<CalendarEntry>();
133+
bool changed = false;
133134
if (Uri.TryCreate(this._calendarUrl, UriKind.Absolute, out Uri? uri))
134135
{
135-
entries = await this._calendarService.FetchWithErrorsAsync(uri, ct).ConfigureAwait(false);
136-
}
137-
else
138-
{
139-
entries = Array.Empty<CalendarEntry>();
136+
// Prefer conditional fetch when we already have validators; fall back to full fetch otherwise.
137+
if (this._calendarService.HasChangeValidators)
138+
{
139+
newEntries = await this._calendarService.FetchIfChangedWithErrorsAsync(uri, ct).ConfigureAwait(false);
140+
changed = newEntries.Count > 0; // empty list means not modified (or error not thrown).
141+
if (!changed && this._lastEntries != null)
142+
{
143+
newEntries = this._lastEntries; // reuse prior parsed entries.
144+
}
145+
}
146+
else
147+
{
148+
newEntries = await this._calendarService.FetchWithErrorsAsync(uri, ct).ConfigureAwait(false);
149+
changed = true;
150+
}
140151
}
141152

142153
this._lastFetchError = null;
143-
this._lastEntries = entries;
144-
this._nextMeeting = NextMeetingSelector.GetNextMeeting(entries, DateTime.Now, this._ignoreFreeOrFollowing);
154+
this._lastEntries = newEntries;
155+
this._nextMeeting = NextMeetingSelector.GetNextMeeting(newEntries, DateTime.Now, this._ignoreFreeOrFollowing);
145156
this._lastRefreshUtc = DateTime.UtcNow;
146157
return true;
147158
}

src/ComingUpNextTray/TrayContext.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal sealed class TrayContext : ApplicationContext
1818
private readonly ContextMenuStrip menu;
1919
private readonly ToolStripMenuItem nextMeetingDisplayItem; // shows formatted first meeting
2020
private readonly ToolStripMenuItem secondMeetingDisplayItem; // shows formatted second meeting
21+
private readonly ToolStripMenuItem lastUpdatedDisplayItem; // shows last successful refresh timestamp
2122
private readonly System.Windows.Forms.Timer refreshTimer;
2223
private readonly System.Windows.Forms.Timer overlayTimer; // updates icon/tooltip more frequently
2324
private ToolStripMenuItem? toggleHoverWindowItem;
@@ -45,6 +46,7 @@ public TrayContext()
4546
// Dynamic meeting display items (inserted at top later):
4647
this.nextMeetingDisplayItem = new ToolStripMenuItem(string.Empty) { Enabled = false }; // will show formatted first meeting
4748
this.secondMeetingDisplayItem = new ToolStripMenuItem(string.Empty) { Enabled = false }; // will show formatted second meeting
49+
this.lastUpdatedDisplayItem = new ToolStripMenuItem(string.Empty) { Enabled = false }; // will show last updated timestamp
4850

4951
// Core items
5052
ToolStripMenuItem openMeetingItem = new ToolStripMenuItem(UiText.OpenMeeting, null, this.OnOpenMeetingClick) { Enabled = false };
@@ -85,6 +87,7 @@ public TrayContext()
8587
// Insert meeting display placeholders at top.
8688
this.menu.Items.Add(this.nextMeetingDisplayItem);
8789
this.menu.Items.Add(this.secondMeetingDisplayItem);
90+
this.menu.Items.Add(this.lastUpdatedDisplayItem);
8891
this.menu.Items.Add(new ToolStripSeparator());
8992
this.menu.Items.Add(openMeetingItem);
9093
this.menu.Items.Add(copyMeetingLinkItem);
@@ -246,6 +249,7 @@ protected override void Dispose(bool disposing)
246249
this.notifyIcon.Dispose();
247250
this.nextMeetingDisplayItem.Dispose();
248251
this.secondMeetingDisplayItem.Dispose();
252+
this.lastUpdatedDisplayItem.Dispose();
249253
this.toggleHoverWindowItem?.Dispose();
250254
this.toggleIgnoreFreeFollowingItem?.Dispose();
251255
this.menu.Dispose();
@@ -400,6 +404,20 @@ private void UpdateMenuState()
400404

401405
this.secondMeetingDisplayItem.Text = second is null ? string.Empty : NextMeetingSelector.FormatTooltip(second, DateTime.Now);
402406
this.secondMeetingDisplayItem.Visible = second is not null;
407+
408+
// Update last updated timestamp line
409+
DateTime lastUtc = this.app.GetLastRefreshUtcForUi();
410+
if (lastUtc != default)
411+
{
412+
DateTime local = lastUtc.ToLocalTime();
413+
this.lastUpdatedDisplayItem.Text = $"Updated {local:MM/dd ddd h:mm tt}";
414+
this.lastUpdatedDisplayItem.Visible = true;
415+
}
416+
else
417+
{
418+
this.lastUpdatedDisplayItem.Visible = false;
419+
}
420+
403421
string calendarUrl = this.app.GetCalendarUrlForUi();
404422
if (this.toggleHoverWindowItem is not null)
405423
{
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System;
2+
using System.Net;
3+
using System.Net.Http;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using ComingUpNextTray.Services;
7+
using Xunit;
8+
9+
namespace ComingUpNextTray.Tests
10+
{
11+
public sealed class ConditionalFetchTests
12+
{
13+
private sealed class StubHandler : HttpMessageHandler
14+
{
15+
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
16+
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
17+
{
18+
this.responder = responder;
19+
}
20+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
21+
{
22+
return Task.FromResult(this.responder(request));
23+
}
24+
}
25+
26+
[Fact]
27+
public async Task FirstFetch_NoValidators_ReturnsEntries()
28+
{
29+
const string ics = "BEGIN:VEVENT\nDTSTART:20250101T130000Z\nEND:VEVENT";
30+
using StubHandler handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
31+
{
32+
Content = new StringContent(ics),
33+
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v1\"") }
34+
});
35+
using CalendarService service = new CalendarService(handler);
36+
IReadOnlyList<ComingUpNextTray.Models.CalendarEntry> entries = await service.FetchIfChangedAsync(new Uri("https://example.com/calendar.ics"));
37+
Assert.NotEmpty(entries);
38+
Assert.True(service.HasChangeValidators);
39+
}
40+
41+
[Fact]
42+
public async Task SecondFetch_NotModified_ReturnsEmpty()
43+
{
44+
int call = 0;
45+
using StubHandler handler = new StubHandler(_ =>
46+
{
47+
call++;
48+
if (call == 1)
49+
{
50+
return new HttpResponseMessage(HttpStatusCode.OK)
51+
{
52+
Content = new StringContent("BEGIN:VEVENT\nDTSTART:20250101T130000Z\nEND:VEVENT"),
53+
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v2\"") }
54+
};
55+
}
56+
return new HttpResponseMessage(HttpStatusCode.NotModified)
57+
{
58+
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v2\"") }
59+
};
60+
});
61+
using CalendarService service = new CalendarService(handler);
62+
IReadOnlyList<ComingUpNextTray.Models.CalendarEntry> first = await service.FetchIfChangedAsync(new Uri("https://example.com/calendar.ics"));
63+
Assert.NotEmpty(first);
64+
IReadOnlyList<ComingUpNextTray.Models.CalendarEntry> second = await service.FetchIfChangedAsync(new Uri("https://example.com/calendar.ics"));
65+
Assert.Empty(second); // indicates unchanged
66+
}
67+
68+
[Fact]
69+
public async Task Modified_ReturnsNewEntries()
70+
{
71+
int call = 0;
72+
using StubHandler handler = new StubHandler(_ =>
73+
{
74+
call++;
75+
if (call == 1)
76+
{
77+
return new HttpResponseMessage(HttpStatusCode.OK)
78+
{
79+
Content = new StringContent("BEGIN:VEVENT\nDTSTART:20250101T130000Z\nEND:VEVENT"),
80+
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v3\"") }
81+
};
82+
}
83+
return new HttpResponseMessage(HttpStatusCode.OK)
84+
{
85+
Content = new StringContent("BEGIN:VEVENT\nDTSTART:20250102T130000Z\nEND:VEVENT"),
86+
Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"v4\"") }
87+
};
88+
});
89+
using CalendarService service = new CalendarService(handler);
90+
IReadOnlyList<ComingUpNextTray.Models.CalendarEntry> first = await service.FetchIfChangedAsync(new Uri("https://example.com/calendar.ics"));
91+
IReadOnlyList<ComingUpNextTray.Models.CalendarEntry> second = await service.FetchIfChangedAsync(new Uri("https://example.com/calendar.ics"));
92+
Assert.Single(first);
93+
Assert.Single(second);
94+
Assert.NotEqual(first[0].StartTime, second[0].StartTime);
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)