Compare commits

..

No commits in common. "34e88bdde5f29437b5c75d67eef02ff85d0d888e" and "c36cad9c54ea99f742b71f6cccc671027949db53" have entirely different histories.

17 changed files with 398 additions and 542 deletions

View File

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

View File

@ -1,55 +0,0 @@
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

@ -1,68 +0,0 @@
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,52 +1,18 @@
import axios, { type AxiosRequestConfig } from "axios"; import axios 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,
timeout: 15000,
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: { headers: {
Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
}, },
}; });
}
// 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

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

View File

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

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 { gymsApi, type Gym } from "@/api/gyms"; import { API_BASE_URL } from "@/config/api";
import log from "../../utils/logger"; import log from "../../utils/logger";
export default function OnboardingScreen() { export default function OnboardingScreen() {
@ -20,7 +20,9 @@ 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<Gym[]>([]); const [gyms, setGyms] = useState<
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);
@ -29,8 +31,13 @@ export default function OnboardingScreen() {
try { try {
setGymsLoading(true); setGymsLoading(true);
const token = await getToken(); const token = await getToken();
const data = await gymsApi.getGyms(token); const res = await fetch(`${API_BASE_URL}/api/gyms`, {
setGyms(data); headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
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 {
@ -75,7 +82,14 @@ 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 gymsApi.updateUserGym(selectedGymId, token); await fetch(`${API_BASE_URL}/api/users/gym`, {
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,7 +29,9 @@ 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<Gym[]>([]); const [gyms, setGyms] = useState<
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);
@ -74,14 +76,51 @@ export default function ProfileScreen() {
try { try {
setGymsLoading(true); setGymsLoading(true);
const token = await getToken(); const token = await getToken();
const list = await gymsApi.getGyms(token); const url = `${API_BASE_URL}${API_ENDPOINTS.GYMS}`;
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) => x.id === gid); const g = list.find((x: any) => 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);
@ -97,7 +136,42 @@ export default function ProfileScreen() {
const handleApplyGym = async () => { const handleApplyGym = async () => {
try { try {
const token = await getToken(); const token = await getToken();
await gymsApi.updateUserGym(selectedGymId, token); const url = `${API_BASE_URL}${API_ENDPOINTS.USERS.GYM}`;
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,7 +3,6 @@ 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";
@ -169,22 +168,12 @@ 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,18 +74,6 @@ 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, useUser } from "@clerk/clerk-expo"; import { useAuth } from "@clerk/clerk-expo";
import { import {
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
@ -37,7 +37,6 @@ 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);
@ -161,19 +160,6 @@ 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,19 +88,6 @@ 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,7 +3,6 @@ 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";
@ -109,22 +108,12 @@ 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,10 +1,4 @@
import React, { import React, { createContext, useContext, useState, useCallback } from "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";
@ -81,21 +75,11 @@ 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,6 +1,4 @@
import { isAxiosError } from "axios"; import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
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 {
@ -43,21 +41,38 @@ 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 response = await apiClient.get(API_ENDPOINTS.FITNESS_GOALS.LIST, { const headers = await this.getAuthHeaders(token);
params: { let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
userId,
...(status && status !== "all" ? { status } : {}),
},
...withAuth(token),
});
const result = response.data; if (status && status !== "all") {
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: {...} }
@ -68,9 +83,6 @@ 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;
} }
@ -81,13 +93,22 @@ export class FitnessGoalsService {
token: string | null, token: string | null,
): Promise<FitnessGoal> { ): Promise<FitnessGoal> {
try { try {
const response = await apiClient.post( const headers = await this.getAuthHeaders(token);
API_ENDPOINTS.FITNESS_GOALS.CREATE, const response = await fetch(
goalData, `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`,
withAuth(token), {
method: "POST",
headers,
body: JSON.stringify(goalData),
},
); );
const result = response.data; if (!response.ok) {
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) {
@ -97,14 +118,6 @@ 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;
} }
@ -116,13 +129,21 @@ export class FitnessGoalsService {
token: string | null, token: string | null,
): Promise<FitnessGoal> { ): Promise<FitnessGoal> {
try { try {
const response = await apiClient.put( const headers = await this.getAuthHeaders(token);
API_ENDPOINTS.FITNESS_GOALS.UPDATE(id), const response = await fetch(
updates, `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`,
withAuth(token), {
method: "PUT",
headers,
body: JSON.stringify(updates),
},
); );
const result = response.data; if (!response.ok) {
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) {
@ -132,9 +153,6 @@ 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;
} }
@ -150,13 +168,20 @@ export class FitnessGoalsService {
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> { async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
try { try {
const response = await apiClient.post( const headers = await this.getAuthHeaders(token);
API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id), const response = await fetch(
{}, `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`,
withAuth(token), {
method: "POST",
headers,
},
); );
const result = response.data; if (!response.ok) {
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)
@ -167,9 +192,6 @@ 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;
} }
@ -177,17 +199,22 @@ export class FitnessGoalsService {
async deleteGoal(id: string, token: string | null): Promise<void> { async deleteGoal(id: string, token: string | null): Promise<void> {
try { try {
await apiClient.delete( const headers = await this.getAuthHeaders(token);
API_ENDPOINTS.FITNESS_GOALS.DELETE(id), const response = await fetch(
withAuth(token), `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`,
{
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;
} }