Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5320,12 +5320,17 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
// --------------------------------------------------------------- signals
api.get('/signals', async (c) => {
const actor = c.get('actor');
const limit = clampLimit(c.req.query('limit'));
const signals = await repo.listSignals(
c.get('db'),
actor.workspaceId,
clampLimit(c.req.query('limit')),
limit,
repo.parseSignalCursor(c.req.query('before')),
);
return c.json({ signals });
// A full page may have more behind it; a short one is the end.
const next =
signals.length >= limit ? repo.signalCursorOf(signals[signals.length - 1] as never) : null;
return c.json({ signals, next_cursor: next });
});

// ---------------------------------------------------------- suppressions
Expand Down
41 changes: 38 additions & 3 deletions apps/api/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,19 +314,54 @@ export async function approvalCounts(db: Client, workspaceId: string): Promise<A
};
}

export async function listSignals(db: Client, workspaceId: string, limit: number) {
/**
* The signal feed, newest first, a page at a time.
*
* `before` is the cursor the previous page handed back: the timestamp and id of
* its last row. Both are needed, because listening often stamps several posts
* with the same second, and a timestamp alone would skip or repeat them.
*/
export async function listSignals(
db: Client,
workspaceId: string,
limit: number,
before?: SignalCursor | null,
) {
const when = 'COALESCE(s.source_timestamp, s.observed_at)';
return queryAll(
db,
`SELECT s.*, p.display_name
FROM signals s
LEFT JOIN people p ON p.id = s.person_id
WHERE s.workspace_id = ?
ORDER BY COALESCE(s.source_timestamp, s.observed_at) DESC
${before ? `AND (${when} < ? OR (${when} = ? AND s.id < ?))` : ''}
ORDER BY ${when} DESC, s.id DESC
LIMIT ?`,
[workspaceId, limit],
before ? [workspaceId, before.at, before.at, before.id, limit] : [workspaceId, limit],
);
}

export interface SignalCursor {
at: string;
id: string;
}

/** `<timestamp>|<id>`, as the feed hands it out. Anything else reads as no cursor. */
export function parseSignalCursor(raw: string | undefined | null): SignalCursor | null {
if (!raw) return null;
const at = raw.lastIndexOf('|');
if (at <= 0 || at === raw.length - 1) return null;
return { at: raw.slice(0, at), id: raw.slice(at + 1) };
}

export function signalCursorOf(
row: { source_timestamp?: unknown; observed_at?: unknown; id?: unknown } | undefined,
): string | null {
if (!row?.id) return null;
const at = row.source_timestamp ?? row.observed_at;
return at ? `${String(at)}|${String(row.id)}` : null;
}

export async function listPersonSignals(db: Client, workspaceId: string, personId: string) {
return queryAll(
db,
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/signals-feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* The signal feed pages with a cursor, so "Load more" in Refine results walks
* the whole feed: no post skipped and none repeated, even when several share a
* timestamp, and the last page says there is nothing after it.
*/
import { afterEach, expect, test } from 'bun:test';
import { createApp } from './app';
import type { RequestActor } from './context';
import { parseSignalCursor } from './repository';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';

const ACTOR: RequestActor = {
userId: SEED.userId,
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
role: 'owner',
};

let active: SeededDatabase | undefined;
afterEach(() => {
active?.cleanup();
active = undefined;
});

test('load more walks every signal once, ties included, and ends', async () => {
const seeded = await seedDatabase('signals-feed');
active = seeded;
await seeded.db.execute({
sql: 'DELETE FROM signals WHERE workspace_id = ?',
args: [SEED.workspaceId],
});
// Seven posts, three of them stamped the same second.
const stamps = [
'2026-10-06T10:00:00Z',
'2026-10-06T09:00:00Z',
'2026-10-06T09:00:00Z',
'2026-10-06T09:00:00Z',
'2026-10-05T00:00:00Z',
'2026-10-04T00:00:00Z',
'2026-10-03T00:00:00Z',
];
await seeded.db.batch(
stamps.map((stamp, i) => ({
sql: `INSERT INTO signals (id, workspace_id, network, signal_type, summary, evidence, source_timestamp, observed_at, confidence, relevance, sentiment)
VALUES (?, ?, ?, 'recommendation_request', ?, '[]', ?, ?, 0.5, ?, ?)`,
args: [
`sig_${i}`,
SEED.workspaceId,
i % 2 ? 'reddit' : 'bluesky',
`post ${i}`,
stamp,
stamp,
0.1 * (i + 1),
i === 0 ? 'negative' : 'neutral',
],
})),
);
const app = createApp({ db: seeded.db, authenticate: async () => ACTOR });

const seen: string[] = [];
let cursor: string | null = null;
for (let page = 0; page < 10; page++) {
const res = await app.request(
`/api/v1/signals?limit=3${cursor ? `&before=${encodeURIComponent(cursor)}` : ''}`,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
signals: Array<{ id: string; sentiment: string }>;
next_cursor: string | null;
};
seen.push(...body.signals.map((s) => s.id));
cursor = body.next_cursor;
if (!cursor) break;
}
expect(seen).toHaveLength(7);
expect(new Set(seen).size).toBe(7);
expect(seen[0]).toBe('sig_0');
expect(seen.at(-1)).toBe('sig_6');
});

test('a malformed cursor reads as none', () => {
expect(parseSignalCursor('2026-10-06T09:00:00Z|sig_2')).toEqual({
at: '2026-10-06T09:00:00Z',
id: 'sig_2',
});
expect(parseSignalCursor('nonsense')).toBeNull();
expect(parseSignalCursor('|x')).toBeNull();
expect(parseSignalCursor('x|')).toBeNull();
expect(parseSignalCursor(undefined)).toBeNull();
});
53 changes: 8 additions & 45 deletions apps/web/app/(app)/signals/page.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { redirect } from 'next/navigation';
import { PageGuide } from '../../../components/page-guide';
import {
ApiUnavailableError,
NotAuthenticatedError,
fetchSignals,
relativeTime,
} from '../../../lib/api';
import { SignalFeed } from '../../../components/signal-feed';
import { ApiUnavailableError, NotAuthenticatedError, fetchSignalPage } from '../../../lib/api';

export const dynamic = 'force-dynamic';

export const metadata = { title: 'Signals · OutreachGraph' };

/** The signal feed (PRD §25.3) — the screen intended to become habit-forming. */
export default async function SignalsPage() {
let signals;
let page;

try {
signals = await fetchSignals();
page = await fetchSignalPage();
} catch (error) {
if (error instanceof NotAuthenticatedError) redirect('/login');
if (error instanceof ApiUnavailableError) {
Expand All @@ -29,47 +25,14 @@ export default async function SignalsPage() {
<div className="pt-4">
<header className="mb-4">
<h1 className="text-xl font-semibold">Signals</h1>
<p className="text-ink-muted text-sm">Recent public activity, newest first</p>
<p className="text-ink-muted text-sm">
Recent public activity: refine by relevance, tone and network, then export
</p>
</header>

<PageGuide page="signals" />

{signals.length === 0 ? (
<p className="border-border text-ink-muted rounded-2xl border border-dashed p-8 text-center text-sm">
No signals collected yet.
</p>
) : (
<ul className="flex flex-col gap-3">
{signals.map((signal) => (
<li key={signal.id} className="border-border bg-surface-raised rounded-2xl border p-4">
<div className="flex items-center gap-2">
{signal.relevance >= 0.8 ? (
<span className="text-hot text-[11px] font-semibold tracking-wide uppercase">
High intent
</span>
) : null}
<span className="text-ink-muted text-xs">{signal.network}</span>
<span className="text-ink-muted text-xs">
· {relativeTime(signal.source_timestamp)}
</span>
</div>

<p className="mt-1 font-medium">{signal.display_name ?? 'Unattributed'}</p>
<p className="mt-1 text-sm">{signal.summary}</p>

{signal.source_url ? (
<a
className="text-accent mt-2 inline-block text-xs underline"
href={signal.source_url}
rel="noreferrer"
>
View source
</a>
) : null}
</li>
))}
</ul>
)}
<SignalFeed initial={page.signals} initialCursor={page.nextCursor} />
</div>
);
}
Loading
Loading