Compare commits

...

3 Commits

17 changed files with 541 additions and 397 deletions

View File

@ -1,11 +1,19 @@
import 'react-native-gesture-handler/jestSetup' try {
require("react-native-gesture-handler/jestSetup");
} catch {
// Package may be absent in minimal test environments
}
jest.mock('react-native-reanimated', () => { jest.mock(
const Reanimated = require('react-native-reanimated/mock') "react-native-reanimated",
Reanimated.default.call = () => {} () => {
return Reanimated const Reanimated = require("react-native-reanimated/mock");
}) Reanimated.default.call = () => {};
return Reanimated;
},
{ virtual: true },
);
jest.mock('@expo/vector-icons', () => ({ jest.mock("@expo/vector-icons", () => ({
Ionicons: 'Ionicons', Ionicons: "Ionicons",
})) }));

View File

@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import { gymsApi } from "../gyms";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
patch: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("gymsApi", () => {
const getMock = apiClient.get as any;
const patchMock = apiClient.patch as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns array payload from getGyms", async () => {
getMock.mockResolvedValue({
data: [{ id: "gym_1", name: "Gym One" }],
});
const result = await gymsApi.getGyms("token_1");
expect(result).toEqual([{ id: "gym_1", name: "Gym One" }]);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("returns nested data payload from getGyms", async () => {
getMock.mockResolvedValue({
data: { data: [{ id: "gym_2", name: "Gym Two" }] },
});
const result = await gymsApi.getGyms(null);
expect(result).toEqual([{ id: "gym_2", name: "Gym Two" }]);
});
it("patches selected gym for current user", async () => {
patchMock.mockResolvedValue({});
await gymsApi.updateUserGym("gym_2", "token_2");
expect(apiClient.patch).toHaveBeenCalledWith(
"/api/users/gym",
{ gymId: "gym_2" },
expect.any(Object),
);
});
});

View File

@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
import {
approveRecommendation,
generateRecommendation,
getRecommendations,
} from "../recommendations";
import { apiClient, withAuth } from "../client";
jest.mock("../client", () => ({
apiClient: {
get: jest.fn(),
post: jest.fn(),
},
withAuth: jest.fn((token?: string | null) =>
token ? { headers: { Authorization: `Bearer ${token}` } } : {},
),
}));
describe("recommendations api", () => {
const getMock = apiClient.get as any;
const postMock = apiClient.post as any;
beforeEach(() => {
jest.clearAllMocks();
});
it("returns normalized list from standardized response", async () => {
getMock.mockResolvedValue({
data: {
success: true,
data: [{ id: "rec_1", status: "pending" }],
},
});
const result = await getRecommendations("user_1", "token_1");
expect(result).toEqual([{ id: "rec_1", status: "pending" }]);
expect(withAuth).toHaveBeenCalledWith("token_1");
});
it("falls back to legacy response for generate", async () => {
postMock.mockResolvedValue({
data: { id: "rec_2", status: "pending" },
});
const result = await generateRecommendation({ userId: "user_1" }, null);
expect(result).toEqual({ id: "rec_2", status: "pending" });
});
it("sends approval payload without approvedBy", async () => {
postMock.mockResolvedValue({
data: {
success: true,
data: { id: "rec_3", status: "approved" },
},
});
const result = await approveRecommendation("rec_3", "token_3");
expect(result).toEqual({ id: "rec_3", status: "approved" });
expect(apiClient.post).toHaveBeenCalledWith(
"/api/recommendations/approve",
{ recommendationId: "rec_3", status: "approved" },
expect.any(Object),
);
});
});

View File

