cms.c2sgmbh/docs/plans/2026-02-13-youtube-analytics-dashboard.md
Martin Porwoll 06c93ba05c feat: add YouTube Analytics Dashboard custom admin view
Custom admin view at /admin/youtube-analytics with 4 tabs:
- Performance: Views, Watch Time, CTR, Subscribers with period comparison
- Pipeline: Status distribution, scheduled videos, overdue tasks
- Goals: Monthly target progress bars and custom KPIs
- Community: Sentiment analysis, response time, top topics

Includes channel selector, period selector (7d/30d/90d), and
sidebar nav link in the YouTube section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:50:35 +00:00

28 KiB

YouTube Analytics Dashboard Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a custom admin view at /admin/youtube-analytics with 4 tabs (Performance, Pipeline, Goals, Community), channel/period selectors, and a sidebar nav link.

Architecture: Single client component with tab navigation. One API route serves all tabs via ?tab=X query parameter, loading data lazily per tab switch. Follows the TenantDashboard pattern (BEM CSS, Payload variables, useTenantSelection()).

Tech Stack: React 19, Next.js 16, Payload CMS 3.76.1, SCSS (BEM), Payload CSS variables

Design doc: docs/plans/2026-02-13-youtube-analytics-dashboard-design.md


Task 1: API Route — Performance Tab

Files:

  • Create: src/app/(payload)/api/youtube/analytics/route.ts

Step 1: Create the API route with performance tab handler

// src/app/(payload)/api/youtube/analytics/route.ts

import { getPayload } from 'payload'
import config from '@payload-config'
import { NextRequest, NextResponse } from 'next/server'
import { subDays } from 'date-fns'

interface UserWithYouTubeRole {
  id: number
  isSuperAdmin?: boolean
  youtubeRole?: string
}

function getPeriodDates(period: string) {
  const now = new Date()
  switch (period) {
    case '7d':
      return { start: subDays(now, 7), prevStart: subDays(now, 14), prevEnd: subDays(now, 7) }
    case '90d':
      return { start: subDays(now, 90), prevStart: subDays(now, 180), prevEnd: subDays(now, 90) }
    default:
      return { start: subDays(now, 30), prevStart: subDays(now, 60), prevEnd: subDays(now, 30) }
  }
}

async function getPerformanceData(payload: any, channel: string, period: string) {
  const { start, prevStart, prevEnd } = getPeriodDates(period)

  // Build where clause for published videos with performance data
  const where: Record<string, unknown> = {
    status: { equals: 'published' },
    actualPublishDate: { greater_than_equal: start.toISOString() },
  }
  if (channel !== 'all') {
    where.channel = { equals: parseInt(channel) }
  }

  // Previous period where clause
  const prevWhere: Record<string, unknown> = {
    status: { equals: 'published' },
    actualPublishDate: {
      greater_than_equal: prevStart.toISOString(),
      less_than: prevEnd.toISOString(),
    },
  }
  if (channel !== 'all') {
    prevWhere.channel = { equals: parseInt(channel) }
  }

  const [current, previous] = await Promise.all([
    payload.find({
      collection: 'youtube-content',
      where,
      limit: 100,
      depth: 0,
      sort: '-performance.views',
    }),
    payload.find({
      collection: 'youtube-content',
      where: prevWhere,
      limit: 100,
      depth: 0,
    }),
  ])

  const docs = current.docs
  const prevDocs = previous.docs

  // Aggregate current period
  let totalViews = 0, totalWatchTime = 0, totalCtr = 0, totalSubscribers = 0
  let totalLikes = 0, totalComments = 0, totalShares = 0
  let ctrCount = 0

  for (const doc of docs) {
    const p = doc.performance || {}
    totalViews += p.views || 0
    totalWatchTime += p.watchTimeMinutes || 0
    if (p.ctr != null) { totalCtr += p.ctr; ctrCount++ }
    totalSubscribers += p.subscribersGained || 0
    totalLikes += p.likes || 0
    totalComments += p.comments || 0
    totalShares += p.shares || 0
  }

  // Aggregate previous period
  let prevViews = 0, prevWatchTime = 0, prevSubscribers = 0
  for (const doc of prevDocs) {
    const p = doc.performance || {}
    prevViews += p.views || 0
    prevWatchTime += p.watchTimeMinutes || 0
    prevSubscribers += p.subscribersGained || 0
  }

  // Top 5 and Bottom 5
  const sorted = [...docs].sort((a, b) => (b.performance?.views || 0) - (a.performance?.views || 0))
  const mapVideo = (d: any) => ({
    id: d.id,
    title: d.title,
    thumbnailText: d.thumbnailText || '',
    views: d.performance?.views || 0,
    ctr: d.performance?.ctr || 0,
    avgRetention: d.performance?.avgViewPercentage || 0,
    likes: d.performance?.likes || 0,
  })

  return {
    stats: {
      totalViews,
      totalWatchTimeHours: Math.round((totalWatchTime / 60) * 10) / 10,
      avgCtr: ctrCount > 0 ? Math.round((totalCtr / ctrCount) * 100) / 100 : 0,
      subscribersGained: totalSubscribers,
    },
    comparison: {
      views: prevViews > 0 ? Math.round(((totalViews - prevViews) / prevViews) * 100) : 0,
      watchTime: prevWatchTime > 0 ? Math.round(((totalWatchTime - prevWatchTime) / prevWatchTime) * 100) : 0,
      subscribers: prevSubscribers > 0 ? Math.round(((totalSubscribers - prevSubscribers) / prevSubscribers) * 100) : 0,
    },
    topVideos: sorted.slice(0, 5).map(mapVideo),
    bottomVideos: sorted.length > 5 ? sorted.slice(-5).reverse().map(mapVideo) : [],
    engagement: { likes: totalLikes, comments: totalComments, shares: totalShares },
  }
}

