-
- {props.children}
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+
Error: {err.message}
}>
+ (
+ <>
+ }>
+
+
+
-
-
-
-
- >
- )}
- >
-
-
+
+
+ >
+ )}
+ >
+
+
+
)
}
diff --git a/src/entry-client.tsx b/src/entry-client.tsx
index 46acd52f9..ab0ebffaf 100644
--- a/src/entry-client.tsx
+++ b/src/entry-client.tsx
@@ -1,4 +1,8 @@
// @refresh reload
import { mount, StartClient } from '@solidjs/start/client'
+import { initializeTelemetry } from '~/modules/observability/application/telemetry'
+
+initializeTelemetry('client')
+
mount(() =>
, document.getElementById('app')!)
diff --git a/src/entry-server.tsx b/src/entry-server.tsx
index 7ebe59d0f..0e25a3e9d 100644
--- a/src/entry-server.tsx
+++ b/src/entry-server.tsx
@@ -1,6 +1,10 @@
// @refresh reload
import { createHandler, StartServer } from '@solidjs/start/server'
+import { initializeTelemetry } from '~/modules/observability/application/telemetry'
+
+initializeTelemetry('server')
+
export default createHandler(() => (
(
diff --git a/src/instrument.server.ts b/src/instrument.server.ts
new file mode 100644
index 000000000..86de402f4
--- /dev/null
+++ b/src/instrument.server.ts
@@ -0,0 +1,3 @@
+import { initializeTelemetry } from '~/modules/observability/application/telemetry'
+
+initializeTelemetry('server')
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 000000000..f9560d878
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,9 @@
+import { sentryBeforeResponseMiddleware } from '@sentry/solidstart'
+import { createMiddleware } from '@solidjs/start/middleware'
+
+export default createMiddleware({
+ onBeforeResponse: [
+ sentryBeforeResponseMiddleware(),
+ // Add your other middleware handlers after `sentryBeforeResponseMiddleware`
+ ],
+})
diff --git a/src/modules/auth/application/services/authService.ts b/src/modules/auth/application/services/authService.ts
new file mode 100644
index 000000000..a7e9dec37
--- /dev/null
+++ b/src/modules/auth/application/services/authService.ts
@@ -0,0 +1,213 @@
+import {
+ type SignInOptions,
+ type SignOutOptions,
+} from '~/modules/auth/domain/auth'
+import { type AuthGateway } from '~/modules/auth/domain/authGateway'
+import { setAuthState } from '~/modules/auth/infrastructure/signals/authState'
+import { createSupabaseAuthGateway } from '~/modules/auth/infrastructure/supabase/supabaseAuthGateway'
+import { showError } from '~/modules/toast/application/toastManager'
+import {
+ changeToUser,
+ fetchUsers,
+ insertUserSilently,
+} from '~/modules/user/application/user'
+import { createDefaultUserFromAuthSession } from '~/modules/user/application/userCreationHelper'
+import { logging } from '~/shared/utils/logging'
+
+export function createAuthService(
+ authGateway: AuthGateway = createSupabaseAuthGateway(),
+) {
+ /**
+ * Sign in with specified provider
+ */
+ async function signIn(options: SignInOptions): Promise {
+ try {
+ setAuthState((prev) => ({ ...prev, isLoading: true }))
+
+ const result = await authGateway.signIn(options)
+
+ if (result.error) {
+ throw result.error
+ }
+
+ // For OAuth providers, the user will be redirected
+ if (result.url !== undefined && options.provider === 'google') {
+ if (typeof window !== 'undefined') {
+ window.location.href = result.url
+ }
+ }
+ } catch (e) {
+ logging.error('Auth signIn error:', e)
+ setAuthState((prev) => ({ ...prev, isLoading: false }))
+ throw e
+ }
+ }
+
+ /**
+ * Sign out current user
+ */
+ async function signOut(options?: SignOutOptions): Promise {
+ try {
+ setAuthState((prev) => ({ ...prev, isLoading: true }))
+
+ const result = await authGateway.signOut(options)
+
+ if (result.error) {
+ throw result.error
+ }
+
+ // Auth state will be updated via the subscription
+ } catch (e) {
+ logging.error('Auth signOut error:', e)
+ setAuthState((prev) => ({ ...prev, isLoading: false }))
+ throw e
+ }
+ }
+
+ /**
+ * Refresh current session
+ */
+ async function refreshSession(): Promise {
+ try {
+ await authGateway.refreshSession()
+ // Session will be updated via the subscription
+ } catch (e) {
+ logging.error('Auth refreshSession error:', e)
+ throw e
+ }
+ }
+
+ // Auth state subscription cleanup function
+ let unsubscribeAuthState: (() => void) | null = null
+
+ /**
+ * Initialize authentication system
+ */
+ function initializeAuth(): void {
+ try {
+ // Set up auth state change subscription
+ unsubscribeAuthState = authGateway.onAuthStateChange(
+ (_event, session) => {
+ setAuthState((prev) => ({
+ ...prev,
+ session,
+ user: session?.user
+ ? {
+ id: session.user.id,
+ email: session.user.email,
+ emailConfirmedAt: session.user.email_confirmed_at,
+ lastSignInAt: session.user.last_sign_in_at,
+ createdAt: session.user.created_at,
+ updatedAt: session.user.updated_at,
+ userMetadata: session.user.user_metadata,
+ appMetadata: session.user.app_metadata,
+ }
+ : null,
+ isAuthenticated: !!session,
+ isLoading: false,
+ }))
+
+ if (session?.user.id !== undefined) {
+ fetchUsers()
+ .then(async (users) => {
+ console.debug(`Users: `, users)
+ const user = users.find((u) => u.uuid === session.user.id)
+ if (user !== undefined) {
+ changeToUser(user.uuid)
+ } else {
+ logging.info(
+ 'User profile not found, creating default profile for OAuth user',
+ )
+ const newUser = createDefaultUserFromAuthSession(session)
+ const createdUser = await insertUserSilently(newUser)
+ if (createdUser !== null) {
+ logging.info('User profile created successfully')
+ changeToUser(createdUser.uuid)
+ } else {
+ showError(
+ `Couldn't create user profile for ${JSON.stringify(session.user)}`,
+ )
+ changeToUser('')
+ signOut().catch(showError)
+ }
+ }
+ })
+ .catch(showError)
+ }
+ },
+ )
+
+ // Load initial session
+ void loadInitialSession()
+ } catch (e) {
+ logging.error('Auth initializeAuth error:', e)
+ setAuthState((prev) => ({ ...prev, isLoading: false }))
+ }
+ }
+
+ /**
+ * Load initial session on app startup
+ */
+ async function loadInitialSession(): Promise {
+ try {
+ const session = await authGateway.getSession()
+ logging.debug(`loadInitialSession session:`, { session })
+ setAuthState((prev) => ({
+ ...prev,
+ session,
+ user: session?.user
+ ? {
+ id: session.user.id,
+ email: session.user.email,
+ emailConfirmedAt: session.user.email_confirmed_at,
+ lastSignInAt: session.user.last_sign_in_at,
+ createdAt: session.user.created_at,
+ updatedAt: session.user.updated_at,
+ userMetadata: session.user.user_metadata,
+ appMetadata: session.user.app_metadata,
+ }
+ : null,
+ isAuthenticated: session !== null,
+ isLoading: false,
+ }))
+ } catch (e) {
+ logging.error('Auth loadInitialSession error:', e)
+ setAuthState((prev) => ({ ...prev, isLoading: false }))
+ throw e
+ }
+ }
+ /**
+ * Cleanup auth subscriptions
+ */
+ function cleanupAuth(): void {
+ if (unsubscribeAuthState) {
+ unsubscribeAuthState()
+ unsubscribeAuthState = null
+ }
+ }
+
+ return {
+ signIn,
+ signOut,
+ refreshSession,
+ initializeAuth,
+ loadInitialSession,
+ cleanupAuth,
+ }
+}
+
+// Default instance for convenience
+const defaultAuthService = createAuthService()
+
+// Export individual functions for easier importing
+export const {
+ signIn,
+ signOut,
+ refreshSession,
+ initializeAuth,
+ loadInitialSession,
+ cleanupAuth,
+} = defaultAuthService
+
+// Also export the default instance
+export default defaultAuthService
diff --git a/src/modules/auth/application/usecases/authState.ts b/src/modules/auth/application/usecases/authState.ts
new file mode 100644
index 000000000..cfad04367
--- /dev/null
+++ b/src/modules/auth/application/usecases/authState.ts
@@ -0,0 +1,32 @@
+import { type AuthState, type AuthUser } from '~/modules/auth/domain/auth'
+import { authState } from '~/modules/auth/infrastructure/signals/authState'
+/**
+ * Get current auth state
+ */
+export function getAuthState(): AuthState {
+ return authState()
+}
+
+/**
+ * Get current authenticated user
+ */
+export function getCurrentUser(): AuthUser | null {
+ return authState().user
+}
+
+/**
+ * Check if user is authenticated
+ */
+export function isAuthenticated(): boolean {
+ return authState().isAuthenticated
+}
+
+/**
+ * Check if auth is loading
+ */
+export function isAuthLoading(): boolean {
+ return authState().isLoading
+}
+
+// Export the auth state signal for reactive components
+export { authState }
diff --git a/src/modules/auth/domain/auth.ts b/src/modules/auth/domain/auth.ts
new file mode 100644
index 000000000..e66b0d298
--- /dev/null
+++ b/src/modules/auth/domain/auth.ts
@@ -0,0 +1,53 @@
+import { z } from 'zod/v4'
+
+export const authSessionSchema = z.object({
+ access_token: z.string(),
+ refresh_token: z.string(),
+ expires_at: z.number(),
+ token_type: z.string(),
+ user: z.object({
+ id: z.string(),
+ email: z.string().email(),
+ email_confirmed_at: z.string().optional(),
+ last_sign_in_at: z.string().optional(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ user_metadata: z.record(z.string(), z.unknown()).optional(),
+ app_metadata: z.record(z.string(), z.unknown()).optional(),
+ }),
+})
+
+export type AuthSession = z.infer
+
+export const authUserSchema = z.object({
+ id: z.string(),
+ email: z.string().email(),
+ emailConfirmedAt: z.string().optional(),
+ lastSignInAt: z.string().optional(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ userMetadata: z.record(z.string(), z.unknown()).optional(),
+ appMetadata: z.record(z.string(), z.unknown()).optional(),
+})
+
+export type AuthUser = z.infer
+
+export const authStateSchema = z.object({
+ user: authUserSchema.nullable(),
+ session: authSessionSchema.nullable(),
+ isLoading: z.boolean(),
+ isAuthenticated: z.boolean(),
+})
+
+export type AuthState = z.infer
+
+export type AuthProvider = 'google' | 'email'
+
+export type SignInOptions = {
+ provider: AuthProvider
+ redirectTo: string
+}
+
+export type SignOutOptions = {
+ redirectTo: string
+}
diff --git a/src/modules/auth/domain/authGateway.ts b/src/modules/auth/domain/authGateway.ts
new file mode 100644
index 000000000..b951b6674
--- /dev/null
+++ b/src/modules/auth/domain/authGateway.ts
@@ -0,0 +1,17 @@
+import type {
+ AuthSession,
+ AuthUser,
+ SignInOptions,
+ SignOutOptions,
+} from '~/modules/auth/domain/auth'
+
+export type AuthGateway = {
+ getSession: () => Promise
+ getUser: () => Promise
+ signIn: (options: SignInOptions) => Promise<{ url?: string; error?: Error }>
+ signOut: (options?: SignOutOptions) => Promise<{ error?: Error }>
+ refreshSession: () => Promise
+ onAuthStateChange: (
+ callback: (event: string, session: AuthSession | null) => void,
+ ) => () => void
+}
diff --git a/src/modules/auth/infrastructure/signals/authState.ts b/src/modules/auth/infrastructure/signals/authState.ts
new file mode 100644
index 000000000..bd82d65fe
--- /dev/null
+++ b/src/modules/auth/infrastructure/signals/authState.ts
@@ -0,0 +1,11 @@
+import { createSignal } from 'solid-js'
+
+import { type AuthState } from '~/modules/auth/domain/auth'
+
+// Auth state signals
+export const [authState, setAuthState] = createSignal({
+ user: null,
+ session: null,
+ isLoading: true,
+ isAuthenticated: false,
+})
diff --git a/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts b/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts
new file mode 100644
index 000000000..a08fbffbc
--- /dev/null
+++ b/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts
@@ -0,0 +1,132 @@
+import type { AuthChangeEvent, Session } from '@supabase/supabase-js'
+
+import type {
+ AuthSession,
+ AuthUser,
+ SignInOptions,
+ SignOutOptions,
+} from '~/modules/auth/domain/auth'
+import type { AuthGateway } from '~/modules/auth/domain/authGateway'
+import { supabase } from '~/shared/supabase/supabase'
+import { logging } from '~/shared/utils/logging'
+
+import { supabaseAuthMapper } from './supabaseAuthMapper'
+
+export function createSupabaseAuthGateway(): AuthGateway {
+ return {
+ async getSession(): Promise {
+ try {
+ const { data, error } = await supabase.auth.getSession()
+ logging.debug(`getSession: data:`, { data, error })
+
+ if (error !== null) {
+ throw new Error('Failed to get session', { cause: error })
+ }
+
+ return supabaseAuthMapper.mapSessionToDomain(data.session)
+ } catch (error) {
+ logging.error('SupabaseAuthRepository getSession error:', error)
+ throw error
+ }
+ },
+
+ async getUser(): Promise {
+ try {
+ const { data, error } = await supabase.auth.getUser()
+
+ if (error !== null) {
+ throw new Error('Failed to get user', { cause: error })
+ }
+
+ return supabaseAuthMapper.mapUserToDomain(data.user)
+ } catch (error) {
+ logging.error('SupabaseAuthRepository getUser error:', error)
+ throw error
+ }
+ },
+
+ async signIn(
+ options: SignInOptions,
+ ): Promise<{ url?: string; error?: Error }> {
+ try {
+ if (options.provider === 'google') {
+ const { data, error } = await supabase.auth.signInWithOAuth({
+ provider: 'google',
+ options: {
+ redirectTo: options.redirectTo,
+ },
+ })
+ return {
+ url: data.url ?? undefined,
+ error:
+ error !== null
+ ? new Error('Google sign in failed', { cause: error })
+ : undefined,
+ }
+ }
+
+ return {
+ error: new Error(`Provider ${options.provider} not implemented yet`),
+ }
+ } catch (error) {
+ logging.error('SupabaseAuthRepository signIn error:', error)
+ return {
+ error: error instanceof Error ? error : new Error(String(error)),
+ }
+ }
+ },
+
+ async signOut(_options?: SignOutOptions): Promise<{ error?: Error }> {
+ try {
+ const { error } = await supabase.auth.signOut()
+ return {
+ error:
+ error !== null
+ ? new Error('Sign out failed', { cause: error })
+ : undefined,
+ }
+ } catch (error) {
+ logging.error('SupabaseAuthRepository signOut error:', error)
+ return {
+ error: error instanceof Error ? error : new Error(String(error)),
+ }
+ }
+ },
+
+ async refreshSession(): Promise {
+ try {
+ const { data, error } = await supabase.auth.refreshSession()
+
+ if (error !== null) {
+ throw new Error('Failed to refresh session', { cause: error })
+ }
+
+ return supabaseAuthMapper.mapSessionToDomain(data.session)
+ } catch (error) {
+ logging.error('SupabaseAuthRepository refreshSession error:', error)
+ throw error
+ }
+ },
+
+ onAuthStateChange(
+ callback: (event: string, session: AuthSession | null) => void,
+ ): () => void {
+ try {
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange(
+ (event: AuthChangeEvent, session: Session | null) => {
+ callback(event, supabaseAuthMapper.mapSessionToDomain(session))
+ },
+ )
+
+ return () => {
+ subscription.unsubscribe()
+ }
+ } catch (error) {
+ logging.error('SupabaseAuthRepository onAuthStateChange error:', error)
+ return () => {}
+ }
+ },
+ }
+}
diff --git a/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts b/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts
new file mode 100644
index 000000000..88e4e5f40
--- /dev/null
+++ b/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts
@@ -0,0 +1,65 @@
+import type { Session, User } from '@supabase/supabase-js'
+
+import type { AuthSession, AuthUser } from '~/modules/auth/domain/auth'
+
+/**
+ * Maps Supabase User to domain AuthUser
+ */
+export function mapSupabaseUserToAuthUser(user: User | null): AuthUser | null {
+ if (!user) return null
+
+ return {
+ id: user.id,
+ email: user.email ?? 'unknown@example.com',
+ emailConfirmedAt: user.email_confirmed_at ?? undefined,
+ lastSignInAt: user.last_sign_in_at ?? undefined,
+ createdAt:
+ user.created_at !== '' ? user.created_at : new Date().toISOString(),
+ updatedAt:
+ user.updated_at !== undefined && user.updated_at !== ''
+ ? user.updated_at
+ : new Date().toISOString(),
+ userMetadata: user.user_metadata,
+ appMetadata: user.app_metadata,
+ }
+}
+
+/**
+ * Maps Supabase Session to domain AuthSession
+ */
+export function mapSupabaseSessionToAuthSession(
+ session: Session | null,
+): AuthSession | null {
+ if (session === null) return null
+
+ return {
+ access_token: session.access_token,
+ refresh_token: session.refresh_token,
+ expires_at: session.expires_at ?? 0,
+ token_type: session.token_type,
+ user: {
+ id: session.user.id,
+ email: session.user.email ?? '',
+ email_confirmed_at: session.user.email_confirmed_at ?? undefined,
+ last_sign_in_at: session.user.last_sign_in_at ?? undefined,
+ created_at:
+ session.user.created_at !== ''
+ ? session.user.created_at
+ : new Date().toISOString(),
+ updated_at:
+ session.user.updated_at !== undefined && session.user.updated_at !== ''
+ ? session.user.updated_at
+ : new Date().toISOString(),
+ user_metadata: session.user.user_metadata,
+ app_metadata: session.user.app_metadata,
+ },
+ }
+}
+
+/**
+ * Centralized Supabase Auth mapper functions
+ */
+export const supabaseAuthMapper = {
+ mapUserToDomain: mapSupabaseUserToAuthUser,
+ mapSessionToDomain: mapSupabaseSessionToAuthSession,
+} as const
diff --git a/src/modules/auth/tests/auth.test.ts b/src/modules/auth/tests/auth.test.ts
new file mode 100644
index 000000000..e0e2844c7
--- /dev/null
+++ b/src/modules/auth/tests/auth.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+
+import * as authModule1 from '~/modules/auth/application/services/authService'
+import * as authModule2 from '~/modules/auth/application/usecases/authState'
+import { createAuthGatewayMock } from '~/modules/auth/tests/utils/mockAuthGateway'
+
+const authModule = {
+ ...authModule1,
+ ...authModule2,
+}
+
+describe('Auth Module', () => {
+ const authService = authModule1.createAuthService(createAuthGatewayMock())
+
+ it('should initialize with loading state', () => {
+ const initialState = authModule.getAuthState()
+ expect(initialState.isLoading).toBe(true)
+ expect(initialState.isAuthenticated).toBe(false)
+ expect(initialState.user).toBeNull()
+ expect(initialState.session).toBeNull()
+ })
+
+ it('should check authentication status', () => {
+ expect(authModule.isAuthenticated()).toBe(false)
+ expect(authModule.isAuthLoading()).toBe(true)
+ expect(authModule.getCurrentUser()).toBeNull()
+ })
+
+ it('should handle sign in operation', async () => {
+ await expect(
+ authService.signIn({
+ provider: 'google',
+ redirectTo: 'localhost:3000',
+ }),
+ ).resolves.not.toThrow()
+ })
+
+ it('should handle sign out operation', async () => {
+ await expect(authService.signOut()).resolves.not.toThrow()
+ })
+
+ it('should handle session refresh', async () => {
+ await expect(authService.refreshSession()).resolves.not.toThrow()
+ })
+
+ it('should cleanup auth subscriptions', () => {
+ expect(() => authService.cleanupAuth()).not.toThrow()
+ })
+})
diff --git a/src/modules/auth/tests/authUserCreation.test.ts b/src/modules/auth/tests/authUserCreation.test.ts
new file mode 100644
index 000000000..498a9bd55
--- /dev/null
+++ b/src/modules/auth/tests/authUserCreation.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { createAuthService } from '~/modules/auth/application/services/authService'
+import { type AuthSession } from '~/modules/auth/domain/auth'
+import { type AuthGateway } from '~/modules/auth/domain/authGateway'
+import { createAuthGatewayMock } from '~/modules/auth/tests/utils/mockAuthGateway'
+
+describe('Auth with automatic user creation', () => {
+ it('should automatically create user profile for new OAuth users', async () => {
+ const mockGateway: AuthGateway = createAuthGatewayMock()
+ const authService = createAuthService(mockGateway)
+
+ let stateChangeCallback:
+ | ((event: string, session: AuthSession | null) => void)
+ | null = null
+ vi.spyOn(mockGateway, 'onAuthStateChange').mockImplementation((cb) => {
+ stateChangeCallback = cb
+ return () => {}
+ })
+
+ authService.initializeAuth()
+
+ expect(stateChangeCallback).not.toBeNull()
+ })
+
+ it('should handle errors gracefully when user creation fails', async () => {
+ const mockGateway: AuthGateway = createAuthGatewayMock()
+ const authService = createAuthService(mockGateway)
+
+ expect(() => authService.initializeAuth()).not.toThrow()
+ })
+})
diff --git a/src/modules/auth/tests/utils/mockAuthGateway.ts b/src/modules/auth/tests/utils/mockAuthGateway.ts
new file mode 100644
index 000000000..280f27e5f
--- /dev/null
+++ b/src/modules/auth/tests/utils/mockAuthGateway.ts
@@ -0,0 +1,14 @@
+import { vi } from 'vitest'
+
+import { type AuthGateway } from '~/modules/auth/domain/authGateway'
+
+export function createAuthGatewayMock(): AuthGateway {
+ return {
+ getSession: vi.fn().mockResolvedValue(null),
+ getUser: vi.fn().mockReturnValue(null),
+ signIn: vi.fn().mockResolvedValue({ error: null }),
+ signOut: vi.fn().mockResolvedValue({ error: null }),
+ refreshSession: vi.fn().mockResolvedValue(undefined),
+ onAuthStateChange: vi.fn().mockReturnValue(() => {}),
+ }
+}
diff --git a/src/modules/diet/day-diet/application/dayDiet.ts b/src/modules/diet/day-diet/application/dayDiet.ts
deleted file mode 100644
index 37ae79a91..000000000
--- a/src/modules/diet/day-diet/application/dayDiet.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import { createEffect, createSignal, onCleanup } from 'solid-js'
-
-import {
- type DayDiet,
- type NewDayDiet,
-} from '~/modules/diet/day-diet/domain/dayDiet'
-import {
- createSupabaseDayRepository,
- SUPABASE_TABLE_DAYS,
-} from '~/modules/diet/day-diet/infrastructure/supabaseDayRepository'
-import { showPromise } from '~/modules/toast/application/toastManager'
-import { currentUserId } from '~/modules/user/application/user'
-import { type User } from '~/modules/user/domain/user'
-import { createErrorHandler } from '~/shared/error/errorHandler'
-import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils'
-import { registerSubapabaseRealtimeCallback } from '~/shared/utils/supabase'
-
-const dayRepository = createSupabaseDayRepository()
-const errorHandler = createErrorHandler('application', 'DayDiet')
-
-export const [targetDay, setTargetDay] =
- createSignal(getTodayYYYYMMDD())
-
-export const [dayDiets, setDayDiets] = createSignal([])
-
-export const [currentDayDiet, setCurrentDayDiet] = createSignal(
- null,
-)
-
-/**
- * Reactive signal that tracks the current day and automatically updates when the day changes.
- * This is used for day lock functionality to ensure proper edit mode restrictions.
- */
-export const [currentToday, setCurrentToday] =
- createSignal(getTodayYYYYMMDD())
-
-/**
- * Signal that tracks when the day has changed and a confirmation modal should be shown.
- * Contains the previous day that the user was viewing when the day changed.
- */
-export const [dayChangeData, setDayChangeData] = createSignal<{
- previousDay: string
- newDay: string
-} | null>(null)
-
-// Set up automatic day change detection
-let dayCheckInterval: NodeJS.Timeout | null = null
-
-function startDayChangeDetection() {
- // Clear any existing interval
- if (dayCheckInterval !== null) {
- clearInterval(dayCheckInterval)
- }
-
- dayCheckInterval = setInterval(() => {
- const newToday = getTodayYYYYMMDD()
- const previousToday = currentToday()
-
- if (newToday !== previousToday) {
- console.log(`[dayDiet] Day changed from ${previousToday} to ${newToday}`)
- setCurrentToday(newToday)
-
- // Only show modal if user is not already viewing today
- if (targetDay() !== newToday) {
- setDayChangeData({
- previousDay: previousToday,
- newDay: newToday,
- })
- }
- }
- }, 6000)
-}
-
-createEffect(() => {
- // Start day change detection immediately
- startDayChangeDetection()
- // Cleanup interval on module cleanup
- onCleanup(() => {
- if (dayCheckInterval !== null) {
- clearInterval(dayCheckInterval)
- dayCheckInterval = null
- }
- })
-})
-
-/**
- * Dismisses the day change confirmation modal
- */
-export function dismissDayChangeModal() {
- setDayChangeData(null)
-}
-
-/**
- * Accepts the day change and navigates to the new day
- */
-export function acceptDayChange() {
- const changeData = dayChangeData()
- if (changeData) {
- setTargetDay(changeData.newDay)
- setDayChangeData(null)
- }
-}
-
-function bootstrap() {
- void showPromise(
- fetchAllUserDayDiets(currentUserId()),
- {
- loading: 'Carregando dietas do usuário...',
- success: 'Dietas do usuário obtidas com sucesso',
- error: 'Erro ao obter dietas do usuário',
- },
- { context: 'background' },
- )
-}
-
-/**
- * When user changes, fetch all day diets for the new user
- */
-createEffect(() => {
- bootstrap()
-})
-
-/**
- * When realtime day diets change, update day diets for current user
- */
-// TODO: Move all registerSubapabaseRealtimeCallback to infra layer
-registerSubapabaseRealtimeCallback(SUPABASE_TABLE_DAYS, () => {
- bootstrap()
-})
-
-/**
- * When target day changes, update current day diet
- */
-createEffect(() => {
- const dayDiet = dayDiets().find(
- (dayDiet) => dayDiet.target_day === targetDay(),
- )
-
- if (dayDiet === undefined) {
- console.warn(`[dayDiet] No day diet found for ${targetDay()}`)
- setCurrentDayDiet(null)
- return
- }
-
- setCurrentDayDiet(dayDiet)
-})
-
-async function fetchAllUserDayDiets(userId: User['id']): Promise {
- try {
- const newDayDiets = await dayRepository.fetchAllUserDayDiets(userId)
- setDayDiets(newDayDiets)
- } catch (error) {
- errorHandler.error(error)
- setDayDiets([])
- }
-}
-
-/**
- * Inserts a new day diet.
- * @param dayDiet - The new day diet data.
- * @returns True if inserted, false otherwise.
- */
-export async function insertDayDiet(dayDiet: NewDayDiet): Promise {
- try {
- await showPromise(
- dayRepository.insertDayDiet(dayDiet),
- {
- loading: 'Criando dia de dieta...',
- success: 'Dia de dieta criado com sucesso',
- error: 'Erro ao criar dia de dieta',
- },
- { context: 'user-action', audience: 'user' },
- )
- await fetchAllUserDayDiets(dayDiet.owner)
- return true
- } catch (error) {
- errorHandler.error(error)
- return false
- }
-}
-
-/**
- * Updates a day diet by ID.
- * @param dayId - The day diet ID.
- * @param dayDiet - The new day diet data.
- * @returns True if updated, false otherwise.
- */
-export async function updateDayDiet(
- dayId: DayDiet['id'],
- dayDiet: NewDayDiet,
-): Promise {
- try {
- await showPromise(
- dayRepository.updateDayDiet(dayId, dayDiet),
- {
- loading: 'Atualizando dieta...',
- success: 'Dieta atualizada com sucesso',
- error: 'Erro ao atualizar dieta',
- },
- { context: 'user-action', audience: 'user' },
- )
- await fetchAllUserDayDiets(dayDiet.owner)
- return true
- } catch (error) {
- errorHandler.error(error)
- return false
- }
-}
-
-/**
- * Deletes a day diet by ID.
- * @param dayId - The day diet ID.
- * @returns True if deleted, false otherwise.
- */
-export async function deleteDayDiet(dayId: DayDiet['id']): Promise {
- try {
- await showPromise(
- dayRepository.deleteDayDiet(dayId),
- {
- loading: 'Deletando dieta...',
- success: 'Dieta deletada com sucesso',
- error: 'Erro ao deletar dieta',
- },
- { context: 'user-action', audience: 'user' },
- )
- await fetchAllUserDayDiets(currentUserId())
- return true
- } catch (error) {
- errorHandler.error(error)
- return false
- }
-}
-
-/**
- * Returns all previous DayDiet objects before the given target day, ordered by descending date.
- *
- * @param dayDiets - List of all DayDiet objects (should be sorted ascending by date)
- * @param selectedDay - The YYYY-MM-DD string to compare against
- * @returns Array of DayDiet objects before selectedDay, ordered by descending date
- */
-export function getPreviousDayDiets(
- dayDiets: readonly DayDiet[],
- selectedDay: string,
-): DayDiet[] {
- const selectedDate = new Date(selectedDay)
- selectedDate.setHours(0, 0, 0, 0) // Normalize to midnight to avoid time zone issues
-
- return dayDiets
- .filter((day) => {
- const dayDate = new Date(day.target_day)
- dayDate.setHours(0, 0, 0, 0) // Normalize to midnight
- return dayDate.getTime() < selectedDate.getTime()
- })
- .sort((a, b) => {
- const dateA = new Date(a.target_day)
- const dateB = new Date(b.target_day)
- return dateB.getTime() - dateA.getTime()
- })
-}
diff --git a/src/modules/diet/day-diet/application/services/cacheManagement.ts b/src/modules/diet/day-diet/application/services/cacheManagement.ts
new file mode 100644
index 000000000..96daaa36a
--- /dev/null
+++ b/src/modules/diet/day-diet/application/services/cacheManagement.ts
@@ -0,0 +1,42 @@
+import { untrack } from 'solid-js'
+
+import { type DayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
+import { type User } from '~/modules/user/domain/user'
+import { logging } from '~/shared/utils/logging'
+
+export function createCacheManagementService(deps: {
+ getExistingDays: () => readonly DayDiet[]
+ getCurrentDayDiet: () => DayDiet | null
+ clearCache: () => void
+ fetchTargetDay: (userId: User['uuid'], targetDay: string) => void
+}) {
+ return ({
+ currentTargetDay,
+ userId,
+ }: {
+ currentTargetDay: string
+ userId: User['uuid']
+ }) => {
+ logging.debug(`Effect - Refetch/Manage cache`)
+ const existingDays = untrack(deps.getExistingDays)
+ const currentDayDiet_ = untrack(deps.getCurrentDayDiet)
+
+ // If any day is from other user, purge cache
+ if (existingDays.find((d) => d.user_id !== userId) !== undefined) {
+ logging.debug(`User changed! Purge cache`)
+ deps.clearCache()
+ void deps.fetchTargetDay(userId, currentTargetDay)
+ return
+ }
+
+ logging.debug(
+ `Target day effect - user: ${userId}, target: ${currentTargetDay}, cache size: ${existingDays.length}`,
+ )
+ if (currentDayDiet_ === null) {
+ logging.debug(
+ `No day diet found for user ${userId} on ${currentTargetDay}, fetching...`,
+ )
+ void deps.fetchTargetDay(userId, currentTargetDay)
+ }
+ }
+}
diff --git a/src/modules/diet/day-diet/application/services/dayChange.ts b/src/modules/diet/day-diet/application/services/dayChange.ts
new file mode 100644
index 000000000..dc99a2c57
--- /dev/null
+++ b/src/modules/diet/day-diet/application/services/dayChange.ts
@@ -0,0 +1,45 @@
+import { type Setter } from 'solid-js'
+
+import { logging } from '~/shared/utils/logging'
+
+let dayCheckInterval: NodeJS.Timeout | null = null
+export function startDayChangeDetectionWorker(deps: {
+ getTodayYYYYMMDD: () => string
+ getPreviousToday: () => string
+ getCurrentTargetDay: () => string
+ setCurrentToday: Setter
+ setDayChangeData: Setter<{
+ previousDay: string
+ newDay: string
+ } | null>
+}) {
+ // Clear any existing interval
+ if (dayCheckInterval !== null) {
+ clearInterval(dayCheckInterval)
+ }
+ dayCheckInterval = setInterval(() => {
+ const newToday = deps.getTodayYYYYMMDD()
+ const previousToday = deps.getPreviousToday()
+ const currentTarget = deps.getCurrentTargetDay()
+ if (newToday !== previousToday) {
+ logging.debug(`Day changed from ${previousToday} to ${newToday}`)
+ deps.setCurrentToday(newToday)
+ // Only show modal if user is not already viewing today
+ if (currentTarget !== newToday) {
+ deps.setDayChangeData({
+ previousDay: previousToday,
+ newDay: newToday,
+ })
+ }
+ }
+ }, 6000)
+
+ const cleanup = () => {
+ if (dayCheckInterval !== null) {
+ clearInterval(dayCheckInterval)
+ dayCheckInterval = null
+ }
+ }
+
+ return cleanup
+}
diff --git a/src/modules/diet/day-diet/application/services/targetDayReset.ts b/src/modules/diet/day-diet/application/services/targetDayReset.ts
new file mode 100644
index 000000000..94675708c
--- /dev/null
+++ b/src/modules/diet/day-diet/application/services/targetDayReset.ts
@@ -0,0 +1,14 @@
+import { type Setter } from 'solid-js'
+
+import { logging } from '~/shared/utils/logging'
+
+export function createTargetDayResetService(deps: {
+ getTodayYYYYMMDD: () => string
+ setTargetDay: Setter
+}) {
+ return () => {
+ logging.debug(`Effect - Reset to today!`)
+ const today = deps.getTodayYYYYMMDD()
+ deps.setTargetDay(today)
+ }
+}
diff --git a/src/modules/diet/day-diet/application/usecases/copyDayOperations.ts b/src/modules/diet/day-diet/application/usecases/copyDayOperations.ts
new file mode 100644
index 000000000..74da80234
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/copyDayOperations.ts
@@ -0,0 +1,134 @@
+import { createSignal } from 'solid-js'
+
+import {
+ createNewDayDiet,
+ type DayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createDayDietRepository } from '~/modules/diet/day-diet/infrastructure/dayDietRepository'
+import { type User } from '~/modules/user/domain/user'
+import { logging } from '~/shared/utils/logging'
+
+export type CopyDayState = {
+ previousDays: readonly DayDiet[]
+ isLoadingPreviousDays: boolean
+ copyingDay: string | null
+ isCopying: boolean
+}
+
+export type CopyDayOperations = {
+ state: () => CopyDayState
+ loadPreviousDays: (
+ userId: User['uuid'],
+ beforeDay: string,
+ limit?: number,
+ ) => Promise
+ copyDay: (params: {
+ fromDay: string
+ toDay: string
+ existingDay?: DayDiet | undefined
+ previousDays: readonly DayDiet[]
+ }) => Promise
+ resetState: () => void
+}
+
+function createCopyDayOperations(
+ repository = createDayDietRepository(),
+): CopyDayOperations {
+ const [previousDays, setPreviousDays] = createSignal([])
+ const [isLoadingPreviousDays, setIsLoadingPreviousDays] = createSignal(false)
+ const [copyingDay, setCopyingDay] = createSignal(null)
+ const [isCopying, setIsCopying] = createSignal(false)
+
+ const state = (): CopyDayState => ({
+ previousDays: previousDays(),
+ isLoadingPreviousDays: isLoadingPreviousDays(),
+ copyingDay: copyingDay(),
+ isCopying: isCopying(),
+ })
+
+ const loadPreviousDays = async (
+ userId: User['uuid'],
+ beforeDay: string,
+ limit: number = 30,
+ ): Promise => {
+ if (isLoadingPreviousDays()) return
+
+ setIsLoadingPreviousDays(true)
+ try {
+ const days = await repository.fetchDayDietsByUserIdBeforeDate(
+ userId,
+ beforeDay,
+ limit,
+ )
+ setPreviousDays(days)
+ } catch (error) {
+ logging.error('CopyDayOperations loadPreviousDays error:', error)
+ setPreviousDays([])
+ throw error
+ } finally {
+ setIsLoadingPreviousDays(false)
+ }
+ }
+
+ const copyDay = async (params: {
+ fromDay: string
+ toDay: string
+ existingDay?: DayDiet
+ previousDays: readonly DayDiet[]
+ }): Promise => {
+ const { fromDay, toDay, existingDay, previousDays } = params
+
+ setCopyingDay(fromDay)
+ setIsCopying(true)
+
+ try {
+ const copyFrom = previousDays.find((d) => d.target_day === fromDay)
+ if (!copyFrom) {
+ throw new Error(`No matching previous day found for ${fromDay}`, {
+ cause: {
+ fromDay,
+ availableDays: previousDays.map((d) => d.target_day),
+ },
+ })
+ }
+
+ const newDay = createNewDayDiet({
+ target_day: toDay,
+ user_id: copyFrom.user_id,
+ meals: copyFrom.meals,
+ })
+
+ if (existingDay) {
+ await repository.updateDayDietById(existingDay.id, newDay)
+ } else {
+ await repository.insertDayDiet(newDay)
+ }
+ } catch (error) {
+ logging.error('CopyDayOperations copyDay error:', error)
+ throw error
+ } finally {
+ setIsCopying(false)
+ setCopyingDay(null)
+ }
+ }
+
+ const resetState = (): void => {
+ setPreviousDays([])
+ setIsLoadingPreviousDays(false)
+ setCopyingDay(null)
+ setIsCopying(false)
+ }
+
+ return {
+ state,
+ loadPreviousDays,
+ copyDay,
+ resetState,
+ }
+}
+
+const defaultOperations = createCopyDayOperations()
+
+export { createCopyDayOperations }
+export const { state, loadPreviousDays, copyDay, resetState } =
+ defaultOperations
diff --git a/src/modules/diet/day-diet/application/usecases/createBlankDay.ts b/src/modules/diet/day-diet/application/usecases/createBlankDay.ts
new file mode 100644
index 000000000..dfa05d881
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/createBlankDay.ts
@@ -0,0 +1,23 @@
+import { insertDayDiet } from '~/modules/diet/day-diet/application/usecases/dayCrud'
+import { createNewDayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
+import { createDefaultMeals } from '~/modules/diet/day-diet/domain/defaultMeals'
+import { type User } from '~/modules/user/domain/user'
+
+/**
+ * Creates a blank day diet with default meals for the specified user and date
+ * @param userId - The ID of the user creating the day
+ * @param targetDay - The target date in YYYY-MM-DD format
+ * @returns Promise that resolves when the day is created
+ */
+export async function createBlankDay(
+ userId: User['uuid'],
+ targetDay: string,
+): Promise {
+ const newDayDiet = createNewDayDiet({
+ user_id: userId,
+ target_day: targetDay,
+ meals: createDefaultMeals(),
+ })
+
+ await insertDayDiet(newDayDiet)
+}
diff --git a/src/modules/diet/day-diet/application/usecases/dayChange.ts b/src/modules/diet/day-diet/application/usecases/dayChange.ts
new file mode 100644
index 000000000..2eeff5099
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/dayChange.ts
@@ -0,0 +1,28 @@
+import { batch } from 'solid-js'
+
+import { dayCacheStore } from '~/modules/diet/day-diet/infrastructure/signals/dayCacheStore'
+import { dayChangeStore } from '~/modules/diet/day-diet/infrastructure/signals/dayChangeStore'
+import { dayStateStore } from '~/modules/diet/day-diet/infrastructure/signals/dayStateStore'
+
+export const dayChangeData = dayChangeStore.dayChangeData
+
+/**
+ * Dismisses the day change confirmation modal
+ */
+export function dismissDayChangeModal() {
+ dayChangeStore.setDayChangeData(null)
+}
+
+/**
+ * Accepts the day change and navigates to the new day
+ */
+export function acceptDayChange() {
+ const changeData = dayChangeStore.dayChangeData()
+ if (changeData) {
+ batch(() => {
+ dayCacheStore.clearCache()
+ dayStateStore.setTargetDay(changeData.newDay)
+ dayChangeStore.setDayChangeData(null)
+ })
+ }
+}
diff --git a/src/modules/diet/day-diet/application/usecases/dayCrud.ts b/src/modules/diet/day-diet/application/usecases/dayCrud.ts
new file mode 100644
index 000000000..522953556
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/dayCrud.ts
@@ -0,0 +1,88 @@
+import {
+ type DayDiet,
+ type NewDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createDayDietRepository } from '~/modules/diet/day-diet/infrastructure/dayDietRepository'
+import { showPromise } from '~/modules/toast/application/toastManager'
+import { type User } from '~/modules/user/domain/user'
+
+function createCrud(repository = createDayDietRepository()) {
+ const fetchTargetDay = async (
+ userId: User['uuid'],
+ targetDay: string,
+ ): Promise => {
+ await repository.fetchDayDietByUserIdAndTargetDay(userId, targetDay)
+ }
+
+ const fetchPreviousDayDiets = async (
+ userId: User['uuid'],
+ beforeDay: string,
+ limit: number = 30,
+ ): Promise => {
+ return await repository.fetchDayDietsByUserIdBeforeDate(
+ userId,
+ beforeDay,
+ limit,
+ )
+ }
+
+ const insertDayDiet = async (dayDiet: NewDayDiet): Promise => {
+ await showPromise(
+ repository.insertDayDiet(dayDiet),
+ {
+ loading: 'Criando dia de dieta...',
+ success: 'Dia de dieta criado com sucesso',
+ error: 'Erro ao criar dia de dieta',
+ },
+ { context: 'user-action' },
+ )
+ }
+
+ const updateDayDiet = async (
+ dayId: DayDiet['id'],
+ dayDiet: NewDayDiet,
+ ): Promise => {
+ await showPromise(
+ repository.updateDayDietById(dayId, dayDiet),
+ {
+ loading: 'Atualizando dieta...',
+ success: 'Dieta atualizada com sucesso',
+ error: 'Erro ao atualizar dieta',
+ },
+ { context: 'user-action' },
+ )
+ }
+
+ const deleteDayDiet = async (dayId: DayDiet['id']): Promise => {
+ await showPromise(
+ repository.deleteDayDietById(dayId),
+ {
+ loading: 'Deletando dieta...',
+ success: 'Dieta deletada com sucesso',
+ error: 'Erro ao deletar dieta',
+ },
+ { context: 'user-action' },
+ )
+ }
+
+ return {
+ fetchTargetDay,
+ fetchPreviousDayDiets,
+ insertDayDiet,
+ updateDayDiet,
+ deleteDayDiet,
+ }
+}
+
+// Default instance for production use
+const defaultCrud = createCrud()
+
+export const {
+ fetchTargetDay,
+ fetchPreviousDayDiets,
+ insertDayDiet,
+ updateDayDiet,
+ deleteDayDiet,
+} = defaultCrud
+
+export { createCrud }
diff --git a/src/modules/diet/day-diet/application/usecases/dayEditOrchestrator.ts b/src/modules/diet/day-diet/application/usecases/dayEditOrchestrator.ts
new file mode 100644
index 000000000..2d1d86360
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/dayEditOrchestrator.ts
@@ -0,0 +1,153 @@
+import { type DayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
+import { getMacroTargetForDay } from '~/modules/diet/macro-target/application/macroTarget'
+import { updateMeal } from '~/modules/diet/meal/application/meal'
+import { type Meal } from '~/modules/diet/meal/domain/meal'
+import {
+ addItemToMeal,
+ updateItemInMeal,
+} from '~/modules/diet/meal/domain/mealOperations'
+import { type UnifiedItem } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
+import { stringToDate } from '~/shared/utils/date/dateUtils'
+import { logging } from '~/shared/utils/logging'
+
+export type EditMode = 'edit' | 'read-only' | 'summary'
+
+export type EditPermissionResult =
+ | { canEdit: true }
+ | {
+ canEdit: false
+ reason: string
+ title: string
+ confirmText: string
+ cancelText: string
+ }
+
+export type MacroOverflowConfig =
+ | { enable: false; originalItem: undefined }
+ | { enable: true; originalItem: UnifiedItem }
+
+/**
+ * Orchestrates day editing operations, handling permissions, validations, and business logic
+ */
+export function createDayEditOrchestrator() {
+ /**
+ * Checks if a day can be edited based on the current mode
+ */
+ function checkEditPermission(mode: EditMode): EditPermissionResult {
+ if (mode === 'summary') {
+ return {
+ canEdit: false,
+ reason: 'Summary mode',
+ title: 'Modo resumo',
+ confirmText: 'OK',
+ cancelText: '',
+ }
+ }
+
+ if (mode !== 'edit') {
+ return {
+ canEdit: false,
+ reason: 'Day not editable',
+ title: 'Dia não editável',
+ confirmText: 'Desbloquear',
+ cancelText: 'Cancelar',
+ }
+ }
+
+ return { canEdit: true }
+ }
+
+ /**
+ * Prepares macro overflow configuration for item editing
+ */
+ function prepareMacroOverflowConfig(
+ dayDiet: DayDiet,
+ item: UnifiedItem,
+ ): MacroOverflowConfig {
+ try {
+ const dayDate = stringToDate(dayDiet.target_day)
+ const macroTarget = getMacroTargetForDay(dayDate)
+
+ if (!macroTarget) {
+ return {
+ enable: false,
+ originalItem: undefined,
+ }
+ }
+
+ return {
+ enable: true,
+ originalItem: item,
+ }
+ } catch (error) {
+ logging.error(
+ 'DayEditOrchestrator prepareMacroOverflowConfig error:',
+ error,
+ )
+
+ return {
+ enable: false,
+ originalItem: undefined,
+ }
+ }
+ }
+
+ /**
+ * Orchestrates the update of an item in a meal
+ */
+ async function updateItemInMealOrchestrated(
+ meal: Meal,
+ _item: UnifiedItem,
+ updatedItem: UnifiedItem,
+ ): Promise {
+ try {
+ const updatedMeal = updateItemInMeal(meal, updatedItem.id, updatedItem)
+ await updateMeal(meal.id, updatedMeal)
+ } catch (error) {
+ logging.error(
+ 'DayEditOrchestrator updateItemInMealOrchestrated error:',
+ error,
+ )
+ throw error
+ }
+ }
+
+ /**
+ * Orchestrates adding a new item to a meal
+ */
+ async function addItemToMealOrchestrated(
+ meal: Meal,
+ newItem: UnifiedItem,
+ ): Promise {
+ try {
+ const updatedMeal = addItemToMeal(meal, newItem)
+ await updateMeal(meal.id, updatedMeal)
+ } catch (error) {
+ logging.error(
+ 'DayEditOrchestrator addItemToMealOrchestrated error:',
+ error,
+ )
+ throw error
+ }
+ }
+
+ /**
+ * Orchestrates updating a meal
+ */
+ async function updateMealOrchestrated(meal: Meal): Promise {
+ try {
+ await updateMeal(meal.id, meal)
+ } catch (error) {
+ logging.error('DayEditOrchestrator updateMealOrchestrated error:', error)
+ throw error
+ }
+ }
+
+ return {
+ checkEditPermission,
+ prepareMacroOverflowConfig,
+ updateItemInMealOrchestrated,
+ addItemToMealOrchestrated,
+ updateMealOrchestrated,
+ }
+}
diff --git a/src/modules/diet/day-diet/application/usecases/dayState.ts b/src/modules/diet/day-diet/application/usecases/dayState.ts
new file mode 100644
index 000000000..21f1a6a5d
--- /dev/null
+++ b/src/modules/diet/day-diet/application/usecases/dayState.ts
@@ -0,0 +1,22 @@
+import { createEffect } from 'solid-js'
+
+import { dayCacheStore } from '~/modules/diet/day-diet/infrastructure/signals/dayCacheStore'
+import { dayChangeStore } from '~/modules/diet/day-diet/infrastructure/signals/dayChangeStore'
+import { initializeDayEffects } from '~/modules/diet/day-diet/infrastructure/signals/dayEffects'
+import { dayStateStore } from '~/modules/diet/day-diet/infrastructure/signals/dayStateStore'
+import { initializeDayDietRealtime } from '~/modules/diet/day-diet/infrastructure/supabase/realtime'
+import { logging } from '~/shared/utils/logging'
+
+export const targetDay = dayStateStore.targetDay
+export const setTargetDay = dayStateStore.setTargetDay
+
+export const currentToday = dayChangeStore.currentToday
+export const currentDayDiet = () =>
+ dayCacheStore.createCacheItemSignal({ by: 'target_day', value: targetDay() })
+
+createEffect(() => {
+ logging.debug(`CurrentDayDiet:`, { currentDayDiet: currentDayDiet() })
+})
+
+initializeDayEffects()
+initializeDayDietRealtime()
diff --git a/src/modules/diet/day-diet/domain/dayDiet.ts b/src/modules/diet/day-diet/domain/dayDiet.ts
index 3caa0ae89..d55072ec0 100644
--- a/src/modules/diet/day-diet/domain/dayDiet.ts
+++ b/src/modules/diet/day-diet/domain/dayDiet.ts
@@ -12,8 +12,8 @@ export const {
promote: promoteDayDiet,
demote: demoteNewDayDiet,
} = ze.create({
- target_day: ze.string(), // TODO: Change target_day to supabase date type
- owner: ze.number(),
+ target_day: ze.string(), // TODO: Change target_day to supabase date type
+ user_id: ze.string(),
meals: ze.array(mealSchema),
})
diff --git a/src/modules/diet/day-diet/domain/dayDietGateway.ts b/src/modules/diet/day-diet/domain/dayDietGateway.ts
new file mode 100644
index 000000000..f2b18ff74
--- /dev/null
+++ b/src/modules/diet/day-diet/domain/dayDietGateway.ts
@@ -0,0 +1,24 @@
+import {
+ type DayDiet,
+ type NewDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { type User } from '~/modules/user/domain/user'
+
+export type DayGateway = {
+ fetchDayDietByUserIdAndTargetDay: (
+ userId: User['uuid'],
+ targetDay: string,
+ ) => Promise
+ fetchDayDietsByUserIdBeforeDate: (
+ userId: User['uuid'],
+ beforeDay: string,
+ limit?: number,
+ ) => Promise
+ fetchDayDietById: (dayId: DayDiet['id']) => Promise
+ insertDayDiet: (newDay: NewDayDiet) => Promise
+ updateDayDietById: (
+ dayId: DayDiet['id'],
+ newDay: NewDayDiet,
+ ) => Promise
+ deleteDayDietById: (id: DayDiet['id']) => Promise
+}
diff --git a/src/modules/diet/day-diet/domain/dayDietRepository.ts b/src/modules/diet/day-diet/domain/dayDietRepository.ts
index 05c02e228..38720ae45 100644
--- a/src/modules/diet/day-diet/domain/dayDietRepository.ts
+++ b/src/modules/diet/day-diet/domain/dayDietRepository.ts
@@ -1,5 +1,3 @@
-import { type Accessor } from 'solid-js'
-
import {
type DayDiet,
type NewDayDiet,
@@ -7,14 +5,20 @@ import {
import { type User } from '~/modules/user/domain/user'
export type DayRepository = {
- // fetchAllUserDayIndexes: (
- // userId: User['id'],
- // ) => Promise>
- fetchAllUserDayDiets: (
- userId: User['id'],
- ) => Promise>
- fetchDayDiet: (dayId: DayDiet['id']) => Promise
- insertDayDiet: (newDay: NewDayDiet) => Promise // TODO: Remove nullability from insertDay
- updateDayDiet: (dayId: DayDiet['id'], newDay: NewDayDiet) => Promise
- deleteDayDiet: (id: DayDiet['id']) => Promise
+ fetchDayDietByUserIdAndTargetDay: (
+ userId: User['uuid'],
+ targetDay: string,
+ ) => Promise
+ fetchDayDietsByUserIdBeforeDate: (
+ userId: User['uuid'],
+ beforeDay: string,
+ limit?: number,
+ ) => Promise
+ fetchDayDietById: (dayId: DayDiet['id']) => Promise
+ insertDayDiet: (newDay: NewDayDiet) => Promise // TODO: Remove nullability from insertDay
+ updateDayDietById: (
+ dayId: DayDiet['id'],
+ newDay: NewDayDiet,
+ ) => Promise
+ deleteDayDietById: (id: DayDiet['id']) => Promise
}
diff --git a/src/modules/diet/day-diet/domain/defaultMeals.ts b/src/modules/diet/day-diet/domain/defaultMeals.ts
new file mode 100644
index 000000000..c5418b293
--- /dev/null
+++ b/src/modules/diet/day-diet/domain/defaultMeals.ts
@@ -0,0 +1,32 @@
+import { createNewMeal, promoteMeal } from '~/modules/diet/meal/domain/meal'
+import { generateId } from '~/shared/utils/idUtils'
+
+/**
+ * Default meal names for Brazilian users
+ * TODO: Make meal names editable and persistent by user
+ */
+const DEFAULT_MEAL_NAMES = [
+ 'Café da manhã',
+ 'Almoço',
+ 'Lanche',
+ 'Janta',
+ 'Pós janta',
+] as const
+
+/**
+ * Creates default meals with empty items for a new day diet
+ * @returns Array of promoted meals with generated IDs
+ */
+export function createDefaultMeals() {
+ return DEFAULT_MEAL_NAMES.map((name) =>
+ promoteMeal(createNewMeal({ name, items: [] }), { id: generateId() }),
+ )
+}
+
+/**
+ * Get the default meal names
+ * @returns Readonly array of default meal names
+ */
+export function getDefaultMealNames(): readonly string[] {
+ return DEFAULT_MEAL_NAMES
+}
diff --git a/src/modules/diet/day-diet/infrastructure/dayDietDAO.ts b/src/modules/diet/day-diet/infrastructure/dayDietDAO.ts
deleted file mode 100644
index d7ea3ee0d..000000000
--- a/src/modules/diet/day-diet/infrastructure/dayDietDAO.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { z } from 'zod/v4'
-
-import {
- type DayDiet,
- dayDietSchema,
- type NewDayDiet,
-} from '~/modules/diet/day-diet/domain/dayDiet'
-import { mealDAOSchema } from '~/modules/diet/meal/infrastructure/mealDAO'
-import { parseWithStack } from '~/shared/utils/parseWithStack'
-
-// DAO schema for creating new day diets in unified format (direct UnifiedItem persistence)
-export const createDayDietDAOSchema = z.object({
- target_day: z.string(),
- owner: z.number(),
- meals: z.array(mealDAOSchema),
-})
-
-// DAO schema for database record
-export const dayDietDAOSchema = z.object({
- id: z.number(),
- target_day: z.string(),
- owner: z.number(),
- meals: z.array(mealDAOSchema),
-})
-
-export type CreateDayDietDAO = z.infer
-
-export type DayDietDAO = z.infer
-
-/**
- * Converts a NewDayDiet object to a CreateDayDietDAO for direct UnifiedItem database operations
- */
-export function createDayDietDAOFromNewDayDiet(
- newDayDiet: NewDayDiet,
-): CreateDayDietDAO {
- return parseWithStack(createDayDietDAOSchema, {
- target_day: newDayDiet.target_day,
- owner: newDayDiet.owner,
- meals: newDayDiet.meals, // Use meals directly with UnifiedItems
- })
-}
-
-/**
- * Converts a DAO object from database to domain DayDiet object
- */
-export function daoToDayDiet(dao: DayDietDAO): DayDiet {
- return parseWithStack(dayDietSchema, {
- id: dao.id,
- target_day: dao.target_day,
- owner: dao.owner,
- meals: dao.meals,
- })
-}
diff --git a/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts b/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts
new file mode 100644
index 000000000..b29c15e56
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts
@@ -0,0 +1,127 @@
+import {
+ type DayDiet,
+ type NewDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { type DayRepository } from '~/modules/diet/day-diet/domain/dayDietRepository'
+import { dayCacheStore } from '~/modules/diet/day-diet/infrastructure/signals/dayCacheStore'
+import { createSupabaseDayGateway } from '~/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway'
+import { type User } from '~/modules/user/domain/user'
+import { logging } from '~/shared/utils/logging'
+
+const supabaseGateway = createSupabaseDayGateway()
+
+export function createDayDietRepository(): DayRepository {
+ return {
+ fetchDayDietById,
+ fetchDayDietByUserIdAndTargetDay,
+ fetchDayDietsByUserIdBeforeDate,
+ insertDayDiet,
+ updateDayDietById,
+ deleteDayDietById,
+ }
+}
+
+export async function fetchDayDietById(
+ dayId: DayDiet['id'],
+): Promise {
+ try {
+ const dayDiet = await supabaseGateway.fetchDayDietById(dayId)
+ if (dayDiet === null) {
+ dayCacheStore.removeFromCache({ by: 'id', value: dayId })
+ return null
+ }
+
+ dayCacheStore.upsertToCache(dayDiet)
+ return dayDiet
+ } catch (error) {
+ logging.error('DayDiet fetch error:', error)
+ dayCacheStore.removeFromCache({ by: 'id', value: dayId })
+ return null
+ }
+}
+
+export async function fetchDayDietByUserIdAndTargetDay(
+ userId: User['uuid'],
+ targetDay: string,
+): Promise {
+ try {
+ const currentDayDiet =
+ await supabaseGateway.fetchDayDietByUserIdAndTargetDay(userId, targetDay)
+
+ if (currentDayDiet === null) {
+ dayCacheStore.removeFromCache({ by: 'target_day', value: targetDay })
+ return null
+ }
+ dayCacheStore.upsertToCache(currentDayDiet)
+ return currentDayDiet
+ } catch (error) {
+ logging.error('DayDiet fetch error:', error)
+ dayCacheStore.removeFromCache({ by: 'target_day', value: targetDay })
+ return null
+ }
+}
+
+export async function fetchDayDietsByUserIdBeforeDate(
+ userId: User['uuid'],
+ beforeDay: string,
+ limit: number = 30,
+): Promise {
+ try {
+ const previousDays = await supabaseGateway.fetchDayDietsByUserIdBeforeDate(
+ userId,
+ beforeDay,
+ limit,
+ )
+ for (const day of previousDays) {
+ dayCacheStore.upsertToCache(day)
+ }
+ return previousDays
+ } catch (error) {
+ logging.error('DayDiet fetch error:', error)
+ return []
+ }
+}
+
+export async function insertDayDiet(
+ dayDiet: NewDayDiet,
+): Promise {
+ try {
+ const insertedDayDiet = await supabaseGateway.insertDayDiet(dayDiet)
+ if (insertedDayDiet !== null) {
+ dayCacheStore.upsertToCache(insertedDayDiet)
+ }
+ return insertedDayDiet
+ } catch (error) {
+ logging.error('DayDiet insert error:', error)
+ return null
+ }
+}
+
+export async function updateDayDietById(
+ dayId: DayDiet['id'],
+ dayDiet: NewDayDiet,
+): Promise {
+ try {
+ const updatedDayDiet = await supabaseGateway.updateDayDietById(
+ dayId,
+ dayDiet,
+ )
+
+ if (updatedDayDiet !== null) {
+ dayCacheStore.upsertToCache(updatedDayDiet)
+ }
+ return updatedDayDiet
+ } catch (error) {
+ logging.error('DayDiet update error:', error)
+ return null
+ }
+}
+
+export async function deleteDayDietById(dayId: DayDiet['id']): Promise {
+ try {
+ await supabaseGateway.deleteDayDietById(dayId)
+ dayCacheStore.removeFromCache({ by: 'id', value: dayId })
+ } catch (error) {
+ logging.error('DayDiet delete error:', error)
+ }
+}
diff --git a/src/modules/diet/day-diet/infrastructure/signals/dayCacheStore.ts b/src/modules/diet/day-diet/infrastructure/signals/dayCacheStore.ts
new file mode 100644
index 000000000..186a6d2f0
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/signals/dayCacheStore.ts
@@ -0,0 +1,58 @@
+import { createEffect, createSignal, untrack } from 'solid-js'
+
+import { type DayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
+import { logging } from '~/shared/utils/logging'
+
+const [dayDiets, setDayDiets] = createSignal([])
+
+function clearCache() {
+ logging.debug(`Clearing cache`)
+ setDayDiets([])
+}
+
+function upsertToCache(dayDiet: DayDiet) {
+ logging.debug(`Upserting day:`, dayDiet)
+ const existingDayIndex = untrack(dayDiets).findIndex(
+ (d) => d.target_day === dayDiet.target_day,
+ )
+ setDayDiets((existingDays) => {
+ const days = [...existingDays]
+ if (existingDayIndex >= 0) {
+ days[existingDayIndex] = dayDiet
+ } else {
+ days.push(dayDiet)
+ days.sort((a, b) => a.target_day.localeCompare(b.target_day))
+ }
+ return days
+ })
+}
+
+function removeFromCache(filter: {
+ by: T
+ value: DayDiet[T]
+}) {
+ setDayDiets((days) => days.filter((d) => d[filter.by] !== filter.value))
+}
+
+function createCacheItemSignal(filter: {
+ by: T
+ value: DayDiet[T]
+}) {
+ logging.debug(`findInCache filter=`, filter)
+ const result = dayDiets().find((d) => d[filter.by] === filter.value) ?? null
+ logging.debug(`findInCache result=`, { result })
+ return result
+}
+
+export const dayCacheStore = {
+ dayDiets,
+ setDayDiets,
+ clearCache,
+ upsertToCache,
+ removeFromCache,
+ createCacheItemSignal,
+}
+
+createEffect(() => {
+ logging.debug(`Cache size: `, { length: dayDiets().length })
+})
diff --git a/src/modules/diet/day-diet/infrastructure/signals/dayChangeStore.ts b/src/modules/diet/day-diet/infrastructure/signals/dayChangeStore.ts
new file mode 100644
index 000000000..d3cae5c74
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/signals/dayChangeStore.ts
@@ -0,0 +1,26 @@
+import { createEffect, createSignal } from 'solid-js'
+
+import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils'
+import { logging } from '~/shared/utils/logging'
+
+/**
+ * Signal that tracks when the day has changed and a confirmation modal should be shown.
+ * Contains the previous day that the user was viewing when the day changed.
+ */
+const [dayChangeData, setDayChangeData] = createSignal<{
+ previousDay: string
+ newDay: string
+} | null>(null)
+
+const [currentToday, setCurrentToday] = createSignal(getTodayYYYYMMDD())
+
+export const dayChangeStore = {
+ dayChangeData,
+ setDayChangeData,
+ currentToday,
+ setCurrentToday,
+}
+
+createEffect(() => {
+ logging.debug(`Today has changed: `, { currentToday: currentToday() })
+})
diff --git a/src/modules/diet/day-diet/infrastructure/signals/dayEffects.ts b/src/modules/diet/day-diet/infrastructure/signals/dayEffects.ts
new file mode 100644
index 000000000..a3dd34deb
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/signals/dayEffects.ts
@@ -0,0 +1,61 @@
+import { createEffect, createRoot, onCleanup, onMount, untrack } from 'solid-js'
+
+import { createCacheManagementService } from '~/modules/diet/day-diet/application/services/cacheManagement'
+import { startDayChangeDetectionWorker } from '~/modules/diet/day-diet/application/services/dayChange'
+import { createTargetDayResetService } from '~/modules/diet/day-diet/application/services/targetDayReset'
+import { fetchTargetDay } from '~/modules/diet/day-diet/application/usecases/dayCrud'
+import {
+ currentDayDiet,
+ targetDay,
+} from '~/modules/diet/day-diet/application/usecases/dayState'
+import { dayCacheStore } from '~/modules/diet/day-diet/infrastructure/signals/dayCacheStore'
+import { dayChangeStore } from '~/modules/diet/day-diet/infrastructure/signals/dayChangeStore'
+import { dayStateStore } from '~/modules/diet/day-diet/infrastructure/signals/dayStateStore'
+import { currentUserId } from '~/modules/user/application/user'
+import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils'
+import { logging } from '~/shared/utils/logging'
+
+const runTargetDayReset = createTargetDayResetService({
+ getTodayYYYYMMDD,
+ setTargetDay: dayStateStore.setTargetDay,
+})
+
+const runCacheManagement = createCacheManagementService({
+ getExistingDays: () => untrack(dayCacheStore.dayDiets),
+ getCurrentDayDiet: () => untrack(currentDayDiet),
+ clearCache: dayCacheStore.clearCache,
+ fetchTargetDay: (userId, targetDay) => void fetchTargetDay(userId, targetDay),
+})
+
+let initialized = false
+export function initializeDayEffects() {
+ if (initialized) {
+ return
+ }
+ initialized = true
+ return createRoot(() => {
+ onMount(() => {
+ const cleanup = startDayChangeDetectionWorker({
+ getTodayYYYYMMDD,
+ getPreviousToday: () => untrack(dayChangeStore.currentToday),
+ getCurrentTargetDay: () => untrack(dayStateStore.targetDay),
+ setCurrentToday: dayChangeStore.setCurrentToday,
+ setDayChangeData: dayChangeStore.setDayChangeData,
+ })
+
+ onCleanup(cleanup)
+ })
+
+ createEffect(() => {
+ const userId = currentUserId()
+ logging.debug(`User changed to ${userId}, resetting target day`)
+ runTargetDayReset()
+ })
+
+ createEffect(() => {
+ const userId = currentUserId()
+ const currentTargetDay = targetDay()
+ runCacheManagement({ userId, currentTargetDay })
+ })
+ })
+}
diff --git a/src/modules/diet/day-diet/infrastructure/signals/dayStateStore.ts b/src/modules/diet/day-diet/infrastructure/signals/dayStateStore.ts
new file mode 100644
index 000000000..679cdd648
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/signals/dayStateStore.ts
@@ -0,0 +1,15 @@
+import { createEffect, createSignal } from 'solid-js'
+
+import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils'
+import { logging } from '~/shared/utils/logging'
+
+const [targetDay, setTargetDay] = createSignal(getTodayYYYYMMDD())
+
+export const dayStateStore = {
+ targetDay,
+ setTargetDay,
+}
+
+createEffect(() => {
+ logging.debug(`TargetDay =`, { targetDay: targetDay() })
+})
diff --git a/src/modules/diet/day-diet/infrastructure/supabase/constants.ts b/src/modules/diet/day-diet/infrastructure/supabase/constants.ts
new file mode 100644
index 000000000..d511d706f
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/supabase/constants.ts
@@ -0,0 +1 @@
+export const SUPABASE_TABLE_DAYS = 'days'
diff --git a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts
new file mode 100644
index 000000000..b95c6c852
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts
@@ -0,0 +1,66 @@
+import {
+ type DayDiet,
+ dayDietSchema,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { dayCacheStore } from '~/modules/diet/day-diet/infrastructure/signals/dayCacheStore'
+import { SUPABASE_TABLE_DAYS } from '~/modules/diet/day-diet/infrastructure/supabase/constants'
+import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase'
+import { logging } from '~/shared/utils/logging'
+
+let initialized = false
+
+/**
+ * Sets up granular realtime subscription for day diet changes
+ * @param onDayDietChange - Callback for granular updates with event details
+ */
+export function setupDayDietRealtimeSubscription(
+ onDayDietChange: (event: {
+ eventType: 'INSERT' | 'UPDATE' | 'DELETE'
+ old?: DayDiet
+ new?: DayDiet
+ }) => void,
+): void {
+ registerSubapabaseRealtimeCallback(
+ SUPABASE_TABLE_DAYS,
+ dayDietSchema,
+ onDayDietChange,
+ )
+}
+
+export function initializeDayDietRealtime(): void {
+ if (initialized) {
+ return
+ }
+ logging.debug(`Day diet realtime initialized!`)
+ initialized = true
+ registerSubapabaseRealtimeCallback(
+ SUPABASE_TABLE_DAYS,
+ dayDietSchema,
+ (event) => {
+ logging.debug(`Event:`, event)
+
+ switch (event.eventType) {
+ case 'INSERT': {
+ if (event.new !== undefined) {
+ dayCacheStore.upsertToCache(event.new)
+ }
+ break
+ }
+
+ case 'UPDATE': {
+ if (event.new) {
+ dayCacheStore.upsertToCache(event.new)
+ }
+ break
+ }
+
+ case 'DELETE': {
+ if (event.old) {
+ dayCacheStore.removeFromCache({ by: 'id', value: event.old.id })
+ }
+ break
+ }
+ }
+ },
+ )
+}
diff --git a/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts b/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts
new file mode 100644
index 000000000..224b71967
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts
@@ -0,0 +1,164 @@
+import {
+ type DayDiet,
+ dayDietSchema,
+ type NewDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { type DayGateway } from '~/modules/diet/day-diet/domain/dayDietGateway'
+import { SUPABASE_TABLE_DAYS } from '~/modules/diet/day-diet/infrastructure/supabase/constants'
+import { supabaseDayMapper } from '~/modules/diet/day-diet/infrastructure/supabase/supabaseMapper'
+import { type User } from '~/modules/user/domain/user'
+import { supabase } from '~/shared/supabase/supabase'
+import { wrapErrorWithStack } from '~/shared/utils/errorUtils'
+import { logging } from '~/shared/utils/logging'
+
+export function createSupabaseDayGateway(): DayGateway {
+ return {
+ fetchDayDietByUserIdAndTargetDay,
+ fetchDayDietsByUserIdBeforeDate,
+ fetchDayDietById,
+ insertDayDiet,
+ updateDayDietById,
+ deleteDayDietById,
+ }
+}
+
+async function fetchDayDietById(dayId: DayDiet['id']): Promise {
+ try {
+ const { data, error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .select()
+ .eq('id', dayId)
+
+ if (error !== null) {
+ logging.error('DayDiet fetch error:', error)
+ throw error
+ }
+
+ const dayDiets = Array.isArray(data) ? data : []
+ if (dayDiets.length === 0) {
+ logging.error('DayDiet not found:', { dayId })
+ throw new Error('DayDiet not found')
+ }
+ const result = dayDietSchema.safeParse(dayDiets[0])
+ if (!result.success) {
+ logging.error('DayDiet invalid:', { dayId, parseError: result.error })
+ throw new Error('DayDiet invalid')
+ }
+ return result.data
+ } catch (err) {
+ logging.error('DayDiet fetch error:', err)
+ throw err
+ }
+}
+
+async function fetchDayDietByUserIdAndTargetDay(
+ userId: User['uuid'],
+ targetDay: string,
+): Promise {
+ logging.debug(
+ `[supabaseDayRepository] fetchCurrentUserDayDiet(${userId}, ${targetDay})`,
+ )
+
+ const { data, error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .select()
+ .eq('user_id', userId)
+ .eq('target_day', targetDay)
+ .single()
+
+ if (error !== null) {
+ if (error.code === 'PGRST116') {
+ // No rows returned - day doesn't exist
+ logging.debug(`[supabaseDayRepository] No day found for ${targetDay}`)
+ return null
+ }
+ logging.error('DayDiet fetch error:', error)
+ throw error
+ }
+
+ const dayData = data
+ const result = dayDietSchema.safeParse(dayData)
+ if (!result.success) {
+ logging.error('Error parsing current day diet:', {
+ parseError: result.error,
+ targetDay,
+ })
+ throw wrapErrorWithStack(result.error)
+ }
+
+ logging.debug(`[supabaseDayRepository] Successfully fetched day ${targetDay}`)
+ return result.data
+}
+
+async function fetchDayDietsByUserIdBeforeDate(
+ userId: User['uuid'],
+ beforeDay: string,
+ limit: number = 30,
+): Promise {
+ logging.debug(
+ `[supabaseDayRepository] fetchPreviousUserDayDiets(${userId}, ${beforeDay}, ${limit})`,
+ )
+
+ const { data: dayDTOs, error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .select()
+ .eq('user_id', userId)
+ .lt('target_day', beforeDay)
+ .order('target_day', { ascending: false })
+ .limit(limit)
+
+ if (error !== null) {
+ logging.error('DayDiet fetch error:', error)
+ throw error
+ }
+
+ return dayDTOs.map((dto) => supabaseDayMapper.toDomain(dto))
+}
+
+async function insertDayDiet(newDay: NewDayDiet): Promise {
+ const newDayDTO = supabaseDayMapper.toInsertDTO(newDay)
+
+ const { data: dayDTO, error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .insert(newDayDTO)
+ .select()
+ .single()
+ if (error !== null) {
+ throw wrapErrorWithStack(error)
+ }
+
+ return supabaseDayMapper.toDomain(dayDTO)
+}
+
+async function updateDayDietById(
+ id: DayDiet['id'],
+ newDay: NewDayDiet,
+): Promise {
+ const updateDTO = supabaseDayMapper.toInsertDTO(newDay)
+
+ const { data: dayDTO, error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .update(updateDTO)
+ .eq('id', id)
+ .select()
+ .single()
+
+ if (error !== null) {
+ logging.error('DayDiet update error:', error)
+ throw error
+ }
+
+ return supabaseDayMapper.toDomain(dayDTO)
+}
+
+const deleteDayDietById = async (id: DayDiet['id']): Promise => {
+ const { error } = await supabase
+ .from(SUPABASE_TABLE_DAYS)
+ .delete()
+ .eq('id', id)
+ .select()
+
+ if (error !== null) {
+ throw wrapErrorWithStack(error)
+ }
+}
diff --git a/src/modules/diet/day-diet/infrastructure/supabase/supabaseMapper.ts b/src/modules/diet/day-diet/infrastructure/supabase/supabaseMapper.ts
new file mode 100644
index 000000000..4b618cdbc
--- /dev/null
+++ b/src/modules/diet/day-diet/infrastructure/supabase/supabaseMapper.ts
@@ -0,0 +1,32 @@
+import {
+ type DayDiet,
+ dayDietSchema,
+ type NewDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { type Database } from '~/shared/supabase/database.types'
+import { parseWithStack } from '~/shared/utils/parseWithStack'
+
+export type DayDietDTO = Database['public']['Tables']['days']['Row']
+export type InsertDayDietDTO = Database['public']['Tables']['days']['Insert']
+
+function toInsertDTO(newDayDiet: NewDayDiet): InsertDayDietDTO {
+ return {
+ target_day: newDayDiet.target_day,
+ user_id: newDayDiet.user_id,
+ meals: newDayDiet.meals,
+ }
+}
+
+function toDomain(dto: DayDietDTO): DayDiet {
+ return parseWithStack(dayDietSchema, {
+ id: dto.id,
+ target_day: dto.target_day,
+ user_id: dto.user_id,
+ meals: dto.meals,
+ })
+}
+
+export const supabaseDayMapper = {
+ toInsertDTO,
+ toDomain,
+}
diff --git a/src/modules/diet/day-diet/infrastructure/supabaseDayRepository.ts b/src/modules/diet/day-diet/infrastructure/supabaseDayRepository.ts
deleted file mode 100644
index 865a20470..000000000
--- a/src/modules/diet/day-diet/infrastructure/supabaseDayRepository.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { type Accessor, createSignal } from 'solid-js'
-
-import {
- type DayDiet,
- dayDietSchema,
- type NewDayDiet,
-} from '~/modules/diet/day-diet/domain/dayDiet'
-import { type DayRepository } from '~/modules/diet/day-diet/domain/dayDietRepository'
-import {
- createDayDietDAOFromNewDayDiet,
- daoToDayDiet,
- type DayDietDAO,
-} from '~/modules/diet/day-diet/infrastructure/dayDietDAO'
-import { type User } from '~/modules/user/domain/user'
-import {
- createErrorHandler,
- wrapErrorWithStack,
-} from '~/shared/error/errorHandler'
-import supabase from '~/shared/utils/supabase'
-
-export const SUPABASE_TABLE_DAYS = 'days'
-
-const errorHandler = createErrorHandler('infrastructure', 'DayDiet')
-
-export function createSupabaseDayRepository(): DayRepository {
- return {
- // fetchAllUserDayIndexes: fetchUserDayIndexes,
- fetchAllUserDayDiets,
- fetchDayDiet,
- insertDayDiet,
- updateDayDiet,
- deleteDayDiet,
- }
-}
-
-/**
- * // TODO: Replace userDays with userDayIndexes
- * @deprecated should be replaced by userDayIndexes
- */
-const [userDays, setUserDays] = createSignal([])
-// const [userDayIndexes, setUserDayIndexes] = createSignal([])
-
-// TODO: better error handling
-/**
- * Fetches a DayDiet by its ID.
- * Throws on error or if not found.
- * @param dayId - The DayDiet ID
- * @returns The DayDiet
- * @throws Error if not found or on API/validation error
- */
-async function fetchDayDiet(dayId: DayDiet['id']): Promise {
- try {
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_DAYS)
- .select()
- .eq('id', dayId)
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- const dayDiets = Array.isArray(data) ? data : []
- if (dayDiets.length === 0) {
- errorHandler.validationError('DayDiet not found', {
- component: 'supabaseDayRepository',
- operation: 'fetchDayDiet',
- additionalData: { dayId },
- })
- throw new Error('DayDiet not found')
- }
- const result = dayDietSchema.safeParse(dayDiets[0])
- if (!result.success) {
- errorHandler.validationError('DayDiet invalid', {
- component: 'supabaseDayRepository',
- operation: 'fetchDayDiet',
- additionalData: { dayId, parseError: result.error },
- })
- throw new Error('DayDiet invalid')
- }
- return result.data
- } catch (err) {
- errorHandler.error(err)
- throw err
- }
-}
-
-// TODO: better error handling
-async function fetchAllUserDayDiets(
- userId: User['id'],
-): Promise> {
- console.debug(`[supabaseDayRepository] fetchUserDays(${userId})`)
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_DAYS)
- .select()
- .eq('owner', userId)
- .order('target_day', { ascending: true })
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- const days = data
- .map((day) => {
- return dayDietSchema.safeParse(day)
- })
- .map((result) => {
- if (result.success) {
- return result.data
- }
- errorHandler.validationError('Error while parsing day', {
- component: 'supabaseDayRepository',
- operation: 'fetchAllUserDayDiets',
- additionalData: { parseError: result.error },
- })
- throw wrapErrorWithStack(result.error)
- })
-
- console.log('days', days)
-
- console.debug(
- `[supabaseDayRepository] fetchUserDays returned ${days.length} days`,
- )
- setUserDays(days)
-
- return userDays
-}
-
-// TODO: Change upserts to inserts on the entire app
-const insertDayDiet = async (newDay: NewDayDiet): Promise => {
- // Use direct UnifiedItem persistence (no migration needed)
- const createDAO = createDayDietDAOFromNewDayDiet(newDay)
-
- const { data: days, error } = await supabase
- .from(SUPABASE_TABLE_DAYS)
- .insert(createDAO)
- .select()
- if (error !== null) {
- throw wrapErrorWithStack(error)
- }
-
- const dayDAO = days[0] as DayDietDAO | undefined
- if (dayDAO !== undefined) {
- // Data is already in unified format, no migration needed for new inserts
- return daoToDayDiet(dayDAO)
- }
- return null
-}
-
-const updateDayDiet = async (
- id: DayDiet['id'],
- newDay: NewDayDiet,
-): Promise => {
- // Use direct UnifiedItem persistence (no migration needed)
- const updateDAO = createDayDietDAOFromNewDayDiet(newDay)
-
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_DAYS)
- .update(updateDAO)
- .eq('id', id)
- .select()
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- const dayDAO = data[0] as DayDietDAO
- // Data is already in unified format, no migration needed for updates
- return daoToDayDiet(dayDAO)
-}
-
-const deleteDayDiet = async (id: DayDiet['id']): Promise => {
- const { error } = await supabase
- .from(SUPABASE_TABLE_DAYS)
- .delete()
- .eq('id', id)
- .select()
-
- if (error !== null) {
- throw wrapErrorWithStack(error)
- }
-
- const userId = userDays().find((day) => day.id === id)?.owner
- if (userId === undefined) {
- throw new Error(
- `Invalid state: userId not found for day ${id} on local cache`,
- )
- }
-}
diff --git a/src/modules/diet/day-diet/tests/application/copyDayOperations.test.ts b/src/modules/diet/day-diet/tests/application/copyDayOperations.test.ts
new file mode 100644
index 000000000..e2a3b684d
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/application/copyDayOperations.test.ts
@@ -0,0 +1,275 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { createCopyDayOperations } from '~/modules/diet/day-diet/application/usecases/copyDayOperations'
+import {
+ createNewDayDiet,
+ type DayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createDefaultMeals } from '~/modules/diet/day-diet/domain/defaultMeals'
+import { type User } from '~/modules/user/domain/user'
+
+// Mock the repository
+vi.mock('~/modules/diet/day-diet/infrastructure/dayDietRepository', () => ({
+ createDayDietRepository: vi.fn(() => ({
+ fetchDayDietsByUserIdBeforeDate: vi.fn(),
+ insertDayDiet: vi.fn(),
+ updateDayDietById: vi.fn(),
+ })),
+}))
+
+const mockRepository = {
+ fetchDayDietByUserIdAndTargetDay: vi.fn(),
+ fetchDayDietsByUserIdBeforeDate: vi.fn(),
+ fetchDayDietById: vi.fn(),
+ insertDayDiet: vi.fn(),
+ updateDayDietById: vi.fn(),
+ deleteDayDietById: vi.fn(),
+}
+
+function makeMockDayDiet(
+ targetDay: string,
+ user_id: User['uuid'] = '1',
+): DayDiet {
+ return promoteDayDiet(
+ createNewDayDiet({
+ target_day: targetDay,
+ user_id,
+ meals: createDefaultMeals(),
+ }),
+ { id: 1 },
+ )
+}
+
+describe('CopyDayOperations', () => {
+ let operations: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ operations = createCopyDayOperations(mockRepository)
+ })
+
+ describe('initial state', () => {
+ it('should have empty initial state', () => {
+ const state = operations.state()
+
+ expect(state.previousDays).toEqual([])
+ expect(state.isLoadingPreviousDays).toBe(false)
+ expect(state.copyingDay).toBe(null)
+ expect(state.isCopying).toBe(false)
+ })
+ })
+
+ describe('loadPreviousDays', () => {
+ it('should load previous days successfully', async () => {
+ const mockDays = [
+ makeMockDayDiet('2023-01-01'),
+ makeMockDayDiet('2023-01-02'),
+ ]
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce(
+ mockDays,
+ )
+
+ await operations.loadPreviousDays('1', '2023-01-03', 30)
+
+ expect(
+ mockRepository.fetchDayDietsByUserIdBeforeDate,
+ ).toHaveBeenCalledWith('1', '2023-01-03', 30)
+ expect(operations.state().previousDays).toEqual(mockDays)
+ expect(operations.state().isLoadingPreviousDays).toBe(false)
+ })
+
+ it('should set loading state during fetch', async () => {
+ let resolvePromise: (value: DayDiet[]) => void
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockReturnValueOnce(
+ promise,
+ )
+
+ const loadPromise = operations.loadPreviousDays('1', '2023-01-03')
+
+ expect(operations.state().isLoadingPreviousDays).toBe(true)
+
+ resolvePromise!([])
+ await loadPromise
+
+ expect(operations.state().isLoadingPreviousDays).toBe(false)
+ })
+
+ it('should handle fetch error and call errorHandler.apiError', async () => {
+ const error = new Error('Network error')
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockRejectedValueOnce(
+ error,
+ )
+
+ await expect(
+ operations.loadPreviousDays('1', '2023-01-03'),
+ ).rejects.toThrow('Network error')
+
+ expect(operations.state().previousDays).toEqual([])
+ expect(operations.state().isLoadingPreviousDays).toBe(false)
+ })
+
+ it('should not load if already loading', async () => {
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockImplementation(
+ () => new Promise(() => {}),
+ ) // Never resolves
+
+ const firstCall = operations.loadPreviousDays('1', '2023-01-03')
+ const secondCall = operations.loadPreviousDays('1', '2023-01-03')
+
+ await Promise.race([
+ firstCall,
+ secondCall,
+ new Promise((resolve) => setTimeout(resolve, 10)), // Small timeout
+ ])
+
+ expect(
+ mockRepository.fetchDayDietsByUserIdBeforeDate,
+ ).toHaveBeenCalledTimes(1)
+ })
+
+ it('should use default limit of 30', async () => {
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce([])
+
+ await operations.loadPreviousDays('1', '2023-01-03')
+
+ expect(
+ mockRepository.fetchDayDietsByUserIdBeforeDate,
+ ).toHaveBeenCalledWith('1', '2023-01-03', 30)
+ })
+ })
+
+ describe('copyDay', () => {
+ it('should copy day successfully when no existing day', async () => {
+ const sourceDayDiet = makeMockDayDiet('2023-01-01')
+ const previousDays = [sourceDayDiet]
+
+ mockRepository.insertDayDiet.mockResolvedValueOnce(undefined)
+
+ await operations.copyDay({
+ fromDay: '2023-01-01',
+ toDay: '2023-01-03',
+ previousDays,
+ })
+
+ expect(mockRepository.insertDayDiet).toHaveBeenCalledWith({
+ target_day: '2023-01-03',
+ user_id: sourceDayDiet.user_id,
+ meals: sourceDayDiet.meals,
+ __type: 'NewDayDiet',
+ })
+ expect(operations.state().isCopying).toBe(false)
+ expect(operations.state().copyingDay).toBe(null)
+ })
+
+ it('should update existing day when provided', async () => {
+ const sourceDayDiet = makeMockDayDiet('2023-01-01')
+ const existingDayDiet = makeMockDayDiet('2023-01-03')
+ const previousDays = [sourceDayDiet]
+
+ mockRepository.updateDayDietById.mockResolvedValueOnce(undefined)
+
+ await operations.copyDay({
+ fromDay: '2023-01-01',
+ toDay: '2023-01-03',
+ existingDay: existingDayDiet,
+ previousDays,
+ })
+
+ expect(mockRepository.updateDayDietById).toHaveBeenCalledWith(
+ existingDayDiet.id,
+ {
+ target_day: '2023-01-03',
+ user_id: sourceDayDiet.user_id,
+ meals: sourceDayDiet.meals,
+ __type: 'NewDayDiet',
+ },
+ )
+ })
+
+ it('should set copying state during operation', async () => {
+ const sourceDayDiet = makeMockDayDiet('2023-01-01')
+ const previousDays = [sourceDayDiet]
+
+ let resolvePromise: () => void
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ mockRepository.insertDayDiet.mockReturnValueOnce(promise)
+
+ const copyPromise = operations.copyDay({
+ fromDay: '2023-01-01',
+ toDay: '2023-01-03',
+ previousDays,
+ })
+
+ expect(operations.state().isCopying).toBe(true)
+ expect(operations.state().copyingDay).toBe('2023-01-01')
+
+ resolvePromise!()
+ await copyPromise
+
+ expect(operations.state().isCopying).toBe(false)
+ expect(operations.state().copyingDay).toBe(null)
+ })
+
+ it('should throw error when source day not found', async () => {
+ const previousDays: DayDiet[] = []
+
+ await expect(
+ operations.copyDay({
+ fromDay: '2023-01-01',
+ toDay: '2023-01-03',
+ previousDays,
+ }),
+ ).rejects.toThrow('No matching previous day found for 2023-01-01')
+
+ expect(operations.state().isCopying).toBe(false)
+ expect(operations.state().copyingDay).toBe(null)
+ })
+
+ it('should handle repository error and call handleApiError', async () => {
+ const sourceDayDiet = makeMockDayDiet('2023-01-01')
+ const previousDays = [sourceDayDiet]
+ const error = new Error('Database error')
+
+ mockRepository.insertDayDiet.mockRejectedValueOnce(error)
+
+ await expect(
+ operations.copyDay({
+ fromDay: '2023-01-01',
+ toDay: '2023-01-03',
+ previousDays,
+ }),
+ ).rejects.toThrow('Database error')
+ expect(operations.state().isCopying).toBe(false)
+ expect(operations.state().copyingDay).toBe(null)
+ })
+ })
+
+ describe('resetState', () => {
+ it('should reset all state to initial values', async () => {
+ // Set some state first
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce([
+ makeMockDayDiet('2023-01-01'),
+ ])
+ await operations.loadPreviousDays('1', '2023-01-03')
+
+ // Verify state is set
+ expect(operations.state().previousDays).toHaveLength(1)
+
+ // Reset
+ operations.resetState()
+
+ // Verify reset
+ const state = operations.state()
+ expect(state.previousDays).toEqual([])
+ expect(state.isLoadingPreviousDays).toBe(false)
+ expect(state.copyingDay).toBe(null)
+ expect(state.isCopying).toBe(false)
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/application/createBlankDay.test.ts b/src/modules/diet/day-diet/tests/application/createBlankDay.test.ts
new file mode 100644
index 000000000..6e9c8da79
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/application/createBlankDay.test.ts
@@ -0,0 +1,108 @@
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { createBlankDay } from '~/modules/diet/day-diet/application/usecases/createBlankDay'
+import { createDefaultMeals } from '~/modules/diet/day-diet/domain/defaultMeals'
+
+// Mock the dayCrud module
+vi.mock('~/modules/diet/day-diet/application/usecases/dayCrud', () => ({
+ insertDayDiet: vi.fn(),
+}))
+
+// Mock the defaultMeals module
+vi.mock('~/modules/diet/day-diet/domain/defaultMeals', () => ({
+ createDefaultMeals: vi.fn(),
+}))
+
+// Mock showPromise toast function
+vi.mock('~/modules/toast/application/toastManager', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ showPromise: vi.fn((promise) => promise), // Pass through the promise
+}))
+
+describe('createBlankDay', () => {
+ let mockInsertDayDiet: ReturnType
+
+ beforeAll(async () => {
+ const dayCrudModule = await import(
+ '~/modules/diet/day-diet/application/usecases/dayCrud'
+ )
+ mockInsertDayDiet = vi.mocked(dayCrudModule.insertDayDiet)
+ })
+ const mockCreateDefaultMeals = vi.mocked(createDefaultMeals)
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCreateDefaultMeals.mockReturnValue([])
+ })
+
+ it('should create a blank day with default meals', async () => {
+ const mockMeals = [
+ { id: 1, name: 'Café da manhã', items: [], __type: 'Meal' as const },
+ { id: 2, name: 'Almoço', items: [], __type: 'Meal' as const },
+ ]
+ mockCreateDefaultMeals.mockReturnValue(mockMeals)
+ mockInsertDayDiet.mockResolvedValueOnce(undefined)
+
+ await createBlankDay('123', '2023-01-01')
+
+ expect(mockCreateDefaultMeals).toHaveBeenCalledOnce()
+ expect(mockInsertDayDiet).toHaveBeenCalledWith({
+ user_id: '123',
+ target_day: '2023-01-01',
+ meals: mockMeals,
+ __type: 'NewDayDiet',
+ })
+ })
+
+ it('should handle different user IDs and dates', async () => {
+ mockInsertDayDiet.mockResolvedValueOnce(undefined)
+
+ await createBlankDay('456', '2023-12-25')
+
+ expect(mockInsertDayDiet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user_id: '456',
+ target_day: '2023-12-25',
+ }),
+ )
+ })
+
+ it('should propagate insertDayDiet errors', async () => {
+ const error = new Error('Database error')
+ mockInsertDayDiet.mockRejectedValueOnce(error)
+
+ await expect(createBlankDay('123', '2023-01-01')).rejects.toThrow(
+ 'Database error',
+ )
+ })
+
+ it('should create new day diet with correct structure', async () => {
+ const mockMeals = [
+ { id: 1, name: 'Café da manhã', items: [], __type: 'Meal' as const },
+ ]
+ mockCreateDefaultMeals.mockReturnValue(mockMeals)
+ mockInsertDayDiet.mockResolvedValueOnce(undefined)
+
+ await createBlankDay('789', '2023-06-15')
+
+ expect(mockInsertDayDiet).toHaveBeenCalledWith({
+ __type: 'NewDayDiet',
+ user_id: '789',
+ target_day: '2023-06-15',
+ meals: mockMeals,
+ })
+ })
+
+ it('should handle empty meals from createDefaultMeals', async () => {
+ mockCreateDefaultMeals.mockReturnValue([])
+ mockInsertDayDiet.mockResolvedValueOnce(undefined)
+
+ await createBlankDay('100', '2023-01-01')
+
+ expect(mockInsertDayDiet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ meals: [],
+ }),
+ )
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/application/dayCrud.test.ts b/src/modules/diet/day-diet/tests/application/dayCrud.test.ts
new file mode 100644
index 000000000..b7255b55f
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/application/dayCrud.test.ts
@@ -0,0 +1,307 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ createNewDayDiet,
+ type DayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createDefaultMeals } from '~/modules/diet/day-diet/domain/defaultMeals'
+
+// Mock the repository
+vi.mock('~/modules/diet/day-diet/infrastructure/dayDietRepository', () => ({
+ createDayDietRepository: vi.fn(() => ({
+ fetchDayDietByUserIdAndTargetDay: vi.fn(),
+ fetchDayDietsByUserIdBeforeDate: vi.fn(),
+ insertDayDiet: vi.fn(),
+ updateDayDietById: vi.fn(),
+ deleteDayDietById: vi.fn(),
+ })),
+}))
+
+// Mock showPromise toast function
+vi.mock('~/modules/toast/application/toastManager', () => ({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
+ showPromise: vi.fn((promise) => promise), // Pass through the promise
+}))
+
+const mockRepository = {
+ fetchDayDietById: vi.fn(),
+ fetchDayDietByUserIdAndTargetDay: vi.fn(),
+ fetchDayDietsByUserIdBeforeDate: vi.fn(),
+ insertDayDiet: vi.fn(),
+ updateDayDietById: vi.fn(),
+ deleteDayDietById: vi.fn(),
+} satisfies DayRepository
+
+// Import the createCrud function
+import { createCrud } from '~/modules/diet/day-diet/application/usecases/dayCrud'
+import { type DayRepository } from '~/modules/diet/day-diet/domain/dayDietRepository'
+import { type User } from '~/modules/user/domain/user'
+
+function makeMockDayDiet(
+ targetDay: string,
+ user_id: User['uuid'] = '1',
+): DayDiet {
+ return promoteDayDiet(
+ createNewDayDiet({
+ target_day: targetDay,
+ user_id,
+ meals: createDefaultMeals(),
+ }),
+ { id: 1 },
+ )
+}
+
+describe('Day Diet CRUD Operations', () => {
+ let crud: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ crud = createCrud(mockRepository)
+ })
+
+ describe('fetchTargetDay', () => {
+ it('should call repository with correct parameters', async () => {
+ mockRepository.fetchDayDietByUserIdAndTargetDay.mockResolvedValueOnce(
+ undefined,
+ )
+
+ await crud.fetchTargetDay('1', '2023-01-01')
+
+ expect(
+ mockRepository.fetchDayDietByUserIdAndTargetDay,
+ ).toHaveBeenCalledWith('1', '2023-01-01')
+ })
+
+ it('should handle repository errors', async () => {
+ const error = new Error('Database error')
+ mockRepository.fetchDayDietByUserIdAndTargetDay.mockRejectedValueOnce(
+ error,
+ )
+
+ await expect(crud.fetchTargetDay('1', '2023-01-01')).rejects.toThrow(
+ 'Database error',
+ )
+ })
+ })
+
+ describe('fetchPreviousDayDiets', () => {
+ it('should fetch previous days with default limit', async () => {
+ const mockDays = [
+ makeMockDayDiet('2023-01-01'),
+ makeMockDayDiet('2023-01-02'),
+ ]
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce(
+ mockDays,
+ )
+
+ const result = await crud.fetchPreviousDayDiets('1', '2023-01-03')
+
+ expect(
+ mockRepository.fetchDayDietsByUserIdBeforeDate,
+ ).toHaveBeenCalledWith('1', '2023-01-03', 30)
+ expect(result).toEqual(mockDays)
+ })
+
+ it('should fetch previous days with custom limit', async () => {
+ const mockDays = [makeMockDayDiet('2023-01-01')]
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce(
+ mockDays,
+ )
+
+ const result = await crud.fetchPreviousDayDiets('1', '2023-01-03', 10)
+
+ expect(
+ mockRepository.fetchDayDietsByUserIdBeforeDate,
+ ).toHaveBeenCalledWith('1', '2023-01-03', 10)
+ expect(result).toEqual(mockDays)
+ })
+
+ it('should handle empty results', async () => {
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockResolvedValueOnce([])
+
+ const result = await crud.fetchPreviousDayDiets('1', '2023-01-03')
+
+ expect(result).toEqual([])
+ })
+
+ it('should handle repository errors', async () => {
+ const error = new Error('Network error')
+ mockRepository.fetchDayDietsByUserIdBeforeDate.mockRejectedValueOnce(
+ error,
+ )
+
+ await expect(
+ crud.fetchPreviousDayDiets('1', '2023-01-03'),
+ ).rejects.toThrow('Network error')
+ })
+ })
+
+ describe('insertDayDiet', () => {
+ it('should insert day diet with toast notifications', async () => {
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: createDefaultMeals(),
+ })
+
+ mockRepository.insertDayDiet.mockResolvedValueOnce(undefined)
+
+ await crud.insertDayDiet(newDayDiet)
+
+ expect(mockRepository.insertDayDiet).toHaveBeenCalledWith(newDayDiet)
+
+ const { showPromise } = await import(
+ '~/modules/toast/application/toastManager'
+ )
+ expect(showPromise).toHaveBeenCalledWith(
+ expect.any(Promise),
+ {
+ loading: 'Criando dia de dieta...',
+ success: 'Dia de dieta criado com sucesso',
+ error: 'Erro ao criar dia de dieta',
+ },
+ { context: 'user-action' },
+ )
+ })
+
+ it('should handle repository errors with toast', async () => {
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+
+ const error = new Error('Insert failed')
+ mockRepository.insertDayDiet.mockRejectedValueOnce(error)
+
+ await expect(crud.insertDayDiet(newDayDiet)).rejects.toThrow(
+ 'Insert failed',
+ )
+ })
+ })
+
+ describe('updateDayDiet', () => {
+ it('should update day diet with toast notifications', async () => {
+ const dayDiet = makeMockDayDiet('2023-01-01')
+ const updatedData = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: createDefaultMeals(),
+ })
+
+ mockRepository.updateDayDietById.mockResolvedValueOnce(undefined)
+
+ await crud.updateDayDiet(dayDiet.id, updatedData)
+
+ expect(mockRepository.updateDayDietById).toHaveBeenCalledWith(
+ dayDiet.id,
+ updatedData,
+ )
+
+ const { showPromise } = await import(
+ '~/modules/toast/application/toastManager'
+ )
+ expect(showPromise).toHaveBeenCalledWith(
+ expect.any(Promise),
+ {
+ loading: 'Atualizando dieta...',
+ success: 'Dieta atualizada com sucesso',
+ error: 'Erro ao atualizar dieta',
+ },
+ { context: 'user-action' },
+ )
+ })
+
+ it('should handle repository errors with toast', async () => {
+ const error = new Error('Update failed')
+ mockRepository.updateDayDietById.mockRejectedValueOnce(error)
+
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+
+ await expect(crud.updateDayDiet(1, newDayDiet)).rejects.toThrow(
+ 'Update failed',
+ )
+ })
+ })
+
+ describe('deleteDayDiet', () => {
+ it('should delete day diet with toast notifications', async () => {
+ const dayDiet = makeMockDayDiet('2023-01-01')
+
+ mockRepository.deleteDayDietById.mockResolvedValueOnce(undefined)
+
+ await crud.deleteDayDiet(dayDiet.id)
+
+ expect(mockRepository.deleteDayDietById).toHaveBeenCalledWith(dayDiet.id)
+
+ const { showPromise } = await import(
+ '~/modules/toast/application/toastManager'
+ )
+ expect(showPromise).toHaveBeenCalledWith(
+ expect.any(Promise),
+ {
+ loading: 'Deletando dieta...',
+ success: 'Dieta deletada com sucesso',
+ error: 'Erro ao deletar dieta',
+ },
+ { context: 'user-action' },
+ )
+ })
+
+ it('should handle repository errors with toast', async () => {
+ const error = new Error('Delete failed')
+ mockRepository.deleteDayDietById.mockRejectedValueOnce(error)
+
+ await expect(crud.deleteDayDiet(1)).rejects.toThrow('Delete failed')
+ })
+ })
+
+ describe('Error Handling Integration', () => {
+ it('should propagate repository errors correctly', async () => {
+ const repositoryError = new Error('Connection timeout')
+ mockRepository.insertDayDiet.mockRejectedValueOnce(repositoryError)
+
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+
+ // The error should propagate through showPromise
+ await expect(crud.insertDayDiet(newDayDiet)).rejects.toThrow(
+ 'Connection timeout',
+ )
+ })
+
+ it('should handle multiple operations independently', async () => {
+ const dayDiet = makeMockDayDiet('2023-01-01')
+
+ // First operation succeeds
+ mockRepository.fetchDayDietByUserIdAndTargetDay.mockResolvedValueOnce(
+ undefined,
+ )
+ await expect(
+ crud.fetchTargetDay('1', '2023-01-01'),
+ ).resolves.toBeUndefined()
+
+ // Second operation fails
+ mockRepository.deleteDayDietById.mockRejectedValueOnce(
+ new Error('Delete error'),
+ )
+ await expect(crud.deleteDayDiet(dayDiet.id)).rejects.toThrow(
+ 'Delete error',
+ )
+
+ // Verify both calls were made
+ expect(
+ mockRepository.fetchDayDietByUserIdAndTargetDay,
+ ).toHaveBeenCalledTimes(1)
+ expect(mockRepository.deleteDayDietById).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/application/dayEditOrchestrator.test.ts b/src/modules/diet/day-diet/tests/application/dayEditOrchestrator.test.ts
new file mode 100644
index 000000000..3791d48f9
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/application/dayEditOrchestrator.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { createDayEditOrchestrator } from '~/modules/diet/day-diet/application/usecases/dayEditOrchestrator'
+import {
+ createNewDayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients'
+import { createNewMeal, promoteMeal } from '~/modules/diet/meal/domain/meal'
+import { createUnifiedItem } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
+
+// Mock dependencies
+vi.mock('~/modules/diet/macro-target/application/macroTarget', () => ({
+ getMacroTargetForDay: vi.fn(),
+}))
+
+vi.mock('~/modules/diet/meal/application/meal', () => ({
+ updateMeal: vi.fn(),
+}))
+
+vi.mock('~/shared/utils/date/dateUtils', () => ({
+ stringToDate: vi.fn(() => new Date('2023-01-01')),
+}))
+
+const { getMacroTargetForDay } = await import(
+ '~/modules/diet/macro-target/application/macroTarget'
+)
+const { updateMeal } = await import('~/modules/diet/meal/application/meal')
+
+function makeTestItem(id = 1) {
+ return createUnifiedItem({
+ id,
+ name: 'Test Item',
+ quantity: 100,
+ reference: {
+ type: 'food' as const,
+ id,
+ macros: createMacroNutrients({ carbs: 10, protein: 2, fat: 1 }),
+ },
+ })
+}
+
+function makeTestMeal(id = 1) {
+ return promoteMeal(
+ createNewMeal({ name: 'Test Meal', items: [makeTestItem()] }),
+ { id },
+ )
+}
+
+function makeTestDayDiet() {
+ return promoteDayDiet(
+ createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [makeTestMeal()],
+ }),
+ { id: 1 },
+ )
+}
+
+describe('DayEditOrchestrator', () => {
+ describe('checkEditPermission', () => {
+ it('should allow editing when mode is edit', () => {
+ const orchestrator = createDayEditOrchestrator()
+ const result = orchestrator.checkEditPermission('edit')
+
+ expect(result.canEdit).toBe(true)
+ })
+
+ it('should deny editing when mode is summary', () => {
+ const orchestrator = createDayEditOrchestrator()
+ const result = orchestrator.checkEditPermission('summary')
+
+ expect(result.canEdit).toBe(false)
+ if (!result.canEdit) {
+ expect(result.reason).toBe('Summary mode')
+ }
+ })
+
+ it('should deny editing when mode is read-only', () => {
+ const orchestrator = createDayEditOrchestrator()
+ const result = orchestrator.checkEditPermission('read-only')
+
+ expect(result.canEdit).toBe(false)
+ if (!result.canEdit) {
+ expect(result.reason).toBe('Day not editable')
+ expect(result.title).toBe('Dia não editável')
+ expect(result.confirmText).toBe('Desbloquear')
+ expect(result.cancelText).toBe('Cancelar')
+ }
+ })
+ })
+
+ describe('prepareMacroOverflowConfig', () => {
+ it('should enable macro overflow when macro target exists', () => {
+ const mockMacroTarget = createMacroNutrients({
+ carbs: 100,
+ protein: 50,
+ fat: 30,
+ })
+ vi.mocked(getMacroTargetForDay).mockReturnValue(mockMacroTarget)
+
+ const orchestrator = createDayEditOrchestrator()
+ const dayDiet = makeTestDayDiet()
+ const item = makeTestItem()
+
+ const result = orchestrator.prepareMacroOverflowConfig(dayDiet, item)
+
+ expect(result.enable).toBe(true)
+ expect(result.originalItem).toBe(item)
+ })
+
+ it('should disable macro overflow when no macro target exists', () => {
+ vi.mocked(getMacroTargetForDay).mockReturnValue(null)
+
+ const orchestrator = createDayEditOrchestrator()
+ const dayDiet = makeTestDayDiet()
+ const item = makeTestItem()
+
+ const result = orchestrator.prepareMacroOverflowConfig(dayDiet, item)
+
+ expect(result.enable).toBe(false)
+ expect(result.originalItem).toBeUndefined()
+ })
+ })
+
+ describe('updateMealOrchestrated', () => {
+ it('should call updateMeal with correct parameters', async () => {
+ vi.mocked(updateMeal).mockResolvedValue(true)
+
+ const orchestrator = createDayEditOrchestrator()
+ const meal = makeTestMeal()
+
+ await orchestrator.updateMealOrchestrated(meal)
+
+ expect(updateMeal).toHaveBeenCalledWith(meal.id, meal)
+ })
+
+ it('should propagate errors with proper context', async () => {
+ const error = new Error('Update failed')
+ vi.mocked(updateMeal).mockRejectedValue(error)
+
+ const orchestrator = createDayEditOrchestrator()
+ const meal = makeTestMeal()
+
+ await expect(orchestrator.updateMealOrchestrated(meal)).rejects.toThrow(
+ 'Update failed',
+ )
+ })
+ })
+
+ describe('addItemToMealOrchestrated', () => {
+ it('should update meal with new item', async () => {
+ vi.mocked(updateMeal).mockResolvedValue(true)
+
+ const orchestrator = createDayEditOrchestrator()
+ const meal = makeTestMeal()
+ const newItem = makeTestItem(2)
+
+ await orchestrator.addItemToMealOrchestrated(meal, newItem)
+
+ expect(updateMeal).toHaveBeenCalledWith(meal.id, expect.any(Object))
+ })
+ })
+
+ describe('updateItemInMealOrchestrated', () => {
+ it('should update specific item in meal', async () => {
+ vi.mocked(updateMeal).mockResolvedValue(true)
+
+ const orchestrator = createDayEditOrchestrator()
+ const meal = makeTestMeal()
+ const originalItem = meal.items[0]!
+ const updatedItem = createUnifiedItem({
+ ...originalItem,
+ name: 'Updated Item',
+ })
+
+ await orchestrator.updateItemInMealOrchestrated(
+ meal,
+ originalItem,
+ updatedItem,
+ )
+
+ expect(updateMeal).toHaveBeenCalledWith(meal.id, expect.any(Object))
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/application/services/cacheManagement.test.ts b/src/modules/diet/day-diet/tests/application/services/cacheManagement.test.ts
new file mode 100644
index 000000000..b166a0609
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/application/services/cacheManagement.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('~/shared/utils/logging', () => ({
+ logging: {
+ debug: vi.fn(),
+ },
+}))
+
+import { createCacheManagementService } from '~/modules/diet/day-diet/application/services/cacheManagement'
+import {
+ createNewDayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+
+describe('cacheManagementService', () => {
+ describe('when there are days from other users', () => {
+ const myUserId = '1'
+ const otherUserId = '2'
+ it('should clear cache and fetch current day', () => {
+ const clearCache = vi.fn()
+ const fetchTargetDay = vi.fn()
+ const getCurrentDayDiet = vi.fn(() =>
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: myUserId,
+ target_day: '2023-01-01',
+ }),
+ { id: 1 },
+ ),
+ )
+ const getExistingDays = vi.fn(() => [
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: otherUserId, // Different user than current userId
+ target_day: '2023-01-01',
+ }),
+ { id: 1 },
+ ),
+ ])
+
+ const runService = createCacheManagementService({
+ clearCache,
+ getCurrentDayDiet,
+ getExistingDays,
+ fetchTargetDay,
+ })
+
+ runService({
+ currentTargetDay: '2023-01-01',
+ userId: myUserId,
+ })
+
+ expect(clearCache).toHaveBeenCalledOnce()
+ expect(fetchTargetDay).toHaveBeenCalledWith('1', '2023-01-01')
+ })
+ })
+
+ describe('when current day diet is null', () => {
+ it('should fetch target day', () => {
+ const clearCache = vi.fn()
+ const fetchTargetDay = vi.fn()
+ const getCurrentDayDiet = vi.fn(() => null)
+ const getExistingDays = vi.fn(() => [])
+
+ const runService = createCacheManagementService({
+ clearCache,
+ getCurrentDayDiet,
+ getExistingDays,
+ fetchTargetDay,
+ })
+
+ runService({
+ currentTargetDay: '2023-01-01',
+ userId: '1',
+ })
+
+ expect(clearCache).not.toHaveBeenCalled()
+ expect(fetchTargetDay).toHaveBeenCalledWith('1', '2023-01-01')
+ })
+ })
+
+ describe('when cache is valid and current day exists', () => {
+ it('should not clear cache or fetch data', () => {
+ const clearCache = vi.fn()
+ const fetchTargetDay = vi.fn()
+ const getCurrentDayDiet = vi.fn(() =>
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: '1',
+ target_day: '2023-01-01',
+ }),
+ { id: 1 },
+ ),
+ )
+ const getExistingDays = vi.fn(() => [
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: '1', // Same user
+ target_day: '2023-01-01',
+ }),
+ { id: 1 },
+ ),
+ ])
+
+ const runService = createCacheManagementService({
+ clearCache,
+ getCurrentDayDiet,
+ getExistingDays,
+ fetchTargetDay,
+ })
+
+ runService({
+ currentTargetDay: '2023-01-01',
+ userId: '1',
+ })
+
+ expect(clearCache).not.toHaveBeenCalled()
+ expect(fetchTargetDay).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('when there are multiple days with mixed users', () => {
+ it('should clear cache when any day belongs to different user', () => {
+ const clearCache = vi.fn()
+ const fetchTargetDay = vi.fn()
+ const getCurrentDayDiet = vi.fn(() => null)
+ const getExistingDays = vi.fn(() => [
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: '1', // Same user
+ target_day: '2023-01-01',
+ }),
+ { id: 1 },
+ ),
+ promoteDayDiet(
+ createNewDayDiet({
+ meals: [],
+ user_id: '2', // Different user - should trigger purge
+ target_day: '2023-01-02',
+ }),
+ { id: 2 },
+ ),
+ ])
+
+ const runService = createCacheManagementService({
+ clearCache,
+ getCurrentDayDiet,
+ getExistingDays,
+ fetchTargetDay,
+ })
+
+ runService({
+ currentTargetDay: '2023-01-01',
+ userId: '1',
+ })
+
+ expect(clearCache).toHaveBeenCalledOnce()
+ expect(fetchTargetDay).toHaveBeenCalledWith('1', '2023-01-01')
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/dayChangeDetection.test.ts b/src/modules/diet/day-diet/tests/dayChangeDetection.test.ts
deleted file mode 100644
index 2751aade9..000000000
--- a/src/modules/diet/day-diet/tests/dayChangeDetection.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-
-import {
- acceptDayChange,
- currentToday,
- dayChangeData,
- dismissDayChangeModal,
- setCurrentToday,
- setDayChangeData,
-} from '~/modules/diet/day-diet/application/dayDiet'
-import * as dateUtils from '~/shared/utils/date/dateUtils'
-
-describe('Day Change Detection', () => {
- it('should accept day change and navigate to new day', () => {
- vi.spyOn(dateUtils, 'getTodayYYYYMMDD').mockReturnValue('2024-01-16')
-
- setDayChangeData({
- previousDay: '2024-01-15',
- newDay: '2024-01-16',
- })
-
- acceptDayChange()
-
- expect(dayChangeData()).toBeNull()
- })
-})
diff --git a/src/modules/diet/day-diet/tests/dayDiet.test.ts b/src/modules/diet/day-diet/tests/dayDiet.test.ts
deleted file mode 100644
index 3a0f7053f..000000000
--- a/src/modules/diet/day-diet/tests/dayDiet.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import { getPreviousDayDiets } from '~/modules/diet/day-diet/application/dayDiet'
-import {
- createNewDayDiet,
- type DayDiet,
- promoteDayDiet,
-} from '~/modules/diet/day-diet/domain/dayDiet'
-
-const makeDay = (target_day: string, id: number): DayDiet =>
- promoteDayDiet(
- createNewDayDiet({
- target_day,
- owner: 1,
- meals: [],
- }),
- { id },
- )
-
-describe('getPreviousDayDiets', () => {
- it('returns all days before selectedDay, ordered descending', () => {
- const days = [
- makeDay('2024-01-01', 1),
- makeDay('2024-01-05', 2),
- makeDay('2024-01-03', 3),
- makeDay('2024-01-10', 4),
- ]
- const result = getPreviousDayDiets(days, '2024-01-06')
- expect(result.map((d) => d.target_day)).toEqual([
- '2024-01-05',
- '2024-01-03',
- '2024-01-01',
- ])
- })
-
- it('returns empty if no previous days', () => {
- const days = [makeDay('2024-01-10', 1)]
- const result = getPreviousDayDiets(days, '2024-01-01')
- expect(result).toEqual([])
- })
-})
diff --git a/src/modules/diet/day-diet/tests/dayDietOperations.test.ts b/src/modules/diet/day-diet/tests/dayDietOperations.test.ts
deleted file mode 100644
index de20d87d6..000000000
--- a/src/modules/diet/day-diet/tests/dayDietOperations.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, expect, it } from 'vitest'
-
-import type { DayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
-import {
- createNewDayDiet,
- promoteDayDiet,
-} from '~/modules/diet/day-diet/domain/dayDiet'
-import { updateMealInDayDiet } from '~/modules/diet/day-diet/domain/dayDietOperations'
-import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients'
-import { createNewMeal, promoteMeal } from '~/modules/diet/meal/domain/meal'
-import { createUnifiedItem } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
-
-function makeUnifiedItem(id: number, name = 'Arroz') {
- return createUnifiedItem({
- id,
- name,
- quantity: 100,
- reference: {
- type: 'food' as const,
- id,
- macros: createMacroNutrients({ carbs: 10, protein: 2, fat: 1 }),
- },
- })
-}
-
-function makeMeal(id: number, name = 'Almoço', items = [makeUnifiedItem(1)]) {
- return promoteMeal(createNewMeal({ name, items }), { id })
-}
-
-const baseItem = makeUnifiedItem(1)
-const baseMeal = makeMeal(1, 'Almoço', [baseItem])
-const baseDayDiet: DayDiet = promoteDayDiet(
- createNewDayDiet({
- target_day: '2023-01-01',
- owner: 1,
- meals: [baseMeal],
- }),
- { id: 1 },
-)
-
-describe('dayDietOperations', () => {
- it('updateMealInDayDiet updates a meal', () => {
- const updated = makeMeal(1, 'Jantar', [baseItem])
- const result = updateMealInDayDiet(baseDayDiet, 1, updated)
- expect(result.meals[0]?.name).toBe('Jantar')
- })
-
- it('updateMealInDayDiet should return the original DayDiet if mealId does not exist', () => {
- const nonExistentMealId = 999
- const updated = makeMeal(nonExistentMealId, 'Non Existent', [])
- const result = updateMealInDayDiet(baseDayDiet, nonExistentMealId, updated)
- expect(result).toEqual(baseDayDiet)
- })
-
- it('updateMealInDayDiet should preserve other meals in the DayDiet', () => {
- const meal2 = makeMeal(2, 'Café da Manhã', [makeUnifiedItem(2)])
- const dayDietWithTwoMeals = promoteDayDiet(
- createNewDayDiet({
- target_day: '2023-01-01',
- owner: 1,
- meals: [baseMeal, meal2],
- }),
- { id: 1 },
- )
- const updatedMeal1 = makeMeal(1, 'Almoço Atualizado', [baseItem])
- const result = updateMealInDayDiet(dayDietWithTwoMeals, 1, updatedMeal1)
- expect(result.meals).toHaveLength(2)
- expect(result.meals[0]?.name).toBe('Almoço Atualizado')
- expect(result.meals[1]).toEqual(meal2)
- })
-
- it('updateMealInDayDiet should preserve other properties of the DayDiet', () => {
- const updated = makeMeal(1, 'Jantar', [baseItem])
- const result = updateMealInDayDiet(baseDayDiet, 1, updated)
- expect(result.target_day).toBe(baseDayDiet.target_day)
- expect(result.owner).toBe(baseDayDiet.owner)
- expect(result.id).toBe(baseDayDiet.id)
- })
-})
diff --git a/src/modules/diet/day-diet/tests/domain/dayDiet.test.ts b/src/modules/diet/day-diet/tests/domain/dayDiet.test.ts
new file mode 100644
index 000000000..92463f349
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/domain/dayDiet.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ createNewDayDiet,
+ type DayDiet,
+ demoteNewDayDiet,
+ type NewDayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients'
+import { createNewMeal, promoteMeal } from '~/modules/diet/meal/domain/meal'
+import { createUnifiedItem } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
+
+function makeTestMeal() {
+ const item = createUnifiedItem({
+ id: 1,
+ name: 'Arroz',
+ quantity: 100,
+ reference: {
+ type: 'food' as const,
+ id: 1,
+ macros: createMacroNutrients({ carbs: 10, protein: 2, fat: 1 }),
+ },
+ })
+
+ return promoteMeal(createNewMeal({ name: 'Almoço', items: [item] }), {
+ id: 1,
+ })
+}
+
+describe('DayDiet Factory Functions', () => {
+ describe('createNewDayDiet', () => {
+ it('should create a new day diet with required fields', () => {
+ const meals = [makeTestMeal()]
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals,
+ })
+
+ expect(newDayDiet.target_day).toBe('2023-01-01')
+ expect(newDayDiet.user_id).toBe('1')
+ expect(newDayDiet.meals).toEqual(meals)
+ expect(newDayDiet.__type).toBe('NewDayDiet')
+ })
+
+ it('should create a day diet with empty meals array', () => {
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+
+ expect(newDayDiet.meals).toEqual([])
+ expect(newDayDiet.meals.length).toBe(0)
+ })
+
+ it('should preserve meal structure in created day diet', () => {
+ const meals = [makeTestMeal()]
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals,
+ })
+
+ expect(newDayDiet.meals[0]?.name).toBe('Almoço')
+ expect(newDayDiet.meals[0]?.items).toHaveLength(1)
+ expect(newDayDiet.meals[0]?.items[0]?.name).toBe('Arroz')
+ })
+ })
+
+ describe('promoteDayDiet', () => {
+ it('should promote new day diet to day diet with id', () => {
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+
+ const dayDiet = promoteDayDiet(newDayDiet, { id: 123 })
+
+ expect(dayDiet.id).toBe(123)
+ expect(dayDiet.target_day).toBe('2023-01-01')
+ expect(dayDiet.user_id).toBe('1')
+ expect(dayDiet.__type).toBe('DayDiet')
+ })
+
+ it('should preserve all fields when promoting', () => {
+ const meals = [makeTestMeal()]
+ const newDayDiet = createNewDayDiet({
+ target_day: '2023-12-25',
+ user_id: '42',
+ meals,
+ })
+
+ const dayDiet = promoteDayDiet(newDayDiet, { id: 999 })
+
+ expect(dayDiet.id).toBe(999)
+ expect(dayDiet.target_day).toBe('2023-12-25')
+ expect(dayDiet.user_id).toBe('42')
+ expect(dayDiet.meals).toEqual(meals)
+ })
+ })
+
+ describe('demoteNewDayDiet', () => {
+ it('should demote day diet back to new day diet', () => {
+ const originalNewDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+ const dayDiet = promoteDayDiet(originalNewDayDiet, { id: 123 })
+
+ const demotedDayDiet = demoteNewDayDiet(dayDiet)
+
+ expect(demotedDayDiet.target_day).toBe('2023-01-01')
+ expect(demotedDayDiet.user_id).toBe('1')
+ expect(demotedDayDiet.meals).toEqual([])
+ expect(demotedDayDiet.__type).toBe('NewDayDiet')
+ expect('id' in demotedDayDiet).toBe(false)
+ })
+
+ it('should preserve meals when demoting', () => {
+ const meals = [makeTestMeal()]
+ const originalNewDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals,
+ })
+ const dayDiet = promoteDayDiet(originalNewDayDiet, { id: 123 })
+
+ const demotedDayDiet = demoteNewDayDiet(dayDiet)
+
+ expect(demotedDayDiet.meals).toEqual(meals)
+ expect(demotedDayDiet.meals[0]?.name).toBe('Almoço')
+ })
+ })
+
+ describe('Type Discrimination', () => {
+ it('should correctly discriminate between NewDayDiet and DayDiet types', () => {
+ const newDayDiet: NewDayDiet = createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [],
+ })
+ const dayDiet: DayDiet = promoteDayDiet(newDayDiet, { id: 1 })
+
+ expect(newDayDiet.__type).toBe('NewDayDiet')
+ expect(dayDiet.__type).toBe('DayDiet')
+
+ // Type guard test
+ function isDayDiet(item: NewDayDiet | DayDiet): item is DayDiet {
+ return item.__type === 'DayDiet'
+ }
+
+ expect(isDayDiet(newDayDiet)).toBe(false)
+ expect(isDayDiet(dayDiet)).toBe(true)
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/domain/dayDietOperations.test.ts b/src/modules/diet/day-diet/tests/domain/dayDietOperations.test.ts
new file mode 100644
index 000000000..65aba442c
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/domain/dayDietOperations.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+
+import type { DayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
+import {
+ createNewDayDiet,
+ promoteDayDiet,
+} from '~/modules/diet/day-diet/domain/dayDiet'
+import { updateMealInDayDiet } from '~/modules/diet/day-diet/domain/dayDietOperations'
+import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients'
+import { createNewMeal, promoteMeal } from '~/modules/diet/meal/domain/meal'
+import { createUnifiedItem } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
+
+function makeUnifiedItem(id: number, name = 'Arroz') {
+ return createUnifiedItem({
+ id,
+ name,
+ quantity: 100,
+ reference: {
+ type: 'food' as const,
+ id,
+ macros: createMacroNutrients({ carbs: 10, protein: 2, fat: 1 }),
+ },
+ })
+}
+
+function makeMeal(id: number, name = 'Almoço', items = [makeUnifiedItem(1)]) {
+ return promoteMeal(createNewMeal({ name, items }), { id })
+}
+
+const baseItem = makeUnifiedItem(1)
+const baseMeal = makeMeal(1, 'Almoço', [baseItem])
+const baseDayDiet: DayDiet = promoteDayDiet(
+ createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [baseMeal],
+ }),
+ { id: 1 },
+)
+
+describe('dayDietOperations', () => {
+ describe('updateMealInDayDiet', () => {
+ it('should update a meal in the day diet', () => {
+ const updated = makeMeal(1, 'Jantar', [baseItem])
+ const result = updateMealInDayDiet(baseDayDiet, 1, updated)
+ expect(result.meals[0]?.name).toBe('Jantar')
+ })
+
+ it('should return the original DayDiet if mealId does not exist', () => {
+ const nonExistentMealId = 999
+ const updated = makeMeal(nonExistentMealId, 'Non Existent', [])
+ const result = updateMealInDayDiet(
+ baseDayDiet,
+ nonExistentMealId,
+ updated,
+ )
+ expect(result).toEqual(baseDayDiet)
+ })
+
+ it('should preserve other meals in the DayDiet', () => {
+ const meal2 = makeMeal(2, 'Café da Manhã', [makeUnifiedItem(2)])
+ const dayDietWithTwoMeals = promoteDayDiet(
+ createNewDayDiet({
+ target_day: '2023-01-01',
+ user_id: '1',
+ meals: [baseMeal, meal2],
+ }),
+ { id: 1 },
+ )
+ const updatedMeal1 = makeMeal(1, 'Almoço Atualizado', [baseItem])
+ const result = updateMealInDayDiet(dayDietWithTwoMeals, 1, updatedMeal1)
+
+ expect(result.meals).toHaveLength(2)
+ expect(result.meals[0]?.name).toBe('Almoço Atualizado')
+ expect(result.meals[1]).toEqual(meal2)
+ })
+
+ it('should preserve other properties of the DayDiet', () => {
+ const updated = makeMeal(1, 'Jantar', [baseItem])
+ const result = updateMealInDayDiet(baseDayDiet, 1, updated)
+
+ expect(result.target_day).toBe(baseDayDiet.target_day)
+ expect(result.user_id).toBe(baseDayDiet.user_id)
+ expect(result.id).toBe(baseDayDiet.id)
+ })
+ })
+})
diff --git a/src/modules/diet/day-diet/tests/domain/defaultMeals.test.ts b/src/modules/diet/day-diet/tests/domain/defaultMeals.test.ts
new file mode 100644
index 000000000..ecac5eb5f
--- /dev/null
+++ b/src/modules/diet/day-diet/tests/domain/defaultMeals.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ createDefaultMeals,
+ getDefaultMealNames,
+} from '~/modules/diet/day-diet/domain/defaultMeals'
+
+describe('Default Meals', () => {
+ describe('getDefaultMealNames', () => {
+ it('should return expected meal names for Brazilian users', () => {
+ const mealNames = getDefaultMealNames()
+
+ expect(mealNames).toEqual([
+ 'Café da manhã',
+ 'Almoço',
+ 'Lanche',
+ 'Janta',
+ 'Pós janta',
+ ])
+ })
+
+ it('should return readonly array', () => {
+ const mealNames = getDefaultMealNames()
+
+ // TypeScript compile-time check - should be readonly
+ expect(Array.isArray(mealNames)).toBe(true)
+ expect(mealNames.length).toBe(5)
+ })
+
+ it('should return consistent results on multiple calls', () => {
+ const firstCall = getDefaultMealNames()
+ const secondCall = getDefaultMealNames()
+
+ expect(firstCall).toEqual(secondCall)
+ })
+ })
+
+ describe('createDefaultMeals', () => {
+ it('should create meals with correct names and structure', () => {
+ const meals = createDefaultMeals()
+
+ expect(meals).toHaveLength(5)
+ expect(meals[0]?.name).toBe('Café da manhã')
+ expect(meals[1]?.name).toBe('Almoço')
+ expect(meals[2]?.name).toBe('Lanche')
+ expect(meals[3]?.name).toBe('Janta')
+ expect(meals[4]?.name).toBe('Pós janta')
+ })
+
+ it('should create meals with empty items arrays', () => {
+ const meals = createDefaultMeals()
+
+ meals.forEach((meal) => {
+ expect(meal.items).toEqual([])
+ expect(Array.isArray(meal.items)).toBe(true)
+ })
+ })
+
+ it('should create promoted meals with IDs', () => {
+ const meals = createDefaultMeals()
+
+ meals.forEach((meal) => {
+ expect(meal.id).toBeDefined()
+ expect(typeof meal.id).toBe('number')
+ expect(meal.__type).toBe('Meal')
+ })
+ })
+
+ it('should generate unique IDs for each meal', () => {
+ const meals = createDefaultMeals()
+ const ids = meals.map((meal) => meal.id)
+ const uniqueIds = new Set(ids)
+
+ expect(uniqueIds.size).toBe(meals.length)
+ })
+
+ it('should create fresh meals on each call', () => {
+ const firstBatch = createDefaultMeals()
+ const secondBatch = createDefaultMeals()
+
+ // IDs should be different (since generateId creates unique IDs)
+ const firstIds = firstBatch.map((meal) => meal.id)
+ const secondIds = secondBatch.map((meal) => meal.id)
+
+ expect(firstIds).not.toEqual(secondIds)
+
+ // But names should be the same
+ const firstNames = firstBatch.map((meal) => meal.name)
+ const secondNames = secondBatch.map((meal) => meal.name)
+ expect(firstNames).toEqual(secondNames)
+ })
+
+ it('should create meals with correct meal schema structure', () => {
+ const meals = createDefaultMeals()
+
+ meals.forEach((meal) => {
+ expect(meal).toHaveProperty('id')
+ expect(meal).toHaveProperty('name')
+ expect(meal).toHaveProperty('items')
+ expect(meal).toHaveProperty('__type', 'Meal')
+
+ expect(typeof meal.id).toBe('number')
+ expect(typeof meal.name).toBe('string')
+ expect(Array.isArray(meal.items)).toBe(true)
+ })
+ })
+ })
+
+ describe('Integration', () => {
+ it('should create meals using the same names returned by getDefaultMealNames', () => {
+ const mealNames = getDefaultMealNames()
+ const meals = createDefaultMeals()
+
+ const createdNames = meals.map((meal) => meal.name)
+ expect(createdNames).toEqual([...mealNames])
+ })
+ })
+})
diff --git a/src/modules/diet/food/application/food.ts b/src/modules/diet/food/application/usecases/foodCrud.ts
similarity index 57%
rename from src/modules/diet/food/application/food.ts
rename to src/modules/diet/food/application/usecases/foodCrud.ts
index ee8e312a1..7e33a8026 100644
--- a/src/modules/diet/food/application/food.ts
+++ b/src/modules/diet/food/application/usecases/foodCrud.ts
@@ -4,18 +4,15 @@ import {
importFoodFromApiByEan,
importFoodsFromApiByName,
} from '~/modules/diet/food/infrastructure/api/application/apiFood'
-import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/supabaseFoodRepository'
-import { isSearchCached } from '~/modules/search/application/searchCache'
+import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository'
+import { isSearchCached } from '~/modules/search/application/usecases/cachedSearchCrud'
import { showPromise } from '~/modules/toast/application/toastManager'
import { setBackendOutage } from '~/shared/error/backendOutageSignal'
-import {
- createErrorHandler,
- isBackendOutageError,
-} from '~/shared/error/errorHandler'
import { formatError } from '~/shared/formatError'
+import { isBackendOutageError } from '~/shared/utils/errorUtils'
+import { logging } from '~/shared/utils/logging'
const foodRepository = createSupabaseFoodRepository()
-const errorHandler = createErrorHandler('application', 'Food')
/**
* Fetches foods by search params.
@@ -28,33 +25,12 @@ export async function fetchFoods(
try {
return await foodRepository.fetchFoods(params)
} catch (error) {
- errorHandler.error(error)
+ logging.error('Food application error:', error)
if (isBackendOutageError(error)) setBackendOutage(true)
return []
}
}
-/**
- * Fetches a food by ID.
- * @param id - Food ID.
- * @param params - Search parameters.
- * @returns Food or null on error.
- */
-export async function fetchFoodById(
- id: Food['id'],
- params: FoodSearchParams = {},
-): Promise {
- try {
- return await foodRepository.fetchFoodById(id, params)
- } catch (error) {
- errorHandler.error(error, {
- entityId: id,
- })
- if (isBackendOutageError(error)) setBackendOutage(true)
- return null
- }
-}
-
/**
* Fetches foods by name, importing if not cached.
* @param name - Food name.
@@ -66,7 +42,9 @@ export async function fetchFoodsByName(
params: FoodSearchParams = {},
): Promise {
try {
- if (!(await isSearchCached(name))) {
+ const isCached = await isSearchCached(name)
+
+ if (!isCached) {
await showPromise(
importFoodsFromApiByName(name),
{
@@ -74,10 +52,11 @@ export async function fetchFoodsByName(
success: 'Alimentos importados com sucesso',
error: `Erro ao importar alimentos por nome: ${name}`,
},
- { context: 'background', audience: 'system' },
+ { context: 'background' },
)
}
- return await showPromise(
+
+ const foods = await showPromise(
foodRepository.fetchFoodsByName(name, params),
{
loading: 'Buscando alimentos por nome...',
@@ -85,12 +64,12 @@ export async function fetchFoodsByName(
error: (error: unknown) =>
`Erro ao buscar alimentos por nome: ${formatError(error)}`,
},
- { context: 'user-action', audience: 'user' },
+ { context: 'background' },
)
+
+ return foods
} catch (error) {
- errorHandler.error(error, {
- additionalData: { name },
- })
+ logging.error('Food application error:', error)
if (isBackendOutageError(error)) setBackendOutage(true)
return []
}
@@ -103,7 +82,7 @@ export async function fetchFoodsByName(
* @returns Food or null on error.
*/
export async function fetchFoodByEan(
- ean: Food['ean'],
+ ean: NonNullable,
params: FoodSearchParams = {},
): Promise {
try {
@@ -114,7 +93,7 @@ export async function fetchFoodByEan(
success: 'Alimento importado com sucesso',
error: `Erro ao importar alimento por EAN: ${ean}`,
},
- { context: 'background', audience: 'system' },
+ { context: 'background' },
)
return await showPromise(
foodRepository.fetchFoodByEan(ean, params),
@@ -124,52 +103,11 @@ export async function fetchFoodByEan(
error: (error: unknown) =>
`Erro ao buscar alimento por EAN: ${formatError(error)}`,
},
- { context: 'user-action', audience: 'user' },
+ { context: 'user-action' },
)
} catch (error) {
- errorHandler.error(error, {
- additionalData: { ean },
- })
+ logging.error('Food application error:', error)
if (isBackendOutageError(error)) setBackendOutage(true)
return null
}
}
-
-/**
- * Checks if a food EAN is cached.
- * @param ean - Food EAN.
- * @returns True if cached, false otherwise.
- */
-export async function isEanCached(
- ean: Required['ean'],
-): Promise {
- try {
- const cached = (await foodRepository.fetchFoodByEan(ean, {})) !== null
- return cached
- } catch (error) {
- errorHandler.error(error, {
- additionalData: { ean },
- })
- if (isBackendOutageError(error)) setBackendOutage(true)
- return false
- }
-}
-
-/**
- * Fetches foods by IDs.
- * @param ids - Array of food IDs.
- * @returns Array of foods or empty array on error.
- */
-export async function fetchFoodsByIds(
- ids: Food['id'][],
-): Promise {
- try {
- return await foodRepository.fetchFoodsByIds(ids)
- } catch (error) {
- errorHandler.error(error, {
- additionalData: { ids },
- })
- if (isBackendOutageError(error)) setBackendOutage(true)
- return []
- }
-}
diff --git a/src/modules/diet/food/domain/foodRepository.ts b/src/modules/diet/food/domain/foodRepository.ts
index aa9ef3bf6..525b813fc 100644
--- a/src/modules/diet/food/domain/foodRepository.ts
+++ b/src/modules/diet/food/domain/foodRepository.ts
@@ -1,9 +1,10 @@
import { type Food, type NewFood } from '~/modules/diet/food/domain/food'
+import { type User } from '~/modules/user/domain/user'
export type FoodSearchParams = {
limit?: number
allowedFoods?: number[]
- userId?: number
+ userId?: User['uuid']
isFavoritesSearch?: boolean
}
@@ -19,7 +20,7 @@ export type FoodRepository = {
params: FoodSearchParams,
) => Promise
fetchFoodByEan: (
- ean: Required['ean'],
+ ean: NonNullable['ean']>,
params: Omit,
) => Promise
diff --git a/src/modules/diet/food/infrastructure/api/application/apiFood.ts b/src/modules/diet/food/infrastructure/api/application/apiFood.ts
index 651c29562..9643bb02d 100644
--- a/src/modules/diet/food/infrastructure/api/application/apiFood.ts
+++ b/src/modules/diet/food/infrastructure/api/application/apiFood.ts
@@ -1,64 +1,58 @@
import axios from 'axios'
import { type Food } from '~/modules/diet/food/domain/food'
-import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodModel'
-import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/supabaseFoodRepository'
-import { markSearchAsCached } from '~/modules/search/application/searchCache'
+import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema'
+import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository'
+import { markSearchAsCached } from '~/modules/search/application/usecases/cachedSearchCrud'
import { showError } from '~/modules/toast/application/toastManager'
-import { createErrorHandler } from '~/shared/error/errorHandler'
import { convertApi2Food } from '~/shared/utils/convertApi2Food'
+import { ORIGINAL_ERROR_SYMBOL } from '~/shared/utils/errorUtils'
+import { logging } from '~/shared/utils/logging'
-// TODO: Depency injection for repositories on all application files
const foodRepository = createSupabaseFoodRepository()
-const errorHandler = createErrorHandler('infrastructure', 'Food')
-
export async function importFoodFromApiByEan(
ean: Food['ean'],
): Promise {
if (ean === null) {
- errorHandler.error(new Error('EAN is required to import food from API'), {
- additionalData: { ean },
- })
+ logging.error('EAN is required to import food from API:', { ean })
return null
}
- const apiFood = (await axios.get(`/api/food/ean/${ean}`))
- .data as unknown as ApiFood
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const apiFood = (await axios.get(`/api/food/ean/${ean}`)).data
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (apiFood.id === 0) {
- errorHandler.error(
- new Error(`Food with ean ${ean} not found on external api`),
- {
- additionalData: { ean },
- },
- )
+ logging.error(`Food with ean ${ean} not found on external api:`, { ean })
return null
}
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const food = convertApi2Food(apiFood)
const upsertedFood = await foodRepository.upsertFood(food)
return upsertedFood
}
export async function importFoodsFromApiByName(name: string): Promise {
- console.debug(`[ApiFood] Importing foods with name "${name}"`)
- const apiFoods = (await axios.get(`/api/food/name/${name}`))
- .data as unknown as ApiFood[]
+ logging.debug(`Importing foods with name "${name}"`)
+
+ const apiFoods = (await axios.get(`/api/food/name/${name}`)).data
if (apiFoods.length === 0) {
showError(`Nenhum alimento encontrado para "${name}"`)
return []
}
- console.debug(`[ApiFood] Found ${apiFoods.length} foods`)
+ logging.debug(`Found ${apiFoods.length} foods`)
+
const foodsToupsert = apiFoods.map(convertApi2Food)
const upsertPromises = foodsToupsert.map(foodRepository.upsertFood)
const upsertionResults = await Promise.allSettled(upsertPromises)
- console.debug(
- `[ApiFood] upserted ${upsertionResults.length} foods. ${
+ logging.debug(
+ `upserted ${upsertionResults.length} foods. ${
upsertionResults.filter((result) => result.status === 'fulfilled').length
} succeeded, ${
upsertionResults.filter((result) => result.status === 'rejected').length
@@ -66,19 +60,21 @@ export async function importFoodsFromApiByName(name: string): Promise {
)
if (upsertionResults.some((result) => result.status === 'rejected')) {
+ logging.debug(`Erros de upsert: `, { upsertionResults })
const allRejected = upsertionResults.filter(
- (result): result is PromiseRejectedResult => result.status === 'rejected',
+ (result) => result.status === 'rejected',
)
- type Reason = { code: string }
const reasons = allRejected.map((result) => {
- const reason: unknown = result.reason
- if (typeof reason === 'object' && reason !== null && 'code' in reason) {
- return reason as Reason
- }
- return { code: 'unknown' }
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ const reason: Error = result.reason as unknown as Error
+ return reason
})
- const errors = reasons.map((reason) => reason.code)
+ const errors = reasons.map(
+ // eslint-disable-next-line
+ (reason) => (reason as any)[ORIGINAL_ERROR_SYMBOL].code as string,
+ )
+ logging.debug(`Readable errors:`, { errors })
const ignoredErrors = [
'23505', // Unique violation: food already exists, ignore
@@ -89,26 +85,24 @@ export async function importFoodsFromApiByName(name: string): Promise {
)
if (relevantErrors.length > 0) {
- errorHandler.error(
- new Error(`Failed to upsert ${relevantErrors.length} foods`),
- {
- operation: 'searchAndUpsertFoodsByNameFromApi',
-
- additionalData: {
- name,
- relevantErrors,
- errorCount: relevantErrors.length,
- },
- },
- )
+ logging.debug(`Relevant errors:`, { relevantErrors })
+ logging.error(`Failed to upsert ${relevantErrors.length} foods:`, {
+ operation: 'searchAndUpsertFoodsByNameFromApi',
+ name,
+ relevantErrors,
+ errorCount: relevantErrors.length,
+ })
showError(
`Erro ao importar alguns alimentos: ${relevantErrors.length} falhas. Verifique o console para mais detalhes.`,
- { context: 'background' },
+ { context: 'user-action' },
)
+ } else {
+ logging.debug('No RELEVANT failed upsertions, marking search as cached')
+ await markSearchAsCached(name)
}
} else {
- console.debug('[ApiFood] No failed upsertions, marking search as cached')
+ logging.debug('No failed upsertions, marking search as cached')
await markSearchAsCached(name)
}
@@ -119,9 +113,7 @@ export async function importFoodsFromApiByName(name: string): Promise {
)
.map((result) => result.value)
- console.debug(
- `[ApiFood] Returning ${upsertedFoods.length}/${apiFoods.length} foods`,
- )
+ logging.debug(` Returning ${upsertedFoods.length}/${apiFoods.length} foods`)
return upsertedFoods.filter((food): food is Food => food !== null)
}
diff --git a/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts b/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts
index fd74a6097..a17b79c5b 100644
--- a/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts
+++ b/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts
@@ -1,4 +1,4 @@
-import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodModel'
+import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema'
export type ApiFoodRepository = {
fetchApiFoods: () => Promise
diff --git a/src/modules/diet/food/infrastructure/api/domain/apiFoodModel.ts b/src/modules/diet/food/infrastructure/api/domain/apiFoodSchema.ts
similarity index 100%
rename from src/modules/diet/food/infrastructure/api/domain/apiFoodModel.ts
rename to src/modules/diet/food/infrastructure/api/domain/apiFoodSchema.ts
diff --git a/src/modules/diet/food/infrastructure/api/infrastructure/apiFoodRepository.ts b/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts
similarity index 77%
rename from src/modules/diet/food/infrastructure/api/infrastructure/apiFoodRepository.ts
rename to src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts
index 884845e85..10bcb22c4 100644
--- a/src/modules/diet/food/infrastructure/api/infrastructure/apiFoodRepository.ts
+++ b/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts
@@ -10,16 +10,14 @@ import {
EXTERNAL_API_HOST,
EXTERNAL_API_REFERER,
} from '~/modules/diet/api/constants/apiSecrets'
+import { type ApiFoodRepository } from '~/modules/diet/food/infrastructure/api/domain/apiFoodRepository'
import {
type ApiFood,
apiFoodSchema,
-} from '~/modules/diet/food/infrastructure/api/domain/apiFoodModel'
-import { type ApiFoodRepository } from '~/modules/diet/food/infrastructure/api/domain/apiFoodRepository'
-import {
- createErrorHandler,
- wrapErrorWithStack,
-} from '~/shared/error/errorHandler'
+} from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema'
+import { wrapErrorWithStack } from '~/shared/utils/errorUtils'
import { jsonParseWithStack } from '~/shared/utils/jsonParseWithStack'
+import { logging } from '~/shared/utils/logging'
import { parseWithStack } from '~/shared/utils/parseWithStack'
const API = rateLimit(axios.create(), {
@@ -28,8 +26,6 @@ const API = rateLimit(axios.create(), {
maxRPS: 2,
})
-const errorHandler = createErrorHandler('infrastructure', 'Food')
-
export function createApiFoodRepository(): ApiFoodRepository {
return {
fetchApiFoodByEan,
@@ -51,7 +47,7 @@ async function fetchApiFoodsByName(
try {
parsedParams = jsonParseWithStack(EXTERNAL_API_FOOD_PARAMS)
} catch (err) {
- errorHandler.error(err)
+ logging.error('API food parse error:', err)
parsedParams = {}
}
const params =
@@ -76,22 +72,25 @@ async function fetchApiFoodsByName(
},
}
- console.debug(`[ApiFood] Fetching foods with name from url ${url}`, config)
+ logging.debug(`[ApiFood] Fetching foods with name from url ${url}`, config)
let response
try {
response = await API.get(url, config)
} catch (error) {
- errorHandler.error(error)
+ logging.error('API food fetch error:', error)
throw wrapErrorWithStack(error)
}
- console.debug(`[ApiFood] Response from url ${url}`, response.data)
+ logging.debug(`[ApiFood] Response from url ${url}`, { response })
- const data = response.data as Record
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const data = response.data
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const alimentosRaw = data.alimentos
if (!Array.isArray(alimentosRaw)) {
- errorHandler.error(new Error('Invalid alimentos array in API response'), {
- additionalData: { url, dataType: typeof alimentosRaw },
+ logging.error('Invalid alimentos array in API response:', {
+ url,
+ dataType: typeof alimentosRaw,
})
return []
}
@@ -114,7 +113,7 @@ async function fetchApiFoodByEan(
'user-agent': 'okhttp/4.9.2',
},
})
- console.log(response.data)
- console.dir(response.data)
+
+ logging.debug('response=', { response })
return parseWithStack(apiFoodSchema, response.data)
}
diff --git a/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts
new file mode 100644
index 000000000..0c8cb5eed
--- /dev/null
+++ b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts
@@ -0,0 +1,43 @@
+import {
+ type Food,
+ foodSchema,
+ type NewFood,
+} from '~/modules/diet/food/domain/food'
+import { type Database } from '~/shared/supabase/database.types'
+import { parseWithStack } from '~/shared/utils/parseWithStack'
+
+export type FoodDTO = Database['public']['Tables']['foods']['Row']
+export type InsertFoodDTO = Database['public']['Tables']['foods']['Insert']
+export type UpdateFoodDTO = Database['public']['Tables']['foods']['Update']
+
+function toDomain(dao: FoodDTO): Food {
+ return parseWithStack(foodSchema, {
+ ...dao,
+ ean: dao.ean ?? null,
+ source: dao.source ?? undefined,
+ })
+}
+
+function toInsertDTO(newFood: NewFood): InsertFoodDTO {
+ return {
+ name: newFood.name,
+ ean: newFood.ean ?? null,
+ macros: newFood.macros,
+ source: newFood.source ?? null,
+ }
+}
+
+function toUpdateDTO(food: Food): UpdateFoodDTO {
+ return {
+ name: food.name,
+ ean: food.ean ?? null,
+ macros: food.macros,
+ source: food.source ?? null,
+ }
+}
+
+export const supabaseFoodMapper = {
+ toDomain,
+ toInsertDTO,
+ toUpdateDTO,
+}
diff --git a/src/modules/diet/food/infrastructure/supabaseFoodRepository.ts b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts
similarity index 64%
rename from src/modules/diet/food/infrastructure/supabaseFoodRepository.ts
rename to src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts
index c642c84f4..3933757a3 100644
--- a/src/modules/diet/food/infrastructure/supabaseFoodRepository.ts
+++ b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts
@@ -3,24 +3,12 @@ import {
type FoodRepository,
type FoodSearchParams,
} from '~/modules/diet/food/domain/foodRepository'
-import {
- createFoodFromDAO,
- createInsertFoodDAOFromNewFood,
- foodDAOSchema,
-} from '~/modules/diet/food/infrastructure/foodDAO'
-import {
- createErrorHandler,
- wrapErrorWithStack,
-} from '~/shared/error/errorHandler'
+import { supabaseFoodMapper } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper'
+import { SUPABASE_TABLE_FOODS } from '~/modules/diet/food/infrastructure/supabase/constants'
+import { supabase } from '~/shared/supabase/supabase'
import { isSupabaseDuplicateEanError } from '~/shared/supabase/supabaseErrorUtils'
-import { createDebug } from '~/shared/utils/createDebug'
-import { parseWithStack } from '~/shared/utils/parseWithStack'
-import supabase from '~/shared/utils/supabase'
-
-const debug = createDebug()
-const errorHandler = createErrorHandler('infrastructure', 'Food')
-
-const TABLE = 'foods'
+import { wrapErrorWithStack } from '~/shared/utils/errorUtils'
+import { logging } from '~/shared/utils/logging'
export function createSupabaseFoodRepository(): FoodRepository {
return {
@@ -52,12 +40,12 @@ async function fetchFoodById(
{ ...params, limit: 1 },
)
if (foods.length === 0 || foods[0] === undefined) {
- errorHandler.error(new Error('Food not found'))
+ logging.error('Food not found')
throw new Error('Food not found')
}
return foods[0]
} catch (err) {
- errorHandler.error(err)
+ logging.error('Food fetch error:', err)
throw err
}
}
@@ -71,7 +59,7 @@ async function fetchFoodById(
* @throws Error if not found or on API/validation error
*/
async function fetchFoodByEan(
- ean: Required['ean'],
+ ean: NonNullable['ean']>,
params: Omit = {},
): Promise {
try {
@@ -80,12 +68,12 @@ async function fetchFoodByEan(
{ ...params, limit: 1 },
)
if (foods.length === 0 || foods[0] === undefined) {
- errorHandler.error(new Error('Food not found'))
+ logging.error('Food not found')
throw new Error('Food not found')
}
return foods[0]
} catch (err) {
- errorHandler.error(err)
+ logging.error('Food fetch error:', err)
throw err
}
}
@@ -98,25 +86,22 @@ async function fetchFoodByEan(
* @throws Error if not created or on API/validation error
*/
async function insertFood(newFood: NewFood): Promise {
- const createDAO = createInsertFoodDAOFromNewFood(newFood)
+ const insertDTO = supabaseFoodMapper.toInsertDTO(newFood)
const { data, error } = await supabase
- .from(TABLE)
- .insert(createDAO)
- .select('*')
+ .from(SUPABASE_TABLE_FOODS)
+ .insert(insertDTO)
+ .select()
+ .single()
+
if (error !== null) {
if (isSupabaseDuplicateEanError(error, newFood.ean)) {
return await fetchFoodByEan(newFood.ean)
}
- errorHandler.error(error)
+ logging.error('Food insert error:', error)
throw wrapErrorWithStack(error)
}
- const foodDAOs = parseWithStack(foodDAOSchema.array(), data)
- const foods = foodDAOs.map(createFoodFromDAO)
- if (foods.length === 0 || foods[0] === undefined) {
- errorHandler.error(new Error('Food not created'))
- throw new Error('Food not created')
- }
- return foods[0]
+
+ return supabaseFoodMapper.toDomain(data)
}
/**
@@ -127,40 +112,36 @@ async function insertFood(newFood: NewFood): Promise {
* @throws Error if not created or on API/validation error
*/
async function upsertFood(newFood: NewFood): Promise {
- const createDAO = createInsertFoodDAOFromNewFood(newFood)
- const { data, error } = await supabase
- .from(TABLE)
+ const createDAO = supabaseFoodMapper.toInsertDTO(newFood)
+ const { data: food, error } = await supabase
+ .from(SUPABASE_TABLE_FOODS)
.upsert(createDAO)
- .select('*')
+ .select()
+ .single()
+
if (error !== null) {
if (isSupabaseDuplicateEanError(error, newFood.ean)) {
return await fetchFoodByEan(newFood.ean)
}
- errorHandler.error(error)
+ logging.error('Food insert error:', error)
throw wrapErrorWithStack(error)
}
- const foodDAOs = parseWithStack(foodDAOSchema.array(), data)
- const foods = foodDAOs.map(createFoodFromDAO)
- if (foods.length === 0 || foods[0] === undefined) {
- errorHandler.error(new Error('Food not created'))
- throw new Error('Food not created')
- }
- return foods[0]
+
+ return supabaseFoodMapper.toDomain(food)
}
async function fetchFoodsByName(
name: Required['name'],
params: FoodSearchParams = {},
) {
- try {
- // Use optimized favorites search if userId and isFavoritesSearch are provided
- const { userId, isFavoritesSearch, limit = 50 } = params
+ const { userId, isFavoritesSearch, limit = 50 } = params
+ try {
let result
if (isFavoritesSearch === true && userId !== undefined) {
// Search within favorites only using optimized RPC
result = await supabase.rpc('search_favorite_foods_with_scoring', {
- p_user_id: userId,
+ p_user_uuid: userId,
p_search_term: name,
p_limit: limit,
})
@@ -173,26 +154,20 @@ async function fetchFoodsByName(
}
if (result.error !== null) {
- errorHandler.error(result.error)
+ logging.error('Food search error:', result.error)
throw wrapErrorWithStack(result.error)
}
- if (result.data === null || result.data === undefined) {
- debug('No data returned from enhanced search')
- return []
- }
-
+ const resultsCount = Array.isArray(result.data) ? result.data.length : 0
const searchType =
isFavoritesSearch === true && userId !== undefined
? 'favorites search'
: 'enhanced search'
- debug(
- `Found ${Array.isArray(result.data) ? result.data.length : 0} foods using ${searchType}`,
- )
- const foodDAOs = parseWithStack(foodDAOSchema.array(), result.data)
- return foodDAOs.map(createFoodFromDAO)
+
+ logging.debug(`Found ${resultsCount} foods using ${searchType}`)
+ return result.data.map(supabaseFoodMapper.toDomain)
} catch (err) {
- errorHandler.error(err)
+ logging.error('Food search error:', err)
throw err
}
}
@@ -209,7 +184,7 @@ async function internalCachedSearchFoods(
}:
| {
field: 'ean' | 'id' | 'name'
- value: Food['ean' | 'id' | 'name']
+ value: NonNullable
operator?: 'eq' | 'ilike'
}
| {
@@ -219,14 +194,14 @@ async function internalCachedSearchFoods(
},
params?: FoodSearchParams,
): Promise {
- debug(
+ logging.debug(
`Searching for foods with ${field} = ${value} (limit: ${
params?.limit ?? 'none'
})`,
)
const { limit, allowedFoods } = params ?? {}
const base = supabase
- .from(TABLE)
+ .from(SUPABASE_TABLE_FOODS)
.select('*')
.not('name', 'eq', '')
.not('name', 'eq', '.')
@@ -244,29 +219,28 @@ async function internalCachedSearchFoods(
query = query.ilike(field, `%${normalizedValue}%`)
break
default:
- ;((_: never) => _)(operator) // TODO: Create a better function for exhaustive checks
+ operator satisfies never
}
}
if (allowedFoods !== undefined) {
- debug('Limiting search to allowed foods')
+ logging.debug('Limiting search to allowed foods')
query = query.in('id', allowedFoods)
}
if (limit !== undefined) {
- debug(`Limiting search to ${limit} results`)
+ logging.debug(`Limiting search to ${limit} results`)
query = query.limit(limit)
}
- const { data, error } = await query
+ const { data: foods, error } = await query
if (error !== null) {
- errorHandler.error(error)
+ logging.error('Food insert error:', error)
throw wrapErrorWithStack(error)
}
- debug(`Found ${data.length} foods`)
- const foodDAOs = parseWithStack(foodDAOSchema.array(), data)
- return foodDAOs.map(createFoodFromDAO)
+ logging.debug(`Found ${foods.length} foods`)
+ return foods.map(supabaseFoodMapper.toDomain)
}
/**
@@ -276,11 +250,15 @@ async function internalCachedSearchFoods(
*/
async function fetchFoodsByIds(ids: Food['id'][]): Promise {
if (!Array.isArray(ids) || ids.length === 0) return []
- const { data, error } = await supabase.from(TABLE).select('*').in('id', ids)
+ const { data: foods, error } = await supabase
+ .from(SUPABASE_TABLE_FOODS)
+ .select('*')
+ .in('id', ids)
+
if (error !== null) {
- errorHandler.error(error)
+ logging.error('Food insert error:', error)
throw wrapErrorWithStack(error)
}
- const foodDAOs = parseWithStack(foodDAOSchema.array(), data)
- return foodDAOs.map(createFoodFromDAO)
+
+ return foods.map(supabaseFoodMapper.toDomain)
}
diff --git a/src/modules/diet/food/infrastructure/foodDAO.ts b/src/modules/diet/food/infrastructure/foodDAO.ts
deleted file mode 100644
index c218445d4..000000000
--- a/src/modules/diet/food/infrastructure/foodDAO.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { z } from 'zod/v4'
-
-import {
- type Food,
- foodSchema,
- type NewFood,
-} from '~/modules/diet/food/domain/food'
-import { macroNutrientsSchema } from '~/modules/diet/macro-nutrients/domain/macroNutrients'
-import { parseWithStack } from '~/shared/utils/parseWithStack'
-
-// Base schema (with ID)
-export const foodDAOSchema = z.object({
- id: z.number(),
- name: z.string(),
- ean: z.string().nullable().optional(),
- macros: macroNutrientsSchema,
- source: z
- .object({
- type: z.literal('api'),
- id: z.string(),
- })
- .nullable()
- .optional(),
-})
-
-// Schema for creation (without ID)
-export const createFoodDAOSchema = foodDAOSchema.omit({ id: true })
-
-// Schema for update (optional fields, without ID)
-export const updateFoodDAOSchema = foodDAOSchema.omit({ id: true }).partial()
-
-// Types
-export type FoodDAO = z.infer
-export type CreateFoodDAO = z.infer
-export type UpdateFoodDAO = z.infer
-
-// Conversions
-export function createFoodDAO(food: Food): FoodDAO {
- return parseWithStack(foodDAOSchema, {
- id: food.id,
- name: food.name,
- ean: food.ean ?? null,
- macros: food.macros,
- source: food.source ?? null,
- })
-}
-
-export function createInsertFoodDAO(
- food: Omit,
-): CreateFoodDAO {
- return parseWithStack(createFoodDAOSchema, {
- name: food.name,
- ean: food.ean ?? null,
- macros: food.macros,
- source: food.source ?? null,
- })
-}
-
-export function createFoodFromDAO(dao: FoodDAO): Food {
- return parseWithStack(foodSchema, {
- ...dao,
- ean: dao.ean ?? null,
- source: dao.source ?? undefined,
- })
-}
-
-export function createInsertFoodDAOFromNewFood(
- newFood: NewFood,
-): CreateFoodDAO {
- return parseWithStack(createFoodDAOSchema, {
- name: newFood.name,
- ean: newFood.ean ?? null,
- macros: newFood.macros,
- source: newFood.source ?? null,
- })
-}
-
-export function createUpdateFoodDAOFromFood(food: Food): UpdateFoodDAO {
- return parseWithStack(updateFoodDAOSchema, {
- name: food.name,
- ean: food.ean ?? null,
- macros: food.macros,
- source: food.source ?? null,
- })
-}
diff --git a/src/modules/diet/food/infrastructure/supabase/constants.ts b/src/modules/diet/food/infrastructure/supabase/constants.ts
new file mode 100644
index 000000000..9c93cce98
--- /dev/null
+++ b/src/modules/diet/food/infrastructure/supabase/constants.ts
@@ -0,0 +1 @@
+export const SUPABASE_TABLE_FOODS = 'foods'
diff --git a/src/modules/diet/macro-nutrients/domain/macroNutrients.ts b/src/modules/diet/macro-nutrients/domain/macroNutrients.ts
index 533f9353f..d3b7b8403 100644
--- a/src/modules/diet/macro-nutrients/domain/macroNutrients.ts
+++ b/src/modules/diet/macro-nutrients/domain/macroNutrients.ts
@@ -5,7 +5,7 @@ import { parseWithStack } from '~/shared/utils/parseWithStack'
const ze = createZodEntity('MacroNutrients')
-// TODO: Use macroNutrientsSchema for other schemas that need macro nutrients
+// TODO: Use macroNutrientsSchema for other schemas that need macro nutrients
const macronutrientsEntity = ze.create(
{
carbs: ze
diff --git a/src/modules/diet/macro-profile/application/macroProfile.ts b/src/modules/diet/macro-profile/application/macroProfile.ts
deleted file mode 100644
index d2c165c07..000000000
--- a/src/modules/diet/macro-profile/application/macroProfile.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import { createResource } from 'solid-js'
-
-import {
- type MacroProfile,
- type NewMacroProfile,
-} from '~/modules/diet/macro-profile/domain/macroProfile'
-import {
- createSupabaseMacroProfileRepository,
- SUPABASE_TABLE_MACRO_PROFILES,
-} from '~/modules/diet/macro-profile/infrastructure/supabaseMacroProfileRepository'
-import { showPromise } from '~/modules/toast/application/toastManager'
-import { currentUserId } from '~/modules/user/application/user'
-import { createErrorHandler } from '~/shared/error/errorHandler'
-import { getLatestMacroProfile } from '~/shared/utils/macroProfileUtils'
-import { registerSubapabaseRealtimeCallback } from '~/shared/utils/supabase'
-
-const macroProfileRepository = createSupabaseMacroProfileRepository()
-
-export const [userMacroProfiles, { refetch: refetchUserMacroProfiles }] =
- createResource(currentUserId, fetchUserMacroProfiles, {
- initialValue: [],
- ssrLoadFrom: 'initial',
- })
-
-export const latestMacroProfile = () =>
- getLatestMacroProfile(userMacroProfiles.latest)
-
-export const previousMacroProfile = () =>
- getLatestMacroProfile(userMacroProfiles.latest, 1)
-
-registerSubapabaseRealtimeCallback(SUPABASE_TABLE_MACRO_PROFILES, () => {
- void refetchUserMacroProfiles()
-})
-
-/**
- * Fetches all macro profiles for a user.
- * @param userId - The user ID.
- * @returns Array of macro profiles or empty array on error.
- */
-async function fetchUserMacroProfiles(
- userId: number,
-): Promise {
- try {
- return await showPromise(
- macroProfileRepository.fetchUserMacroProfiles(userId),
- {
- loading: 'Carregando perfis de macro...',
- success: 'Perfis de macro carregados com sucesso',
- error: 'Falha ao carregar perfis de macro',
- },
- {
- context: 'background',
- },
- )
- } catch (error) {
- errorHandler.error(error)
- return []
- }
-}
-
-/**
- * Inserts a new macro profile.
- * @param newMacroProfile - The new macro profile data.
- * @returns The inserted macro profile or null on error.
- */
-const errorHandler = createErrorHandler('application', 'MacroProfile')
-
-export async function insertMacroProfile(
- newMacroProfile: NewMacroProfile,
-): Promise {
- try {
- const macroProfile = await showPromise(
- macroProfileRepository.insertMacroProfile(newMacroProfile),
- {
- loading: 'Criando perfil de macro...',
- success: 'Perfil de macro criado com sucesso',
- error: 'Falha ao criar perfil de macro',
- },
- { context: 'user-action', audience: 'user' },
- )
- const userProfiles = userMacroProfiles()
- const hasResult = macroProfile !== null
- const hasNoProfiles = userProfiles.length === 0
- const firstProfile = userProfiles[0]
- const isSameOwner =
- !hasNoProfiles &&
- macroProfile !== null &&
- firstProfile !== undefined &&
- macroProfile.owner === firstProfile.owner
- if (hasResult && (hasNoProfiles || isSameOwner)) {
- await fetchUserMacroProfiles(macroProfile.owner)
- }
- return macroProfile
- } catch (error) {
- errorHandler.error(error)
- return null
- }
-}
-
-/**
- * Updates a macro profile by ID.
- * @param macroProfileId - The macro profile ID.
- * @param newMacroProfile - The new macro profile data.
- * @returns The updated macro profile or null on error.
- */
-export async function updateMacroProfile(
- macroProfileId: MacroProfile['id'],
- newMacroProfile: NewMacroProfile,
-): Promise {
- try {
- const macroProfile = await showPromise(
- macroProfileRepository.updateMacroProfile(
- macroProfileId,
- newMacroProfile,
- ),
- {
- loading: 'Atualizando perfil de macro...',
- success: 'Perfil de macro atualizado com sucesso',
- error: 'Falha ao atualizar perfil de macro',
- },
- { context: 'user-action', audience: 'user' },
- )
- const firstUserMacroProfile = userMacroProfiles()[0]
- const hasResult = macroProfile !== null
- const hasFirstProfile = firstUserMacroProfile !== undefined
- const isSameOwner =
- hasResult &&
- hasFirstProfile &&
- macroProfile.owner === firstUserMacroProfile.owner
- if (isSameOwner) {
- await fetchUserMacroProfiles(macroProfile.owner)
- }
- return macroProfile
- } catch (error) {
- errorHandler.error(error)
- return null
- }
-}
-
-/**
- * Deletes a macro profile by ID.
- * @param macroProfileId - The macro profile ID.
- * @returns True if deleted, false otherwise.
- */
-export async function deleteMacroProfile(
- macroProfileId: number,
-): Promise {
- try {
- await showPromise(
- macroProfileRepository.deleteMacroProfile(macroProfileId),
- {
- loading: 'Deletando perfil de macro...',
- success: 'Perfil de macro deletado com sucesso',
- error: 'Falha ao deletar perfil de macro',
- },
- { context: 'user-action', audience: 'user' },
- )
- const [first] = userMacroProfiles()
- if (first) {
- await fetchUserMacroProfiles(first.owner)
- }
- return true
- } catch (error) {
- errorHandler.error(error)
- return false
- }
-}
diff --git a/src/modules/diet/macro-profile/application/usecases/macroProfileCrud.ts b/src/modules/diet/macro-profile/application/usecases/macroProfileCrud.ts
new file mode 100644
index 000000000..c4a1db414
--- /dev/null
+++ b/src/modules/diet/macro-profile/application/usecases/macroProfileCrud.ts
@@ -0,0 +1,58 @@
+import {
+ type MacroProfile,
+ type NewMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { createMacroProfileRepository } from '~/modules/diet/macro-profile/infrastructure/macroProfileRepository'
+import { showPromise } from '~/modules/toast/application/toastManager'
+import { type User } from '~/modules/user/domain/user'
+
+const macroProfileRepository = createMacroProfileRepository()
+
+export async function fetchUserMacroProfiles(
+ userId: User['uuid'],
+): Promise {
+ return await macroProfileRepository.fetchUserMacroProfiles(userId)
+}
+
+export async function insertMacroProfile(
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ return await showPromise(
+ macroProfileRepository.insertMacroProfile(newMacroProfile),
+ {
+ loading: 'Criando perfil de macro...',
+ success: 'Perfil de macro criado com sucesso',
+ error: 'Erro ao criar perfil de macro',
+ },
+ { context: 'user-action' },
+ )
+}
+
+export async function updateMacroProfile(
+ macroProfileId: MacroProfile['id'],
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ return await showPromise(
+ macroProfileRepository.updateMacroProfile(macroProfileId, newMacroProfile),
+ {
+ loading: 'Atualizando perfil de macro...',
+ success: 'Perfil de macro atualizado com sucesso',
+ error: 'Erro ao atualizar perfil de macro',
+ },
+ { context: 'user-action' },
+ )
+}
+
+export async function deleteMacroProfile(
+ macroProfileId: MacroProfile['id'],
+): Promise {
+ await showPromise(
+ macroProfileRepository.deleteMacroProfile(macroProfileId),
+ {
+ loading: 'Deletando perfil de macro...',
+ success: 'Perfil de macro deletado com sucesso',
+ error: 'Erro ao deletar perfil de macro',
+ },
+ { context: 'user-action' },
+ )
+}
diff --git a/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts b/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts
new file mode 100644
index 000000000..5c92e3daa
--- /dev/null
+++ b/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts
@@ -0,0 +1,34 @@
+import {
+ createDefaultMacroProfile,
+ getLatestMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfileOperations'
+import { macroProfileCacheStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore'
+import { initializeMacroProfileEffects } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileEffects'
+import { macroProfileStateStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileStateStore'
+import { initializeMacroProfileRealtime } from '~/modules/diet/macro-profile/infrastructure/supabase/realtime'
+import { currentUserId } from '~/modules/user/application/user'
+
+export const selectedUserId = macroProfileStateStore.selectedUserId
+export const setSelectedUserId = macroProfileStateStore.setSelectedUserId
+
+export const userMacroProfiles = () => {
+ const userId = currentUserId()
+ return macroProfileCacheStore.getProfilesByUserId(userId)
+}
+
+export const latestMacroProfile = () => {
+ const profiles = userMacroProfiles()
+ const latest = getLatestMacroProfile(profiles)
+ if (latest === null) {
+ return createDefaultMacroProfile(currentUserId())
+ }
+ return latest
+}
+
+export const previousMacroProfile = () => {
+ const profiles = userMacroProfiles()
+ return getLatestMacroProfile(profiles, 1)
+}
+
+initializeMacroProfileEffects()
+initializeMacroProfileRealtime()
diff --git a/src/modules/diet/macro-profile/domain/macroProfile.ts b/src/modules/diet/macro-profile/domain/macroProfile.ts
index 94ee73bbc..36c14df61 100644
--- a/src/modules/diet/macro-profile/domain/macroProfile.ts
+++ b/src/modules/diet/macro-profile/domain/macroProfile.ts
@@ -11,7 +11,7 @@ export const {
promote: promoteToMacroProfile,
demote: demoteToNewMacroProfile,
} = ze.create({
- owner: ze.number(),
+ user_id: ze.string(),
target_day: z
.date()
.or(z.string())
diff --git a/src/modules/diet/macro-profile/domain/macroProfileGateway.ts b/src/modules/diet/macro-profile/domain/macroProfileGateway.ts
new file mode 100644
index 000000000..779a0d14c
--- /dev/null
+++ b/src/modules/diet/macro-profile/domain/macroProfileGateway.ts
@@ -0,0 +1,19 @@
+import {
+ type MacroProfile,
+ type NewMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type User } from '~/modules/user/domain/user'
+
+export type MacroProfileGateway = {
+ fetchUserMacroProfiles: (
+ userId: User['uuid'],
+ ) => Promise
+ insertMacroProfile: (
+ newMacroProfile: NewMacroProfile,
+ ) => Promise
+ updateMacroProfile: (
+ macroProfileId: MacroProfile['id'],
+ newMacroProfile: NewMacroProfile,
+ ) => Promise
+ deleteMacroProfile: (id: MacroProfile['id']) => Promise
+}
diff --git a/src/modules/diet/macro-profile/domain/macroProfileOperations.ts b/src/modules/diet/macro-profile/domain/macroProfileOperations.ts
new file mode 100644
index 000000000..e75a283b8
--- /dev/null
+++ b/src/modules/diet/macro-profile/domain/macroProfileOperations.ts
@@ -0,0 +1,83 @@
+import { type MacroProfile } from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type User } from '~/modules/user/domain/user'
+import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils'
+
+export function getLatestMacroProfile(
+ profiles: readonly MacroProfile[],
+ offset: number = 0,
+): MacroProfile | null {
+ if (profiles.length === 0) return null
+
+ const sortedProfiles = [...profiles].sort(
+ (a, b) =>
+ new Date(b.target_day).getTime() - new Date(a.target_day).getTime(),
+ )
+
+ return sortedProfiles[offset] ?? null
+}
+
+export function getMacroProfileByDate(
+ profiles: readonly MacroProfile[],
+ targetDate: Date,
+): MacroProfile | null {
+ return (
+ profiles.find((profile) => {
+ const profileDate = new Date(profile.target_day)
+ return profileDate.toDateString() === targetDate.toDateString()
+ }) ?? null
+ )
+}
+
+export function calculateTotalMacros(
+ profile: MacroProfile,
+ bodyWeightKg: number,
+): {
+ totalCarbs: number
+ totalProtein: number
+ totalFat: number
+ totalCalories: number
+} {
+ const totalCarbs = profile.gramsPerKgCarbs * bodyWeightKg
+ const totalProtein = profile.gramsPerKgProtein * bodyWeightKg
+ const totalFat = profile.gramsPerKgFat * bodyWeightKg
+
+ // 1g carbs = 4 kcal, 1g protein = 4 kcal, 1g fat = 9 kcal
+ const totalCalories = totalCarbs * 4 + totalProtein * 4 + totalFat * 9
+
+ return {
+ totalCarbs,
+ totalProtein,
+ totalFat,
+ totalCalories,
+ }
+}
+
+export function getFirstMacroProfile(
+ profiles: readonly MacroProfile[],
+): MacroProfile | null {
+ if (profiles.length === 0) return null
+ return profiles[0] ?? null
+}
+
+export function inForceMacroProfile(
+ profiles: readonly MacroProfile[],
+ date: Date,
+): MacroProfile | null {
+ const reversedProfiles = [...profiles].reverse()
+ const found = reversedProfiles.find(
+ (profile) => new Date(profile.target_day).getTime() <= date.getTime(),
+ )
+ return found === undefined ? null : found
+}
+
+export function createDefaultMacroProfile(userId: User['uuid']): MacroProfile {
+ return {
+ id: -1,
+ user_id: userId,
+ target_day: new Date(getTodayYYYYMMDD()),
+ gramsPerKgCarbs: 0,
+ gramsPerKgProtein: 0,
+ gramsPerKgFat: 0,
+ __type: 'MacroProfile',
+ }
+}
diff --git a/src/modules/diet/macro-profile/domain/macroProfileRepository.ts b/src/modules/diet/macro-profile/domain/macroProfileRepository.ts
index 6fae45b1e..095451240 100644
--- a/src/modules/diet/macro-profile/domain/macroProfileRepository.ts
+++ b/src/modules/diet/macro-profile/domain/macroProfileRepository.ts
@@ -6,7 +6,7 @@ import { type User } from '~/modules/user/domain/user'
export type MacroProfileRepository = {
fetchUserMacroProfiles: (
- userId: User['id'],
+ userId: User['uuid'],
) => Promise
insertMacroProfile: (
newMacroProfile: NewMacroProfile,
diff --git a/src/modules/diet/macro-profile/infrastructure/macroProfileDAO.ts b/src/modules/diet/macro-profile/infrastructure/macroProfileDAO.ts
deleted file mode 100644
index 6e312c6df..000000000
--- a/src/modules/diet/macro-profile/infrastructure/macroProfileDAO.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { z } from 'zod/v4'
-
-import {
- type MacroProfile,
- macroProfileSchema,
- type NewMacroProfile,
-} from '~/modules/diet/macro-profile/domain/macroProfile'
-import { parseWithStack } from '~/shared/utils/parseWithStack'
-
-// DAO schemas for database operations
-export const createMacroProfileDAOSchema = z.object({
- owner: z.number(),
- target_day: z.date().or(z.string()),
- gramsPerKgCarbs: z.number(),
- gramsPerKgProtein: z.number(),
- gramsPerKgFat: z.number(),
-})
-
-export const macroProfileDAOSchema = createMacroProfileDAOSchema.extend({
- id: z.number(),
-})
-
-export type CreateMacroProfileDAO = z.infer
-export type MacroProfileDAO = z.infer
-
-// Conversion functions
-export function createInsertMacroProfileDAOFromNewMacroProfile(
- newMacroProfile: NewMacroProfile,
-): CreateMacroProfileDAO {
- return {
- owner: newMacroProfile.owner,
- target_day: newMacroProfile.target_day,
- gramsPerKgCarbs: newMacroProfile.gramsPerKgCarbs,
- gramsPerKgProtein: newMacroProfile.gramsPerKgProtein,
- gramsPerKgFat: newMacroProfile.gramsPerKgFat,
- }
-}
-
-export function createUpdateMacroProfileDAOFromNewMacroProfile(
- newMacroProfile: NewMacroProfile,
-): CreateMacroProfileDAO {
- return createInsertMacroProfileDAOFromNewMacroProfile(newMacroProfile)
-}
-
-export function createMacroProfileFromDAO(dao: MacroProfileDAO): MacroProfile {
- return parseWithStack(macroProfileSchema, {
- id: dao.id,
- owner: dao.owner,
- target_day: new Date(dao.target_day),
- gramsPerKgCarbs: dao.gramsPerKgCarbs,
- gramsPerKgProtein: dao.gramsPerKgProtein,
- gramsPerKgFat: dao.gramsPerKgFat,
- })
-}
diff --git a/src/modules/diet/macro-profile/infrastructure/macroProfileRepository.ts b/src/modules/diet/macro-profile/infrastructure/macroProfileRepository.ts
new file mode 100644
index 000000000..a22f1c5fc
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/macroProfileRepository.ts
@@ -0,0 +1,79 @@
+import {
+ type MacroProfile,
+ type NewMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type MacroProfileRepository } from '~/modules/diet/macro-profile/domain/macroProfileRepository'
+import { macroProfileCacheStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore'
+import { createSupabaseMacroProfileGateway } from '~/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway'
+import { type User } from '~/modules/user/domain/user'
+import { logging } from '~/shared/utils/logging'
+
+const supabaseGateway = createSupabaseMacroProfileGateway()
+
+export function createMacroProfileRepository(): MacroProfileRepository {
+ return {
+ fetchUserMacroProfiles,
+ insertMacroProfile,
+ updateMacroProfile,
+ deleteMacroProfile,
+ }
+}
+
+export async function fetchUserMacroProfiles(
+ userId: User['uuid'],
+): Promise {
+ try {
+ const profiles = await supabaseGateway.fetchUserMacroProfiles(userId)
+ macroProfileCacheStore.upsertManyToCache(profiles)
+ return profiles
+ } catch (error) {
+ logging.error('MacroProfile fetch error:', error)
+ macroProfileCacheStore.removeFromCache({ by: 'user_id', value: userId })
+ return []
+ }
+}
+
+export async function insertMacroProfile(
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ try {
+ const profile = await supabaseGateway.insertMacroProfile(newMacroProfile)
+ if (profile !== null) {
+ macroProfileCacheStore.upsertToCache(profile)
+ }
+ return profile
+ } catch (error) {
+ logging.error('MacroProfile fetch error:', error)
+ return null
+ }
+}
+
+export async function updateMacroProfile(
+ macroProfileId: MacroProfile['id'],
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ try {
+ const profile = await supabaseGateway.updateMacroProfile(
+ macroProfileId,
+ newMacroProfile,
+ )
+ if (profile !== null) {
+ macroProfileCacheStore.upsertToCache(profile)
+ }
+ return profile
+ } catch (error) {
+ logging.error('MacroProfile fetch error:', error)
+ return null
+ }
+}
+
+export async function deleteMacroProfile(
+ id: MacroProfile['id'],
+): Promise {
+ try {
+ await supabaseGateway.deleteMacroProfile(id)
+ macroProfileCacheStore.removeFromCache({ by: 'id', value: id })
+ } catch (error) {
+ logging.error('MacroProfile fetch error:', error)
+ }
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore.ts b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore.ts
new file mode 100644
index 000000000..c2d7cad33
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore.ts
@@ -0,0 +1,71 @@
+import { createSignal } from 'solid-js'
+
+import { type MacroProfile } from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type User } from '~/modules/user/domain/user'
+
+type CacheKey =
+ | { by: 'id'; value: MacroProfile['id'] }
+ | { by: 'user_id'; value: User['uuid'] }
+
+const [cachedProfiles, setCachedProfiles] = createSignal<
+ readonly MacroProfile[]
+>([])
+
+export const macroProfileCacheStore = {
+ getCache: () => cachedProfiles(),
+
+ upsertToCache: (profile: MacroProfile) => {
+ setCachedProfiles((current) => {
+ const index = current.findIndex((p) => p.id === profile.id)
+ if (index >= 0) {
+ // Update existing
+ const newProfiles = [...current]
+ newProfiles[index] = profile
+ return newProfiles
+ } else {
+ // Add new
+ return [...current, profile]
+ }
+ })
+ },
+
+ upsertManyToCache: (profiles: readonly MacroProfile[]) => {
+ setCachedProfiles((current) => {
+ const updatedProfiles = [...current]
+
+ for (const profile of profiles) {
+ const index = updatedProfiles.findIndex((p) => p.id === profile.id)
+ if (index >= 0) {
+ updatedProfiles[index] = profile
+ } else {
+ updatedProfiles.push(profile)
+ }
+ }
+
+ return updatedProfiles
+ })
+ },
+
+ removeFromCache: (key: CacheKey) => {
+ setCachedProfiles((current) => {
+ switch (key.by) {
+ case 'id':
+ return current.filter((p) => p.id !== key.value)
+ case 'user_id':
+ return current.filter((p) => p.user_id !== key.value)
+ default:
+ return current
+ }
+ })
+ },
+
+ clearCache: () => setCachedProfiles([]),
+
+ getProfilesByUserId: (userId: User['uuid']) => {
+ return cachedProfiles().filter((p) => p.user_id === userId)
+ },
+
+ getProfileById: (id: MacroProfile['id']) => {
+ return cachedProfiles().find((p) => p.id === id) ?? null
+ },
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/signals/macroProfileEffects.ts b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileEffects.ts
new file mode 100644
index 000000000..91bc49172
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileEffects.ts
@@ -0,0 +1,42 @@
+import { createEffect, createRoot, untrack } from 'solid-js'
+
+import { fetchUserMacroProfiles } from '~/modules/diet/macro-profile/application/usecases/macroProfileCrud'
+import { macroProfileCacheStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore'
+import { macroProfileStateStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileStateStore'
+import { currentUserId } from '~/modules/user/application/user'
+import { logging } from '~/shared/utils/logging'
+
+let initialized = false
+
+export function initializeMacroProfileEffects() {
+ if (initialized) {
+ return
+ }
+ initialized = true
+
+ return createRoot(() => {
+ // When user changes, update selected user and clear cache if needed
+ createEffect(() => {
+ const userId = currentUserId()
+ logging.debug(`User changed to ${userId}`)
+
+ const previousUserId = untrack(macroProfileStateStore.selectedUserId)
+
+ if (previousUserId !== null && previousUserId !== userId) {
+ logging.debug(`Different user detected, clearing cache`)
+ macroProfileCacheStore.clearCache()
+ }
+
+ macroProfileStateStore.setSelectedUserId(userId)
+ })
+
+ // When selected user changes, fetch their macro profiles
+ createEffect(() => {
+ const userId = macroProfileStateStore.selectedUserId()
+ if (userId !== null) {
+ logging.debug(`Fetching macro profiles for user ${userId}`)
+ void fetchUserMacroProfiles(userId)
+ }
+ })
+ })
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/signals/macroProfileStateStore.ts b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileStateStore.ts
new file mode 100644
index 000000000..8f7568e89
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/signals/macroProfileStateStore.ts
@@ -0,0 +1,12 @@
+import { createSignal } from 'solid-js'
+
+import { type User } from '~/modules/user/domain/user'
+
+const [selectedUserId, setSelectedUserId] = createSignal(
+ null,
+)
+
+export const macroProfileStateStore = {
+ selectedUserId,
+ setSelectedUserId,
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts b/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts
new file mode 100644
index 000000000..de5f24648
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts
@@ -0,0 +1 @@
+export const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles'
diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts
new file mode 100644
index 000000000..f78fbfe50
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts
@@ -0,0 +1,69 @@
+import {
+ type MacroProfile,
+ macroProfileSchema,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { macroProfileCacheStore } from '~/modules/diet/macro-profile/infrastructure/signals/macroProfileCacheStore'
+import { SUPABASE_TABLE_MACRO_PROFILES } from '~/modules/diet/macro-profile/infrastructure/supabase/constants'
+import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase'
+import { logging } from '~/shared/utils/logging'
+
+let initialized = false
+
+/**
+ * Sets up granular realtime subscription for macro profile changes
+ * @param onMacroProfileChange - Callback for granular updates with event details
+ */
+export function setupMacroProfileRealtimeSubscription(
+ onMacroProfileChange: (event: {
+ eventType: 'INSERT' | 'UPDATE' | 'DELETE'
+ old?: MacroProfile
+ new?: MacroProfile
+ }) => void,
+): void {
+ registerSubapabaseRealtimeCallback(
+ SUPABASE_TABLE_MACRO_PROFILES,
+ macroProfileSchema,
+ onMacroProfileChange,
+ )
+}
+
+export function initializeMacroProfileRealtime(): void {
+ if (initialized) {
+ return
+ }
+ logging.debug(`Macro profile realtime initialized!`)
+ initialized = true
+ registerSubapabaseRealtimeCallback(
+ SUPABASE_TABLE_MACRO_PROFILES,
+ macroProfileSchema,
+ (event) => {
+ logging.debug(`Event:`, event)
+
+ switch (event.eventType) {
+ case 'INSERT': {
+ if (event.new) {
+ macroProfileCacheStore.upsertToCache(event.new)
+ }
+ break
+ }
+
+ case 'UPDATE': {
+ if (event.new) {
+ macroProfileCacheStore.upsertToCache(event.new)
+ }
+ break
+ }
+
+ case 'DELETE': {
+ if (event.old) {
+ macroProfileCacheStore.removeFromCache({
+ by: 'id',
+ value: event.old.id,
+ })
+ }
+ break
+ }
+ }
+ },
+ )
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts
new file mode 100644
index 000000000..692a25742
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts
@@ -0,0 +1,86 @@
+import {
+ type MacroProfile,
+ type NewMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type MacroProfileGateway } from '~/modules/diet/macro-profile/domain/macroProfileGateway'
+import { SUPABASE_TABLE_MACRO_PROFILES } from '~/modules/diet/macro-profile/infrastructure/supabase/constants'
+import { supabaseMacroProfileMapper } from '~/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper'
+import { type User } from '~/modules/user/domain/user'
+import { supabase } from '~/shared/supabase/supabase'
+import { logging } from '~/shared/utils/logging'
+
+export function createSupabaseMacroProfileGateway(): MacroProfileGateway {
+ return {
+ fetchUserMacroProfiles,
+ insertMacroProfile,
+ updateMacroProfile,
+ deleteMacroProfile,
+ }
+}
+
+async function fetchUserMacroProfiles(
+ userId: User['uuid'],
+): Promise {
+ const { data, error } = await supabase
+ .from(SUPABASE_TABLE_MACRO_PROFILES)
+ .select('*')
+ .eq('user_id', userId)
+ .order('target_day', { ascending: true })
+
+ if (error !== null) {
+ logging.error('MacroProfile fetch error:', error)
+ throw error
+ }
+
+ return data.map(supabaseMacroProfileMapper.toDomain)
+}
+
+async function insertMacroProfile(
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ const createDAO = supabaseMacroProfileMapper.toInsertDTO(newMacroProfile)
+ const { data, error } = await supabase
+ .from(SUPABASE_TABLE_MACRO_PROFILES)
+ .insert(createDAO)
+ .select()
+ .single()
+
+ if (error !== null) {
+ logging.error('MacroProfile fetch error:', error)
+ throw error
+ }
+
+ return supabaseMacroProfileMapper.toDomain(data)
+}
+
+async function updateMacroProfile(
+ profileId: MacroProfile['id'],
+ newMacroProfile: NewMacroProfile,
+): Promise {
+ const updateDAO = supabaseMacroProfileMapper.toInsertDTO(newMacroProfile)
+ const { data, error } = await supabase
+ .from(SUPABASE_TABLE_MACRO_PROFILES)
+ .update(updateDAO)
+ .eq('id', profileId)
+ .select()
+ .single()
+
+ if (error !== null) {
+ logging.error('MacroProfile fetch error:', error)
+ throw error
+ }
+
+ return supabaseMacroProfileMapper.toDomain(data)
+}
+
+async function deleteMacroProfile(id: MacroProfile['id']): Promise {
+ const { error } = await supabase
+ .from(SUPABASE_TABLE_MACRO_PROFILES)
+ .delete()
+ .eq('id', id)
+
+ if (error !== null) {
+ logging.error('MacroProfile fetch error:', error)
+ throw error
+ }
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts
new file mode 100644
index 000000000..fbffba33f
--- /dev/null
+++ b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts
@@ -0,0 +1,39 @@
+import {
+ type MacroProfile,
+ macroProfileSchema,
+ type NewMacroProfile,
+} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { type Database } from '~/shared/supabase/database.types'
+import { parseWithStack } from '~/shared/utils/parseWithStack'
+
+export type InsertMacroProfileDTO =
+ Database['public']['Tables']['macro_profiles']['Insert']
+export type MacroProfileDTO =
+ Database['public']['Tables']['macro_profiles']['Row']
+
+// Conversion functions
+function toInsertDTO(newMacroProfile: NewMacroProfile): InsertMacroProfileDTO {
+ return {
+ user_id: newMacroProfile.user_id,
+ target_day: newMacroProfile.target_day.toISOString(),
+ gramsPerKgCarbs: newMacroProfile.gramsPerKgCarbs,
+ gramsPerKgProtein: newMacroProfile.gramsPerKgProtein,
+ gramsPerKgFat: newMacroProfile.gramsPerKgFat,
+ }
+}
+
+function toDomain(dto: MacroProfileDTO): MacroProfile {
+ return parseWithStack(macroProfileSchema, {
+ id: dto.id,
+ user_id: dto.user_id,
+ target_day: new Date(dto.target_day ?? ''),
+ gramsPerKgCarbs: dto.gramsPerKgCarbs,
+ gramsPerKgProtein: dto.gramsPerKgProtein,
+ gramsPerKgFat: dto.gramsPerKgFat,
+ })
+}
+
+export const supabaseMacroProfileMapper = {
+ toInsertDTO,
+ toDomain,
+}
diff --git a/src/modules/diet/macro-profile/infrastructure/supabaseMacroProfileRepository.ts b/src/modules/diet/macro-profile/infrastructure/supabaseMacroProfileRepository.ts
deleted file mode 100644
index 7200fd8c2..000000000
--- a/src/modules/diet/macro-profile/infrastructure/supabaseMacroProfileRepository.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import {
- type MacroProfile,
- type NewMacroProfile,
-} from '~/modules/diet/macro-profile/domain/macroProfile'
-import { type MacroProfileRepository } from '~/modules/diet/macro-profile/domain/macroProfileRepository'
-import {
- createInsertMacroProfileDAOFromNewMacroProfile,
- createMacroProfileFromDAO,
- createUpdateMacroProfileDAOFromNewMacroProfile,
- macroProfileDAOSchema,
-} from '~/modules/diet/macro-profile/infrastructure/macroProfileDAO'
-import { type User } from '~/modules/user/domain/user'
-import { createErrorHandler } from '~/shared/error/errorHandler'
-import { parseWithStack } from '~/shared/utils/parseWithStack'
-import supabase from '~/shared/utils/supabase'
-
-/**
- * Supabase table name for macro profiles.
- */
-export const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles'
-
-/**
- * Creates a MacroProfileRepository implementation using Supabase as backend.
- * @returns {MacroProfileRepository} The repository instance.
- */
-const errorHandler = createErrorHandler('infrastructure', 'MacroProfile')
-
-export function createSupabaseMacroProfileRepository(): MacroProfileRepository {
- return {
- fetchUserMacroProfiles,
- insertMacroProfile,
- updateMacroProfile,
- deleteMacroProfile,
- }
-}
-
-/**
- * Fetches all macro profiles for a user.
- * @param {User['id']} userId - The user ID.
- * @returns {Promise} Array of macro profiles. Throws on error.
- * @throws {Error} On API or validation error.
- */
-async function fetchUserMacroProfiles(
- userId: User['id'],
-): Promise {
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_MACRO_PROFILES)
- .select('*')
- .eq('owner', userId)
- .order('target_day', { ascending: true })
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- let macroProfileDAOs
- try {
- macroProfileDAOs = parseWithStack(macroProfileDAOSchema.array(), data)
- } catch (validationError) {
- errorHandler.error(validationError)
- throw validationError
- }
- return macroProfileDAOs.map(createMacroProfileFromDAO)
-}
-
-/**
- * Inserts a new macro profile.
- * @param {NewMacroProfile} newMacroProfile - The macro profile to insert.
- * @returns {Promise} The inserted macro profile. Throws on error.
- * @throws {Error} On API or validation error.
- */
-async function insertMacroProfile(
- newMacroProfile: NewMacroProfile,
-): Promise {
- const createDAO =
- createInsertMacroProfileDAOFromNewMacroProfile(newMacroProfile)
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_MACRO_PROFILES)
- .insert(createDAO)
- .select()
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- let macroProfileDAOs
- try {
- macroProfileDAOs = parseWithStack(macroProfileDAOSchema.array(), data)
- } catch (validationError) {
- errorHandler.error(validationError)
- throw validationError
- }
- if (!macroProfileDAOs[0]) {
- const notFoundError = new Error(
- 'Inserted macro profile not found in response',
- )
- errorHandler.error(notFoundError)
- throw notFoundError
- }
- return createMacroProfileFromDAO(macroProfileDAOs[0])
-}
-
-/**
- * Updates an existing macro profile.
- * @param {MacroProfile['id']} profileId - The macro profile ID.
- * @param {NewMacroProfile} newMacroProfile - The new macro profile data.
- * @returns {Promise} The updated macro profile. Throws on error.
- * @throws {Error} On API or validation error.
- */
-async function updateMacroProfile(
- profileId: MacroProfile['id'],
- newMacroProfile: NewMacroProfile,
-): Promise {
- const updateDAO =
- createUpdateMacroProfileDAOFromNewMacroProfile(newMacroProfile)
- const { data, error } = await supabase
- .from(SUPABASE_TABLE_MACRO_PROFILES)
- .update(updateDAO)
- .eq('id', profileId)
- .select()
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-
- let macroProfileDAOs
- try {
- macroProfileDAOs = parseWithStack(macroProfileDAOSchema.array(), data)
- } catch (validationError) {
- errorHandler.error(validationError)
- throw validationError
- }
- if (!macroProfileDAOs[0]) {
- const notFoundError = new Error(
- 'Updated macro profile not found in response',
- )
- errorHandler.error(notFoundError)
- throw notFoundError
- }
- return createMacroProfileFromDAO(macroProfileDAOs[0])
-}
-
-/**
- * Deletes a macro profile by ID.
- * @param {MacroProfile['id']} id - The macro profile ID.
- * @returns {Promise} Resolves on success. Throws on error.
- * @throws {Error} On API error.
- */
-async function deleteMacroProfile(id: MacroProfile['id']): Promise {
- const { error } = await supabase
- .from(SUPABASE_TABLE_MACRO_PROFILES)
- .delete()
- .eq('id', id)
-
- if (error !== null) {
- errorHandler.error(error)
- throw error
- }
-}
diff --git a/src/modules/diet/macro-profile/tests/macroProfile.test.ts b/src/modules/diet/macro-profile/tests/macroProfile.test.ts
index c29497534..036692b19 100644
--- a/src/modules/diet/macro-profile/tests/macroProfile.test.ts
+++ b/src/modules/diet/macro-profile/tests/macroProfile.test.ts
@@ -9,13 +9,14 @@ import {
newMacroProfileSchema,
promoteToMacroProfile,
} from '~/modules/diet/macro-profile/domain/macroProfile'
+import { createDefaultMacroProfile } from '~/modules/diet/macro-profile/domain/macroProfileOperations'
describe('MacroProfile Domain', () => {
describe('macroProfileSchema', () => {
it('should transform string target_day to Date', () => {
const macroProfileWithStringDate = {
id: 1,
- owner: 42,
+ user_id: '42',
target_day: '2023-01-01T00:00:00Z',
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -36,7 +37,7 @@ describe('MacroProfile Domain', () => {
it('should transform negative gramsPerKg values to 0', () => {
const macroProfileWithNegativeValues = {
id: 1,
- owner: 42,
+ user_id: '42',
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: -2.5,
gramsPerKgProtein: -1.8,
@@ -58,7 +59,7 @@ describe('MacroProfile Domain', () => {
it('should fail validation with NaN gramsPerKg values', () => {
const macroProfileWithNaNValues = {
id: 1,
- owner: 42,
+ user_id: '42',
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: NaN,
gramsPerKgProtein: NaN,
@@ -72,7 +73,7 @@ describe('MacroProfile Domain', () => {
it('should fail validation with missing required fields', () => {
const invalidMacroProfile = {
- // Missing owner, target_day, gramsPerKg values
+ // Missing user_id, target_day, gramsPerKg values
id: 1,
__type: 'MacroProfile',
}
@@ -84,7 +85,7 @@ describe('MacroProfile Domain', () => {
it('should fail validation with invalid field types', () => {
const invalidMacroProfile = {
id: 1,
- owner: 'not-a-number',
+ user_id: 42,
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -99,7 +100,7 @@ describe('MacroProfile Domain', () => {
it('should handle invalid date format by creating Invalid Date', () => {
const invalidMacroProfile = {
id: 1,
- owner: 42,
+ user_id: '42',
target_day: 'not-a-date',
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -119,7 +120,7 @@ describe('MacroProfile Domain', () => {
describe('newMacroProfileSchema', () => {
it('should transform string target_day to Date', () => {
const newMacroProfileWithStringDate = {
- owner: 42,
+ user_id: '42',
target_day: '2023-06-15T12:30:00Z',
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -152,7 +153,7 @@ describe('MacroProfile Domain', () => {
describe('createNewMacroProfile', () => {
it('should create a valid NewMacroProfile', () => {
const macroProfileProps = {
- owner: 42,
+ user_id: '42',
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -161,7 +162,7 @@ describe('MacroProfile Domain', () => {
const newMacroProfile = createNewMacroProfile(macroProfileProps)
- expect(newMacroProfile.owner).toBe(42)
+ expect(newMacroProfile.user_id).toBe('42')
expect(newMacroProfile.target_day).toStrictEqual(new Date('2023-01-01'))
expect(newMacroProfile.gramsPerKgCarbs).toBe(5.0)
expect(newMacroProfile.gramsPerKgProtein).toBe(2.2)
@@ -173,7 +174,7 @@ describe('MacroProfile Domain', () => {
describe('promoteToMacroProfile', () => {
it('should promote NewMacroProfile to MacroProfile', () => {
const newMacroProfile: NewMacroProfile = {
- owner: 42,
+ user_id: '42',
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -184,7 +185,7 @@ describe('MacroProfile Domain', () => {
const macroProfile = promoteToMacroProfile(newMacroProfile, { id: 123 })
expect(macroProfile.id).toBe(123)
- expect(macroProfile.owner).toBe(42)
+ expect(macroProfile.user_id).toBe('42')
expect(macroProfile.target_day).toStrictEqual(new Date('2023-01-01'))
expect(macroProfile.gramsPerKgCarbs).toBe(5.0)
expect(macroProfile.gramsPerKgProtein).toBe(2.2)
@@ -197,7 +198,7 @@ describe('MacroProfile Domain', () => {
it('should demote MacroProfile to NewMacroProfile', () => {
const macroProfile: MacroProfile = {
id: 123,
- owner: 42,
+ user_id: '42',
target_day: new Date('2023-01-01'),
gramsPerKgCarbs: 5.0,
gramsPerKgProtein: 2.2,
@@ -207,7 +208,7 @@ describe('MacroProfile Domain', () => {
const newMacroProfile = demoteToNewMacroProfile(macroProfile)
- expect(newMacroProfile.owner).toBe(42)
+ expect(newMacroProfile.user_id).toBe('42')
expect(newMacroProfile.target_day).toStrictEqual(new Date('2023-01-01'))
expect(newMacroProfile.gramsPerKgCarbs).toBe(5.0)
expect(newMacroProfile.gramsPerKgProtein).toBe(2.2)
@@ -216,4 +217,19 @@ describe('MacroProfile Domain', () => {
expect('id' in newMacroProfile).toBe(false)
})
})
+
+ describe('createDefaultMacroProfile', () => {
+ it('should create a default macro profile with zeros for new users', () => {
+ const userId = 'test-user-123'
+ const defaultProfile = createDefaultMacroProfile(userId)
+
+ expect(defaultProfile.id).toBe(-1)
+ expect(defaultProfile.user_id).toBe(userId)
+ expect(defaultProfile.gramsPerKgCarbs).toBe(0)
+ expect(defaultProfile.gramsPerKgProtein).toBe(0)
+ expect(defaultProfile.gramsPerKgFat).toBe(0)
+ expect(defaultProfile.__type).toBe('MacroProfile')
+ expect(defaultProfile.target_day).toBeInstanceOf(Date)
+ })
+ })
})
diff --git a/src/modules/diet/macro-target/application/macroTarget.ts b/src/modules/diet/macro-target/application/macroTarget.ts
index 03039948d..50cafe1df 100644
--- a/src/modules/diet/macro-target/application/macroTarget.ts
+++ b/src/modules/diet/macro-target/application/macroTarget.ts
@@ -2,12 +2,13 @@ import {
createMacroNutrients,
type MacroNutrients,
} from '~/modules/diet/macro-nutrients/domain/macroNutrients'
-import { userMacroProfiles } from '~/modules/diet/macro-profile/application/macroProfile'
+import { userMacroProfiles } from '~/modules/diet/macro-profile/application/usecases/macroProfileState'
import { type MacroProfile } from '~/modules/diet/macro-profile/domain/macroProfile'
+import { inForceMacroProfile } from '~/modules/diet/macro-profile/domain/macroProfileOperations'
import { showError } from '~/modules/toast/application/toastManager'
import { currentUserId } from '~/modules/user/application/user'
-import { userWeights } from '~/modules/weight/application/weight'
-import { inForceMacroProfile } from '~/shared/utils/macroProfileUtils'
+import { type User } from '~/modules/user/domain/user'
+import { userWeights } from '~/modules/weight/application/usecases/weightState'
import { inForceWeight } from '~/shared/utils/weightUtils'
export const calculateMacroTarget = (
@@ -25,10 +26,10 @@ export const calculateMacroTarget = (
class WeightNotFoundForDayError extends Error {
readonly day: Date
- readonly userId: number
+ readonly userId: User['uuid']
readonly errorId: string
- constructor(day: Date, userId: number) {
+ constructor(day: Date, userId: User['uuid']) {
super(
`Peso não encontrado para o dia ${day.toISOString()}, usuário ${userId}`,
)
@@ -52,10 +53,10 @@ class WeightNotFoundForDayError extends Error {
class MacroTargetNotFoundForDayError extends Error {
readonly day: Date
- readonly userId: number
+ readonly userId: User['uuid']
readonly errorId: string
- constructor(day: Date, userId: number) {
+ constructor(day: Date, userId: User['uuid']) {
super(
`Meta de macros não encontrada para o dia ${day.toISOString()}, usuário ${userId}`,
)
@@ -78,23 +79,18 @@ class MacroTargetNotFoundForDayError extends Error {
}
export const getMacroTargetForDay = (day: Date): MacroNutrients | null => {
- const targetDayWeight_ =
- inForceWeight(userWeights.latest, day)?.weight ?? null
+ const targetDayWeight_ = inForceWeight(userWeights(), day)?.weight ?? null
const targetDayMacroProfile_ = inForceMacroProfile(userMacroProfiles(), day)
const userId = currentUserId()
if (targetDayWeight_ === null) {
- showError(new WeightNotFoundForDayError(day, userId), {
- audience: 'system',
- })
+ showError(new WeightNotFoundForDayError(day, userId), {})
return null
}
if (targetDayMacroProfile_ === null) {
- showError(new MacroTargetNotFoundForDayError(day, userId), {
- audience: 'system',
- })
+ showError(new MacroTargetNotFoundForDayError(day, userId), {})
return null
}
diff --git a/src/modules/diet/meal/application/meal.ts b/src/modules/diet/meal/application/meal.ts
index aa53b150f..94387ab0a 100644
--- a/src/modules/diet/meal/application/meal.ts
+++ b/src/modules/diet/meal/application/meal.ts
@@ -1,11 +1,9 @@
-import {
- currentDayDiet,
- updateDayDiet,
-} from '~/modules/diet/day-diet/application/dayDiet'
+import { updateDayDiet } from '~/modules/diet/day-diet/application/usecases/dayCrud'
+import { currentDayDiet } from '~/modules/diet/day-diet/application/usecases/dayState'
import { demoteNewDayDiet } from '~/modules/diet/day-diet/domain/dayDiet'
import { updateMealInDayDiet } from '~/modules/diet/day-diet/domain/dayDietOperations'
import { type Meal } from '~/modules/diet/meal/domain/meal'
-import { createErrorHandler } from '~/shared/error/errorHandler'
+import { logging } from '~/shared/utils/logging'
/**
* Updates a meal in the current day diet.
@@ -13,7 +11,6 @@ import { createErrorHandler } from '~/shared/error/errorHandler'
* @param newMeal - The new meal data.
* @returns True if updated, false otherwise.
*/
-const errorHandler = createErrorHandler('application', 'Meal')
export async function updateMeal(
mealId: Meal['id'],
@@ -22,15 +19,20 @@ export async function updateMeal(
try {
const currentDayDiet_ = currentDayDiet()
if (currentDayDiet_ === null) {
- errorHandler.error(new Error('Current day diet is null'))
+ logging.error(
+ 'Meal application error:',
+ new Error('Current day diet is null'),
+ )
return false
}
+
const updatedDayDiet = updateMealInDayDiet(currentDayDiet_, mealId, newMeal)
const newDay = demoteNewDayDiet(updatedDayDiet)
await updateDayDiet(currentDayDiet_.id, newDay)
+
return true
} catch (error) {
- errorHandler.error(error)
+ logging.error('Meal application error:', error)
return false
}
}
diff --git a/src/modules/diet/meal/domain/meal.ts b/src/modules/diet/meal/domain/meal.ts
index 0b9c02377..895504d23 100644
--- a/src/modules/diet/meal/domain/meal.ts
+++ b/src/modules/diet/meal/domain/meal.ts
@@ -10,11 +10,9 @@ export const {
newSchema: newMealSchema,
createNew: createNewMeal,
promote: promoteMeal,
- demote: demoteMeal,
} = ze.create({
name: ze.string(),
items: ze.array(unifiedItemSchema),
})
-export type NewMeal = Readonly>
export type Meal = Readonly>
diff --git a/src/modules/diet/meal/infrastructure/mealDAO.ts b/src/modules/diet/meal/infrastructure/mealDAO.ts
index 779368047..ae31468f7 100644
--- a/src/modules/diet/meal/infrastructure/mealDAO.ts
+++ b/src/modules/diet/meal/infrastructure/mealDAO.ts
@@ -2,6 +2,8 @@ import { z } from 'zod/v4'
import { unifiedItemSchema } from '~/modules/diet/unified-item/schema/unifiedItemSchema'
+// TODO: Remove all DAOs, renaming remaining to DTO
+// Issue URL: https://github.com/marcuscastelo/macroflows/issues/1064
// DAO schema for database record (current unified format)
export const mealDAOSchema = z.object({
id: z.number(),
diff --git a/src/modules/diet/recipe/application/recipe.ts b/src/modules/diet/recipe/application/recipe.ts
deleted file mode 100644
index 1d88e6c12..000000000
--- a/src/modules/diet/recipe/application/recipe.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import {
- type NewRecipe,
- type Recipe,
-} from '~/modules/diet/recipe/domain/recipe'
-import { createSupabaseRecipeRepository } from '~/modules/diet/recipe/infrastructure/supabaseRecipeRepository'
-import { showPromise } from '~/modules/toast/application/toastManager'
-import { type User } from '~/modules/user/domain/user'
-import { createErrorHandler } from '~/shared/error/errorHandler'
-
-const recipeRepository = createSupabaseRecipeRepository()
-
-const errorHandler = createErrorHandler('application', 'Recipe')
-
-export async function fetchUserRecipes(userId: User['id']) {
- try {
- return await showPromise(
- recipeRepository.fetchUserRecipes(userId),
- {
- loading: 'Carregando receitas...',
- success: 'Receitas carregadas com sucesso',
- error: 'Falha ao carregar receitas',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- userId,
- businessContext: { userId },
- })
- return []
- }
-}
-
-export async function fetchUserRecipeByName(userId: User['id'], name: string) {
- try {
- return await showPromise(
- recipeRepository.fetchUserRecipeByName(userId, name),
- {
- loading: 'Carregando receitas...',
- success: 'Receitas carregadas com sucesso',
- error: 'Falha ao carregar receitas',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- userId,
- businessContext: { userId, recipeName: name },
- })
- return []
- }
-}
-
-export async function fetchRecipeById(recipeId: Recipe['id']) {
- try {
- return await showPromise(
- recipeRepository.fetchRecipeById(recipeId),
- {
- loading: 'Carregando receita...',
- success: 'Receita carregada com sucesso',
- error: 'Falha ao carregar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- entityId: recipeId,
- businessContext: { recipeId },
- })
- return null
- }
-}
-
-export async function insertRecipe(newRecipe: NewRecipe) {
- try {
- return await showPromise(
- recipeRepository.insertRecipe(newRecipe),
- {
- loading: 'Criando nova receita...',
- success: (recipe) => `Receita '${recipe.name}' criada com sucesso`,
- error: 'Falha ao criar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- businessContext: {
- recipeName: newRecipe.name,
- itemsCount: newRecipe.items.length,
- },
- })
- return null
- }
-}
-
-export async function updateRecipe(recipeId: Recipe['id'], newRecipe: Recipe) {
- try {
- return await showPromise(
- recipeRepository.updateRecipe(recipeId, newRecipe),
- {
- loading: 'Atualizando receita...',
- success: 'Receita atualizada com sucesso',
- error: 'Falha ao atualizar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- entityId: recipeId,
- businessContext: {
- recipeId,
- recipeName: newRecipe.name,
- itemsCount: newRecipe.items.length,
- },
- })
- return null
- }
-}
-
-export async function deleteRecipe(recipeId: Recipe['id']) {
- try {
- return await showPromise(
- recipeRepository.deleteRecipe(recipeId),
- {
- loading: 'Deletando receita...',
- success: 'Receita deletada com sucesso',
- error: 'Falha ao deletar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error, {
- entityId: recipeId,
- businessContext: { recipeId },
- })
- return null
- }
-}
diff --git a/src/modules/diet/recipe/application/services/cacheManagement.ts b/src/modules/diet/recipe/application/services/cacheManagement.ts
new file mode 100644
index 000000000..94d2518b1
--- /dev/null
+++ b/src/modules/diet/recipe/application/services/cacheManagement.ts
@@ -0,0 +1,28 @@
+import { untrack } from 'solid-js'
+
+import { type Recipe } from '~/modules/diet/recipe/domain/recipe'
+import { type User } from '~/modules/user/domain/user'
+import { logging } from '~/shared/utils/logging'
+
+export function createRecipeCacheManagementService(deps: {
+ getExistingRecipes: () => readonly Recipe[]
+ clearCache: () => void
+ fetchUserRecipes: (userId: User['uuid']) => void
+}) {
+ return ({ userId }: { userId: User['uuid'] }) => {
+ logging.debug(`Effect - Refetch/Manage recipe cache`)
+ const existingRecipes = untrack(deps.getExistingRecipes)
+
+ // If any recipe is from other user, purge cache
+ if (existingRecipes.find((r) => r.user_id !== userId) !== undefined) {
+ logging.debug(`User changed! Purge recipe cache`)
+ deps.clearCache()
+ void deps.fetchUserRecipes(userId)
+ return
+ }
+
+ logging.debug(
+ `Recipe cache effect - user: ${userId}, cache size: ${existingRecipes.length}`,
+ )
+ }
+}
diff --git a/src/modules/diet/recipe/application/unifiedRecipe.ts b/src/modules/diet/recipe/application/unifiedRecipe.ts
deleted file mode 100644
index e72acdfd4..000000000
--- a/src/modules/diet/recipe/application/unifiedRecipe.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import {
- type NewRecipe,
- type Recipe,
-} from '~/modules/diet/recipe/domain/recipe'
-import { createSupabaseRecipeRepository } from '~/modules/diet/recipe/infrastructure/supabaseRecipeRepository'
-import { showPromise } from '~/modules/toast/application/toastManager'
-import { type User } from '~/modules/user/domain/user'
-import { createErrorHandler } from '~/shared/error/errorHandler'
-
-const unifiedRecipeRepository = createSupabaseRecipeRepository()
-
-const errorHandler = createErrorHandler('application', 'Recipe')
-
-export async function fetchUserRecipes(userId: User['id']) {
- try {
- return await showPromise(
- unifiedRecipeRepository.fetchUserRecipes(userId),
- {
- loading: 'Carregando receitas...',
- success: 'Receitas carregadas com sucesso',
- error: 'Falha ao carregar receitas',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error)
- return []
- }
-}
-
-export async function fetchUserRecipeByName(userId: User['id'], name: string) {
- try {
- return await showPromise(
- unifiedRecipeRepository.fetchUserRecipeByName(userId, name),
- {
- loading: 'Carregando receitas...',
- success: 'Receitas carregadas com sucesso',
- error: 'Falha ao carregar receitas',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error)
- return []
- }
-}
-
-export async function fetchRecipeById(recipeId: Recipe['id']) {
- try {
- return await showPromise(
- unifiedRecipeRepository.fetchRecipeById(recipeId),
- {
- loading: 'Carregando receita...',
- success: 'Receita carregada com sucesso',
- error: 'Falha ao carregar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error)
- return null
- }
-}
-
-export async function saveRecipe(newRecipe: NewRecipe): Promise {
- try {
- return await showPromise(
- unifiedRecipeRepository.insertRecipe(newRecipe),
- {
- loading: 'Salvando receita...',
- success: 'Receita salva com sucesso',
- error: 'Falha ao salvar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error)
- return null
- }
-}
-
-export async function updateRecipe(
- recipeId: Recipe['id'],
- recipe: Recipe,
-): Promise {
- try {
- return await showPromise(
- unifiedRecipeRepository.updateRecipe(recipeId, recipe),
- {
- loading: 'Atualizando receita...',
- success: 'Receita atualizada com sucesso',
- error: 'Falha ao atualizar receita',
- },
- { context: 'background', audience: 'user' },
- )
- } catch (error) {
- errorHandler.error(error)
- return null
- }
-}
-
-export async function deleteRecipe(recipeId: Recipe['id']) {
- try {
- await showPromise(
- unifiedRecipeRepository.deleteRecipe(recipeId),
- {
- loading: 'Excluindo receita...',
- success: 'Receita excluída com sucesso',
- error: 'Falha ao excluir receita',
- },
- { context: 'background', audience: 'user' },
- )
- return true
- } catch (error) {
- errorHandler.error(error)
- return false
- }
-}
diff --git a/src/modules/diet/recipe/application/usecases/recipeCrud.ts b/src/modules/diet/recipe/application/usecases/recipeCrud.ts
new file mode 100644
index 000000000..49ec948a4
--- /dev/null
+++ b/src/modules/diet/recipe/application/usecases/recipeCrud.ts
@@ -0,0 +1,84 @@
+import {
+ type NewRecipe,
+ type Recipe,
+} from '~/modules/diet/recipe/domain/recipe'
+import { createRecipeRepository } from '~/modules/diet/recipe/infrastructure/recipeRepository'
+import { showPromise } from '~/modules/toast/application/toastManager'
+import { type User } from '~/modules/user/domain/user'
+
+const recipeRepository = createRecipeRepository()
+
+export async function fetchUserRecipes(
+ userId: User['uuid'],
+): Promise {
+ return await recipeRepository.fetchUserRecipes(userId)
+}
+
+export async function fetchUserRecipeByName(
+ userId: User['uuid'],
+ name: string,
+): Promise {
+ return await recipeRepository.fetchUserRecipeByName(userId, name)
+}
+
+export async function fetchRecipeById(
+ recipeId: Recipe['id'],
+): Promise {
+ return await recipeRepository.fetchRecipeById(recipeId)
+}
+
+export async function insertRecipe(newRecipe: NewRecipe): Promise {
+ await showPromise(
+ recipeRepository.insertRecipe(newRecipe),
+ {
+ loading: 'Criando nova receita...',
+ success: (recipe) => `Receita '${recipe?.name}' criada com sucesso`,
+ error: 'Falha ao criar receita',
+ },
+ { context: 'user-action' },
+ )
+}
+
+export async function saveRecipe(newRecipe: NewRecipe): Promise {
+ return await showPromise(
+ recipeRepository.insertRecipe(newRecipe),
+ {
+ loading: 'Salvando receita...',
+ success: 'Receita salva com sucesso',
+ error: 'Falha ao salvar receita',
+ },
+ { context: 'background' },
+ )
+}
+
+export async function updateRecipe(
+ recipeId: Recipe['id'],
+ newRecipe: Recipe,
+): Promise {
+ return await showPromise(
+ recipeRepository.updateRecipe(recipeId, newRecipe),
+ {
+ loading: 'Atualizando receita...',
+ success: 'Receita atualizada com sucesso',
+ error: 'Falha ao atualizar receita',
+ },
+ { context: 'user-action' },
+ )
+}
+
+export async function deleteRecipe(recipeId: Recipe['id']): Promise {
+ try {
+ await showPromise(
+ recipeRepository.deleteRecipe(recipeId),
+ {
+ loading: 'Deletando receita...',
+ success: 'Receita deletada com sucesso',
+ error: 'Falha ao deletar receita',
+ },
+ { context: 'user-action' },
+ )
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/src/modules/diet/recipe/domain/recipe.ts b/src/modules/diet/recipe/domain/recipe.ts
index ba67b9d93..cda84ffbd 100644
--- a/src/modules/diet/recipe/domain/recipe.ts
+++ b/src/modules/diet/recipe/domain/recipe.ts
@@ -12,7 +12,7 @@ export const {
promote: promoteRecipe,
} = ze.create({
name: ze.string(),
- owner: ze.number(),
+ user_id: ze.string(),
items: ze.array(unifiedItemSchema).readonly(),
prepared_multiplier: ze.number().default(1),
})
diff --git a/src/modules/diet/recipe/domain/recipeGateway.ts b/src/modules/diet/recipe/domain/recipeGateway.ts
new file mode 100644
index 000000000..78425e895
--- /dev/null
+++ b/src/modules/diet/recipe/domain/recipeGateway.ts
@@ -0,0 +1,20 @@
+import {
+ type NewRecipe,
+ type Recipe,
+} from '~/modules/diet/recipe/domain/recipe'
+import { type User } from '~/modules/user/domain/user'
+
+export type RecipeGateway = {
+ fetchUserRecipes: (userId: User['uuid']) => Promise
+ fetchRecipeById: (id: Recipe['id']) => Promise
+ fetchUserRecipeByName: (
+ userId: User['uuid'],
+ name: Recipe['name'],
+ ) => Promise
+ insertRecipe: (newRecipe: NewRecipe) => Promise
+ updateRecipe: (
+ recipeId: Recipe['id'],
+ newRecipe: Recipe,
+ ) => Promise
+ deleteRecipe: (id: Recipe['id']) => Promise
+}
diff --git a/src/modules/diet/recipe/domain/recipeOperations.ts b/src/modules/diet/recipe/domain/recipeOperations.ts
index 0a9725732..2e97afd07 100644
--- a/src/modules/diet/recipe/domain/recipeOperations.ts
+++ b/src/modules/diet/recipe/domain/recipeOperations.ts
@@ -58,13 +58,6 @@ export function removeItemFromRecipe(
}
}
-export function setRecipeItems(recipe: Recipe, items: UnifiedItem[]): Recipe {
- return {
- ...recipe,
- items,
- }
-}
-
export function clearRecipeItems(recipe: Recipe): Recipe {
return {
...recipe,
@@ -72,23 +65,6 @@ export function clearRecipeItems(recipe: Recipe): Recipe {
}
}
-export function findItemInRecipe(
- recipe: Recipe,
- itemId: UnifiedItem['id'],
-): UnifiedItem | undefined {
- return recipe.items.find((item) => item.id === itemId)
-}
-
-export function replaceRecipe(
- recipe: Recipe,
- updates: Partial,
-): Recipe {
- return {
- ...recipe,
- ...updates,
- }
-}
-
/**
* Calculates the total raw quantity of a recipe by summing all item quantities.
* This represents the total weight of ingredients before preparation.
diff --git a/src/modules/diet/recipe/domain/recipeRepository.ts b/src/modules/diet/recipe/domain/recipeRepository.ts
index 8205e7c78..5a1da81b4 100644
--- a/src/modules/diet/recipe/domain/recipeRepository.ts
+++ b/src/modules/diet/recipe/domain/recipeRepository.ts
@@ -5,13 +5,16 @@ import {
import { type User } from '~/modules/user/domain/user'
export type RecipeRepository = {
- fetchUserRecipes: (userId: User['id']) => Promise
- fetchRecipeById: (id: Recipe['id']) => Promise
+ fetchUserRecipes: (userId: User['uuid']) => Promise
+ fetchRecipeById: (id: Recipe['id']) => Promise