Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d74aa22
fix(infra): migrate logging and garmin sync to typescript
serjsv87 Apr 5, 2026
e9dd1fa
refactor: migrate Garmin services to TypeScript
serjsv87 Apr 6, 2026
b3a0314
feat: add MyFitnessPal integration and settings UI
serjsv87 Apr 6, 2026
05954fe
feat: add telegram integration and chat service
serjsv87 Apr 6, 2026
f046cee
feat: add telegram integration translations
serjsv87 Apr 6, 2026
562c7e3
fix(infra): migrate logging and garmin sync to typescript
serjsv87 Apr 5, 2026
3a04e5f
feat: add Edamam and FatSecret food integration
serjsv87 Apr 6, 2026
8153d6f
feat: add edamam and fatsecret translations
serjsv87 Apr 6, 2026
1897bcd
Fix regression in shared/src/index.ts: Restored database schemas and …
serjsv87 Apr 6, 2026
a3dcb7f
CI: Fix frontend types, add MFP risk warning, and move Telegram docum…
serjsv87 Apr 6, 2026
b67ad72
style: fix formatting across frontend and shared
serjsv87 Apr 6, 2026
9c1c0f0
feat: add telegram integration translations
serjsv87 Apr 6, 2026
92cd958
refactor(edamam-fatsecret): address code review feedback
serjsv87 Apr 7, 2026
64c1e61
fix: remove duplicate edamam case in getProviderCategory switch
serjsv87 Apr 7, 2026
ae528c4
fix: allow editing Edamam app_id and app_key in settings
serjsv87 Apr 8, 2026
d44e0f9
fix: final unified project state with Edamam UI and duplication fixes
serjsv87 Apr 8, 2026
08a09c8
feat: complete edamam integration with default provider support
serjsv87 Apr 8, 2026
dd3556b
Fix Edamam search, clean up food providers, and fix server crash on e…
serjsv87 Apr 8, 2026
b898f7f
style: fix formatting in edamam integration
serjsv87 Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ docker/docker_volume/

# TypeScript
*.tsbuildinfo
pnpm-lock.yaml
17 changes: 16 additions & 1 deletion SparkyFitnessFrontend/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,22 @@
"milliliters": "Milliliters (ml)",
"fluidOunces": "Fluid Ounces (oz)",
"cups": "Cups",
"saveWaterDisplayUnit": "Save Water Display Unit"
"saveWaterDisplayUnit": "Save Water Display Unit",
"telegram_status": "Telegram Bot Status",
"telegram_not_linked": "Not Linked",
"telegram_linked": "Linked (Chat ID: {{chatId}})",
"telegram_link_button": "Link Telegram Account",
"telegram_unlink_button": "Unlink Telegram",
"telegram_code_description": "Send this code to the bot to link your account:",
"telegram_get_code": "Generate Link Code",
"telegram_integration_description": "Log food and exercises directly from Telegram using AI.",
"telegram_bot_token": "Telegram Bot Token",
"telegram_bot_name": "Telegram Bot Name",
"telegram_webhook_url": "Telegram Webhook URL",
"edamam_app_id": "Edamam App ID",
"edamam_app_key": "Edamam App Key",
"fatsecret_client_id": "FatSecret Client ID",
"fatsecret_client_secret": "FatSecret Client Secret"
},
"nutrientDisplay": {
"title": "Nutrient Display",
Expand Down
31 changes: 29 additions & 2 deletions SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,21 @@ export const handleManualSyncStrava = async (
}
};

export const handleManualSyncMFP = async (
startDate?: string,
endDate?: string
) => {
try {
await apiCall(`/integrations/myfitnesspal/sync`, {
method: 'POST',
body: JSON.stringify({ startDate, endDate }),
});
} catch (error: unknown) {
console.error('Error initiating manual MFP sync:', error);
throw error;
}
};

