eletrotupi / tcc/ commit / cb5d2d5

Merge pull request #17 from pedrolucasp/notifications

Notifications + insights
Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago cb5d2d5c050efb38c5d440aba81f7bc1ded98c34
17 file(s) changed
  • api/src/controllers/moods.ts +36 -0
  • api/src/routes/moods.ts +1 -0
  • api/src/services/user.service.ts +10 -0
  • frontend/app/(tabs)/_layout.tsx +23 -0
  • frontend/app/(tabs)/new/index.tsx +126 -114
  • frontend/app/auth/login.tsx +77 -44
  • frontend/app/index.tsx +6 -6
  • frontend/app/notifications.tsx +126 -0
  • frontend/components/ui/MoodTrendWidget.tsx +117 -0
  • frontend/constants/theme.ts +1 -0
  • frontend/context/AuthContext.tsx +35 -25
  • frontend/hooks/useInsights.queries.ts +37 -0
  • frontend/hooks/useRegisterPushToken.ts +38 -0
  • frontend/lib/api/client.ts +21 -5
  • frontend/lib/api/index.ts +4 -0
  • frontend/lib/api/types.ts +46 -0
  • monografia/monografia.pdf +0 -0
api/src/controllers/moods.ts
1 1
import { Request, Response, NextFunction } from 'express';
2 2
import { AuthenticatedRequest } from '@app/middleware/auth';
3 +
import { User } from '@prisma/client';
3 4
import {
4 5
  createMood,
5 6
  getMoodsByUserId,
6 7
  getMoodById,
7 8
  destroyMoodById
8 9
} from '@app/services/mood.service';
10 +

11 +
import {
12 +
  findUsersElligibleForPush
13 +
} from '@app/services/user.service';
9 14

10 15
import {
11 16
  CreateMoodSchema
12 17
} from '@app/schemas'
13 18

▸ 52 unchanged lines
66 71
  destroy: async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
67 72
    try {
68 73
      const result = await destroyMoodById(req.userId!, Number(req.params.id!));
69 74

70 75
      return res.status(200).json({})
76 +
    } catch (err) {
77 +
      next(err);
78 +
    }
79 +
  },
80 +

81 +
  sendNotification: async (req: Request, res: Response, next: NextFunction) => {
82 +
    try {
83 +
      const users = await findUsersElligibleForPush();
84 +

85 +
      if (users) {
86 +
        users.forEach(async (user: User) => {
87 +
          const response = await fetch('https://exp.host/--/api/v2/push/send', {
88 +
            method: 'POST', headers: {
89 +
              'Content-Type': 'application/json'
90 +
            },
91 +
            body: JSON.stringify({
92 +
              to: user.pushToken,
93 +
              title: 'Como você está?',
94 +
              body: 'Adicione mais registros de humor.',
95 +
              data: { screen: 'notifications' }, // Deep link payload
96 +
            })
97 +
          });
98 +

99 +
          const data = await response.json();
100 +

101 +
          req.log.info("Resposta %s", data)
102 +
          console.log("Resposta ", data)
103 +
        })
104 +
      }
105 +

106 +
      return res.status(200).json(users)
71 107
    } catch (err) {
72 108
      next(err);
73 109
    }
74 110
  }
75 111
};
api/src/routes/moods.ts
1 1
import { Router } from "express";
2 2
import { MoodsController } from '@app/controllers/moods'
3 3
import { requireAuth } from '@app/middleware/auth';
4 4

5 5
const router = Router();
6 6

7 7
router.get('/', requireAuth, MoodsController.index);
8 8
router.get('/:id', requireAuth, MoodsController.show);
9 9
router.post('/', requireAuth, MoodsController.create);
10 10
router.delete('/:id', requireAuth, MoodsController.destroy);
11 +
router.post('/notify', MoodsController.sendNotification);
11 12

12 13
export default router;
api/src/services/user.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { User } from '@prisma/client';
3 3
import bcrypt from 'bcryptjs';
4 4

5 5
import {
▸ 49 unchanged lines
55 55
      id: input.id
56 56
    },
57 57
    data: updateData
58 58
  })
59 59
}
60 +

61 +
export const findUsersElligibleForPush = async (): Promise<User[]> => {
62 +
  return await prisma.user.findMany({
63 +
    where: {
64 +
      pushToken: {
65 +
        not: null
66 +
      }
67 +
    }
68 +
  });
69 +
}
frontend/app/(tabs)/_layout.tsx
1 1
import { Tabs, Redirect, router } from 'expo-router';
2 2
import React, { useEffect } from 'react';
3 3
import { View, ActivityIndicator } from 'react-native';
4 4

5 5
import { HapticTab } from '@/components/misc/haptic-tab';
6 6
import { IconSymbol } from '@/components/ui/icon-symbol';
7 7
import { Colors } from '@/constants/theme';
8 8
import { useColorScheme } from '@/hooks/use-color-scheme';
9 9
import { useAuth } from '@/context/AuthContext';
10 +
import * as Notifications from "expo-notifications";
10 11

11 12
export default function TabLayout() {
12 13
  const colorScheme = useColorScheme();
13 14
  const { isAuthenticated, isLoading } = useAuth();
14 15

15 16
  useEffect(() => {
16 17
    if (!isLoading && !isAuthenticated) {
17 18
      router.replace('/auth/login');
18 19
    }
19 20
  }, [isAuthenticated, isLoading]);
21 +

22 +
  // TODO: Store the notification in a hook ctx, then capture if was
23 +
  useEffect(() => {
24 +
    const subscription = Notifications.addNotificationResponseReceivedListener(response => {
25 +
      const screen = response.notification.request.content.data?.screen;
26 +

27 +
      if (screen === 'notifications') {
28 +
        router.push('/notifications');
29 +
      }
30 +
    });
31 +

32 +
    // Handle notification tap when app was already open
33 +
    Notifications.setNotificationHandler({
34 +
      handleNotification: async () => ({
35 +
        shouldShowAlert: true,
36 +
        shouldPlaySound: false,
37 +
        shouldSetBadge: false,
38 +
      }),
39 +
    });
40 +

41 +
    return () => subscription.remove();
42 +
  }, []);
20 43

21 44
  if (isLoading) {
22 45
    return (
23 46
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
24 47
        <ActivityIndicator size="large" />
▸ 54 unchanged lines
79 102
        }}
80 103
      />
81 104
    </Tabs>
82 105
  );
