Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c67e397
feat(310): update docs and icons
timothyrusso May 20, 2026
73db352
feat(310): use clerk profile image
timothyrusso May 21, 2026
8bae8fd
feat(310): use clerk user name
timothyrusso May 21, 2026
08df106
feat(310): update clerk library
timothyrusso May 21, 2026
d9ba224
feat(310): add new image provider
timothyrusso May 27, 2026
f802c3e
feat(310): add new food properties
timothyrusso May 27, 2026
d2539af
feat(310): increase food category keywords
timothyrusso May 27, 2026
a12f94c
feat(310): update food fallback image
timothyrusso May 28, 2026
7c30d02
feat(310): add typical dishes new modal
timothyrusso May 28, 2026
08950a9
feat(310): remove old modals
timothyrusso May 28, 2026
e433d1e
feat(310): add modal header
timothyrusso May 29, 2026
f270464
feat(310): add location to the modal header
timothyrusso May 29, 2026
0fb992a
feat(310): move dish list into the modal
timothyrusso May 29, 2026
322afc3
feat(310): style the dish list
timothyrusso May 30, 2026
ca81c7f
feat(310): use proper key labels for dishes
timothyrusso May 30, 2026
7f96fa2
feat(310): style the button for the dishes
timothyrusso May 30, 2026
2a6e895
feat(310): style the android top border modal
timothyrusso May 30, 2026
96db286
feat(310): type properly the form sheet options
timothyrusso May 30, 2026
703b332
fix(310): lint errors
timothyrusso May 30, 2026
19c19c9
fix(310): lint errors
timothyrusso May 30, 2026
ea04769
fix(310): guard for nested fields
timothyrusso Jun 1, 2026
4624103
fix(310): truncate long location names in TypicalDishesModalHeader
timothyrusso Jun 1, 2026
788ddba
fix(310): use searchTerm as stable key in TypicalDishesList
timothyrusso Jun 1, 2026
40ef180
fix(310): use endsWith for image extension check in WikimediaDishImag…
timothyrusso Jun 1, 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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

A smart travel planning assistant that leverages Google Gemini AI to create personalized trip itineraries. Simply input your destination, travel dates, budget, and number of travelers to receive customized travel plans tailored specifically to your needs - eliminating hours of research and planning.

## AI Generation Pipeline

<div align="center">
<img src="wiki/docs/diagrams/AI_Generation_Pipeline.png" alt="AI Generation Pipeline" width="100%">
Comment thread
timothyrusso marked this conversation as resolved.
</div>

## AI Model

HolidAI is powered by Google's Gemini 2.5 Flash model, a state-of-the-art large language model specifically optimized for fast, efficient responses while maintaining high-quality outputs. The model is particularly well-suited for travel planning tasks due to its:
Expand Down
8 changes: 4 additions & 4 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"scheme": "holidai",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./features/core/ui/assets/images/logo_round.png",
"image": "./features/core/ui/assets/images/logo.png",
"resizeMode": "contain",
"backgroundColor": "#220059"
},
Expand Down Expand Up @@ -53,13 +53,13 @@
"expo-splash-screen",
{
"backgroundColor": "#220059",
"image": "./features/core/ui/assets/images/logo_round.png",
"image": "./features/core/ui/assets/images/logo.png",
"imageWidth": 150,
Comment thread
timothyrusso marked this conversation as resolved.
"dark": {
"image": "./features/core/ui/assets/images/logo_round.png",
"image": "./features/core/ui/assets/images/logo.png",
"backgroundColor": "#220059"
},
"ios": {
"imageWidth": 100,
"resizeMode": "contain"
},
"android": {
Expand Down
3 changes: 2 additions & 1 deletion app/(main)/(authenticated)/create-trip/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Routes, navigationService } from '@/features/core/navigation';
import { Modals, Routes, formSheetOptions, navigationService } from '@/features/core/navigation';
import { CustomHeader, icons } from '@/features/core/ui';
import { Stack } from 'expo-router';

Expand Down Expand Up @@ -72,6 +72,7 @@ export default function CreateTripLayout() {
headerShown: false,
}}
/>
<Stack.Screen name={Modals.TypicalDishes} options={formSheetOptions} />
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TypicalDishesModalPage } from '@/features/trips/pages';

const TypicalDishesModal = () => {
return <TypicalDishesModalPage />;
};

export default TypicalDishesModal;
11 changes: 10 additions & 1 deletion convex/validators/Trips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,19 @@ export const Weather = v.object({
weatherOutdoorActivitiesNotes: v.string(),
});

