all: add an opt-in weekly resume preference
Settings had a weekly switch that was never saved; it now persists on the user as weekly_resume_enabled and decides whether the Resumo Semanal card shows at the top of Jornada. Nothing is computed or notified yet, so the flag only gates display, and the weekly insight fan-out stays untouched so the resume remains opt-in and separate from the notifications mute.
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago
6b62f4dcf96987b8b2e55bd210139a3ac9f0d976 Browse filesParents
4d890b5Changed files
11
Additions
+66
Deletions
-12
/dev/null → api/prisma/migrations/20261017011724_add_user_weekly_resume_pref/migration.sql
1 +
-- AlterTable
2 +
ALTER TABLE "users" ADD COLUMN "weekly_resume_enabled" BOOLEAN NOT NULL DEFAULT false;
api/prisma/schema.prisma
1 1
generator client {
2 2
provider = "prisma-client-js"
3 3
//output = "../src/generated/prisma"
4 4
}
5 5
25 25
active Boolean @default(false) @map("active")
26 26
27 27
pushToken String? @map("push_token")
28 28
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
29 29
dailyReminderTime String? @map("daily_reminder_time")
30 +
weeklyResumeEnabled Boolean @default(false) @map("weekly_resume_enabled")
30 31
31 32
onboardingSeenAt DateTime? @map("onboarding_seen_at")
32 33
termsAcceptedAt DateTime? @map("terms_accepted_at")
33 34
termsVersion String? @map("terms_version")
34 35
305 306
THREE_TIMES_DAILY
306 307
WEEKLY
307 308
BIWEEKLY
308 309
MONTHLY
309 310
}
api/src/lib/serializers/user.ts
1 1
import { Prisma, User } from '@prisma/client';
2 2
3 3
// DB-level projection of the allow-list below — keep the two in sync.
4 4
export const publicUserSelect = {
5 5
id: true,
10 10
avatarKey: true,
11 11
avatarURL: true,
12 12
active: true,
13 13
notificationsEnabled: true,
14 14
dailyReminderTime: true,
15 +
weeklyResumeEnabled: true,
15 16
onboardingSeenAt: true,
16 17
} satisfies Prisma.UserSelect;
17 18
18 19
// The only shape a user leaves the API in. Everything not listed here
19 20
// (encryptedPassword, reset secrets, activation code, pushToken) stays server-side.
26 27
avatarKey: user.avatarKey,
27 28
avatarURL: user.avatarURL,
28 29
active: user.active,
29 30
notificationsEnabled: user.notificationsEnabled,
30 31
dailyReminderTime: user.dailyReminderTime,
32 +
weeklyResumeEnabled: user.weeklyResumeEnabled,
31 33
onboardingSeenAt: user.onboardingSeenAt,
32 34
});
api/src/schemas/user.schema.ts
1 1
import { z } from "zod";
2 2
3 3
export const CreateUserSchema = z.object({
4 4
firstName: z.string(),
5 5
lastName: z.string().optional(),
19 19
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
20 20
21 21
export const UpdateUserPreferencesSchema = z.object({
22 22
notificationsEnabled: z.boolean().optional(),
23 23
dailyReminderTime: z.string().regex(/^\d{2}:\d{2}$/).nullable().optional(),
24 +
weeklyResumeEnabled: z.boolean().optional(),
24 25
});
25 26
26 27
export type UpdateUserPreferencesInput = z.infer<typeof UpdateUserPreferencesSchema>;
api/src/services/export/table.descriptors.ts
1 1
import {
2 2
Activity,
3 3
Appointment,
4 4
CareAction,
5 5
Insight,
46 46
{
47 47
header: "Notificações",
48 48
value: (user) => booleanLabel(user.notificationsEnabled),
49 49
},
50 50
{ header: "Lembrete diário", value: (user) => user.dailyReminderTime },
51 +
{
52 +
header: "Resumo semanal",
53 +
value: (user) => booleanLabel(user.weeklyResumeEnabled),
54 +
},
51 55
{ header: "Criado em", value: (user) => user.createdAt },
52 56
{ header: "Atualizado em", value: (user) => user.updatedAt },
53 57
],
54 58
};
55 59
202 206
{ header: "Início do período", value: (insight) => insight.periodStart },
203 207
{ header: "Fim do período", value: (insight) => insight.periodEnd },
204 208
{ header: "Gerado em", value: (insight) => insight.generatedAt },
205 209
],
206 210
};
api/tests/serializers/user.test.ts
1 1
import { describe, it, expect } from 'vitest'
2 2
import { User } from '@prisma/client'
3 3
import { publicUser } from '@app/lib/serializers/user'
4 4
5 5
const row = {
18 18
activationCodeExpiresAt: new Date(),
19 19
active: true,
20 20
pushToken: 'device-token',
21 21
notificationsEnabled: true,
22 22
dailyReminderTime: '08:30',
23 +
weeklyResumeEnabled: false,
23 24
onboardingSeenAt: null,
24 25
termsAcceptedAt: new Date(),
25 26
termsVersion: '1',
26 27
} as User
27 28
30 31
expect(publicUser(row)).toMatchObject({
31 32
id: 7,
32 33
email: 'pedro@example.com',
33 34
firstName: 'Pedro',
34 35
active: true,
36 +
weeklyResumeEnabled: false,
35 37
onboardingSeenAt: null,
36 38
})
37 39
})
38 40
39 41
it('never carries a secret off the server', () => {
51 53
]) {
52 54
expect(safe).not.toHaveProperty(field)
53 55
}
54 56
})
55 57
})
api/tests/users/me.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { buildPrisma, cleanupTestDb, createTestUser, generateTestToken, expectPublicUser } from '../test-helper'
5 5
19 19
.set('Authorization', `Bearer ${token}`)
20 20
21 21
expect(res.status).toBe(200)
22 22
expect(res.body.user.email).toBe('me@example.com')
23 23
expect(res.body.user.firstName).toBe('Pedro')
24 +
expect(res.body.user.weeklyResumeEnabled).toBe(false)
24 25
expectPublicUser(res.body.user)
25 26
})
26 27
27 28
it('returns 401 without token', async () => {
28 29
const res = await request(app).get('/users/me')
29 30
expect(res.status).toBe(401)
30 31
})
31 32
})
api/tests/users/preferences.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { DailyReminderJobName } from '@app/lib/queue/types'
5 5
import {
47 47
data: { userId: user.id },
48 48
})
49 49
)
50 50
})
51 51
52 +
it('updates the weekly resume preference', async () => {
53 +
const user = await createTestUser(db, { email: 'prefs@example.com' })
54 +
const token = generateTestToken(user.id, user.email)
55 +
56 +
const res = await request(app)
57 +
.patch('/users/me')
58 +
.set('Authorization', `Bearer ${token}`)
59 +
.send({ user: { weeklyResumeEnabled: true } })
60 +
61 +
expect(res.status).toBe(200)
62 +
expect(res.body.user.weeklyResumeEnabled).toBe(true)
63 +
expectPublicUser(res.body.user)
64 +
65 +
const dbUser = await db.user.findUnique({ where: { id: user.id } })
66 +
expect(dbUser!.weeklyResumeEnabled).toBe(true)
67 +
})
68 +
69 +
it('returns 400 for a non-boolean weeklyResumeEnabled', async () => {
70 +
const user = await createTestUser(db, { email: 'prefs@example.com' })
71 +
const token = generateTestToken(user.id, user.email)
72 +
73 +
const res = await request(app)
74 +
.patch('/users/me')
75 +
.set('Authorization', `Bearer ${token}`)
76 +
.send({ user: { weeklyResumeEnabled: 'yes' } })
77 +
78 +
expect(res.status).toBe(400)
79 +
})
80 +
52 81
it('accepts a null reminder time and schedules nothing', async () => {
53 82
const user = await createTestUser(db, { email: 'prefs@example.com' })
54 83
const token = generateTestToken(user.id, user.email)
55 84
56 85
const res = await request(app)
94 123
.send({ user: { notificationsEnabled: false } })
95 124
96 125
expect(res.status).toBe(401)
97 126
})
98 127
})
frontend/app/(tabs)/insights.tsx
1 1
import { ScreenLayout } from "@/components/ui/ScreenLayout";
2 2
import { useAuth } from "@/context/AuthContext";
3 3
import { Section, SectionHeader } from "@/components/ui/Sections";
4 4
import { WeeklyResume } from "@/components/insights/WeeklyResume";
5 5
import { MoodTrendWidget } from "@/components/insights/MoodTrendWidget";
13 13
<ScreenLayout
14 14
userName={user?.firstName}
15 15
userAvatar={user?.avatarURL}
16 16
showNotificationBadge={true}
17 17
>
18 -
<WeeklyResume
19 -
avgMood="Neutro"
20 -
stats={[
21 -
{ label: "Média de Sono", value: "7.2h" },
22 -
{ label: "Humor Estável", value: "92%" },
23 -
]}
24 -
/>
18 +
{user?.weeklyResumeEnabled && (
19 +
<WeeklyResume
20 +
avgMood="Neutro"
21 +
stats={[
22 +
{ label: "Média de Sono", value: "7.2h" },
23 +
{ label: "Humor Estável", value: "92%" },
24 +
]}
25 +
/>
26 +
)}
25 27
26 28
<Section>
27 29
<SectionHeader title="Indicadores" subtitle="Baseado nos últimos 7 dias" />
28 30
29 31
<EnergySleepWidget />
33 35
<TriggerPatternWidget />
34 36
</Section>
35 37
</ScreenLayout>
36 38
);
37 39
}
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
View,
5 5
Switch,
21 21
import { useToast } from '@/context/ToastContext';
22 22
import { apiClient } from "@/lib/api";
23 23
24 24
export default function Settings() {
25 25
const { user, logout, updateAuthUser } = useAuth();
26 -
const [allowWeeklyInsights, setAllowWeeklyInsights] = useState(false);
27 26
const [logoutVisible, setLogoutVisible] = useState(false);
28 27
const [exportVisible, setExportVisible] = useState(false);
29 28
const [exporting, setExporting] = useState(false);
30 29
const { showToast } = useToast();
31 30
32 31
const notificationsEnabled = user?.notificationsEnabled ?? true;
33 32
const dailyReminderTime = user?.dailyReminderTime ?? "20:00";
33 +
const weeklyResumeEnabled = user?.weeklyResumeEnabled ?? false;
34 34
35 35
const patchUserMe = usePatchUserMe((updatedUser) => {
36 36
updateAuthUser(updatedUser);
37 37
});
38 38
39 39
const handleNotificationToggle = (value: boolean) => {
40 40
patchUserMe.mutate({ notificationsEnabled: value });
41 +
};
42 +
43 +
const handleWeeklyResumeToggle = (value: boolean) => {
44 +
patchUserMe.mutate({ weeklyResumeEnabled: value });
41 45
};
42 46
43 47
const handleTimeChange = (date: Date) => {
44 48
const time = `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
45 49
patchUserMe.mutate({ dailyReminderTime: time });
202 206
justifyContent: "space-between",
203 207
marginLeft: 12,
204 208
flex: 1,
205 209
}}
206 210
>
207 -
<Text style={styles.rowLabel}>Indicadores semanais</Text>
208 -
<Text style={styles.rowSubtitle}>Relatórios de progressos</Text>
211 +
<Text style={styles.rowLabel}>Resumo semanal</Text>
212 +
<Text style={styles.rowSubtitle}>
213 +
Mostra um resumo no topo da Jornada, a partir dos seus dados.
214 +
Desativar não afeta os indicadores semanais.
215 +
</Text>
209 216
</View>
210 217
211 218
<Switch
212 -
onValueChange={setAllowWeeklyInsights}
213 -
value={allowWeeklyInsights}
219 +
onValueChange={handleWeeklyResumeToggle}
220 +
value={weeklyResumeEnabled}
214 221
thumbColor={Colors.light.tint}
215 222
trackColor={{
216 223
false: Colors.light.disabled,
217 224
true: Colors.light.gray,
218 225
}}
226 +
disabled={patchUserMe.isPending}
219 227
/>
220 228
</View>
221 229
</Card>
222 230
</Section>
223 231
394 402
height: StyleSheet.hairlineWidth,
395 403
backgroundColor: "#D1D5DB",
396 404
marginVertical: 16,
397 405
},
398 406
});
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;
7 7
avatarKey?: string;
8 8
avatarURL?: string;
9 9
active: boolean;
10 10
notificationsEnabled?: boolean;
11 11
dailyReminderTime?: string;
12 +
weeklyResumeEnabled?: boolean;
12 13
onboardingSeenAt?: Date | null;
13 14
}
14 15
15 16
export interface AvatarResponse {
16 17
message: string;
63 64
}
64 65
65 66
export interface UserPreferencesPayload {
66 67
notificationsEnabled?: boolean;
67 68
dailyReminderTime?: string | null;
69 +
weeklyResumeEnabled?: boolean;
68 70
}
69 71
70 72
export interface ExportDataResponse {
71 73
message: string;
72 74
}
388 390
moodId?: number;
389 391
}
390 392
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
391 393
392 394
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }