eletrotupi / tcc/ commit / c6d61c3

frontend: flesh and rig react-query + mmkv

Pedro Lucas Porcellis porcellis@eletrotupi.com · 5 months ago c6d61c3d0df216df84ee31bc55dd02e9d0073fce Browse files
Parents 9178180
Changed files
7
Additions
+258
Deletions
-37
frontend/app/(tabs)/new/entry.tsx
1 1
import { useState, useEffect } from 'react';
2 2
import {
3 3
  Link, router, useLocalSearchParams
4 4
} from 'expo-router';
5 5

22 22
import { Section, SectionHeader } from '@/components/ui/Sections';
23 23
import { Spacing, Typography, Colors } from '@/constants/theme';
24 24
import { ScaleSlider } from '@/components/ui/ScaleSlider';
25 25
import { MoodComponentCard } from '@/components/ui/MoodComponentCard';
26 26
import { Ionicons } from '@expo/vector-icons';
27 -
import { useThemeColor } from '@/hooks/use-theme-color';
27 +
import { useThemeColor, useCreateMoodEntry } from '@/hooks';
28 28
import {
29 29
  useMoodEntryStore,
30 -
} from '@/stores/moodEntry';
30 +
} from '@/stores';
31 31

32 32
import {
33 33
  MOODS,
34 34
  getMood
35 35
} from '@/constants/moods';
57 57
    setSelectedMood,
58 58
    components,
59 59
    reset
60 60
  } = useMoodEntryStore();
61 61

62 +
  const createMoodEntry = useCreateMoodEntry();
63 +

62 64
  useEffect(() => {
63 65
    setSelectedMood(getMood(initialMood));
64 66
  }, [initialMood]);
65 67

66 68
  useEffect(() => {
77 79
      annotation: annotation,
78 80
      moodComponents: components.map((c) => ({
79 81
        component: c.id.toUpperCase(),
80 82
        intensity: intensityToValue(c.intensity)
81 83
      }))
82 -
    }
84 +
    };
83 85

84 -
    const result = await apiClient.createMoodEntry(data)
86 +
    await createMoodEntry.mutateAsync(data)
85 87

86 -
    router.push("/")
88 +
    reset();
89 +
    router.replace("/")
87 90
  }
88 91

89 92
  const editComponents = () => {
90 93
    router.push("/entry/mood-components")
91 94
  }
199 202
  },
200 203
  resumeCard: {
201 204
    padding: Spacing.cardGap,
202 205
  }
203 206
});
frontend/app/_layout.tsx modified+36 -32
frontend/app/_layout.tsx
1 1
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
2 2
import { Stack } from 'expo-router';
3 -
import { useColorScheme } from 'react-native'
3 +
import { useColorScheme } from 'react-native';
4 +
import { QueryClientProvider } from '@tanstack/react-query';
5 +
import { queryClient } from '@/lib/queryClient';
4 6

5 7
import { AuthProvider } from '@/context/AuthContext';
6 8

