From cacebfd94e6eb6634c6e1a5de16bf2b36ca5888d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 26 Aug 2026 18:26:15 +0700 Subject: [PATCH] feat(messages): enhance message and edit history with channel names and content diffs --- .../modules/messages/messages.repository.ts | 21 ++- .../src/app/(dashboard)/messages/view.tsx | 43 +++++ .../src/components/ActivityHeatmap.tsx | 149 ++++++++++++------ .../frontend/src/components/EditHistory.tsx | 86 ++++++++-- services/frontend/src/lib/types/knowledge.ts | 1 + services/frontend/src/lib/types/message.ts | 1 + 6 files changed, 234 insertions(+), 67 deletions(-) diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 24ff19c1..7d5dbed4 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -469,17 +469,20 @@ export class MessagesRepository { const db = getDatabase(); const since = Date.now() - days * 24 * 60 * 60 * 1000; const result = await db.execute(sql` - SELECT channel_id, - EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour, - COUNT(*)::int AS c - FROM messages - WHERE created_at >= ${since} - GROUP BY channel_id, hour - ORDER BY channel_id, hour + SELECT + m.channel_id, + COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, + EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour, + COUNT(*)::int AS c + FROM messages m + WHERE m.created_at >= ${since} + GROUP BY m.channel_id, channel_name, hour + ORDER BY channel_name, hour `); const rows = (result.rows as Record[]) || []; return rows.map((r) => ({ channelId: String(r.channel_id ?? "unknown"), + channelName: String(r.channel_name ?? r.channel_id ?? "unknown"), hour: Number(r.hour ?? 0), count: Number(r.c ?? 0), })); @@ -499,7 +502,8 @@ export class MessagesRepository { e.edited_at, m.channel_id, COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, - m.username + m.username, + m.content AS new_content FROM message_edits e JOIN messages m ON m.id = e.message_id ${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``} @@ -511,6 +515,7 @@ export class MessagesRepository { id: String(r.id), message_id: String(r.message_id), old_content: r.old_content ? String(r.old_content) : "", + new_content: r.new_content ? String(r.new_content) : "", edited_at: r.edited_at ? Number(r.edited_at) : 0, channel_id: r.channel_id ? String(r.channel_id) : null, channel_name: r.channel_name ? String(r.channel_name) : null, diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 1f3363f4..a5e38220 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Calendar, CheckCircle2, + History, Image as ImageIcon, Loader2, MessageSquare, @@ -548,6 +549,10 @@ function MessageDetail({ }) { const flags = safeParseJsonArray(m.ai_moderation_flags); const cats = safeParseJsonArray(m.ai_categories); + const editHistory = + (m as { edit_history?: Array<{ old_content: string; edited_at: number }> }) + .edit_history ?? []; + const editCount = (m as { edit_count?: number }).edit_count ?? 0; return (
@@ -595,6 +600,44 @@ function MessageDetail({
)} + {/* Edit history — before / after diff */} + {editCount > 0 && editHistory.length > 0 && ( +
+
+ Edit history ({editCount}) +
+
+ {editHistory.map((e, i) => ( +
+
+
+ Before +
+
+
+                      {e.old_content || (empty)}
+                    
+
+
+
+
+ After +
+
+
+                      {m.content || (empty)}
+                    
+
+
+
+ ))} +
+
+ )} + {attachments.length > 0 && (
diff --git a/services/frontend/src/components/ActivityHeatmap.tsx b/services/frontend/src/components/ActivityHeatmap.tsx index 7cf3341e..9371b8c2 100644 --- a/services/frontend/src/components/ActivityHeatmap.tsx +++ b/services/frontend/src/components/ActivityHeatmap.tsx @@ -1,35 +1,62 @@ "use client"; +import { useMemo } from "react"; import { GlassPanel } from "@/components/primitives"; import { SectionHeader } from "@/components/shared"; import type { MessageActivityBucket } from "@/lib/types"; const HOURS = Array.from({ length: 24 }, (_, i) => i); +/** Neutral palette: dark slate → signal teal. No brand color for quiet data. */ function heatColor(t: number): string { - // t in [0,1] → signal gradient (dark → bright). - if (t <= 0) return "var(--color-hairline)"; - return `rgba(45, 212, 191, ${0.15 + 0.85 * t})`; + if (t <= 0) return "var(--color-surface-2)"; + // 4 stops: subtle → medium → bright → full + if (t < 0.25) return "rgba(45, 212, 191, 0.10)"; + if (t < 0.5) return "rgba(45, 212, 191, 0.25)"; + if (t < 0.75) return "rgba(45, 212, 191, 0.50)"; + return "rgba(45, 212, 191, 0.85)"; } +const HOUR_MARKS = [0, 4, 8, 12, 16, 20, 23]; + export function ActivityHeatmap({ buckets, }: { buckets: MessageActivityBucket[]; }) { - // Group by channel, find max count for normalization. - const channels = Array.from(new Set(buckets.map((b) => b.channelId))); - const byKey = new Map(); - let max = 0; - for (const b of buckets) { - const k = `${b.channelId}:${b.hour}`; - byKey.set(k, (byKey.get(k) ?? 0) + b.count); - if ((byKey.get(k) ?? 0) > max) max = byKey.get(k) ?? 0; - } + // Group by channel, normalise per-channel for better contrast. + const { rows, globalMax } = useMemo(() => { + const byKey = new Map(); + const channelNames = new Map(); + let gMax = 0; - if (buckets.length === 0) { + for (const b of buckets) { + const k = `${b.channelId}:${b.hour}`; + const next = (byKey.get(k) ?? 0) + b.count; + byKey.set(k, next); + channelNames.set(b.channelId, b.channelName); + if (next > gMax) gMax = next; + } + + const rows = Array.from(channelNames.entries()) + .map(([id, name]) => ({ + id, + name, + cells: HOURS.map((h) => byKey.get(`${id}:${h}`) ?? 0), + })) + // Sort by total descending so busiest channel is on top + .sort((a, b) => { + const sumA = a.cells.reduce((s, v) => s + v, 0); + const sumB = b.cells.reduce((s, v) => s + v, 0); + return sumB - sumA; + }); + + return { rows, globalMax: gMax }; + }, [buckets]); + + if (rows.length === 0) { return ( - +

No message activity recorded yet. @@ -39,49 +66,81 @@ export function ActivityHeatmap({ } return ( - + - {channels.length} channels · messages/hour + + {rows.length} channels · {globalMax} peak msgs/hr } />

-
- {channels.map((ch) => ( -
- - {ch.slice(-6)} - -
- {HOURS.map((h) => { - const c = byKey.get(`${ch}:${h}`) ?? 0; - const t = max > 0 ? c / max : 0; - return ( -
- ); - })} -
-
- ))} -
- -
- {[0, 6, 12, 18, 23].map((h) => ( - - {String(h).padStart(2, "0")}h +
+ {/* Column headers — hour labels */} +
+ +
+ {HOUR_MARKS.map((h) => ( + + {String(h).padStart(2, "0")} ))}
+ + {/* Rows */} +
+ {rows.map((row) => ( +
+ + {row.name} + +
+ {row.cells.map((count, h) => { + // Per-channel normalisation for better local contrast + const chMax = Math.max(...row.cells, 1); + const t = count / chMax; + return ( +
+ ); + })} +
+ {/* Row total */} + + {row.cells.reduce((s, v) => s + v, 0)} + +
+ ))} +
+ + {/* Legend */} +
+ +
+ Less + {[0, 0.15, 0.35, 0.6, 0.9].map((t) => ( +
+ ))} + More +
+
diff --git a/services/frontend/src/components/EditHistory.tsx b/services/frontend/src/components/EditHistory.tsx index cb37cb21..fd406992 100644 --- a/services/frontend/src/components/EditHistory.tsx +++ b/services/frontend/src/components/EditHistory.tsx @@ -1,12 +1,68 @@ "use client"; -import { Download, History } from "lucide-react"; +import { ArrowRight, Download, History } from "lucide-react"; import { GlassPanel } from "@/components/primitives"; import { SectionHeader } from "@/components/shared"; import { downloadCsv } from "@/lib/csv"; import { formatRelativeTime } from "@/lib/format"; import type { EditHistoryRow } from "@/lib/types"; +function DiffBlock({ oldText, newText }: { oldText: string; newText: string }) { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const maxLen = Math.max(oldLines.length, newLines.length); + + return ( +
+ {/* Before */} +
+
+ + Before + +
+
+ {oldLines.length === 0 || (oldLines.length === 1 && !oldLines[0]) ? ( + (empty) + ) : ( + oldLines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+ + {/* After */} +
+
+ + After + +
+
+ {newLines.length === 0 || (newLines.length === 1 && !newLines[0]) ? ( + (empty) + ) : ( + newLines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+
+ ); +} + export function EditHistory({ edits }: { edits: EditHistoryRow[] }) { return ( @@ -24,6 +80,7 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) { author: e.username ?? "", channel: e.channel_name ?? "", old_content: e.old_content, + new_content: e.new_content, edited_at: e.edited_at, })), ) @@ -41,24 +98,25 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) { No edited messages recorded recently.

) : ( -
+
{edits.map((e) => ( -
-
- +
+ {/* Header row */} +
+ {e.username ?? "unknown"} - - edited {formatRelativeTime(e.edited_at)} ·{" "} - {e.channel_name ?? e.channel_id ?? "unknown channel"} + + + edited {formatRelativeTime(e.edited_at)} + + + {e.channel_name ?? e.channel_id ?? ""}
-
- -
-                  {e.old_content || (content not available)}
-                
-
+ + {/* Before / After diff */} +
))}
diff --git a/services/frontend/src/lib/types/knowledge.ts b/services/frontend/src/lib/types/knowledge.ts index 3e986c63..139bd487 100644 --- a/services/frontend/src/lib/types/knowledge.ts +++ b/services/frontend/src/lib/types/knowledge.ts @@ -18,6 +18,7 @@ export interface EditHistoryRow { id: string; message_id: string; old_content: string; + new_content: string; edited_at: number; channel_id: string | null; channel_name: string | null; diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index 0e6a9a89..b0471fd3 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -175,6 +175,7 @@ export interface SemanticSearchResult { export interface MessageActivityBucket { channelId: string; + channelName: string; hour: number; count: number; }