Skip to content

Commit b4eace1

Browse files
leopu00claude
andcommitted
feat(activity): pagina /activity feed timeline team con paginazione e filtri tipo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aea541c commit b4eace1

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

web/app/activity/page.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useCallback, useEffect, useState } from 'react'
5+
6+
type ActivityType = 'merge' | 'pr' | 'task' | 'test' | 'forum' | 'deploy'
7+
type ActivityItem = { id: string; type: ActivityType; title: string; description?: string; actor?: string; at: number }
8+
type ActivityRes = { items: ActivityItem[]; total: number; page: number; pages: number }
9+
10+
const TYPE_ICON: Record<ActivityType, string> = {
11+
merge: '🔀', pr: '📬', task: '📋', test: '🧪', forum: '💬', deploy: '🚀',
12+
}
13+
const TYPE_COLOR: Record<ActivityType, string> = {
14+
merge: 'var(--color-green)', pr: 'var(--color-yellow)', task: 'var(--color-muted)',
15+
test: 'var(--color-green)', forum: 'var(--color-dim)', deploy: 'var(--color-orange)',
16+
}
17+
const TYPE_LABEL: Record<ActivityType, string> = {
18+
merge: 'merge', pr: 'PR', task: 'task', test: 'test', forum: 'forum', deploy: 'deploy',
19+
}
20+
const FILTERS: Array<{ key: 'all' | ActivityType; label: string }> = [
21+
{ key: 'all', label: 'tutto' }, { key: 'merge', label: 'merge' }, { key: 'pr', label: 'PR' },
22+
{ key: 'task', label: 'task' }, { key: 'test', label: 'test' }, { key: 'forum', label: 'forum' },
23+
]
24+
25+
function fmtTime(ms: number) {
26+
const diff = Date.now() - ms
27+
const m = Math.floor(diff / 60000)
28+
if (m < 1) return 'adesso'
29+
if (m < 60) return `${m}m fa`
30+
const h = Math.floor(m / 60)
31+
if (h < 24) return `${h}h fa`
32+
return new Date(ms).toLocaleDateString('it-IT', { day: '2-digit', month: 'short' })
33+
}
34+
35+
function TimelineDot({ type }: { type: ActivityType }) {
36+
return (
37+
<div className="flex flex-col items-center flex-shrink-0" style={{ width: 32 }}>
38+
<div className="w-7 h-7 rounded-full flex items-center justify-center text-sm flex-shrink-0"
39+
style={{ background: `${TYPE_COLOR[type]}18`, border: `1px solid ${TYPE_COLOR[type]}44` }}>
40+
{TYPE_ICON[type]}
41+
</div>
42+
</div>
43+
)
44+
}
45+
46+
export default function ActivityPage() {
47+
const [data, setData] = useState<ActivityRes | null>(null)
48+
const [loading, setLoading] = useState(true)
49+
const [filter, setFilter] = useState<'all' | ActivityType>('all')
50+
const [page, setPage] = useState(1)
51+
52+
const fetchData = useCallback(async () => {
53+
setLoading(true)
54+
const q = new URLSearchParams({ page: String(page), limit: '20', type: filter })
55+
const res = await fetch(`/api/activity?${q}`).catch(() => null)
56+
if (res?.ok) setData(await res.json())
57+
setLoading(false)
58+
}, [page, filter])
59+
60+
useEffect(() => { fetchData() }, [fetchData])
61+
62+
const onFilter = (f: typeof filter) => { setFilter(f); setPage(1) }
63+
64+
return (
65+
<div style={{ animation: 'fade-in 0.35s ease both' }}>
66+
<div className="mb-8 pb-6 border-b border-[var(--color-border)]">
67+
<div className="flex items-center gap-2 mb-1">
68+
<Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link>
69+
<span className="text-[var(--color-border)]">/</span>
70+
<span className="text-[10px] text-[var(--color-muted)]">Attività</span>
71+
</div>
72+
<div className="mt-3">
73+
<h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)]">Attività team</h1>
74+
<p className="text-[var(--color-muted)] text-[11px] mt-1">{data ? `${data.total} eventi totali` : '…'}</p>
75+
</div>
76+
<div className="flex gap-1.5 flex-wrap mt-4">
77+
{FILTERS.map(f => (
78+
<button key={f.key} onClick={() => onFilter(f.key)}
79+
className="px-3 py-1.5 rounded text-[10px] font-semibold cursor-pointer transition-all"
80+
style={{ border: `1px solid ${filter === f.key ? 'var(--color-green)' : 'var(--color-border)'}`, color: filter === f.key ? 'var(--color-green)' : 'var(--color-dim)', background: filter === f.key ? 'rgba(0,232,122,0.08)' : 'transparent' }}>
81+
{f.label}
82+
</button>
83+
))}
84+
</div>
85+
</div>
86+
87+
{loading && <div className="flex justify-center py-16"><span className="text-[var(--color-dim)] text-[12px]">Caricamento…</span></div>}
88+
89+
{!loading && data && data.items.length === 0 && (
90+
<div className="flex flex-col items-center justify-center py-16 gap-3">
91+
<span className="text-4xl">📭</span>
92+
<p className="text-[12px] font-semibold text-[var(--color-muted)]">Nessuna attività</p>
93+
<p className="text-[10px] text-[var(--color-dim)]">L&apos;attività del team apparirà qui.</p>
94+
</div>
95+
)}
96+
97+
{!loading && data && data.items.length > 0 && (
98+
<div className="flex flex-col gap-0">
99+
{data.items.map((item, i) => (
100+
<div key={item.id} className="flex gap-3 group">
101+
<div className="flex flex-col items-center">
102+
<TimelineDot type={item.type} />
103+
{i < data.items.length - 1 && <div className="w-px flex-1 mt-1" style={{ background: 'var(--color-border)', minHeight: 20 }} />}
104+
</div>
105+
<div className="flex-1 pb-5 pt-0.5">
106+
<div className="flex items-start justify-between gap-3 flex-wrap">
107+
<div className="flex-1 min-w-0">
108+
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
109+
<span className="badge text-[9px]" style={{ color: TYPE_COLOR[item.type], background: `${TYPE_COLOR[item.type]}12`, border: `1px solid ${TYPE_COLOR[item.type]}33` }}>
110+
{TYPE_LABEL[item.type]}
111+
</span>
112+
{item.actor && <span className="text-[9px] text-[var(--color-dim)] font-mono">{item.actor}</span>}
113+
</div>
114+
<p className="text-[12px] text-[var(--color-bright)] leading-snug">{item.title}</p>
115+
{item.description && <p className="text-[10px] text-[var(--color-dim)] mt-0.5 truncate">{item.description}</p>}
116+
</div>
117+
<span className="text-[9px] text-[var(--color-dim)] flex-shrink-0 mt-0.5">{fmtTime(item.at)}</span>
118+
</div>
119+
</div>
120+
</div>
121+
))}
122+
</div>
123+
)}
124+
125+
{data && data.pages > 1 && (
126+
<div className="flex items-center justify-between mt-6 pt-4 border-t border-[var(--color-border)]">
127+
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
128+
className="px-4 py-2 rounded text-[10px] font-semibold transition-colors"
129+
style={{ border: '1px solid var(--color-border)', color: page <= 1 ? 'var(--color-border)' : 'var(--color-muted)', cursor: page <= 1 ? 'default' : 'pointer', background: 'transparent' }}>
130+
← precedente
131+
</button>
132+
<span className="text-[10px] text-[var(--color-dim)]">pag. {page} / {data.pages}</span>
133+
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}
134+
className="px-4 py-2 rounded text-[10px] font-semibold transition-colors"
135+
style={{ border: '1px solid var(--color-border)', color: page >= data.pages ? 'var(--color-border)' : 'var(--color-muted)', cursor: page >= data.pages ? 'default' : 'pointer', background: 'transparent' }}>
136+
successiva →
137+
</button>
138+
</div>
139+
)}
140+
</div>
141+
)
142+
}

