# 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** ```typescript // 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 = { status: { equals: 'published' }, actualPublishDate: { greater_than_equal: start.toISOString() }, } if (channel !== 'all') { where.channel = { equals: parseInt(channel) } } // Previous period where clause const prevWhere: Record = { 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** ```bash 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: ```typescript async function getPipelineData(payload: any, channel: string) { const channelFilter: Record = 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 = {} 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 = { 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** ```typescript 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 = { 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** ```typescript async function getCommunityData(payload: any, channel: string, period: string) { const { start } = getPeriodDates(period) const where: Record = { 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() 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: ```typescript 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** ```bash git add src/app/\(payload\)/api/youtube/analytics/route.ts git commit -m "feat(yt-analytics): add pipeline, goals, community tab handlers" ``` --- ### Task 3: Nav Link Component **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`: ```typescript // 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 (
🎬 YouTube
) } export default YouTubeAnalyticsNavLinks ``` **Step 2: Create nav link SCSS** ```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** ```bash 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** ```typescript // src/components/admin/YouTubeAnalyticsDashboardView.tsx 'use client' import React from 'react' import { YouTubeAnalyticsDashboard } from './YouTubeAnalyticsDashboard' export const YouTubeAnalyticsDashboardView: React.FC = () => { return } 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: ```typescript 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: ```typescript 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** ```bash 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: ```typescript 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 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** ```bash 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** ```bash pnpm payload generate:importmap ``` Expected: importMap.js updated with new component paths **Step 2: Build** ```bash pm2 stop payload && NODE_OPTIONS="--no-deprecation --max-old-space-size=2048" pnpm build ``` Expected: Build succeeds without errors **Step 3: Start and verify** ```bash 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** ```bash 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** ```bash 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