@ -1,18 +1,52 @@
import axios from 'axios'; import axios, { type AxiosRequestConfig } from "axios";
import { API_BASE_URL } from '../config/api'; import { API_BASE_URL } from "../config/api";
import log from "../utils/logger";
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { timeout: 15000,
'Content-Type': 'application/json', headers: {
}, "Content-Type": "application/json",
},
}); });
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
log.warn("API unauthorized response", { url: error.config?.url });
} else if (status === 403) {
log.warn("API forbidden response", { url: error.config?.url });
} else if (status && status >= 500) {
log.error("API server error", error, {
status,
url: error.config?.url,
});
}
}
return Promise.reject(error);
},
);
export function withAuth(token?: string | null): AxiosRequestConfig {
if (!token) {
return {};
}
return {
headers: {
Authorization: `Bearer ${token}`,
},
};
}
// Helper to set the auth token for a request // Helper to set the auth token for a request
export const setAuthToken = (token: string) => { export const setAuthToken = (token: string) => {
if (token) { if (token) {
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
} else { } else {
delete apiClient.defaults.headers.common['Authorization']; delete apiClient.defaults.headers.common.Authorization;
} }
}; };

View File

@ -0,0 +1,56 @@
import { isAxiosError } from "axios";
import { API_ENDPOINTS } from "../config/api";
import { apiClient, withAuth } from "./client";
export interface Gym {
id: string;
name: string;
location?: string;
}
export const gymsApi = {
getGyms: async (token: string | null): Promise<Gym[]> => {
try {
const response = await apiClient.get<Gym[] | { data?: Gym[] }>(
API_ENDPOINTS.GYMS,
withAuth(token),
);
const payload = response.data;
if (Array.isArray(payload)) {
return payload;
}
if (payload && Array.isArray(payload.data)) {
return payload.data;
}
return [];
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch gyms: ${error.response.status}`);
}
throw new Error("Failed to fetch gyms");
}
},
updateUserGym: async (
gymId: string | null,
token: string | null,
): Promise<void> => {
try {
await apiClient.patch(
API_ENDPOINTS.USERS.GYM,
{ gymId },
withAuth(token),
);
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to update gym selection: ${error.response.status}`,
);
}
throw new Error("Failed to update gym selection");
}
},
};

View File

@ -13,3 +13,4 @@ export * from "./recommendations";
export * from "./nutrition"; export * from "./nutrition";
export * from "./hydration"; export * from "./hydration";
export * from "./client"; export * from "./client";
export * from "./gyms";

View File