export async function GET(req: NextRequest) {
  try {
    const payload = await getPayload({ config })
    const { user } = await payload.auth({ headers: req.headers })
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    const typedUser = user as UserWithYouTubeRole
    if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
      return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
    }

    const { searchParams } = new URL(req.url)
    const tab = searchParams.get('tab') || 'performance'
    const channel = searchParams.get('channel') || 'all'
    const period = searchParams.get('period') || '30d'

    // Channels always needed for selector
    const channels = await payload.find({
      collection: 'youtube-channels',
      where: { status: { equals: 'active' } },
      depth: 0,
      limit: 50,
    })

    let tabData: unknown = {}

    switch (tab) {
      case 'performance':
        tabData = await getPerformanceData(payload, channel, period)
        break
      // Pipeline, Goals, Community added in subsequent tasks
      default:
        tabData = {}
    }

    return NextResponse.json({
      tab,
      channel,
      period,
      channels: channels.docs.map((c: any) => ({ id: c.id, name: c.name, slug: c.slug })),
      data: tabData,
    })
  } catch (error) {
    console.error('[YouTube Analytics] Error:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      { status: 500 },
    )
  }
}

export const dynamic = 'force-dynamic'

Step 2: Verify route loads

Run: curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/api/youtube/analytics Expected: 401 (no auth) — confirms route is registered

Step 3: Commit

git add src/app/\(payload\)/api/youtube/analytics/route.ts
git commit -m "feat(yt-analytics): add API route with performance tab"

Task 2: API Route — Pipeline, Goals, Community Tabs

Files:

  • Modify: src/app/(payload)/api/youtube/analytics/route.ts

Step 1: Add pipeline tab handler

Add this function before the GET handler:

