Benjamin Harris преди 1 месец
родител
ревизия
db5835aba5
променени са 8 файла, в които са добавени 544 реда и са изтрити 6 реда
  1. 1 1
      .gitignore
  2. 79 0
      services/gateway/server.js
  3. 16 5
      services/scheduler/index.js
  4. 1 0
      ui/src/components/NavBar.vue
  5. 40 0
      ui/src/locales/en.ts
  6. 40 0
      ui/src/locales/tr.ts
  7. 5 0
      ui/src/router/index.ts
  8. 362 0
      ui/src/views/Analytics.vue

+ 1 - 1
.gitignore

@@ -1,6 +1,6 @@
 CLAUDE.md
 .env
-.claude
+.claude/
 
 # Logs
 logs

+ 79 - 0
services/gateway/server.js

@@ -521,6 +521,31 @@ app.post('/post', async (request, reply) => {
   );
 
   const anyFailed = output.some((r) => !r.success);
+  const anySucceeded = output.some((r) => r.success);
+  const postStatus = anyFailed && anySucceeded ? 'partial' : anyFailed ? 'failed' : 'published';
+
+  // Record the post for analytics
+  try {
+    const db = await getDb();
+    await db.collection('posts').insertOne({
+      _id: crypto.randomUUID(),
+      type: 'immediate',
+      content,
+      destinations,
+      platformResults: Object.fromEntries(
+        output.map((r) => [
+          r.accountId ? `${r.platform}:${r.accountId}` : r.platform,
+          { success: r.success, ...(r.error && { error: r.error }) },
+        ])
+      ),
+      status: postStatus,
+      publishedAt: new Date(),
+      createdAt: new Date(),
+    });
+  } catch (err) {
+    app.log.warn({ action: 'post_record', outcome: 'failure', err: err.message });
+  }
+
   return reply.code(anyFailed ? 207 : 200).send({ results: output });
 });
 
@@ -746,4 +771,58 @@ app.get('/credentials', async () => {
   };
 });
 
+// ─── Analytics ────────────────────────────────────────────────────────────────
+
+app.get('/analytics/summary', async () => {
+  const db = await getDb();
+  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+  const sevenDaysAgo  = new Date(Date.now() -  7 * 24 * 60 * 60 * 1000);
+
+  const [published, failed, partial, recentCount, byPlatformRaw, byDayRaw] = await Promise.all([
+    db.collection('posts').countDocuments({ status: 'published' }),
+    db.collection('posts').countDocuments({ status: 'failed' }),
+    db.collection('posts').countDocuments({ status: 'partial' }),
+    db.collection('posts').countDocuments({ publishedAt: { $gte: sevenDaysAgo } }),
+    db.collection('posts').aggregate([
+      { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
+      { $unwind: '$results' },
+      { $match: { 'results.v.success': true } },
+      { $project: { platform: { $arrayElemAt: [{ $split: ['$results.k', ':'] }, 0] } } },
+      { $group: { _id: '$platform', count: { $sum: 1 } } },
+      { $sort: { count: -1 } },
+    ]).toArray(),
+    db.collection('posts').aggregate([
+      { $match: { publishedAt: { $gte: thirtyDaysAgo } } },
+      { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
+      { $sort: { _id: 1 } },
+    ]).toArray(),
+  ]);
+
+  const total = published + failed + partial;
+  const successRate = total > 0 ? Math.round(((published + partial) / total) * 100) : 0;
+  const byPlatform = Object.fromEntries(byPlatformRaw.map((p) => [p._id, p.count]));
+  const byDay = byDayRaw.map((d) => ({ date: d._id, count: d.count }));
+
+  return { total, published, failed, partial, successRate, byPlatform, byDay, recentCount };
+});
+
+app.get('/analytics/posts', async (request) => {
+  const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
+  const skip  = parseInt(request.query.skip || '0', 10);
+  const db = await getDb();
+
+  const [posts, total] = await Promise.all([
+    db.collection('posts')
+      .find({})
+      .sort({ publishedAt: -1 })
+      .skip(skip)
+      .limit(limit)
+      .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1, type: 1 })
+      .toArray(),
+    db.collection('posts').countDocuments({}),
+  ]);
+
+  return { posts, total };
+});
+
 module.exports = app;

