diff --git a/app/models.py b/app/models.py index 56bf5e8..804b0ed 100644 --- a/app/models.py +++ b/app/models.py @@ -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 diff --git a/app/repository/messages.py b/app/repository/messages.py index 5ef251e..c2a052e 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -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 diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 65ed1bf..99dcf05 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -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, ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c7641f5..645711b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(`/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(`/contacts/analytics?${searchParams.toString()}`); + }, getContactDetail: (publicKey: string) => fetchJson(`/contacts/${publicKey}/detail`), getNameOnlyContactDetail: (name: string) => diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 5d965ce..dd21a2f 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -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(null); - const [nameOnlyDetail, setNameOnlyDetail] = useState(null); + const [analytics, setAnalytics] = useState(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 */}
- +
-

{nameOnlyValue}

+

+ {analytics?.name ?? nameOnlyValue} +

We have not heard an advertisement associated with this name, so we cannot identify their key. @@ -209,16 +193,29 @@ export function ContactInfoPane({ + {analytics?.name_first_seen_at && ( +

+
+ +
+
+ )} + + +
- ) : loading && !detail ? ( + ) : loading && !analytics && !contact ? (
Loading...
@@ -391,11 +388,11 @@ export function ContactInfoPane({ )} {/* Nearest Repeaters */} - {detail && detail.nearest_repeaters.length > 0 && ( + {analytics && analytics.nearest_repeaters.length > 0 && (
Nearest Repeaters
- {detail.nearest_repeaters.map((r) => ( + {analytics.nearest_repeaters.map((r) => (
{r.name || r.public_key.slice(0, 12)} @@ -411,11 +408,11 @@ export function ContactInfoPane({ )} {/* Advert Paths */} - {detail && detail.advert_paths.length > 0 && ( + {analytics && analytics.advert_paths.length > 0 && (
Recent Advert Paths
- {detail.advert_paths.map((p) => ( + {analytics.advert_paths.map((p) => (
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 && (
Also Known As
- {detail.name_history.map((h) => ( + {analytics.name_history.map((h) => (
{h.name} @@ -456,12 +453,14 @@ export function ContactInfoPane({ )} + +
@@ -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.'}

); @@ -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 ( +
+ {hasHourlyActivity && ( +
+ Messages Per Hour + + value.toFixed(value % 1 === 0 ? 0 : 1)} + tickFormatter={(bucket) => + new Date(bucket.bucket_start * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + } + /> +
+ )} + + {hasWeeklyActivity && ( +
+ Messages Per Week + value.toFixed(0)} + tickFormatter={(bucket) => + new Date(bucket.bucket_start * 1000).toLocaleDateString([], { + month: 'short', + day: 'numeric', + }) + } + /> +
+ )} + +

+ 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.'} +

+
+ ); +} + +function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) { + return ( +
+ {items.map((item) => ( + + + ))} +
+ ); +} + +function ActivityLineChart({ + 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 ( +
+ + {[0, 0.5, 1].map((ratio) => { + const y = padding.top + plotHeight - ratio * plotHeight; + const value = maxValue * ratio; + return ( + + + + {valueFormatter(value)} + + + ); + })} + + {series.map((entry) => ( + + ))} + + {tickIndices.map((index) => { + const point = points[index]; + const x = + padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth); + return ( + + {tickFormatter(point)} + + ); + })} + +
+ ); +} + function InfoItem({ label, value }: { label: string; value: ReactNode }) { return (
diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index 6dfacdf..1d6789f 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -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 { }; } -function createDetail(contact: Contact, overrides: Partial = {}): ContactDetail { +function createAnalytics( + contact: Contact | null, + overrides: Partial = {} +): 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 { - 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(); @@ -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(); @@ -130,7 +133,7 @@ describe('ContactInfoPane', () => { route_override_len: 2, route_override_hash_mode: 1, }); - getContactDetail.mockResolvedValue(createDetail(contact)); + getContactAnalytics.mockResolvedValue(createAnalytics(contact)); render(); @@ -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(); }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5cf11e0..5d47ece 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 2561f86..7e28625 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -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."""