async function getPipelineData(payload: any, channel: string) {
  const channelFilter: Record<string, unknown> = channel !== 'all'
    ? { channel: { equals: parseInt(channel) } }
    : {}

  // Pipeline status counts (parallel)
  const statuses = ['idea', 'script_draft', 'script_review', 'script_approved',
    'shoot_scheduled', 'shot', 'rough_cut', 'fine_cut', 'final_review',
    'approved', 'upload_scheduled', 'published']

  const counts = await Promise.all(
    statuses.map((s) =>
      payload.count({
        collection: 'youtube-content',
        where: { status: { equals: s }, ...channelFilter },
      }),
    ),
  )

  const pipeline: Record<string, number> = {}
  statuses.forEach((s, i) => { pipeline[s] = counts[i].totalDocs })

  // In production count (everything between idea and published exclusive)
  const inProduction = Object.entries(pipeline)
    .filter(([k]) => k !== 'published')
    .reduce((sum, [, v]) => sum + v, 0)

  // This week scheduled
  const weekStart = new Date()
  weekStart.setHours(0, 0, 0, 0)
  const weekEnd = new Date(weekStart)
  weekEnd.setDate(weekEnd.getDate() + 7)

  const weekWhere: Record<string, unknown> = {
    scheduledPublishDate: {
      greater_than_equal: weekStart.toISOString(),
      less_than: weekEnd.toISOString(),
    },
    ...channelFilter,
  }

  const [thisWeek, overdueTasks, pendingApprovals] = await Promise.all([
    payload.find({
      collection: 'youtube-content',
      where: weekWhere,
      sort: 'scheduledPublishDate',
      depth: 1,
      limit: 20,
    }),
    payload.find({
      collection: 'yt-tasks',
      where: {
        and: [
          { status: { not_in: ['done', 'cancelled'] } },
          { dueDate: { less_than: new Date().toISOString() } },
          ...(channel !== 'all' ? [{ channel: { equals: parseInt(channel) } }] : []),
        ],
      },
      depth: 1,
      limit: 20,
      sort: 'dueDate',
    }),
    payload.count({
      collection: 'youtube-content',
      where: {
        status: { in: ['script_review', 'final_review'] },
        ...channelFilter,
      },
    }),
  ])

  return {
    stats: {
      inProduction,
      thisWeekCount: thisWeek.totalDocs,
      overdueTasksCount: overdueTasks.totalDocs,
      pendingApprovals: pendingApprovals.totalDocs,
    },
    pipeline,
    thisWeekVideos: thisWeek.docs.map((d: any) => ({
      id: d.id,
      title: d.title,
      status: d.status,
      scheduledPublishDate: d.scheduledPublishDate,
      channel: typeof d.channel === 'object' ? d.channel?.name : d.channel,
    })),
    overdueTasks: overdueTasks.docs.map((d: any) => ({
      id: d.id,
      title: d.title,
      dueDate: d.dueDate,
      assignedTo: typeof d.assignedTo === 'object' ? d.assignedTo?.email : d.assignedTo,
      video: typeof d.video === 'object' ? d.video?.title : null,
    })),
  }
}

Step 2: Add goals tab handler

async function getGoalsData(payload: any, channel: string) {
  // Find current month's goals
  const now = new Date()
  const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
  const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)

  const where: Record<string, unknown> = {
    month: {
      greater_than_equal: monthStart.toISOString(),
      less_than_equal: monthEnd.toISOString(),
    },
  }
  if (channel !== 'all') {
    where.channel = { equals: parseInt(channel) }
  }

  const goals = await payload.find({
    collection: 'yt-monthly-goals',
    where,
    depth: 1,
    limit: 10,
  })

  if (goals.docs.length === 0) {
    return { stats: { contentProgress: 0, subscriberProgress: 0, viewsProgress: 0, overall: 0 }, goals: [], customGoals: [] }
  }

  // Aggregate across all matching goals (could be multiple channels)
  let totalContentTarget = 0, totalContentCurrent = 0
  let totalSubsTarget = 0, totalSubsCurrent = 0
  let totalViewsTarget = 0, totalViewsCurrent = 0
  const allCustomGoals: any[] = []

  for (const goal of goals.docs) {
    const c = goal.contentGoals || {}
    totalContentTarget += (c.longformsTarget || 0) + (c.shortsTarget || 0)
    totalContentCurrent += (c.longformsCurrent || 0) + (c.shortsCurrent || 0)

    const a = goal.audienceGoals || {}
    totalSubsTarget += a.subscribersTarget || 0
    totalSubsCurrent += a.subscribersCurrent || 0
    totalViewsTarget += a.viewsTarget || 0
    totalViewsCurrent += a.viewsCurrent || 0

    if (goal.customGoals) {
      for (const cg of goal.customGoals) {
        allCustomGoals.push({
          metric: cg.metric,
          target: cg.target,
          current: cg.current,
          status: cg.status,
        })
      }
    }
  }

  const pct = (current: number, target: number) => target > 0 ? Math.round((current / target) * 100) : 0
  const contentPct = pct(totalContentCurrent, totalContentTarget)
  const subsPct = pct(totalSubsCurrent, totalSubsTarget)
  const viewsPct = pct(totalViewsCurrent, totalViewsTarget)
  const overall = Math.round((contentPct + subsPct + viewsPct) / 3)

  return {
    stats: {
      contentProgress: contentPct,
      contentLabel: `${totalContentCurrent}/${totalContentTarget}`,
      subscriberProgress: subsPct,
      subscriberLabel: `${totalSubsCurrent}/${totalSubsTarget}`,
      viewsProgress: viewsPct,
      viewsLabel: `${totalViewsCurrent}/${totalViewsTarget}`,
      overall,
    },
    goals: goals.docs.map((g: any) => ({
      id: g.id,
      channel: typeof g.channel === 'object' ? g.channel?.name : g.channel,
      month: g.month,
      contentGoals: g.contentGoals,
      audienceGoals: g.audienceGoals,
      engagementGoals: g.engagementGoals,
      businessGoals: g.businessGoals,
      notes: g.notes,
    })),
    customGoals: allCustomGoals,
  }
}

