Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Backend.Contracts;
|
||||
using Backend.Data;
|
||||
using Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Backend.Endpoints;
|
||||
@@ -17,6 +18,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var settings = await db.SiteSettings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == 1);
|
||||
var trackingRules = TrackingRulesSettings.Read(settings);
|
||||
|
||||
var candidates = await db.Candidates
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -24,9 +30,11 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
.Select(item => new AdminCandidateItemDto(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.StreamerIdentityId,
|
||||
item.DisplayName,
|
||||
item.ChannelSlug,
|
||||
item.Platform,
|
||||
item.NominationTally,
|
||||
item.AcceptanceStatus,
|
||||
item.AcceptanceNote,
|
||||
item.ClipCompilationUrl,
|
||||
@@ -53,10 +61,36 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
})
|
||||
.ToArrayAsync();
|
||||
var subcategoryTemplateSettings = SeasonSubcategoryTemplateSettings.Read(
|
||||
season,
|
||||
categoryRows.Select(category => new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
}));
|
||||
var visibleCategoryRows = categoryRows
|
||||
.Where(category => SeasonSubcategoryTemplateSettings.MatchesTemplate(
|
||||
new Backend.Domain.Category
|
||||
{
|
||||
GroupName = category.GroupName,
|
||||
Name = category.Name,
|
||||
Slug = category.Slug,
|
||||
SortOrder = category.SortOrder,
|
||||
ViewerRangeMin = category.ViewerRangeMin,
|
||||
ViewerRangeMax = category.ViewerRangeMax,
|
||||
},
|
||||
subcategoryTemplateSettings))
|
||||
.ToArray();
|
||||
|
||||
var categories = categoryRows
|
||||
var categories = visibleCategoryRows
|
||||
.Select(category => new AdminCategoryItemDto(
|
||||
category.Id,
|
||||
category.GroupName,
|
||||
@@ -65,20 +99,42 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
category.Description,
|
||||
category.SortOrder,
|
||||
category.MaxNomineesPerUser,
|
||||
category.ViewerRangeMin,
|
||||
category.ViewerRangeMax,
|
||||
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominations = await db.Nominations
|
||||
var subcategoryTemplates = SeasonSubcategoryTemplateSettings.ToDtos(subcategoryTemplateSettings);
|
||||
|
||||
var pendingNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? string.Empty,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -88,17 +144,41 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = await db.Nominations
|
||||
var pendingNominations = pendingNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var pendingNominationGroups = BuildNominationReviewGroups(pendingNominationRows, categoryRows, trackingRules);
|
||||
|
||||
var reviewedNominationRows = await db.Nominations
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId && item.Status != "pending")
|
||||
.OrderByDescending(item => item.ReviewedAt ?? item.CreatedAt)
|
||||
.Select(item => new AdminNominationReviewItemDto(
|
||||
.Select(item => new AdminNominationRow(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CategoryId != null ? item.Category!.GroupName : item.CategoryGroupName,
|
||||
item.CategoryId != null ? item.Category!.Name : null,
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText ?? (item.CandidateId != null ? item.Candidate!.DisplayName : string.Empty),
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.HoursStreamed,
|
||||
item.HoursWatched,
|
||||
item.PeakViewers,
|
||||
item.FollowersGained,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryId != null ? item.SuggestedCategory!.Name : null,
|
||||
item.StreamerIdentityId,
|
||||
item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
item.TrackingReviewStatus,
|
||||
item.TrackingFlagsJson,
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
@@ -108,6 +188,10 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.ReviewedAt))
|
||||
.ToArrayAsync();
|
||||
|
||||
var reviewedNominations = reviewedNominationRows
|
||||
.Select(item => ToNominationReviewItem(item, categoryRows, trackingRules))
|
||||
.ToArray();
|
||||
|
||||
var resultItems = await db.Results
|
||||
.AsNoTracking()
|
||||
.Where(item => item.SeasonId == seasonId)
|
||||
@@ -118,6 +202,7 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
item.CategoryId,
|
||||
item.Category.Name,
|
||||
item.CandidateId,
|
||||
item.Candidate.StreamerIdentityId,
|
||||
item.Candidate.DisplayName,
|
||||
item.Candidate.ChannelSlug,
|
||||
item.Candidate.Platform))
|
||||
@@ -159,11 +244,299 @@ public static partial class AdminSeasonManagementEndpoints
|
||||
season.ReviewEndsAt,
|
||||
season.ShowDate,
|
||||
season.ShowStartsAt,
|
||||
subcategoryTemplates,
|
||||
categories,
|
||||
candidates,
|
||||
pendingNominations,
|
||||
pendingNominationGroups,
|
||||
reviewedNominations,
|
||||
settings?.TrackingReviewNotes ?? string.Empty,
|
||||
trackingRules.Source.ShowManualReviewNotesInReview,
|
||||
resultItems,
|
||||
clipSubmissions));
|
||||
}
|
||||
|
||||
private sealed record AdminNominationRow(
|
||||
int Id,
|
||||
int? CategoryId,
|
||||
string? CategoryGroupName,
|
||||
string? CategoryName,
|
||||
string SubmittedByTwitchId,
|
||||
string CandidateText,
|
||||
string? StreamUrl,
|
||||
string? ResolvedChannel,
|
||||
string? ResolvedPlatform,
|
||||
int? AvgViewers,
|
||||
int? HoursStreamed,
|
||||
int? HoursWatched,
|
||||
int? PeakViewers,
|
||||
int? FollowersGained,
|
||||
int? SuggestedCategoryId,
|
||||
string? SuggestedCategoryName,
|
||||
int? StreamerIdentityId,
|
||||
string TrackerStatus,
|
||||
DateTimeOffset? TrackerCheckedAt,
|
||||
string TrackingReviewStatus,
|
||||
string TrackingFlagsJson,
|
||||
string? TrackingReviewNote,
|
||||
string? TrackingReviewedByTwitchId,
|
||||
DateTimeOffset? TrackingReviewedAt,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
int? CandidateId,
|
||||
string? CandidateDisplayName,
|
||||
string? ReviewNote,
|
||||
string? ReviewedByTwitchId,
|
||||
DateTimeOffset? ReviewedAt);
|
||||
|
||||
private static AdminNominationReviewItemDto ToNominationReviewItem(
|
||||
AdminNominationRow item,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules)
|
||||
{
|
||||
var trackingFlags = TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson)
|
||||
.Select(ToTrackingFlagHitDto)
|
||||
.ToArray();
|
||||
|
||||
return new(
|
||||
item.Id,
|
||||
item.CategoryId,
|
||||
ResolveCategoryGroupName(item, categoryRows),
|
||||
ResolveCategoryName(item, categoryRows),
|
||||
item.SubmittedByTwitchId,
|
||||
item.CandidateText,
|
||||
item.StreamUrl,
|
||||
item.ResolvedChannel,
|
||||
item.ResolvedPlatform,
|
||||
item.AvgViewers,
|
||||
item.SuggestedCategoryId,
|
||||
item.SuggestedCategoryName,
|
||||
item.StreamerIdentityId,
|
||||
string.IsNullOrWhiteSpace(item.TrackerStatus) ? "pending" : item.TrackerStatus,
|
||||
item.TrackerCheckedAt,
|
||||
string.IsNullOrWhiteSpace(item.TrackingReviewStatus) ? "clear" : item.TrackingReviewStatus,
|
||||
trackingFlags.Any(flag => flag.RequiresManualReview),
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(item, trackingRules),
|
||||
item.TrackingReviewNote,
|
||||
item.TrackingReviewedByTwitchId,
|
||||
item.TrackingReviewedAt,
|
||||
item.Status,
|
||||
item.CreatedAt,
|
||||
item.CandidateId,
|
||||
item.CandidateDisplayName,
|
||||
item.ReviewNote,
|
||||
item.ReviewedByTwitchId,
|
||||
item.ReviewedAt);
|
||||
}
|
||||
|
||||
private static AdminNominationReviewGroupDto[] BuildNominationReviewGroups(
|
||||
IEnumerable<AdminNominationRow> rows,
|
||||
IEnumerable<dynamic> categoryRows,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
rows
|
||||
.GroupBy(item => new
|
||||
{
|
||||
CategoryGroupName = ResolveCategoryGroupName(item, categoryRows),
|
||||
IdentityKey = item.StreamerIdentityId.HasValue
|
||||
? $"identity:{item.StreamerIdentityId.Value}"
|
||||
: $"link:{(item.StreamUrl ?? item.CandidateText).Trim().ToLowerInvariant()}",
|
||||
})
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group.OrderBy(item => item.CreatedAt).ToArray();
|
||||
var representative = ordered
|
||||
.OrderByDescending(item => item.StreamerIdentityId.HasValue)
|
||||
.ThenByDescending(item => item.SuggestedCategoryId.HasValue)
|
||||
.ThenByDescending(item => item.AvgViewers.HasValue)
|
||||
.First();
|
||||
var trackerStatus = ResolveGroupTrackerStatus(ordered);
|
||||
var trackingFlags = ordered
|
||||
.SelectMany(item => TrackingRulesSettings.ReadFlagHits(item.TrackingFlagsJson))
|
||||
.GroupBy(item => item.Key)
|
||||
.Select(grouping => ToTrackingFlagHitDto(grouping.First()))
|
||||
.ToArray();
|
||||
var requiresManualReview = trackingFlags.Any(flag => flag.RequiresManualReview);
|
||||
return new AdminNominationReviewGroupDto(
|
||||
representative.Id,
|
||||
ordered.Select(item => item.Id).ToArray(),
|
||||
ResolveCategoryGroupName(representative, categoryRows),
|
||||
ResolveNominationDisplayName(representative),
|
||||
representative.StreamUrl,
|
||||
representative.ResolvedChannel,
|
||||
representative.ResolvedPlatform,
|
||||
representative.AvgViewers,
|
||||
representative.SuggestedCategoryId,
|
||||
representative.SuggestedCategoryName,
|
||||
representative.StreamerIdentityId,
|
||||
trackerStatus,
|
||||
representative.TrackerCheckedAt,
|
||||
ResolveGroupTrackingReviewStatus(ordered),
|
||||
requiresManualReview,
|
||||
trackingFlags,
|
||||
BuildTrackingMetricStateDtos(representative, trackingRules),
|
||||
ordered.Select(item => item.TrackingReviewNote).FirstOrDefault(note => !string.IsNullOrWhiteSpace(note)),
|
||||
ordered.Select(item => item.TrackingReviewedByTwitchId).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)),
|
||||
ordered.Max(item => item.TrackingReviewedAt),
|
||||
ordered.Length,
|
||||
ordered.Select(item => item.SubmittedByTwitchId.Trim().ToLowerInvariant()).Where(item => !string.IsNullOrWhiteSpace(item)).Distinct().Count(),
|
||||
ordered.First().CreatedAt,
|
||||
ordered.Last().CreatedAt);
|
||||
})
|
||||
.OrderByDescending(item => item.TrackingFlags.Any(flag => flag.BlocksApproval))
|
||||
.ThenByDescending(item => item.RequiresManualReview)
|
||||
.ThenByDescending(item => item.NominationTally)
|
||||
.ThenByDescending(item => item.UniqueSubmitterCount)
|
||||
.ThenBy(item => item.SuggestedCategoryId.HasValue ? 0 : 1)
|
||||
.ThenByDescending(item => item.AvgViewers ?? -1)
|
||||
.ThenByDescending(item => item.LastSubmittedAt)
|
||||
.ToArray();
|
||||
|
||||
private static string ResolveNominationDisplayName(AdminNominationRow item) =>
|
||||
item.ResolvedChannel
|
||||
?? item.CandidateText
|
||||
?? item.StreamUrl
|
||||
?? "Name im Review festlegen";
|
||||
|
||||
private static string ResolveGroupTrackerStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["resolved", "no_data", "unsupported_platform", "unresolved", "pending"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackerStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackerStatus
|
||||
?? "pending";
|
||||
}
|
||||
|
||||
private static string ResolveGroupTrackingReviewStatus(IReadOnlyCollection<AdminNominationRow> rows)
|
||||
{
|
||||
string[] priority = ["overridden", "reviewed", "flagged", "clear"];
|
||||
return priority.FirstOrDefault(status => rows.Any(item => string.Equals(item.TrackingReviewStatus, status, StringComparison.OrdinalIgnoreCase)))
|
||||
?? rows.FirstOrDefault()?.TrackingReviewStatus
|
||||
?? "clear";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryGroupName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryGroupName))
|
||||
{
|
||||
return item.CategoryGroupName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.GroupName ?? "Unbekannte Hauptkategorie";
|
||||
}
|
||||
|
||||
private static string ResolveCategoryName(AdminNominationRow item, IEnumerable<dynamic> categoryRows)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(item.CategoryName))
|
||||
{
|
||||
return item.CategoryName.Trim();
|
||||
}
|
||||
|
||||
var category = categoryRows.FirstOrDefault(row => item.CategoryId.HasValue && row.Id == item.CategoryId.Value);
|
||||
return category?.Name ?? ResolveCategoryGroupName(item, categoryRows);
|
||||
}
|
||||
|
||||
private static AdminTrackingFlagHitDto ToTrackingFlagHitDto(TrackingFlagHit flag) =>
|
||||
new(
|
||||
flag.Key,
|
||||
flag.Label,
|
||||
flag.Severity,
|
||||
flag.Description,
|
||||
flag.RequiresManualReview,
|
||||
flag.BlocksApproval,
|
||||
flag.AdminNoteRequiredOnOverride);
|
||||
|
||||
private static AdminTrackingMetricStateDto[] BuildTrackingMetricStateDtos(
|
||||
AdminNominationRow row,
|
||||
TrackingRulesConfiguration trackingRules) =>
|
||||
trackingRules.ImportantMetrics
|
||||
.Concat(trackingRules.OptionalMetrics)
|
||||
.Where(metric => metric.Enabled && metric.ShowInReview)
|
||||
.Select(metric => new AdminTrackingMetricStateDto(
|
||||
metric.Key,
|
||||
metric.Label,
|
||||
metric.RequiredForAutoClassification,
|
||||
metric.SourceSupport,
|
||||
MetricPresent(metric, row),
|
||||
MetricValue(metric, row),
|
||||
metric.Description,
|
||||
metric.WindowKey,
|
||||
TrackingRulesSettings.WindowLabel(metric.WindowKey),
|
||||
TrackingRulesSettings.SupportsAutomaticWindow(metric)))
|
||||
.ToArray();
|
||||
|
||||
private static bool MetricPresent(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers.HasValue,
|
||||
TrackingRulesSettings.TrackerStatus => !string.IsNullOrWhiteSpace(row.TrackerStatus),
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt.HasValue,
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed.HasValue,
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched.HasValue,
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers.HasValue,
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained.HasValue,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MetricValue(TrackingMetricRuleSetting metric, AdminNominationRow row)
|
||||
{
|
||||
if (metric.SourceSupport == "auto" && !TrackingRulesSettings.SupportsAutomaticWindow(metric))
|
||||
{
|
||||
return $"Auto nur fuer {string.Join(", ", metric.AutoSupportedWindowKeys.Select(TrackingRulesSettings.WindowLabel))}";
|
||||
}
|
||||
|
||||
return metric.Key switch
|
||||
{
|
||||
TrackingRulesSettings.AvgViewers => row.AvgViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.TrackerStatus => string.IsNullOrWhiteSpace(row.TrackerStatus) ? "offen" : row.TrackerStatus,
|
||||
TrackingRulesSettings.TrackerCheckedAt => row.TrackerCheckedAt?.ToString("g") ?? "offen",
|
||||
TrackingRulesSettings.HoursStreamed => row.HoursStreamed?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.HoursWatched => row.HoursWatched?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.PeakViewers => row.PeakViewers?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.FollowersGained => row.FollowersGained?.ToString() ?? "offen",
|
||||
TrackingRulesSettings.CategoryFit => "Manueller Kategorie-Check",
|
||||
TrackingRulesSettings.TopCategoriesContext => BuildTopCategoriesContextSummary(metric),
|
||||
_ => "manuell",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTopCategoriesContextSummary(TrackingMetricRuleSetting metric)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (metric.TopCount.HasValue)
|
||||
{
|
||||
parts.Add($"Top {metric.TopCount.Value}");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategorySharePercent.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategorySharePercent.Value}% Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MinPrimaryCategoryHours.HasValue)
|
||||
{
|
||||
parts.Add($">= {metric.MinPrimaryCategoryHours.Value}h Hauptkategorie");
|
||||
}
|
||||
|
||||
if (metric.MaxDistinctCategoriesBeforeFlag.HasValue)
|
||||
{
|
||||
parts.Add($"Flag ab {metric.MaxDistinctCategoriesBeforeFlag.Value}+ Kategorien");
|
||||
}
|
||||
|
||||
if (metric.IgnoredCategories.Length > 0)
|
||||
{
|
||||
parts.Add($"Ignore: {string.Join(", ", metric.IgnoredCategories)}");
|
||||
}
|
||||
|
||||
return parts.Count > 0
|
||||
? string.Join(" · ", parts)
|
||||
: "Top-Kategorien manuell pruefen";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user