Enhance template and scheduling functionalities: add PlanningRules to template endpoints and commands, implement repeat limits and audience filtering in scheduling logic, and update related data structures. Refactor frontend components to support new rules and improve user experience in channel management.

This commit is contained in:
Leonid Pershin
2026-07-26 14:25:32 +03:00
parent 7b12a06d1b
commit 8494eb5e6a
30 changed files with 3400 additions and 438 deletions
@@ -106,7 +106,8 @@ public static class TemplateEndpoints
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
@@ -197,7 +198,8 @@ public static class TemplateEndpoints
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
@@ -258,7 +258,19 @@ public sealed class GridScheduleGenerator(
.Distinct()
.ToList();
var elementsByGroup = await expander.ExpandAsync(groupIds, channel.Id, cancellationToken);
// Правила канала: детское время резолвится на каждый слот по его времени, а под потолок
// повторов нужна история показов — её глубина и задаётся окном правила.
var rules = PlanningRules.FromJson(template.RulesJson);
var repeatLimit = rules?.MaxRepeatsInWindow is { WindowDays: > 0, Max: > 0 } limit
? new RepeatLimit(limit.WindowDays, limit.Max)
: null;
var elementsByGroup = await expander.ExpandAsync(
groupIds,
channel.Id,
cancellationToken,
repeatLimit is null ? null : startUtc.AddDays(-repeatLimit.WindowDays)
);
var slotIds = scheduled.Select(s => s.Slot.Id).Distinct().ToList();
var states = await dbContext
@@ -308,7 +320,13 @@ public sealed class GridScheduleGenerator(
junctions,
elementsByGroup,
channel
)
),
rules?.AudienceAt(
TimeOnly.FromDateTime(
item.StartUtc.ToOffset(TimeSpan.FromMinutes(channel.UtcOffsetMinutes)).DateTime
)
),
repeatLimit
)
);
}
@@ -1,171 +1,232 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var episodes = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
Episodes = s
.Episodes.OrderBy(e => e.Position)
.Select(e => new { e.MediaAssetId, e.Position })
.ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
List<PlanningUnit> UnitsOfShow(Guid showId)
{
if (!episodes.TryGetValue(showId, out var show))
return [];
return show
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
.Select((e, index) => new PlanningUnit(
e.MediaAssetId,
durations[e.MediaAssetId],
showId,
index
))
.ToList();
}
foreach (var group in groups)
{
var elements = new List<PlanningElement>();
foreach (var item in group.Items.OrderBy(i => i.Position))
{
if (item.ElementKind == GroupElementKind.Show)
{
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOfShow(item.ElementId),
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null
)
);
continue;
}
var partShowIds = collectionParts
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
units,
// Остывание коллекции считается по самой свежей из её частей: показ любой
// из них означает, что франшиза недавно была в эфире.
partShowIds
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max()
)
);
}
result[group.Id] = elements;
}
return result;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var episodes = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
Episodes = s
.Episodes.OrderBy(e => e.Position)
.Select(e => new { e.MediaAssetId, e.Position })
.ToList(),
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
var assetIds = episodes.Values.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId)).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
List<PlanningUnit> UnitsOfShow(Guid showId)
{
if (!episodes.TryGetValue(showId, out var show))
return [];
return show
.Episodes.Where(e => durations.ContainsKey(e.MediaAssetId))
.Select((e, index) => new PlanningUnit(
e.MediaAssetId,
durations[e.MediaAssetId],
showId,
index
))
.ToList();
}
foreach (var group in groups)
{
var elements = new List<PlanningElement>();
foreach (var item in group.Items.OrderBy(i => i.Position))
{
if (item.ElementKind == GroupElementKind.Show)
{
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOfShow(item.ElementId),
lastPlayed.TryGetValue(item.ElementId, out var played) ? played : null,
episodes.TryGetValue(item.ElementId, out var show) ? show.Audience : null,
recentPlays.TryGetValue(item.ElementId, out var plays) ? plays : null
)
);
continue;
}
var partShowIds = collectionParts
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
var units = partShowIds.SelectMany(UnitsOfShow).ToList();
elements.Add(
new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
units,
// Остывание коллекции считается по самой свежей из её частей: показ любой
// из них означает, что франшиза недавно была в эфире.
partShowIds
.Select(id => lastPlayed.TryGetValue(id, out var p) ? p : (DateTimeOffset?)null)
.Where(p => p is not null)
.DefaultIfEmpty(null)
.Max(),
// Категория коллекции — строжайшая среди частей: франшиза идёт целиком,
// и одна взрослая часть делает взрослой всю.
partShowIds
.Select(id =>
episodes.TryGetValue(id, out var part) ? part.Audience : (ShowAudience?)null
)
.Where(a => a is not null)
.DefaultIfEmpty(null)
.Max(),
partShowIds
.SelectMany(id =>
recentPlays.TryGetValue(id, out var p) ? p : []
)
.ToList()
)
);
}
result[group.Id] = elements;
}
return result;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId!.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
@@ -88,6 +88,7 @@ public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
@@ -92,6 +92,7 @@ public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
@@ -17,12 +17,13 @@ public sealed record UpdateLayerCommand(
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона и аварийная группа, играющая, когда пуст даже фоновый слой.</summary>
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
@@ -45,5 +46,22 @@ public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerC
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
ShowAudience? strictest = null;
foreach (var window in windows.Where(w => w.Contains(moment)))
strictest = strictest is { } current && current <= window.MaxAudience
? current
: window.MaxAudience;
return strictest;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -41,6 +41,8 @@ public sealed record ScheduleTemplateDto(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
@@ -22,7 +22,11 @@ public static class ElementSelector
IRandomSource random
)
{
var playable = slot.Elements.Where(e => e.Units.Count > 0).ToList();
// Возрастной потолок — жёсткое «нельзя», поэтому отсекает до любой стратегии и до курсора:
// начатый в общее время сериал не должен доигрываться в детское.
var playable = slot
.Elements.Where(e => e.Units.Count > 0 && IsAllowedByAudience(slot, e))
.ToList();
if (playable.Count == 0)
return null;
@@ -95,10 +99,14 @@ public static class ElementSelector
if (current is not null && HasUnitsLeft(slot, current))
return Continue(slot, current);
// Потолок повторов — тоже жёсткий фильтр, но применяется только здесь: в последовательной
// стратегии он выбрасывал бы очередную серию сериала и рвал порядок показа.
var withinLimit = ApplyRepeatLimit(slot, playable, moment);
var cooldown = TimeSpan.FromDays(Math.Max(0, slot.Strategy.CooldownDays));
var eligible = cooldown <= TimeSpan.Zero
? playable
: playable
? withinLimit
: withinLimit
.Where(e => e.LastPlayedUtc is not { } last || moment - last >= cooldown)
.ToList();
@@ -106,16 +114,47 @@ public static class ElementSelector
if (exhausted)
{
// Остывание отсекло всех. Либо игнорируем его на этот выход, либо берём самый давний —
// пустой эфир хуже раннего повтора в обоих случаях.
// пустой эфир хуже раннего повтора в обоих случаях. Откат идёт по уже суженному потолком
// повторов пулу: иначе более слабое правило воскрешало бы отсечённое более строгим.
eligible = slot.Strategy.IgnoreCooldownWhenExhausted
? playable
: [playable.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
? withinLimit
: [withinLimit.OrderBy(e => e.LastPlayedUtc ?? DateTimeOffset.MinValue).First()];
}
var picked = WeightedPick(eligible, random);
return new ElementPick(picked, 0, eligible.Count, exhausted);
}
/// <summary>
/// Проходит ли элемент по возрастному потолку слота. Категории упорядочены по строгости, поэтому
/// сравнение — обычное «не строже». Элемент без категории не отсекается: неизвестное не значит
/// «взрослое», а молчаливое выбрасывание такого контента искало бы потом весь вечер.
/// </summary>
public static bool IsAllowedByAudience(PlanningSlot slot, PlanningElement element) =>
slot.MaxAudience is not { } max || element.Audience is not { } audience || audience <= max;
/// <summary>
/// Отсекает элементы, выбранные за период чаще потолка. История — старты уже записанных показов;
/// без неё (правило не задано или лента не загружена) фильтр не работает и никого не выбрасывает.
/// </summary>
private static List<PlanningElement> ApplyRepeatLimit(
PlanningSlot slot,
List<PlanningElement> playable,
DateTimeOffset moment
)
{
if (slot.RepeatLimit is not { } limit || limit.WindowDays <= 0 || limit.Max <= 0)
return playable;
var from = moment.AddDays(-limit.WindowDays);
var filtered = playable
.Where(e => (e.RecentPlaysUtc?.Count(p => p >= from) ?? 0) < limit.Max)
.ToList();
// Все упёрлись в потолок — правило не должно оставлять слот пустым: пусть решает остывание.
return filtered.Count == 0 ? playable : filtered;
}
/// <summary>Продолжение текущего элемента с позиции курсора.</summary>
private static ElementPick Continue(PlanningSlot slot, PlanningElement element)
{
@@ -1,3 +1,5 @@
using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
@@ -16,9 +18,16 @@ public sealed record PlanningElement(
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null
DateTimeOffset? LastPlayedUtc = null,
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
@@ -66,7 +75,11 @@ public sealed record PlanningSlot(
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null
PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
@@ -189,4 +202,7 @@ public enum PlanningWarningKind
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
}
@@ -244,12 +244,26 @@ public static class SchedulePlanner
var pick = ElementSelector.Select(slot, cursor, random);
if (pick is null)
{
// Пустая группа и отсечённая фильтром — разные беды: во втором случае контент есть,
// но не подходит по правилам, и админу надо чинить правило, а не состав группы.
var filteredOut =
slot.Elements.Any(e => e.Units.Count > 0)
&& !slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
warnings.Add(
new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
filteredOut
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
)
);
return FillWithFallback(
cursor,
@@ -22,6 +22,12 @@ public class ScheduleTemplate
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; }
/// <summary>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; }
@@ -63,6 +69,9 @@ public class ScheduleTemplate
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TemplatePlanningRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RulesJson",
table: "ScheduleTemplates",
type: "jsonb",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RulesJson",
table: "ScheduleTemplates");
}
}
}
@@ -928,6 +928,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("RulesJson")
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("ChannelId");
@@ -10,6 +10,7 @@ public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTe
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.RulesJson).HasColumnType("jsonb");
builder.HasIndex(x => x.ChannelId);
builder
@@ -0,0 +1,148 @@
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
/// </summary>
public class LayerApplicabilityTests
{
private static readonly TimeOnly DayStart = new(6, 0);
[Fact]
public void Empty_CoversEveryDate()
{
var applicability = new LayerApplicability();
Assert.True(applicability.IsEmpty);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
}
[Theory]
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
[InlineData(2026, 12, 20, true)] // ровно начало
[InlineData(2026, 1, 8, true)] // ровно конец
[InlineData(2026, 12, 19, false)] // за день до начала
[InlineData(2026, 1, 9, false)] // на следующий день после конца
[InlineData(2026, 6, 15, false)] // середина года
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
int year,
int month,
int day,
bool expected
)
{
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
// по паре (месяц, день), а не по датам.
var applicability = new LayerApplicability(
AnnualRanges: [new AnnualRange(12, 20, 1, 8)]
);
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
}
[Fact]
public void AnnualRange_WithinOneYear_DoesNotWrap()
{
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
}
[Fact]
public void Sections_AreCombinedWithOr()
{
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
var applicability = new LayerApplicability(
Weekdays: [1],
SpecificDates: [new DateOnly(2026, 3, 21)]
);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
}
[Fact]
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
{
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
// половину настройки — своей группы и стратегии у половинки нет.
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var top = template.AddLayer("Прайм", 100);
var bottom = template.AddLayer("Обычный", 50);
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
Assert.Contains("Кино", titles);
Assert.Contains("Ночь", titles);
Assert.DoesNotContain("Сериал", titles);
}
[Fact]
public void Build_DropsLayersThatDoNotApplyOnTheDate()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var newYear = template.AddLayer("Новогодний", 100);
newYear.Update(
newYear.Name,
newYear.Priority,
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
isEnabled: true
);
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
var usual = template.AddLayer("Обычный", 50);
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
Assert.Contains("Вечерний сериал", march);
Assert.DoesNotContain("Ирония судьбы", march);
Assert.Contains("Ирония судьбы", december);
Assert.DoesNotContain("Вечерний сериал", december);
}
[Fact]
public void Build_DisabledLayerIsIgnored()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var layer = template.AddLayer("Выключённый", 100);
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
Assert.Empty(Build(template));
}
private static void AddSlot(GridLayer layer, string title, TimeOnly start, int durationMinutes) =>
layer.AddSlot(title, start, durationMinutes);
private static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template,
DateTimeOffset? from = null
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build(
template,
utcOffsetMinutes: 180,
DayStart,
start,
start.AddHours(18)
);
}
}
@@ -0,0 +1,208 @@
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;
namespace TeleWave.Domain.Tests.Programming;
/// <summary>
/// Жёсткие фильтры кандидатов (см. 3.8): детское время и потолок повторов. Оба отсекают до жребия,
/// поэтому проверяются на выборе элемента, а не на готовой ленте.
/// </summary>
public class CandidateFilterTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
private sealed class FirstAlways : IRandomSource
{
public int Next(int maxExclusive) => 0;
}
private static PlanningElement Element(
ShowAudience? audience = null,
int position = 0,
DateTimeOffset? lastPlayed = null,
IReadOnlyList<DateTimeOffset>? recentPlays = null
)
{
var showId = Guid.NewGuid();
return new PlanningElement(
GroupElementKind.Show,
Guid.NewGuid(),
Weight: 1,
position,
[new PlanningUnit(Guid.NewGuid(), TimeSpan.FromMinutes(30), showId, 0)],
lastPlayed,
audience,
recentPlays
);
}
private static PlanningSlot Slot(
IReadOnlyList<PlanningElement> elements,
ShowAudience? maxAudience = null,
RepeatLimit? repeatLimit = null,
SlotStrategyKind strategy = SlotStrategyKind.RandomWithCooldown
) =>
new(
Guid.NewGuid(),
T0,
TargetDurationMinutes: 60,
SlotKind.Content,
IsAnchor: false,
MaxDriftMinutes: 30,
SnapToMinutes: null,
SlotBlockMode.FillSlot,
BlockValue: 1,
OverflowPolicy.ContinueNext,
new PlanningStrategy(strategy),
elements,
Cursor: null,
MaxAudience: maxAudience,
RepeatLimit: repeatLimit
);
[Fact]
public void Audience_FiltersOutStricterContent()
{
var kids = Element(ShowAudience.Kids);
var adult = Element(ShowAudience.Adult, position: 1);
var pick = ElementSelector.Select(
Slot([adult, kids], maxAudience: ShowAudience.Family),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(kids.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_WithoutLimit_KeepsEverything()
{
var adult = Element(ShowAudience.Adult);
var pick = ElementSelector.Select(Slot([adult]), T0, new FirstAlways());
Assert.NotNull(pick);
Assert.Equal(adult.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_UnknownCategory_IsNotDropped()
{
// Неизвестная категория — не повод выбрасывать: иначе контент исчезал бы из эфира молча.
var unknown = Element(audience: null);
var pick = ElementSelector.Select(
Slot([unknown], maxAudience: ShowAudience.Kids),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(unknown.ElementId, pick.Element.ElementId);
}
[Fact]
public void Audience_AllFilteredOut_ReturnsNothing()
{
var pick = ElementSelector.Select(
Slot([Element(ShowAudience.Adult)], maxAudience: ShowAudience.Kids),
T0,
new FirstAlways()
);
Assert.Null(pick);
}
[Fact]
public void Audience_AppliesToSequentialStrategyToo()
{
var adult = Element(ShowAudience.Adult, position: 0);
var teen = Element(ShowAudience.Teen, position: 1);
var pick = ElementSelector.Select(
Slot([adult, teen], maxAudience: ShowAudience.Teen, strategy: SlotStrategyKind.Sequential),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(teen.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_DropsElementsOverTheCap()
{
var overCap = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var fresh = Element(position: 1, recentPlays: [T0.AddDays(-1)]);
var pick = ElementSelector.Select(
Slot([overCap, fresh], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(fresh.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_IgnoresPlaysOutsideTheWindow()
{
// Показы старше окна не считаются — иначе правило «не чаще двух раз в неделю» запирало бы
// элемент навсегда.
var old = Element(recentPlays: [T0.AddDays(-30), T0.AddDays(-31)]);
var pick = ElementSelector.Select(
Slot([old], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 2)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
Assert.Equal(old.ElementId, pick.Element.ElementId);
}
[Fact]
public void RepeatLimit_WhenEveryoneIsOverTheCap_StillPicksSomething()
{
// Пустой эфир хуже раннего повтора — как и при исчерпанном остывании.
var a = Element(recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var b = Element(position: 1, recentPlays: [T0.AddDays(-1), T0.AddDays(-2)]);
var pick = ElementSelector.Select(
Slot([a, b], repeatLimit: new RepeatLimit(WindowDays: 7, Max: 1)),
T0,
new FirstAlways()
);
Assert.NotNull(pick);
}
[Fact]
public void RepeatLimit_AppliesBeforeCooldown()
{
// Порядок проверяется через исчерпание: потолок повторов выбрасывает «давний» элемент ещё
// до остывания, поэтому в вырожденном случае остаётся свежий. В обратном порядке остывание
// оставило бы давний, и выбор был бы другим.
var overCap = Element(
lastPlayed: T0.AddDays(-10),
recentPlays: [T0.AddDays(-10), T0.AddDays(-9), T0.AddDays(-8)]
);
var recent = Element(position: 1, lastPlayed: T0.AddHours(-1), recentPlays: [T0.AddHours(-1)]);
var slot = Slot([overCap, recent], repeatLimit: new RepeatLimit(WindowDays: 30, Max: 2)) with
{
Strategy = new PlanningStrategy(SlotStrategyKind.RandomWithCooldown, CooldownDays: 2),
};
var pick = ElementSelector.Select(slot, T0, new FirstAlways());
Assert.NotNull(pick);
Assert.Equal(recent.ElementId, pick.Element.ElementId);
}
}
+10 -9
View File
@@ -18,8 +18,12 @@
(`BumperMinIntervalMinutes`, обе `*Chance`, `NextBumperIndex`) удалены — условия показа живут
в элементе стыка; из `BumperSelection` убрана «ротация», у которой не было реализации.
**Срезы 3 и 4** не начинались, кроме того, что уже понадобилось раньше: слоты повтора и конца
вещания сделаны вместе с планировщиком, применимость слоёв и панель слоёв — частично.
**Срез 3 закрыт целиком.** Применимость слоёв правится из UI, слои переупорядочиваются
перетаскиванием и выключаются, сетку можно посмотреть на конкретную дату, слоты двигаются
и растягиваются мышью, день копируется на другие дни. Добавлены жёсткие фильтры кандидатов —
детское время и потолок повторов.
**Срез 4** не начинался.
Ничего из этого не проверялось на живой базе: только сборка, юнит-тесты и typecheck.
@@ -307,19 +311,17 @@ N новых позиций по фильтру».
## Срез 3 — гибкость и календарь
#### [~] 3.1. Применимость слоёв
#### [x] 3.1. Применимость слоёв
`Domain` `Infrastructure` `Application`
> Частично: модель применимости и разрешение слоёв на дату готовы; нет UI правки применимости.
`applicability`: дни недели, разовые диапазоны дат, ежегодные диапазоны, конкретные даты (см. 3.4).
Приоритеты, разрешение конфликтов. Юнит-тесты на пересекающиеся слои и на границу года в ежегодном
диапазоне.
#### [~] 3.2. Фильтры кандидатов
#### [x] 3.2. Фильтры кандидатов
`Domain`
> Частично: остывание работает; возрастной фильтр по времени и потолок повторов не сделаны.
`maxAudienceByTime`, `cooldown`, `maxRepeatsInWindow` (см. 3.8) — применяются **до** взвешенного
выбора. История берётся из материализованной ленты. `fallback` стратегии, когда остывание отсекло
@@ -337,15 +339,14 @@ N новых позиций по фильтру».
Конец вещания: заполнение зацикленным ассетом, пометка в EPG «эфир не ведётся», отображение
в программе.
#### [~] 3.5. Панель слоёв
#### [x] 3.5. Панель слоёв
`Frontend`
> Частично: список слоёв с приоритетом и выбором готов; нет drag-переупорядочивания и переключателя даты.
Список слоёв с видимостью и приоритетом, drag для переупорядочивания, выбор редактируемого слоя,
штриховка перекрытых слотов, переключатель даты («показать сетку на 25 декабря»).
#### [ ] 3.6. Drag & drop в календаре
#### [x] 3.6. Drag & drop в календаре
`Frontend`
Перетаскивание слотов и изменение длительности за края, копирование дня на другие дни, копирование
@@ -8,29 +8,42 @@ import { HttpError } from '@/shared/api/client'
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { BumperCard } from './components/BumperCard'
import { CollapsibleCard } from './components/CollapsibleCard'
import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard'
import { TemplatePreview } from './components/TemplatePreview'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
@@ -85,6 +98,82 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: invalidate,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: invalidate,
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: invalidate,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: invalidate,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
invalidate()
},
onError,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
const layerForNewSlot =
@@ -159,19 +248,90 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<LayerList
template={template}
activeLayerId={layerForNewSlot ?? null}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
</div>
<div className="flex flex-col gap-3">
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() =>
copyDayMutation.mutate({ from: copySource, to: copyTargets })
}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
@@ -186,6 +346,10 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</CollapsibleCard>
)}
{template && (
<RulesCard template={template} onChanged={invalidate} onError={onError} />
)}
<JunctionsCard
channel={channel}
template={template}
@@ -195,6 +359,15 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
<SchedulePreview entries={schedule ?? []} />
</div>
)
+13 -1
View File
@@ -12,6 +12,7 @@ import type {
JunctionElementKind,
JunctionTemplateDto,
LayerApplicability,
PlanningRules,
ScheduleEntryDto,
SchedulePreviewDto,
ScheduleTemplateDto,
@@ -73,7 +74,12 @@ export function previewTemplate(channelId: string, days: number) {
export function updateTemplate(
templateId: string,
body: { name: string; fallbackGroupId: string | null; defaultJunctionId: string | null },
body: {
name: string
fallbackGroupId: string | null
defaultJunctionId: string | null
rules: PlanningRules | null
},
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
@@ -104,6 +110,12 @@ export function deleteLayer(layerId: string) {
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
/** Слот из ответа сервера → тело запроса: отбрасываем то, что сервер проставляет сам. */
export function toSlotBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
export function createSlot(layerId: string, body: SlotBody) {
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
}
@@ -97,6 +97,7 @@ export function JunctionsCard({
name: template!.name,
fallbackGroupId: template!.fallbackGroupId,
defaultJunctionId: junctionId,
rules: template!.rules,
}),
onSuccess: onChanged,
onError,
@@ -0,0 +1,300 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { AnnualRange, DateRange, GridLayerDto, LayerApplicability } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateLayer } from '../api'
import { isEmpty, toIsoDate } from '../lib/applicability'
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/**
* Когда действует слой (см. 3.4). Разделы объединяются по ИЛИ: слой применим, если дата подходит
* хотя бы под одно условие. Пустая применимость — слой действует всегда.
*/
export function LayerApplicabilityDialog({
layer,
onClose,
onChanged,
onError,
}: {
layer: GridLayerDto
onClose: () => void
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(layer.name)
const [weekdays, setWeekdays] = useState<number[]>(layer.applicability?.weekdays ?? [])
const [dateRanges, setDateRanges] = useState<DateRange[]>(layer.applicability?.dateRanges ?? [])
const [annualRanges, setAnnualRanges] = useState<AnnualRange[]>(
layer.applicability?.annualRanges ?? [],
)
const [specificDates, setSpecificDates] = useState<string[]>(
layer.applicability?.specificDates ?? [],
)
const save = useMutation({
mutationFn: () => {
const applicability: LayerApplicability = {
weekdays: weekdays.length > 0 ? [...weekdays].sort((a, b) => a - b) : null,
dateRanges: dateRanges.length > 0 ? dateRanges : null,
annualRanges: annualRanges.length > 0 ? annualRanges : null,
specificDates: specificDates.length > 0 ? specificDates : null,
}
return updateLayer(layer.id, {
name: name.trim() || layer.name,
priority: layer.priority,
// Пустую применимость отправляем как null — «действует всегда» и «пустые списки» это одно
// и то же, но null читается однозначно.
applicability: isEmpty(applicability) ? null : applicability,
isEnabled: layer.isEnabled,
})
},
onSuccess: () => {
onChanged()
onClose()
},
onError,
})
const toggleWeekday = (day: number) =>
setWeekdays((current) =>
current.includes(day) ? current.filter((d) => d !== day) : [...current, day],
)
const today = toIsoDate(new Date())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('admin.channels.layerApplicability')}</DialogTitle>
<DialogDescription>{t('admin.channels.applicabilityHint')}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto text-sm">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.layerName')}</Label>
<Input value={name} maxLength={128} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.applicabilityWeekdays')}</Label>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(day)}
className={
weekdays.includes(day)
? 'rounded border border-primary bg-primary/15 px-2 py-1 text-xs text-primary'
: 'rounded border border-border px-2 py-1 text-xs text-muted-foreground'
}
>
{t(`admin.channels.weekdays.${day}`)}
</button>
))}
</div>
</div>
<Section
title={t('admin.channels.applicabilityDateRanges')}
onAdd={() => setDateRanges((c) => [...c, { from: today, to: today }])}
empty={dateRanges.length === 0}
>
{dateRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<Input
type="date"
className="w-40"
value={range.from}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, from: e.target.value } : r)),
)
}
/>
<Input
type="date"
className="w-40"
value={range.to}
onChange={(e) =>
setDateRanges((c) =>
c.map((r, i) => (i === index ? { ...r, to: e.target.value } : r)),
)
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setDateRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityAnnual')}
onAdd={() =>
setAnnualRanges((c) => [...c, { fromMonth: 12, fromDay: 20, toMonth: 1, toDay: 8 }])
}
empty={annualRanges.length === 0}
>
{annualRanges.map((range, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<MonthDay
value={range}
prefix="from"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<span className="pb-2 text-muted-foreground"></span>
<MonthDay
value={range}
prefix="to"
onChange={(part) =>
setAnnualRanges((c) => c.map((r, i) => (i === index ? { ...r, ...part } : r)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setAnnualRanges((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
<Section
title={t('admin.channels.applicabilityDates')}
onAdd={() => setSpecificDates((c) => [...c, today])}
empty={specificDates.length === 0}
>
{specificDates.map((date, index) => (
<li key={index} className="flex items-end gap-2">
<Input
type="date"
className="w-40"
value={date}
onChange={(e) =>
setSpecificDates((c) => c.map((d, i) => (i === index ? e.target.value : d)))
}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setSpecificDates((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</Section>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Section({
title,
onAdd,
empty,
children,
}: {
title: string
onAdd: () => void
empty: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label>{title}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}>
<Plus className="h-4 w-4" />
</Button>
</div>
{empty ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.applicabilityNone')}</p>
) : (
<ul className="flex flex-col gap-2">{children}</ul>
)}
</div>
)
}
/** Пара «месяц / день» ежегодного периода — год у него намеренно отсутствует. */
function MonthDay({
value,
prefix,
onChange,
}: {
value: AnnualRange
prefix: 'from' | 'to'
onChange: (part: Partial<AnnualRange>) => void
}) {
const month = prefix === 'from' ? value.fromMonth : value.toMonth
const day = prefix === 'from' ? value.fromDay : value.toDay
return (
<span className="flex items-end gap-1">
<Input
type="number"
min={1}
max={12}
className="w-16"
value={month}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromMonth: Number(e.target.value) }
: { toMonth: Number(e.target.value) },
)
}
/>
<Input
type="number"
min={1}
max={31}
className="w-16"
value={day}
onChange={(e) =>
onChange(
prefix === 'from'
? { fromDay: Number(e.target.value) }
: { toDay: Number(e.target.value) },
)
}
/>
</span>
)
}
@@ -0,0 +1,189 @@
import { useMutation } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
SHOW_AUDIENCES,
type AudienceWindow,
type PlanningRules,
type ScheduleTemplateDto,
type ShowAudience,
} from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { updateTemplate } from '../api'
import { CollapsibleCard } from './CollapsibleCard'
const EMPTY_WINDOW: AudienceWindow = { from: '06:00:00', to: '23:00:00', maxAudience: 'Teen' }
/**
* Правила отбора кандидатов канала (см. 3.8): детское время и потолок повторов. Это жёсткие
* фильтры — они отсекают недопустимое до жребия, поэтому не требуют пересборки и не ломают
* воспроизводимость. Как и правка сетки, эфира сами по себе не двигают.
*/
export function RulesCard({
template,
onChanged,
onError,
}: {
template: ScheduleTemplateDto
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [windows, setWindows] = useState<AudienceWindow[]>(
() => template.rules?.maxAudienceByTime ?? [],
)
const [limitOn, setLimitOn] = useState(() => template.rules?.maxRepeatsInWindow != null)
const [windowDays, setWindowDays] = useState(
() => template.rules?.maxRepeatsInWindow?.windowDays ?? 7,
)
const [max, setMax] = useState(() => template.rules?.maxRepeatsInWindow?.max ?? 2)
useEffect(() => {
setWindows(template.rules?.maxAudienceByTime ?? [])
setLimitOn(template.rules?.maxRepeatsInWindow != null)
setWindowDays(template.rules?.maxRepeatsInWindow?.windowDays ?? 7)
setMax(template.rules?.maxRepeatsInWindow?.max ?? 2)
}, [template])
const save = useMutation({
mutationFn: () => {
const rules: PlanningRules = {
maxAudienceByTime: windows.length > 0 ? windows : null,
maxRepeatsInWindow: limitOn ? { windowDays, max } : null,
}
return updateTemplate(template.id, {
name: template.name,
fallbackGroupId: template.fallbackGroupId,
defaultJunctionId: template.defaultJunctionId,
rules,
})
},
onSuccess: onChanged,
onError,
})
const patchWindow = (index: number, part: Partial<AudienceWindow>) =>
setWindows((current) => current.map((w, i) => (i === index ? { ...w, ...part } : w)))
return (
<CollapsibleCard title={t('admin.channels.rules')}>
<div className="flex flex-col gap-4 text-sm">
<p className="text-xs text-muted-foreground">{t('admin.channels.rulesHint')}</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.audienceWindows')}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => [...c, EMPTY_WINDOW])}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{windows.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('admin.channels.noAudienceWindows')}</p>
) : (
<ul className="flex flex-col gap-2">
{windows.map((window, index) => (
<li key={index} className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input
type="time"
className="w-28"
value={window.from.slice(0, 5)}
onChange={(e) => patchWindow(index, { from: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input
type="time"
className="w-28"
value={window.to.slice(0, 5)}
onChange={(e) => patchWindow(index, { to: `${e.target.value}:00` })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.maxAudience')}</Label>
<select
className="h-9 rounded-md border border-border bg-transparent px-2"
value={window.maxAudience}
onChange={(e) =>
patchWindow(index, { maxAudience: e.target.value as ShowAudience })
}
>
{SHOW_AUDIENCES.map((value) => (
<option key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setWindows((c) => c.filter((_, i) => i !== index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.audienceWindowsHint')}</p>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={limitOn}
onChange={(e) => setLimitOn(e.target.checked)}
/>
{t('admin.channels.repeatLimit')}
</label>
{limitOn && (
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
className="w-28"
value={windowDays}
onChange={(e) => setWindowDays(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.repeatMax')}</Label>
<Input
type="number"
min={1}
className="w-28"
value={max}
onChange={(e) => setMax(Number(e.target.value))}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{t('admin.channels.repeatLimitHint')}</p>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</CollapsibleCard>
)
}
@@ -1,203 +1,372 @@
import { Anchor, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
const HOUR_HEIGHT = 44
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
onSelectSlot,
onAddSlot,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div key={weekday} className="px-2 py-1 text-center font-medium">
{t(`admin.channels.weekdays.${weekday}`)}
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className="relative border-l border-border"
style={{ height: HOUR_HEIGHT * 24 }}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
return (
<button
key={`${slot.id}-${weekday}`}
type="button"
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
</span>
<span className="block truncate">{slot.title}</span>
</button>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
export function LayerList({
template,
activeLayerId,
onSelect,
onDelete,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => (
<li key={layer.id} className="flex items-center gap-2 py-1.5">
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.priority}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.slots.length}
</span>
{!layer.isBackground && (
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
)}
</li>
))}
</ul>
)
}
import { Anchor, CalendarRange, Copy, GripVertical, Plus, Repeat } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { cn } from '@/shared/lib/cn'
import { coversDate, isEmpty } from '../lib/applicability'
const HOUR_HEIGHT = 44
/** Шаг сетки при перетаскивании и растягивании — минуты. */
const SNAP_MINUTES = 15
const snap = (minutes: number) => Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
const DAYPART_CLASS: Record<string, string> = {
Morning: 'bg-amber-500/20 border-amber-500/40',
Day: 'bg-sky-500/20 border-sky-500/40',
Prime: 'bg-violet-500/25 border-violet-500/50',
Night: 'bg-slate-500/20 border-slate-500/40',
}
function minutesOf(time: string): number {
const [h, m] = time.split(':')
return Number(h) * 60 + Number(m)
}
/**
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
*/
function offsetInDay(slotStart: string, dayStart: string): number {
const diff = minutesOf(slotStart) - minutesOf(dayStart)
return diff >= 0 ? diff : diff + 24 * 60
}
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
return layers
.filter((layer) => layer.isEnabled)
.flatMap((layer) =>
layer.slots
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
.map((slot) => ({ slot, layer })),
)
}
export function ScheduleGrid({
template,
selectedSlotId,
viewDate,
onSelectSlot,
onAddSlot,
onMoveSlot,
onResizeSlot,
onCopyDay,
}: {
template: ScheduleTemplateDto
selectedSlotId: string | null
/** Дата, на которую смотрим сетку («показать 25 декабря»); null — все слои разом. */
viewDate: string | null
onSelectSlot: (slot: SlotDto) => void
onAddSlot: (weekday: number, startMinutes: number) => void
/** Перенос слота: новое время старта и (для слота с днём недели) новый день. */
onMoveSlot: (slot: SlotDto, weekday: number, startMinutes: number) => void
onResizeSlot: (slot: SlotDto, durationMinutes: number) => void
onCopyDay: (fromWeekday: number) => void
}) {
const { t } = useTranslation()
const dayStart = template.dayStartTime.slice(0, 5)
const dayStartMinutes = minutesOf(dayStart)
// Подписи часов идут от начала вещательных суток, а не от полуночи.
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
// На выбранную дату показываем только те слои, которые в этот день действуют, — иначе сетка
// «на 25 декабря» показывала бы и обычный день, и новогодний одновременно.
const day = viewDate ? parseIsoDate(viewDate) : null
const applicable = day
? template.layers.filter((layer) => coversDate(layer.applicability, day))
: template.layers
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
const ordered = [...applicable].sort((a, b) => b.priority - a.priority)
const highlightWeekday = day?.getDay() ?? null
const [dragged, setDragged] = useState<SlotDto | null>(null)
const [resizing, setResizing] = useState<{ slot: SlotDto; minutes: number } | null>(null)
/** Позиция курсора в колонке дня — минуты суток, округлённые до шага сетки. */
const minutesAt = (clientY: number, column: HTMLElement) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, clientY - rect.top))
const fromDayStart = snap((offset / HOUR_HEIGHT) * 60)
return (dayStartMinutes + fromDayStart) % (24 * 60)
}
const drop = (event: React.DragEvent<HTMLDivElement>, weekday: number) => {
event.preventDefault()
if (!dragged) return
// Слот «каждый день» при переносе таким и остаётся: молча превратить его в слот одного дня
// значило бы убрать его сразу из шести колонок.
onMoveSlot(dragged, dragged.weekday ?? weekday, minutesAt(event.clientY, event.currentTarget))
setDragged(null)
}
/** Растягивание за нижний край: пока тянем — видно новую высоту, отпустили — сохраняем. */
const startResize = (event: React.MouseEvent, slot: SlotDto, column: HTMLElement) => {
event.preventDefault()
event.stopPropagation()
const from = offsetInDay(slot.targetStart, dayStart)
const move = (moveEvent: MouseEvent) => {
const rect = column.getBoundingClientRect()
const offset = Math.max(0, Math.min(rect.height, moveEvent.clientY - rect.top))
const end = snap((offset / HOUR_HEIGHT) * 60)
setResizing({ slot, minutes: Math.max(SNAP_MINUTES, end - from) })
}
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
setResizing((current) => {
if (current && current.minutes !== slot.targetDurationMinutes)
onResizeSlot(slot, current.minutes)
return null
})
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
const from = offsetInDay(slot.targetStart, dayStart)
const to = from + slot.targetDurationMinutes
return ordered
.filter((other) => other.isEnabled && other.priority > layer.priority)
.some((other) =>
other.slots
.filter((s) => s.weekday === null || s.weekday === weekday)
.some((s) => {
const otherFrom = offsetInDay(s.targetStart, dayStart)
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
}),
)
}
return (
<div className="crt-panel overflow-x-auto rounded-md">
<div className="min-w-[720px]">
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
<div className="px-2 py-1">{dayStart}</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'flex items-center justify-center gap-1 px-2 py-1 font-medium',
highlightWeekday === weekday && 'text-primary',
)}
>
{t(`admin.channels.weekdays.${weekday}`)}
<button
type="button"
title={t('admin.channels.copyDay')}
className="opacity-40 hover:opacity-100"
onClick={() => onCopyDay(weekday)}
>
<Copy className="h-3 w-3" />
</button>
</div>
))}
</div>
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
<div>
{hours.map((hour, index) => (
<div
key={index}
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
style={{ height: HOUR_HEIGHT }}
>
{hour.toString().padStart(2, '0')}:00
</div>
))}
</div>
{WEEKDAYS.map((weekday) => (
<div
key={weekday}
className={cn(
'relative border-l border-border',
highlightWeekday === weekday && 'bg-primary/5',
dragged && 'bg-primary/10',
)}
style={{ height: HOUR_HEIGHT * 24 }}
onDragOver={(e) => dragged && e.preventDefault()}
onDrop={(e) => drop(e, weekday)}
>
{hours.map((_, index) => (
<button
key={index}
type="button"
title={t('admin.channels.addSlotHere')}
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
>
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
</button>
))}
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
const from = offsetInDay(slot.targetStart, dayStart)
const covered = isCovered(slot, layer, weekday)
const minutes =
resizing?.slot.id === slot.id ? resizing.minutes : slot.targetDurationMinutes
return (
<div
key={`${slot.id}-${weekday}`}
draggable
onDragStart={() => setDragged(slot)}
onDragEnd={() => setDragged(null)}
onClick={() => onSelectSlot(slot)}
className={cn(
'absolute inset-x-1 cursor-grab overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
selectedSlotId === slot.id && 'ring-2 ring-primary',
dragged?.id === slot.id && 'opacity-50',
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
)}
style={{
top: (from / 60) * HOUR_HEIGHT,
height: Math.max(16, (minutes / 60) * HOUR_HEIGHT - 2),
}}
>
<span className="flex items-center gap-1 font-medium">
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
{slot.targetStart.slice(0, 5)}
{slot.weekday === null && <Repeat className="h-3 w-3 shrink-0 opacity-60" />}
</span>
<span className="block truncate">{slot.title}</span>
<span
role="presentation"
title={t('admin.channels.resizeSlot')}
className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize hover:bg-primary/40"
onMouseDown={(e) =>
startResize(e, slot, e.currentTarget.parentElement!.parentElement!)
}
/>
</div>
)
})}
</div>
))}
</div>
</div>
</div>
)
}
/**
* Панель слоёв: видимость, приоритет (перетаскиванием), применимость и выбор редактируемого.
* Выше в списке — приоритетнее; фоновый слой всегда внизу и не двигается.
*/
export function LayerList({
template,
activeLayerId,
viewDate,
onSelect,
onDelete,
onToggle,
onReorder,
onEditApplicability,
}: {
template: ScheduleTemplateDto
activeLayerId: string | null
viewDate: string | null
onSelect: (layer: GridLayerDto) => void
onDelete: (layer: GridLayerDto) => void
onToggle: (layer: GridLayerDto) => void
onReorder: (layerIdsTopFirst: string[]) => void
onEditApplicability: (layer: GridLayerDto) => void
}) {
const { t } = useTranslation()
const [dragged, setDragged] = useState<string | null>(null)
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
const day = viewDate ? parseIsoDate(viewDate) : null
const dropOn = (targetId: string) => {
if (!dragged || dragged === targetId) return
const movable = ordered.filter((l) => !l.isBackground).map((l) => l.id)
const order = movable.filter((id) => id !== dragged)
const at = order.indexOf(targetId)
// Бросок на фоновый слой означает «в самый низ»: он в порядке не участвует.
order.splice(at === -1 ? order.length : at, 0, dragged)
setDragged(null)
onReorder(order)
}
return (
<ul className="divide-y divide-border text-sm">
{ordered.map((layer) => {
const inactiveToday = day !== null && !coversDate(layer.applicability, day)
return (
<li
key={layer.id}
draggable={!layer.isBackground}
onDragStart={() => setDragged(layer.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(layer.id)}
className={cn('flex items-center gap-1.5 py-1.5', inactiveToday && 'opacity-40')}
>
{layer.isBackground ? (
<span className="w-4 shrink-0" />
) : (
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
)}
<input
type="checkbox"
className="shrink-0"
title={t('admin.channels.layerVisible')}
checked={layer.isEnabled}
onChange={() => onToggle(layer)}
/>
<button
type="button"
className={cn(
'min-w-0 flex-1 truncate text-left',
activeLayerId === layer.id && 'text-primary',
)}
onClick={() => onSelect(layer)}
>
{layer.name}
</button>
<span className="shrink-0 text-xs text-muted-foreground">
{layer.isBackground ? t('admin.channels.background') : layer.slots.length}
</span>
{!layer.isBackground && (
<>
<Button
size="sm"
variant="ghost"
title={t('admin.channels.layerApplicability')}
onClick={() => onEditApplicability(layer)}
>
<CalendarRange
className={cn(
'h-4 w-4',
isEmpty(layer.applicability) ? 'text-muted-foreground' : 'text-primary',
)}
/>
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
×
</Button>
</>
)}
</li>
)
})}
</ul>
)
}
/** «2026-12-25» → локальная дата. `new Date(iso)` разобрал бы её как UTC и сместил день. */
function parseIsoDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number)
return new Date(year, month - 1, day)
}
@@ -16,7 +16,14 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { toast } from '@/shared/ui/toast-store'
import { createSlot, deleteSlot, listJunctions, updateSlot, type SlotBody } from '../api'
import {
createSlot,
deleteSlot,
listJunctions,
toSlotBody,
updateSlot,
type SlotBody,
} from '../api'
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
@@ -28,11 +35,6 @@ const SNAP_OPTIONS = [0, 5, 10, 15, 30]
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
function toBody(slot: SlotDto): SlotBody {
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
return body
}
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
return {
title: '',
@@ -74,11 +76,11 @@ export function SlotInspector({
}) {
const { t } = useTranslation()
const [body, setBody] = useState<SlotBody>(() =>
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults),
)
useEffect(() => {
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
@@ -0,0 +1,45 @@
import type { LayerApplicability } from '@/shared/api/types'
/**
* Действует ли слой в эту дату вещательных суток. Зеркало серверного `LayerApplicability.Covers`
* (`TeleWave.Application/Programming/Templates/LayerApplicability.cs`) — источник правды там,
* здесь только подсветка сетки. Меняя одно, правьте второе.
*/
export function coversDate(applicability: LayerApplicability | null, date: Date): boolean {
if (!applicability || isEmpty(applicability)) return true
const weekday = date.getDay()
if (applicability.weekdays?.includes(weekday)) return true
const iso = toIsoDate(date)
if (applicability.specificDates?.includes(iso)) return true
if (applicability.dateRanges?.some((r) => iso >= r.from && iso <= r.to)) return true
// Ежегодный период может пересекать Новый год — сравниваем по паре (месяц, день).
const value = (date.getMonth() + 1) * 100 + date.getDate()
return (
applicability.annualRanges?.some((r) => {
const from = r.fromMonth * 100 + r.fromDay
const to = r.toMonth * 100 + r.toDay
return from <= to ? value >= from && value <= to : value >= from || value <= to
}) ?? false
)
}
export function isEmpty(applicability: LayerApplicability | null): boolean {
if (!applicability) return true
return (
!applicability.weekdays?.length &&
!applicability.dateRanges?.length &&
!applicability.annualRanges?.length &&
!applicability.specificDates?.length
)
}
/** «2026-12-25» из локальной даты — без ухода в UTC, иначе вечер уезжает на день назад. */
export function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
date.getDate(),
).padStart(2, '0')}`
}
@@ -10,6 +10,12 @@ export function formatTime(iso: string | null) {
})
}
/** Минуты суток → «HH:MM:00» — формат `TimeOnly` на сервере. */
export function toTime(minutes: number): string {
const m = ((minutes % (24 * 60)) + 24 * 60) % (24 * 60)
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
}
/** Минуты суток → «HH:MM». */
export function formatMinute(minute: number | null) {
if (minute == null) return '—'
+12
View File
@@ -459,6 +459,15 @@ export type GridLayerDto = {
slots: SlotDto[]
}
/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */
export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience }
/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */
export type PlanningRules = {
maxAudienceByTime?: AudienceWindow[] | null
maxRepeatsInWindow?: { windowDays: number; max: number } | null
}
export type ScheduleTemplateDto = {
id: string
channelId: string
@@ -466,6 +475,8 @@ export type ScheduleTemplateDto = {
fallbackGroupId: string | null
/** Стык, который берётся, когда слот своего не задал. */
defaultJunctionId: string | null
/** Детское время и потолок повторов. */
rules: PlanningRules | null
revision: number
appliedRevision: number
/** Есть ли правки правил, не применённые к эфиру. */
@@ -481,6 +492,7 @@ export type PlanningWarningKind =
| 'CooldownExhausted'
| 'RepeatSourceEmpty'
| 'FallbackEmpty'
| 'CandidatesFiltered'
export type PlanningWarningDto = {
kind: PlanningWarningKind
+66 -26
View File
@@ -341,6 +341,37 @@ const resources = {
snapOff: 'нет',
bumperConditionsHint:
'Как часто ставить заставку и на каких переходах — условия элемента стыка, а не настройка канала.',
resizeSlot: 'Потянуть за край — длительность',
copyDay: 'Копировать день',
copyDayFrom: 'Копировать {{day}} в:',
copy: 'Копировать',
layerVisible: 'Показывать слой',
layerName: 'Название слоя',
layerApplicability: 'Когда действует',
applicabilityHint:
'Разделы объединяются по ИЛИ: слой действует, если дата подходит хотя бы под одно условие. Ничего не заполнено — действует всегда.',
applicabilityWeekdays: 'Дни недели',
applicabilityDateRanges: 'Диапазоны дат',
applicabilityAnnual: 'Ежегодно (месяц / день)',
applicabilityDates: 'Конкретные даты',
applicabilityNone: 'не задано',
showForDate: 'Сетка на дату',
allDates: 'Все слои',
rules: 'Правила отбора',
rulesHint:
'Жёсткие фильтры: отсекают неподходящее до жребия. Как и правка сетки, эфир не двигают — нужно применить.',
audienceWindows: 'Детское время',
noAudienceWindows: 'Окон нет — возраст ничем не ограничен.',
audienceWindowsHint:
'В окне в эфир идёт контент не строже выбранной категории. Окно может переходить через полночь. Контент без категории не отсекается.',
from: 'С',
to: 'До',
maxAudience: 'Не строже',
repeatLimit: 'Потолок повторов',
repeatWindowDays: 'Окно, суток',
repeatMax: 'Не чаще, раз',
repeatLimitHint:
'Считается по уже записанной ленте. Если потолка достигли все кандидаты, слот всё равно заполняется: пустой эфир хуже раннего повтора.',
preview: 'Предпросмотр',
previewHide: 'Свернуть предпросмотр',
previewHint: 'Прогон по текущим правилам: ничего не пишется, курсоры слотов не двигаются.',
@@ -422,6 +453,7 @@ const resources = {
CooldownExhausted: 'Остывание отсекло всех',
RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех',
},
title: 'Каналы',
@@ -509,19 +541,7 @@ const resources = {
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
preferredAddWindow: 'Добавить окно',
preferredBadRange: 'начало ≥ конца',
ads: 'Реклама',
pickAd: 'Выберите ролик',
noAds: 'Пул рекламы пуст',
overrides: 'Марафоны / override',
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
overrideRecurrence: 'Повтор',
recurrenceOneTime: 'Разово',
recurrenceWeekly: 'Еженедельно',
from: 'С',
to: 'По',
noOverrides: 'Override не заданы',
schedule: 'Расписание (12 ч)',
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
noSchedule: 'Расписание ещё не построено',
},
maintenance: {
title: 'Обслуживание',
@@ -918,6 +938,37 @@ const resources = {
snapOff: 'off',
bumperConditionsHint:
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
resizeSlot: 'Drag the edge to change duration',
copyDay: 'Copy day',
copyDayFrom: 'Copy {{day}} to:',
copy: 'Copy',
layerVisible: 'Show layer',
layerName: 'Layer name',
layerApplicability: 'When it applies',
applicabilityHint:
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
applicabilityWeekdays: 'Weekdays',
applicabilityDateRanges: 'Date ranges',
applicabilityAnnual: 'Yearly (month / day)',
applicabilityDates: 'Specific dates',
applicabilityNone: 'not set',
showForDate: 'Grid for date',
allDates: 'All layers',
rules: 'Candidate rules',
rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',
audienceWindows: 'Family hours',
noAudienceWindows: 'No windows — the age is not limited.',
audienceWindowsHint:
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
from: 'From',
to: 'To',
maxAudience: 'No stricter than',
repeatLimit: 'Repeat cap',
repeatWindowDays: 'Window, days',
repeatMax: 'At most, times',
repeatLimitHint:
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
preview: 'Preview',
previewHide: 'Hide preview',
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
@@ -993,6 +1044,7 @@ const resources = {
CooldownExhausted: 'Cooldown ruled out every candidate',
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
},
title: 'Channels',
@@ -1080,19 +1132,7 @@ const resources = {
preferredNone: 'No windows set — no time preference.',
preferredAddWindow: 'Add window',
preferredBadRange: 'start ≥ end',
ads: 'Ads',
pickAd: 'Pick an ad',
noAds: 'Ad pool is empty',
overrides: 'Marathons / overrides',
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
overrideRecurrence: 'Repeat',
recurrenceOneTime: 'One-time',
recurrenceWeekly: 'Weekly',
from: 'From',
to: 'To',
noOverrides: 'No overrides set',
schedule: 'Schedule (12h)',
noSchedule: 'Schedule not built yet — click “Rebuild”',
noSchedule: 'Schedule not built yet',
},
maintenance: {
title: 'Maintenance',