export const TypicalDish = v.object({
name: v.string(),
searchTerm: v.string(),
description: v.string(),
ingredients: v.array(v.string()),
isGlutenFree: v.boolean(),
isVegetarian: v.boolean(),
});

export const Food = v.object({
foodGeneralNotes: v.string(),
foodBudgetNotes: v.string(),
typicalDishes: v.array(v.string()),
typicalDishes: v.array(TypicalDish),
});

export const TripAiResp = v.object({
Expand Down

This file was deleted.

This file was deleted.

28 changes: 28 additions & 0 deletions features/core/images/data/dtos/WikimediaSearchResponseDTO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type WikimediaExtMetadataDTO = {
Restrictions?: { value: string };
DeletionReason?: { value: string };
};

export type WikimediaImageInfoDTO = {
thumburl: string;
width: number;
height: number;
extmetadata?: WikimediaExtMetadataDTO;
};

export type WikimediaCategoryDTO = {
title: string;
};

export type WikimediaPageDTO = {
pageid: number;
title: string;
categories?: WikimediaCategoryDTO[];
imageinfo?: WikimediaImageInfoDTO[];
};

export type WikimediaSearchResponseDTO = {
query?: {
pages?: Record<string, WikimediaPageDTO>;
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Result } from '@/features/core/error';
import { ok } from '@/features/core/error';
import type { IHttpClient } from '@/features/core/http';
import { HTTP_TYPES } from '@/features/core/http';
import type {
WikimediaPageDTO,
WikimediaSearchResponseDTO,
} from '@/features/core/images/data/dtos/WikimediaSearchResponseDTO';
import type { ImageFetchOptions } from '@/features/core/images/domain/entities/ImageFetchOptions';
import type { ImageResult } from '@/features/core/images/domain/entities/ImageResult';
import { FOOD_CATEGORY_KEYWORDS } from '@/features/core/images/domain/entities/foodCategoryKeywords';
import type { IImageRepository } from '@/features/core/images/domain/entities/repositories/IImageRepository';
import { inject, injectable } from 'inversify';

const BASE_URL = 'https://commons.wikimedia.org/w/api.php';
const USER_AGENT = 'HolidAI/1.0 (https://github.com/timothyrusso/holidai)';
const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png'] as const;
const MIN_IMAGE_DIMENSION_PX = 400;

@injectable()
export class WikimediaDishImageRepository implements IImageRepository {
constructor(@inject(HTTP_TYPES.HttpClient) private readonly http: IHttpClient) {}

async getImage(dish: string, options?: ImageFetchOptions): Promise<Result<ImageResult | null>> {
const urlWidth = options?.maxWidthPx ?? 800;
const params = new URLSearchParams({
action: 'query',
generator: 'search',
gsrsearch: `${dish} food haswbstatement:P180`,
gsrnamespace: '6',
gsrlimit: '20',
gsrsort: 'relevance',
prop: 'imageinfo|categories',
iiprop: 'url|dimensions|extmetadata',
cllimit: '50',
iiurlwidth: String(urlWidth),
iimetadataversion: 'latest',
format: 'json',
origin: '*',
});

const result = await this.http.get<WikimediaSearchResponseDTO>(`${BASE_URL}?${params.toString()}`, {
headers: { 'User-Agent': USER_AGENT },
});

if (!result.success) return result;

const pages = Object.values(result.data.query?.pages ?? {});
const info = pages.find(p => this.isUsableImage(p, dish))?.imageinfo?.[0] ?? null;

if (!info) return ok(null);
return ok({ url: info.thumburl });
}

private isUsableImage(page: WikimediaPageDTO, dish: string): boolean {
const info = page.imageinfo?.[0];
if (!info?.thumburl) return false;
if (info.width < MIN_IMAGE_DIMENSION_PX && info.height < MIN_IMAGE_DIMENSION_PX) return false;
Comment thread
timothyrusso marked this conversation as resolved.
Comment thread
timothyrusso marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!this.isAllowedImageExtension(info.thumburl)) return false;
if (info.extmetadata?.Restrictions?.value) return false;
if (!this.hasFoodCategory(page, dish)) return false;
return true;
}

private isAllowedImageExtension(url: string): boolean {
const lower = url.toLowerCase();
return ALLOWED_IMAGE_EXTENSIONS.some(ext => lower.endsWith(ext));
}

private hasFoodCategory(page: WikimediaPageDTO, dish: string): boolean {
if (!page.categories?.length) return false;
const lowerDish = dish.toLowerCase();
return page.categories.some(cat => {
const lowerCat = cat.title.toLowerCase();
return lowerCat.includes(lowerDish) || FOOD_CATEGORY_KEYWORDS.some(kw => lowerCat.includes(kw));
});
}
}
4 changes: 4 additions & 0 deletions features/core/images/di/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { ContainerModule } from 'inversify';
import { container } from '@/features/core/container';
import { GooglePlacesImageRepository } from '@/features/core/images/data/repositories/GooglePlacesImageRepository';
import { UnsplashImageRepository } from '@/features/core/images/data/repositories/UnsplashImageRepository';
import { WikimediaDishImageRepository } from '@/features/core/images/data/repositories/WikimediaDishImageRepository';
import { IMAGES_TYPES } from '@/features/core/images/di/types';
import { FetchGooglePlaceImageUseCase } from '@/features/core/images/useCases/FetchGooglePlaceImageUseCase';
import { FetchGooglePlaceImagesUseCase } from '@/features/core/images/useCases/FetchGooglePlaceImagesUseCase';
import { FetchUnsplashImageUseCase } from '@/features/core/images/useCases/FetchUnsplashImageUseCase';
import { FetchWikimediaDishImageUseCase } from '@/features/core/images/useCases/FetchWikimediaDishImageUseCase';

const imagesModule = new ContainerModule(({ bind }) => {
bind<string>(IMAGES_TYPES.UnsplashApiKey).toConstantValue(Constants.expoConfig?.extra?.unsplashAccessKey ?? '');
Expand All @@ -18,6 +20,8 @@ const imagesModule = new ContainerModule(({ bind }) => {
bind(IMAGES_TYPES.FetchUnsplashImageUseCase).to(FetchUnsplashImageUseCase).inSingletonScope();
bind(IMAGES_TYPES.FetchGooglePlaceImageUseCase).to(FetchGooglePlaceImageUseCase).inSingletonScope();
bind(IMAGES_TYPES.FetchGooglePlaceImagesUseCase).to(FetchGooglePlaceImagesUseCase).inSingletonScope();
bind(IMAGES_TYPES.WikimediaDishImageRepository).to(WikimediaDishImageRepository).inSingletonScope();
bind(IMAGES_TYPES.FetchWikimediaDishImageUseCase).to(FetchWikimediaDishImageUseCase).inSingletonScope();
});

container.load(imagesModule);
4 changes: 4 additions & 0 deletions features/core/images/di/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { IMAGES_TYPES } from '@/features/core/images/di/types';
import type { FetchGooglePlaceImageUseCase } from '@/features/core/images/useCases/FetchGooglePlaceImageUseCase';
import type { FetchGooglePlaceImagesUseCase } from '@/features/core/images/useCases/FetchGooglePlaceImagesUseCase';
import type { FetchUnsplashImageUseCase } from '@/features/core/images/useCases/FetchUnsplashImageUseCase';
import type { FetchWikimediaDishImageUseCase } from '@/features/core/images/useCases/FetchWikimediaDishImageUseCase';

export const fetchUnsplashImageUseCase = container.get<FetchUnsplashImageUseCase>(
IMAGES_TYPES.FetchUnsplashImageUseCase,
Expand All @@ -15,3 +16,6 @@ export const fetchGooglePlaceImageUseCase = container.get<FetchGooglePlaceImageU
export const fetchGooglePlaceImagesUseCase = container.get<FetchGooglePlaceImagesUseCase>(
IMAGES_TYPES.FetchGooglePlaceImagesUseCase,
);
export const fetchWikimediaDishImageUseCase = container.get<FetchWikimediaDishImageUseCase>(
IMAGES_TYPES.FetchWikimediaDishImageUseCase,
);
2 changes: 2 additions & 0 deletions features/core/images/di/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export const IMAGES_TYPES = {
FetchUnsplashImageUseCase: Symbol.for('Images.FetchUnsplashImageUseCase'),
FetchGooglePlaceImageUseCase: Symbol.for('Images.FetchGooglePlaceImageUseCase'),
FetchGooglePlaceImagesUseCase: Symbol.for('Images.FetchGooglePlaceImagesUseCase'),
WikimediaDishImageRepository: Symbol.for('Images.WikimediaDishImageRepository'),
FetchWikimediaDishImageUseCase: Symbol.for('Images.FetchWikimediaDishImageUseCase'),
} as const;
Loading
Loading