web/app/api/activity/route.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import * as fs from 'node:fs'
3+
import * as path from 'node:path'
4+
import * as os from 'node:os'
5+
6+
export const dynamic = 'force-dynamic'
7+
8+
const TASKS_DIR = path.join(os.homedir(), '.jht-dev', 'tasks')
9+
const FORUM_LOG = path.join(os.homedir(), '.jht', 'forum.log')
10+
11+
export type ActivityType = 'merge' | 'pr' | 'task' | 'test' | 'forum' | 'deploy'
12+
13+
export interface ActivityItem {
14+
id: string
15+
type: ActivityType
16+
title: string
17+
description?: string
18+
actor?: string
19+
at: number
20+
}
21+
22+
/** Legge task YAML frontmatter senza dipendenze */
23+
function parseTaskFrontmatter(content: string): Record<string, string> {
24+
const match = content.match(/^---\n([\s\S]*?)\n---/)
25+
if (!match) return {}
26+
const out: Record<string, string> = {}
27+
for (const line of match[1]!.split('\n')) {
28+
const sep = line.indexOf(':')
29+
if (sep === -1) continue
30+
out[line.slice(0, sep).trim()] = line.slice(sep + 1).trim().replace(/^"|"$/g, '')
31+
}
32+
return out
33+
}
34+
35+
function taskToActivity(meta: Record<string, string>): ActivityItem | null {
36+
const stato = meta['stato'] ?? ''
37+
if (!['merged', 'pr-ready', 'in-progress'].includes(stato)) return null
38+
39+
const type: ActivityType = stato === 'merged' ? 'merge' : stato === 'pr-ready' ? 'pr' : 'task'
40+
const id = meta['id'] ?? ''
41+
const aggiornato = meta['aggiornato'] ?? ''
42+
const at = aggiornato ? new Date(aggiornato.replace(' ', 'T')).getTime() : 0
43+
if (!at || !id) return null
44+
45+
const assegnato = meta['assegnato_a']?.split('(')[0]?.trim() ?? '—'
46+
const label: Record<string, string> = { merge: 'Mergiato', 'pr': 'PR pronta', task: 'In corso' }
47+
48+
return {
49+
id: `task-${id}-${stato}`,
50+
type,
51+
title: `${label[type]}: ${id}`,
52+
description: (meta['richiesta'] ?? '').slice(0, 80) + ((meta['richiesta'] ?? '').length > 80 ? '…' : ''),
53+
actor: assegnato,
54+
at,
55+
}
56+
}
57+
58+
/** Parsa forum.log — ultime N righe */
59+
function parseForumLog(limit: number): ActivityItem[] {
60+
try {
61+
const raw = fs.readFileSync(FORUM_LOG, 'utf-8')
62+
const lines = raw.trim().split('\n').slice(-limit * 3)
63+
const items: ActivityItem[] = []
64+
for (const line of lines) {
65+
const m = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \[(\w+)\] (.+)$/)
66+
if (!m) continue
67+
const [, ts, actor, msg] = m
68+
const at = new Date(ts!.replace(' ', 'T')).getTime()
69+
const type: ActivityType = /test|vitest/.test(msg!) ? 'test' : /deploy|build/.test(msg!) ? 'deploy' : 'forum'
70+
items.push({ id: `forum-${at}-${actor}`, type, title: msg!.slice(0, 80) + (msg!.length > 80 ? '…' : ''), actor: actor!, at })
71+
}
72+
return items.slice(-limit)
73+
} catch { return [] }
74+
}
75+
76+
/** GET — activity feed paginato */
77+
export async function GET(req: NextRequest) {
78+
const page = Math.max(1, parseInt(req.nextUrl.searchParams.get('page') ?? '1'))
79+
const limit = Math.min(50, Math.max(5, parseInt(req.nextUrl.searchParams.get('limit') ?? '20')))
80+
const type = req.nextUrl.searchParams.get('type') ?? 'all'
81+
82+
const items: ActivityItem[] = []
83+
84+
// Task files
85+
try {
86+
const files = fs.readdirSync(TASKS_DIR).filter(f => f.endsWith('.md'))
87+
for (const file of files) {
88+
const content = fs.readFileSync(path.join(TASKS_DIR, file), 'utf-8')
89+
const meta = parseTaskFrontmatter(content)
90+
const item = taskToActivity(meta)
91+
if (item) items.push(item)
92+
}
93+
} catch { /* dir non trovata */ }
94+
95+
// Forum log
96+
items.push(...parseForumLog(60))
97+
98+
// Filtra per tipo
99+
const filtered = type === 'all' ? items : items.filter(i => i.type === type)
100+
101+
// Sort desc e pagina
102+
filtered.sort((a, b) => b.at - a.at)
103+
const total = filtered.length
104+
const start = (page - 1) * limit
105+
const data = filtered.slice(start, start + limit)
106+
107+
return NextResponse.json({ items: data, total, page, limit, pages: Math.ceil(total / limit) })
108+
}

0 commit comments

Comments
 (0)