83 106
}
frontend/app/(tabs)/new/index.tsx
1 -
import { useEffect, useState } from 'react';
2 -
import {
3 -
  StyleSheet,
4 -
  View,
5 -
  Text,
6 -
  ActivityIndicator
7 -
} from 'react-native';
1 +
import { useEffect, useState } from "react";
2 +
import { StyleSheet, View, Text, ActivityIndicator } from "react-native";
8 3

9 -
import { ThemedText } from '@/components/misc/themed-text';
10 -
import { ThemedView } from '@/components/misc/themed-view';
11 -
import { ScreenLayout } from '@/components/ui/ScreenLayout';
12 -
import { useAuth } from '@/context/AuthContext';
13 -
import { Grid, Col, Row, Between } from '@/components/ui/LayoutHelpers';
14 -
import { Card } from '@/components/ui/Cards';
15 -
import { Badge } from '@/components/ui/Badge';
16 -
import { Button } from '@/components/ui/Button';
17 -
import { Section, SectionHeader } from '@/components/ui/Sections';
18 -
import { MoodSelector } from '@/components/ui/EmojiSelectors';
19 -
import { Typography, Spacing, Shadows, Colors, BorderRadius } from '@/constants/theme';
20 -
import { Ionicons } from '@expo/vector-icons';
21 -
import { router } from 'expo-router';
4 +
import { ThemedText } from "@/components/misc/themed-text";
5 +
import { ThemedView } from "@/components/misc/themed-view";
6 +
import { ScreenLayout } from "@/components/ui/ScreenLayout";
7 +
import { useAuth } from "@/context/AuthContext";
8 +
import { Grid, Col, Row, Between } from "@/components/ui/LayoutHelpers";
9 +
import { Card } from "@/components/ui/Cards";
10 +
import { Badge } from "@/components/ui/Badge";
11 +
import { Button } from "@/components/ui/Button";
12 +
import { Section, SectionHeader } from "@/components/ui/Sections";
13 +
import { MoodSelector } from "@/components/ui/EmojiSelectors";
22 14
import {
23 -
  MoodDefinition, MOODS
24 -
} from '@/constants/moods'
15 +
  Typography,
16 +
  Spacing,
17 +
  Shadows,
18 +
  Colors,
19 +
  BorderRadius,
20 +
} from "@/constants/theme";
21 +
import { Ionicons } from "@expo/vector-icons";
22 +
import { router } from "expo-router";
23 +
import { MoodDefinition, MOODS } from "@/constants/moods";
25 24

26 -
import {
27 -
  useThemeColor,
28 -
  useMoodEntries
29 -
} from '@/hooks';
25 +
import { useThemeColor, useMoodEntries } from "@/hooks";
30 26

31 -
import MoodEntryLog from '@/components/ui/MoodEntryLog';
27 +
import MoodEntryLog from "@/components/ui/MoodEntryLog";
32 28

