Enrich contact no-key info pane with first-in-use date

This commit is contained in:
Jack Kingsman
2026-03-11 16:19:10 -07:00
parent 93369f8d64
commit 0c35601af3
8 changed files with 749 additions and 164 deletions

View File

@@ -233,6 +233,44 @@ class NameOnlyContactDetail(BaseModel):
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
last_24h_count: int = 0
last_week_average: float = 0
all_time_average: float = 0
class ContactAnalyticsWeeklyBucket(BaseModel):
"""A single weekly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
message_count: int = 0
class ContactAnalytics(BaseModel):
"""Unified contact analytics payload for keyed and name-only lookups."""
lookup_type: Literal["contact", "name"]
name: str
contact: Contact | None = None
name_first_seen_at: int | None = None
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
includes_direct_messages: bool = False
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str

View File

@@ -3,10 +3,28 @@ import time
from typing import Any
from app.database import db
from app.models import Message, MessagePath
from app.models import (
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Message,
MessagePath,
)
class MessageRepository:
@staticmethod
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower()
return (
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key],
)
@staticmethod
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
return "type = 'CHAN' AND sender_name = ?", [sender_name]
@staticmethod
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
"""Parse paths JSON string to list of MessagePath objects."""
@@ -555,6 +573,16 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
"""Get the earliest stored channel message timestamp for a display name."""
cursor = await db.conn.execute(
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["first_seen"] if row and row["first_seen"] is not None else None
@staticmethod
async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
@@ -663,3 +691,114 @@ class MessageRepository:
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
cursor = await db.conn.execute(
f"""
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
FROM messages
WHERE {where_sql}
GROUP BY hour_bucket
""",
params,
)
rows = await cursor.fetchall()
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
@staticmethod
def _build_hourly_activity(
hour_counts: dict[int, int], now: int
) -> list[ContactAnalyticsHourlyBucket]:
current_hour = now // 3600
if hour_counts:
min_hour = min(hour_counts)
else:
min_hour = current_hour
buckets: list[ContactAnalyticsHourlyBucket] = []
for hour_bucket in range(current_hour - 23, current_hour + 1):
last_24h_count = hour_counts.get(hour_bucket, 0)
week_total = 0
week_samples = 0
all_time_total = 0
all_time_samples = 0
compare_hour = hour_bucket
while compare_hour >= min_hour:
count = hour_counts.get(compare_hour, 0)
all_time_total += count
all_time_samples += 1
if week_samples < 7:
week_total += count
week_samples += 1
compare_hour -= 24
buckets.append(
ContactAnalyticsHourlyBucket(
bucket_start=hour_bucket * 3600,
last_24h_count=last_24h_count,
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
all_time_average=round(all_time_total / all_time_samples, 2)
if all_time_samples
else 0,
)
)
return buckets
@staticmethod
async def _get_weekly_activity(
where_sql: str,
params: list[Any],
now: int,
weeks: int = 26,
) -> list[ContactAnalyticsWeeklyBucket]:
bucket_seconds = 7 * 24 * 3600
current_day_start = (now // 86400) * 86400
start = current_day_start - (weeks - 1) * bucket_seconds
cursor = await db.conn.execute(
f"""
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
FROM messages
WHERE {where_sql} AND received_at >= ?
GROUP BY bucket_idx
""",
[start, bucket_seconds, *params, start],
)
rows = await cursor.fetchall()
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
return [
ContactAnalyticsWeeklyBucket(
bucket_start=start + bucket_idx * bucket_seconds,
message_count=counts.get(bucket_idx, 0),
)
for bucket_idx in range(weeks)
]
@staticmethod
async def get_contact_activity_series(
public_key: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get combined DM + channel activity series for a keyed contact."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._contact_activity_filter(public_key)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly
@staticmethod
async def get_sender_name_activity_series(
sender_name: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get channel-only activity series for a sender name."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._name_activity_filter(sender_name)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly

View File

@@ -10,6 +10,7 @@ from app.models import (
ContactActiveRoom,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactAnalytics,
ContactDetail,
ContactRoutingOverrideRequest,
ContactUpsert,
@@ -92,6 +93,102 @@ async def _broadcast_contact_update(contact: Contact) -> None:
broadcast_event("contact", contact.model_dump())
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
contact.public_key
)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
first_hop_stats: dict[str, dict] = {}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
return ContactAnalytics(
lookup_type="contact",
name=contact.name or contact.public_key[:12],
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
includes_direct_messages=True,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
return ContactAnalytics(
lookup_type="name",
name=name,
name_first_seen_at=name_first_seen_at,
channel_message_count=chan_count,
includes_direct_messages=False,
most_active_rooms=most_active_rooms,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
@@ -115,6 +212,26 @@ async def list_repeater_advert_paths(
)
@router.get("/analytics", response_model=ContactAnalytics)
async def get_contact_analytics(
public_key: str | None = Query(default=None),
name: str | None = Query(default=None, min_length=1, max_length=200),
) -> ContactAnalytics:
"""Get unified contact analytics for either a keyed contact or a sender name."""
if bool(public_key) == bool(name):
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
if public_key:
contact = await _resolve_contact_or_404(public_key)
return await _build_keyed_contact_analytics(contact)
assert name is not None
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
return await _build_name_only_contact_analytics(normalized_name)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -180,73 +297,17 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
advertisement paths, advert frequency, and nearest repeaters.
"""
contact = await _resolve_contact_or_404(public_key)
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
# Compute advert observation rate (observations/hour) from path data.
# Note: a single advertisement can arrive via multiple paths, so this counts
# RF observations, not unique advertisement broadcasts.
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
# Resolve all first-hop prefixes to contacts in a single query
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
analytics = await _build_keyed_contact_analytics(contact)
assert analytics.contact is not None
return ContactDetail(
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
contact=analytics.contact,
name_history=analytics.name_history,
dm_message_count=analytics.dm_message_count,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
advert_paths=analytics.advert_paths,
advert_frequency=analytics.advert_frequency,
nearest_repeaters=analytics.nearest_repeaters,
)
@@ -258,18 +319,11 @@ async def get_name_only_contact_detail(
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
chan_count = await MessageRepository.count_channel_messages_by_sender_name(normalized_name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(normalized_name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
analytics = await _build_name_only_contact_analytics(normalized_name)
return NameOnlyContactDetail(
name=normalized_name,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
name=analytics.name,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
)

View File

@@ -5,6 +5,7 @@ import type {
ChannelDetail,
CommandResponse,
Contact,
ContactAnalytics,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
@@ -114,6 +115,12 @@ export const api = {
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
const searchParams = new URLSearchParams();
if (params.publicKey) searchParams.set('public_key', params.publicKey);
if (params.name) searchParams.set('name', params.name);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
},
getContactDetail: (publicKey: string) =>
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
getNameOnlyContactDetail: (name: string) =>

View File

@@ -20,9 +20,10 @@ import { toast } from './ui/sonner';
import type {
Contact,
ContactActiveRoom,
ContactDetail,
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
NameOnlyContactDetail,
RadioConfig,
} from '../types';
@@ -73,8 +74,7 @@ export function ContactInfoPane({
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [nameOnlyDetail, setNameOnlyDetail] = useState<NameOnlyContactDetail | null>(null);
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false);
// Get live contact data from contacts array (real-time via WS)
@@ -82,21 +82,26 @@ export function ContactInfoPane({
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
useEffect(() => {
if (!contactKey || isNameOnly) {
setDetail(null);
if (!contactKey) {
setAnalytics(null);
return;
}
let cancelled = false;
setAnalytics(null);
setLoading(true);
api
.getContactDetail(contactKey)
const request =
isNameOnly && nameOnlyValue
? api.getContactAnalytics({ name: nameOnlyValue })
: api.getContactAnalytics({ publicKey: contactKey });
request
.then((data) => {
if (!cancelled) setDetail(data);
if (!cancelled) setAnalytics(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch contact detail:', err);
console.error('Failed to fetch contact analytics:', err);
toast.error('Failed to load contact info');
}
})
@@ -106,37 +111,10 @@ export function ContactInfoPane({
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
}, [contactKey, isNameOnly, nameOnlyValue]);
useEffect(() => {
if (!nameOnlyValue) {
setNameOnlyDetail(null);
return;
}
let cancelled = false;
setLoading(true);
api
.getNameOnlyContactDetail(nameOnlyValue)
.then((data) => {
if (!cancelled) setNameOnlyDetail(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch name-only contact detail:', err);
toast.error('Failed to load contact info');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [nameOnlyValue]);
// Use live contact data where available, fall back to detail snapshot
const contact = liveContact ?? detail?.contact ?? null;
// Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null;
const distFromUs =
contact &&
@@ -165,9 +143,15 @@ export function ContactInfoPane({
{/* Name-only header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
<ContactAvatar
name={analytics?.name ?? nameOnlyValue}
publicKey={`name:${nameOnlyValue}`}
size={56}
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
<h2 className="text-lg font-semibold truncate">
{analytics?.name ?? nameOnlyValue}
</h2>
<p className="text-xs text-muted-foreground mt-1">
We have not heard an advertisement associated with this name, so we cannot
identify their key.
@@ -209,16 +193,29 @@ export function ContactInfoPane({
<MessageStatsSection
dmMessageCount={0}
channelMessageCount={nameOnlyDetail?.channel_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
showDirectMessages={false}
/>
{analytics?.name_first_seen_at && (
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<InfoItem
label="Name First In Use"
value={formatTime(analytics.name_first_seen_at)}
/>
</div>
</div>
)}
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={nameOnlyDetail?.most_active_rooms ?? []}
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : loading && !detail ? (
) : loading && !analytics && !contact ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
@@ -391,11 +388,11 @@ export function ContactInfoPane({
)}
{/* Nearest Repeaters */}
{detail && detail.nearest_repeaters.length > 0 && (
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{detail.nearest_repeaters.map((r) => (
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
@@ -411,11 +408,11 @@ export function ContactInfoPane({
)}
{/* Advert Paths */}
{detail && detail.advert_paths.length > 0 && (
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1">
{detail.advert_paths.map((p) => (
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
@@ -434,16 +431,16 @@ export function ContactInfoPane({
{fromChannel && (
<ChannelAttributionWarning
includeAliasNote={Boolean(detail && detail.name_history.length > 1)}
includeAliasNote={Boolean(analytics && analytics.name_history.length > 1)}
/>
)}
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
{analytics && analytics.name_history.length > 1 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Also Known As</SectionLabel>
<div className="space-y-1">
{detail.name_history.map((h) => (
{analytics.name_history.map((h) => (
<div key={h.name} className="flex justify-between items-center text-sm">
<span className="font-medium truncate">{h.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
@@ -456,12 +453,14 @@ export function ContactInfoPane({
)}
<MessageStatsSection
dmMessageCount={detail?.dm_message_count ?? 0}
channelMessageCount={detail?.channel_message_count ?? 0}
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={detail?.most_active_rooms ?? []}
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -499,7 +498,7 @@ function ChannelAttributionWarning({
same name will be attributed to the same {nameOnly ? 'sender name' : 'contact'}. Stats below
may be inaccurate.
{includeAliasNote &&
' Message counts below include messages attributed under the names listed in Also Known As.'}
' Historical counts below may include messages previously attributed under names shown in Also Known As.'}
</p>
</div>
);
@@ -576,6 +575,211 @@ function MostActiveRoomsSection({
);
}
function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | null }) {
if (!analytics) {
return null;
}
const hasHourlyActivity = analytics.hourly_activity.some(
(bucket) =>
bucket.last_24h_count > 0 || bucket.last_week_average > 0 || bucket.all_time_average > 0
);
const hasWeeklyActivity = analytics.weekly_activity.some((bucket) => bucket.message_count > 0);
if (!hasHourlyActivity && !hasWeeklyActivity) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border space-y-4">
{hasHourlyActivity && (
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ChartLegend
items={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
/>
<ActivityLineChart
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
]}
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
/>
</div>
)}
{hasWeeklyActivity && (
<div>
<SectionLabel>Messages Per Week</SectionLabel>
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
month: 'short',
day: 'numeric',
})
}
/>
</div>
)}
<p className="text-[11px] text-muted-foreground">
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
slots.
{!analytics.includes_direct_messages &&
' Name-only analytics include channel messages only.'}
</p>
</div>
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string }>;
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((entry) => {
const value = point[entry.key];
return typeof value === 'number' ? value : 0;
})
);
const maxValue = Math.max(1, ...allValues);
const tickIndices = Array.from(
new Set([
0,
Math.floor((points.length - 1) / 3),
Math.floor(((points.length - 1) * 2) / 3),
points.length - 1,
])
);
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
return `${x},${y}`;
})
.join(' ');
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<text
x={padding.left - 6}
y={y + 4}
fontSize="10"
textAnchor="end"
fill="hsl(var(--muted-foreground))"
>
{valueFormatter(value)}
</text>
</g>
);
})}
{series.map((entry) => (
<polyline
key={String(entry.key)}
fill="none"
stroke={entry.color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={buildPolyline(entry.key)}
/>
))}
{tickIndices.map((index) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>

View File

@@ -2,17 +2,15 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactDetail, NameOnlyContactDetail } from '../types';
import type { Contact, ContactAnalytics } from '../types';
const { getContactDetail, getNameOnlyContactDetail } = vi.hoisted(() => ({
getContactDetail: vi.fn(),
getNameOnlyContactDetail: vi.fn(),
const { getContactAnalytics } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(),
}));
vi.mock('../api', () => ({
api: {
getContactDetail,
getNameOnlyContactDetail,
getContactAnalytics,
},
}));
@@ -56,27 +54,33 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
};
}
function createDetail(contact: Contact, overrides: Partial<ContactDetail> = {}): ContactDetail {
function createAnalytics(
contact: Contact | null,
overrides: Partial<ContactAnalytics> = {}
): ContactAnalytics {
return {
lookup_type: contact ? 'contact' : 'name',
name: contact?.name ?? 'Mystery',
contact,
name_first_seen_at: null,
name_history: [],
dm_message_count: 0,
channel_message_count: 0,
includes_direct_messages: Boolean(contact),
most_active_rooms: [],
advert_paths: [],
advert_frequency: null,
nearest_repeaters: [],
...overrides,
};
}
function createNameOnlyDetail(
overrides: Partial<NameOnlyContactDetail> = {}
): NameOnlyContactDetail {
return {
name: 'Mystery',
channel_message_count: 0,
most_active_rooms: [],
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 3600,
last_24h_count: 0,
last_week_average: 0,
all_time_average: 0,
})),
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 604800,
message_count: 0,
})),
...overrides,
};
}
@@ -92,13 +96,12 @@ const baseProps = {
describe('ContactInfoPane', () => {
beforeEach(() => {
getContactDetail.mockReset();
getNameOnlyContactDetail.mockReset();
getContactAnalytics.mockReset();
});
it('shows hop width when contact has a stored path hash mode', async () => {
const contact = createContact({ out_path_hash_mode: 1 });
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -111,7 +114,7 @@ describe('ContactInfoPane', () => {
it('does not show hop width for flood-routed contacts', async () => {
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -130,7 +133,7 @@ describe('ContactInfoPane', () => {
route_override_len: 2,
route_override_hash_mode: 1,
});
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -144,9 +147,11 @@ describe('ContactInfoPane', () => {
});
it('loads name-only channel stats and most active rooms', async () => {
getNameOnlyContactDetail.mockResolvedValue(
createNameOnlyDetail({
getContactAnalytics.mockResolvedValue(
createAnalytics(null, {
lookup_type: 'name',
name: 'Mystery',
name_first_seen_at: 1_699_999_000,
channel_message_count: 4,
most_active_rooms: [
{
@@ -155,6 +160,16 @@ describe('ContactInfoPane', () => {
message_count: 3,
},
],
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 3600,
last_24h_count: index === 23 ? 2 : 0,
last_week_average: index === 23 ? 1.5 : 0,
all_time_average: index === 23 ? 1.2 : 0,
})),
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 604800,
message_count: index === 25 ? 4 : 0,
})),
})
);
@@ -162,20 +177,26 @@ describe('ContactInfoPane', () => {
await screen.findByText('Mystery');
await waitFor(() => {
expect(getNameOnlyContactDetail).toHaveBeenCalledWith('Mystery');
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
expect(screen.getByText('#ops')).toBeInTheDocument();
expect(
screen.getByText(/Name-only analytics include channel messages only/i)
).toBeInTheDocument();
expect(screen.getByText(/same sender name/i)).toBeInTheDocument();
});
});
it('shows alias note in the channel attribution warning for keyed contacts', async () => {
const contact = createContact();
getContactDetail.mockResolvedValue(
createDetail(contact, {
getContactAnalytics.mockResolvedValue(
createAnalytics(contact, {
name_history: [
{ name: 'Alice', first_seen: 1000, last_seen: 2000 },
{ name: 'AliceOld', first_seen: 900, last_seen: 999 },
@@ -189,7 +210,9 @@ describe('ContactInfoPane', () => {
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Also Known As' })).toBeInTheDocument();
expect(
screen.getByText(/include messages attributed under the names listed in Also Known As/i)
screen.getByText(
/may include messages previously attributed under names shown in Also Known As/i
)
).toBeInTheDocument();
});
});

View File

@@ -131,6 +131,35 @@ export interface NameOnlyContactDetail {
most_active_rooms: ContactActiveRoom[];
}
export interface ContactAnalyticsHourlyBucket {
bucket_start: number;
last_24h_count: number;
last_week_average: number;
all_time_average: number;
}
export interface ContactAnalyticsWeeklyBucket {
bucket_start: number;
message_count: number;
}
export interface ContactAnalytics {
lookup_type: 'contact' | 'name';
name: string;
contact: Contact | null;
name_first_seen_at: number | null;
name_history: ContactNameHistory[];
dm_message_count: number;
channel_message_count: number;
includes_direct_messages: boolean;
most_active_rooms: ContactActiveRoom[];
advert_paths: ContactAdvertPath[];
advert_frequency: number | null;
nearest_repeaters: NearestRepeater[];
hourly_activity: ContactAnalyticsHourlyBucket[];
weekly_activity: ContactAnalyticsWeeklyBucket[];
}
export interface Channel {
key: string;
name: string;

View File

@@ -453,6 +453,97 @@ class TestNameOnlyContactDetail:
assert data["advert_frequency"] > 0
class TestContactAnalytics:
"""Test GET /api/contacts/analytics."""
@pytest.mark.asyncio
async def test_analytics_returns_keyed_contact_profile_and_series(self, test_db, client):
now = 2_000_000_000
chan_key = "11" * 16
await _insert_contact(KEY_A, "Alice", type=1)
await MessageRepository.create(
msg_type="PRIV",
text="hi",
conversation_key=KEY_A,
sender_timestamp=now - 100,
received_at=now - 100,
sender_key=KEY_A,
)
await MessageRepository.create(
msg_type="CHAN",
text="Alice: ping",
conversation_key=chan_key,
sender_timestamp=now - 7200,
received_at=now - 7200,
sender_name="Alice",
sender_key=KEY_A,
)
with patch("app.repository.messages.time.time", return_value=now):
response = await client.get("/api/contacts/analytics", params={"public_key": KEY_A})
assert response.status_code == 200
data = response.json()
assert data["lookup_type"] == "contact"
assert data["contact"]["public_key"] == KEY_A
assert data["includes_direct_messages"] is True
assert data["dm_message_count"] == 1
assert data["channel_message_count"] == 1
assert len(data["hourly_activity"]) == 24
assert len(data["weekly_activity"]) == 26
assert sum(bucket["last_24h_count"] for bucket in data["hourly_activity"]) == 2
assert sum(bucket["message_count"] for bucket in data["weekly_activity"]) == 2
@pytest.mark.asyncio
async def test_analytics_returns_name_only_profile_and_series(self, test_db, client):
now = 2_000_000_000
chan_key = "22" * 16
await MessageRepository.create(
msg_type="CHAN",
text="Mystery: hi",
conversation_key=chan_key,
sender_timestamp=now - 100,
received_at=now - 100,
sender_name="Mystery",
)
await MessageRepository.create(
msg_type="CHAN",
text="Mystery: hello",
conversation_key=chan_key,
sender_timestamp=now - 86400,
received_at=now - 86400,
sender_name="Mystery",
)
with patch("app.repository.messages.time.time", return_value=now):
response = await client.get("/api/contacts/analytics", params={"name": "Mystery"})
assert response.status_code == 200
data = response.json()
assert data["lookup_type"] == "name"
assert data["contact"] is None
assert data["name"] == "Mystery"
assert data["name_first_seen_at"] == now - 86400
assert data["includes_direct_messages"] is False
assert data["dm_message_count"] == 0
assert data["channel_message_count"] == 2
assert len(data["hourly_activity"]) == 24
assert len(data["weekly_activity"]) == 26
assert sum(bucket["last_24h_count"] for bucket in data["hourly_activity"]) == 1
assert sum(bucket["message_count"] for bucket in data["weekly_activity"]) == 2
@pytest.mark.asyncio
async def test_analytics_requires_exactly_one_lookup_mode(self, test_db, client):
response = await client.get(
"/api/contacts/analytics",
params={"public_key": KEY_A, "name": "Alice"},
)
assert response.status_code == 400
assert "exactly one" in response.json()["detail"].lower()
class TestDeleteContactCascade:
"""Test that contact delete cleans up related tables."""