diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000000..085c0f2666 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.14 diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml new file mode 100644 index 0000000000..ddbdbbff09 --- /dev/null +++ b/.github/workflows/builds.yml @@ -0,0 +1,310 @@ +name: Builds + +# Build packrat apps on every PR. Currently web only (guides + landing); native +# builds (Expo / macOS) may slot in here later under the same workflow. +# +# Gives us inspectable build signal (post counts, OG image counts, `out/` +# artifact) independent of the Cloudflare Pages dashboard, so a stale +# `lib/content.ts` or a missing OG image surfaces directly in PR checks. +# +# Each job: +# 1. Installs deps with the private @packrat-ai/nativewindui token. +# 2. Runs `bun run --cwd apps/ build`. +# 3. Surfaces post count + OG image count + `out/` size as a GitHub Step +# Summary so reviewers see it without clicking through to logs. +# 4. Uploads `out/` as a downloadable artifact (7-day retention). + +on: + pull_request: + branches: ['**'] + paths: + - 'apps/guides/**' + - 'apps/landing/**' + - 'packages/web-ui/**' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builds.yml' + push: + branches: ['main', 'development'] + paths: + - 'apps/guides/**' + - 'apps/landing/**' + - 'packages/web-ui/**' + - 'package.json' + - 'bun.lock' + - '.github/workflows/builds.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + guides: + name: Builds (guides) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Build guides app + id: build + run: | + set -o pipefail + bun run --cwd apps/guides build 2>&1 | tee /tmp/guides-build.log + + - name: Validate OG meta in built HTML + # Parse out/index.html + every out/guide/.html and assert the + # OG / Twitter meta tags expected by social previewers (LinkedIn, + # FB, microlink). A regression here — missing meta tag, relative + # og:image, wrong twitter:card — fails the workflow before the + # artifact is uploaded so reviewers see the failure on the PR check. + run: bun run --cwd apps/guides test:og-meta + + - name: Lighthouse CI + # Runs `lhci autorun` against the already-built `out/` directory + # (staticDistDir in .lighthouserc.js). Error pages (404/500) are + # excluded via assertMatrix. Real failures fail the workflow so + # regressions are caught at PR time. + id: lhci + run: bun run --cwd apps/guides lighthouse:ci 2>&1 | tee /tmp/guides-lhci.log + + - name: Summarize Lighthouse scores + if: always() + run: | + set -euo pipefail + LOG=/tmp/guides-lhci.log + { + echo "" + echo "## Guides Lighthouse CI" + echo "" + if [ -f "$LOG" ]; then + if grep -q "All results processed!" "$LOG" 2>/dev/null; then + echo '
LHCI output (tail)' + echo '' + echo '```' + tail -n 80 "$LOG" + echo '```' + echo '
' + else + echo '> LHCI did not finish cleanly — see job log.' + echo '' + echo '```' + tail -n 60 "$LOG" 2>/dev/null || echo '(no log)' + echo '```' + fi + else + echo '> No LHCI log captured.' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize build output + if: always() + run: | + set -euo pipefail + GUIDES_DIR="apps/guides" + OUT_DIR="${GUIDES_DIR}/out" + OG_DIR="${GUIDES_DIR}/public/og" + CONTENT_FILE="${GUIDES_DIR}/lib/content.ts" + POSTS_DIR="${GUIDES_DIR}/content/posts" + + mdx_count=$(find "$POSTS_DIR" -maxdepth 1 -name '*.mdx' 2>/dev/null | wc -l | tr -d ' ') + content_count=0 + if [ -f "$CONTENT_FILE" ]; then + # Tolerate both `slug:` and `"slug":` shapes — build-content has emitted both. + content_count=$(grep -cE '^[[:space:]]+"?slug"?[[:space:]]*:' "$CONTENT_FILE" || true) + fi + og_count=0 + if [ -d "$OG_DIR" ]; then + og_count=$(find "$OG_DIR" -maxdepth 1 -name '*.png' 2>/dev/null | wc -l | tr -d ' ') + fi + out_size="(missing)" + out_html=0 + if [ -d "$OUT_DIR" ]; then + out_size=$(du -sh "$OUT_DIR" 2>/dev/null | cut -f1) + out_html=$(find "$OUT_DIR" -maxdepth 3 -name '*.html' 2>/dev/null | wc -l | tr -d ' ') + fi + root_og="missing" + if [ -f "${GUIDES_DIR}/public/og-image.png" ]; then + root_og="ok ($(stat -c%s "${GUIDES_DIR}/public/og-image.png" 2>/dev/null || stat -f%z "${GUIDES_DIR}/public/og-image.png") bytes)" + fi + + { + echo "## Guides build summary" + echo "" + echo "| Metric | Value |" + echo "|---|---|" + echo "| MDX source files (\`content/posts/*.mdx\`) | $mdx_count |" + echo "| Posts in \`lib/content.ts\` | $content_count |" + echo "| Per-post OG PNGs (\`public/og/*.png\`) | $og_count |" + echo "| Root \`public/og-image.png\` | $root_og |" + echo "| Static HTML pages in \`out/\` | $out_html |" + echo "| \`out/\` directory size | $out_size |" + echo "" + if [ "$mdx_count" -gt 0 ] && [ "$content_count" -lt "$mdx_count" ]; then + echo "> WARNING: \`lib/content.ts\` reports fewer posts than there are MDX files on disk." + echo "> This indicates \`build-content\` did not run (or ran before MDX changes were committed)." + echo "> See PR #2436." + fi + if [ "$mdx_count" -gt 0 ] && [ "$og_count" -lt "$mdx_count" ]; then + echo "> WARNING: fewer OG images than MDX files — \`generate-og-images\` may have read a stale \`lib/content.ts\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Lighthouse reports (guides) + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-lighthouse + path: apps/guides/.lighthouseci + retention-days: 14 + if-no-files-found: warn + + - name: Upload guides static export + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-out + path: apps/guides/out + retention-days: 7 + if-no-files-found: warn + + - name: Upload guides build log + if: always() + uses: actions/upload-artifact@v7 + with: + name: guides-build-log + path: /tmp/guides-build.log + retention-days: 7 + if-no-files-found: ignore + + landing: + name: Builds (landing) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Build landing app + id: build + run: | + set -o pipefail + bun run --cwd apps/landing build 2>&1 | tee /tmp/landing-build.log + + - name: Validate OG meta in built HTML + # Parse every out/*.html + out//index.html and assert the + # OG / Twitter meta tags every social previewer (LinkedIn, FB, + # microlink, Slack) expects. Catches relative og:image URLs, + # missing twitter:card, and similar regressions before the + # artifact is uploaded. + run: bun run --cwd apps/landing test:og-meta + + - name: Lighthouse CI + # Runs `lhci autorun` against the already-built `out/` directory. + # Budgets in .lighthouserc.js: perf >=0.8, a11y/best-practices/seo + # >=0.9, LCP <2500ms, CLS <0.1. Error pages (404/500) are excluded + # via assertMatrix. Real failures fail the workflow. + id: lhci + run: bun run --cwd apps/landing lighthouse:ci 2>&1 | tee /tmp/landing-lhci.log + + - name: Summarize Lighthouse scores + if: always() + run: | + set -euo pipefail + LOG=/tmp/landing-lhci.log + { + echo "" + echo "## Landing Lighthouse CI" + echo "" + if [ -f "$LOG" ]; then + if grep -q "All results processed!" "$LOG" 2>/dev/null; then + echo '
LHCI output (tail)' + echo '' + echo '```' + tail -n 80 "$LOG" + echo '```' + echo '
' + else + echo '> LHCI did not finish cleanly — see job log.' + echo '' + echo '```' + tail -n 60 "$LOG" 2>/dev/null || echo '(no log)' + echo '```' + fi + else + echo '> No LHCI log captured.' + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize build output + if: always() + run: | + set -euo pipefail + OUT_DIR="apps/landing/out" + out_size="(missing)" + out_html=0 + if [ -d "$OUT_DIR" ]; then + out_size=$(du -sh "$OUT_DIR" 2>/dev/null | cut -f1) + out_html=$(find "$OUT_DIR" -maxdepth 3 -name '*.html' 2>/dev/null | wc -l | tr -d ' ') + fi + root_og="missing" + if [ -f "apps/landing/public/og-image.png" ]; then + root_og="ok ($(stat -c%s apps/landing/public/og-image.png 2>/dev/null || stat -f%z apps/landing/public/og-image.png) bytes)" + fi + { + echo "## Landing build summary" + echo "" + echo "| Metric | Value |" + echo "|---|---|" + echo "| Static HTML pages in \`out/\` | $out_html |" + echo "| \`out/\` directory size | $out_size |" + echo "| Root \`public/og-image.png\` | $root_og |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Lighthouse reports (landing) + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-lighthouse + path: apps/landing/.lighthouseci + retention-days: 14 + if-no-files-found: warn + + - name: Upload landing static export + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-out + path: apps/landing/out + retention-days: 7 + if-no-files-found: warn + + - name: Upload landing build log + if: always() + uses: actions/upload-artifact@v7 + with: + name: landing-build-log + path: /tmp/landing-build.log + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml new file mode 100644 index 0000000000..d0fa8e8a3a --- /dev/null +++ b/.github/workflows/web-e2e-tests.yml @@ -0,0 +1,135 @@ +name: Web E2E Tests (Playwright) + +on: + push: + branches: [main, development] + paths: + - "apps/expo/**" + - ".github/workflows/web-e2e-tests.yml" + # Note: Using `pull_request` (not `pull_request_target`) so forked PRs get + # CI feedback on their own code. Secrets are unavailable for forks, so + # the job is skipped via the `if` condition on the job below. + pull_request: + branches: [main, development] + paths: + - "apps/expo/**" + - ".github/workflows/web-e2e-tests.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + web-e2e: + name: Web E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + # Skip on forked PRs — secrets are not available in forks + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + + env: + # The E2E user is upserted into the dev DB by the seed step below, + # so both email and password are driven entirely by repo secrets. + TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + + steps: + - name: Verify E2E secrets are configured + run: | + missing=() + [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + [ -z "${EXPO_PUBLIC_API_URL:-}" ] && missing+=("EXPO_PUBLIC_API_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required E2E secrets missing: ${missing[*]}" + echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" + exit 1 + fi + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + cache: true + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + node-modules-${{ runner.os }}- + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install chromium --with-deps + + - name: Build Expo web app + working-directory: apps/expo + env: + EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: ${{ secrets.EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID }} + EXPO_PUBLIC_R2_PUBLIC_URL: ${{ secrets.EXPO_PUBLIC_R2_PUBLIC_URL }} + EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY }} + run: bunx expo export -p web --output-dir dist + + - name: Seed E2E test user in dev DB + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + + - name: Serve web app (SPA mode, port 8081) + working-directory: apps/expo + # -s routes all 404s to index.html for client-side routing + run: npx serve -s dist -l 8081 & + + - name: Wait for web server + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:8081 && echo "Server ready" && break + echo "Waiting... ($i/30)" + sleep 2 + done + + - name: Run Playwright E2E tests + working-directory: apps/expo + env: + BASE_URL: http://localhost:8081 + API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + CI: "true" + run: bun test:web + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: apps/expo/playwright-report/ + retention-days: 7 + + - name: Upload Playwright traces on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces + path: apps/expo/test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 7c94d60f1a..3e349e3e7e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,9 @@ apps/landing/public/og-image.png apps/guides/public/og-image.png apps/guides/public/og/ +# Lighthouse CI output (produced by `lhci autorun`) +.lighthouseci/ + # Git worktrees .worktrees/ .worktrees diff --git a/apps/admin/lib/api.ts b/apps/admin/lib/api.ts index 1df1af5636..f5d7810585 100644 --- a/apps/admin/lib/api.ts +++ b/apps/admin/lib/api.ts @@ -1,28 +1,28 @@ import { treaty } from '@elysiajs/eden'; import type { App } from '@packrat/api'; -import type { - ActiveUsersSchema, - ActivityPointSchema, - AdminCatalogItemSchema, - AdminPackItemSchema, - AdminUserItemSchema, - BrandRowSchema, - BreakdownItemSchema, - CatalogOverviewSchema, - EmbeddingStatsSchema, - EtlFailureSummarySchema, - EtlJobFailuresSchema, - EtlJobSchema, - EtlResponseSchema, - GrowthPointSchema, - PriceBucketSchema, - TrailConditionReportSchema, - TrailGeometrySchema, - TrailSearchItemSchema, - TrailSearchResultSchema, -} from '@packrat/api/schemas/admin'; import { isObject } from '@packrat/guards'; -import type { Static } from '@sinclair/typebox'; +import type { + ActiveUsers, + ActivityPoint, + AdminCatalogItem, + AdminPackItem, + AdminStats, + AdminTrailConditionReport, + AdminUserItem, + BrandRow, + BreakdownItem, + CatalogOverview, + EmbeddingStats, + EtlFailureSummary, + EtlJob, + EtlJobFailures, + EtlResponse, + GrowthPoint, + PriceBucket, + TrailGeometry, + TrailSearchItem, + TrailSearchResult as TrailSearchResultList, +} from '@packrat/schemas/admin'; import { clearToken, getAuthHeader } from './auth'; import { adminEnv } from './env'; @@ -64,7 +64,7 @@ function unwrap(data: T | null | undefined, name: string): T { // ─── Stats ──────────────────────────────────────────────────────────────────── -export type AdminStats = { users: number; packs: number; items: number }; +export type { AdminStats }; export async function getStats(): Promise { const { data, error } = await adminClient.stats.get(); @@ -74,7 +74,7 @@ export async function getStats(): Promise { // ─── Users ──────────────────────────────────────────────────────────────────── -export type AdminUser = Static; +export type AdminUser = AdminUserItem; export interface PaginatedResponse { data: T[]; @@ -94,8 +94,12 @@ export async function getUsers({ q?: string; includeDeleted?: boolean; } = {}): Promise> { + // users-list no longer accepts includeDeleted — Better Auth doesn't support + // user soft-delete, so the field was dead code. Caller-supplied value is + // ignored. + void includeDeleted; const { data, error } = await adminClient['users-list'].get({ - query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { limit, offset, q }, }); if (error) throwOnError(error); return unwrap(data, 'users'); @@ -124,7 +128,7 @@ export async function restoreUser(id: string): Promise<{ success: boolean }> { // ─── Packs ──────────────────────────────────────────────────────────────────── -export type AdminPack = Static; +export type AdminPack = AdminPackItem; export async function getPacks({ limit = 100, @@ -138,7 +142,7 @@ export async function getPacks({ includeDeleted?: boolean; } = {}): Promise> { const { data, error } = await adminClient['packs-list'].get({ - query: { limit, offset, q, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { limit, offset, q, includeDeleted }, }); if (error) throwOnError(error); return unwrap(data, 'packs'); @@ -152,7 +156,7 @@ export async function deletePack(id: string): Promise<{ success: boolean }> { // ─── Catalog Items ──────────────────────────────────────────────────────────── -export type AdminCatalogItem = Static; +export type { AdminCatalogItem }; export interface UpdateCatalogItemInput { name?: string; @@ -197,10 +201,7 @@ export async function updateCatalogItem( // ─── Analytics — Platform ───────────────────────────────────────────────────── -export type GrowthPoint = Static; -export type ActivityPoint = Static; -export type BreakdownItem = Static; -export type ActiveUsers = Static; +export type { GrowthPoint, ActivityPoint, BreakdownItem, ActiveUsers }; export type AnalyticsPeriod = 'day' | 'week' | 'month'; export async function getPlatformGrowth( @@ -233,12 +234,7 @@ export async function getPlatformBreakdown(): Promise { // ─── Analytics — Catalog ───────────────────────────────────────────────────── -export type CatalogOverview = Static; -export type BrandRow = Static; -export type PriceBucket = Static; -export type EtlJob = Static; -export type EtlResponse = Static; -export type EmbeddingStats = Static; +export type { CatalogOverview, BrandRow, PriceBucket, EtlJob, EtlResponse, EmbeddingStats }; export async function getCatalogOverview(): Promise { const { data, error } = await adminClient.analytics.catalog.overview.get(); @@ -276,10 +272,10 @@ export async function getCatalogEmbeddings(): Promise { // ─── Admin Trails ───────────────────────────────────────────────────────────── -export type TrailSearchResult = Static; -export type TrailGeometry = Static; -export type TrailSearchPage = Static; -export type TrailConditionReport = Static; +export type TrailSearchResult = TrailSearchItem; +export type TrailSearchPage = TrailSearchResultList; +export type { TrailGeometry }; +export type TrailConditionReport = AdminTrailConditionReport; export async function searchTrails({ q, @@ -323,7 +319,7 @@ export async function getTrailConditions({ includeDeleted?: boolean; } = {}): Promise> { const { data, error } = await adminClient.trails.conditions.get({ - query: { q, limit, offset, includeDeleted: includeDeleted ? 'true' : undefined }, + query: { q, limit, offset, includeDeleted }, }); if (error) throwOnError(error); return unwrap(data, 'trailConditions'); @@ -345,8 +341,7 @@ export function resetStuckEtlJobs(): Promise<{ reset: number; ids: string[] }> { return adminFetch('/analytics/catalog/etl/reset-stuck', { method: 'POST' }); } -export type EtlFailureSummary = Static; -export type EtlJobFailures = Static; +export type { EtlFailureSummary, EtlJobFailures }; export function getEtlFailureSummary(limit = 20): Promise { return adminFetch(`/analytics/catalog/etl/failure-summary?limit=${limit}`); diff --git a/apps/admin/package.json b/apps/admin/package.json index c182e21755..778202037f 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,6 +15,7 @@ "@packrat/api-client": "workspace:*", "@packrat/app": "workspace:*", "@packrat/guards": "workspace:*", + "@packrat/schemas": "workspace:*", "@packrat/web-ui": "workspace:*", "@radix-ui/react-alert-dialog": "catalog:", "@radix-ui/react-avatar": "catalog:", diff --git a/apps/expo/.gitignore b/apps/expo/.gitignore index 8bef009eca..742824b0a3 100644 --- a/apps/expo/.gitignore +++ b/apps/expo/.gitignore @@ -37,3 +37,10 @@ android .env .env.* !.env.example + +# Playwright E2E — cached auth tokens (written by globalSetup, contain real credentials) +playwright/.auth-tokens.json +playwright/playwright-report/ +playwright/test-results/ +playwright-report/ +test-results/ diff --git a/apps/expo/app/(app)/(tabs)/_layout.web.tsx b/apps/expo/app/(app)/(tabs)/_layout.web.tsx new file mode 100644 index 0000000000..880dcd0e14 --- /dev/null +++ b/apps/expo/app/(app)/(tabs)/_layout.web.tsx @@ -0,0 +1,33 @@ +import { featureFlags } from 'expo-app/config'; +import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Tabs } from 'expo-router'; + +/** + * Web version of the tabs layout. + * Replaces NativeTabs (expo-router/unstable-native-tabs) with standard Expo Router Tabs. + * NativeTabs uses native UITabBarController and cannot run on web. + * Metro automatically picks this file over _layout.tsx for web builds. + */ +export default function TabLayout() { + const { t } = useTranslation(); + + return ( + + + + + + + + + ); +} diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index be08387b8c..ef91c30016 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -94,6 +94,7 @@ function Profile() { { id: 'name', title: t('common.name'), + testID: testIds.profile.nameEditBtn, onPress: () => router.push('/(app)/(tabs)/profile/name'), ...(Platform.OS === 'ios' ? { value: displayName } : { subTitle: displayName }), }, @@ -146,6 +147,7 @@ function Item({ info }: { info: ListRenderItemInfo }) { return ( @@ -331,4 +333,5 @@ type DataItem = value?: string; subTitle?: string; onPress?: () => void; + testID?: string; }; diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index 24b10ed0c3..18e2607be5 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -18,7 +18,11 @@ import { ChatBubble } from 'expo-app/features/ai/components/ChatBubble'; import { ErrorState } from 'expo-app/features/ai/components/ErrorState'; import { LocationContext } from 'expo-app/features/ai/components/LocationContext'; import { CustomChatTransport } from 'expo-app/features/ai/lib/CustomChatTransport'; -import { getLocalModel, initLocalModel } from 'expo-app/features/ai/lib/localModelManager'; +import { + getLocalModel, + initLocalModel, + releaseLocalModel, +} from 'expo-app/features/ai/lib/localModelManager'; import { createLocalTools } from 'expo-app/features/ai/lib/tools'; import { useActiveLocation } from 'expo-app/features/weather/hooks'; import type { WeatherLocation } from 'expo-app/features/weather/types'; @@ -31,6 +35,7 @@ import { Stack, useLocalSearchParams } from 'expo-router'; import { useAtomValue } from 'jotai'; import * as React from 'react'; import { + AppState, Dimensions, type NativeSyntheticEvent, Platform, @@ -92,6 +97,7 @@ export default function AIChat() { const { data: _authSession } = authClient.useSession(); const token = _authSession?.session?.token ?? null; + const userId = _authSession?.user?.id ?? ''; const [input, setInput] = React.useState(''); const [lastUserMessage, setLastUserMessage] = React.useState(''); const [previousMessages, setPreviousMessages] = React.useState([]); @@ -109,9 +115,33 @@ export default function AIChat() { [context], ); - // Kick off model init check on mount (prepares already-downloaded models) + // Kick off model init check on mount (prepares already-downloaded models). + // Release the model when the app backgrounds so the llama TurboModule can be + // properly invalidated — prevents "Timed out waiting for modules to be + // invalidated" crashes on hot reload and app restart. React.useEffect(() => { - if (featureFlags.enableLocalAI) initLocalModel(); + if (!featureFlags.enableLocalAI) return; + + initLocalModel(); + + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'background' || nextState === 'inactive') { + releaseLocalModel(); + } else if (nextState === 'active') { + // Re-prepare the model when the app comes back to the foreground. + initLocalModel(); + } + }); + + // In development, release before fast-refresh tears down native modules. + if (__DEV__) { + const devModule = module as unknown as { hot?: { dispose: (cb: () => void) => void } }; + devModule.hot?.dispose(() => releaseLocalModel()); + } + + return () => { + subscription.remove(); + }; }, []); // Keep a ref for context body values so the transport closure stays fresh @@ -123,30 +153,65 @@ export default function AIChat() { const isLocalReady = modelStatus === 'ready'; const tools = React.useMemo(() => createLocalTools(), []); - const transport = React.useMemo(() => { + const { transport, transportKey } = React.useMemo(() => { if (featureFlags.enableLocalAI && aiMode === 'local' && isLocalReady) { const model = getLocalModel(); if (model) { - return new CustomChatTransport(model, tools); + let systemPrompt = `You are PackRat AI, a helpful assistant for hikers and outdoor enthusiasts. + You help users manage their hiking packs and gear efficiently using ultralight principles. + + Guidelines: + - Focus on ultralight hiking principles when appropriate + - For beginners, emphasize safety and comfort over weight savings + - Always consider weather conditions in your recommendations + - Suggest multi-purpose items to reduce pack weight + - Be concise but helpful in your responses + - Use tools proactively to provide accurate, up-to-date information + + Context: + - User id is ${userId} + - Current date is ${new Date().toLocaleString()}`; + + if (contextRef.current.contextType === 'pack' && contextRef.current.packId) { + systemPrompt += `\n- You are currently helping with a pack with ID: ${contextRef.current.packId}.`; + } else if (contextRef.current.contextType === 'item' && contextRef.current.itemId) { + systemPrompt += `\n- You are currently helping with an item with ID: ${contextRef.current.itemId}.`; + } + + if (contextRef.current.location) { + systemPrompt += `\n- The current location of the user is: ${contextRef.current.location}.`; + } + + return { + transport: new CustomChatTransport({ model, tools, systemPrompt }), + transportKey: 'local', + }; } + } else { } - return new DefaultChatTransport({ - fetch: expoFetch as unknown as typeof globalThis.fetch, - api: `${clientEnvs.EXPO_PUBLIC_API_URL}/api/chat`, - headers: { - Authorization: `Bearer ${token}`, - }, - body: () => ({ - contextType: contextRef.current.contextType, - itemId: contextRef.current.itemId, - packId: contextRef.current.packId, - location: locationRef.current, - date: new Date().toLocaleString(), + return { + transport: new DefaultChatTransport({ + fetch: expoFetch as unknown as typeof globalThis.fetch, + api: `${clientEnvs.EXPO_PUBLIC_API_URL}/api/chat`, + headers: { + Authorization: `Bearer ${token}`, + }, + body: () => ({ + contextType: contextRef.current.contextType, + itemId: contextRef.current.itemId, + packId: contextRef.current.packId, + location: locationRef.current, + date: new Date().toLocaleString(), + }), }), - }); - }, [aiMode, isLocalReady, token, tools]); + transportKey: 'remote', + }; + }, [aiMode, isLocalReady, modelStatus, token, tools, userId]); + // transportKey forces useChat to remount when the transport type switches, + // since useChat captures the transport reference on mount and won't update it. const { messages, setMessages, error, sendMessage, stop, status } = useChat({ + id: transportKey, transport, onError: (error: Error) => console.log(error, 'ERROR'), experimental_throttle: 200, diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index db114c3fe7..f5fe095587 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -7,12 +7,12 @@ import { } from '@packrat/ui/nativewindui'; import { userStore } from 'expo-app/features/auth/store'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; +import type { PackItem } from 'expo-app/features/packs/types'; import { type CategorySummary, computeCategorySummaries } from 'expo-app/features/packs/utils'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { getRelativeTime } from 'expo-app/lib/utils/getRelativeTime'; -import type { PackItem } from 'expo-app/types'; import { useLocalSearchParams } from 'expo-router'; import type React from 'react'; import { ScrollView, View } from 'react-native'; @@ -192,10 +192,7 @@ export default function CurrentPackScreen() { index.toString()} - renderItem={(item, index) => ( - // safe-cast: Treaty response type has createdAt?: string but PackItem schema requires string - - )} + renderItem={(item, index) => } /> diff --git a/apps/expo/app/(app)/settings/index.tsx b/apps/expo/app/(app)/settings/index.tsx index 0ec2f40c8f..4b12f25ee3 100644 --- a/apps/expo/app/(app)/settings/index.tsx +++ b/apps/expo/app/(app)/settings/index.tsx @@ -93,7 +93,7 @@ export default function SettingsScreen() { {isApple ? 'Built into iOS 26+ · no download required' - : `SmolLM3-Q4_K_M.gguf · ${LLAMA_MODEL_SIZE}`} + : `qwen2.5-3b-instruct-q3_k_m.gguf · ${LLAMA_MODEL_SIZE}`} {/* Status + action row */} diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 81dd6426d5..b5cdf42ace 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -1,7 +1,7 @@ import '../polyfills'; import { ThemeProvider as NavThemeProvider } from '@react-navigation/native'; -import 'expo-dev-client'; +import 'expo-app/lib/devClient'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import '../global.css'; @@ -20,6 +20,8 @@ Sentry.init({ // Adds more context data to events (IP address, cookies, user, etc.) // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ sendDefaultPii: true, + // Disable Sentry in local development or when no DSN is configured. + enabled: clientEnvs.NODE_ENV !== 'development' && !!clientEnvs.EXPO_PUBLIC_SENTRY_DSN, }); const user = userStore.peek(); diff --git a/apps/expo/app/_layout.web.tsx b/apps/expo/app/_layout.web.tsx new file mode 100644 index 0000000000..eaec58d62c --- /dev/null +++ b/apps/expo/app/_layout.web.tsx @@ -0,0 +1,55 @@ +import '../polyfills'; + +import { ThemeProvider as NavThemeProvider } from '@react-navigation/native'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import '../global.css'; + +import { Alert, type AlertMethods } from '@packrat-ai/nativewindui'; +import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme'; +import { Providers } from 'expo-app/providers'; +import { NAV_THEME } from 'expo-app/theme'; +import { type RefObject, useRef } from 'react'; + +/** + * Web version of the root layout. + * Removes native-only imports: + * - expo-dev-client (not needed on web) + * - @sentry/react-native (use @sentry/nextjs / @sentry/browser for web instead) + * Metro automatically picks this file over _layout.tsx for web builds. + */ + +export { ErrorBoundary } from 'expo-router'; + +export let appAlert: RefObject; + +function RootLayout() { + useInitialAndroidBarSync(); + + appAlert = useRef(null); + + const { colorScheme, isDarkColorScheme } = useColorScheme(); + + return ( + + + + + + + + + + + ); +} + +export default RootLayout; + +const SCREEN_OPTIONS = { + headerShown: false, + animation: 'ios_from_right', +} as const; diff --git a/apps/expo/atoms/atomWithSecureStorage.web.ts b/apps/expo/atoms/atomWithSecureStorage.web.ts new file mode 100644 index 0000000000..42680c86cf --- /dev/null +++ b/apps/expo/atoms/atomWithSecureStorage.web.ts @@ -0,0 +1,36 @@ +import { isFunction } from '@packrat/guards'; +import { atom } from 'jotai'; + +/** + * Web (localStorage) equivalent of atomWithSecureStorage. + * Note: localStorage is NOT cryptographically secure. This is a functional + * fallback for web; sensitive flows should use server-side sessions on web. + * Metro automatically picks this file over atomWithSecureStorage.ts for web builds. + */ +export const atomWithSecureStorage = (key: string, initialValue: T) => { + const baseAtom = atom(initialValue); + + baseAtom.onMount = (setValue) => { + try { + const item = localStorage.getItem(key); + setValue(item !== null ? JSON.parse(item) : initialValue); + } catch { + setValue(initialValue); + } + }; + + const derivedAtom = atom( + (get) => get(baseAtom), + (get, set, update: T | ((prev: T) => T)) => { + const nextValue = isFunction(update) ? (update as (prev: T) => T)(get(baseAtom)) : update; + set(baseAtom, nextValue); + try { + localStorage.setItem(key, JSON.stringify(nextValue)); + } catch { + // Ignore storage errors + } + }, + ); + + return derivedAtom; +}; diff --git a/apps/expo/components/initial/UserAvatar.tsx b/apps/expo/components/initial/UserAvatar.tsx index 7ced670e65..3991d18e16 100644 --- a/apps/expo/components/initial/UserAvatar.tsx +++ b/apps/expo/components/initial/UserAvatar.tsx @@ -1,8 +1,8 @@ -import type { User } from 'expo-app/types'; +import type { MockUser } from 'expo-app/data/mockData'; import { Image, Text, View } from 'react-native'; type UserAvatarProps = { - user: User; + user: Pick; size?: 'sm' | 'md' | 'lg'; showName?: boolean; }; @@ -20,11 +20,13 @@ export function UserAvatar({ user, size = 'md', showName = false }: UserAvatarPr lg: 'text-base', }[size]; + const avatarUri = user.avatarUrl || null; + return ( - {user.avatar ? ( - + {avatarUri ? ( + ) : ( {user.name.substring(0, 2).toUpperCase()} diff --git a/apps/expo/components/initial/WeightBadge.tsx b/apps/expo/components/initial/WeightBadge.tsx index 83c5326a36..10b58ae0da 100644 --- a/apps/expo/components/initial/WeightBadge.tsx +++ b/apps/expo/components/initial/WeightBadge.tsx @@ -1,6 +1,6 @@ +import type { WeightUnit } from '@packrat/constants'; import { isString } from '@packrat/guards'; import { cn } from 'expo-app/lib/cn'; -import type { WeightUnit } from 'expo-app/types'; import { formatWeight } from 'expo-app/utils/weight'; import { Text, View } from 'react-native'; diff --git a/apps/expo/data/mockData.ts b/apps/expo/data/mockData.ts index f39d3619e8..1dcd6fff43 100644 --- a/apps/expo/data/mockData.ts +++ b/apps/expo/data/mockData.ts @@ -1,12 +1,20 @@ -import type { User } from 'expo-app/types'; +export type MockUser = { + id: string; + name: string; + email: string; + avatarUrl: string; + experience: string; + joinedAt: string; + bio: string; +}; // --- Users --- -export const mockUsers: [User, ...User[]] = [ +export const mockUsers: [MockUser, ...MockUser[]] = [ { id: '1', name: 'Alex Hiker', email: 'alex@example.com', - avatar: 'https://i.pravatar.cc/150?img=1', + avatarUrl: 'https://i.pravatar.cc/150?img=1', experience: 'expert', joinedAt: '2023-01-15T00:00:00.000Z', bio: 'Thru-hiker with 5,000+ miles under my feet. PCT, AT, and CDT completed.', @@ -15,7 +23,7 @@ export const mockUsers: [User, ...User[]] = [ id: '2', name: 'Sam Backpacker', email: 'sam@example.com', - avatar: 'https://i.pravatar.cc/150?img=2', + avatarUrl: 'https://i.pravatar.cc/150?img=2', experience: 'intermediate', joinedAt: '2023-03-22T00:00:00.000Z', bio: 'Weekend warrior trying to lighten my load.', @@ -24,7 +32,7 @@ export const mockUsers: [User, ...User[]] = [ id: '3', name: 'Jamie Newbie', email: 'jamie@example.com', - avatar: 'https://i.pravatar.cc/150?img=3', + avatarUrl: 'https://i.pravatar.cc/150?img=3', experience: 'beginner', joinedAt: '2023-06-10T00:00:00.000Z', bio: 'Just getting started with hiking and camping.', @@ -33,7 +41,7 @@ export const mockUsers: [User, ...User[]] = [ id: '4', name: 'Taylor Trailblazer', email: 'taylor@example.com', - avatar: 'https://i.pravatar.cc/150?img=4', + avatarUrl: 'https://i.pravatar.cc/150?img=4', experience: 'expert', joinedAt: '2022-11-05T00:00:00.000Z', bio: 'Explorer with a passion for the unbeaten path.', @@ -42,7 +50,7 @@ export const mockUsers: [User, ...User[]] = [ id: '5', name: 'Chris Camper', email: 'chris@example.com', - avatar: 'https://i.pravatar.cc/150?img=5', + avatarUrl: 'https://i.pravatar.cc/150?img=5', experience: 'intermediate', joinedAt: '2023-02-28T00:00:00.000Z', bio: 'Camping enthusiast and nature lover.', @@ -51,7 +59,7 @@ export const mockUsers: [User, ...User[]] = [ id: '6', name: 'Morgan Mountaineer', email: 'morgan@example.com', - avatar: 'https://i.pravatar.cc/150?img=6', + avatarUrl: 'https://i.pravatar.cc/150?img=6', experience: 'expert', joinedAt: '2023-01-20T00:00:00.000Z', bio: 'Scaling peaks and chasing horizons.', @@ -60,7 +68,7 @@ export const mockUsers: [User, ...User[]] = [ id: '7', name: 'Jordan Explorer', email: 'jordan@example.com', - avatar: 'https://i.pravatar.cc/150?img=7', + avatarUrl: 'https://i.pravatar.cc/150?img=7', experience: 'beginner', joinedAt: '2023-04-15T00:00:00.000Z', bio: 'New to outdoor adventures, learning every step.', @@ -69,7 +77,7 @@ export const mockUsers: [User, ...User[]] = [ id: '8', name: 'Riley Ranger', email: 'riley@example.com', - avatar: 'https://i.pravatar.cc/150?img=8', + avatarUrl: 'https://i.pravatar.cc/150?img=8', experience: 'intermediate', joinedAt: '2023-03-30T00:00:00.000Z', bio: 'Always ready for a spontaneous trip.', @@ -78,7 +86,7 @@ export const mockUsers: [User, ...User[]] = [ id: '9', name: 'Casey Climber', email: 'casey@example.com', - avatar: 'https://i.pravatar.cc/150?img=9', + avatarUrl: 'https://i.pravatar.cc/150?img=9', experience: 'expert', joinedAt: '2022-12-10T00:00:00.000Z', bio: 'Rock climbing is life.', @@ -87,7 +95,7 @@ export const mockUsers: [User, ...User[]] = [ id: '10', name: 'Peyton Paddler', email: 'peyton@example.com', - avatar: 'https://i.pravatar.cc/150?img=10', + avatarUrl: 'https://i.pravatar.cc/150?img=10', experience: 'intermediate', joinedAt: '2023-05-01T00:00:00.000Z', bio: 'Loves exploring rivers and lakes.', diff --git a/apps/expo/features/ai/components/GuidesRAGGenerativeUI.tsx b/apps/expo/features/ai/components/GuidesRAGGenerativeUI.tsx index a8d2f0858f..29f028586a 100644 --- a/apps/expo/features/ai/components/GuidesRAGGenerativeUI.tsx +++ b/apps/expo/features/ai/components/GuidesRAGGenerativeUI.tsx @@ -1,5 +1,5 @@ +import { Text, useColorScheme } from '@packrat/ui/nativewindui'; import { Icon } from 'expo-app/components/Icon'; -import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRef, useState } from 'react'; import { @@ -8,7 +8,6 @@ import { type NativeScrollEvent, type NativeSyntheticEvent, ScrollView, - Text, TouchableOpacity, View, } from 'react-native'; @@ -95,9 +94,9 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP }; const getRelevanceColor = (score: number) => { - if (score >= 0.7) return 'text-green-600'; - if (score >= 0.5) return 'text-yellow-600'; - return 'text-gray-500'; + if (score >= 0.7) return 'text-green-600 dark:text-green-400'; + if (score >= 0.5) return 'text-yellow-600 dark:text-yellow-400'; + return 'text-gray-500 dark:text-gray-400'; }; const getRelevanceText = (score: number) => { @@ -107,9 +106,11 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP }; const getRelevanceBadgeColor = (score: number) => { - if (score >= 0.7) return 'bg-green-100 border-green-200'; - if (score >= 0.5) return 'bg-yellow-100 border-yellow-200'; - return 'bg-gray-100 border-gray-200'; + if (score >= 0.7) + return 'bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-700'; + if (score >= 0.5) + return 'bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-700'; + return 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600'; }; const handleScroll = (event: NativeSyntheticEvent) => { @@ -145,11 +146,11 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP - + {t('ai.tools.guideSearchResults')} - + {t('ai.tools.foundGuides', { count: toolInvocation.output.data.data.length, query: toolInvocation.input.query, @@ -182,7 +183,7 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP width: CARD_WIDTH, marginRight: CARD_SPACING, }} - className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm active:scale-[0.98]" + className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm active:scale-[0.98]" > {/* Relevance Badge */} @@ -201,7 +202,7 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP {/* Guide Title */} {formatGuideTitle(guide.filename)} @@ -209,7 +210,10 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP {/* Content Preview */} {guide.content[0] && ( - + {truncateText(guide.content[0].text.trim())} )} @@ -218,10 +222,12 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP - {t('ai.tools.packratGuides')} + + {t('ai.tools.packratGuides')} + - + {t('ai.tools.readMore')} @@ -239,7 +245,9 @@ export function GuidesRAGGenerativeUI({ toolInvocation }: GuidesRAGGenerativeUIP key={item.file_id} onPress={() => scrollToIndex(index)} className={`h-2 rounded-full transition-all duration-200 ${ - index === currentIndex ? 'w-6 bg-blue-600' : 'w-2 bg-gray-300' + index === currentIndex + ? 'w-6 bg-blue-600 dark:bg-blue-400' + : 'w-2 bg-gray-300 dark:bg-gray-600' }`} activeOpacity={0.7} /> diff --git a/apps/expo/features/ai/components/ToolInvocationRenderer.tsx b/apps/expo/features/ai/components/ToolInvocationRenderer.tsx index ae4746c610..e3b8630816 100644 --- a/apps/expo/features/ai/components/ToolInvocationRenderer.tsx +++ b/apps/expo/features/ai/components/ToolInvocationRenderer.tsx @@ -1,3 +1,4 @@ +import { isString } from '@packrat/guards'; import type { ToolUIPart } from 'ai'; import type { CatalogItemsTool } from './CatalogItemsGenerativeUI'; import { CatalogItemsGenerativeUI } from './CatalogItemsGenerativeUI'; @@ -17,29 +18,36 @@ interface ToolInvocationRendererProps { } export function ToolInvocationRenderer({ toolInvocation }: ToolInvocationRendererProps) { + // On-device AI models may serialize tool output as a JSON string instead of a parsed object. + // Normalize it here once so all GenUI components receive a plain object. + const normalizedInvocation = + toolInvocation.state === 'output-available' && isString(toolInvocation.output) + ? { ...toolInvocation, output: JSON.parse(toolInvocation.output) } + : toolInvocation; + // safe-cast: each case branch narrows toolInvocation.type to the discriminant literal; the // local tool types (WebSearchTool, etc.) extend ToolUIPart with that exact `type` field, so // the cast is verified by the switch guard above each arm. - switch (toolInvocation.type) { + switch (normalizedInvocation.type) { case 'tool-webSearchTool': // safe-cast: case guard narrows type to discriminant; local tool types extend ToolUIPart with that exact `type` field - return ; + return ; case 'tool-getWeatherForLocation': // safe-cast: case guard narrows type to discriminant literal - return ; + return ; case 'tool-getCatalogItems': case 'tool-catalogVectorSearch': // safe-cast: case guard narrows type to discriminant literal - return ; + return ; case 'tool-searchPackratOutdoorGuidesRAG': // safe-cast: case guard narrows type to discriminant literal - return ; + return ; case 'tool-getPackDetails': // safe-cast: case guard narrows type to discriminant literal - return ; + return ; case 'tool-getPackItemDetails': // safe-cast: case guard narrows type to discriminant literal - return ; + return ; default: return null; } diff --git a/apps/expo/features/ai/components/WeatherGenerativeUI.tsx b/apps/expo/features/ai/components/WeatherGenerativeUI.tsx index 210988a136..f56c296cdf 100644 --- a/apps/expo/features/ai/components/WeatherGenerativeUI.tsx +++ b/apps/expo/features/ai/components/WeatherGenerativeUI.tsx @@ -12,11 +12,15 @@ type WeatherToolOutput = | { success: true; data: { - location: string; + name: string; temperature: number; - conditions: string; - humidity: number; - windSpeed: number; + condition: string; + details: { + humidity: number; + windSpeed: number; + feelsLike: number; + isDay: number; + }; }; } | { @@ -124,7 +128,7 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps {t('ai.tools.weatherIn', { - location: toolInvocation.output.data.location, + location: toolInvocation.output.data.name, })} @@ -135,7 +139,10 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps @@ -146,7 +153,7 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps {toolInvocation.output.data.temperature}° - {toolInvocation.output.data.conditions} + {toolInvocation.output.data.condition} @@ -163,7 +170,7 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps - {toolInvocation.output.data.humidity}% + {toolInvocation.output.data.details.humidity}% @@ -177,7 +184,7 @@ export function WeatherGenerativeUI({ toolInvocation }: WeatherGenerativeUIProps - {toolInvocation.output.data.windSpeed} mph + {toolInvocation.output.data.details.windSpeed} mph diff --git a/apps/expo/features/ai/lib/CustomChatTransport.ts b/apps/expo/features/ai/lib/CustomChatTransport.ts index 8bc8bb7bf0..de0392d0c1 100644 --- a/apps/expo/features/ai/lib/CustomChatTransport.ts +++ b/apps/expo/features/ai/lib/CustomChatTransport.ts @@ -5,6 +5,7 @@ import { type ChatTransport, convertToModelMessages, type LanguageModel, + stepCountIs, streamText, type ToolSet, type UIMessageChunk, @@ -13,10 +14,16 @@ import { export class CustomChatTransport implements ChatTransport { private model: LanguageModel | undefined; private tools: ToolSet | undefined; + private systemPrompt: string | undefined; - constructor(model?: LanguageModel, tools?: ToolSet) { + constructor({ + model, + tools, + systemPrompt, + }: { model?: LanguageModel; tools?: ToolSet; systemPrompt?: string } = {}) { this.model = model; this.tools = tools; + this.systemPrompt = systemPrompt; } setModel(model: LanguageModel) { @@ -41,6 +48,8 @@ export class CustomChatTransport implements ChatTransport { model: this.model, messages: await convertToModelMessages(options.messages), abortSignal: options.abortSignal, + stopWhen: stepCountIs(5), + ...(this.systemPrompt ? { system: this.systemPrompt } : {}), ...(this.tools ? { tools: this.tools, toolChoice: 'auto' } : {}), }); diff --git a/apps/expo/features/ai/lib/appleModelWrapper.ts b/apps/expo/features/ai/lib/appleModelWrapper.ts new file mode 100644 index 0000000000..842cb1ddfd --- /dev/null +++ b/apps/expo/features/ai/lib/appleModelWrapper.ts @@ -0,0 +1,129 @@ +/** + * Wraps the Apple Foundation Model to fix two defects in its doStream: + * + * 1. **"null" artifact** – Apple's native streaming layer emits `data.content = "null"` + * (the JSON-serialised tool result) on the first update after a tool call fires. + * The accumulative-delta code (`content.slice(previousContent.length)`) then + * enqueues "null" as a visible text-delta before the real response arrives. + * + * 2. **Missing tool parts** – doStream only emits text-* events; it never emits + * `tool-call` or `tool-result` stream parts, so GenUI components never render. + * + * Root cause: the Apple provider's `doStream` ignores tool execution in the stream + * even though `doGenerate` correctly returns `tool-call` / `tool-result` content parts + * (with `providerExecuted: true`). + * + * Fix: override `doStream` to call `doGenerate` and synthesise a proper + * `ReadableStream` from its result, including matched + * tool-call / tool-result pairs so that streamText + toUIMessageStream can emit + * ToolUIParts for GenUI rendering. + * + * Trade-off: the Apple model response arrives all-at-once instead of + * character-by-character. On-device latency is low enough that this is acceptable + * while the upstream provider bug is unresolved. + */ + +import { isString } from '@packrat/guards'; + +// biome-ignore lint/suspicious/noExplicitAny: Apple model type is unknown at this layer +type AnyModel = any; + +let _counter = 0; +function makeId(): string { + _counter += 1; + return `atc_${_counter}_${Math.random().toString(36).slice(2, 8)}`; +} + +export class AppleModelWrapper { + readonly specificationVersion = 'v2' as const; + readonly provider: string; + readonly modelId: string; + readonly supportedUrls: Record; + + constructor(private readonly inner: AnyModel) { + this.provider = inner.provider; + this.modelId = inner.modelId; + this.supportedUrls = inner.supportedUrls ?? {}; + } + + doGenerate(options: AnyModel): Promise { + return this.inner.doGenerate(options); + } + + /** + * Calls `doGenerate` and converts its result into a properly-shaped + * `LanguageModelV2StreamPart` stream. This sidesteps both defects: + * + * - No more "null" delta: we never touch the native streaming path. + * - Tool parts are emitted: doGenerate gives us the full content array + * including tool-call / tool-result pairs. + */ + async doStream(options: AnyModel): Promise { + const result = await this.inner.doGenerate(options); + + // Apple's native bridge inserts a spurious text part with the literal string + // "null" immediately before tool-call parts (a serialization artifact from the + // native tool-execution layer). Strip it when tool calls are present. + const hasToolCalls = result.content.some((p: AnyModel) => p.type === 'tool-call'); + const content: AnyModel[] = hasToolCalls + ? result.content.filter((p: AnyModel) => !(p.type === 'text' && p.text?.trim() === 'null')) + : result.content; + + // Pre-assign IDs so every tool-call/tool-result pair shares the same ID. + // Apple returns them in order: [tool-call₁, tool-result₁, tool-call₂, …, text] + const toolCallIds: string[] = []; + for (const part of content) { + if (part.type === 'tool-call') { + toolCallIds.push(makeId()); + } + } + + const stream = new ReadableStream({ + start(controller) { + let callIdx = 0; + let resultIdx = 0; + + for (const part of content) { + if (part.type === 'text' && part.text) { + const id = makeId(); + controller.enqueue({ type: 'text-start', id }); + controller.enqueue({ type: 'text-delta', id, delta: part.text }); + controller.enqueue({ type: 'text-end', id }); + } else if (part.type === 'tool-call') { + const toolCallId = toolCallIds[callIdx++] ?? makeId(); + controller.enqueue({ + type: 'tool-call', + toolCallId, + toolName: part.toolName, + // Apple may return input as an object; the spec requires a JSON string + input: isString(part.input) ? part.input : JSON.stringify(part.input), + providerExecuted: true, + }); + } else if (part.type === 'tool-result') { + const toolCallId = toolCallIds[resultIdx++] ?? makeId(); + controller.enqueue({ + type: 'tool-result', + toolCallId, + toolName: part.toolName, + result: part.result, + providerExecuted: true, + }); + } + } + + controller.enqueue({ + type: 'finish', + finishReason: result.finishReason ?? 'stop', + usage: result.usage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }); + + controller.close(); + }, + }); + + return { + stream, + rawCall: { rawPrompt: options.prompt, rawSettings: {} }, + }; + } +} diff --git a/apps/expo/features/ai/lib/constants.ts b/apps/expo/features/ai/lib/constants.ts index ce4d1fe96b..ed5df01aa8 100644 --- a/apps/expo/features/ai/lib/constants.ts +++ b/apps/expo/features/ai/lib/constants.ts @@ -1,3 +1,3 @@ -export const LLAMA_MODEL_ID = 'ggml-org/SmolLM3-3B-GGUF/SmolLM3-Q4_K_M.gguf'; -export const LLAMA_MODEL_SIZE = '~1.8 GB'; -export const LLAMA_MODEL_SIZE_BYTES = 1915305312; +export const LLAMA_MODEL_ID = 'Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q3_k_m.gguf'; +export const LLAMA_MODEL_SIZE = '~1.7 GB'; +export const LLAMA_MODEL_SIZE_BYTES = 1_724_178_848; diff --git a/apps/expo/features/ai/lib/llamaToolsWrapper.ts b/apps/expo/features/ai/lib/llamaToolsWrapper.ts new file mode 100644 index 0000000000..28f9a8ca4a --- /dev/null +++ b/apps/expo/features/ai/lib/llamaToolsWrapper.ts @@ -0,0 +1,231 @@ +/** + * Wraps LlamaLanguageModel to add proper tool calling support. + * + * @react-native-ai/llama v0.10.0 implements the AI SDK LanguageModelV2 + * interface but its doStream/doGenerate completely ignore options.tools — + * they never reach llama.rn's context.completion(). This wrapper fixes + * that by calling context.completion() directly with the tools and + * message format that llama.rn actually expects. + * + * Note: we avoid importing from the root @ai-sdk/provider because there + * are two installed versions (root + ai/node_modules) and they are not + * assignable to each other in TypeScript even though they're identical at + * runtime. We use inline structural types instead. + */ + +import { generateId } from '@ai-sdk/provider-utils'; +import { isString } from '@packrat/guards'; +import type { LlamaLanguageModel } from '@react-native-ai/llama'; + +// Minimal structural slice of LanguageModelV2CallOptions we need +type Prompt = ReadonlyArray<{ + role: string; + // biome-ignore lint/suspicious/noExplicitAny: mirrors AI SDK union + content: any; + providerOptions?: unknown; +}>; + +type ToolDef = { + type: 'function'; + name: string; + description?: string; + // biome-ignore lint/suspicious/noExplicitAny: JSON schema + inputSchema: any; +}; + +type CallOptions = { + prompt: Prompt; + tools?: ToolDef[] | readonly ToolDef[]; + temperature?: number; + maxOutputTokens?: number; + topP?: number; + topK?: number; +}; + +// llama.rn OAI-compatible message extended with tool calling fields +// that the model's Jinja template understands. +type LlamaMessage = { + role: string; + content?: string; + // biome-ignore lint/suspicious/noExplicitAny: passed through to Jinja template as-is + tool_calls?: any[]; + tool_call_id?: string; + name?: string; +}; + +// biome-ignore lint/suspicious/noExplicitAny: mirrors AI SDK ToolResultOutput union +function toolResultOutputToString(output: any): string { + if (!output) return ''; + if (isString(output)) return output; + switch (output.type) { + case 'text': + case 'error-text': + return String(output.value ?? ''); + case 'json': + case 'error-json': + return JSON.stringify(output.value); + case 'content': + return (output.value ?? []) + .map((p: { type: string; text?: string }) => (p.type === 'text' ? (p.text ?? '') : '')) + .join(''); + default: + return JSON.stringify(output); + } +} + +function convertPromptToLlamaMessages(prompt: Prompt): LlamaMessage[] { + const out: LlamaMessage[] = []; + + for (const msg of prompt) { + if (msg.role === 'system') { + out.push({ role: 'system', content: String(msg.content ?? '') }); + continue; + } + + if (msg.role === 'user') { + const parts: { type: string; text?: string }[] = Array.isArray(msg.content) + ? msg.content + : []; + const text = parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .join(''); + out.push({ role: 'user', content: text }); + continue; + } + + if (msg.role === 'assistant') { + const parts: { + type: string; + text?: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + }[] = Array.isArray(msg.content) ? msg.content : []; + const textParts = parts.filter((p) => p.type === 'text'); + const callParts = parts.filter((p) => p.type === 'tool-call'); + + if (callParts.length > 0) { + out.push({ + role: 'assistant', + content: textParts.map((p) => p.text ?? '').join(''), + tool_calls: callParts.map((p) => ({ + type: 'function', + id: p.toolCallId, + function: { + name: p.toolName, + arguments: isString(p.input) ? p.input : JSON.stringify(p.input), + }, + })), + }); + } else { + out.push({ role: 'assistant', content: textParts.map((p) => p.text ?? '').join('') }); + } + continue; + } + + if (msg.role === 'tool') { + const parts: { type: string; toolCallId: string; toolName: string; output: unknown }[] = + Array.isArray(msg.content) ? msg.content : []; + for (const part of parts) { + if (part.type !== 'tool-result') continue; + out.push({ + role: 'tool', + tool_call_id: part.toolCallId, + name: part.toolName, + content: toolResultOutputToString(part.output), + }); + } + } + } + + return out; +} + +function convertTools(tools: CallOptions['tools']): object[] | undefined { + if (!tools?.length) return undefined; + const fns = [...tools].filter((t) => t.type === 'function'); + if (!fns.length) return undefined; + return fns.map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description, parameters: t.inputSchema }, + })); +} + +export class LlamaToolsWrapper { + readonly specificationVersion = 'v2' as const; + readonly supportedUrls = {}; + readonly provider = 'llama'; + + constructor(private readonly inner: LlamaLanguageModel) {} + + get modelId() { + return this.inner.modelId; + } + + async doGenerate(_options: CallOptions): Promise { + throw new Error('LlamaToolsWrapper.doGenerate not implemented'); + } + + async doStream(options: CallOptions): Promise<{ stream: ReadableStream }> { + const context = this.inner.getContext(); + if (!context) throw new Error('Llama model not prepared. Call prepare() first.'); + + const messages = convertPromptToLlamaMessages(options.prompt); + const llamaTools = convertTools(options.tools); + + const stream = new ReadableStream({ + start: async (controller) => { + try { + controller.enqueue({ type: 'stream-start', warnings: [] }); + + const result = await context.completion({ + messages: messages as Parameters[0]['messages'], + tools: llamaTools, + tool_choice: llamaTools ? 'auto' : undefined, + temperature: options.temperature, + n_predict: options.maxOutputTokens, + top_p: options.topP, + top_k: options.topK, + }); + + const usage = { + inputTokens: result.timings?.prompt_n ?? 0, + outputTokens: result.timings?.predicted_n ?? 0, + totalTokens: (result.timings?.prompt_n ?? 0) + (result.timings?.predicted_n ?? 0), + }; + + if (result.tool_calls?.length) { + for (const call of result.tool_calls) { + controller.enqueue({ + type: 'tool-call', + toolCallId: call.id ?? generateId(), + toolName: call.function.name, + input: call.function.arguments, + }); + } + controller.enqueue({ type: 'finish', finishReason: 'tool-calls', usage }); + } else { + const textId = generateId(); + const text = result.content || result.text || ''; + controller.enqueue({ type: 'text-start', id: textId }); + controller.enqueue({ type: 'text-delta', id: textId, delta: text }); + controller.enqueue({ type: 'text-end', id: textId }); + controller.enqueue({ type: 'finish', finishReason: 'stop', usage }); + } + + controller.close(); + } catch (error) { + try { + controller.enqueue({ type: 'error', error }); + controller.close(); + } catch { + // controller already closed + } + } + }, + }); + + return { stream }; + } +} diff --git a/apps/expo/features/ai/lib/localModelManager.ts b/apps/expo/features/ai/lib/localModelManager.ts index a72ca60236..6138a00f4a 100644 --- a/apps/expo/features/ai/lib/localModelManager.ts +++ b/apps/expo/features/ai/lib/localModelManager.ts @@ -2,14 +2,16 @@ * Singleton manager for the on-device AI model. * * - On iOS 26+: uses @react-native-ai/apple (Apple Foundation Models, no download needed) - * - On other devices: uses @react-native-ai/llama with SmolLM3-3B-GGUF + * - On other devices: uses @react-native-ai/llama with Qwen2.5-3B-Instruct Q3_K_M * * Updates Jotai atoms via the global store so download progress is visible * from any component, even while the bottom sheet is closed. */ import { isString } from '@packrat/guards'; -import { type LlamaLanguageModel, llama } from '@react-native-ai/llama'; +import type { LlamaLanguageModel } from '@react-native-ai/llama'; +import { llama } from '@react-native-ai/llama'; +import type { LanguageModel } from 'ai'; import { store } from 'expo-app/atoms/store'; import { Platform } from 'react-native'; import RNBlobUtil from 'react-native-blob-util'; @@ -19,11 +21,12 @@ import { localModelProgressAtom, localModelStatusAtom, } from '../atoms/aiModeAtoms'; - +import { AppleModelWrapper } from './appleModelWrapper'; import { LLAMA_MODEL_ID, LLAMA_MODEL_SIZE_BYTES } from './constants'; +import { LlamaToolsWrapper } from './llamaToolsWrapper'; import { createLocalTools } from './tools'; -const LLAMA_MODEL_FILENAME = 'SmolLM3-Q4_K_M.gguf'; +const LLAMA_MODEL_FILENAME = LLAMA_MODEL_ID.split('/').at(-1) ?? 'model.gguf'; const LLAMA_MODELS_DIR = `${RNBlobUtil.fs.dirs.DocumentDir}/llama-models`; function _getLlamaModelPath(): string { @@ -58,9 +61,12 @@ function getAppleModule() { if (appleModule) return appleModule; try { - appleModule = import('@react-native-ai/apple'); + // require() is synchronous — import() returns a Promise, which breaks + // the synchronous callers (isAppleIntelligenceAvailable, etc.) + appleModule = require('@react-native-ai/apple'); return appleModule; - } catch { + } catch (err) { + console.error('Failed to load Apple module:', err); return null; } } @@ -68,6 +74,7 @@ function getAppleModule() { // ─── Singletons ───────────────────────────────────────────────────────────── let llamaModel: LlamaLanguageModel | null = null; +let llamaModelWrapper: LlamaToolsWrapper | null = null; // biome-ignore lint/suspicious/noExplicitAny: Apple module type unknown let appleModel: any = null; // biome-ignore lint/suspicious/noExplicitAny: download task type unknown @@ -98,9 +105,10 @@ export function isAppleIntelligenceAvailable(): boolean { } /** Returns the ready model instance, or null if not prepared yet. */ -export function getLocalModel(): LlamaLanguageModel | null { +export function getLocalModel(): LanguageModel | null { if (isAppleIntelligenceAvailable()) return appleModel; - return llamaModel; + // safe-cast: LlamaToolsWrapper is structurally a LanguageModel; double-cast through unknown is required because two incompatible @ai-sdk/provider versions are installed + return llamaModelWrapper as unknown as LanguageModel; } /** Check if the local model file is fully present on disk (existence + size). */ @@ -144,7 +152,7 @@ export async function downloadLocalModel(): Promise { store.set(localModelErrorAtom, null); if (!llamaModel) { - llamaModel = llama.languageModel(LLAMA_MODEL_ID, { n_ctx: 2048, n_gpu_layers: 99 }); + llamaModel = llama.languageModel(LLAMA_MODEL_ID, { n_ctx: 4096, n_gpu_layers: 99 }); } const isAvailable = await _isLlamaModelAvailable(); @@ -171,8 +179,15 @@ export async function downloadLocalModel(): Promise { activeDownloadTask.progress((received: number, total: number) => { store.set(localModelProgressAtom, Math.round((Number(received) / Number(total)) * 100)); }); - await activeDownloadTask; + const downloadRes = await activeDownloadTask; activeDownloadTask = null; + const httpStatus = downloadRes.respInfo?.status ?? 0; + if (httpStatus < 200 || httpStatus >= 300) { + await RNBlobUtil.fs.unlink(_getLlamaModelPath()).catch(() => {}); + store.set(localModelStatusAtom, 'error'); + store.set(localModelErrorAtom, `Download failed: HTTP ${httpStatus}`); + return; + } } catch (err) { activeDownloadTask = null; if (_isCancellingDownload) return; @@ -208,6 +223,32 @@ export async function cancelLocalModelDownload(): Promise { _isCancellingDownload = false; } +/** + * Unload the active model from memory without deleting the file. + * Use when backgrounding the app or before hot-reload, so native modules + * are cleanly invalidated. Call `initLocalModel` to reload after. + */ +export async function releaseLocalModel(): Promise { + if (appleModel) { + try { + await appleModel.unload?.(); + } catch { + // ignore + } + appleModel = null; + } + if (llamaModel) { + try { + await llamaModel.unload(); + } catch { + // ignore + } + llamaModel = null; + llamaModelWrapper = null; + } + store.set(localModelStatusAtom, 'idle'); +} + /** Delete the downloaded llama model from disk. */ export async function deleteLocalModel(): Promise { if (llamaModel) { @@ -217,6 +258,7 @@ export async function deleteLocalModel(): Promise { // ignore unload errors } llamaModel = null; + llamaModelWrapper = null; } // Direct filesystem deletion — more reliable than the library's remove() @@ -245,8 +287,11 @@ async function _initAppleModel(): Promise { const mod = await getAppleModule(); if (!mod) throw new Error('Apple module not available'); - appleModel = mod.apple(); - appleModel.updateTools(createLocalTools()); + const apple = mod.createAppleProvider({ + availableTools: createLocalTools(), + }); + + appleModel = new AppleModelWrapper(apple()); store.set(localModelStatusAtom, 'ready'); } catch { store.set(localModelStatusAtom, 'error'); @@ -256,7 +301,7 @@ async function _initAppleModel(): Promise { async function _initLlamaModel(): Promise { if (!llamaModel) { - llamaModel = llama.languageModel(LLAMA_MODEL_ID, { n_ctx: 2048, n_gpu_layers: 99 }); + llamaModel = llama.languageModel(LLAMA_MODEL_ID, { n_ctx: 4096, n_gpu_layers: 99 }); } const isAvailable = await _isLlamaModelAvailable(); store.set(localModelFileAvailableAtom, isAvailable); @@ -273,6 +318,7 @@ async function _prepareLlamaModel(): Promise { try { if (!llamaModel) throw new Error('llamaModel is not initialised'); await llamaModel.prepare(); + llamaModelWrapper = new LlamaToolsWrapper(llamaModel); store.set(localModelFileAvailableAtom, true); store.set(localModelStatusAtom, 'ready'); } catch (err) { diff --git a/apps/expo/features/ai/lib/localModelManager.web.ts b/apps/expo/features/ai/lib/localModelManager.web.ts new file mode 100644 index 0000000000..548d5a8914 --- /dev/null +++ b/apps/expo/features/ai/lib/localModelManager.web.ts @@ -0,0 +1,25 @@ +/** + * Web no-op stub for localModelManager. + * On-device AI models (llama.rn, @react-native-ai/apple) are native-only. + * Metro automatically picks this file over localModelManager.ts for web builds. + */ + +export function isAppleIntelligenceAvailable(): boolean { + return false; +} + +export function getLocalModel(): null { + return null; +} + +export async function isLlamaModelDownloaded(): Promise { + return false; +} + +export async function initLocalModel(): Promise {} + +export async function downloadLocalModel(): Promise {} + +export async function cancelLocalModelDownload(): Promise {} + +export async function deleteLocalModel(): Promise {} diff --git a/apps/expo/features/ai/lib/tools.ts b/apps/expo/features/ai/lib/tools.ts index fccb336d7a..b66ff14e22 100644 --- a/apps/expo/features/ai/lib/tools.ts +++ b/apps/expo/features/ai/lib/tools.ts @@ -10,14 +10,36 @@ * through authenticated API endpoints. */ +import { isString } from '@packrat/guards'; import * as Sentry from '@sentry/react-native'; import { tool } from 'ai'; import { getPackItems, packItemsStore } from 'expo-app/features/packs/store/packItems'; import { packsStore } from 'expo-app/features/packs/store/packs'; -import { getWeatherData, searchLocations } from 'expo-app/features/weather/lib/weatherService'; +import { + formatWeatherData, + getWeatherData, + searchLocations, +} from 'expo-app/features/weather/lib/weatherService'; import { apiClient } from 'expo-app/lib/api/packrat'; import { z } from 'zod'; +function trimCatalogItem(item: unknown) { + // safe-cast: items originate from the PackRat API which returns typed JSON objects + const obj = item as Record; + const cats = Array.isArray(obj.categories) ? (obj.categories as string[]).slice(0, 2) : []; + return { + id: obj.id, + name: obj.name, + brand: obj.brand, + weight: obj.weight, + weightUnit: obj.weightUnit, + categories: cats, + price: obj.price, + ratingValue: obj.ratingValue, + description: isString(obj.description) ? obj.description.slice(0, 120) : undefined, + }; +} + export function createLocalTools() { return { getPackDetails: tool({ @@ -116,7 +138,7 @@ export function createLocalTools() { return { success: false, error: `No location found for "${location}"` }; } const weatherData = await getWeatherData(first.id); - return { success: true, data: weatherData }; + return { success: true, data: formatWeatherData(weatherData) }; } catch (error) { return { success: false, @@ -135,12 +157,13 @@ export function createLocalTools() { limit: z .number() .min(1) - .max(50) + .max(10) .optional() - .describe('Number of results to return (default 10)'), + .describe('Number of results to return (default 5, max 10)'), offset: z.number().min(0).optional().describe('Offset for pagination'), }), - execute: async ({ query, category, limit = 10, offset: _offset = 0 }) => { + execute: async ({ query, category, limit = 5, offset: _offset = 0 }) => { + console.log('getCatalogItems called with', { query, category, limit, offset: _offset }); const { data, error } = await apiClient.catalog.get({ query: { page: 1, @@ -152,7 +175,10 @@ export function createLocalTools() { if (error) { return { success: false, error: error.value ?? 'Failed to retrieve catalog items' }; } - return { success: true, data }; + const items = Array.isArray(data) ? data : ((data as { items?: unknown[] })?.items ?? []); + const trimmedItems = items.map((item) => trimCatalogItem(item)); + console.log('getCatalogItems returning', { items: trimmedItems }); + return { success: true, data: { items: trimmedItems } }; }, }), @@ -164,19 +190,23 @@ export function createLocalTools() { limit: z .number() .min(1) - .max(100) + .max(10) .optional() - .describe('Number of results to return (default 10)'), + .describe('Number of results to return (default 5, max 10)'), offset: z.number().min(0).optional().describe('Offset for pagination'), }), - execute: async ({ query, limit = 10, offset = 0 }) => { + execute: async ({ query, limit = 5, offset = 0 }) => { const { data, error } = await apiClient.catalog['vector-search'].get({ query: { q: query, limit, offset }, }); if (error) { return { success: false, error: error.value ?? 'Failed to perform vector search' }; } - return { success: true, data }; + const items = Array.isArray(data) ? data : ((data as { items?: unknown[] })?.items ?? []); + return { + success: true, + data: { items: items.map((item) => trimCatalogItem(item)) }, + }; }, }), diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index e7be9f4966..95ed336fae 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -1,4 +1,3 @@ -import { clientEnvs } from '@packrat/env/expo-client'; import { asBoolean, asString } from '@packrat/guards'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { @@ -189,27 +188,19 @@ export function useAuthActions() { }; const forgotPassword = async (email: string) => { - const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/request`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + const { error } = await authClient.requestPasswordReset({ + email, + redirectTo: 'packrat://reset-password', }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error ?? 'Forgot password failed'); - } + if (error) throw new Error(error.message ?? 'Forgot password failed'); }; - const resetPassword = async (email: string, opts: { token: string; newPassword: string }) => { - const res = await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/password-reset/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code: opts.token, newPassword: opts.newPassword }), + const resetPassword = async (_email: string, opts: { token: string; newPassword: string }) => { + const { error } = await authClient.resetPassword({ + token: opts.token, + newPassword: opts.newPassword, }); - if (!res.ok) { - const data = (await res.json()) as { error?: string }; - throw new Error(data.error ?? 'Reset password failed'); - } + if (error) throw new Error(error.message ?? 'Reset password failed'); }; const verifyEmail = async (_email: string, token: string) => { diff --git a/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts b/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts index f672865154..b141891f81 100644 --- a/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts +++ b/apps/expo/features/catalog/hooks/useCatalogItemDetails.ts @@ -1,4 +1,4 @@ -import { CatalogItemSchema } from '@packrat/api/schemas/catalog'; +import { CatalogItemSchema } from '@packrat/schemas/catalog'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/catalog/hooks/useCatalogItems.ts b/apps/expo/features/catalog/hooks/useCatalogItems.ts index 50e9e0e7c9..528b58296b 100644 --- a/apps/expo/features/catalog/hooks/useCatalogItems.ts +++ b/apps/expo/features/catalog/hooks/useCatalogItems.ts @@ -1,4 +1,4 @@ -import { CatalogItemsResponseSchema } from '@packrat/api/schemas/catalog'; +import { CatalogItemsResponseSchema } from '@packrat/schemas/catalog'; import { useInfiniteQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; diff --git a/apps/expo/features/catalog/hooks/useVectorSearch.ts b/apps/expo/features/catalog/hooks/useVectorSearch.ts index 34baec182b..ac0d8a83cb 100644 --- a/apps/expo/features/catalog/hooks/useVectorSearch.ts +++ b/apps/expo/features/catalog/hooks/useVectorSearch.ts @@ -1,4 +1,4 @@ -import { VectorSearchResponseSchema } from '@packrat/api/schemas/catalog'; +import { VectorSearchResponseSchema } from '@packrat/schemas/catalog'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from 'expo-app/lib/api/packrat'; import { useAuthenticatedQueryToolkit } from 'expo-app/lib/hooks/useAuthenticatedQueryToolkit'; diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 80aec8997c..cedaffedec 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { assertDefined, fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import { Button, Text } from '@packrat/ui/nativewindui'; import { useQueryClient } from '@tanstack/react-query'; import * as Burnt from 'burnt'; diff --git a/apps/expo/features/catalog/types.ts b/apps/expo/features/catalog/types.ts index a00dbdf8e8..8b2e07e5a8 100644 --- a/apps/expo/features/catalog/types.ts +++ b/apps/expo/features/catalog/types.ts @@ -1,4 +1,4 @@ -import type { CatalogItemSchema } from '@packrat/api/schemas/catalog'; +import type { CatalogItemSchema } from '@packrat/schemas/catalog'; import type { z } from 'zod'; import type { PackItemInput } from '../packs/input'; diff --git a/apps/expo/features/guides/types.ts b/apps/expo/features/guides/types.ts index 7bf619f216..f6ac2a5940 100644 --- a/apps/expo/features/guides/types.ts +++ b/apps/expo/features/guides/types.ts @@ -7,7 +7,7 @@ export interface Guide { description: string; content?: string; author?: string; - readingTime?: string; + readingTime?: number; difficulty?: string; createdAt: string; updatedAt: string; diff --git a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx index a52eb54cb3..7d4a6a1168 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx @@ -1,5 +1,5 @@ -import { PackCategorySchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { PackCategorySchema } from '@packrat/schemas/constants'; import { Button, createDropdownItem, diff --git a/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts b/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts index ffb1e49686..68b0c57afe 100644 --- a/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts +++ b/apps/expo/features/pack-templates/hooks/useBulkAddCatalogItems.ts @@ -1,5 +1,5 @@ -import { WeightUnitSchema } from '@packrat/api/types'; import { fromZod } from '@packrat/guards'; +import { WeightUnitSchema } from '@packrat/schemas/constants'; import { cacheCatalogItemImage } from 'expo-app/features/catalog/lib/cacheCatalogItemImage'; import type { CatalogItemWithPackItemFields } from 'expo-app/features/catalog/types'; import { useState } from 'react'; diff --git a/apps/expo/features/pack-templates/packTemplateListAtoms.ts b/apps/expo/features/pack-templates/packTemplateListAtoms.ts index 293d6d3ce0..beea35f837 100644 --- a/apps/expo/features/pack-templates/packTemplateListAtoms.ts +++ b/apps/expo/features/pack-templates/packTemplateListAtoms.ts @@ -1,4 +1,4 @@ -import type { PackCategory } from 'expo-app/types'; +import type { PackCategory } from '@packrat/constants'; import { atom } from 'jotai'; export const activeTemplateFilterAtom = atom('all'); diff --git a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx index 615d0d2e98..8a9db11b7c 100644 --- a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx +++ b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx @@ -1,6 +1,7 @@ // CreatePackTemplateItemForm.tsx import { useActionSheet } from '@expo/react-native-action-sheet'; +import type { WeightUnit } from '@packrat/constants'; import { safeIndexOf } from '@packrat/guards'; import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packrat/ui/nativewindui'; import { useForm } from '@tanstack/react-form'; @@ -9,7 +10,6 @@ import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import type { WeightUnit } from 'expo-app/types'; import { useRouter } from 'expo-router'; import { useMemo, useRef, useState } from 'react'; import { Alert, Image, Pressable, Switch, Text, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/pack-templates/store/packTemplateItems.ts b/apps/expo/features/pack-templates/store/packTemplateItems.ts index f942a5e6f4..8ac2fbaef4 100644 --- a/apps/expo/features/pack-templates/store/packTemplateItems.ts +++ b/apps/expo/features/pack-templates/store/packTemplateItems.ts @@ -4,7 +4,7 @@ import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackTemplateItemSchema, PackTemplateWithItemsSchema, -} from '@packrat/api/schemas/packTemplates'; +} from '@packrat/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/pack-templates/store/packTemplates.ts b/apps/expo/features/pack-templates/store/packTemplates.ts index 8e6afbf641..78d023754b 100644 --- a/apps/expo/features/pack-templates/store/packTemplates.ts +++ b/apps/expo/features/pack-templates/store/packTemplates.ts @@ -1,10 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { - PackTemplateSchema, - PackTemplateWithItemsSchema, -} from '@packrat/api/schemas/packTemplates'; +import { PackTemplateSchema, PackTemplateWithItemsSchema } from '@packrat/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index 247f26e154..d540b5cf29 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -36,6 +36,7 @@ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCata onPress={isSelectable ? () => restProps.onSelect(item) : restProps.onPress} > !!obs(packsStore, id).get()); } diff --git a/apps/expo/features/packs/input.ts b/apps/expo/features/packs/input.ts index 806351be95..2ae602c9b2 100644 --- a/apps/expo/features/packs/input.ts +++ b/apps/expo/features/packs/input.ts @@ -1,4 +1,4 @@ -import type { WeightUnit } from 'expo-app/types'; +import type { WeightUnit } from '@packrat/constants'; export interface PackItemInput { name: string; diff --git a/apps/expo/features/packs/packListAtoms.ts b/apps/expo/features/packs/packListAtoms.ts index daf48bb44b..83d91ef285 100644 --- a/apps/expo/features/packs/packListAtoms.ts +++ b/apps/expo/features/packs/packListAtoms.ts @@ -1,4 +1,4 @@ -import type { PackCategory } from 'expo-app/types'; +import type { PackCategory } from '@packrat/constants'; import { atom } from 'jotai'; export const activeFilterAtom = atom('all'); diff --git a/apps/expo/features/packs/screens/CreatePackItemForm.tsx b/apps/expo/features/packs/screens/CreatePackItemForm.tsx index 9f0dff53d7..324de70dd7 100644 --- a/apps/expo/features/packs/screens/CreatePackItemForm.tsx +++ b/apps/expo/features/packs/screens/CreatePackItemForm.tsx @@ -1,4 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import type { WeightUnit } from '@packrat/constants'; import { safeIndexOf } from '@packrat/guards'; import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packrat/ui/nativewindui'; import { useForm } from '@tanstack/react-form'; @@ -7,7 +8,6 @@ import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import type { WeightUnit } from 'expo-app/types'; import { useRouter } from 'expo-router'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Image, Pressable, Switch, Text, TouchableOpacity, View } from 'react-native'; diff --git a/apps/expo/features/packs/store/packItems.ts b/apps/expo/features/packs/store/packItems.ts index e8339a567f..e7d88f1744 100644 --- a/apps/expo/features/packs/store/packItems.ts +++ b/apps/expo/features/packs/store/packItems.ts @@ -1,8 +1,8 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackItemSchema, PackWithWeightsSchema } from '@packrat/api/schemas/packs'; import { isRemoteUrl } from '@packrat/guards'; +import { PackItemSchema, PackWithWeightsSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/store/packWeightHistory.ts b/apps/expo/features/packs/store/packWeightHistory.ts index 75dc2f32a1..8a5f979ce6 100644 --- a/apps/expo/features/packs/store/packWeightHistory.ts +++ b/apps/expo/features/packs/store/packWeightHistory.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackWeightHistoryResponseSchema } from '@packrat/api/schemas/packs'; +import { PackWeightHistoryResponseSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/store/packs.ts b/apps/expo/features/packs/store/packs.ts index 4cf21b0b8b..f73daf13e3 100644 --- a/apps/expo/features/packs/store/packs.ts +++ b/apps/expo/features/packs/store/packs.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { PackWithWeightsSchema } from '@packrat/api/schemas/packs'; +import { PackWithWeightsSchema } from '@packrat/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/packs/types.ts b/apps/expo/features/packs/types.ts index 9b6f433895..512988ee48 100644 --- a/apps/expo/features/packs/types.ts +++ b/apps/expo/features/packs/types.ts @@ -1,6 +1,6 @@ +import type { PackCategory, WeightUnit } from '@packrat/constants'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import type { PackTemplateItem } from 'expo-app/features/pack-templates/types'; -import type { PackCategory, WeightUnit } from 'expo-app/types'; export type { PackCategory, WeightUnit }; diff --git a/apps/expo/features/packs/utils/uploadImage.web.ts b/apps/expo/features/packs/utils/uploadImage.web.ts new file mode 100644 index 0000000000..bda654704a --- /dev/null +++ b/apps/expo/features/packs/utils/uploadImage.web.ts @@ -0,0 +1,71 @@ +/** + * Web version of uploadImage. + * Uses the browser Fetch API to upload images via a presigned URL. + * The caller obtains a presigned URL from the API (same as the native flow) + * but the binary upload uses fetch instead of expo-file-system. + */ +import { userStore } from 'expo-app/features/auth/store'; +import { apiClient } from 'expo-app/lib/api/packrat'; + +export const uploadImage = async ( + fileName: string, + blobOrDataUrl: string, +): Promise => { + if (!fileName || fileName.trim() === '') { + console.warn('Skipping upload: fileName is empty'); + return; + } + + try { + const fileExtension = fileName.split('.').pop()?.toLowerCase() || 'jpg'; + const type = `image/${fileExtension === 'jpg' ? 'jpeg' : fileExtension}`; + const remoteFileName = `${userStore.id.peek()}-${fileName}`; + + const { url: presignedUrl } = await getPresignedUrl(remoteFileName, type); + + // Convert data URL / blob URL to a Blob for upload + const blob = await urlToBlob(blobOrDataUrl, type); + + const uploadResponse = await fetch(presignedUrl, { + method: 'PUT', + body: blob, + headers: { 'Content-Type': type }, + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status: ${uploadResponse.status}`); + } + + return remoteFileName; + } catch (err) { + console.error('Error uploading image:', err); + throw err; + } +}; + +const getPresignedUrl = async ( + fileName: string, + contentType: string, +): Promise<{ url: string; publicUrl: string; objectKey: string }> => { + const { data, error } = await apiClient.upload.presigned.get({ + query: { fileName, contentType }, + }); + if (error || !data) throw new Error(`Failed to get upload URL: ${error?.value}`); + return data; +}; + +async function urlToBlob(url: string, type: string): Promise { + if (url.startsWith('data:')) { + const arr = url.split(','); + const bstr = atob(arr[1] ?? ''); + const n = bstr.length; + const u8arr = new Uint8Array(n); + for (let i = 0; i < n; i++) { + u8arr[i] = bstr.charCodeAt(i); + } + return new Blob([u8arr], { type }); + } + // blob: URL or http URL — fetch it + const res = await fetch(url); + return res.blob(); +} diff --git a/apps/expo/features/profile/types.ts b/apps/expo/features/profile/types.ts index 433004df47..00836cd2eb 100644 --- a/apps/expo/features/profile/types.ts +++ b/apps/expo/features/profile/types.ts @@ -1,4 +1,4 @@ -import type { UserSchema } from '@packrat/api/schemas/users'; +import type { UserSchema } from '@packrat/schemas/users'; import type { z } from 'zod'; import type { WeightUnit } from '../packs/types'; diff --git a/apps/expo/features/trail-conditions/store/trailConditionReports.ts b/apps/expo/features/trail-conditions/store/trailConditionReports.ts index 0ddc8d5115..21a5989235 100644 --- a/apps/expo/features/trail-conditions/store/trailConditionReports.ts +++ b/apps/expo/features/trail-conditions/store/trailConditionReports.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { TrailConditionReportSchema } from '@packrat/api/schemas/trailConditions'; +import { TrailConditionReportSchema } from '@packrat/schemas/trailConditions'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/trips/components/TripForm.tsx b/apps/expo/features/trips/components/TripForm.tsx index a013bb5196..33ad5847d7 100644 --- a/apps/expo/features/trips/components/TripForm.tsx +++ b/apps/expo/features/trips/components/TripForm.tsx @@ -1,13 +1,13 @@ import { assertDefined, isString } from '@packrat/guards'; import { Form, FormItem, FormSection, TextField } from '@packrat/ui/nativewindui'; import DateTimePicker from '@react-native-community/datetimepicker'; -import { Picker } from '@react-native-picker/picker'; import { useForm } from '@tanstack/react-form'; import * as Burnt from 'burnt'; import { Icon } from 'expo-app/components/Icon'; import { usePacks } from 'expo-app/features/packs/hooks/usePacks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { Picker } from 'expo-app/lib/Picker'; import { testIds } from 'expo-app/lib/testIds'; import { Stack, useRouter } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; @@ -170,6 +170,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { {(field) => ( { {(field) => ( (null); // safe-cast: trip may be undefined before the store is hydrated; the guard at line ~38 handles // the undefined case and returns early, ensuring trip is non-null at render time below. const trip = useTripDetailsFromStore(id as string) as Trip; const packs = useDetailedPacks(); + const deleteTrip = useDeleteTrip(); // Create a stable key for MapView based on location coordinates // This forces remount when location changes, fixing iOS initialRegion issue @@ -66,6 +77,24 @@ export function TripDetailScreen() { } }; + const handleDeleteTrip = () => { + alertRef.current?.alert({ + title: t('trips.deleteTrip'), + message: t('trips.deleteTripConfirmation'), + buttons: [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('common.delete'), + style: 'destructive', + onPress: async () => { + await deleteTrip(id as string); + router.back(); + }, + }, + ], + }); + }; + const handleWeatherPress = () => { if (!trip.location) return; @@ -95,6 +124,32 @@ export function TripDetailScreen() { color={colors.grey2} /> + + {/* Dates */} @@ -262,6 +317,7 @@ export function TripDetailScreen() { /> + ); } diff --git a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx index bddef8c413..7fa5b63ef7 100644 --- a/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx +++ b/apps/expo/features/trips/screens/TripWeatherDetailsScreen.tsx @@ -1,4 +1,4 @@ -import type { WeatherAPIForecastResponse } from '@packrat/api/schemas/weather'; +import type { WeatherAPIForecastResponse } from '@packrat/schemas/weather'; import { Icon } from 'expo-app/components/Icon'; import { WeatherForecast } from 'expo-app/features/weather/components/WeatherForecast'; import { diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index 6d428457d2..d14d5c0bc6 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -1,7 +1,7 @@ import { observable, syncState } from '@legendapp/state'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; -import { TripSchema } from '@packrat/api/schemas/trips'; +import { TripSchema } from '@packrat/schemas/trips'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; import { persistPlugin } from 'expo-app/lib/persist-plugin'; diff --git a/apps/expo/features/weather/lib/weatherService.ts b/apps/expo/features/weather/lib/weatherService.ts index 89f4078671..284cc2dd57 100644 --- a/apps/expo/features/weather/lib/weatherService.ts +++ b/apps/expo/features/weather/lib/weatherService.ts @@ -1,9 +1,9 @@ +import { assertDefined } from '@packrat/guards'; import { LocationSearchResponseSchema, type WeatherAPIForecastResponse, WeatherAPIForecastResponseSchema, -} from '@packrat/api/schemas/weather'; -import { assertDefined } from '@packrat/guards'; +} from '@packrat/schemas/weather'; import * as Sentry from '@sentry/react-native'; import { apiClient } from 'expo-app/lib/api/packrat'; import { getWeatherIconName as getIconNameFromCode } from './weatherIcons'; diff --git a/apps/expo/lib/Picker.tsx b/apps/expo/lib/Picker.tsx new file mode 100644 index 0000000000..351bb642e9 --- /dev/null +++ b/apps/expo/lib/Picker.tsx @@ -0,0 +1 @@ +export { Picker } from '@react-native-picker/picker'; diff --git a/apps/expo/lib/Picker.web.tsx b/apps/expo/lib/Picker.web.tsx new file mode 100644 index 0000000000..44e989ff71 --- /dev/null +++ b/apps/expo/lib/Picker.web.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; + +type ItemProps = { + label: string; + value: string | number; +}; + +type PickerProps = { + selectedValue?: string | number | null; + onValueChange?: (value: string) => void; + children?: React.ReactNode; + style?: React.CSSProperties; +}; + +function PickerItem(_props: ItemProps) { + return null; +} + +function Picker({ selectedValue, onValueChange, children }: PickerProps) { + const options: ItemProps[] = []; + React.Children.forEach(children, (child) => { + if (React.isValidElement(child) && child.type === PickerItem) { + options.push(child.props); + } + }); + + return ( + + ); +} + +Picker.Item = PickerItem; + +export { Picker }; diff --git a/apps/expo/lib/appleAuthentication.ts b/apps/expo/lib/appleAuthentication.ts new file mode 100644 index 0000000000..ca7ce9cfa4 --- /dev/null +++ b/apps/expo/lib/appleAuthentication.ts @@ -0,0 +1,7 @@ +export { + AppleAuthenticationOperation, + AppleAuthenticationScope, + AppleAuthenticationUserDetectionStatus, + isAvailableAsync, + signInAsync, +} from 'expo-apple-authentication'; diff --git a/apps/expo/lib/appleAuthentication.web.ts b/apps/expo/lib/appleAuthentication.web.ts new file mode 100644 index 0000000000..ec609349f0 --- /dev/null +++ b/apps/expo/lib/appleAuthentication.web.ts @@ -0,0 +1,12 @@ +export const isAvailableAsync = (): Promise => Promise.resolve(false); + +export const signInAsync = (): Promise => + Promise.reject(new Error('Apple Sign-In is not available on web.')); + +export const AppleAuthenticationScope = { FULL_NAME: 0, EMAIL: 1 }; +export const AppleAuthenticationOperation = { LOGIN: 0, REFRESH: 1, LOGOUT: 2, IMPLICIT: 3 }; +export const AppleAuthenticationUserDetectionStatus = { + UNKNOWN: 0, + UNSUPPORTED: 1, + LIKELY_REAL: 2, +}; diff --git a/apps/expo/lib/constants.web.ts b/apps/expo/lib/constants.web.ts new file mode 100644 index 0000000000..5be19f6d0c --- /dev/null +++ b/apps/expo/lib/constants.web.ts @@ -0,0 +1,5 @@ +/** + * Web equivalent of lib/constants.ts. + * There is no filesystem-backed image cache on web; the browser handles caching. + */ +export const IMAGES_DIR = ''; diff --git a/apps/expo/lib/devClient.ts b/apps/expo/lib/devClient.ts new file mode 100644 index 0000000000..15703db6e3 --- /dev/null +++ b/apps/expo/lib/devClient.ts @@ -0,0 +1 @@ +import 'expo-dev-client'; diff --git a/apps/expo/lib/devClient.web.ts b/apps/expo/lib/devClient.web.ts new file mode 100644 index 0000000000..5d7e59fc5c --- /dev/null +++ b/apps/expo/lib/devClient.web.ts @@ -0,0 +1 @@ +// expo-dev-client is not needed on web diff --git a/apps/expo/lib/hooks/useColorScheme.web.tsx b/apps/expo/lib/hooks/useColorScheme.web.tsx new file mode 100644 index 0000000000..971bb9d2c1 --- /dev/null +++ b/apps/expo/lib/hooks/useColorScheme.web.tsx @@ -0,0 +1,37 @@ +import { COLORS } from 'expo-app/theme/colors'; +import { useColorScheme as useNativewindColorScheme } from 'nativewind'; +import * as React from 'react'; + +/** + * Web version of useColorScheme. + * Removes the expo-navigation-bar dependency (Android-only native module). + * Metro automatically picks this file over useColorScheme.tsx for web builds. + */ +function useColorScheme() { + const { colorScheme, setColorScheme: setNativeWindColorScheme } = useNativewindColorScheme(); + + function setColorScheme(scheme: 'light' | 'dark') { + setNativeWindColorScheme(scheme); + } + + function toggleColorScheme() { + return setColorScheme(colorScheme === 'light' ? 'dark' : 'light'); + } + + return { + colorScheme: colorScheme ?? 'light', + isDarkColorScheme: colorScheme === 'dark', + setColorScheme, + toggleColorScheme, + colors: COLORS[colorScheme ?? 'light'], + }; +} + +/** + * No-op on web — Android navigation bar sync is not needed in the browser. + */ +function useInitialAndroidBarSync() { + React.useEffect(() => {}, []); +} + +export { useColorScheme, useInitialAndroidBarSync }; diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index e383c554fa..fc97196165 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -684,7 +684,7 @@ "downloadedModels": "Downloaded Models", "noDownloadedModels": "No models downloaded", "appleFoundationModel": "Apple Foundation Model", - "llamaModel": "SmolLM3 3B", + "llamaModel": "Qwen2.5 3B", "generateNewPacks": "Generate New Packs", "enterCount": "Enter count", "numberOfPacksToGenerate": "Number of packs to generate", diff --git a/apps/expo/lib/updates.ts b/apps/expo/lib/updates.ts new file mode 100644 index 0000000000..f613d0b7eb --- /dev/null +++ b/apps/expo/lib/updates.ts @@ -0,0 +1,10 @@ +export { + channel, + checkForUpdateAsync, + fetchUpdateAsync, + isEnabled, + reloadAsync, + runtimeVersion, + updateId, + useUpdates, +} from 'expo-updates'; diff --git a/apps/expo/lib/updates.web.ts b/apps/expo/lib/updates.web.ts new file mode 100644 index 0000000000..073468ae4a --- /dev/null +++ b/apps/expo/lib/updates.web.ts @@ -0,0 +1,11 @@ +export const reloadAsync = async () => { + window.location.reload(); +}; + +export const checkForUpdateAsync = async () => ({ isAvailable: false }); +export const fetchUpdateAsync = async () => ({ isNew: false }); +export const useUpdates = () => ({ isUpdateAvailable: false, isUpdatePending: false }); +export const isEnabled = false; +export const channel = 'web'; +export const updateId = null; +export const runtimeVersion = '0.0.0'; diff --git a/apps/expo/lib/utils/ImageCacheManager.web.ts b/apps/expo/lib/utils/ImageCacheManager.web.ts new file mode 100644 index 0000000000..3e71cdcaf9 --- /dev/null +++ b/apps/expo/lib/utils/ImageCacheManager.web.ts @@ -0,0 +1,31 @@ +/** + * Web stub for ImageCacheManager. + * The browser handles HTTP caching natively; no local file cache is needed on web. + * All methods are safe no-ops so that callers compile and run without changes. + */ +class WebImageCacheManager { + public cacheDirectory = ''; + + public async initCacheDirectory(): Promise {} + + public async getCachedImageUri(_fileName: string): Promise { + return null; + } + + public async cacheRemoteImage(_fileName: string, remoteUrl: string): Promise { + return remoteUrl; + } + + public async cacheLocalTempImage(_tempImageUri: string, _fileName: string): Promise {} + + public async clearImage(_fileName: string): Promise {} + + public async clearCache(): Promise {} + + public async getCacheInfo(): Promise<{ size: number; count: number }> { + return { size: 0, count: 0 }; + } +} + +export { WebImageCacheManager as ImageCacheManager }; +export default new WebImageCacheManager(); diff --git a/apps/expo/lib/utils/__tests__/compute-pack.test.ts b/apps/expo/lib/utils/__tests__/compute-pack.test.ts index 90825eac36..811605c56d 100644 --- a/apps/expo/lib/utils/__tests__/compute-pack.test.ts +++ b/apps/expo/lib/utils/__tests__/compute-pack.test.ts @@ -1,11 +1,10 @@ -import type { Pack, PackItem } from 'expo-app/types'; +import type { PackItem, PackWithItems } from '@packrat/types'; import { describe, expect, it } from 'vitest'; import { computePacksWeights, computePackWeights } from '../compute-pack'; // --------------------------------------------------------------------------- // Minimal factory helpers // --------------------------------------------------------------------------- -// Arbitrary fixed timestamp used only as a required field value, not asserted on const NOW = new Date().toISOString(); function makePackItem( @@ -14,28 +13,44 @@ function makePackItem( return { id: 'item-1', name: 'Test Item', + description: null, quantity: overrides.quantity ?? 1, + category: null, consumable: overrides.consumable ?? false, worn: overrides.worn ?? false, - category: 'tools', + image: null, + notes: null, packId: 'pack-1', + catalogItemId: null, userId: 'user-1', + deleted: false, + isAIGenerated: false, + templateItemId: null, + embedding: null, createdAt: NOW, updatedAt: NOW, ...overrides, - }; + } as PackItem; } -function makePack(items: PackItem[] = [], overrides: Partial = {}): Pack { +function makePack(items: PackItem[] = [], overrides: Partial = {}): PackWithItems { return { id: 'pack-1', name: 'Test Pack', + description: null, category: 'hiking', - items, userId: 'user-1', + templateId: null, + isPublic: false, + image: null, + tags: null, + deleted: false, + isAIGenerated: false, + localCreatedAt: NOW, + localUpdatedAt: NOW, createdAt: NOW, updatedAt: NOW, - isPublic: false, + items, ...overrides, }; } @@ -53,7 +68,7 @@ describe('computePackWeights', () => { it('throws when items property is null/undefined', () => { const pack = makePack(); // Force missing items - (pack as Pack & { items: undefined }).items = undefined; + (pack as unknown as { items: undefined }).items = undefined; expect(() => computePackWeights(pack)).toThrow('Pack with ID pack-1 has no items'); }); diff --git a/apps/expo/lib/utils/compute-pack.ts b/apps/expo/lib/utils/compute-pack.ts index e1e16c324c..28b81d97df 100644 --- a/apps/expo/lib/utils/compute-pack.ts +++ b/apps/expo/lib/utils/compute-pack.ts @@ -1,8 +1,16 @@ +import type { PackWithItems } from '@packrat/types'; import type { WeightUnit } from '@packrat/units'; import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; -import type { Pack } from 'expo-app/types'; -export const computePackWeights = (pack: Pack, preferredUnit: WeightUnit = 'g'): Pack => { +export type ComputedPack = PackWithItems & { + baseWeight: number; + totalWeight: number; +}; + +export const computePackWeights = ( + pack: PackWithItems, + preferredUnit: WeightUnit = 'g', +): ComputedPack => { if (!pack.items) { throw new Error(`Pack with ID ${pack.id} has no items`); } @@ -26,5 +34,7 @@ export const computePackWeights = (pack: Pack, preferredUnit: WeightUnit = 'g'): }; }; -export const computePacksWeights = (packs: Pack[], preferredUnit: WeightUnit = 'g'): Pack[] => - packs.map((pack) => computePackWeights(pack, preferredUnit)); +export const computePacksWeights = ( + packs: PackWithItems[], + preferredUnit: WeightUnit = 'g', +): ComputedPack[] => packs.map((pack) => computePackWeights(pack, preferredUnit)); diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index 2613f966d0..426e483c55 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -10,6 +10,9 @@ const config = getSentryExpoConfig(__dirname); config.resolver = { ...config.resolver, assetExts: [...(config.resolver?.assetExts ?? []), 'wasm'], + // Enable package.json "exports" field resolution so workspace packages with + // subpath exports (e.g. @packrat/schemas/constants) resolve correctly. + unstable_enablePackageExports: true, // Exclude the ESM "import" condition so packages like Jotai resolve to their // CJS builds instead of .mjs files that contain import.meta (invalid in // Metro's __d() CJS module wrapper). @@ -24,12 +27,13 @@ const WEB_STUBS = { '@react-native-ai/llama': 'mocks/react-native-ai-llama.ts', 'llama.rn': 'mocks/react-native-ai-llama.ts', '@react-native-ai/apple': 'mocks/react-native-ai-apple.ts', + '@react-native-google-signin/google-signin': 'mocks/react-native-google-signin.ts', 'expo-sqlite/kv-store': 'mocks/expo-sqlite-kv-store.ts', + // Required by lib/persist-plugin.web.ts (ObservablePersistAsyncStorage) + '@react-native-async-storage/async-storage': 'mocks/async-storage.ts', // Keyboard utilities — on web the software keyboard doesn't overlay content 'react-native-keyboard-controller': 'mocks/react-native-keyboard-controller.tsx', - // Google Sign-In and date picker are native-only; web uses password auth - '@react-native-google-signin/google-signin': 'mocks/google-signin.ts', - '@react-native-community/datetimepicker': 'mocks/datetimepicker.tsx', + '@react-native-community/datetimepicker': 'mocks/react-native-community-datetimepicker.tsx', // expo-file-system throws UnavailabilityError on web; stub all ops as no-ops 'expo-file-system/legacy': 'mocks/expo-file-system-legacy.ts', }; diff --git a/apps/expo/mocks/expo-sqlite-kv-store.ts b/apps/expo/mocks/expo-sqlite-kv-store.ts index ea7305a655..0ecd3639e1 100644 --- a/apps/expo/mocks/expo-sqlite-kv-store.ts +++ b/apps/expo/mocks/expo-sqlite-kv-store.ts @@ -2,31 +2,27 @@ import { isFunction } from '@packrat/guards'; type UpdateFn = (prevValue: string | null) => string; -const PREFIX = '__kv__'; - const isClient = typeof window !== 'undefined'; const memFallback = new Map(); const rawGet = (key: string): string | null => - isClient ? window.localStorage.getItem(PREFIX + key) : (memFallback.get(key) ?? null); + isClient ? window.localStorage.getItem(key) : (memFallback.get(key) ?? null); const rawSet = (key: string, value: string): void => { - if (isClient) window.localStorage.setItem(PREFIX + key, value); + if (isClient) window.localStorage.setItem(key, value); else memFallback.set(key, value); }; const rawRemove = (key: string): boolean => { const had = rawGet(key) !== null; - if (isClient) window.localStorage.removeItem(PREFIX + key); + if (isClient) window.localStorage.removeItem(key); else memFallback.delete(key); return had; }; const rawKeys = (): string[] => { if (!isClient) return Array.from(memFallback.keys()); - return Object.keys(window.localStorage) - .filter((k) => k.startsWith(PREFIX)) - .map((k) => k.slice(PREFIX.length)); + return Object.keys(window.localStorage); }; const deepMerge = ( diff --git a/apps/expo/mocks/react-native-community-datetimepicker.tsx b/apps/expo/mocks/react-native-community-datetimepicker.tsx new file mode 100644 index 0000000000..a437cced6a --- /dev/null +++ b/apps/expo/mocks/react-native-community-datetimepicker.tsx @@ -0,0 +1,57 @@ +import type * as React from 'react'; + +type DateTimePickerEvent = { type: string; nativeEvent: { timestamp: number } }; + +type Props = { + value: Date; + mode?: 'date' | 'time' | 'datetime'; + onChange?: (event: DateTimePickerEvent, date?: Date) => void; + display?: string; + minimumDate?: Date; + maximumDate?: Date; + style?: unknown; +}; + +function toInputValue(date: Date, mode: Props['mode']): string { + if (mode === 'time') return date.toTimeString().slice(0, 5); + if (mode === 'datetime') + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + return date.toISOString().split('T')[0] ?? ''; +} + +export default function DateTimePicker({ + value, + mode = 'date', + onChange, + minimumDate, + maximumDate, +}: Props) { + const inputType = mode === 'time' ? 'time' : mode === 'datetime' ? 'datetime-local' : 'date'; + + function handleChange(e: React.ChangeEvent) { + if (!onChange) return; + const raw = e.target.value; + if (!raw) return; + const date = new Date(mode === 'time' ? `1970-01-01T${raw}` : raw); + onChange({ type: 'set', nativeEvent: { timestamp: date.getTime() } }, date); + } + + return ( + + ); +} diff --git a/apps/expo/mocks/react-native-google-signin.ts b/apps/expo/mocks/react-native-google-signin.ts new file mode 100644 index 0000000000..8e684c27da --- /dev/null +++ b/apps/expo/mocks/react-native-google-signin.ts @@ -0,0 +1,20 @@ +// Web stub: Google Sign-In is a native-only SDK. On web, sign-in throws immediately. +export const GoogleSignin = { + hasPlayServices: (): Promise => Promise.resolve(true), + signIn: (): Promise => + Promise.reject(new Error('Google Sign-In is not supported on web. Please use email/password.')), + getTokens: (): Promise<{ idToken: string | null; accessToken: string | null }> => + Promise.resolve({ idToken: null, accessToken: null }), + hasPreviousSignIn: (): Promise => Promise.resolve(false), + signOut: (): Promise => Promise.resolve(), + configure: (): void => {}, +}; + +export const isErrorWithCode = (_error: unknown): boolean => false; + +export const statusCodes = { + SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', + IN_PROGRESS: 'IN_PROGRESS', + PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', + SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED', +}; diff --git a/apps/expo/package.json b/apps/expo/package.json index b329b41dab..5cb9ef7b55 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -35,6 +35,8 @@ "submit:ios": "eas submit --platform ios", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:web": "playwright test --config playwright/playwright.config.ts", + "test:web:ui": "playwright test --config playwright/playwright.config.ts --ui", "update:development": "APP_VARIANT=development eas update --branch development --environment development", "update:preview": "APP_VARIANT=preview eas update --branch preview --environment preview", "update:production": "eas update --branch production --environment production", @@ -53,8 +55,12 @@ "@legendapp/state": "^3.0.0-beta.30", "@packrat/api-client": "workspace:*", "@packrat/config": "workspace:*", + "@packrat/constants": "workspace:*", + "@packrat/db": "workspace:*", "@packrat/env": "workspace:*", "@packrat/guards": "workspace:*", + "@packrat/schemas": "workspace:*", + "@packrat/types": "workspace:*", "@packrat/units": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts new file mode 100644 index 0000000000..30049dc98e --- /dev/null +++ b/apps/expo/playwright/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081'; + +export default defineConfig({ + testDir: './tests', + globalSetup: './tests/globalSetup.ts', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + video: 'on-first-retry', + headless: true, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts new file mode 100644 index 0000000000..a458f48774 --- /dev/null +++ b/apps/expo/playwright/tests/core.spec.ts @@ -0,0 +1,267 @@ +/** + * Web E2E tests for PackRat core functionality. + * + * Each test navigates to a route after seeding auth tokens in localStorage. + * TestIds match the constants in lib/testIds.ts and the Maestro iOS flows. + */ +import { BASE_URL, expect, test } from './fixtures'; + +// ─── Dashboard ────────────────────────────────────────────────────────────── + +test('dashboard loads authenticated', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/`); + // Tab bar must be visible — confirms app rendered past the auth gate + await expect(page.getByRole('tab', { name: /Dashboard/i })).toBeVisible(); + await expect(page.getByRole('tab', { name: /Packs/i })).toBeVisible(); +}); + +// ─── Packs ─────────────────────────────────────────────────────────────────── + +test('packs tab loads and shows create button', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/packs`); + await expect(page.getByTestId('create-pack-button')).toBeVisible(); +}); + +test('create a pack end-to-end', async ({ authedPage: page }) => { + const packName = `E2E-Pack-${Date.now()}`; + + // Use waitForResponse to capture the created pack ID. + // Navigating directly to /pack/new means router.back() fails on submit, + // so we intercept the API response instead of relying on navigation. + const [packResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), + (async () => { + await page.goto(`${BASE_URL}/pack/new`); + await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('submit-pack-button').click(); + })(), + ]); + + expect(packResponse.ok()).toBeTruthy(); + + // Verify pack appears in the list + await page.goto(`${BASE_URL}/packs`); + await expect(page.getByText(packName)).toBeVisible({ timeout: 10_000 }); +}); + +// ─── Pack Detail — add items ───────────────────────────────────────────────── + +test('add item manually to a pack', async ({ authedPage: page }) => { + const packName = `E2E-AddItem-${Date.now()}`; + + // Create a pack via API and capture the ID + const [packResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), + (async () => { + await page.goto(`${BASE_URL}/pack/new`); + await page.getByTestId('packs:name-input').fill(packName); + await page.getByTestId('submit-pack-button').click(); + })(), + ]); + + expect(packResponse.ok()).toBeTruthy(); + const { id: packId } = (await packResponse.json()) as { id: number }; + + // Fill the item creation form using testIds + await page.goto(`${BASE_URL}/item/new?packId=${packId}`); + await page.getByTestId('items:name-input').fill('Test Tent'); + await page.getByTestId('items:weight-input').fill('1200'); + + // Register listener BEFORE clicking — syncedCrud initiates the POST shortly after form submit. + // We must await the response BEFORE page.goto() because a full navigation aborts in-flight requests. + const itemPostPromise = page.waitForResponse( + (r) => + r.url().includes('/api/packs') && + r.url().includes('/items') && + r.request().method() === 'POST', + { timeout: 15_000 }, + ); + + await page.getByTestId('items:submit').click(); + + // Wait for the item to land in the DB before navigating away + await itemPostPromise; + + // Now safe: item is persisted, page.goto() won't abort anything critical + await page.goto(`${BASE_URL}/pack/${packId}`); + await expect(page.getByText('Test Tent')).toBeVisible({ timeout: 15_000 }); +}); + +test('add item from catalog to a pack', async ({ authedPage: page }) => { + const packName = `E2E-Catalog-${Date.now()}`; + + // Create a pack and capture the ID + const [packResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), + (async () => { + await page.goto(`${BASE_URL}/pack/new`); + await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('submit-pack-button').click(); + })(), + ]); + + const { id: packId } = (await packResponse.json()) as { id: number }; + + // Navigate to pack detail and open "Add from Catalog" sheet + await page.goto(`${BASE_URL}/pack/${packId}`); + await page.getByTestId('add-from-catalog-option').last().click(); + + // Dialog with catalog items should appear + await expect(page.getByText('Browse Catalog').first()).toBeVisible({ timeout: 10_000 }); + + // Wait for catalog items to load, then click the first one + const firstCard = page.getByTestId(/^catalog-item-card-/).first(); + await firstCard.waitFor({ timeout: 15_000 }); + await firstCard.click(); + + // Confirm "Add N item(s)" panel appears and click it + await expect(page.getByText(/Add \d+ item/i)).toBeVisible({ timeout: 5_000 }); + await page.getByText(/Add \d+ item/i).click(); + + // Local store updates synchronously; the pack detail (behind the modal) re-renders. + // A non-zero weight confirms the catalog item was added. + await expect(page.getByText(/[1-9]\d*\.?\d*g/).first()).toBeVisible({ timeout: 10_000 }); +}); + +// ─── Trips ──────────────────────────────────────────────────────────────────── + +test('trips tab loads', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/trips`); + await expect(page.getByText('Create New Trip')).toBeVisible(); +}); + +test('create a trip with dates', async ({ authedPage: page }) => { + test.setTimeout(60_000); + const tripName = `E2E-Trip-${Date.now()}`; + + const postPromise = page.waitForResponse( + (r) => r.url().includes('/api/trips') && r.request().method() === 'POST', + { timeout: 20_000 }, + ); + + await page.goto(`${BASE_URL}/trip/new`); + const nameInput = page.getByTestId('trips:name-input'); + await nameInput.waitFor({ timeout: 10_000 }); + await nameInput.fill(tripName); + + // Open start date picker and set via native input + await page + .getByText(/Start Date/i) + .first() + .click(); + const startInput = page.locator('input[type="date"]').first(); + await startInput.waitFor({ timeout: 5_000 }); + await startInput.fill('2026-08-01'); + + // Open end date picker + await page + .getByText(/End Date/i) + .first() + .click(); + const endInput = page.locator('input[type="date"]').last(); + await endInput.waitFor({ timeout: 5_000 }); + await endInput.fill('2026-08-14'); + + await page.getByTestId('submit-trip-button').click(); + + // Wait for the POST to complete so the trip is persisted before navigating + const response = await postPromise; + expect(response.ok()).toBeTruthy(); + + // Navigate to trips list and verify + await page.goto(`${BASE_URL}/trips`); + await page.waitForLoadState('networkidle'); + await expect(page.getByText(tripName)).toBeVisible({ timeout: 15_000 }); +}); + +// ─── Catalog ────────────────────────────────────────────────────────────────── + +test('catalog tab loads items', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/catalog`); + // Wait for items to load — at least one item name visible + await expect(page.locator('text=/\\d+,?\\d+ items/i').first()).toBeVisible({ timeout: 15_000 }); +}); + +test('catalog search filters results', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/catalog`); + // Wait for initial load + await page.waitForLoadState('networkidle'); + + // The search box is revealed by clicking the search icon + await page.getByText('󰍉').first().click(); + + const searchBox = page.locator('input[placeholder*="Search"]'); + await searchBox.waitFor({ timeout: 5_000 }); + await searchBox.fill('sleeping bag'); + // Results should update — check item names + await expect(page.getByText(/sleeping bag/i).first()).toBeVisible({ timeout: 10_000 }); +}); + +// ─── Profile ────────────────────────────────────────────────────────────────── + +test('profile screen loads user info', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/profile`); + await expect(page.getByText('Account Information')).toBeVisible(); + // User email should be visible + await expect(page.getByText(/@/).first()).toBeVisible(); +}); + +test('profile name edit screen', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/profile/name`); + await expect(page.getByRole('heading', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('textbox')).toHaveCount(2); // First + Last +}); + +// ─── Settings ───────────────────────────────────────────────────────────────── + +test('settings screen loads', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/settings`); + await expect(page.getByText('AI Models')).toBeVisible(); + await expect(page.getByText('Danger Zone')).toBeVisible(); + await expect(page.getByText(/PackRat v/i)).toBeVisible(); +}); + +// ─── AI Chat ────────────────────────────────────────────────────────────────── + +test('AI chat sends message and gets response', async ({ authedPage: page }) => { + test.setTimeout(60_000); // AI streaming responses can take 20-30s + // Create a pack to chat about first + const packName = `E2E-AI-${Date.now()}`; + + const [packResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), + (async () => { + await page.goto(`${BASE_URL}/pack/new`); + await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('submit-pack-button').click(); + })(), + ]); + + const { id: packId } = (await packResponse.json()) as { id: number }; + + await page.goto( + `${BASE_URL}/ai-chat?packId=${packId}&packName=${encodeURIComponent(packName)}&contextType=pack`, + ); + + // Greet message should be visible + await expect(page.getByText(/working with your/i).first()).toBeVisible(); + + // Send a message + await page.getByRole('textbox', { name: /Ask about this pack/i }).fill('List 3 essential items.'); + // Send button is icon-only with no accessible name; use the arrow-up icon character + await page.getByText('󰁝').click(); + + // Wait for AI response (streaming may take a while) + await expect(page.getByText(/item/i).nth(1)).toBeVisible({ timeout: 30_000 }); +}); + +// ─── Weather ────────────────────────────────────────────────────────────────── + +test('weather screen loads', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/weather`); + await expect(page.getByText('Weather', { exact: true }).first()).toBeVisible(); + // Empty state or locations list + await expect(page.getByText('No saved locations').or(page.locator('text=/°[FC]/'))).toBeVisible({ + timeout: 10_000, + }); +}); diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts new file mode 100644 index 0000000000..edde2a6f35 --- /dev/null +++ b/apps/expo/playwright/tests/fixtures.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { type Browser, type BrowserContext, test as base, type Page } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081'; +export const API_URL = process.env.API_URL ?? 'http://localhost:8787'; + +const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); + +interface CachedAuth { + accessToken: string; + refreshToken: string; + user: Record | null; +} + +function loadCachedAuth(): CachedAuth { + if (!fs.existsSync(TOKENS_FILE)) { + throw new Error(`Auth tokens file not found at ${TOKENS_FILE}. Did globalSetup run?`); + } + // safe-cast: JSON.parse result is validated implicitly by the known file format written by globalSetup + return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8')) as CachedAuth; +} + +/** + * Creates a browser context with auth pre-seeded in localStorage: + * - access_token / refresh_token → read by expo-sqlite kv-store stub + tokenAtom + * - user → read by ObservablePersistLocalStorage to hydrate userStore + * (isAuthed is computed from userStore !== null) + * + * Using storageState guarantees the values are present before ANY page JS runs. + */ +async function createAuthedContext(browser: Browser): Promise { + const { accessToken, refreshToken, user } = loadCachedAuth(); + + const localStorage = [ + { name: 'access_token', value: accessToken }, + { name: 'refresh_token', value: refreshToken }, + ]; + + if (user) { + localStorage.push({ name: 'user', value: JSON.stringify(user) }); + } + + return browser.newContext({ + storageState: { + cookies: [], + origins: [{ origin: BASE_URL, localStorage }], + }, + }); +} + +export type AuthFixtures = { authedPage: Page }; + +export const test = base.extend({ + authedPage: async ({ browser }, use) => { + const context = await createAuthedContext(browser); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +export { expect } from '@playwright/test'; +export { BASE_URL }; diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts new file mode 100644 index 0000000000..cebd7341b7 --- /dev/null +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -0,0 +1,114 @@ +/** + * Playwright global setup — runs once before all tests. + * + * Priority order for obtaining auth tokens: + * 1. TEST_ACCESS_TOKEN + TEST_REFRESH_TOKEN — used directly (no API call) + * 2. TEST_EMAIL + TEST_PASSWORD — logs in against the API (matches the + * iOS/Android Maestro pattern: seed the user, then log in with credentials) + * 3. Fallback — registers a fresh ephemeral user, reads the OTP from the DB, + * and verifies email to obtain tokens (useful for local development) + * + * The resulting tokens are written to .auth-tokens.json so the authedPage + * fixture can seed localStorage without hitting auth on every test. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { neon } from '@neondatabase/serverless'; + +const API_URL = process.env.API_URL ?? 'http://localhost:8787'; +const DB_URL = process.env.NEON_DATABASE_URL ?? '***REDACTED_DB_URL***'; + +export const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); + +async function setup() { + // Priority 1: pre-minted tokens provided directly + if (process.env.TEST_ACCESS_TOKEN && process.env.TEST_REFRESH_TOKEN) { + const meRes = await fetch(`${API_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, + }); + const user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + fs.writeFileSync( + TOKENS_FILE, + JSON.stringify({ + accessToken: process.env.TEST_ACCESS_TOKEN, + refreshToken: process.env.TEST_REFRESH_TOKEN, + user, + }), + ); + console.log('[globalSetup] Using tokens from TEST_ACCESS_TOKEN env var'); + return; + } + + // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) + if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { + const loginRes = await fetch(`${API_URL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), + }); + if (!loginRes.ok) { + const body = await loginRes.text(); + throw new Error(`Login failed ${loginRes.status}: ${body}`); + } + const { accessToken, refreshToken, user } = (await loginRes.json()) as { + accessToken: string; + refreshToken: string; + user: Record; + }; + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); + return; + } + + // Priority 3: register a fresh ephemeral user (local dev fallback) + const email = `e2e-${Date.now()}@packrat.test`; + const password = 'E2eTest1!'; + + // 1. Register + const registerRes = await fetch(`${API_URL}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, firstName: 'E2E', lastName: 'User' }), + }); + if (!registerRes.ok) { + const body = await registerRes.text(); + throw new Error(`Register failed ${registerRes.status}: ${body}`); + } + console.log(`[globalSetup] Registered ${email}`); + + // 2. Fetch OTP directly from the database + const sql = neon(DB_URL); + const rows = await sql` + SELECT otp.code + FROM one_time_passwords otp + JOIN users u ON u.id = otp.user_id + WHERE u.email = ${email} + ORDER BY otp.expires_at DESC + LIMIT 1 + `; + + const code = (rows[0] as { code: string } | undefined)?.code; + if (!code) throw new Error(`No OTP found in DB for ${email}`); + console.log(`[globalSetup] Got OTP from DB`); + + // 3. Verify email + const verifyRes = await fetch(`${API_URL}/api/auth/verify-email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, code }), + }); + if (!verifyRes.ok) { + const body = await verifyRes.text(); + throw new Error(`Verify failed ${verifyRes.status}: ${body}`); + } + const { accessToken, refreshToken, user } = (await verifyRes.json()) as { + accessToken: string; + refreshToken: string; + user: Record; + }; + console.log('[globalSetup] Email verified, tokens obtained'); + + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); +} + +export default setup; diff --git a/apps/expo/playwright/tests/packs.spec.ts b/apps/expo/playwright/tests/packs.spec.ts new file mode 100644 index 0000000000..8b2421fa7f --- /dev/null +++ b/apps/expo/playwright/tests/packs.spec.ts @@ -0,0 +1,265 @@ +/** + * Web E2E tests for Pack and Item CRUD functionality. + * + * Covers: + * - Pack create / edit / delete + * - Item add (manually) / edit / delete + * - Validation: empty name on pack and item forms + * + * Auth is pre-seeded via the `authedPage` fixture (storageState). + * Pack IDs are always captured from the POST /api/packs response so that + * tests can navigate directly to detail/edit routes without relying on + * post-submit navigation behaviour. + */ +import { BASE_URL, expect, test } from './fixtures'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Create a pack via the UI and return its server-assigned id. */ +async function createPackViaForm( + page: import('@playwright/test').Page, + packName: string, +): Promise { + const [packResponse] = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), + (async () => { + await page.goto(`${BASE_URL}/pack/new`); + await page.getByTestId('packs:name-input').fill(packName); + await page.getByTestId('submit-pack-button').click(); + })(), + ]); + + expect(packResponse.ok()).toBeTruthy(); + const { id } = (await packResponse.json()) as { id: string }; + return id; +} + +/** Add an item to a pack via the UI, wait for the API to persist it, return item id. */ +async function addItemViaForm( + page: import('@playwright/test').Page, + opts: { packId: string; itemName: string; weight?: string }, +): Promise { + const { packId, itemName, weight = '500' } = opts; + await page.goto(`${BASE_URL}/item/new?packId=${packId}`); + await page.getByTestId('items:name-input').fill(itemName); + await page.getByTestId('items:weight-input').fill(weight); + + const itemPostPromise = page.waitForResponse( + (r) => + r.url().includes('/api/packs') && + r.url().includes('/items') && + r.request().method() === 'POST', + { timeout: 20_000 }, + ); + + await page.getByTestId('items:submit').click(); + const response = await itemPostPromise; + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { id: string }; + return body.id; +} + +// ─── Pack CRUD ──────────────────────────────────────────────────────────────── + +test.describe('Pack CRUD', () => { + test('create pack → appears in packs list', async ({ authedPage: page }) => { + test.setTimeout(30_000); + const packName = `E2E-Create-${Date.now()}`; + + await createPackViaForm(page, packName); + + await page.goto(`${BASE_URL}/packs`); + await expect(page.getByText(packName)).toBeVisible({ timeout: 10_000 }); + }); + + test('edit pack name → updated name appears in detail', async ({ authedPage: page }) => { + test.setTimeout(60_000); + const originalName = `E2E-Edit-${Date.now()}`; + const updatedName = `${originalName}-UPDATED`; + + const packId = await createPackViaForm(page, originalName); + + // Use the header edit button (SPA nav) so router.back() stays in-SPA and + // syncedCrud can flush the PUT before the page unloads. + await page.goto(`${BASE_URL}/pack/${packId}`); + await page.waitForLoadState('networkidle'); + await page.getByTestId('packs:edit').click(); + + const nameInput = page.getByTestId('packs:name-input'); + await nameInput.waitFor({ timeout: 10_000 }); + await nameInput.clear(); + await nameInput.fill(updatedName); + + // Register listener before clicking — scoped to this pack's URL + const editPutPromise = page.waitForResponse( + (r) => + r.url().includes(`/api/packs/${packId}`) && + (r.request().method() === 'PUT' || r.request().method() === 'PATCH'), + { timeout: 20_000 }, + ); + + await page.getByTestId('submit-pack-button').click(); + + // SPA router.back() keeps the JS context alive; await the PUT before navigating away + await editPutPromise; + + // Updated name should appear in the pack detail (full reload from API) + await page.goto(`${BASE_URL}/pack/${packId}`); + await expect(page.getByText(updatedName)).toBeVisible({ timeout: 10_000 }); + }); + + test('delete pack → disappears from packs list', async ({ authedPage: page }) => { + test.setTimeout(60_000); + const packName = `E2E-Delete-${Date.now()}`; + const packId = await createPackViaForm(page, packName); + + await page.goto(`${BASE_URL}/pack/${packId}`); + + // Wait for the store to load and the owner check to resolve so header buttons appear + await page.waitForLoadState('networkidle'); + + // Accept any browser-native confirm/alert dialogs before triggering delete + page.on('dialog', (dialog) => dialog.accept()); + + const deleteButton = page.getByTestId('packs:delete'); + await deleteButton.waitFor({ timeout: 15_000 }); + await deleteButton.click(); + + // After deletion the app should navigate away; go to list and confirm pack is gone + await page.goto(`${BASE_URL}/packs`); + await expect(page.getByText(packName)).not.toBeVisible({ timeout: 10_000 }); + }); +}); + +// ─── Item CRUD within a pack ────────────────────────────────────────────────── + +test.describe('Item CRUD within a pack', () => { + // Create a fresh pack before each item test so tests are independent + let sharedPackId: string; + + test.beforeEach(async ({ authedPage: page }) => { + const packName = `E2E-ItemPack-${Date.now()}`; + sharedPackId = await createPackViaForm(page, packName); + }); + + test('add item manually → appears in pack detail', async ({ authedPage: page }) => { + test.setTimeout(60_000); + const itemName = `E2E-Item-${Date.now()}`; + + await addItemViaForm(page, { packId: sharedPackId, itemName, weight: '850' }); + + await page.goto(`${BASE_URL}/pack/${sharedPackId}`); + await expect(page.getByText(itemName)).toBeVisible({ timeout: 15_000 }); + }); + + test('edit item name → updated name appears in pack detail', async ({ authedPage: page }) => { + test.setTimeout(90_000); + const itemName = `E2E-EditItem-${Date.now()}`; + const updatedItemName = `${itemName}-UPDATED`; + + const itemId = await addItemViaForm(page, { packId: sharedPackId, itemName, weight: '500' }); + + // Navigate to pack detail to verify item exists + await page.goto(`${BASE_URL}/pack/${sharedPackId}`); + await expect(page.getByText(itemName)).toBeVisible({ timeout: 15_000 }); + + // Navigate to the item edit form + await page.goto(`${BASE_URL}/item/${itemId}/edit?packId=${sharedPackId}`); + const nameInput = page.getByTestId('items:name-input'); + await nameInput.waitFor({ timeout: 10_000 }); + await nameInput.clear(); + await nameInput.fill(updatedItemName); + + const editPromise = page.waitForResponse( + (r) => + r.url().includes('/api/packs') && + r.url().includes('/items') && + (r.request().method() === 'PUT' || r.request().method() === 'PATCH'), + { timeout: 20_000 }, + ); + + await page.getByTestId('items:submit').click(); + await editPromise.catch(() => null); + + // Updated name should be visible in pack detail + await page.goto(`${BASE_URL}/pack/${sharedPackId}`); + await expect(page.getByText(updatedItemName)).toBeVisible({ timeout: 15_000 }); + }); + + test('delete item via more-actions menu → disappears from pack detail', async ({ + authedPage: page, + }) => { + test.setTimeout(90_000); + const itemName = `E2E-DeleteItem-${Date.now()}`; + + const itemId = await addItemViaForm(page, { packId: sharedPackId, itemName, weight: '300' }); + + // Confirm item is in pack detail + await page.goto(`${BASE_URL}/pack/${sharedPackId}`); + await expect(page.getByTestId(`items:card-${itemId}`)).toBeVisible({ timeout: 15_000 }); + + // Accept dialogs (web confirm) before triggering delete + page.on('dialog', (dialog) => dialog.accept()); + + // Open the more-actions menu for the item + const moreActionsButton = page.getByTestId(`items:more-actions-${itemId}`); + if (await moreActionsButton.isVisible()) { + await moreActionsButton.click(); + const deleteOption = page + .getByText(/delete/i) + .or(page.getByRole('menuitem', { name: /delete/i })) + .first(); + await deleteOption.waitFor({ timeout: 5_000 }); + await deleteOption.click(); + + // Item card should be gone + await expect(page.getByTestId(`items:card-${itemId}`)).not.toBeVisible({ timeout: 10_000 }); + } else { + test.skip(true, 'items:more-actions button not accessible on web'); + } + }); +}); + +// ─── Validation ─────────────────────────────────────────────────────────────── + +test.describe('Validation', () => { + test.setTimeout(30_000); + + test('empty pack name → form does not navigate on submit', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/pack/new`); + + const submitButton = page.getByTestId('submit-pack-button'); + await submitButton.waitFor({ timeout: 10_000 }); + + // Name field starts empty — clicking submit should either be blocked or stay on this page + const formUrl = page.url(); + await submitButton.click(); + + // Wait a moment for any navigation to settle + await page.waitForTimeout(1_000); + + // Should still be on the create form (validation prevented navigation) + expect(page.url()).toBe(formUrl); + }); + + test('empty item name → form does not navigate on submit', async ({ authedPage: page }) => { + const packId = await createPackViaForm(page, `E2E-Validation-${Date.now()}`); + + await page.goto(`${BASE_URL}/item/new?packId=${packId}`); + + const submitButton = page.getByTestId('items:submit'); + await submitButton.waitFor({ timeout: 10_000 }); + + const nameInput = page.getByTestId('items:name-input'); + await nameInput.waitFor({ timeout: 10_000 }); + await nameInput.clear(); + + const formUrl = page.url(); + await submitButton.click(); + + await page.waitForTimeout(1_000); + + // Should still be on the create item form + expect(page.url()).toBe(formUrl); + }); +}); diff --git a/apps/expo/playwright/tests/profile.spec.ts b/apps/expo/playwright/tests/profile.spec.ts new file mode 100644 index 0000000000..c182c6a29e --- /dev/null +++ b/apps/expo/playwright/tests/profile.spec.ts @@ -0,0 +1,132 @@ +/** + * Web E2E tests for PackRat profile functionality. + * + * Tests use the `authedPage` fixture which pre-seeds auth tokens in + * localStorage before any page JS runs. + * + * TestIds match the constants in lib/testIds.ts. + */ +import { testIds } from '../../lib/testIds'; +import { BASE_URL, expect, test } from './fixtures'; + +// ─── Profile name edit ──────────────────────────────────────────────────────── + +test.describe('Profile name edit', () => { + test('both name inputs are visible on /profile/name', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/profile/name`); + + await expect(page.getByTestId(testIds.profile.firstNameInput)).toBeVisible(); + await expect(page.getByTestId(testIds.profile.lastNameInput)).toBeVisible(); + }); + + test('save button is disabled when name is unchanged', async ({ authedPage: page }) => { + await page.goto(`${BASE_URL}/profile/name`); + + const saveBtn = page.getByTestId(testIds.profile.saveBtn); + await saveBtn.waitFor({ state: 'visible' }); + + // NativeWindUI Button renders as
on web, not