33 29
export default function New() {
34 30
  const { user } = useAuth();
35 -
  const [mood, setMood] = useState<string>('good');
31 +
  const [mood, setMood] = useState<string>("good");
36 32
  const { data, isLoading } = useMoodEntries({ limit: 5 });
37 33
  const recentEntries = data?.entries ?? [];
38 34

39 -
  console.log("entries: ", data, recentEntries)
35 +
  console.log("entries: ", data, recentEntries);
40 36

41 37
  const initQuickRegister = () => {
42 38
    // TODO: Set down on camelCase vs kebab case
43 -
    router.navigate(`/new/entry?initialMood=${mood}`)
39 +
    router.navigate(`/new/entry?initialMood=${mood}`);
44 40
  };
45 41

46 -
  return (
47 -
    <ScreenLayout userName={user.firstName} userAvatar={user.avatarURL}
48 -
      onNotificationPress={() => console.log('Notifications')}
49 -
      showNotificationBadge={true}>
50 -
        <Section>
51 -
          <SectionHeader
52 -
            title="Como você está hoje?"
53 -
            subtitle="Sua jornada começa agora"
54 -
            >
55 -
          </SectionHeader>
42 +
  const onNotificationPress = () => {
43 +
    router.push('/notifications');
44 +
  }
56 45

57 -
          <Card style={{ minHeight: 200 }}>
58 -
            <Col gap={16}>
59 -
              <Between style={styles.quickRegisterHeader}>
60 -
                <ThemedText style={styles.quickRegisterTitle}>
61 -
                  Registro rápido
62 -
                </ThemedText>
46 +
  return (
47 +
    <ScreenLayout
48 +
      userName={user.firstName}
49 +
      userAvatar={user.avatarURL}
50 +
      onNotificationPress={onNotificationPress}
51 +
      showNotificationBadge={true}
52 +
    >
53 +
      <Section>
54 +
        <SectionHeader
55 +
          title="Como você está hoje?"
56 +
          subtitle="Sua jornada começa agora"
57 +
        ></SectionHeader>
63 58

64 -
                <ThemedText style={styles.quickRegisterBadge}>
65 -
                  HOJE
66 -
                </ThemedText>
67 -
              </Between>
59 +
        <Card style={{ minHeight: 200 }}>
60 +
          <Col gap={16}>
61 +
            <Between style={styles.quickRegisterHeader}>
62 +
              <ThemedText style={styles.quickRegisterTitle}>
63 +
                Registro rápido
64 +
              </ThemedText>
68 65

69 -
              <MoodSelector
70 -
                style={{paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
71 -
                items={MOODS}
72 -
                value={mood}
73 -
                onSelect={setMood}
74 -
              />
66 +
              <ThemedText style={styles.quickRegisterBadge}>HOJE</ThemedText>
67 +
            </Between>
75 68

76 -
              <Button
77 -
                title="Salvar Humor"
78 -
                onPress={initQuickRegister}
79 -
                style={styles.quickRegisterButton}
80 -
              />
81 -
            </Col>
82 -
          </Card>
83 -
        </Section>
69 +
            <MoodSelector
70 +
              style={{ paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
71 +
              items={MOODS}
72 +
              value={mood}
73 +
              onSelect={setMood}
74 +
            />
84 75

85 -
        {/* TODO: Check whether we gonna have to generate these on demand */}
86 -
        <Section>
87 -
          <Grid gap={4}>
88 -
            <Card style={{ padding: Spacing.cardGap }}>
89 -
              <View style={{ flex: 1, flexDirection: 'row', flexGrow: 0, paddingVertical: 10 }}>
90 -
                <Ionicons name="flash" size={20} color="#64748B" />
76 +
            <Button
77 +
              title="Salvar Humor"
78 +
              onPress={initQuickRegister}
79 +
              style={styles.quickRegisterButton}
80 +
            />
81 +
          </Col>
82 +
        </Card>
83 +
      </Section>
91 84

92 -
                <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
93 -
                  Energia
94 -
                </Text>
95 -
              </View>
85 +
      {/* TODO: Check whether we gonna have to generate these on demand */}
86 +
      <Section>
87 +
        <Grid gap={4}>
88 +
          <Card style={{ padding: Spacing.cardGap }}>
89 +
            <View
90 +
              style={{
91 +
                flex: 1,
92 +
                flexDirection: "row",
93 +
                flexGrow: 0,
94 +
                paddingVertical: 10,
95 +
              }}
96 +
            >
97 +
              <Ionicons name="flash" size={20} color="#64748B" />
96 98

97 99
              <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
98 -
                Média Alta
100 +
                Energia
99 101
              </Text>
102 +
            </View>
100 103

101 -
              <View style={styles.lineTrack}>
102 -
                <View style={[styles.lineFill, { width: `35%` }]} />
103 -
              </View>
104 -
            </Card>
104 +
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
105 +
              Média Alta
106 +
            </Text>
105 107

106 -
            <Card style={{ padding: Spacing.cardGap }}>
107 -
              <View style={{ flex: 1, flexDirection: 'row', flexGrow: 0, paddingVertical: 10 }}>
108 -
                <Ionicons name="moon-outline" size={20} color="#64748B" />
108 +
            <View style={styles.lineTrack}>
109 +
              <View style={[styles.lineFill, { width: `35%` }]} />
110 +
            </View>
111 +
          </Card>
109 112

110 -
                <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
111 -
                  Sono
112 -
                </Text>
113 -
              </View>
113 +
          <Card style={{ padding: Spacing.cardGap }}>
114 +
            <View
115 +
              style={{
116 +
                flex: 1,
117 +
                flexDirection: "row",
118 +
                flexGrow: 0,
119 +
                paddingVertical: 10,
120 +
              }}
121 +
            >
122 +
              <Ionicons name="moon-outline" size={20} color="#64748B" />
114 123

115 124
              <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
116 -
                7h 30m
125 +
                Sono
117 126
              </Text>
127 +
            </View>
118 128

119 -
              <Text style={styles.indicatorGreen}>
120 -
                +45min que ontem
121 -
              </Text>
122 -
            </Card>
123 -
          </Grid>
124 -
        </Section>
129 +
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
130 +
              7h 30m
131 +
            </Text>
125 132

126 -
        <Section>
127 -
          <SectionHeader title="Registros recentes" />
133 +
            <Text style={styles.indicatorGreen}>+45min que ontem</Text>
134 +
          </Card>
135 +
        </Grid>
136 +
      </Section>
128 137

129 -
          {isLoading ? (
130 -
            <ActivityIndicator />
131 -
          ) : (
132 -
            <Col gap={8}>
133 -
              {recentEntries.map((entry) => (
134 -
                <MoodEntryLog key={entry.id} entry={entry} />
135 -
              ))}
136 -
            </Col>
137 -
          )}
138 -
        </Section>
138 +
      <Section>
139 +
        <SectionHeader title="Registros recentes" />
140 +

141 +
        {isLoading ? (
142 +
          <ActivityIndicator />
143 +
        ) : (
144 +
          <Col gap={8}>
145 +
            {recentEntries.map((entry) => (
146 +
              <MoodEntryLog key={entry.id} entry={entry} />
147 +
            ))}
148 +
          </Col>
149 +
        )}
150 +
      </Section>
139 151
    </ScreenLayout>
140 -
  )
152 +
  );
141 153
}
142 154

143 155
const styles = StyleSheet.create({
144 156
  quickRegisterHeader: {
145 157
    marginTop: 16,
146 158
    paddingLeft: 16,
147 -
    paddingRight: 16
159 +
    paddingRight: 16,
148 160
  },
149 161
  quickRegisterTitle: {
150 -
    ...Typography.bodyLg
162 +
    ...Typography.bodyLg,
151 163
  },
152 164
  // TODO: Replace when we have badges
153 165
  quickRegisterBadge: {
154 166
    fontSize: 12,
155 -
    lineHeight: 16
167 +
    lineHeight: 16,
156 168
  },
157 169
  quickRegisterButton: {
158 170
    marginLeft: 16,
159 171
    marginRight: 16,
160 172
    marginBottom: 16,
161 -
    ...Shadows.lg
173 +
    ...Shadows.lg,
162 174
  },
163 175
  lineTrack: {
164 176
    height: 6,
165 177
    backgroundColor: Colors.light.divider,
166 178
    borderRadius: BorderRadius.full,
167 179
    marginBottom: 4,
168 -
    overflow: 'hidden',
180 +
    overflow: "hidden",
169 181
  },
170 182
  lineFill: {
171 -
    height: '100%',
183 +
    height: "100%",
172 184
    backgroundColor: Colors.light.tint,
173 185
    borderRadius: BorderRadius.full,
174 186
  },
175 187
  indicatorGreen: {
176 188
    color: Colors.light.tint,
177 189
    fontSize: 11,
178 190
    fontWeight: 600,
179 -
    lineHeight: 16.5
180 -
  }
191 +
    lineHeight: 16.5,
192 +
  },
181 193
});
frontend/app/auth/login.tsx
1 -
import React, { useState } from 'react';
1 +
import React, { useState } from "react";
2 2
import {
3 3
  View,
4 4
  Text,
5 5
  StyleSheet,
6 6
  ScrollView,
7 7
  KeyboardAvoidingView,
8 8
  Platform,
9 9
  Alert,
10 -
} from 'react-native';
11 -
import { Link, router } from 'expo-router';
12 -
import { SafeAreaView } from 'react-native-safe-area-context';
13 -
import { useThemeColor } from '@/hooks/use-theme-color';
14 -
import { Button, Input } from '@/components/ui';
15 -
import { useAuth } from '@/context/AuthContext';
16 -
import { apiClient, User } from '@/lib/api';
10 +
} from "react-native";
11 +
import { Link, router } from "expo-router";
12 +
import { SafeAreaView } from "react-native-safe-area-context";
13 +
import { useThemeColor } from "@/hooks/use-theme-color";
14 +
import { Button, Input } from "@/components/ui";
15 +
import { useAuth } from "@/context/AuthContext";
16 +
import { apiClient, User } from "@/lib/api";
17 +
import { registerForPushNotifications } from "@/hooks/useRegisterPushToken";
17 18

18 19
export default function LoginScreen() {
19 -
  const [email, setEmail] = useState('');
20 -
  const [password, setPassword] = useState('');
21 -
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
20 +
  const [email, setEmail] = useState("");
21 +
  const [password, setPassword] = useState("");
22 +
  const [errors, setErrors] = useState<{ email?: string; password?: string }>(
23 +
    {},
24 +
  );
22 25

23 -
  const { login, isLoading } = useAuth();
24 -
  const textColor = useThemeColor({}, 'text');
25 -
  const backgroundColor = useThemeColor({}, 'background');
26 -
  const tintColor = useThemeColor({}, 'tint');
26 +
  const { login, updateAuthUser, isLoading } = useAuth();
27 +
  const textColor = useThemeColor({}, "text");
28 +
  const backgroundColor = useThemeColor({}, "background");
29 +
  const tintColor = useThemeColor({}, "tint");
27 30

28 31
  const validateForm = () => {
29 32
    const newErrors: { email?: string; password?: string } = {};
30 33

31 34
    if (!email) {
32 -
      newErrors.email = 'Email é obrigatório';
35 +
      newErrors.email = "Email é obrigatório";
33 36
    } else if (!/\S+@\S+\.\S+/.test(email)) {
34 -
      newErrors.email = 'Por favor, use um email válido';
37 +
      newErrors.email = "Por favor, use um email válido";
35 38
    }
36 39

37 40
    if (!password) {
38 -
      newErrors.password = 'Senha é obrigatória';
41 +
      newErrors.password = "Senha é obrigatória";
39 42
    }
40 43

41 44
    setErrors(newErrors);
42 45
    return Object.keys(newErrors).length === 0;
43 46
  };
44 47

45 48
  const handleLogin = async () => {
46 49
    if (!validateForm()) return;
47 50

48 51
    try {
49 -
      await login(email, password);
50 -
      router.replace('/');
52 +
      const response = await login(email, password);
53 +

54 +
      if (response?.user) {
55 +
        const token = await registerForPushNotifications();
56 +
        if (token) {
57 +
          const updateResponse = await apiClient.updateUser({
58 +
            id: response.user.id,
59 +
            pushToken: token,
60 +
          });
61 +

62 +
          console.log("UPDATE USER response: ", updateResponse);
63 +

64 +
          if (updateResponse) {
65 +
            updateAuthUser(updateResponse.user);
66 +
          }
67 +
        }
68 +
      }
69 +

70 +
      router.replace("/");
51 71
    } catch (error: any) {
52 -
      console.log("LOGIN error: ", error)
53 -
      Alert.alert('Erro: ', 'Falha ao fazer login');
72 +
      console.log("LOGIN error: ", error);
73 +
      Alert.alert("Erro: ", "Falha ao fazer login");
54 74
    }
55 75
  };
56 76

57 77
  return (
58 78
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
59 -
      <KeyboardAvoidingView
60 -
        behavior='height'
61 -
        style={styles.keyboardView}
62 -
      >
79 +
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
63 80
        <ScrollView
64 81
          contentContainerStyle={styles.scrollContainer}
65 82
          showsVerticalScrollIndicator={false}
66 83
        >
67 84
          <View style={styles.header}>
68 -
            <Text style={[styles.title, { color: textColor }]}>Bem vindo de volta</Text>
85 +
            <Text style={[styles.title, { color: textColor }]}>
86 +
              Bem vindo de volta
87 +
            </Text>
69 88
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
70 89
              Logue na sua conta nexo
71 90
            </Text>
72 91
          </View>
73 92

▸ 23 unchanged lines
97 116
              onPress={handleLogin}
98 117
              loading={isLoading}
99 118
              style={styles.loginButton}
100 119
            />
101 120

102 -
            <Link href="/auth/forgot-password" style={[styles.link, {color: tintColor }]} asChild>
103 -
              <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
121 +
            <Link
122 +
              href="/auth/forgot-password"
123 +
              style={[styles.link, { color: tintColor }]}
124 +
              asChild
125 +
            >
126 +
              <Text
127 +
                style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
128 +
              >
104 129
                Esqueceu a senha?
105 130
              </Text>
106 131
            </Link>
107 132
          </View>
108 133

109 134
          <View style={styles.footer}>
110 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
111 -
              Não tem uma conta ainda?{' '}
135 +
            <Text
136 +
              style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
137 +
            >
138 +
              Não tem uma conta ainda?{" "}
112 139
            </Text>
113 140

114 -
            <Link href="/auth/signup" asChild style={[styles.link, { color: tintColor }]}>
115 -
              <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
141 +
            <Link
142 +
              href="/auth/signup"
143 +
              asChild
144 +
              style={[styles.link, { color: tintColor }]}
145 +
            >
146 +
              <Text
147 +
                style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
148 +
              >
116 149
                Cadastre-se
117 150
              </Text>
118 151
            </Link>
119 152
          </View>
120 153
        </ScrollView>
▸ 10 unchanged lines
131 164
    flex: 1,
132 165
  },
133 166
  scrollContainer: {
134 167
    flexGrow: 1,
135 168
    paddingHorizontal: 24,
136 -
    justifyContent: 'center',
137 -
    minHeight: '100%',
169 +
    justifyContent: "center",
170 +
    minHeight: "100%",
138 171
  },
139 172
  header: {
140 -
    alignItems: 'center',
173 +
    alignItems: "center",
141 174
    marginBottom: 40,
142 175
  },
143 176
  title: {
144 177
    fontSize: 28,
145 -
    fontWeight: 'bold',
178 +
    fontWeight: "bold",
146 179
    marginBottom: 8,
147 180
  },
148 181
  subtitle: {
149 182
    fontSize: 16,
150 -
    textAlign: 'center',
183 +
    textAlign: "center",
151 184
  },
152 185
  form: {
153 186
    marginBottom: 32,
154 187
  },
155 188
  loginButton: {
156 189
    marginTop: 8,
157 190
    marginBottom: 16,
158 191
  },
159 192
  link: {
160 -
    textAlign: 'center',
193 +
    textAlign: "center",
161 194
    fontSize: 14,
162 -
    fontWeight: '500',
195 +
    fontWeight: "500",
163 196
  },
164 197
  footer: {
165 -
    flexDirection: 'row',
166 -
    justifyContent: 'center',
167 -
    alignItems: 'center',
168 -
    marginTop: 'auto',
198 +
    flexDirection: "row",
199 +
    justifyContent: "center",
200 +
    alignItems: "center",
201 +
    marginTop: "auto",
169 202
    paddingBottom: 40,
170 203
  },
171 204
  footerText: {
172 205
    fontSize: 14,
173 206
  },
174 207
});
frontend/app/index.tsx
1 -
import { Redirect } from 'expo-router';
2 -
import { useAuth } from '@/context/AuthContext';
3 -
import { ActivityIndicator, View } from 'react-native';
1 +
import { Redirect } from "expo-router";
2 +
import { useAuth } from "@/context/AuthContext";
3 +
import { ActivityIndicator, View } from "react-native";
4 4

5 5
export default function Index() {
6 6
  const { isAuthenticated, isLoading, user } = useAuth();
7 7
  let url = "/auth/login";
8 8

9 9
  if (isLoading) {
10 10
    return (
11 -
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
11 +
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
12 12
        <ActivityIndicator size="large" />
13 13
      </View>
14 14
    );
15 15
  }
16 16

17 17
  if (isAuthenticated) {
18 18
    if (user.active) {
19 -
      url = "/(tabs)/new"
19 +
      url = "/(tabs)/new";
20 20
    } else {
21 -
      url = "/auth/activate"
21 +
      url = "/auth/activate";
22 22
    }
23 23
  }
24 24

25 25
  return <Redirect href={url} />;
26 26
}
frontend/app/notifications.tsx
@@ -0,0 +1,126 @@
1 + import {
2 + View,
3 + Text,
4 + StyleSheet,
5 + ScrollView,
6 + KeyboardAvoidingView,
7 + Platform,
8 + Alert,
9 + } from 'react-native';
10 +
11 + import {
12 + useState, useEffect
13 + } from 'react';
14 + import * as Notifications from 'expo-notifications';
15 + import { useAuth } from "@/context/AuthContext";
16 +
17 + import { StatusBar } from 'expo-status-bar';
18 + import { SafeAreaView } from 'react-native-safe-area-context';
19 + import { useThemeColor } from '@/hooks/use-theme-color';
20 + import { Button } from '@/components/ui/Button';
21 +
22 + export default function NotificationsPage() {
23 + const { user } = useAuth();
24 + const backgroundColor = useThemeColor({}, 'background');
25 + const [expoPushToken, setExpoPushToken] = useState(user.pushToken);
26 + const [notification, setNotification] = useState<Notifications.Notification | undefined>(
27 + undefined
28 + );
29 +
30 + useEffect(() => {
31 + const notificationListener = Notifications
32 + .addNotificationReceivedListener(notification => {
33 + setNotification(notification);
34 + });
35 +
36 + const responseListener = Notifications
37 + .addNotificationResponseReceivedListener(response => {
38 + console.log(response);
39 + });
40 +
41 + return () => {
42 + notificationListener.remove();
43 + responseListener.remove();
44 + };
45 + }, []);
46 +
47 + useEffect(() => {
48 + setExpoPushToken(user.pushToken)
49 + }, [user]);
50 +
51 + async function sendPushNotification(expoPushToken: string) {
52 + if (expoPushToken) {
53 + const message = {
54 + to: expoPushToken,
55 + sound: 'default',
56 + title: 'Oi meu querido',
57 + body: 'Vamo escutar um The Doors',
58 + data: { screen: 'notifications' },
59 + };
60 +
61 + const response = await fetch('https://exp.host/--/api/v2/push/send', {
62 + method: 'POST',
63 + headers: {
64 + Accept: 'application/json',
65 + 'Accept-encoding': 'gzip, deflate',
66 + 'Content-Type': 'application/json',
67 + },
68 + body: JSON.stringify(message),
69 + });
70 + }
71 + }
72 +
73 + return (
74 + <SafeAreaView style={[styles.container, { backgroundColor }]}>
75 + <StatusBar style={'dark'} />
76 + <KeyboardAvoidingView
77 + behavior='height'
78 + style={styles.keyboardView}
79 + >
80 + <ScrollView
81 + contentContainerStyle={styles.scrollContainer}
82 + showsVerticalScrollIndicator={false}
83 + >
84 + <View>
85 + <Text>
86 + Uma notificação!!!
87 + </Text>
88 + </View>
89 +
90 + <View style={{ alignItems: 'center', justifyContent: 'center' }}>
91 + <Text>Title: {notification && notification.request.content.title} </Text>
92 + <Text>Body: {notification && notification.request.content.body}</Text>
93 + <Text>Data: {notification && JSON.stringify(notification.request.content.data)}</Text>
94 + </View>
95 +
96 + <Button
97 + title="Quer ver uma coisa?"
98 + onPress={async () => {
99 + await sendPushNotification(expoPushToken);
100 + }}
101 + />
102 +
103 + </ScrollView>
104 + </KeyboardAvoidingView>
105 + </SafeAreaView>
106 + );
107 + }
108 +
109 + const styles = StyleSheet.create({
110 + container: {
111 + flex: 1,
112 + },
113 + keyboardView: {
114 + flex: 1,
115 + },
116 + scrollContainer: {
117 + flexGrow: 1,
118 + paddingHorizontal: 24,
119 + justifyContent: 'start',
120 + minHeight: '100%',
121 + },
122 + header: {
123 + alignItems: 'left',
124 + marginBottom: 40,
125 + },
126 + });
frontend/components/ui/MoodTrendWidget.tsx
@@ -0,0 +1,117 @@
1 + import React from "react";
2 + import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
3 + import { Ionicons } from "@expo/vector-icons";
4 + import { Card } from "@/components/ui/Cards";
5 + import { useInsight } from "@/hooks/useInsights.queries";
6 + import { useThemeColor } from "@/hooks/use-theme-color";
7 + import { Spacing, Typography, BorderRadius } from "@/constants/theme";
8 +
9 + export const MoodTrendWidget: React.FC = () => {
10 + const { data: insight, isLoading } = useInsight("MOOD_TREND");
11 +
12 + const successColor = useThemeColor({}, "success");
13 + const errorColor = useThemeColor({}, "danger");
14 + const textSecondary = useThemeColor({}, "textSecondary");
15 +
16 + if (isLoading) {
17 + return (
18 + <Card style={styles.container}>
19 + <ActivityIndicator />
20 + </Card>
21 + );
22 + }
23 +
24 + if (!insight) {
25 + return (
26 + <Card style={styles.container}>
27 + <Text style={styles.label}>Humor</Text>
28 + <Text style={styles.empty}>
29 + Registre mais humores para ver tendências.
30 + </Text>
31 + </Card>
32 + );
33 + }
34 +
35 + const meta = insight.metadata as {
36 + delta: number;
37 + dominantMood: string;
38 + avgEnergy: number;
39 + };
40 +
41 + const isPositive = meta.delta > 0;
42 + const isNeutral = meta.delta === 0;
43 + const deltaColor = isNeutral
44 + ? textSecondary
45 + : isPositive
46 + ? successColor
47 + : errorColor;
48 + const iconName = isNeutral
49 + ? "remove-outline"
50 + : isPositive
51 + ? "trending-up-outline"
52 + : "trending-down-outline";
53 +
54 + return (
55 + <Card style={styles.container}>
56 + <View style={styles.header}>
57 + <Ionicons name="partly-sunny-outline" size={14} color={textSecondary} />
58 + <Text style={styles.label}>Humor</Text>
59 + </View>
60 +
61 + <View style={styles.valueRow}>
62 + <Text style={styles.value}>
63 + {MOOD_LABEL[meta.dominantMood] ?? meta.dominantMood}
64 + </Text>
65 + <Ionicons name={iconName} size={18} color={deltaColor} />
66 + </View>
67 +
68 + {!isNeutral && (
69 + <Text style={[styles.delta, { color: deltaColor }]}>
70 + {isPositive ? "+" : ""}
71 + {meta.delta}% vs período anterior
72 + </Text>
73 + )}
74 + </Card>
75 + );
76 + };
77 +
78 + const MOOD_LABEL: Record<string, string> = {
79 + GREAT: "Ótimo",
80 + GOOD: "Bom",
81 + NEUTRAL: "Neutro",
82 + SAD: "Triste",
83 + ANGRY: "Irritado",
84 + };
85 +
86 + const styles = StyleSheet.create({
87 + container: {
88 + flex: 1,
89 + },
90 + header: {
91 + flexDirection: "row",
92 + alignItems: "center",
93 + gap: 4,
94 + marginBottom: 10,
95 + },
96 + label: {
97 + ...Typography.labelXs,
98 + opacity: 0.6,
99 + },
100 + valueRow: {
101 + flexDirection: "row",
102 + alignItems: "center",
103 + justifyContent: "space-between",
104 + },
105 + value: {
106 + ...Typography.bodyLg,
107 + },
108 + delta: {
109 + ...Typography.labelSm,
110 + marginTop: 2,
111 + },
112 + empty: {
113 + ...Typography.labelXs,
114 + opacity: 0.5,
115 + marginTop: 4,
116 + },
117 + });
frontend/constants/theme.ts
1 1
/**
2 2
 * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 3
 * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 4
 */
5 5

▸ 72 unchanged lines
78 78
    // Disabled / Secondary
79 79
    disabled: "#cbd5e1",
80 80
    divider: BORDER_SUBTLE,
81 81
    sliderTracking: "#E2E8F0",
82 82
    sliderLabels: "#94a3b8",
83 +
    success: "#22c55e",
83 84
    danger: "#EF4444",
84 85
    textBlack: PRIMARY_DARK,
85 86
  },