export const fetchBaseProviders = async (): Promise<ExternalDataProvider[]> => {
return apiCall('/external-providers', {
method: 'GET',
Expand All @@ -325,8 +340,10 @@ export const fetchGarminStatus = async (): Promise<GarminStatusResponse> => {
};

export interface OAuthStatusResponse {
lastSyncAt: string;
tokenExpiresAt: string;
lastSyncAt?: string;
tokenExpiresAt?: string;
isLinked?: boolean;
lastUpdated?: string;
}

export const fetchWithingsStatus = async (
Expand Down Expand Up @@ -364,6 +381,10 @@ export const fetchStravaStatus = async (): Promise<OAuthStatusResponse> => {
return apiCall('/integrations/strava/status');
};

export const fetchMFPStatus = async (): Promise<OAuthStatusResponse> => {
return apiCall('/integrations/myfitnesspal/status');
};

export const getEnrichedProviders = async (): Promise<
ExternalDataProvider[]
> => {
Expand Down Expand Up @@ -429,6 +450,12 @@ export const getEnrichedProviders = async (): Promise<
}
break;
}
case 'myfitnesspal': {
const status = await fetchMFPStatus();
enriched.has_token = status.isLinked;
enriched.last_sync_at = status.lastUpdated; // Standard field
break;
}
}
} catch (error) {
console.error(
Expand Down
88 changes: 72 additions & 16 deletions SparkyFitnessFrontend/src/components/FoodSearch/FoodSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
Expand Down Expand Up @@ -70,6 +70,10 @@ export type ExternalResultWrapper =
| {
provider_type: 'tandoor';
food: Food;
}
| {
provider_type: 'edamam';
food: Food;
};

interface EnhancedFoodSearchProps {
Expand Down Expand Up @@ -153,11 +157,21 @@ const EnhancedFoodSearch = ({
const foods = searchData?.searchResults || [];
const loading = isFetchingRecent || isFetchingSearch || isOnlineLoading;

const selectedFoodDataProvider =
manualProviderId ||
defaultFoodDataProviderId ||
foodDataProviders[0]?.id ||
null;
const selectedFoodDataProviderId = useMemo(() => {
const id = manualProviderId || defaultFoodDataProviderId;
if (id && foodDataProviders.find((p) => p.id === id)) {
return id;
}
return (
foodDataProviders.find(
(p) => getProviderCategory(p).includes('food') && p.is_active
)?.id || null
);
}, [manualProviderId, defaultFoodDataProviderId, foodDataProviders]);

const selectedFoodDataProvider = foodDataProviders.find(
(p) => p.id === selectedFoodDataProviderId
);

// Barcode provider: prefer explicit user selection, then the dedicated barcode
// provider preference (set in External Provider Settings → Default Barcode Provider),
Expand Down Expand Up @@ -358,6 +372,17 @@ const EnhancedFoodSearch = ({
}))
);
},
edamam: async (term, id) => {
const data = await queryClient.fetchQuery(
searchFoodsV2Options('edamam', term, id)
);
setExternalResults(
data.foods.map((food: Food) => ({
provider_type: 'edamam' as const,
food,
}))
);
},
};

const handleSearch = async () => {
Expand All @@ -374,19 +399,48 @@ const EnhancedFoodSearch = ({
await handleMealSearch(searchTerm);
} else if (activeTab === 'online') {
setHasOnlineSearchBeenPerformed(true);
const provider = foodDataProviders.find(
(p) => p.id === selectedFoodDataProvider
);

if (provider && searchHandlers[provider.provider_type]) {
const provider = selectedFoodDataProvider;
const providerType = provider?.provider_type?.toLowerCase()?.trim();

console.group('Food Search Diagnostics');
console.log('Selected Provider ID:', selectedFoodDataProviderId);
console.log('Found Provider:', provider);
console.log('Provider Type:', providerType);
console.log('Available Handlers:', Object.keys(searchHandlers));
console.log('Search Term:', searchTerm);
console.groupEnd();

if (provider && providerType && searchHandlers[providerType]) {
setSearchProviderId(provider.id);
const searchHandler = searchHandlers[provider.provider_type];
if (searchHandler)
await searchHandler(searchTerm, provider.id, provider);
const searchHandler = searchHandlers[providerType];
if (searchHandler) {
try {
await searchHandler(searchTerm, provider.id, provider);
} catch (error) {
console.error('Search handler error:', error);
toast({
title: t('common.error'),
description: t(
'enhancedFoodSearch.searchError',
'Failed to perform search.'
),
variant: 'destructive',
});
}
}
} else {
const errorDetail = !provider
? 'Provider not found in list'
: !providerType
? 'Provider type is missing'
: !searchHandlers[providerType]
? `No handler for type: ${providerType}`
: 'Unknown error';

toast({
title: t('common.error'),
description: 'Provider not supported',
description: `Provider not supported (${errorDetail})`,
variant: 'destructive',
});
}
Expand Down Expand Up @@ -422,7 +476,9 @@ const EnhancedFoodSearch = ({

const handleExternalFoodEdit = async (food: Food) => {
const needsDetailFetch =
(food.provider_type === 'fatsecret' || food.provider_type === 'usda') &&
(food.provider_type === 'fatsecret' ||
food.provider_type === 'usda' ||
food.provider_type === 'edamam') &&
food.provider_external_id;

if (needsDetailFetch) {
Expand Down Expand Up @@ -560,7 +616,7 @@ const EnhancedFoodSearch = ({
</Button>
{activeTab === 'online' && (
<Select
value={selectedFoodDataProvider || ''}
value={selectedFoodDataProviderId || ''}
onValueChange={(value) => {
setManualProviderId(value);
setDefaultFoodDataProviderId(value);
Expand Down
14 changes: 14 additions & 0 deletions SparkyFitnessFrontend/src/hooks/Integrations/useIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
fetchGarminStatus,
GarminMfaPayload,
resumeGarminLogin,
handleManualSyncMFP,
} from '@/api/Settings/externalProviderService';
import { garminKeys } from '@/api/keys/integrations';
import { externalProviderKeys } from '@/api/keys/settings';
Expand Down Expand Up @@ -288,6 +289,19 @@ export const useManualSyncStravaMutation = () => {
},
});
};

export const useManualSyncMFPMutation = () => {
const invalidateSyncData = useDiaryInvalidation();

return useMutation({
mutationFn: ({ startDate, endDate }: SyncVariables) =>
handleManualSyncMFP(startDate, endDate),
onSuccess: () => {
invalidateSyncData();
},
});
};

export interface GarminStatusResponse {
isLinked: boolean;
lastUpdated: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,18 @@ const AddExternalProviderForm = ({
});
}}
/>
{newProvider.provider_type === 'myfitnesspal' && (
<div
className="p-3 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 border border-red-200"
role="alert"
>
<span className="font-bold">⚠️ Strong Warning:</span> MyFitnessPal
synchronization will{' '}
<span className="underline">overwrite all manual entries</span> in
your MFP diary for the synced day. This ensures idempotency and
keeps SparkyFitness as the source of truth.
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="new_is_active"
Expand All @@ -295,6 +307,38 @@ const AddExternalProviderForm = ({
<Label htmlFor="new_is_active">Activate this provider</Label>
</div>

{[
'withings',
'garmin',
'fitbit',
'strava',
'polar',
'hevy',
'myfitnesspal',
].includes(newProvider.provider_type || '') && (
<div className="space-y-2">
<Label htmlFor="new_sync_frequency">Sync Frequency</Label>
<Select
value={newProvider.sync_frequency || 'manual'}
onValueChange={(value) =>
setNewProvider((prev) => ({
...prev,
sync_frequency: value as 'hourly' | 'daily' | 'manual',
}))
}
>
<SelectTrigger id="new_sync_frequency">
<SelectValue placeholder="Select sync frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
</SelectContent>
</Select>
</div>
)}

<div className="flex gap-2">
<Button disabled={isAnyIntegrationPending} type="submit">
<Save className="h-4 w-4 mr-2" />
Expand Down
Loading