Step 3: Add community tab handler

async function getCommunityData(payload: any, channel: string, period: string) {
  const { start } = getPeriodDates(period)

  const where: Record<string, unknown> = {
    publishedAt: { greater_than_equal: start.toISOString() },
  }
  if (channel !== 'all') {
    where.socialAccount = { equals: parseInt(channel) }
  }

  const [interactions, unresolved, escalations] = await Promise.all([
    payload.find({
      collection: 'community-interactions',
      where,
      limit: 500,
      depth: 0,
    }),
    payload.count({
      collection: 'community-interactions',
      where: { ...where, status: { equals: 'new' } },
    }),
    payload.count({
      collection: 'community-interactions',
      where: { ...where, 'flags.requiresEscalation': { equals: true }, status: { not_equals: 'resolved' } },
    }),
  ])

  const docs = interactions.docs

  // Sentiment distribution
  const sentiment = { positive: 0, neutral: 0, negative: 0, question: 0 }
  for (const d of docs) {
    const s = d.analysis?.sentiment as string
    if (s && s in sentiment) sentiment[s as keyof typeof sentiment]++
  }
  const total = docs.length
  const positivePct = total > 0 ? Math.round((sentiment.positive / total) * 100) : 0

  // Avg response time (hours)
  let totalResponseTime = 0, responseCount = 0
  for (const d of docs) {
    if (d.response?.sentAt && d.publishedAt) {
      const diff = new Date(d.response.sentAt as string).getTime() - new Date(d.publishedAt as string).getTime()
      totalResponseTime += diff / (1000 * 60 * 60)
      responseCount++
    }
  }
  const avgResponseHours = responseCount > 0 ? Math.round(totalResponseTime / responseCount * 10) / 10 : 0

  // Top topics
  const topicMap = new Map<string, number>()
  for (const d of docs) {
    const topics = d.analysis?.topics as string[] | undefined
    if (topics) {
      for (const t of topics) { topicMap.set(t, (topicMap.get(t) || 0) + 1) }
    }
  }
  const topTopics = [...topicMap.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(([topic, count]) => ({ topic, count }))

  // Recent unresolved (newest 5)
  const recent = await payload.find({
    collection: 'community-interactions',
    where: { ...where, status: { equals: 'new' } },
    sort: '-publishedAt',
    limit: 5,
    depth: 0,
  })

  return {
    stats: {
      unresolvedCount: unresolved.totalDocs,
      positivePct,
      avgResponseHours,
      escalationsCount: escalations.totalDocs,
    },
    sentiment,
    topTopics,
    recentUnresolved: recent.docs.map((d: any) => ({
      id: d.id,
      message: (d.message as string || '').slice(0, 120),
      authorName: d.author?.name || 'Unknown',
      authorHandle: d.author?.handle || '',
      publishedAt: d.publishedAt,
      platform: d.platform,
      sentiment: d.analysis?.sentiment,
    })),
  }
}

Step 4: Wire all tabs into the switch statement

In the GET handler, replace the switch:

    switch (tab) {
      case 'performance':
        tabData = await getPerformanceData(payload, channel, period)
        break
      case 'pipeline':
        tabData = await getPipelineData(payload, channel)
        break
      case 'goals':
        tabData = await getGoalsData(payload, channel)
        break
      case 'community':
        tabData = await getCommunityData(payload, channel, period)
        break
      default:
        tabData = await getPerformanceData(payload, channel, period)
    }

Step 5: Commit

git add src/app/\(payload\)/api/youtube/analytics/route.ts
git commit -m "feat(yt-analytics): add pipeline, goals, community tab handlers"

Files:

  • Create: src/components/admin/YouTubeAnalyticsNavLinks.tsx
  • Create: src/components/admin/YouTubeAnalyticsNavLinks.scss

Step 1: Create nav link component

Follow the exact pattern from CommunityNavLinks.tsx:

// src/components/admin/YouTubeAnalyticsNavLinks.tsx
'use client'

import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

import './YouTubeAnalyticsNavLinks.scss'

export const YouTubeAnalyticsNavLinks: React.FC = () => {
  const pathname = usePathname()

  const links = [
    {
      href: '/admin/youtube-analytics',
      label: 'YouTube Analytics',
      icon: '📈',
    },
  ]

  return (
    <div className="yt-nav-links">
      <div className="yt-nav-links__header">
        <span className="yt-nav-links__icon">🎬</span>
        <span className="yt-nav-links__title">YouTube</span>
      </div>
      <nav className="yt-nav-links__list">
        {links.map((link) => {
          const isActive = pathname === link.href
          return (
            <Link
              key={link.href}
              href={link.href}
              className={`yt-nav-links__link ${isActive ? 'yt-nav-links__link--active' : ''}`}
            >
              <span className="yt-nav-links__link-icon">{link.icon}</span>
              <span className="yt-nav-links__link-label">{link.label}</span>
            </Link>
          )
        })}
      </nav>
    </div>
  )
}

export default YouTubeAnalyticsNavLinks

Step 2: Create nav link SCSS

// src/components/admin/YouTubeAnalyticsNavLinks.scss
.yt-nav-links {
  padding: 0 var(--base);
  margin-top: var(--base);
  border-top: 1px solid var(--theme-elevation-100);
  padding-top: var(--base);

  &__header {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--theme-elevation-500);
  }

  &__icon {
    font-size: 14px;
  }

  &__title {
    flex: 1;
  }

  &__list {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  &__link {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    border-radius: 4px;
    text-decoration: none;
    color: var(--theme-elevation-800);
    font-size: 13px;
    transition: all 0.15s ease;

    &:hover {
      background-color: var(--theme-elevation-50);
      color: var(--theme-elevation-900);
    }

    &--active {
      background-color: var(--theme-elevation-100);
      color: var(--theme-text);
      font-weight: 500;

      &:hover {
        background-color: var(--theme-elevation-150);
      }
    }
  }

  &__link-icon {
    font-size: 16px;
    width: 20px;
    text-align: center;
  }

  &__link-label {
    flex: 1;
  }
}

[data-theme='dark'] {
  .yt-nav-links {
    &__link {
      &:hover {
        background-color: var(--theme-elevation-100);
      }

      &--active {
        background-color: var(--theme-elevation-150);

        &:hover {
          background-color: var(--theme-elevation-200);
        }
      }
    }
  }
}

Step 3: Commit

git add src/components/admin/YouTubeAnalyticsNavLinks.tsx src/components/admin/YouTubeAnalyticsNavLinks.scss
git commit -m "feat(yt-analytics): add sidebar nav link component"

Task 4: View Wrapper + Registration in payload.config.ts

Files:

  • Create: src/components/admin/YouTubeAnalyticsDashboardView.tsx
  • Modify: src/payload.config.ts:128-141

Step 1: Create wrapper component

// src/components/admin/YouTubeAnalyticsDashboardView.tsx
'use client'

import React from 'react'
import { YouTubeAnalyticsDashboard } from './YouTubeAnalyticsDashboard'

export const YouTubeAnalyticsDashboardView: React.FC = () => {
  return <YouTubeAnalyticsDashboard />
}

export default YouTubeAnalyticsDashboardView

Note: The YouTubeAnalyticsDashboard component is created in Task 5. This wrapper will fail to compile until Task 5 is done — that's expected.

Step 2: Register in payload.config.ts

Change lines 130-140 from:

    components: {
      // Community Management Nav Links
      afterNavLinks: ['@/components/admin/CommunityNavLinks#CommunityNavLinks'],
      // Custom Views - re-enabled after Payload 3.76.1 upgrade (Issue #15241 test)
      views: {
        TenantDashboard: {
          Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
          path: '/tenant-dashboard',
        },
      },
    },

To:

    components: {
      afterNavLinks: [
        '@/components/admin/CommunityNavLinks#CommunityNavLinks',
        '@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
      ],
      views: {
        TenantDashboard: {
          Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
          path: '/tenant-dashboard',
        },
        YouTubeAnalyticsDashboard: {
          Component: '@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView',
          path: '/youtube-analytics',
        },
      },
    },

Step 3: Commit

git add src/components/admin/YouTubeAnalyticsDashboardView.tsx src/payload.config.ts
git commit -m "feat(yt-analytics): register custom view and nav link in config"

Task 5: Main Dashboard Component — Shell with Tabs

Files:

  • Create: src/components/admin/YouTubeAnalyticsDashboard.tsx
  • Create: src/components/admin/YouTubeAnalyticsDashboard.scss

Step 1: Create main component with tab shell, channel/period selectors, loading/error states

The component structure:

  • Header with title, channel selector, period selector, refresh button
  • Tab bar (Performance | Pipeline | Goals | Community)
  • Tab content area (renders based on activeTab state)
  • Each tab is a separate render function within the component
  • Data fetched via useEffect when tab/channel/period changes

The component should follow the TenantDashboard pattern exactly:

  • 'use client' directive
  • useTenantSelection() for tenant context
  • useState for: activeTab, data, loading, error, channel, period, channels
  • useCallback for fetchData
  • useEffect to trigger fetchData on tab/channel/period change
  • credentials: 'include' on fetch
  • Loading spinner, error display, no-tenant fallback

Key types to define:

type Tab = 'performance' | 'pipeline' | 'goals' | 'community'
type Period = '7d' | '30d' | '90d'

interface Channel { id: number; name: string; slug: string }

// Performance tab types
interface PerformanceData {
  stats: { totalViews: number; totalWatchTimeHours: number; avgCtr: number; subscribersGained: number }
  comparison: { views: number; watchTime: number; subscribers: number }
  topVideos: VideoSummary[]
  bottomVideos: VideoSummary[]
  engagement: { likes: number; comments: number; shares: number }
}
interface VideoSummary { id: number; title: string; thumbnailText: string; views: number; ctr: number; avgRetention: number; likes: number }

// Pipeline tab types
interface PipelineData {
  stats: { inProduction: number; thisWeekCount: number; overdueTasksCount: number; pendingApprovals: number }
  pipeline: Record<string, number>
  thisWeekVideos: { id: number; title: string; status: string; scheduledPublishDate: string; channel: string }[]
  overdueTasks: { id: number; title: string; dueDate: string; assignedTo: string; video: string | null }[]
}

// Goals tab types
interface GoalsData {
  stats: { contentProgress: number; contentLabel: string; subscriberProgress: number; subscriberLabel: string; viewsProgress: number; viewsLabel: string; overall: number }
  goals: any[]
  customGoals: { metric: string; target: string; current: string; status: string }[]
}

// Community tab types
interface CommunityData {
  stats: { unresolvedCount: number; positivePct: number; avgResponseHours: number; escalationsCount: number }
  sentiment: { positive: number; neutral: number; negative: number; question: number }
  topTopics: { topic: string; count: number }[]
  recentUnresolved: { id: number; message: string; authorName: string; authorHandle: string; publishedAt: string; platform: string; sentiment: string }[]
}

Tab rendering sections:

Performance tab:

  • 4 stat cards (Views, Watch Time, CTR, Subscribers) — each with comparison arrow
  • "Top 5 Videos" section with table rows
  • "Bottom 5 Videos" section
  • "Engagement" section with 3 inline stats

Pipeline tab:

  • 4 stat cards (In Production, This Week, Overdue Tasks, Pending Approvals)
  • Pipeline bar: horizontal segmented bar showing count per status
  • "Nächste Veröffentlichungen" list
  • "Überfällige Aufgaben" list

Goals tab:

  • 4 stat cards (Content %, Subscribers %, Views %, Overall %)
  • Progress bars for each goal category
  • Custom goals list with status badges

Community tab:

  • 4 stat cards (Unresolved, Positive %, Avg Response Time, Escalations)
  • Sentiment breakdown (colored bars)
  • Recent unresolved list
  • Top topics list

Step 2: Create SCSS

BEM structure with .yt-analytics prefix. Key classes:

.yt-analytics                    — container
.yt-analytics__header            — title row
.yt-analytics__controls          — channel + period selectors + refresh
.yt-analytics__tabs              — tab bar
.yt-analytics__tab               — single tab
.yt-analytics__tab--active       — active tab
.yt-analytics__content           — tab content area
.yt-analytics__stats-grid        — 4-card grid (reuse TenantDashboard pattern)
.yt-analytics__stat-card         — single stat card
.yt-analytics__stat-card--up     — green trend arrow
.yt-analytics__stat-card--down   — red trend arrow
.yt-analytics__section           — content section
.yt-analytics__pipeline-bar      — horizontal pipeline visualization
.yt-analytics__pipeline-segment  — single pipeline segment
.yt-analytics__progress-bar      — goals progress bar
.yt-analytics__progress-fill     — progress bar fill
.yt-analytics__video-row         — video list item
.yt-analytics__topic-tag         — topic pill
.yt-analytics__comment-item      — community comment preview

Colors for pipeline segments (CSS custom properties):

  • idea: --theme-elevation-300
  • script: #6366f1 (indigo)
  • review: #f59e0b (amber)
  • production: #3b82f6 (blue)
  • editing: #8b5cf6 (violet)
  • ready: #10b981 (emerald)
  • published: var(--theme-success-500)

Responsive: @media (max-width: 768px) — 2-col stat grid, stacked layout

Step 3: Commit

git add src/components/admin/YouTubeAnalyticsDashboard.tsx src/components/admin/YouTubeAnalyticsDashboard.scss
git commit -m "feat(yt-analytics): add main dashboard component with all 4 tabs"

Task 6: Build, Generate ImportMap, Deploy to Staging

Files:

  • Modify: src/app/(payload)/admin/importMap.js (auto-generated)

Step 1: Generate importMap

pnpm payload generate:importmap

Expected: importMap.js updated with new component paths

Step 2: Build

pm2 stop payload && NODE_OPTIONS="--no-deprecation --max-old-space-size=2048" pnpm build

Expected: Build succeeds without errors

Step 3: Start and verify

pm2 start payload

Test endpoints:

  • curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/admin/youtube-analytics → 200
  • Login via browser and navigate to /admin/youtube-analytics
  • Verify nav link appears in sidebar
  • Verify all 4 tabs load data

Step 4: Commit build artifacts

git add src/app/\(payload\)/admin/importMap.js
git commit -m "chore: regenerate importMap for YouTube Analytics Dashboard"

Task 7: Final Commit + Push

Step 1: Push to develop

git push origin develop

Step 2: Verify on staging

Browser test:

  1. Navigate to https://pl.porwoll.tech/admin
  2. Login
  3. Check sidebar for "YouTube" section with "YouTube Analytics" link
  4. Click link → Dashboard loads
  5. Switch tabs: Performance, Pipeline, Goals, Community
  6. Change channel selector
  7. Change period selector
  8. Click refresh button