86 87
};
87 88

▸ 136 unchanged lines
224 225
  typography: Typography,
225 226
  spacing: Spacing,
226 227
  shadows: Shadows,
227 228
  borderRadius: BorderRadius,
228 229
};
frontend/context/AuthContext.tsx
1 -
import React, { createContext, useContext, useEffect, useState } from 'react';
2 -
import { apiClient, User } from '@/lib/api';
3 -
import { queryClient, storage } from '@/lib/queryClient';
1 +
import React, { createContext, useContext, useEffect, useState } from "react";
2 +
import { apiClient, User } from "@/lib/api";
3 +
import { queryClient, storage } from "@/lib/queryClient";
4 4

5 5
interface AuthProviderProps {
6 6
  children: React.ReactNode;
7 7
}
8 8

9 9
interface AuthContextType {
10 10
  user: User | null;
11 11
  isLoading: boolean;
12 12
  isAuthenticated: boolean;
13 13
  updateAuthUser: (user: User) => void;
14 -
  login: (email: string, password: string) => Promise<void>;
14 +
  login: (email: string, password: string) => Promise<{ user: User } | null>;
15 15
  signup: (userData: {
16 16
    firstName: string;
17 17
    lastName?: string;
18 18
    email: string;
19 19
    password: string;
20 20
  }) => Promise<void>;
21 21
  logout: () => Promise<void>;
22 -
  forgotPassword: (email: string) => Promise<{ message: string; token?: string }>;
22 +
  forgotPassword: (
23 +
    email: string,
24 +
  ) => Promise<{ message: string; token?: string }>;
23 25
  resetPassword: (token: string, password: string) => Promise<void>;
24 26
  activate: (code: string) => Promise<void>;
25 27
  requestActivateCode: () => Promise<void>;
26 28
}
27 29