7 9
export const unstable_settings = {
8 10
  initialRouteName: '(tabs)',
10 12

11 13
export default function RootLayout() {
12 14
  const colorScheme = useColorScheme();
13 15

14 16
  return (
15 -
    <ThemeProvider value={DefaultTheme}>
16 -
      <AuthProvider>
17 -
        <Stack>
18 -
          <Stack.Screen name="index" options={{ headerShown: false }} />
19 -
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
20 -
          <Stack.Screen
21 -
            name="auth"
22 -
            options={{
23 -
              headerShown: false,
24 -
              presentation: 'modal'
25 -
            }}
26 -
          />
17 +
    <QueryClientProvider client={queryClient}>
18 +
      <ThemeProvider value={DefaultTheme}>
19 +
        <AuthProvider>
20 +
          <Stack>
21 +
            <Stack.Screen name="index" options={{ headerShown: false }} />
22 +
            <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
23 +
            <Stack.Screen
24 +
              name="auth"
25 +
              options={{
26 +
                headerShown: false,
27 +
                presentation: 'modal'
28 +
              }}
29 +
            />
27 30

28 -
          <Stack.Screen
29 -
            name="profile"
30 -
            options={{
31 -
              title: 'Editar perfil',
32 -
            }}
33 -
          />
31 +
            <Stack.Screen
32 +
              name="profile"
33 +
              options={{
34 +
                title: 'Editar perfil',
35 +
              }}
36 +
            />
34 37

35 -
          <Stack.Screen
36 -
            name="entry/mood-components"
37 -
            options={{
38 -
              presentation: 'formSheet',
39 -
              title: 'Editar Componentes',
40 -
              sheetAllowedDetents: [0.25, 0.5, 1],
41 -
              sheetInitialDetentIndex: 1
42 -
            }}
43 -
          />
38 +
            <Stack.Screen
39 +
              name="entry/mood-components"
40 +
              options={{
41 +
                presentation: 'formSheet',
42 +
                title: 'Editar Componentes',
43 +
                sheetAllowedDetents: [0.25, 0.5, 1],
44 +
                sheetInitialDetentIndex: 1
45 +
              }}
46 +
            />
44 47

45 -
          <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
46 -
        </Stack>
47 -
      </AuthProvider>
48 -
    </ThemeProvider>
48 +
            <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
49 +
          </Stack>
50 +
        </AuthProvider>
51 +
      </ThemeProvider>
52 +
    </QueryClientProvider>
49 53
  );
50 54
}
/dev/null → frontend/hooks/index.ts
1 +
// Legacy shit from react native
2 +
export {
3 +
  useThemeColor
4 +
} from '@/hooks/use-theme-color';
5 +

6 +
export {
7 +
  useColorScheme
8 +
} from '@/hooks/use-color-scheme';
9 +

10 +
// Custom stuff
11 +
export {
12 +
  moodKeys,
13 +
  useMoodEntries,
14 +
  useMoodEntriesInfinite,
15 +
  useMoodEntry,
16 +
  useCreateMoodEntry,
17 +
  useDeleteMoodEntry
18 +
} from '@/hooks/useMoodEntries.queries';
/dev/null → frontend/hooks/useMoodEntries.queries.ts
1 +
import {
2 +
  useQuery,
3 +
  useMutation,
4 +
  useQueryClient,
5 +
  useInfiniteQuery,
6 +
} from '@tanstack/react-query';
7 +

8 +
import { apiClient, MoodEntry, CreateMoodEntryPayload } from '@/lib/api';
9 +

10 +
export const moodKeys = {
11 +
  all:    () => ['mood-entries'] as const,
12 +
  lists:  () => [...moodKeys.all(), 'list'] as const,
13 +
  list:   (filters?: MoodEntryFilters) => [...moodKeys.lists(), filters] as const,
14 +
  detail: (id: string) => [...moodKeys.all(), 'detail', id] as const,
15 +
};
16 +

17 +
interface MoodEntryFilters {
18 +
  from?: string;   // ISO date
19 +
  to?: string;     // ISO date
20 +
  limit?: number;
21 +
}
22 +

23 +
// Queries
24 +

25 +
// Fetch a paginated list of mood entries.
26 +
// On cold start, returns cached data instantly then revalidates.
27 +
export const useMoodEntries = (filters?: MoodEntryFilters) => {
28 +
  return useQuery({
29 +
    queryKey: moodKeys.list(filters),
30 +
    queryFn: () => apiClient.getMoodEntries(filters),
31 +
  });
32 +
}
33 +

34 +
// Infinite scroll version for the history tab
35 +
export const useMoodEntriesInfinite = (limit = 20) => {
36 +
  return useInfiniteQuery({
37 +
    queryKey: [...moodKeys.lists(), 'infinite'],
38 +
    queryFn: ({ pageParam = 1 }) =>
39 +
      apiClient.getMoodEntries({ page: pageParam, limit }),
40 +
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
41 +
    initialPageParam: 1,
42 +
  });
43 +
}
44 +

45 +
// Single entry detail
46 +
export const useMoodEntry = (id: string) => {
47 +
  return useQuery({
48 +
    queryKey: moodKeys.detail(id),
49 +
    queryFn: () => apiClient.getMoodEntry(id),
50 +
    enabled: !!id,
51 +
  });
52 +
}
53 +

54 +
// MUTATIONS
55 +

56 +
// Create a new mood entry
57 +
// Optimistically adds to cache, rolls back on failure
58 +
export const useCreateMoodEntry = () => {
59 +
  const queryClient = useQueryClient();
60 +

61 +
  return useMutation({
62 +
    mutationFn: (payload: CreateMoodEntryPayload) =>
63 +
      apiClient.createMoodEntry(payload),
64 +

65 +
    // Optimistic update so that the UI reflects the new entry immediately
66 +
    onMutate: async (payload) => {
67 +
      // Cancel any in-flight refetches so they don't overwrite our optimistic update
68 +
      await queryClient.cancelQueries({ queryKey: moodKeys.lists() });
69 +

70 +
      // Snapshot current cache so we can roll back
71 +
      const previous = queryClient.getQueryData(moodKeys.list());
72 +

73 +
      // Inject a temporary optimistic record
74 +
      // TODO: might as well create a shared attrs that we're keeping it
75 +
      queryClient.setQueryData(moodKeys.list(), (old: MoodEntry[] = []) => [
76 +
        {
77 +
          ...payload,
78 +
          id: `temp-${Date.now()}`,
79 +
          createdAt: new Date().toISOString(),
80 +
          _optimistic: true,
81 +
        },
82 +
        ...old,
83 +
      ]);
84 +

85 +
      return { previous };
86 +
    },
87 +

88 +
    // On error, roll back to the snapshot
89 +
    onError: (_err, _payload, context) => {
90 +
      if (context?.previous) {
91 +
        queryClient.setQueryData(moodKeys.list(), context.previous);
92 +
      }
93 +
    },
94 +

95 +
    // On success, replace optimistic record with real one from server
96 +
    onSuccess: () => {
97 +
      queryClient.invalidateQueries({ queryKey: moodKeys.lists() });
98 +
    },
99 +
  });
100 +
}
101 +

102 +
// Delete a mood entry with optimistic removal
103 +
export const useDeleteMoodEntry = () => {
104 +
  const queryClient = useQueryClient();
105 +

106 +
  return useMutation({
107 +
    mutationFn: (id: string) => apiClient.deleteMoodEntry(id),
108 +

109 +
    onMutate: async (id) => {
110 +
      await queryClient.cancelQueries({ queryKey: moodKeys.lists() });
111 +
      const previous = queryClient.getQueryData(moodKeys.list());
112 +

113 +
      queryClient.setQueryData(moodKeys.list(), (old: MoodEntry[] = []) =>
114 +
        old.filter((e) => e.id !== id)
115 +
      );
116 +

117 +
      return { previous };
118 +
    },
119 +

120 +
    onError: (_err, _id, context) => {
121 +
      if (context?.previous) {
122 +
        queryClient.setQueryData(moodKeys.list(), context.previous);
123 +
      }
124 +
    },
125 +

126 +
    onSuccess: () => {
127 +
      queryClient.invalidateQueries({ queryKey: moodKeys.lists() });
128 +
    },
129 +
  });
130 +
}
frontend/lib/api/client.ts
1 1
import * as SecureStore from 'expo-secure-store';
2 2
import {
3 3
  User,
4 4
  AuthResponse,
5 5
  VerifyTokenResponse,
156 156
    return await this.request(`/moods`, {
157 157
      method: 'POST',
158 158
      body: JSON.stringify({ mood })
159 159
    })
160 160
  }
161 +

162 +
  async getMoodEntries(params?: {
163 +
    from?: string;
164 +
    to?: string;
165 +
    page?: number;
166 +
    limit?: number;
167 +
  }): Promise<PaginatedResponse<MoodEntry>> {
168 +
    const query = new URLSearchParams();
169 +
    if (params?.from)  query.set('from', params.from);
170 +
    if (params?.to)    query.set('to', params.to);
171 +
    if (params?.page)  query.set('page', String(params.page));
172 +
    if (params?.limit) query.set('limit', String(params.limit));
173 +

174 +
    const qs = query.toString();
175 +
    return this.request(`/moods${qs ? `?${qs}` : ''}`);
176 +
  }
177 +

178 +
  async getMoodEntry(id: string): Promise<MoodEntry> {
179 +
    return this.request(`/moods/${id}`);
180 +
  }
161 181
}
162 182

