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
3 changes: 3 additions & 0 deletions __tests__/components/EngagementScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const ROWS: PostEngagement[] = [
click_rate: 0.75,
avg_rating: 4.5,
total_reactions: 6,
engaged_users: 2,
avg_seconds: 12,
avg_scroll: 0.8,
},
Expand Down Expand Up @@ -73,6 +74,8 @@ describe('EngagementScreen', () => {
expect(screen.getByText('75%')).toBeTruthy(); // click_rate
expect(screen.getByText('4.5')).toBeTruthy(); // avg_rating
expect(screen.getByText('6')).toBeTruthy(); // total_reactions
// engaged (ADR-001): interactuaron pero no llegaron al enlace externo.
expect(screen.getByText(/2 interactuaron sin clicar/)).toBeTruthy();
});

it('shows the metrics to a manager too', () => {
Expand Down
2 changes: 2 additions & 0 deletions __tests__/integration/engagementDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ describe('engagement dashboard (integration)', () => {
unique_readers: 2,
unique_clicks: 1,
total_reactions: 1,
// engaged (ADR-001): el manager valoró sin clicar; el staff clicó, así que no cuenta.
engaged_users: 1,
});
expect(row!.click_rate).toBeCloseTo(0.5);
expect(row!.avg_rating).toBeCloseTo(4);
Expand Down
58 changes: 43 additions & 15 deletions __tests__/integration/trackEngagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,41 @@ const staffClient = createClient<Database>(LOCAL_URL, LOCAL_ANON_KEY);

type FnResponse = { status: number; body: unknown };

const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 500;

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// El edge runtime recicla workers (`policy = "per_worker"`), así que una petición
// suelta puede fallar con 5xx mientras arranca uno nuevo. El cliente real reintenta
// los 5xx (lib/engagement/queue.ts) y aquí se hace lo mismo: sin esto, un fallo
// transitorio se colaba en silencio y afloraba después como datos incorrectos.
// Los 4xx son definitivos y se devuelven tal cual (los tests de 400/401 dependen de ello).
async function callFn(events: unknown, token: string | null): Promise<FnResponse> {
const res = await fetch(FN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: LOCAL_ANON_KEY,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(events),
});
const body = await res.json().catch(() => null);
return { status: res.status, body };
let last: FnResponse = { status: 0, body: null };

for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const res = await fetch(FN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
apikey: LOCAL_ANON_KEY,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(events),
});
last = { status: res.status, body: await res.json().catch(() => null) };
if (res.status < 500) return last;
} catch (e) {
last = { status: 0, body: String(e) }; // error de red
}
if (attempt < MAX_ATTEMPTS) await sleep(RETRY_DELAY_MS);
}

return last;
}

async function sessionRow(postId: string) {
Expand Down Expand Up @@ -147,29 +170,34 @@ describe('track-engagement Edge Function (integration)', () => {
const postId = await createPost();
const sid = crypto.randomUUID();

await callFn([{ session_id: sid, post_id: postId, link_clicked: true }], staffToken);
const click = await callFn([{ session_id: sid, post_id: postId, link_clicked: true }], staffToken);
expect(click.status).toBe(200);
expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true });

// A later event without a click must not revert clicked → viewed.
await callFn(
const later = await callFn(
[{ session_id: sid, post_id: postId, link_clicked: false, focused_seconds_delta: 3 }],
staffToken,
);
expect(later.status).toBe(200);
expect(await sessionRow(postId)).toMatchObject({ status: 'clicked', link_clicked: true });
});