@ -1,4 +1,5 @@
import { API_BASE_URL } from "../config/api"; import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
export interface Notification { export interface Notification {
id: string; id: string;
@ -25,66 +26,49 @@ interface ApiResponse<T> {
export async function fetchNotifications( export async function fetchNotifications(
token: string | null, token: string | null,
): Promise<Notification[]> { ): Promise<Notification[]> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", const response = await apiClient.get<ApiResponse<Notification[]>>(
}; "/api/notifications",
withAuth(token),
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/api/notifications`, {
method: "GET",
headers,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to fetch notifications: ${response.status} - ${errorText}`,
); );
const result = response.data;
if (result.success && result.data) {
return result.data.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
}));
}
return [];
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch notifications: ${error.response.status}`,
);
}
throw new Error("Failed to fetch notifications");
} }
const result: ApiResponse<Notification[]> = await response.json();
if (result.success && result.data) {
// Convert date strings to Date objects
return result.data.map((notification) => ({
...notification,
createdAt: new Date(notification.createdAt),
}));
}
return [];
} }
/** /**
* Get unread notification count * Get unread notification count
*/ */
export async function fetchUnreadCount(token: string | null): Promise<number> { export async function fetchUnreadCount(token: string | null): Promise<number> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", const response = await apiClient.get<ApiResponse<{ count: number }>>(
}; "/api/notifications/unread-count",
withAuth(token),
if (token) { );
headers["Authorization"] = `Bearer ${token}`; const result = response.data;
return result.success && result.data ? result.data.count : 0;
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch unread count: ${error.response.status}`);
}
throw new Error("Failed to fetch unread count");
} }
const response = await fetch(
`${API_BASE_URL}/api/notifications/unread-count`,
{
method: "GET",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to fetch unread count: ${response.status}`);
}
const result: ApiResponse<{ count: number }> = await response.json();
return result.success && result.data ? result.data.count : 0;
} }
/** /**
@ -94,62 +78,51 @@ export async function markAsRead(
notificationId: string, notificationId: string,
token: string | null, token: string | null,
): Promise<Notification> { ): Promise<Notification> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", const response = await apiClient.put<ApiResponse<Notification>>(
}; `/api/notifications/${notificationId}`,
{},
withAuth(token),
);
if (token) { const result = response.data;
headers["Authorization"] = `Bearer ${token}`; if (result.success && result.data) {
return {
...result.data,
createdAt: new Date(result.data.createdAt),
};
}
throw new Error("Invalid response format");
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark notification as read: ${error.response.status}`,
);
}
if (error instanceof Error && error.message === "Invalid response format") {
throw error;
}
throw new Error("Failed to mark notification as read");
} }
const response = await fetch(
`${API_BASE_URL}/api/notifications/${notificationId}`,
{
method: "PUT",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to mark notification as read: ${response.status}`);
}
const result: ApiResponse<Notification> = await response.json();
if (result.success && result.data) {
return {
...result.data,
createdAt: new Date(result.data.createdAt),
};
}
throw new Error("Invalid response format");
} }
/** /**
* Mark all notifications as read * Mark all notifications as read
*/ */
export async function markAllAsRead(token: string | null): Promise<void> { export async function markAllAsRead(token: string | null): Promise<void> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", await apiClient.post(
}; "/api/notifications/mark-all-read",
{},
if (token) { withAuth(token),
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}/api/notifications/mark-all-read`,
{
method: "POST",
headers,
},
);
if (!response.ok) {
throw new Error(
`Failed to mark all notifications as read: ${response.status}`,
); );
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to mark all notifications as read: ${error.response.status}`,
);
}
throw new Error("Failed to mark all notifications as read");
} }
} }
@ -160,24 +133,18 @@ export async function deleteNotification(
notificationId: string, notificationId: string,
token: string | null, token: string | null,
): Promise<void> { ): Promise<void> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", await apiClient.delete(
}; `/api/notifications/${notificationId}`,
withAuth(token),
if (token) { );
headers["Authorization"] = `Bearer ${token}`; } catch (error) {
} if (isAxiosError(error) && error.response) {
throw new Error(
const response = await fetch( `Failed to delete notification: ${error.response.status}`,
`${API_BASE_URL}/api/notifications/${notificationId}`, );
{ }
method: "DELETE", throw new Error("Failed to delete notification");
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to delete notification: ${response.status}`);
} }
} }
@ -189,21 +156,16 @@ export async function savePushToken(
deviceType: "ios" | "android", deviceType: "ios" | "android",
token: string | null, token: string | null,
): Promise<void> { ): Promise<void> {
const headers: Record<string, string> = { try {
"Content-Type": "application/json", await apiClient.post(
}; "/api/notifications/save-token",
{ expoPushToken, deviceType },
if (token) { withAuth(token),
headers["Authorization"] = `Bearer ${token}`; );
} } catch (error) {
if (isAxiosError(error) && error.response) {
const response = await fetch(`${API_BASE_URL}/api/notifications/save-token`, { throw new Error(`Failed to save push token: ${error.response.status}`);
method: "POST", }
headers, throw new Error("Failed to save push token");
body: JSON.stringify({ expoPushToken, deviceType }),
});
if (!response.ok) {
throw new Error(`Failed to save push token: ${response.status}`);
} }
} }

View File