+ 16 - 5
services/scheduler/index.js

@@ -3,6 +3,7 @@ const Fastify = require('fastify');
 const { Queue, Worker, QueueEvents } = require('bullmq');
 const IORedis = require('ioredis');
 const axios = require('axios');
+const { randomUUID } = require('crypto');
 const { getDb, connect } = require('./utils/MongoDBConnector');
 const { createLogger } = require('./utils/logger');
 
@@ -29,6 +30,8 @@ async function processPostJob(job) {
   // destinations: [{ platform, accountId?, imageUrl?, videoUrl?, link? }]
   // Falls back to legacy { platforms: string[] } format
   const { postId, content, destinations, platforms, media = [] } = job.data;
+  // Ensure every post has a stable ID for analytics tracking
+  const effectivePostId = postId || randomUUID();
 
   const destList = destinations || (platforms || []).map((p) => ({ platform: p }));
   log.info({ action: 'job_process', jobId: job.id, attempt: job.attemptsMade + 1, destinations: destList.map((d) => d.accountId ? `${d.platform}:${d.accountId}` : d.platform) });
@@ -37,7 +40,7 @@ async function processPostJob(job) {
 
   // Load any results already recorded from previous attempts so we can skip
   // destinations that already succeeded — preventing duplicate posts on retry.
-  const existingPost = postId ? await db.collection('posts').findOne({ _id: postId }, { projection: { platformResults: 1 } }) : null;
+  const existingPost = await db.collection('posts').findOne({ _id: effectivePostId }, { projection: { platformResults: 1 } });
   const results = { ...(existingPost?.platformResults || {}) };
 
   for (const dest of destList) {
@@ -62,16 +65,24 @@ async function processPostJob(job) {
     }
   }
 
-  // MongoDB güncelle
+  const allOk  = Object.values(results).every((r) => r.success);
+  const anyOk  = Object.values(results).some((r) => r.success);
+  const postStatus = allOk ? 'published' : anyOk ? 'partial' : 'failed';
+
   await db.collection('posts').updateOne(
-    { _id: postId },
+    { _id: effectivePostId },
     {
       $set: {
-        status: Object.values(results).every((r) => r.success) ? 'published' : 'failed',
+        content,
+        destinations: destList,
+        type: 'scheduled',
+        status: postStatus,
         publishedAt: new Date(),
         platformResults: results,
       },
-    }
+      $setOnInsert: { createdAt: new Date() },
+    },
+    { upsert: true }
   );
 
   await db.collection('scheduled_jobs').updateOne(

+ 1 - 0
ui/src/components/NavBar.vue

@@ -65,6 +65,7 @@ const navLinks = [
   { to: '/compose',   label: 'nav.compose' },
   { to: '/media',     label: 'nav.media' },
   { to: '/scheduler', label: 'nav.scheduler' },
+  { to: '/analytics', label: 'nav.analytics' },
   { to: '/settings',  label: 'nav.settings' },
 ]
 

+ 40 - 0
ui/src/locales/en.ts

@@ -4,9 +4,49 @@ export default {
     compose: 'New Post',
     media: 'Media',
     scheduler: 'Scheduler',
+    analytics: 'Analytics',
     settings: 'Settings',
   },
 
+  analytics: {
+    title: 'Analytics',
+    subtitle: 'Publishing activity across all connected platforms',
+    refresh: 'Refresh',
+    loading: 'Loading analytics…',
+    empty: 'No posts yet.',
+    emptyHint: 'Start publishing to see analytics here.',
+
+    totalPosts: 'Total Posts',
+    last7Days: 'Last 7 Days',
+    platformsReached: 'Platforms Reached',
+    successRate: 'Success Rate',
+
+    postsPerDay: 'Posts per Day',
+    postsPerDaySubtitle: 'Last 30 days',
+    noActivity: 'No activity in the last 30 days.',
+
+    platformBreakdown: 'Platform Breakdown',
+    successfulPosts: 'successful posts',
+
+    statusBreakdown: 'Status Breakdown',
+    published: 'Published',
+    partial: 'Partial',
+    failed: 'Failed',
+
+    recentPosts: 'Recent Posts',
+    colContent: 'Content',
+    colPlatforms: 'Platforms',
+    colDate: 'Date',
+    colStatus: 'Status',
+    statusPublished: 'Published',
+    statusPartial: 'Partial',
+    statusFailed: 'Failed',
+    typeScheduled: 'Scheduled',
+    typeImmediate: 'Immediate',
+    noRecentPosts: 'No posts to show.',
+    loadMore: 'Load more',
+  },
+
   media: {
     title: 'Media Library',
     fileCount: '{count} files',

+ 40 - 0
ui/src/locales/tr.ts

@@ -4,9 +4,49 @@ export default {
     compose: 'Yeni Gönderi',
     media: 'Medya',
     scheduler: 'Zamanlama',
+    analytics: 'Analitik',
     settings: 'Ayarlar',
   },
 
+  analytics: {
+    title: 'Analitik',
+    subtitle: 'Bağlı tüm platformlardaki yayın aktivitesi',
+    refresh: 'Yenile',
+    loading: 'Analitik yükleniyor…',
+    empty: 'Henüz gönderi yok.',
+    emptyHint: 'Analitikleri görmek için yayın yapmaya başla.',
+
+    totalPosts: 'Toplam Gönderi',
+    last7Days: 'Son 7 Gün',
+    platformsReached: 'Ulaşılan Platform',
+    successRate: 'Başarı Oranı',
+
+    postsPerDay: 'Günlük Gönderi',
+    postsPerDaySubtitle: 'Son 30 gün',
+    noActivity: 'Son 30 günde aktivite yok.',
+
+    platformBreakdown: 'Platform Dağılımı',
+    successfulPosts: 'başarılı gönderi',
+
+    statusBreakdown: 'Durum Dağılımı',
+    published: 'Yayınlandı',
+    partial: 'Kısmi',
+    failed: 'Başarısız',
+
+    recentPosts: 'Son Gönderiler',
+    colContent: 'İçerik',
+    colPlatforms: 'Platformlar',
+    colDate: 'Tarih',
+    colStatus: 'Durum',
+    statusPublished: 'Yayınlandı',
+    statusPartial: 'Kısmi',
+    statusFailed: 'Başarısız',
+    typeScheduled: 'Zamanlanmış',
+    typeImmediate: 'Anlık',
+    noRecentPosts: 'Gösterilecek gönderi yok.',
+    loadMore: 'Daha fazla yükle',
+  },
+
   media: {
     title: 'Medya Kütüphanesi',
     fileCount: '{count} dosya',

+ 5 - 0
ui/src/router/index.ts

@@ -27,6 +27,11 @@ const router = createRouter({
       name: 'media',
       component: () => import('../views/Media.vue'),
     },
+    {
+      path: '/analytics',
+      name: 'analytics',
+      component: () => import('../views/Analytics.vue'),
+    },
     {
       path: '/settings',
       name: 'settings',

+ 362 - 0
ui/src/views/Analytics.vue

@@ -0,0 +1,362 @@
+<template>
+  <div class="min-h-screen bg-gray-950 text-gray-100">
+    <div class="max-w-6xl mx-auto px-6 py-8">
+
+      <!-- Header -->
+      <div class="flex items-center justify-between mb-8">
+        <div>
+          <h1 class="text-2xl font-bold text-white">{{ $t('analytics.title') }}</h1>
+          <p class="text-sm text-gray-500 mt-1">{{ $t('analytics.subtitle') }}</p>
+        </div>
+        <button
+          @click="load"
+          :disabled="loading"
+          class="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors"
+        >
+          <span :class="{ 'animate-spin': loading }">↻</span>
+          {{ $t('analytics.refresh') }}
+        </button>
+      </div>
+
+      <!-- Loading -->
+      <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
+        {{ $t('analytics.loading') }}
+      </div>
+
+      <!-- Empty state -->
+      <div v-else-if="summary && summary.total === 0" class="flex flex-col items-center justify-center h-64 gap-2 text-gray-500">
+        <p class="text-lg">{{ $t('analytics.empty') }}</p>
+        <p class="text-sm">{{ $t('analytics.emptyHint') }}</p>
+      </div>
+
+      <template v-else-if="summary">
+
+        <!-- ── Summary cards ─────────────────────────────────── -->
+        <div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-5">
+            <p class="text-xs text-gray-500 mb-1">{{ $t('analytics.totalPosts') }}</p>
+            <p class="text-3xl font-bold text-white">{{ summary.total }}</p>
+          </div>
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-5">
+            <p class="text-xs text-gray-500 mb-1">{{ $t('analytics.last7Days') }}</p>
+            <p class="text-3xl font-bold text-blue-400">{{ summary.recentCount }}</p>
+          </div>
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-5">
+            <p class="text-xs text-gray-500 mb-1">{{ $t('analytics.platformsReached') }}</p>
+            <p class="text-3xl font-bold text-purple-400">{{ platformCount }}</p>
+          </div>
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-5">
+            <p class="text-xs text-gray-500 mb-1">{{ $t('analytics.successRate') }}</p>
+            <p class="text-3xl font-bold" :class="summary.successRate >= 80 ? 'text-green-400' : summary.successRate >= 50 ? 'text-yellow-400' : 'text-red-400'">
+              {{ summary.successRate }}%
+            </p>
+          </div>
+        </div>
+
+        <!-- ── Posts per Day chart ────────────────────────────── -->
+        <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6 mb-6">
+          <div class="flex items-baseline justify-between mb-4">
+            <h2 class="text-sm font-semibold text-white">{{ $t('analytics.postsPerDay') }}</h2>
+            <span class="text-xs text-gray-500">{{ $t('analytics.postsPerDaySubtitle') }}</span>
+          </div>
+
+          <div v-if="chartDays.every(d => d.count === 0)" class="h-24 flex items-center justify-center text-sm text-gray-600">
+            {{ $t('analytics.noActivity') }}
+          </div>
+
+          <template v-else>
+            <!-- SVG bar chart -->
+            <svg
+              :viewBox="`0 0 ${CHART_W} ${CHART_H}`"
+              preserveAspectRatio="none"
+              class="w-full h-28"
+            >
+              <g v-for="(day, i) in chartDays" :key="day.date">
+                <rect
+                  :x="i * barSlot + barPad"
+                  :y="CHART_H - barH(day.count)"
+                  :width="barSlot - barPad * 2"
+                  :height="barH(day.count)"
+                  class="fill-blue-500 opacity-80 hover:opacity-100 transition-opacity"
+                  rx="2"
+                >
+                  <title>{{ day.date }}: {{ day.count }}</title>
+                </rect>
+              </g>
+            </svg>
+            <!-- X-axis labels: show first, middle, last -->
+            <div class="flex justify-between mt-1 text-xs text-gray-600">
+              <span>{{ chartDays[0]?.date?.slice(5) }}</span>
+              <span>{{ chartDays[14]?.date?.slice(5) }}</span>
+              <span>{{ chartDays[29]?.date?.slice(5) }}</span>
+            </div>
+          </template>
+        </div>
+
+        <!-- ── Two-column: Platform breakdown + Status breakdown ── -->
+        <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
+
+          <!-- Platform Breakdown -->
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+            <h2 class="text-sm font-semibold text-white mb-4">{{ $t('analytics.platformBreakdown') }}</h2>
+            <div v-if="platformEntries.length === 0" class="text-sm text-gray-600">—</div>
+            <div v-else class="space-y-3">
+              <div v-for="[platform, count] in platformEntries" :key="platform" class="space-y-1">
+                <div class="flex items-center justify-between text-xs">
+                  <span class="flex items-center gap-2">
+                    <span
+                      class="w-2 h-2 rounded-full"
+                      :style="{ background: platformColor(platform) }"
+                    ></span>
+                    <span class="capitalize text-gray-300">{{ platformLabel(platform) }}</span>
+                  </span>
+                  <span class="text-gray-500">{{ count }} {{ $t('analytics.successfulPosts') }}</span>
+                </div>
+                <div class="h-1.5 bg-gray-800 rounded-full overflow-hidden">
+                  <div
+                    class="h-full rounded-full transition-all"
+                    :style="{ width: platformBarWidth(count) + '%', background: platformColor(platform) }"
+                  ></div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Status Breakdown -->
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+            <h2 class="text-sm font-semibold text-white mb-4">{{ $t('analytics.statusBreakdown') }}</h2>
+            <div class="space-y-3">
+              <div class="space-y-1">
+                <div class="flex justify-between text-xs">
+                  <span class="text-green-400">{{ $t('analytics.published') }}</span>
+                  <span class="text-gray-500">{{ summary.published }}</span>
+                </div>
+                <div class="h-1.5 bg-gray-800 rounded-full overflow-hidden">
+                  <div class="h-full bg-green-500 rounded-full" :style="{ width: statusWidth(summary.published) + '%' }"></div>
+                </div>
+              </div>
+              <div class="space-y-1">
+                <div class="flex justify-between text-xs">
+                  <span class="text-yellow-400">{{ $t('analytics.partial') }}</span>
+                  <span class="text-gray-500">{{ summary.partial }}</span>
+                </div>
+                <div class="h-1.5 bg-gray-800 rounded-full overflow-hidden">
+                  <div class="h-full bg-yellow-500 rounded-full" :style="{ width: statusWidth(summary.partial) + '%' }"></div>
+                </div>
+              </div>
+              <div class="space-y-1">
+                <div class="flex justify-between text-xs">
+                  <span class="text-red-400">{{ $t('analytics.failed') }}</span>
+                  <span class="text-gray-500">{{ summary.failed }}</span>
+                </div>
+                <div class="h-1.5 bg-gray-800 rounded-full overflow-hidden">
+                  <div class="h-full bg-red-500 rounded-full" :style="{ width: statusWidth(summary.failed) + '%' }"></div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+        </div>
+
+        <!-- ── Recent Posts ───────────────────────────────────── -->
+        <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+          <h2 class="text-sm font-semibold text-white mb-4">{{ $t('analytics.recentPosts') }}</h2>
+
+          <div v-if="posts.length === 0" class="text-sm text-gray-600">
+            {{ $t('analytics.noRecentPosts') }}
+          </div>
+
+          <div v-else class="space-y-2">
+            <div
+              v-for="post in posts"
+              :key="String(post._id)"
+              class="flex items-start gap-4 py-3 border-b border-gray-800 last:border-0"
+            >
+              <!-- Content preview -->
+              <p class="flex-1 text-sm text-gray-300 line-clamp-2 min-w-0">{{ post.content }}</p>
+
+              <!-- Platforms chips -->
+              <div class="flex flex-wrap gap-1 shrink-0">
+                <span
+                  v-for="platform in postPlatforms(post)"
+                  :key="platform"
+                  class="px-2 py-0.5 rounded-full text-xs font-medium text-white"
+                  :style="{ background: platformColor(platform) }"
+                >
+                  {{ platformLabel(platform) }}
+                </span>
+              </div>
+
+              <!-- Date -->
+              <span class="text-xs text-gray-600 shrink-0 w-20 text-right">
+                {{ formatDate(post.publishedAt) }}
+              </span>
+
+              <!-- Status badge -->
+              <span
+                class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0"
+                :class="{
+                  'bg-green-900/40 text-green-400': post.status === 'published',
+                  'bg-yellow-900/40 text-yellow-400': post.status === 'partial',
+                  'bg-red-900/40 text-red-400': post.status === 'failed',
+                }"
+              >
+                {{ $t(`analytics.status${capitalize(post.status)}`) }}
+              </span>
+            </div>
+          </div>
+
+          <!-- Load more -->
+          <button
+            v-if="posts.length < postsTotal"
+            @click="loadMorePosts"
+            :disabled="loadingMore"
+            class="mt-4 w-full py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 disabled:opacity-40 rounded-xl text-sm transition-colors"
+          >
+            {{ $t('analytics.loadMore') }}
+          </button>
+        </div>
+
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import axios from 'axios'
+import { PLATFORM_META } from '../stores/platforms'
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+interface DayStat { date: string; count: number }
+interface Summary {
+  total: number
+  published: number
+  partial: number
+  failed: number
+  successRate: number
+  byPlatform: Record<string, number>
+  byDay: DayStat[]
+  recentCount: number
+}
+interface Post {
+  _id: unknown
+  content: string
+  destinations: Array<{ platform: string; accountId?: string }>
+  platformResults: Record<string, { success: boolean; error?: string }>
+  status: string
+  publishedAt: string
+  type: string
+}
+
+// ── Chart constants ───────────────────────────────────────────────────────────
+
+const CHART_W = 900
+const CHART_H = 80
+const DAY_COUNT = 30
+
+// ── State ─────────────────────────────────────────────────────────────────────
+
+const loading    = ref(false)
+const loadingMore = ref(false)
+const summary    = ref<Summary | null>(null)
+const posts      = ref<Post[]>([])
+const postsTotal = ref(0)
+
+// ── Data loading ──────────────────────────────────────────────────────────────
+
+async function load() {
+  loading.value = true
+  try {
+    const [sRes, pRes] = await Promise.all([
+      axios.get('/api/analytics/summary'),
+      axios.get('/api/analytics/posts', { params: { limit: 20 } }),
+    ])
+    summary.value = sRes.data
+    posts.value = pRes.data.posts
+    postsTotal.value = pRes.data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadMorePosts() {
+  loadingMore.value = true
+  try {
+    const res = await axios.get('/api/analytics/posts', { params: { limit: 20, skip: posts.value.length } })
+    posts.value.push(...res.data.posts)
+  } finally {
+    loadingMore.value = false
+  }
+}
+
+onMounted(load)
+
+// ── Chart helpers ─────────────────────────────────────────────────────────────
+
+const chartDays = computed<DayStat[]>(() => {
+  const byDate = Object.fromEntries((summary.value?.byDay ?? []).map((d) => [d.date, d.count]))
+  return Array.from({ length: DAY_COUNT }, (_, i) => {
+    const d = new Date()
+    d.setDate(d.getDate() - (DAY_COUNT - 1 - i))
+    const dateStr = d.toISOString().split('T')[0]
+    return { date: dateStr, count: byDate[dateStr] ?? 0 }
+  })
+})
+
+const maxCount = computed(() => Math.max(...chartDays.value.map((d) => d.count), 1))
+const barSlot  = computed(() => CHART_W / DAY_COUNT)
+const barPad   = 2
+
+function barH(count: number): number {
+  if (count === 0) return 0
+  return Math.max(4, (count / maxCount.value) * CHART_H * 0.95)
+}
+
+// ── Platform helpers ──────────────────────────────────────────────────────────
+
+const platformEntries = computed(() =>
+  Object.entries(summary.value?.byPlatform ?? {}).sort((a, b) => b[1] - a[1])
+)
+
+const platformCount = computed(() => Object.keys(summary.value?.byPlatform ?? {}).length)
+
+const maxPlatformCount = computed(() => Math.max(...Object.values(summary.value?.byPlatform ?? {}), 1))
+
+function platformBarWidth(count: number): number {
+  return (count / maxPlatformCount.value) * 100
+}
+
+function statusWidth(count: number): number {
+  return summary.value?.total ? (count / summary.value.total) * 100 : 0
+}
+
+function platformColor(platform: string): string {
+  return (PLATFORM_META as Record<string, { color: string }>)[platform]?.color ?? '#6b7280'
+}
+
+function platformLabel(platform: string): string {
+  return (PLATFORM_META as Record<string, { label: string }>)[platform]?.label ?? platform
+}
+
+// ── Post helpers ──────────────────────────────────────────────────────────────
+
+function postPlatforms(post: Post): string[] {
+  if (post.platformResults) {
+    return [...new Set(
+      Object.keys(post.platformResults).map((k) => k.split(':')[0])
+    )]
+  }
+  return [...new Set((post.destinations ?? []).map((d) => d.platform))]
+}
+
+function formatDate(iso: string): string {
+  if (!iso) return '—'
+  return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+}
+
+function capitalize(s: string): string {
+  return s ? s[0].toUpperCase() + s.slice(1) : ''
+}
+</script>