▸ 4 unchanged lines
32 34
    firstName: data.firstName,
33 35
    lastName: data.lastName,
34 36
    updatedAt: data.updatedAt,
35 37
    avatarKey: data.avatarKey,
36 38
    avatarURL: data.avatarURL,
37 -
    active: data.active
39 +
    active: data.active,
38 40
  };
39 41
}
40 42

41 43
const AuthContext = createContext<AuthContextType | null>(null);
42 44

43 45
export function useAuth() {
44 46
  const context = useContext(AuthContext);
45 47
  if (!context) {
46 -
    throw new Error('useAuth must be used within an AuthProvider');
48 +
    throw new Error("useAuth must be used within an AuthProvider");
47 49
  }
48 50

49 51
  return context;
50 52
}
51 53

▸ 11 unchanged lines
63 65
    try {
64 66
      const { token } = await apiClient.getStoredAuthData();
65 67

66 68
      if (token) {
67 69
        const verifyResponse = await apiClient.verifyToken();
68 -
        if (verifyResponse.valid && verifyResponse.userId && verifyResponse.email) {
70 +
        if (
71 +
          verifyResponse.valid &&
72 +
          verifyResponse.userId &&
73 +
          verifyResponse.email
74 +
        ) {
69 75
          // TODO: Fetch the full user profile
70 76
          // For now, we'll create a basic user object
71 77
          setUser({
72 78
            id: verifyResponse.userId,
73 79
            email: verifyResponse.email,
74 -
            ...verifyResponse.user
80 +
            ...verifyResponse.user,
75 81
          });
76 82
        }
77 83
      }
78 84
    } catch (error) {
79 -
      console.error('Auth check failed:', error);
85 +
      console.error("Auth check failed:", error);
80 86
      // Token is invalid, wipe out any stored data
81 87
      await apiClient.logout();
82 88
    } finally {
83 89
      setIsLoading(false);
84 90
    }
85 91
  };
