fix(ui): store ScheduledFor as real UTC instead of mislabeled local time
The DB's global UtcConverter only tags DateTimes as Utc on read (SpecifyKind), it never converts on write. SetScheduledForAsync persisted the ThemedDatePicker's Local/Unspecified wall-clock value verbatim, so QueuePicker's comparison against DateTime.UtcNow fired scheduled tasks late by the local UTC offset (e.g. 2h in CEST). Convert to UTC at the write boundary, and ToLocalTime() at the read/compare sites (overdue checks in TaskRowViewModel/TasksIslandViewModel, the date-picker's edit seed) so existing scheduled/overdue display doesn't shift. Existing DB rows hold local wall-clock values mistagged as Utc; no migration added (few rows, one-time 2h-class shift accepted per the originating audit finding).
This commit is contained in:
@@ -121,7 +121,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasBranch => !string.IsNullOrWhiteSpace(Branch);
|
||||
public bool HasDiff => DiffAdditions > 0 || DiffDeletions > 0;
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.ToLocalTime().Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle
|
||||
|
||||
@@ -637,7 +637,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
if (r.Done && !underOpenPlanningParent)
|
||||
completed.Add(r);
|
||||
else if (r.ScheduledFor is { } d && d.Date < today)
|
||||
else if (r.ScheduledFor is { } d && d.ToLocalTime().Date < today)
|
||||
overdue.Add(r);
|
||||
else
|
||||
open.Add(r);
|
||||
@@ -1361,12 +1361,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
public async Task SetScheduledForAsync(TaskRowViewModel row, DateTime? when)
|
||||
{
|
||||
if (row is null) return;
|
||||
// ThemedDatePicker builds Unspecified/Local wall-clock values; the DB's UtcConverter only
|
||||
// tags DateTimes as Utc on read, it never converts on write, so this is the one place that
|
||||
// must turn "local wall clock" into a real UTC instant before it's persisted.
|
||||
var whenUtc = when is { } w
|
||||
? (w.Kind == DateTimeKind.Utc ? w : DateTime.SpecifyKind(w, DateTimeKind.Local).ToUniversalTime())
|
||||
: (DateTime?)null;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
entity.ScheduledFor = when;
|
||||
entity.ScheduledFor = whenUtc;
|
||||
await db.SaveChangesAsync();
|
||||
row.ScheduledFor = when;
|
||||
row.ScheduledFor = whenUtc;
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -217,7 +217,7 @@ public partial class TaskRowView : UserControl
|
||||
{
|
||||
if (DataContext is not TaskRowViewModel row) return;
|
||||
_pendingScheduleRow = row;
|
||||
ScheduleDate.SelectedDate = row.ScheduledFor ?? DateTime.Now.AddHours(1);
|
||||
ScheduleDate.SelectedDate = row.ScheduledFor?.ToLocalTime() ?? DateTime.Now.AddHours(1);
|
||||
ScheduleAnchor.Flyout?.ShowAt(ScheduleAnchor);
|
||||
}
|
||||
|
||||
|
||||
@@ -346,4 +346,24 @@ public class TasksIslandRegroupTests : IDisposable
|
||||
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Items.Select(r => r.Id));
|
||||
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Rows.OfType<TaskRowViewModel>().Select(r => r.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScheduledForAsync_LocalDateTime_PersistsAsUtc()
|
||||
{
|
||||
await SeedTasksAsync(("t1", TaskStatus.Idle, null, 0));
|
||||
|
||||
var vm = BuildViewModel();
|
||||
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
||||
var row = vm.Items.First(r => r.Id == "t1");
|
||||
|
||||
// ThemedDatePicker hands SetScheduledForAsync Local/Unspecified wall-clock values.
|
||||
var localWhen = DateTime.SpecifyKind(new DateTime(2026, 8, 26, 15, 0, 0), DateTimeKind.Local);
|
||||
|
||||
await vm.SetScheduledForAsync(row, localWhen);
|
||||
|
||||
await using var db = NewContext();
|
||||
var entity = await db.Tasks.FirstAsync(t => t.Id == "t1");
|
||||
Assert.Equal(DateTimeKind.Utc, entity.ScheduledFor!.Value.Kind);
|
||||
Assert.Equal(localWhen.ToUniversalTime(), entity.ScheduledFor!.Value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user