feat(messages): enhance message and edit history with channel names and content diffs

This commit is contained in:
asepharyana
2026-08-26 18:26:15 +07:00
parent 709074935f
commit cacebfd94e
6 changed files with 234 additions and 67 deletions
@@ -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<string, unknown>[]) || [];
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,
@@ -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 (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
@@ -595,6 +600,44 @@ function MessageDetail({
</div>
)}
{/* Edit history — before / after diff */}
{editCount > 0 && editHistory.length > 0 && (
<div>
<div className="eyebrow mb-1.5 flex items-center gap-1.5">
<History className="size-3" /> Edit history ({editCount})
</div>
<div className="space-y-2">
{editHistory.map((e, i) => (
<div
key={`${m.id}-edit-${i}`}
className="grid grid-cols-2 gap-1.5 rounded-[6px] border border-hairline bg-surface-2/50 text-[10px] leading-relaxed"
>
<div className="overflow-hidden rounded-l-[5px] border-r border-hairline">
<div className="border-b border-hairline bg-vermilion/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-vermilion">
Before
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-faint/80">
{e.old_content || <em>(empty)</em>}
</pre>
</div>
</div>
<div className="overflow-hidden rounded-r-[5px]">
<div className="border-b border-hairline bg-success/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-success">
After
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-soft">
{m.content || <em>(empty)</em>}
</pre>
</div>
</div>
</div>
))}
</div>
</div>
)}
{attachments.length > 0 && (
<div>
<div className="eyebrow mb-1 flex items-center gap-1.5">
@@ -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<string, number>();
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<string, number>();
const channelNames = new Map<string, string>();
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 (
<GlassPanel className="lg:col-span-2">
<GlassPanel>
<SectionHeader eyebrow="insight" title="Activity Heatmap" />
<p className="py-6 text-center text-xs text-ink-faint">
No message activity recorded yet.
@@ -39,49 +66,81 @@ export function ActivityHeatmap({
}
return (
<GlassPanel className="lg:col-span-5">
<GlassPanel>
<SectionHeader
eyebrow="insight"
title="Activity Heatmap"
action={
<span className="mono text-[0.65rem] text-ink-faint">
{channels.length} channels · messages/hour
<span className="mono text-[10px] text-ink-faint">
{rows.length} channels · {globalMax} peak msgs/hr
</span>
}
/>
<div className="overflow-x-auto">
<div className="min-w-[640px] space-y-1">
{channels.map((ch) => (
<div key={ch} className="flex items-center gap-2">
<span className="mono w-24 shrink-0 truncate text-[0.6rem] text-ink-faint">
{ch.slice(-6)}
</span>
<div className="flex flex-1 gap-0.5">
{HOURS.map((h) => {
const c = byKey.get(`${ch}:${h}`) ?? 0;
const t = max > 0 ? c / max : 0;
return (
<div
key={h}
title={`${ch} · ${String(h).padStart(2, "0")}:00 — ${c} msgs`}
className="h-4 flex-1 rounded-[2px]"
style={{ background: heatColor(t) }}
/>
);
})}
</div>
</div>
))}
<div className="flex items-center gap-2 pt-1">
<span className="w-24 shrink-0" />
<div className="flex flex-1 justify-between">
{[0, 6, 12, 18, 23].map((h) => (
<span key={h} className="mono text-[0.55rem] text-ink-faint">
{String(h).padStart(2, "0")}h
<div className="min-w-[600px]">
{/* Column headers — hour labels */}
<div className="mb-1 flex items-center gap-2">
<span className="w-28 shrink-0" />
<div className="flex flex-1 justify-between px-px">
{HOUR_MARKS.map((h) => (
<span
key={h}
className="mono text-[9px] text-ink-faint tabular-nums"
>
{String(h).padStart(2, "0")}
</span>
))}
</div>
</div>
{/* Rows */}
<div className="space-y-0.5">
{rows.map((row) => (
<div key={row.id} className="flex items-center gap-2">
<span
className="w-28 shrink-0 truncate text-[10px] text-ink-muted"
title={row.name}
>
{row.name}
</span>
<div className="flex flex-1 gap-px">
{row.cells.map((count, h) => {
// Per-channel normalisation for better local contrast
const chMax = Math.max(...row.cells, 1);
const t = count / chMax;
return (
<div
key={h}
title={`${row.name} · ${String(h).padStart(2, "0")}:00 — ${count} msgs`}
className="h-5 flex-1 rounded-[2px] transition-colors hover:ring-1 hover:ring-signal/40"
style={{ background: heatColor(t) }}
/>
);
})}
</div>
{/* Row total */}
<span className="w-10 shrink-0 text-right font-mono text-[9px] text-ink-faint tabular-nums">
{row.cells.reduce((s, v) => s + v, 0)}
</span>
</div>
))}
</div>
{/* Legend */}
<div className="mt-2 flex items-center gap-2">
<span className="w-28 shrink-0" />
<div className="flex items-center gap-1.5 text-[9px] text-ink-faint">
<span>Less</span>
{[0, 0.15, 0.35, 0.6, 0.9].map((t) => (
<div
key={t}
className="h-3 w-3 rounded-[2px]"
style={{ background: heatColor(t) }}
/>
))}
<span>More</span>
</div>
</div>
</div>
</div>
</GlassPanel>
@@ -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 (
<div className="grid grid-cols-2 gap-2 rounded-[6px] border border-hairline bg-surface-2/50 text-[11px] leading-relaxed">
{/* Before */}
<div className="min-w-0 overflow-hidden rounded-l-[5px] border-r border-hairline">
<div className="flex items-center gap-1.5 border-b border-hairline bg-vermilion/5 px-2.5 py-1">
<span className="font-mono text-[9px] font-semibold uppercase tracking-wider text-vermilion">
Before
</span>
</div>
<div className="max-h-24 overflow-y-auto p-2">
{oldLines.length === 0 || (oldLines.length === 1 && !oldLines[0]) ? (
<span className="italic text-ink-faint">(empty)</span>
) : (
oldLines.map((line, i) => (
<div
key={`o-${i}`}
className="whitespace-pre-wrap break-words text-ink-faint/80"
>
{line}
</div>
))
)}
</div>
</div>
{/* After */}
<div className="min-w-0 overflow-hidden rounded-r-[5px]">
<div className="flex items-center gap-1.5 border-b border-hairline bg-success/5 px-2.5 py-1">
<span className="font-mono text-[9px] font-semibold uppercase tracking-wider text-success">
After
</span>
</div>
<div className="max-h-24 overflow-y-auto p-2">
{newLines.length === 0 || (newLines.length === 1 && !newLines[0]) ? (
<span className="italic text-ink-faint">(empty)</span>
) : (
newLines.map((line, i) => (
<div
key={`n-${i}`}
className="whitespace-pre-wrap break-words text-ink-soft"
>
{line}
</div>
))
)}
</div>
</div>
</div>
);
}
export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
return (
<GlassPanel className="lg:col-span-4">
@@ -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.
</p>
) : (
<div className="space-y-3">
<div className="space-y-4">
{edits.map((e) => (
<div key={e.id} className="text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-medium text-ink">
<div key={e.id} className="space-y-2">
{/* Header row */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-ink">
{e.username ?? "unknown"}
</span>
<span className="text-xs text-ink-faint">
edited {formatRelativeTime(e.edited_at)} ·{" "}
{e.channel_name ?? e.channel_id ?? "unknown channel"}
<span className="flex items-center gap-1 text-[10px] text-ink-muted">
<History className="size-3 text-ink-faint/50" />
edited {formatRelativeTime(e.edited_at)}
</span>
<span className="ml-auto font-mono text-[10px] text-ink-faint">
{e.channel_name ?? e.channel_id ?? ""}
</span>
</div>
<div className="mt-1 flex items-start gap-1.5">
<History className="mt-0.5 size-3.5 shrink-0 text-ink-faint/50" />
<pre className="line-clamp-2 whitespace-pre-wrap break-words text-ink-faint/80">
{e.old_content || <em>(content not available)</em>}
</pre>
</div>
{/* Before / After diff */}
<DiffBlock oldText={e.old_content} newText={e.new_content} />
</div>
))}
</div>
@@ -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;
@@ -175,6 +175,7 @@ export interface SemanticSearchResult {
export interface MessageActivityBucket {
channelId: string;
channelName: string;
hour: number;
count: number;
}