86 92

87 -
  const login = async (email: string, password: string) => {
93 +
  const login = async (
94 +
    email: string,
95 +
    password: string,
96 +
  ): Promise<{ user: User } | null> => {
88 97
    setIsLoading(true);
89 98

90 99
    try {
91 100
      const response = await apiClient.login(email, password);
92 -
      setUser(response.user);
101 +

102 +
      if (response.user) {
103 +
        setUser(response.user);
104 +
      }
105 +

106 +
      return response;
93 107
    } finally {
94 108
      setIsLoading(false);
95 109
    }
96 110
  };
97 111

▸ 36 unchanged lines
134 148

135 149
  const activate = async (code: number) => {
136 150
    const response = await apiClient.activate(code);
137 151

138 152
    if (response.user) {
139 -
      setUser(response.user)
153 +
      setUser(response.user);
140 154
    }
141 155

142 -
    return response
143 -
  }
156 +
    return response;
157 +
  };
144 158

145 -
  const requestActivateCode = async() => {
159 +
  const requestActivateCode = async () => {
146 160
    await apiClient.requestActivateCode();
147 -
  }
161 +
  };
148 162

149 163
  const updateAuthUser = (user) => {
150 164
    setUser(user);
151 -
  }
165 +
  };
152 166

153 167
  const value: AuthContextType = {
154 168
    user,
155 169
    isLoading,
156 170
    isAuthenticated,
▸ 2 unchanged lines
159 173
    logout,
160 174
    forgotPassword,
161 175
    resetPassword,
162 176
    updateAuthUser,
163 177
    activate,
164 -
    requestActivateCode
178 +
    requestActivateCode,
165 179
  };