it('accumulates focused_seconds and takes the max of max_scroll_pct; one row per post', async () => {
const postId = await createPost();
const sid = crypto.randomUUID();

await callFn(
const first = await callFn(
[{ session_id: sid, post_id: postId, focused_seconds_delta: 5, max_scroll_pct: 0.3 }],
staffToken,
);
await callFn(
expect(first.status).toBe(200);

const second = await callFn(
[{ session_id: sid, post_id: postId, focused_seconds_delta: 7, max_scroll_pct: 0.1 }],
staffToken,
);
expect(second.status).toBe(200);

const row = await sessionRow(postId);
expect(row).toMatchObject({ focused_seconds: 12, max_scroll_pct: 0.3 });
Expand Down
7 changes: 7 additions & 0 deletions __tests__/lib/engagementQueries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type DailyRow = {
avg_rating: number | null;
total_ratings: number | null;
total_reactions: number | null;
engaged_users: number | null;
avg_seconds: number | null;
avg_scroll: number | null;
};
Expand Down Expand Up @@ -44,6 +45,7 @@ describe('listPostEngagement', () => {
avg_rating: 4,
total_ratings: 2,
total_reactions: 1,
engaged_users: 1,
avg_seconds: 15,
avg_scroll: 0.6,
},
Expand All @@ -54,6 +56,7 @@ describe('listPostEngagement', () => {
avg_rating: 2,
total_ratings: 1,
total_reactions: 2,
engaged_users: 2,
avg_seconds: 5,
avg_scroll: 0.2,
},
Expand All @@ -69,6 +72,8 @@ describe('listPostEngagement', () => {
unique_readers: 5, // 2 + 3
unique_clicks: 3, // 1 + 2
total_reactions: 3, // 1 + 2
// La vista imputa cada usuario engaged a su primer día, así que sumar no dobla.
engaged_users: 3, // 1 + 2
});
expect(row!.click_rate).toBeCloseTo(3 / 5);
// avg_rating pondera por nº de valoraciones: (4*2 + 2*1) / 3
Expand All @@ -88,6 +93,7 @@ describe('listPostEngagement', () => {
avg_rating: null,
total_ratings: 0,
total_reactions: 2,
engaged_users: 0,
avg_seconds: null,
avg_scroll: null,
},
Expand All @@ -112,6 +118,7 @@ describe('listPostEngagement', () => {
avg_rating: null,
total_ratings: 0,
total_reactions: 0,
engaged_users: 0,
avg_seconds: null,
avg_scroll: null,
};
Expand Down
4 changes: 3 additions & 1 deletion app/(app)/(tabs)/admin/engagement/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ function EngagementRow({ row }: { row: PostEngagement }) {
<Metric label="Valoración" value={formatRating(row.avg_rating)} />
<Metric label="Reacciones" value={String(row.total_reactions)} />
</View>
{/* engaged (ADR-001): interactuaron pero no llegaron al enlace externo. */}
<Text className="mt-2 text-[11px] text-nun-muted">
{row.unique_readers} {row.unique_readers === 1 ? 'lector único' : 'lectores únicos'}
{row.unique_readers} {row.unique_readers === 1 ? 'lector único' : 'lectores únicos'} ·{' '}
{row.engaged_users} {row.engaged_users === 1 ? 'interactuó' : 'interactuaron'} sin clicar
</Text>
</Card>
);
Expand Down
6 changes: 2 additions & 4 deletions lib/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ export type Database = {
avg_seconds: number | null
click_rate: number | null
day: string | null
engaged_users: number | null
post_id: string | null
total_comments: number | null
total_ratings: number | null
Expand Down Expand Up @@ -484,10 +485,6 @@ export type Database = {
}
}
Functions: {
refresh_post_engagement_daily: {
Args: never
Returns: undefined
}
apply_engagement_events: {
Args: { p_events: Json; p_user_id: string }
Returns: {
Expand All @@ -505,6 +502,7 @@ export type Database = {
is_admin: { Args: never; Returns: boolean }
is_manager: { Args: never; Returns: boolean }
is_staff: { Args: never; Returns: boolean }
refresh_post_engagement_daily: { Args: never; Returns: undefined }
}
Enums: {
reaction_type: "like" | "dislike" | "love"
Expand Down
11 changes: 9 additions & 2 deletions lib/supabase/queries/engagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type PostEngagement = {
click_rate: number | null; // null si el post no tuvo lectores en el periodo
avg_rating: number | null;
total_reactions: number;
// ADR-001: interactuaron (reacción/valoración/comentario) sin clicar el enlace.
engaged_users: number;
// Señales opcionales (ADR-0006): pueden no estar informadas.
avg_seconds: number | null;
avg_scroll: number | null;
Expand All @@ -27,6 +29,7 @@ type Totals = {
readers: number;
clicks: number;
reactions: number;
engaged: number;
ratingSum: number;
ratingCount: number;
secondsSum: number;
Expand All @@ -38,6 +41,7 @@ function emptyTotals(): Totals {
readers: 0,
clicks: 0,
reactions: 0,
engaged: 0,
ratingSum: 0,
ratingCount: 0,
secondsSum: 0,
Expand All @@ -57,7 +61,7 @@ export async function listPostEngagement({
const { data: daily, error } = await client
.from('post_engagement_daily')
.select(
'post_id, unique_readers, unique_clicks, avg_rating, total_ratings, total_reactions, avg_seconds, avg_scroll',
'post_id, unique_readers, unique_clicks, avg_rating, total_ratings, total_reactions, engaged_users, avg_seconds, avg_scroll',
)
.gte('day', cutoff);
if (error) throw error;
Expand All @@ -70,10 +74,12 @@ export async function listPostEngagement({

// Sumar unique_readers entre días es correcto porque engagement_sessions guarda
// 1 fila por (user_id, post_id): cada usuario cae en un único día por post, así
// que no hay doble conteo al agregar el periodo.
// que no hay doble conteo al agregar el periodo. engaged_users cumple lo mismo
// porque la vista imputa cada usuario al día de su primera interacción.
t.readers += readers;
t.clicks += row.unique_clicks ?? 0;
t.reactions += row.total_reactions ?? 0;
t.engaged += row.engaged_users ?? 0;

// Medias ponderadas: avg_rating pesa por nº de valoraciones de ese día; las
// señales de comportamiento pesan por nº de sesiones (= lectores) de ese día.
Expand Down Expand Up @@ -108,6 +114,7 @@ export async function listPostEngagement({
click_rate: t.readers > 0 ? t.clicks / t.readers : null,
avg_rating: t.ratingCount > 0 ? t.ratingSum / t.ratingCount : null,
total_reactions: t.reactions,
engaged_users: t.engaged,
avg_seconds: t.readers > 0 ? t.secondsSum / t.readers : null,
avg_scroll: t.readers > 0 ? t.scrollSum / t.readers : null,
};
Expand Down
Loading
Loading