using System.Globalization; using ClawdDotNet.Core.Scheduling; namespace ClawdDotNet.Core.Tasks; /// /// 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 Task.Delay 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 ein 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. /// public static class TaskSchedule { /// /// Der Occurrence-Key des fälligen Termins, oder null, wenn nichts ansteht. /// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker /// () liegt. /// 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 }; } /// /// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst /// null. /// /// Ein solcher Termin feuert nie (siehe ResolveTimeZone). 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 /// (W. Europe Standard Time) auf einem System ohne ICU-Daten. /// public static string? UnresolvableTimeZone(TaskItem task) => task.When is { } when && !string.IsNullOrWhiteSpace(when.TimeZone) && !TimeZones.IsKnown(when.TimeZone) ? when.TimeZone : null; /// /// Der Parserfehler eines Cron-Termins, wenn der Ausdruck ungültig ist — sonst /// null. /// /// Ein solcher Termin feuert nie (DueCron fängt den Fehler ab, damit ein /// kaputter Ausdruck nicht den ganzen Takt kippt). Wie bei der Zeitzone meldet der /// Scanner das einmal je Aufgabe — vorher stand die Aufgabe als todo mit Termin /// im Taskboard und lief schlicht nie (#597). /// public static string? InvalidCron(TaskItem task) { if (task.When is not { Kind: TaskWhenKind.Cron } when) return null; try { CronExpression.Parse(when.Value); return null; } catch (FormatException ex) { return ex.Message; } } 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; if (!TryToUtc(matchLocal.Value, tz, out var occUtc)) return null; 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 ─── /// Deutet einen at-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der /// angegebenen Zeitzone. 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 return TryToUtc(local, tz, out utc); } return false; } /// /// Rechnet eine Wanduhrzeit in ihren UTC-Zeitpunkt um — auch dann, wenn es diese /// Uhrzeit an diesem Tag gar nicht gibt. /// /// Der Frühjahrssprung. In der Nacht der Umstellung springt die Uhr von /// 02:00 auf 03:00; „02:30" existiert an diesem Tag nicht. Vorher fiel ein Termin /// darin schlicht aus — einmal im Jahr lief eine tägliche Aufgabe nicht, und weil /// nichts scheiterte, fiel es niemandem auf. Ein ausgefallener Lauf ist kein /// harmloseres Ergebnis als ein verschobener, nur ein leiseres. /// /// Der Termin wandert deshalb auf den ersten Zeitpunkt, den es nach der Lücke /// gibt — aus 02:30 wird 03:00, nicht 03:30. So spät wie nötig, so früh wie möglich; /// die Reihenfolge mehrerer Termine in derselben Lücke bleibt dabei erhalten, weil /// sie alle auf denselben Zeitpunkt fallen und der Marker den zweiten abfängt. /// /// Der Herbstsprung braucht nichts Eigenes: Eine doppelt vorkommende /// Uhrzeit rechnet ConvertTimeToUtc auf die Normalzeit-Fassung um, also auf /// den zweiten Durchlauf. Beide Durchläufe ergeben denselben Occurrence-Key, und der /// Marker sorgt dafür, dass nur einer davon feuert. /// private static bool TryToUtc(DateTime local, TimeZoneInfo tz, out DateTime utc) { var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); if (!tz.IsInvalidTime(unspecified)) { utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz); return true; } // Minutenweise vorrücken, bis die Uhr wieder eine Zeit zeigt, die es gibt. // Die Obergrenze ist Vorsicht, kein Fachwissen: Die größte gebräuchliche Lücke // ist eine Stunde (Lord Howe Island: 30 Minuten). Bräuchte es je mehr als drei, // stimmt etwas anderes nicht, und dann ist „gar nicht" das ehrlichere Ergebnis. for (var probe = unspecified.AddMinutes(1); probe <= unspecified.AddHours(3); probe = probe.AddMinutes(1)) { if (tz.IsInvalidTime(probe)) continue; utc = TimeZoneInfo.ConvertTimeToUtc(probe, tz); return true; } utc = default; return false; } /// Ein Intervall wie 30s, 15m, 2h, 1d. 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; } /// /// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC. /// /// Eine unbekannte Zone gibt null 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 — weist eine Aufgabe /// mit unbekannter Zone mit Meldung ab. /// 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; }