166 180

167 -
  return (
168 -
    <AuthContext.Provider value={value}>
169 -
      {children}
170 -
    </AuthContext.Provider>
171 -
  );
172 -
}
181 +
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
182 +
};
frontend/hooks/useInsights.queries.ts
@@ -0,0 +1,37 @@
1 + import { useQuery } from "@tanstack/react-query";
2 + import { apiClient, Insight, InsightType, InsightPeriod } from "@/lib/api";
3 +
4 + export const insightKeys = {
5 + all: () => ["insights"] as const,
6 + lists: () => [...insightKeys.all(), "list"] as const,
7 + list: (filters?: InsightFilters) =>
8 + [...insightKeys.lists(), filters] as const,
9 + byType: (type: InsightType) => [...insightKeys.all(), "type", type] as const,
10 + };
11 +
12 + interface InsightFilters {
13 + type?: InsightType;
14 + period?: InsightPeriod;
15 + limit?: number;
16 + }
17 +
18 + // All insights — for the insights page
19 + export const useInsights = (filters?: InsightFilters) => {
20 + return useQuery({
21 + queryKey: insightKeys.list(filters),
22 + queryFn: () => apiClient.getInsights(filters),
23 + staleTime: 1000 * 60 * 15,
24 + });
25 + };
26 +
27 + // Single type — for widgets that only care about one insight
28 + // Each call caches independently, so mood log page doesn't blow
29 + // away what the insights page already fetched
30 + export const useInsight = (type: InsightType) => {
31 + return useQuery({
32 + queryKey: insightKeys.byType(type),
33 + queryFn: () =>
34 + apiClient.getInsights({ type, limit: 1 }).then((res) => res[0] ?? null),
35 + staleTime: 1000 * 60 * 15,
36 + });
37 + };
frontend/hooks/useRegisterPushToken.ts
@@ -0,0 +1,38 @@
1 + import * as Notifications from "expo-notifications";
2 + import * as Device from "expo-device";
3 + import Constants from "expo-constants";
4 +
5 + export async function registerForPushNotifications(): Promise<string | null> {
6 + if (!Device.isDevice) return null;
7 +
8 + // Request permission
9 + const { status: existingStatus } = await Notifications.getPermissionsAsync();
10 + let finalStatus = existingStatus;
11 +
12 + if (existingStatus !== "granted") {
13 + const { status } = await Notifications.requestPermissionsAsync();
14 + finalStatus = status;
15 + }
16 +
17 + if (finalStatus !== "granted") {
18 + console.warn("Push notification permission denied");
19 + return null;
20 + }
21 +
22 + // TODO: Experiment with different vibration patterns
23 + await Notifications.setNotificationChannelAsync("default", {
24 + name: "default",
25 + importance: Notifications.AndroidImportance.MAX,
26 + vibrationPattern: [0, 250, 250, 250],
27 + });
28 +
29 + // Get the Expo push token
30 + const projectId = Constants.expoConfig?.extra?.eas?.projectId;
31 + const token = (
32 + await Notifications.getExpoPushTokenAsync({
33 + projectId,
34 + })
35 + ).data;
36 +
37 + return token;
38 + }
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,
▸ 10 unchanged lines
16 16
  Trigger,
