소스 검색

Analytics Improvements

Benjamin Harris 1 개월 전
부모
커밋
e6f4688e11
6개의 변경된 파일644개의 추가작업 그리고 36개의 파일을 삭제
  1. 4 2
      nginx.conf
  2. 285 19
      services/gateway/server.js
  3. 15 1
      services/scheduler/index.js
  4. 30 0
      ui/src/locales/en.ts
  5. 30 0
      ui/src/locales/tr.ts
  6. 280 14
      ui/src/views/Analytics.vue

+ 4 - 2
nginx.conf

@@ -46,14 +46,16 @@ http {
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Real-IP $remote_addr;
     }
     }
 
 
-    location /feeds/ {
+    location ~ ^/feeds/.+ {
       rewrite ^/feeds/(.*) /$1 break;
       rewrite ^/feeds/(.*) /$1 break;
       proxy_pass http://feed-aggregator:3010;
       proxy_pass http://feed-aggregator:3010;
       proxy_set_header Host $host;
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
       proxy_set_header X-Real-IP $remote_addr;
     }
     }
 
 
-    location /scheduler/ {
+    # Regex match requires at least one character after /scheduler/ so that
+    # a bare /scheduler (Vue Router page) is never redirected here.
+    location ~ ^/scheduler/.+ {
       rewrite ^/scheduler/(.*) /$1 break;
       rewrite ^/scheduler/(.*) /$1 break;
       proxy_pass http://scheduler:3011;
       proxy_pass http://scheduler:3011;
       proxy_set_header Host $host;
       proxy_set_header Host $host;

+ 285 - 19
services/gateway/server.js

@@ -771,6 +771,202 @@ app.get('/credentials', async () => {
   };
   };
 });
 });
 
 
