b53c7fb736
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>
210 lines
9.9 KiB
TypeScript
210 lines
9.9 KiB
TypeScript
import { computed } from 'vue'
|
|
import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vote } from '@lucide/vue'
|
|
|
|
import { getVoteMetricValue } from '../../lib/adminMetrics'
|
|
import { useAwardsStore } from '../../stores/awards'
|
|
|
|
export function useAdminAnalyticsManager() {
|
|
const store = useAwardsStore()
|
|
|
|
const seasonDetail = computed(() => store.adminSeasonDetail)
|
|
const topCategories = computed(() => store.admin.topCategories)
|
|
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
|
|
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
|
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
|
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
|
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
|
|
|
|
const categoryHealth = computed(() =>
|
|
seasonDetail.value.categories
|
|
.map((category) => {
|
|
const candidates = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
|
|
const reviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
|
const hasWinner = resultMap.value.has(category.id)
|
|
const votes = voteMap.value.get(category.name) ?? 0
|
|
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
|
|
|
|
return {
|
|
id: category.id,
|
|
name: category.name,
|
|
groupName: category.groupName,
|
|
candidates,
|
|
reviews,
|
|
hasWinner,
|
|
votes,
|
|
votePct: totalVotes.value > 0 ? Math.round((votes / totalVotes.value) * 100) : 0,
|
|
status,
|
|
statusClass: candidates === 0
|
|
? 'border-rose-100 bg-rose-50 text-rose-700'
|
|
: reviews > 0
|
|
? 'border-amber-100 bg-amber-50 text-amber-700'
|
|
: hasWinner
|
|
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
|
: 'border-sky-100 bg-sky-50 text-sky-700',
|
|
statusDot: candidates === 0 ? 'bg-rose-400' : reviews > 0 ? 'bg-amber-400' : hasWinner ? 'bg-emerald-400' : 'bg-sky-400',
|
|
}
|
|
})
|
|
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
|
|
)
|
|
|
|
const categoryGroups = computed(() => {
|
|
const groups = new Map<string, typeof categoryHealth.value>()
|
|
for (const cat of categoryHealth.value) {
|
|
const key = cat.groupName || 'Ohne Gruppe'
|
|
if (!groups.has(key)) groups.set(key, [])
|
|
groups.get(key)!.push(cat)
|
|
}
|
|
return [...groups.entries()].map(([name, cats]) => ({ name, cats }))
|
|
})
|
|
|
|
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
|
|
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
|
|
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
|
|
const categoriesReady = computed(() => categoryHealth.value.filter((c) => c.status === 'Bereit' || c.status === 'Gewinner gesetzt'))
|
|
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
|
const winnerCoveragePct = computed(() => {
|
|
const categoryCount = seasonDetail.value.categories.length
|
|
if (categoryCount === 0) return 0
|
|
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
|
|
})
|
|
const readinessPct = computed(() => {
|
|
const total = categoryHealth.value.length
|
|
if (total === 0) return 0
|
|
return Math.round((categoriesReady.value.length / total) * 100)
|
|
})
|
|
|
|
const metricCards = computed(() => [
|
|
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
|
|
{ label: 'Stimmen', value: totalVotes.value, note: 'gezählte Votes', icon: Vote, tone: 'text-violet-700 bg-violet-50 border-violet-100' },
|
|
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'freigegeben in Kategorien', icon: Users, tone: 'text-cyan-700 bg-cyan-50 border-cyan-100' },
|
|
{ label: 'Review-Backlog', value: seasonDetail.value.pendingNominations.length, note: 'offene Entscheidungen', icon: Clock3, tone: 'text-amber-700 bg-amber-50 border-amber-100' },
|
|
])
|
|
|
|
const readinessCards = computed(() => [
|
|
{
|
|
label: 'Kategorie-Abdeckung',
|
|
value: `${seasonDetail.value.categories.length - emptyCategories.value.length}/${seasonDetail.value.categories.length}`,
|
|
note: emptyCategories.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${emptyCategories.value.length} Kategorien sind noch leer.`,
|
|
icon: Tags,
|
|
to: emptyCategories.value.length > 0
|
|
? `/admin/categories?group=${encodeURIComponent(emptyCategories.value[0]!.groupName)}`
|
|
: '/admin/categories',
|
|
},
|
|
{
|
|
label: 'Gewinnerstatus',
|
|
value: `${winnerCoveragePct.value}%`,
|
|
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
|
|
icon: Trophy,
|
|
to: categoriesWithoutWinner.value.length > 0
|
|
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
|
|
: '/admin/winners?status=set',
|
|
},
|
|
{
|
|
label: 'Freigabe-Risiko',
|
|
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
|
|
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
|
|
icon: AlertTriangle,
|
|
to: categoriesWithReviews.value.length > 0
|
|
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
|
|
: '/admin/clips?status=pending',
|
|
},
|
|
])
|
|
|
|
const insightCards = computed(() => {
|
|
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
|
|
const busiestReviewCategory = categoriesWithReviews.value[0]
|
|
const strongestCategory = topCategories.value[0]
|
|
|
|
return [
|
|
{
|
|
label: 'Votes pro Kandidat',
|
|
value: votesPerCandidate.toLocaleString('de-DE'),
|
|
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
|
|
icon: Vote,
|
|
},
|
|
{
|
|
label: 'Staerkste Kategorie',
|
|
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
|
|
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
|
|
icon: CheckCircle2,
|
|
},
|
|
{
|
|
label: 'Review-Hotspot',
|
|
value: String(busiestReviewCategory?.reviews ?? 0),
|
|
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
|
|
icon: Clock3,
|
|
to: busiestReviewCategory
|
|
? `/admin/nominations?review=1&categoryId=${busiestReviewCategory.id}&status=heavy`
|
|
: '/admin/nominations?review=1',
|
|
},
|
|
]
|
|
})
|
|
|
|
const attentionItems = computed(() => [
|
|
{
|
|
key: 'empty-categories',
|
|
label: 'Leere Kategorien',
|
|
value: emptyCategories.value.length,
|
|
note: emptyCategories.value.length === 0 ? 'Keine Luecke in der Kandidatenbasis.' : emptyCategories.value.slice(0, 3).map((category) => category.name).join(', '),
|
|
to: emptyCategories.value.length > 0
|
|
? `/admin/categories?group=${encodeURIComponent(emptyCategories.value[0]!.groupName)}`
|
|
: '/admin/categories',
|
|
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
|
},
|
|
{
|
|
key: 'pending-reviews',
|
|
label: 'Offene Reviews',
|
|
value: seasonDetail.value.pendingNominations.length,
|
|
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
|
|
to: categoriesWithReviews.value.length > 0
|
|
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
|
|
: '/admin/nominations?review=1',
|
|
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
|
},
|
|
{
|
|
key: 'missing-winners',
|
|
label: 'Gewinner fehlen',
|
|
value: categoriesWithoutWinner.value.length,
|
|
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
|
|
to: categoriesWithoutWinner.value.length > 0
|
|
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
|
|
: '/admin/winners?status=set',
|
|
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
|
},
|
|
])
|
|
|
|
// Top categories enriched with vote share %
|
|
const topCategoriesEnriched = computed(() =>
|
|
topCategories.value.map((cat) => ({
|
|
...cat,
|
|
pct: totalVotes.value > 0 ? Math.round((cat.votes / totalVotes.value) * 100) : 0,
|
|
barWidth: totalVotes.value > 0 ? Math.max(2, Math.round((cat.votes / maxVotes.value) * 100)) : 2,
|
|
})),
|
|
)
|
|
|
|
// Health counts for status summary
|
|
const healthSummary = computed(() => ({
|
|
leer: emptyCategories.value.length,
|
|
reviewOffen: categoriesWithReviews.value.length,
|
|
bereit: categoryHealth.value.filter((c) => c.status === 'Bereit').length,
|
|
gewinner: categoryHealth.value.filter((c) => c.status === 'Gewinner gesetzt').length,
|
|
total: categoryHealth.value.length,
|
|
}))
|
|
|
|
return {
|
|
categoryHealth,
|
|
categoryGroups,
|
|
metricCards,
|
|
readinessCards,
|
|
insightCards,
|
|
attentionItems,
|
|
topCategories,
|
|
topCategoriesEnriched,
|
|
maxVotes,
|
|
winnerCoveragePct,
|
|
readinessPct,
|
|
healthSummary,
|
|
totalVotes,
|
|
}
|
|
}
|