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
@@ -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>