perf(app): reduce redundant data fetching

Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+4456 -1400

No files matched your search

+29
View File
@@ -10,7 +10,36 @@ export interface LiveDrawState {
updated_by: string | null;
}
/**
* A change summary, not a payload: markers the client compares against the ones
* it saw last time to decide which domain, if any, is worth refetching. It
* replaces a periodic full refresh of profile, couple, matches, pending
* questions, packs and unread counts with one small request.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
match_count: number;
new_match_count: number;
latest_match_at: string | null;
pending_yours: number;
pending_theirs: number;
unread_total: number;
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
enabled_packs_fingerprint: string | null;
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
match_state_fingerprint: string;
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export const appDataApi = {
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
+21
View File
@@ -0,0 +1,21 @@
import { AppState, type AppStateStatus } from 'react-native';
/**
* `background` is a real suspension and `inactive` is the moment behind the app
* switcher, a call, or a system prompt. Anything else — including states a test
* harness never sets — counts as foreground, so recurring work fails towards
* running rather than towards being silently disabled forever.
*/
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
return state !== 'background' && state !== 'inactive';
}
export function isForeground(): boolean {
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
}
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
return () => subscription.remove();
}
+34 -2
View File
@@ -1,8 +1,40 @@
import { apiClient } from './apiClient';
import type { Message, ReportReason } from '../features/chat/types';
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
/**
* A snapshot when `complete`, otherwise only what changed since the cursor the
* previous page handed back. Delta rows are whole messages, not patches, so a
* caller merges them by id without a second shape to understand.
*/
export interface MessagesPage {
messages: Message[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids?: string[];
/** Cursor to send as `since` on the next poll. */
server_time?: string;
complete?: boolean;
/** Present when requested, saving a separate typing request. */
typing?: PartnerTyping;
}
export interface ListMessagesOptions {
since?: string;
typing?: boolean;
}
export const chatApi = {
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
const query: string[] = [];
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
if (options.typing) query.push('typing=true');
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
},
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
@@ -11,5 +43,5 @@ export const chatApi = {
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
};