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;
}