17 17
  TriggerResponse,
18 18
  CreateTriggerPayload,
19 19
  ActivateResponse,
20 20
  PaginatedResponse,
21 +
  InsightType,
22 +
  InsightPeriod,
23 +
  InsightMetadata,
24 +
  Insight,
21 25
} from "@/lib/api/types";
22 26

23 27
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
24 28
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
25 29

▸ 59 unchanged lines
85 89

86 90
    return response.json();
87 91
  }
88 92

89 93
  // Auth
90 -
  async login(
91 -
    email: string,
92 -
    password: string,
93 -
  ): Promise<AuthResponse | { isActive: boolean }> {
94 +
  async login(email: string, password: string): Promise<AuthResponse> {
94 95
    const response = await this.request<AuthResponse>("/auth/login", {
95 96
      method: "POST",
96 97
      body: JSON.stringify({ email, password }),
97 98
    });
98 99

▸ 57 unchanged lines
156 157
  async logout(): Promise<void> {
157 158
    await this.removeToken();
158 159
  }
159 160

160 161
  // User-related
161 -
  async updateUser(user: UserUpdatePayload): Promise<User> {
162 +
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
162 163
    return await this.request(`/users/${user.id}`, {
163 164
      method: "PUT",
164 165
      body: JSON.stringify({ user }),
165 166
    });
166 167
  }
▸ 102 unchanged lines
269 270
    return this.request(`/triggers/${id}`);
270 271
  }
271 272

272 273
  async deleteTrigger(id: string): Promise<void> {
273 274
    return this.request(`/triggers/${id}`, { method: "DELETE" });
275 +
  }
276 +

277 +
  // Insights
278 +
  async getInsights(filters?: {
279 +
    type?: InsightType;
280 +
    period?: InsightPeriod;
281 +
    limit?: number;
282 +
  }): Promise<Insight[]> {
283 +
    const query = new URLSearchParams();
284 +
    if (filters?.type) query.set("type", filters.type);
285 +
    if (filters?.period) query.set("period", filters.period);
286 +
    if (filters?.limit) query.set("limit", String(filters.limit));
287 +

288 +
    const qs = query.toString();
289 +
    return this.request(`/insights${qs ? `?${qs}` : ""}`);
274 290
  }
275 291
}
276 292

277 293
export const apiClient = new ApiClient();
frontend/lib/api/index.ts
1 1
export { apiClient } from "@/lib/api/client";
2 2

3 3
export type {
4 4
  User,
5 5
  AuthResponse,
▸ 8 unchanged lines
14 14
  SleepRecordPayload,
15 15
  SleepRecord,
16 16
  // Trigger
17 17
  Trigger,
18 18
  CreateTriggerPayload,
19 +
  // Insights
20 +
  Insight,
21 +
  InsightType,
22 +
  InsightPeriod,
19 23
} from "@/lib/api/types";
frontend/lib/api/types.ts
1 1
export interface User {
2 2
  id: number;
3 3
  email: string;
4 4
  firstName: string;
5 5
  lastName?: string;
6 6
  updatedAt: Date;
7 7
  avatarKey?: string;
8 8
  avatarURL?: string;
9 9
  active: boolean;
10 +
  pushToken?: string;
10 11
}
11 12

12 13
// Auth
13 14
export interface AuthResponse {
14 15
  token: string;
▸ 104 unchanged lines
119 120
export interface CreateTriggerPayload {
120 121
  comment: string;
121 122
  category: string;
122 123
  moment: Date;
123 124
}
125 +

126 +
// Insights
127 +

128 +
// types/insight.ts
129 +
// Gotta keep those in sync with the Prisma schema
130 +
export type InsightType =
131 +
  | "MOOD_TREND"
132 +
  | "ENERGY_SLEEP_CORRELATION"
133 +
  | "TRIGGER_PATTERN";
134 +

135 +
export type InsightPeriod = "DAILY" | "WEEKLY" | "MONTHLY";
136 +

137 +
export type InsightMetadata =
138 +
  | {
139 +
      type: "MOOD_TREND";
140 +
      delta: number;
141 +
      avgFirst: number;
142 +
      avgSecond: number;
143 +
      dominantMood: string;
144 +
      avgEnergy: number;
145 +
    }
146 +
  | {
147 +
      type: "ENERGY_SLEEP_CORRELATION";
148 +
      correlationScore: number;
149 +
      sampleSize: number;
150 +
    }
151 +
  | {
152 +
      type: "TRIGGER_PATTERN";
153 +
      topCategory: string;
154 +
      topCount: number;
155 +
      total: number;
156 +
      distribution: Record<string, number>;
157 +
    };
158 +

159 +
export type Insight = {
160 +
  id: number;
161 +
  type: InsightType;
162 +
  period: InsightPeriod;
163 +
  title: string;
164 +
  body: string;
165 +
  metadata: InsightMetadata;
166 +
  generatedAt: string;
167 +
  periodStart: string;
168 +
  periodEnd: string;
169 +
};
124 170

125 171
// Generic stuff
126 172
export interface PaginatedResponse<T> {
127 173
  entries: T[];
128 174
  total: number;
▸ 10 unchanged lines
139 185
}
140 186

141 187
export interface TriggerResponse {
142 188
  trigger: Trigger;
143 189
}
monografia/monografia.pdf
Binary file