feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
|
||||
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
|
||||
///
|
||||
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
|
||||
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
|
||||
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
|
||||
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
|
||||
///
|
||||
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
|
||||
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
|
||||
/// eine Welle aus.
|
||||
/// </summary>
|
||||
public static class TaskSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
|
||||
/// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
|
||||
/// (<see cref="TaskItem.LastOccurrence"/>) liegt.
|
||||
/// </summary>
|
||||
public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
|
||||
{
|
||||
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||||
|
||||
if (task.When is null)
|
||||
{
|
||||
// Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
|
||||
if (task.LastOccurrence is not null) return null;
|
||||
var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
|
||||
return Iso(basis);
|
||||
}
|
||||
|
||||
return task.When.Kind switch
|
||||
{
|
||||
TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
|
||||
/// <c>null</c>.
|
||||
///
|
||||
/// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
|
||||
/// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
|
||||
/// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
|
||||
/// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
|
||||
/// </summary>
|
||||
public static string? UnresolvableTimeZone(TaskItem task)
|
||||
=> task.When is { } when
|
||||
&& !string.IsNullOrWhiteSpace(when.TimeZone)
|
||||
&& !TimeZones.IsKnown(when.TimeZone)
|
||||
? when.TimeZone
|
||||
: null;
|
||||
|
||||
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
||||
if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
|
||||
if (nowUtc < targetUtc) return null;
|
||||
return Iso(targetUtc);
|
||||
}
|
||||
|
||||
private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (!TryParseInterval(task.When!.Value, out var interval)) return null;
|
||||
|
||||
var anchor = marker is not null
|
||||
? ParseIso(marker)
|
||||
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
|
||||
|
||||
return nowUtc < anchor + interval ? null : Iso(nowUtc);
|
||||
}
|
||||
|
||||
private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
var when = task.When!;
|
||||
CronExpression cron;
|
||||
try { cron = CronExpression.Parse(when.Value); }
|
||||
catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
|
||||
|
||||
var tz = ResolveTimeZone(when.TimeZone);
|
||||
if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
|
||||
|
||||
var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
|
||||
|
||||
// Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
|
||||
// ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
|
||||
// nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
|
||||
var lowerUtc = marker is not null ? ParseIso(marker)
|
||||
: task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
|
||||
: nowUtc;
|
||||
var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
|
||||
|
||||
var limit = lowerLocal;
|
||||
DateTime? matchLocal = null;
|
||||
for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
|
||||
{
|
||||
if (cron.Matches(candidate)) { matchLocal = candidate; break; }
|
||||
}
|
||||
|
||||
if (matchLocal is null) return null;
|
||||
|
||||
// Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
|
||||
var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return null;
|
||||
|
||||
var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
var occ = Iso(occUtc);
|
||||
|
||||
// Nur fällig, wenn der Termin echt über dem Marker liegt.
|
||||
return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
/// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
|
||||
/// angegebenen Zeitzone.</summary>
|
||||
private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
var v = value.Trim();
|
||||
if (v.Length == 0) return false;
|
||||
|
||||
if (v.EndsWith('Z') || v.EndsWith('z'))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var dto))
|
||||
{
|
||||
utc = dto.UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
|
||||
{
|
||||
var tz = ResolveTimeZone(timeZone);
|
||||
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return false;
|
||||
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
|
||||
private static bool TryParseInterval(string value, out TimeSpan interval)
|
||||
{
|
||||
interval = default;
|
||||
var v = value.Trim().ToLowerInvariant();
|
||||
if (v.Length < 2) return false;
|
||||
|
||||
var unit = v[^1];
|
||||
if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
|
||||
return false;
|
||||
|
||||
interval = unit switch
|
||||
{
|
||||
's' => TimeSpan.FromSeconds(n),
|
||||
'm' => TimeSpan.FromMinutes(n),
|
||||
'h' => TimeSpan.FromHours(n),
|
||||
'd' => TimeSpan.FromDays(n),
|
||||
_ => TimeSpan.Zero
|
||||
};
|
||||
return interval > TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
|
||||
///
|
||||
/// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
|
||||
/// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
|
||||
/// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
|
||||
/// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
|
||||
///
|
||||
/// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
|
||||
/// mit unbekannter Zone mit Meldung ab.
|
||||
/// </summary>
|
||||
private static TimeZoneInfo? ResolveTimeZone(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
|
||||
return TimeZones.TryResolve(id);
|
||||
}
|
||||
|
||||
private static DateTime Truncate(DateTime value)
|
||||
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
|
||||
|
||||
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime ParseIso(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt.ToUniversalTime()
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
Reference in New Issue
Block a user