@ -1,4 +1,6 @@
import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; import { isAxiosError } from "axios";
import { apiClient, withAuth } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface Recommendation { export interface Recommendation {
id: string; id: string;
@ -21,6 +23,15 @@ export interface GenerateRecommendationRequest {
modelProvider?: "openai" | "deepseek" | "ollama"; modelProvider?: "openai" | "deepseek" | "ollama";
} }
interface ApiResponse<T> {
success?: boolean;
data?: T;
}
function isApiResponse<T>(value: unknown): value is ApiResponse<T> {
return typeof value === "object" && value !== null && "success" in value;
}
/** /**
* Get recommendations for a user * Get recommendations for a user
* *
@ -32,28 +43,29 @@ export async function getRecommendations(
userId: string, userId: string,
token: string | null, token: string | null,
): Promise<Recommendation[]> { ): Promise<Recommendation[]> {
const headers: any = { let result: ApiResponse<Recommendation[]> | Recommendation[];
"Content-Type": "application/json", try {
}; const response = await apiClient.get(API_ENDPOINTS.RECOMMENDATIONS, {
params: { userId },
if (token) { ...withAuth(token),
headers["Authorization"] = `Bearer ${token}`; });
result = response.data;
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch recommendations: ${error.response.status}`,
);
}
throw new Error("Failed to fetch recommendations");
} }
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{ headers },
);
if (!response.ok) {
throw new Error(`Failed to fetch recommendations: ${response.status}`);
}
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} } // API returns: { success: true, data: [...], meta: {...} }
if (result.success && result.data) { if (
isApiResponse<Recommendation[]>(result) &&
result.success &&
result.data
) {
return Array.isArray(result.data) ? result.data : []; return Array.isArray(result.data) ? result.data : [];
} }
@ -72,36 +84,30 @@ export async function generateRecommendation(
data: GenerateRecommendationRequest, data: GenerateRecommendationRequest,
token: string | null, token: string | null,
): Promise<Recommendation> { ): Promise<Recommendation> {
const headers: any = { let result: ApiResponse<Recommendation> | Recommendation;
"Content-Type": "application/json", try {
}; const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
if (token) { data,
headers["Authorization"] = `Bearer ${token}`; withAuth(token),
);
result = response.data;
} catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(
`Failed to generate recommendation: ${error.response.status}`,
);
}
throw new Error("Failed to generate recommendation");
} }
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/generate`,
{
method: "POST",
headers,
body: JSON.stringify(data),
},
);
if (!response.ok) {
throw new Error(`Failed to generate recommendation: ${response.status}`);
}
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
if (result.success && result.data) { if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data; return result.data;
} }
// Fallback for legacy format // Fallback for legacy format
return result; return result as Recommendation;
} }
/** /**
@ -115,40 +121,32 @@ export async function generateRecommendation(
export async function approveRecommendation( export async function approveRecommendation(
recommendationId: string, recommendationId: string,
token: string | null, token: string | null,
approvedBy?: string,
): Promise<Recommendation> { ): Promise<Recommendation> {
const headers: any = { let result: ApiResponse<Recommendation> | Recommendation;
"Content-Type": "application/json", try {
}; const response = await apiClient.post(
`${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
if (token) { {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}/approve`,
{
method: "POST",
headers,
body: JSON.stringify({
recommendationId, recommendationId,
status: "approved", status: "approved",
approvedBy, },
}), withAuth(token),
}, );
); result = response.data;
} catch (error) {
if (!response.ok) { if (isAxiosError(error) && error.response) {
throw new Error(`Failed to approve recommendation: ${response.status}`); throw new Error(
`Failed to approve recommendation: ${error.response.status}`,
);
}
throw new Error("Failed to approve recommendation");
} }
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
if (result.success && result.data) { if (isApiResponse<Recommendation>(result) && result.success && result.data) {
return result.data; return result.data;
} }
// Fallback for legacy format // Fallback for legacy format
return result; return result as Recommendation;
} }

View File

@ -12,7 +12,7 @@ import {
import { useUser, useAuth } from "@clerk/clerk-expo"; import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { fitnessProfileApi } from "@/api/fitnessProfile"; import { fitnessProfileApi } from "@/api/fitnessProfile";
import { API_BASE_URL } from "@/config/api"; import { gymsApi, type Gym } from "@/api/gyms";
import log from "../../utils/logger"; import log from "../../utils/logger";
export default function OnboardingScreen() { export default function OnboardingScreen() {
@ -20,9 +20,7 @@ export default function OnboardingScreen() {
const { getToken } = useAuth(); const { getToken } = useAuth();
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [gyms, setGyms] = useState< const [gyms, setGyms] = useState<Gym[]>([]);
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(false); const [gymsLoading, setGymsLoading] = useState<boolean>(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null); const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
@ -31,13 +29,8 @@ export default function OnboardingScreen() {
try { try {
setGymsLoading(true); setGymsLoading(true);
const token = await getToken(); const token = await getToken();
const res = await fetch(`${API_BASE_URL}/api/gyms`, { const data = await gymsApi.getGyms(token);
headers: token ? { Authorization: `Bearer ${token}` } : undefined, setGyms(data);
});
const data = await res.json();
if (Array.isArray(data)) {
setGyms(data);
}
} catch (e) { } catch (e) {
log.error("Failed to fetch gyms", e); log.error("Failed to fetch gyms", e);
} finally { } finally {
@ -82,14 +75,7 @@ export default function OnboardingScreen() {
// If gym was selected or cleared, patch user's gym selection first // If gym was selected or cleared, patch user's gym selection first
// selectedGymId: string gym id, or null to proceed without gym // selectedGymId: string gym id, or null to proceed without gym
try { try {
await fetch(`${API_BASE_URL}/api/users/gym`, { await gymsApi.updateUserGym(selectedGymId, token);
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ gymId: selectedGymId }),
});
} catch (e) { } catch (e) {
log.warn("Failed to update gym selection", { gymId: selectedGymId }); log.warn("Failed to update gym selection", { gymId: selectedGymId });
} }

View File

@ -18,8 +18,8 @@ import { ListItem } from "../../components/ListItem";
import { MinimalButton } from "../../components/MinimalButton"; import { MinimalButton } from "../../components/MinimalButton";
import { Badge } from "../../components/Badge"; import { Badge } from "../../components/Badge";
import { IconContainer } from "../../components/IconContainer"; import { IconContainer } from "../../components/IconContainer";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile"; import { fitnessProfileApi, FitnessProfile } from "../../api/fitnessProfile";
import { gymsApi, type Gym } from "../../api/gyms";
import log from "../../utils/logger"; import log from "../../utils/logger";
export default function ProfileScreen() { export default function ProfileScreen() {
@ -29,9 +29,7 @@ export default function ProfileScreen() {
const { colors, typography, theme: activeTheme, setTheme } = useTheme(); const { colors, typography, theme: activeTheme, setTheme } = useTheme();
const { getToken } = useAuth(); const { getToken } = useAuth();
const [gyms, setGyms] = useState< const [gyms, setGyms] = useState<Gym[]>([]);
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState(false); const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null); const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const [currentGymId, setCurrentGymId] = useState<string | null>(null); const [currentGymId, setCurrentGymId] = useState<string | null>(null);
@ -76,51 +74,14 @@ export default function ProfileScreen() {
try { try {
setGymsLoading(true); setGymsLoading(true);
const token = await getToken(); const token = await getToken();
const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`; const list = await gymsApi.getGyms(token);
log.debug("Loading gyms", { url });
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
const text = await res.text().catch(() => "");
log.error(
"Failed to fetch gyms - non-OK response",
new Error(text.slice(0, 200)),
{ status: res.status },
);
setGyms([]);
return;
}
if (!contentType.includes("application/json")) {
const text = await res.text().catch(() => "");
log.error(
"Failed to fetch gyms - expected JSON",
new Error(text.slice(0, 200)),
{ contentType },
);
setGyms([]);
return;
}
let data: any = null;
try {
data = await res.json();
} catch (e) {
const text = await res.text().catch(() => "");
log.error("Failed to parse gyms JSON", e, {
bodyPreview: text?.slice(0, 200),
});
setGyms([]);
return;
}
const list = Array.isArray(data) ? data : [];
setGyms(list); setGyms(list);
const gid = const gid =
currentGymId ?? currentGymId ??
((user?.publicMetadata as any)?.gymId as string | undefined) ?? ((user?.publicMetadata as any)?.gymId as string | undefined) ??
null; null;
if (gid) { if (gid) {
const g = list.find((x: any) => x.id === gid); const g = list.find((x) => x.id === gid);
setCurrentGymId(gid); setCurrentGymId(gid);
setCurrentGymName(g?.name ?? null); setCurrentGymName(g?.name ?? null);
if (selectedGymId === null) setSelectedGymId(gid); if (selectedGymId === null) setSelectedGymId(gid);
@ -136,42 +97,7 @@ export default function ProfileScreen() {
const handleApplyGym = async () => { const handleApplyGym = async () => {
try { try {
const token = await getToken(); const token = await getToken();
const url = `${API_BASE_URL}${API_ENDPOINTS.USERS.GYM}`; await gymsApi.updateUserGym(selectedGymId, token);
log.debug("Updating gym selection", {
url,
gymId: selectedGymId,
token: token ? "present" : "missing",
});
const res = await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ gymId: selectedGymId }),
});
const contentType = res.headers.get("content-type") || "";
if (!res.ok) {
const text = await res.text().catch(() => "");
log.error(
"Failed to update gym selection - non-OK response",
new Error(text.slice(0, 200)),
{ status: res.status },
);
Alert.alert("Error", "Failed to update gym selection");
return;
}
if (contentType.includes("application/json")) {
try {
const data = await res.json();
log.debug("Gym selection updated", { data });
} catch (e) {
const text = await res.text().catch(() => "");
log.error("Failed to parse update response JSON", e, {
bodyPreview: text?.slice(0, 200),
});
}
}
setCurrentGymId(selectedGymId); setCurrentGymId(selectedGymId);
setCurrentGymName( setCurrentGymName(
selectedGymId selectedGymId

View File

@ -3,6 +3,7 @@ import React, {
useContext, useContext,
useState, useState,
useCallback, useCallback,
useEffect,
useRef, useRef,
} from "react"; } from "react";
import { useUser, useAuth } from "@clerk/clerk-expo"; import { useUser, useAuth } from "@clerk/clerk-expo";
@ -168,12 +169,22 @@ export function FitnessGoalsProvider({
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
setGoals([]); setGoals([]);
setLoading(false);
setLastFetchTime(0); setLastFetchTime(0);
setError(null); setError(null);
fetchInProgress.current = false; fetchInProgress.current = false;
log.debug("Fitness goals cache cleared"); log.debug("Fitness goals cache cleared");
}, []); }, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Fitness goals cache reset for user", { userId: user.id });
} else {
log.debug("Fitness goals cache reset on sign-out");
}
}, [user?.id, clearCache]);
return ( return (
<FitnessGoalsContext.Provider <FitnessGoalsContext.Provider
value={{ value={{

View File

@ -74,6 +74,18 @@ export function HydrationProvider({ children }: { children: React.ReactNode }) {
fetchTodayHydration(); fetchTodayHydration();
}, [fetchTodayHydration]); }, [fetchTodayHydration]);
useEffect(() => {
setHydration(null);
setError(null);
setLoading(false);
setWaterGoal(2000);
if (user?.id) {
log.debug("Hydration state reset for user", { userId: user.id });
} else {
log.debug("Hydration state reset on sign-out");
}
}, [user?.id]);
const addWater = useCallback( const addWater = useCallback(
async (amount: number) => { async (amount: number) => {
if (!user?.id) return; if (!user?.id) return;

View File

@ -6,7 +6,7 @@ import React, {
useCallback, useCallback,
useRef, useRef,
} from "react"; } from "react";
import { useAuth } from "@clerk/clerk-expo"; import { useAuth, useUser } from "@clerk/clerk-expo";
import { import {
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
@ -37,6 +37,7 @@ export function NotificationsProvider({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { getToken, isSignedIn } = useAuth(); const { getToken, isSignedIn } = useAuth();
const { user } = useUser();
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -160,6 +161,19 @@ export function NotificationsProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]); // Only run when sign-in state changes }, [isSignedIn]); // Only run when sign-in state changes
useEffect(() => {
setNotifications([]);
setUnreadCount(0);
setLoading(false);
fetchInProgressRef.current = false;
lastFetchTimeRef.current = 0;
if (user?.id) {
log.debug("Notifications state reset for user", { userId: user.id });
} else {
log.debug("Notifications state reset on sign-out");
}
}, [user?.id]);
// Periodic refresh every 30 seconds // Periodic refresh every 30 seconds
useEffect(() => { useEffect(() => {
if (!isSignedIn) return; if (!isSignedIn) return;

View File

@ -88,6 +88,19 @@ export function NutritionProvider({ children }: { children: React.ReactNode }) {
fetchTodayNutrition(); fetchTodayNutrition();
}, [fetchTodayNutrition]); }, [fetchTodayNutrition]);
useEffect(() => {
setNutrition(null);
setMeals([]);
setError(null);
setLoading(false);
setCalorieGoal(2000);
if (user?.id) {
log.debug("Nutrition state reset for user", { userId: user.id });
} else {
log.debug("Nutrition state reset on sign-out");
}
}, [user?.id]);
const addMeal = useCallback( const addMeal = useCallback(
async (data: Omit<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => { async (data: Omit<MealEntry, "id" | "createdAt" | "dailyNutritionId">) => {
if (!user?.id) return; if (!user?.id) return;

View File

@ -3,6 +3,7 @@ import React, {
useContext, useContext,
useState, useState,
useCallback, useCallback,
useEffect,
useRef, useRef,
} from "react"; } from "react";
import { useUser, useAuth } from "@clerk/clerk-expo"; import { useUser, useAuth } from "@clerk/clerk-expo";
@ -108,12 +109,22 @@ export function RecommendationsProvider({
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
setRecommendations([]); setRecommendations([]);
setLoading(false);
setLastFetchTime(0); setLastFetchTime(0);
setError(null); setError(null);
fetchInProgress.current = false; fetchInProgress.current = false;
log.debug("Recommendations cache cleared"); log.debug("Recommendations cache cleared");
}, []); }, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Recommendations cache reset for user", { userId: user.id });
} else {
log.debug("Recommendations cache reset on sign-out");
}
}, [user?.id, clearCache]);
return ( return (
<RecommendationsContext.Provider <RecommendationsContext.Provider
value={{ value={{

View File

@ -1,4 +1,10 @@
import React, { createContext, useContext, useState, useCallback } from "react"; import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from "react";
import { useUser, useAuth } from "@clerk/clerk-expo"; import { useUser, useAuth } from "@clerk/clerk-expo";
import { getUserStatistics } from "../api/statistics"; import { getUserStatistics } from "../api/statistics";
import type { UserStatisticsResponse } from "../api/types"; import type { UserStatisticsResponse } from "../api/types";
@ -75,11 +81,21 @@ export function StatisticsProvider({
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
setStatistics(null); setStatistics(null);
setLoading(false);
setLastFetchTime(0); setLastFetchTime(0);
setError(null); setError(null);
log.debug("Statistics cache cleared"); log.debug("Statistics cache cleared");
}, []); }, []);
useEffect(() => {
clearCache();
if (user?.id) {
log.debug("Statistics cache reset for user", { userId: user.id });
} else {
log.debug("Statistics cache reset on sign-out");
}
}, [user?.id, clearCache]);
const forceRefresh = useCallback(async () => { const forceRefresh = useCallback(async () => {
if (!user?.id) return; if (!user?.id) return;

View File

@ -1,4 +1,6 @@
import { API_BASE_URL, API_ENDPOINTS } from "../config/api"; import { isAxiosError } from "axios";
import { apiClient, withAuth } from "../api/client";
import { API_ENDPOINTS } from "../config/api";
import log from "../utils/logger"; import log from "../utils/logger";
export interface FitnessGoal { export interface FitnessGoal {
@ -41,38 +43,21 @@ export interface CreateGoalData {
} }
export class FitnessGoalsService { export class FitnessGoalsService {
private async getAuthHeaders(token: string | null): Promise<any> {
const headers: any = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
async getGoals( async getGoals(
userId: string, userId: string,
token: string | null, token: string | null,
status?: string, status?: string,
): Promise<FitnessGoal[]> { ): Promise<FitnessGoal[]> {
try { try {
const headers = await this.getAuthHeaders(token); const response = await apiClient.get(API_ENDPOINTS.FITNESS_GOALS.LIST, {
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`; params: {
userId,
...(status && status !== "all" ? { status } : {}),
},
...withAuth(token),
});
if (status && status !== "all") { const result = response.data;
url += `&status=${status}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch goals: ${response.status}`);
}
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
// API returns: { success: true, data: [...], meta: {...} } // API returns: { success: true, data: [...], meta: {...} }
@ -83,6 +68,9 @@ export class FitnessGoalsService {
// Fallback for legacy format (direct array) // Fallback for legacy format (direct array)
return Array.isArray(result) ? result : []; return Array.isArray(result) ? result : [];
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to fetch goals: ${error.response.status}`);
}
log.error("Failed to fetch fitness goals", error); log.error("Failed to fetch fitness goals", error);
throw error; throw error;
} }
@ -93,22 +81,13 @@ export class FitnessGoalsService {
token: string | null, token: string | null,
): Promise<FitnessGoal> { ): Promise<FitnessGoal> {
try { try {
const headers = await this.getAuthHeaders(token); const response = await apiClient.post(
const response = await fetch( API_ENDPOINTS.FITNESS_GOALS.CREATE,
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, goalData,
{ withAuth(token),
method: "POST",
headers,
body: JSON.stringify(goalData),
},
); );
if (!response.ok) { const result = response.data;
const error = await response.json();
throw new Error(error.error || "Failed to create goal");
}
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
if (result.success && result.data) { if (result.success && result.data) {
@ -118,6 +97,14 @@ export class FitnessGoalsService {
// Fallback for legacy format // Fallback for legacy format
return result; return result;
} catch (error) { } catch (error) {
if (isAxiosError(error)) {
const message =
(error.response?.data as { error?: string } | undefined)?.error ||
(error.response
? `Failed to create goal: ${error.response.status}`
: "Failed to create goal");
throw new Error(message);
}
log.error("Failed to create fitness goal", error); log.error("Failed to create fitness goal", error);
throw error; throw error;
} }
@ -129,21 +116,13 @@ export class FitnessGoalsService {
token: string | null, token: string | null,
): Promise<FitnessGoal> { ): Promise<FitnessGoal> {
try { try {
const headers = await this.getAuthHeaders(token); const response = await apiClient.put(
const response = await fetch( API_ENDPOINTS.FITNESS_GOALS.UPDATE(id),
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, updates,
{ withAuth(token),
method: "PUT",
headers,
body: JSON.stringify(updates),
},
); );
if (!response.ok) { const result = response.data;
throw new Error("Failed to update goal");
}
const result = await response.json();
// Handle standardized API response format // Handle standardized API response format
if (result.success && result.data) { if (result.success && result.data) {
@ -153,6 +132,9 @@ export class FitnessGoalsService {
// Fallback for legacy format // Fallback for legacy format
return result; return result;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to update goal: ${error.response.status}`);
}
log.error("Failed to update fitness goal", error); log.error("Failed to update fitness goal", error);
throw error; throw error;
} }
@ -168,20 +150,13 @@ export class FitnessGoalsService {
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> { async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
try { try {
const headers = await this.getAuthHeaders(token); const response = await apiClient.post(
const response = await fetch( API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id),
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {},
{ withAuth(token),
method: "POST",
headers,
},
); );
if (!response.ok) { const result = response.data;
throw new Error("Failed to complete goal");
}
const result = await response.json();
// Note: Complete endpoint returns direct object (legacy format) // Note: Complete endpoint returns direct object (legacy format)
// Handle standardized API response format (if migrated) // Handle standardized API response format (if migrated)
@ -192,6 +167,9 @@ export class FitnessGoalsService {
// Fallback for legacy format (current implementation) // Fallback for legacy format (current implementation)
return result; return result;
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to complete goal: ${error.response.status}`);
}
log.error("Failed to complete fitness goal", error); log.error("Failed to complete fitness goal", error);
throw error; throw error;
} }
@ -199,22 +177,17 @@ export class FitnessGoalsService {
async deleteGoal(id: string, token: string | null): Promise<void> { async deleteGoal(id: string, token: string | null): Promise<void> {
try { try {
const headers = await this.getAuthHeaders(token); await apiClient.delete(
const response = await fetch( API_ENDPOINTS.FITNESS_GOALS.DELETE(id),
`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, withAuth(token),
{
method: "DELETE",
headers,
},
); );
if (!response.ok) {
throw new Error("Failed to delete goal");
}
// DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} } // DELETE endpoint returns: { success: true, data: { deleted: true }, meta: {...} }
// No need to parse the result for void return type // No need to parse the result for void return type
} catch (error) { } catch (error) {
if (isAxiosError(error) && error.response) {
throw new Error(`Failed to delete goal: ${error.response.status}`);
}
log.error("Failed to delete fitness goal", error); log.error("Failed to delete fitness goal", error);
throw error; throw error;
} }