163 183
export const apiClient = new ApiClient();
/dev/null → frontend/lib/queryClient.ts
1 +
import { QueryClient } from '@tanstack/react-query';
2 +
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
3 +
import { persistQueryClient }  from '@tanstack/react-query-persist-client';
4 +
import { createMMKV } from 'react-native-mmkv';
5 +

6 +
export const storage = new createMMKV({ id: 'query-cache' });
7 +

8 +
const mmkvStorage = {
9 +
  getItem: (key: string) => storage.getString(key) ?? null,
10 +
  setItem: (key: string, value: string) => storage.set(key, value),
11 +
  removeItem: (key: string) => storage.delete(key)
12 +
};
13 +

14 +
export const queryClient = new QueryClient({
15 +
  defaultOptions: {
16 +
    queries: {
17 +
      // Show data by 5min before it is considered stale
18 +
      staleTime: 1000 * 60 * 5,
19 +
      // Keep inactive queries for 10min
20 +
      gcTime: 1000 * 60 * 10,
21 +
      // Retry on flaky connections
22 +
      retry: 1,
23 +
      // Don't switch just because the user switched apps
24 +
      refetchOnWindowFocus: false,
25 +
    },
26 +
    mutations: {
27 +
      retry: 1
28 +
    }
29 +
  }
30 +
});
31 +

32 +
const persister = createSyncStoragePersister({
33 +
  storage: mmkvStorage,
34 +
  // We don't want to hammer mmkv every keystroke
35 +
  throttleTime: 1000
36 +
});
37 +

38 +
persistQueryClient({
39 +
  queryClient,
40 +
  persister,
41 +
  // Queries survive for 24h on disk if not invalidated
42 +
  maxAge: 1000 * 60 * 60 * 24
43 +
});
/dev/null → frontend/stores/index.ts
1 +
export {
2 +
  useMoodEntryStore
3 +
} from '@/stores/moodEntry';