also looking at this
UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target
#8
54 files
+2036
-589
| @@ -0,0 +1,113 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { response } from '@stacksjs/router' | |
| 4 | ||
| 5 | const database = db as any | |
| 6 | ||
| 7 | const DAY_MS = 24 * 60 * 60 * 1000 | |
| 8 | ||
| 9 | function dayKey(date: Date): string { | |
| 10 | return date.toISOString().slice(0, 10) | |
| 11 | } | |
| 12 | ||
| 13 | function parseUtc(value: string): Date | null { | |
| 14 | const date = new Date(`${String(value).replace(' ', 'T')}Z`) | |
| 15 | return Number.isNaN(date.getTime()) ? null : date | |
| 16 | } | |
| 17 | ||
| 18 | export default new Action({ | |
| 19 | name: 'Postline Analytics', | |
| 20 | description: 'Publishing analytics computed from real posts and targets.', | |
| 21 | method: 'GET', | |
| 22 | ||
| 23 | async handle() { | |
| 24 | try { | |
| 25 | const since = new Date(Date.now() - 30 * DAY_MS) | |
| 26 | const sinceKey = `${dayKey(since)} 00:00:00` | |
| 27 | ||
| 28 | const posts = await database | |
| 29 | .selectFrom('posts') | |
| 30 | .select(['id', 'published_at']) | |
| 31 | .where('status', '=', 'published') | |
| 32 | .where('published_at', '>=', sinceKey) | |
| 33 | .execute() | |
| 34 | ||
| 35 | const targets = await database | |
| 36 | .selectFrom('post_targets') | |
| 37 | .select(['provider', 'status', 'updated_at']) | |
| 38 | .where('status', '=', 'published') | |
| 39 | .execute() | |
| 40 | ||
| 41 | // Posts published per day over the last 30 days (gaps filled with 0). | |
| 42 | const byDay = new Map<string, number>() | |
| 43 | for (const post of posts) { | |
| 44 | const date = parseUtc(post.published_at) | |
| 45 | if (date) byDay.set(dayKey(date), (byDay.get(dayKey(date)) || 0) + 1) | |
| 46 | } | |
| 47 | const series: Array<{ label: string, value: number }> = [] | |
| 48 | for (let offset = 29; offset >= 0; offset--) { | |
| 49 | const date = new Date(Date.now() - offset * DAY_MS) | |
| 50 | series.push({ | |
| 51 | label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }), | |
| 52 | value: byDay.get(dayKey(date)) || 0, | |
| 53 | }) | |
| 54 | } | |
| 55 | ||
| 56 | const weekAgo = Date.now() - 7 * DAY_MS | |
| 57 | const twoWeeksAgo = Date.now() - 14 * DAY_MS | |
| 58 | let postsThisWeek = 0 | |
| 59 | let postsLastWeek = 0 | |
| 60 | for (const post of posts) { | |
| 61 | const time = parseUtc(post.published_at)?.getTime() | |
| 62 | if (!time) continue | |
| 63 | if (time >= weekAgo) postsThisWeek += 1 | |
| 64 | else if (time >= twoWeeksAgo) postsLastWeek += 1 | |
| 65 | } | |
| 66 | ||
| 67 | // Channel mix across all published targets. | |
| 68 | const byProvider = new Map<string, number>() | |
| 69 | for (const target of targets) | |
| 70 | byProvider.set(target.provider, (byProvider.get(target.provider) || 0) + 1) | |
| 71 | const totalTargets = targets.length | |
| 72 | const channelMix = [...byProvider.entries()] | |
| 73 | .sort((a, b) => b[1] - a[1]) | |
| 74 | .map(([provider, count]) => ({ | |
| 75 | provider, | |
| 76 | published: count, | |
| 77 | share: totalTargets ? Math.round((count / totalTargets) * 100) : 0, | |
| 78 | })) | |
| 79 | ||
| 80 | // Publish-hour histogram (UTC) → best posting windows. | |
| 81 | const byHour = new Map<number, number>() | |
| 82 | for (const target of targets) { | |
| 83 | const date = parseUtc(target.updated_at) | |
| 84 | if (date) byHour.set(date.getUTCHours(), (byHour.get(date.getUTCHours()) || 0) + 1) | |
| 85 | } | |
| 86 | const windows = [...byHour.entries()] | |
| 87 | .sort((a, b) => b[1] - a[1]) | |
| 88 | .slice(0, 3) | |
| 89 | .map(([hour, count]) => ({ hour, count })) | |
| 90 | ||
| 91 | return response.json({ | |
| 92 | ok: true, | |
| 93 | data: { | |
| 94 | stats: { | |
| 95 | postsThisWeek, | |
| 96 | postsLastWeek, | |
| 97 | bestChannel: channelMix[0] || null, | |
| 98 | bestHour: windows[0] || null, | |
| 99 | }, | |
| 100 | series, | |
| 101 | channelMix, | |
| 102 | windows, | |
| 103 | }, | |
| 104 | }) | |
| 105 | } | |
| 106 | catch (error) { | |
| 107 | return response.json({ | |
| 108 | ok: false, | |
| 109 | error: error instanceof Error ? error.message : String(error), | |
| 110 | }, { status: 500 }) | |
| 111 | } | |
| 112 | }, | |
| 113 | }) | |