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; } } /// /// Prueft einen Termin, bevor er gespeichert wird - liefert den Grund, warum er nie /// feuern wuerde, oder null (#597). /// /// Ein ungueltiger Termin laesst den Scanner nicht abstuerzen, sondern feuert /// schlicht nie. Beim Anlegen ist der richtige Moment, das zu sagen: Dann sieht es der, /// der ihn eingetragen hat, statt Stunden spaeter niemand. /// public static string? ValidateWhen(TaskWhen when) { if (!string.IsNullOrWhiteSpace(when.TimeZone) && !TimeZones.IsKnown(when.TimeZone)) return $"Die Zeitzone '{when.TimeZone}' ist auf diesem System nicht bekannt (IANA-Schreibweise, z.B. 'Europe/Berlin')."; switch (when.Kind) { case TaskWhenKind.Cron: try { CronExpression.Parse(when.Value); return null; } catch (FormatException ex) { return ex.Message; } case TaskWhenKind.Every: return TryParseInterval(when.Value, out _) ? null : $"'{when.Value}' ist kein Intervall (erwartet z.B. 30m, 2h oder 1d)."; case TaskWhenKind.At: return TryResolveInstant(when.Value, when.TimeZone, out _) ? null : $"'{when.Value}' ist kein Zeitpunkt (erwartet z.B. 2026-09-15T08:00)."; default: return "Unbekannte Terminart."; } } /// /// Der naechste geplante Lauf in UTC - fuer die Anzeige im Taskboard (#594). null, /// wenn keiner mehr ansteht oder der Termin nicht lesbar ist. /// public static DateTime? NextOccurrenceUtc(TaskItem task, DateTime nowUtc) { nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc); if (task.When is not { } when) return task.LastOccurrence is null ? nowUtc : null; switch (when.Kind) { case TaskWhenKind.At: return task.LastOccurrence is null && TryResolveInstant(when.Value, when.TimeZone, out var at) ? at : null; case TaskWhenKind.Every: { if (!TryParseInterval(when.Value, out var interval)) return null; var anchor = task.LastOccurrence is not null ? ParseIso(task.LastOccurrence) : (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime()); var next = anchor + interval; return next < nowUtc ? nowUtc : next; } case TaskWhenKind.Cron: return TryPrepareCron(when, out var cron, out var tz) ? NextCronUtc(cron, tz, nowUtc) : null; default: return null; } } /// /// Wie viele Laeufe der Termin im Schnitt je Tag ausloest, gerechnet ueber die naechsten /// Tage - fuer die Kostenprognose (#595). Einmalige Termine /// liefern 0. /// public static double RunsPerDay(TaskItem task, DateTime nowUtc, int days = 7) { nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc); if (task.When is not { } when || days <= 0) return 0; switch (when.Kind) { case TaskWhenKind.Every: return TryParseInterval(when.Value, out var interval) ? TimeSpan.FromDays(1) / interval : 0; case TaskWhenKind.Cron: { if (!TryPrepareCron(when, out var cron, out var tz)) return 0; var end = nowUtc.AddDays(days); var count = 0; var cursor = nowUtc; // Obergrenze: jede Minute ueber sieben Tage sind 10.080 - mehr gibt es nicht. while (count < 100_000 && NextCronUtc(cron, tz, cursor) is { } next && next <= end) { count++; cursor = next; } return count / (double)days; } default: return 0; } } /// /// #716: How many runs the task will still trigger from up to /// (excluding) — including one that is due right now. The /// basis for "how much budget does the rest of the day still need". /// public static int OccurrencesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440) => OccurrenceTimesBetween(task, fromUtc, toUtc, cap).Count; /// /// #716: When the task will run from up to (excluding) /// . A run due right now is listed at . /// public static IReadOnlyList OccurrenceTimesBetween(TaskItem task, DateTime fromUtc, DateTime toUtc, int cap = 1_440) { fromUtc = DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc); toUtc = DateTime.SpecifyKind(toUtc, DateTimeKind.Utc); var times = new List(); if (toUtc <= fromUtc) return times; var dueNow = DueOccurrence(task, fromUtc) is not null; if (dueNow) times.Add(fromUtc); if (task.When is not { } when) return times; // one-time without a schedule: due until it ran switch (when.Kind) { case TaskWhenKind.At: { if (!dueNow && task.LastOccurrence is null && TryResolveInstant(when.Value, when.TimeZone, out var at) && at < toUtc) times.Add(at); return times; } case TaskWhenKind.Every: { if (!TryParseInterval(when.Value, out var interval) || interval <= TimeSpan.Zero) return times; var next = dueNow ? fromUtc + interval : NextOccurrenceUtc(task, fromUtc) ?? toUtc; while (times.Count < cap && next < toUtc) { times.Add(next); next += interval; } return times; } case TaskWhenKind.Cron: { if (!TryPrepareCron(when, out var cron, out var tz)) return times; var cursor = fromUtc; while (times.Count < cap && NextCronUtc(cron, tz, cursor) is { } next && next < toUtc) { if (next > fromUtc) times.Add(next); cursor = next; } return times; } default: return times; } } private static bool TryPrepareCron(TaskWhen when, out CronExpression cron, out TimeZoneInfo tz) { cron = null!; tz = null!; try { cron = CronExpression.Parse(when.Value); } catch (FormatException) { return false; } if (ResolveTimeZone(when.TimeZone) is not { } zone) return false; tz = zone; return true; } private static DateTime? NextCronUtc(CronExpression cron, TimeZoneInfo tz, DateTime afterUtc) { var local = Truncate(TimeZoneInfo.ConvertTimeFromUtc(afterUtc, tz)); if (cron.GetNextOccurrence(local) is not { } nextLocal) return null; return TryToUtc(nextLocal, tz, out var utc) ? utc : 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; 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; }