ReviewOS

also looking at this

stacks/postline

UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target

#8
Merged glennmichael123 wants to merge feat/ui-polish into main
54 files +2036 -589
app/Actions/Postline/AnalyticsAction.tsadded+113-0
Changes to app/Actions/Postline/AnalyticsAction.ts
@@ -0,0 +1,113 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { response } from '@stacksjs/router'
4
5const database = db as any
6
7const DAY_MS = 24 * 60 * 60 * 1000
8
9function dayKey(date: Date): string {
10 return date.toISOString().slice(0, 10)
11}
12
13function 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
18export 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})