+// ─── Analytics Metrics Crawl ─────────────────────────────────────────────────
+
+async function crawlFacebookMetrics(db) {
+  const fb = await getCredentials('facebook');
+  const pages = (fb?.pages || []).filter((p) => p.selected && p.accessToken);
+  if (!pages.length) return { count: 0 };
+
+  let count = 0;
+  for (const page of pages) {
+    const token = decryptToken(page.accessToken);
+    if (!token) continue;
+    try {
+      const res = await axios.get(`${GRAPH_API}/${page.id}/posts`, {
+        params: {
+          fields: 'id,message,created_time,reactions.summary(total_count),comments.summary(total_count),shares',
+          limit: 100,
+          access_token: token,
+        },
+        timeout: 30000,
+      });
+      for (const post of res.data.data || []) {
+        const likes    = post.reactions?.summary?.total_count || 0;
+        const comments = post.comments?.summary?.total_count || 0;
+        const shares   = post.shares?.count || 0;
+        const publishedAt = new Date(post.created_time);
+        await db.collection('post_metrics').updateOne(
+          { platform: 'facebook', postId: post.id },
+          {
+            $set: {
+              platform: 'facebook',
+              accountId: page.id,
+              accountName: page.name,
+              postId: post.id,
+              content: post.message || null,
+              publishedAt,
+              metrics: { likes, comments, shares, views: 0, saves: 0, engagementTotal: likes + comments + shares },
+              hourOfDay: publishedAt.getUTCHours(),
+              dayOfWeek: publishedAt.getUTCDay(),
+              fetchedAt: new Date(),
+            },
+          },
+          { upsert: true }
+        );
+        count++;
+      }
+    } catch (err) {
+      app.log.warn({ action: 'metrics_crawl', platform: 'facebook', pageId: page.id, outcome: 'failure', err: err.message });
+    }
+  }
+  return { count };
+}
+
+async function crawlInstagramMetrics(db) {
+  const ig = await getCredentials('instagram');
+  const accounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
+  if (!accounts.length) return { count: 0 };
+
+  let count = 0;
+  for (const account of accounts) {
+    const token = decryptToken(account.accessToken);
+    if (!token) continue;
+    try {
+      const mediaRes = await axios.get(`${GRAPH_API}/${account.id}/media`, {
+        params: { fields: 'id,caption,timestamp,like_count,comments_count', limit: 100, access_token: token },
+        timeout: 30000,
+      });
+      for (const media of mediaRes.data.data || []) {
+        const likes    = media.like_count    || 0;
+        const comments = media.comments_count || 0;
+        const publishedAt = new Date(media.timestamp);
+        let views = 0;
+        let saves = 0;
+        try {
+          const insRes = await axios.get(`${GRAPH_API}/${media.id}/insights`, {
+            params: { metric: 'reach,saved', access_token: token },
+            timeout: 10000,
+          });
+          for (const ins of insRes.data.data || []) {
+            if (ins.name === 'reach') views = ins.values?.[0]?.value || 0;
+            if (ins.name === 'saved') saves = ins.values?.[0]?.value || 0;
+          }
+        } catch (_) {}
+        await db.collection('post_metrics').updateOne(
+          { platform: 'instagram', postId: media.id },
+          {
+            $set: {
+              platform: 'instagram',
+              accountId: account.id,
+              accountName: account.username,
+              postId: media.id,
+              content: media.caption || null,
+              publishedAt,
+              metrics: { likes, comments, shares: 0, views, saves, engagementTotal: likes + comments },
+              hourOfDay: publishedAt.getUTCHours(),
+              dayOfWeek: publishedAt.getUTCDay(),
+              fetchedAt: new Date(),
+            },
+          },
+          { upsert: true }
+        );
+        count++;
+      }
+    } catch (err) {
+      app.log.warn({ action: 'metrics_crawl', platform: 'instagram', accountId: account.id, outcome: 'failure', err: err.message });
+    }
+  }
+  return { count };
+}
+
+app.post('/analytics/crawl', async () => {
+  const db = await getDb();
+  const results = {};
+  for (const [platform, crawler] of [['facebook', crawlFacebookMetrics], ['instagram', crawlInstagramMetrics]]) {
+    try {
+      results[platform] = await crawler(db);
+    } catch (err) {
+      app.log.error({ action: 'metrics_crawl', platform, outcome: 'failure', err: err.message });
+      results[platform] = { count: 0, error: err.message };
+    }
+  }
+  const total = Object.values(results).reduce((sum, r) => sum + (r.count || 0), 0);
+  app.log.info({ action: 'metrics_crawl', outcome: 'complete', total });
+  return { success: true, total, byPlatform: results };
+});
+
+app.get('/analytics/insights', async () => {
+  const db = await getDb();
+  const total = await db.collection('post_metrics').countDocuments({});
+  if (total === 0) return { empty: true };
+
+  const [byHourRaw, byDayRaw, topPosts, platformComparison, heatmapRaw] = await Promise.all([
+    db.collection('post_metrics').aggregate([
+      { $group: { _id: '$hourOfDay', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
+      { $sort: { _id: 1 } },
+    ]).toArray(),
+    db.collection('post_metrics').aggregate([
+      { $group: { _id: '$dayOfWeek', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
+      { $sort: { _id: 1 } },
+    ]).toArray(),
+    db.collection('post_metrics').find({}).sort({ 'metrics.engagementTotal': -1 }).limit(5).toArray(),
+    db.collection('post_metrics').aggregate([
+      { $group: {
+        _id: '$platform',
+        avgEngagement: { $avg: '$metrics.engagementTotal' },
+        avgLikes:      { $avg: '$metrics.likes' },
+        avgComments:   { $avg: '$metrics.comments' },
+        avgShares:     { $avg: '$metrics.shares' },
+        totalPosts:    { $sum: 1 },
+      }},
+      { $sort: { avgEngagement: -1 } },
+    ]).toArray(),
+    db.collection('post_metrics').aggregate([
+      { $group: {
+        _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
+        avgEngagement: { $avg: '$metrics.engagementTotal' },
+        count: { $sum: 1 },
+      }},
+    ]).toArray(),
+  ]);
+
+  const byHour = Array.from({ length: 24 }, (_, h) => {
+    const e = byHourRaw.find((r) => r._id === h);
+    return { hour: h, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
+  });
+  const byDay = Array.from({ length: 7 }, (_, d) => {
+    const e = byDayRaw.find((r) => r._id === d);
+    return { day: d, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
+  });
+  const heatmap = Array.from({ length: 7 * 24 }, (_, i) => {
+    const day  = Math.floor(i / 24);
+    const hour = i % 24;
+    const e = heatmapRaw.find((r) => r._id.day === day && r._id.hour === hour);
+    return { day, hour, avg: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
+  });
+
+  return {
+    empty: false,
+    total,
+    byHour,
+    byDay,
+    heatmap,
+    topPosts: topPosts.map((p) => ({
+      platform: p.platform, accountName: p.accountName, postId: p.postId,
+      content: p.content, publishedAt: p.publishedAt, metrics: p.metrics,
+    })),
+    platformComparison: platformComparison.map((p) => ({
+      platform: p._id,
+      avgEngagement: Math.round(p.avgEngagement),
+      avgLikes:      Math.round(p.avgLikes),
+      avgComments:   Math.round(p.avgComments),
+      avgShares:     Math.round(p.avgShares),
+      totalPosts:    p.totalPosts,
+    })),
+  };
+});
+
 // ─── Analytics ────────────────────────────────────────────────────────────────
 // ─── Analytics ────────────────────────────────────────────────────────────────
 
 
 app.get('/analytics/summary', async () => {
 app.get('/analytics/summary', async () => {
@@ -778,32 +974,71 @@ app.get('/analytics/summary', async () => {
   const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
   const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
   const sevenDaysAgo  = new Date(Date.now() -  7 * 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 } }),
+  // scheduled_jobs is the primary source — it holds the full history of all
+  // scheduled posts. posts (type: immediate) supplements it for direct dispatches.
+  const [
+    schedCompleted, schedFailed,
+    immPublished, immFailed,
+    recentSched, recentImm,
+    schedPlatformRaw, immPlatformRaw,
+    schedDayRaw, immDayRaw,
+  ] = await Promise.all([
+    db.collection('scheduled_jobs').countDocuments({ status: 'completed' }),
+    db.collection('scheduled_jobs').countDocuments({ status: 'failed' }),
+    db.collection('posts').countDocuments({ type: 'immediate', status: { $in: ['published', 'partial'] } }),
+    db.collection('posts').countDocuments({ type: 'immediate', status: 'failed' }),
+    db.collection('scheduled_jobs').countDocuments({ status: 'completed', completedAt: { $gte: sevenDaysAgo } }),
+    db.collection('posts').countDocuments({ type: 'immediate', publishedAt: { $gte: sevenDaysAgo } }),
+    // Platform breakdown from scheduled_jobs destinations
+    db.collection('scheduled_jobs').aggregate([
+      { $match: { status: 'completed' } },
+      { $unwind: '$destinations' },
+      { $group: { _id: '$destinations.platform', count: { $sum: 1 } } },
+      { $sort: { count: -1 } },
+    ]).toArray(),
+    // Platform breakdown from immediate posts platformResults
     db.collection('posts').aggregate([
     db.collection('posts').aggregate([
+      { $match: { type: 'immediate' } },
       { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
       { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
       { $unwind: '$results' },
       { $unwind: '$results' },
       { $match: { 'results.v.success': true } },
       { $match: { 'results.v.success': true } },
       { $project: { platform: { $arrayElemAt: [{ $split: ['$results.k', ':'] }, 0] } } },
       { $project: { platform: { $arrayElemAt: [{ $split: ['$results.k', ':'] }, 0] } } },
       { $group: { _id: '$platform', count: { $sum: 1 } } },
       { $group: { _id: '$platform', count: { $sum: 1 } } },
-      { $sort: { count: -1 } },
     ]).toArray(),
     ]).toArray(),
+    // Activity by day from scheduled_jobs (using completedAt)
+    db.collection('scheduled_jobs').aggregate([
+      { $match: { status: 'completed', completedAt: { $gte: thirtyDaysAgo } } },
+      { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } }, count: { $sum: 1 } } },
+      { $sort: { _id: 1 } },
+    ]).toArray(),
+    // Activity by day from immediate posts
     db.collection('posts').aggregate([
     db.collection('posts').aggregate([
-      { $match: { publishedAt: { $gte: thirtyDaysAgo } } },
+      { $match: { type: 'immediate', publishedAt: { $gte: thirtyDaysAgo } } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
       { $sort: { _id: 1 } },
       { $sort: { _id: 1 } },
     ]).toArray(),
     ]).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 }));
+  // Merge byDay from both sources
+  const dayMap = {};
+  for (const { _id, count } of [...schedDayRaw, ...immDayRaw]) {
+    dayMap[_id] = (dayMap[_id] || 0) + count;
+  }
+  const byDay = Object.entries(dayMap).map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
+
+  // Merge byPlatform from both sources
+  const platformMap = {};
+  for (const { _id, count } of [...schedPlatformRaw, ...immPlatformRaw]) {
+    if (_id) platformMap[_id] = (platformMap[_id] || 0) + count;
+  }
+
+  const published = schedCompleted + immPublished;
+  const failed    = schedFailed    + immFailed;
+  const total     = published + failed;
+  const successRate = total > 0 ? Math.round((published / total) * 100) : 0;
+  const recentCount = recentSched + recentImm;
 
 
-  return { total, published, failed, partial, successRate, byPlatform, byDay, recentCount };
+  return { total, published, failed, partial: 0, successRate, byPlatform: platformMap, byDay, recentCount };
 });
 });
 
 
 app.get('/analytics/posts', async (request) => {
 app.get('/analytics/posts', async (request) => {
@@ -811,18 +1046,49 @@ app.get('/analytics/posts', async (request) => {
   const skip  = parseInt(request.query.skip || '0', 10);
   const skip  = parseInt(request.query.skip || '0', 10);
   const db = await getDb();
   const db = await getDb();
 
 
-  const [posts, total] = await Promise.all([
-    db.collection('posts')
-      .find({})
-      .sort({ publishedAt: -1 })
+  // scheduled_jobs holds all scheduled-post history (content stored from now on;
+  // older records have content: undefined). Immediate posts come from the posts collection.
+  const [scheduledJobs, immediatePosts, schedTotal, immTotal] = await Promise.all([
+    db.collection('scheduled_jobs')
+      .find({ status: { $in: ['completed', 'failed'] } })
+      .sort({ completedAt: -1, scheduledAt: -1 })
       .skip(skip)
       .skip(skip)
       .limit(limit)
       .limit(limit)
-      .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1, type: 1 })
+      .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
       .toArray(),
       .toArray(),
-    db.collection('posts').countDocuments({}),
+    db.collection('posts')
+      .find({ type: 'immediate' })
+      .sort({ publishedAt: -1 })
+      .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
+      .toArray(),
+    db.collection('scheduled_jobs').countDocuments({ status: { $in: ['completed', 'failed'] } }),
+    db.collection('posts').countDocuments({ type: 'immediate' }),
   ]);
   ]);
 
 
-  return { posts, total };
+  // Normalise to a single shape expected by the frontend
+  const normalised = [
+    ...scheduledJobs.map((j) => ({
+      _id: String(j._id),
+      type: 'scheduled',
+      content: j.content || null,
+      destinations: j.destinations || [],
+      platformResults: null,
+      status: j.status === 'completed' ? 'published' : 'failed',
+      publishedAt: j.completedAt || j.scheduledAt,
+    })),
+    ...immediatePosts.map((p) => ({
+      _id: String(p._id),
+      type: 'immediate',
+      content: p.content || null,
+      destinations: p.destinations || [],
+      platformResults: p.platformResults || null,
+      status: p.status,
+      publishedAt: p.publishedAt,
+    })),
+  ].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
+   .slice(0, limit);
+
+  return { posts: normalised, total: schedTotal + immTotal };
 });
 });
 
 
 module.exports = app;
 module.exports = app;

+ 15 - 1
services/scheduler/index.js

@@ -107,6 +107,12 @@ async function processSystemJob(job) {
     log.info({ action: 'token_refresh', trigger: 'scheduled', outcome: 'success', refreshed: res.data.refreshed, skipped: res.data.skipped, errors: res.data.errors });
     log.info({ action: 'token_refresh', trigger: 'scheduled', outcome: 'success', refreshed: res.data.refreshed, skipped: res.data.skipped, errors: res.data.errors });
     return res.data;
     return res.data;
   }
   }
+  if (job.name === 'metrics-crawl') {
+    log.info({ action: 'metrics_crawl', trigger: 'scheduled', outcome: 'start' });
+    const res = await axios.post(`${GATEWAY_URL}/analytics/crawl`, {}, { timeout: 120000 });
+    log.info({ action: 'metrics_crawl', trigger: 'scheduled', outcome: 'success', total: res.data.total });
+    return res.data;
+  }
 }
 }
 
 
 // ─── HTTP Endpoints ──────────────────────────────────────────────────────────
 // ─── HTTP Endpoints ──────────────────────────────────────────────────────────
@@ -140,6 +146,7 @@ app.post('/schedule', async (request, reply) => {
   await db.collection('scheduled_jobs').insertOne({
   await db.collection('scheduled_jobs').insertOne({
     postId,
     postId,
     type: 'one-time',
     type: 'one-time',
+    content,
     scheduledAt: new Date(scheduledAt),
     scheduledAt: new Date(scheduledAt),
     destinations: destList,
     destinations: destList,
     status: 'pending',
     status: 'pending',
@@ -202,7 +209,7 @@ async function start() {
     log.error({ action: 'system_job', jobId: job?.id, jobName: job?.name, outcome: 'failure', err: err.message });
     log.error({ action: 'system_job', jobId: job?.id, jobName: job?.name, outcome: 'failure', err: err.message });
   });
   });
 
 
-  // Register daily Meta token auto-refresh — BullMQ deduplicates by repeat key on restart
+  // Register daily system jobs — BullMQ deduplicates by repeat key on restart
   await systemQueue.add(
   await systemQueue.add(
     'meta-token-refresh',
     'meta-token-refresh',
     {},
     {},
@@ -210,6 +217,13 @@ async function start() {
   );
   );
   log.info({ action: 'system_job_register', job: 'meta-token-refresh', interval: '24h', outcome: 'success' });
   log.info({ action: 'system_job_register', job: 'meta-token-refresh', interval: '24h', outcome: 'success' });
 
 
+  await systemQueue.add(
+    'metrics-crawl',
+    {},
+    { repeat: { every: 24 * 60 * 60 * 1000 }, removeOnComplete: 5, removeOnFail: 5 }
+  );
+  log.info({ action: 'system_job_register', job: 'metrics-crawl', interval: '24h', outcome: 'success' });
+
   await app.listen({ port: process.env.PORT || 3011, host: '0.0.0.0' });
   await app.listen({ port: process.env.PORT || 3011, host: '0.0.0.0' });
   log.info({ action: 'service_start', port: 3011, outcome: 'success' }, 'Scheduler started');
   log.info({ action: 'service_start', port: 3011, outcome: 'success' }, 'Scheduler started');
 }
 }

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

@@ -45,6 +45,36 @@ export default {
     typeImmediate: 'Immediate',
     typeImmediate: 'Immediate',
     noRecentPosts: 'No posts to show.',
     noRecentPosts: 'No posts to show.',
     loadMore: 'Load more',
     loadMore: 'Load more',
+    noContent: 'Content not available for older posts',
+
+    crawlMetrics: 'Crawl Metrics',
+    crawling: 'Crawling…',
+    crawlDone: 'Crawled {count} posts',
+
+    insightsTitle: 'Advanced Insights',
+    insightsSubtitle: 'Engagement metrics from connected platforms',
+    insightsEmpty: 'No engagement data yet.',
+    insightsEmptyHint: 'Click "Crawl Metrics" to fetch engagement data from your connected platforms.',
+
+    bestTimeTitle: 'Best Posting Times',
+    bestTimeSubtitle: 'Average engagement score by time (UTC)',
+    byHourTitle: 'By Hour of Day',
+    byDayTitle: 'By Day of Week',
+
+    heatmapTitle: 'Engagement Heatmap',
+    heatmapSubtitle: 'Day × hour (UTC) — darker = higher avg engagement',
+
+    topPostsTitle: 'Top Performing Posts',
+    noTopPosts: 'No top posts yet.',
+
+    platformCompTitle: 'Platform Comparison',
+    colAvgEngagement: 'Avg Engagement',
+    colAvgLikes: 'Avg Likes',
+    colAvgComments: 'Avg Comments',
+    colAvgShares: 'Avg Shares',
+    colTracked: 'Posts Tracked',
+
+    dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
   },
   },
 
 
   media: {
   media: {

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

@@ -45,6 +45,36 @@ export default {
     typeImmediate: 'Anlık',
     typeImmediate: 'Anlık',
     noRecentPosts: 'Gösterilecek gönderi yok.',
     noRecentPosts: 'Gösterilecek gönderi yok.',
     loadMore: 'Daha fazla yükle',
     loadMore: 'Daha fazla yükle',
+    noContent: 'Eski gönderiler için içerik mevcut değil',
+
+    crawlMetrics: 'Metrikleri Getir',
+    crawling: 'Getiriliyor…',
+    crawlDone: '{count} gönderi getirildi',
+
+    insightsTitle: 'Gelişmiş İçgörüler',
+    insightsSubtitle: 'Bağlı platformlardan etkileşim metrikleri',
+    insightsEmpty: 'Henüz etkileşim verisi yok.',
+    insightsEmptyHint: '"Metrikleri Getir" butonuna tıklayarak bağlı platformlardan etkileşim verisi çek.',
+
+    bestTimeTitle: 'En İyi Paylaşım Zamanları',
+    bestTimeSubtitle: 'Zamana göre ortalama etkileşim puanı (UTC)',
+    byHourTitle: 'Günün Saatine Göre',
+    byDayTitle: 'Haftanın Gününe Göre',
+
+    heatmapTitle: 'Etkileşim Isı Haritası',
+    heatmapSubtitle: 'Gün × saat (UTC) — koyu = yüksek ortalama etkileşim',
+
+    topPostsTitle: 'En İyi Performanslı Gönderiler',
+    noTopPosts: 'Henüz gönderi yok.',
+
+    platformCompTitle: 'Platform Karşılaştırması',
+    colAvgEngagement: 'Ort. Etkileşim',
+    colAvgLikes: 'Ort. Beğeni',
+    colAvgComments: 'Ort. Yorum',
+    colAvgShares: 'Ort. Paylaşım',
+    colTracked: 'Takip Edilen',
+
+    dayNamesShort: ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt'],
   },
   },
 
 
   media: {
   media: {

+ 280 - 14
ui/src/views/Analytics.vue

@@ -8,14 +8,24 @@
           <h1 class="text-2xl font-bold text-white">{{ $t('analytics.title') }}</h1>
           <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>
           <p class="text-sm text-gray-500 mt-1">{{ $t('analytics.subtitle') }}</p>
         </div>
         </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 class="flex items-center gap-2">
+          <button
+            @click="crawlMetrics"
+            :disabled="crawling"
+            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': crawling }">⟳</span>
+            {{ crawling ? $t('analytics.crawling') : (crawlResult !== null ? $t('analytics.crawlDone', { count: crawlResult }) : $t('analytics.crawlMetrics')) }}
+          </button>
+          <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>
       </div>
       </div>
 
 
       <!-- Loading -->
       <!-- Loading -->
@@ -173,7 +183,9 @@
               class="flex items-start gap-4 py-3 border-b border-gray-800 last:border-0"
               class="flex items-start gap-4 py-3 border-b border-gray-800 last:border-0"
             >
             >
               <!-- Content preview -->
               <!-- Content preview -->
-              <p class="flex-1 text-sm text-gray-300 line-clamp-2 min-w-0">{{ post.content }}</p>
+              <p class="flex-1 text-sm line-clamp-2 min-w-0" :class="post.content ? 'text-gray-300' : 'text-gray-600 italic'">
+                {{ post.content || $t('analytics.noContent') }}
+              </p>
 
 
               <!-- Platforms chips -->
               <!-- Platforms chips -->
               <div class="flex flex-wrap gap-1 shrink-0">
               <div class="flex flex-wrap gap-1 shrink-0">
@@ -218,6 +230,176 @@
         </div>
         </div>
 
 
       </template>
       </template>
+
+      <!-- ── Advanced Insights ─────────────────────────────────── -->
+      <div v-if="!loading || summary" class="mt-8 space-y-6">
+        <div>
+          <h2 class="text-lg font-semibold text-white">{{ $t('analytics.insightsTitle') }}</h2>
+          <p class="text-sm text-gray-500 mt-0.5">{{ $t('analytics.insightsSubtitle') }}</p>
+        </div>
+
+        <div v-if="insightsLoading" class="flex items-center justify-center h-24 text-gray-500 text-sm">
+          {{ $t('analytics.loading') }}
+        </div>
+
+        <div v-else-if="!insights || insights.empty" class="bg-gray-900 border border-gray-800 rounded-2xl p-10 text-center">
+          <p class="text-gray-400 font-medium">{{ $t('analytics.insightsEmpty') }}</p>
+          <p class="text-sm text-gray-600 mt-1">{{ $t('analytics.insightsEmptyHint') }}</p>
+        </div>
+
+        <template v-else>
+
+          <!-- Platform Comparison -->
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6 overflow-x-auto">
+            <h3 class="text-sm font-semibold text-white mb-4">{{ $t('analytics.platformCompTitle') }}</h3>
+            <table class="w-full text-sm min-w-[540px]">
+              <thead>
+                <tr class="text-xs text-gray-500 border-b border-gray-800">
+                  <th class="text-left pb-2 font-normal">Platform</th>
+                  <th class="text-right pb-2 font-normal">{{ $t('analytics.colAvgEngagement') }}</th>
+                  <th class="text-right pb-2 font-normal">{{ $t('analytics.colAvgLikes') }}</th>
+                  <th class="text-right pb-2 font-normal">{{ $t('analytics.colAvgComments') }}</th>
+                  <th class="text-right pb-2 font-normal">{{ $t('analytics.colAvgShares') }}</th>
+                  <th class="text-right pb-2 font-normal">{{ $t('analytics.colTracked') }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="row in insights.platformComparison" :key="row.platform" class="border-b border-gray-800/40 last:border-0">
+                  <td class="py-2.5">
+                    <span class="flex items-center gap-2">
+                      <span class="w-2 h-2 rounded-full shrink-0" :style="{ background: platformColor(row.platform) }"></span>
+                      <span class="capitalize text-gray-300">{{ platformLabel(row.platform) }}</span>
+                    </span>
+                  </td>
+                  <td class="py-2.5 text-right font-semibold text-blue-400">{{ row.avgEngagement }}</td>
+                  <td class="py-2.5 text-right text-gray-400">{{ row.avgLikes }}</td>
+                  <td class="py-2.5 text-right text-gray-400">{{ row.avgComments }}</td>
+                  <td class="py-2.5 text-right text-gray-400">{{ row.avgShares }}</td>
+                  <td class="py-2.5 text-right text-gray-500">{{ row.totalPosts }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+
+          <!-- Best Time: By Hour + By Day -->
+          <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+
+            <!-- By Hour -->
+            <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+              <div class="flex items-baseline justify-between mb-4">
+                <h3 class="text-sm font-semibold text-white">{{ $t('analytics.byHourTitle') }}</h3>
+                <span class="text-xs text-gray-500">{{ $t('analytics.bestTimeSubtitle') }}</span>
+              </div>
+              <svg viewBox="0 0 480 60" preserveAspectRatio="none" class="w-full h-20">
+                <g v-for="bar in insights.byHour" :key="bar.hour">
+                  <rect
+                    :x="bar.hour * 20 + 2"
+                    :y="60 - byHourBarH(bar.avgEngagement)"
+                    :width="16"
+                    :height="byHourBarH(bar.avgEngagement) || 1"
+                    :class="bar.avgEngagement === maxByHourAvg && bar.avgEngagement > 0 ? 'fill-green-400' : 'fill-blue-500 opacity-70'"
+                    rx="1"
+                  >
+                    <title>{{ bar.hour }}:00 — avg {{ bar.avgEngagement }}</title>
+                  </rect>
+                </g>
+              </svg>
+              <div class="flex justify-between mt-1 text-xs text-gray-600 px-0.5">
+                <span>0h</span><span>6h</span><span>12h</span><span>18h</span><span>23h</span>
+              </div>
+            </div>
+
+            <!-- By Day -->
+            <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+              <div class="flex items-baseline justify-between mb-4">
+                <h3 class="text-sm font-semibold text-white">{{ $t('analytics.byDayTitle') }}</h3>
+                <span class="text-xs text-gray-500">{{ $t('analytics.bestTimeSubtitle') }}</span>
+              </div>
+              <svg viewBox="0 0 280 60" preserveAspectRatio="none" class="w-full h-20">
+                <g v-for="bar in insights.byDay" :key="bar.day">
+                  <rect
+                    :x="bar.day * 40 + 4"
+                    :y="60 - byDayBarH(bar.avgEngagement)"
+                    :width="32"
+                    :height="byDayBarH(bar.avgEngagement) || 1"
+                    :class="bar.avgEngagement === maxByDayAvg && bar.avgEngagement > 0 ? 'fill-green-400' : 'fill-blue-500 opacity-70'"
+                    rx="2"
+                  >
+                    <title>{{ ($t('analytics.dayNamesShort') as string[])[bar.day] }} — avg {{ bar.avgEngagement }}</title>
+                  </rect>
+                </g>
+              </svg>
+              <div class="flex justify-between mt-1 text-xs text-gray-600 px-1">
+                <span v-for="name in ($t('analytics.dayNamesShort') as string[])" :key="name">{{ name }}</span>
+              </div>
+            </div>
+
+          </div>
+
+          <!-- Engagement Heatmap -->
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+            <div class="flex items-baseline justify-between mb-4">
+              <h3 class="text-sm font-semibold text-white">{{ $t('analytics.heatmapTitle') }}</h3>
+              <span class="text-xs text-gray-500">{{ $t('analytics.heatmapSubtitle') }}</span>
+            </div>
+            <div class="overflow-x-auto">
+              <div class="inline-block min-w-full">
+                <!-- Hour axis labels -->
+                <div class="flex items-center mb-1" style="padding-left: 28px">
+                  <div
+                    v-for="h in 24"
+                    :key="h"
+                    class="shrink-0 text-center text-xs text-gray-600"
+                    style="width: 18px"
+                  >{{ (h - 1) % 6 === 0 ? String(h - 1) : '' }}</div>
+                </div>
+                <!-- Day rows -->
+                <div v-for="(row, d) in heatmapGrid" :key="d" class="flex items-center gap-px mb-px">
+                  <span class="text-xs text-gray-600 shrink-0 text-right pr-1" style="width: 28px">
+                    {{ ($t('analytics.dayNamesShort') as string[])[d] }}
+                  </span>
+                  <div
+                    v-for="cell in row"
+                    :key="cell.hour"
+                    class="shrink-0 rounded-sm"
+                    :style="{ width: '18px', height: '14px', background: heatmapCellBg(cell.avg) }"
+                    :title="`${($t('analytics.dayNamesShort') as string[])[d]} ${cell.hour}:00 — avg ${cell.avg}`"
+                  ></div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Top Performing Posts -->
+          <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6">
+            <h3 class="text-sm font-semibold text-white mb-4">{{ $t('analytics.topPostsTitle') }}</h3>
+            <div v-if="insights.topPosts.length === 0" class="text-sm text-gray-600">{{ $t('analytics.noTopPosts') }}</div>
+            <div v-else class="space-y-0">
+              <div
+                v-for="post in insights.topPosts"
+                :key="post.postId"
+                class="flex items-start gap-3 py-3 border-b border-gray-800 last:border-0"
+              >
+                <span class="shrink-0 w-2 h-2 rounded-full mt-1.5" :style="{ background: platformColor(post.platform) }"></span>
+                <div class="flex-1 min-w-0">
+                  <p class="text-xs text-gray-500 mb-0.5">{{ platformLabel(post.platform) }} · {{ formatDate(post.publishedAt) }}</p>
+                  <p class="text-sm line-clamp-2" :class="post.content ? 'text-gray-300' : 'text-gray-600 italic'">
+                    {{ post.content || $t('analytics.noContent') }}
+                  </p>
+                </div>
+                <div class="shrink-0 text-right space-y-0.5">
+                  <p class="text-sm font-semibold text-blue-400">{{ post.metrics.engagementTotal }}</p>
+                  <p class="text-xs text-gray-600">
+                    ❤ {{ post.metrics.likes }} · 💬 {{ post.metrics.comments }}<template v-if="post.metrics.shares"> · ↗ {{ post.metrics.shares }}</template>
+                  </p>
+                </div>
+              </div>
+            </div>
+          </div>
+
+        </template>
+      </div>
+
     </div>
     </div>
   </div>
   </div>
 </template>
 </template>
@@ -249,6 +431,26 @@ interface Post {
   publishedAt: string
   publishedAt: string
   type: string
   type: string
 }
 }
+interface HourStat  { hour: number; avgEngagement: number; count: number }
+interface DayEngStat { day: number; avgEngagement: number; count: number }
+interface HeatCell  { day: number; hour: number; avg: number; count: number }
+interface TopPost {
+  platform: string; accountName: string; postId: string
+  content: string | null; publishedAt: string
+  metrics: { likes: number; comments: number; shares: number; views: number; saves: number; engagementTotal: number }
+}
+interface PlatformComp {
+  platform: string; avgEngagement: number; avgLikes: number; avgComments: number; avgShares: number; totalPosts: number
+}
+interface Insights {
+  empty: boolean
+  total?: number
+  byHour?: HourStat[]
+  byDay?: DayEngStat[]
+  heatmap?: HeatCell[]
+  topPosts?: TopPost[]
+  platformComparison?: PlatformComp[]
+}
 
 
 // ── Chart constants ───────────────────────────────────────────────────────────
 // ── Chart constants ───────────────────────────────────────────────────────────
 
 
@@ -258,11 +460,16 @@ const DAY_COUNT = 30
 
 
 // ── State ─────────────────────────────────────────────────────────────────────
 // ── State ─────────────────────────────────────────────────────────────────────
 
 
-const loading    = ref(false)
+const loading     = ref(false)
 const loadingMore = ref(false)
 const loadingMore = ref(false)
-const summary    = ref<Summary | null>(null)
-const posts      = ref<Post[]>([])
-const postsTotal = ref(0)
+const summary     = ref<Summary | null>(null)
+const posts       = ref<Post[]>([])
+const postsTotal  = ref(0)
+
+const insightsLoading = ref(false)
+const insights        = ref<Insights | null>(null)
+const crawling        = ref(false)
+const crawlResult     = ref<number | null>(null)
 
 
 // ── Data loading ──────────────────────────────────────────────────────────────
 // ── Data loading ──────────────────────────────────────────────────────────────
 
 
@@ -291,7 +498,29 @@ async function loadMorePosts() {
   }
   }
 }
 }
 
 
-onMounted(load)
+async function loadInsights() {
+  insightsLoading.value = true
+  try {
+    const res = await axios.get('/api/analytics/insights')
+    insights.value = res.data
+  } finally {
+    insightsLoading.value = false
+  }
+}
+
+async function crawlMetrics() {
+  crawling.value = true
+  crawlResult.value = null
+  try {
+    const res = await axios.post('/api/analytics/crawl')
+    crawlResult.value = res.data.total
+    await loadInsights()
+  } finally {
+    crawling.value = false
+  }
+}
+
+onMounted(() => { load(); loadInsights() })
 
 
 // ── Chart helpers ─────────────────────────────────────────────────────────────
 // ── Chart helpers ─────────────────────────────────────────────────────────────
 
 
@@ -340,6 +569,43 @@ function platformLabel(platform: string): string {
   return (PLATFORM_META as Record<string, { label: string }>)[platform]?.label ?? platform
   return (PLATFORM_META as Record<string, { label: string }>)[platform]?.label ?? platform
 }
 }
 
 
+// ── Insights helpers ──────────────────────────────────────────────────────────
+
+const INSIGHT_CHART_H = 57 // SVG height for by-hour / by-day charts
+
+const maxByHourAvg = computed(() =>
+  Math.max(...(insights.value?.byHour ?? []).map((b) => b.avgEngagement), 1)
+)
+const maxByDayAvg = computed(() =>
+  Math.max(...(insights.value?.byDay ?? []).map((b) => b.avgEngagement), 1)
+)
+
+function byHourBarH(avg: number): number {
+  if (!avg) return 0
+  return Math.max(3, (avg / maxByHourAvg.value) * INSIGHT_CHART_H * 0.95)
+}
+function byDayBarH(avg: number): number {
+  if (!avg) return 0
+  return Math.max(3, (avg / maxByDayAvg.value) * INSIGHT_CHART_H * 0.95)
+}
+
+const maxHeatmapAvg = computed(() =>
+  Math.max(...(insights.value?.heatmap ?? []).map((c) => c.avg), 1)
+)
+
+function heatmapCellBg(avg: number): string {
+  if (!avg) return 'rgba(59,130,246,0.05)'
+  const intensity = avg / maxHeatmapAvg.value
+  return `rgba(59,130,246,${(0.1 + intensity * 0.9).toFixed(2)})`
+}
+
+const heatmapGrid = computed<HeatCell[][]>(() => {
+  const cells = insights.value?.heatmap ?? []
+  return Array.from({ length: 7 }, (_, d) =>
+    Array.from({ length: 24 }, (_, h) => cells[d * 24 + h] ?? { day: d, hour: h, avg: 0, count: 0 })
+  )
+})
+
 // ── Post helpers ──────────────────────────────────────────────────────────────
 // ── Post helpers ──────────────────────────────────────────────────────────────
 
 
 function postPlatforms(post: Post): string[] {
 function postPlatforms(post: Post): string[] {