Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ yarn-error.*

# typescript
*.tsbuildinfo
tsconfig.json

app-example

Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## [1.2.7] - 2026-06-02 — Fix: Pattern detail expand animation and iOS native build support

Fixes a native-only bug where the pattern detail card failed to expand in Expo Go and on-device builds. Adds iOS native build configuration and documents the native and release build workflows.

### Mobile (`apps/mobile`)

- Fixed `TopPatternsSection` expand animation on native: moved content measurement to an off-screen, opacity-0 clone outside the `height: 0` animated container (Yoga constrains child layout to the parent's explicit height, so `onLayout` always reported `h = 0`). Height is cached per pattern and animations are driven directly via `useSharedValue` to skip the `setState → re-render → useEffect` chain that caused visible jank on Android.
- Added `bundleIdentifier: "com.rlyhan.touchgrass"` to `app.json` `ios` config, required for `expo run:ios` native builds.
- Updated `ios` and `android` scripts to `expo run:ios` / `expo run:android` for native dev builds.

### Storybook (`apps/mobile`)

- Added stories for the four previously uncovered components: `PatternRing` (default, selected, dimmed, high/low match, 3-ring row), `PatternMatchAccordion` (default, alt pattern, long description), `TopPatternsSection` (default, close-matches, low-confidence), and `PortableText` (rich marks, single paragraph, custom className, empty).

### Docs

- Added physical-device Expo Go setup (two-terminal workflow), native dev build notes, and a release build command (`EXPO_PUBLIC_API_BASE_URL=http://localhost:3000 npx expo run:ios --configuration Release`) for accurate animation and performance testing to `README.md`.

## [1.2.6] - 2026-05-27 — Chore: Broaden recommendation surface and pattern affinity

Expands the recommendations list from 3 to 10 to give users more activities to browse while adjacent discovery features are still in flight, and widens each mock activity's `related_types` so secondary pattern affinity better reflects the personality dimensions a user actually shares with an activity.
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,41 @@ npm start

This runs `@touchgrass/core` (Express on port 3000) and `@touchgrass/mobile` (Expo) concurrently. Both must be running for auth, profiles, and recommendations to work.

**Platform-specific:**
> **Note:** `npm start` uses `concurrently`, which prefixes log output and breaks QR code rendering in the terminal. If you need to scan a QR code to open the app in Expo Go on a physical device, run the two servers in separate terminals instead (see below).

**Physical device (Expo Go) — separate terminals:**

```bash
# Terminal 1 — API server
npm run dev --workspace=@touchgrass/core

# Terminal 2 — Expo dev server (QR code renders correctly here)
cd apps/mobile && npx expo start
```

Then scan the QR code with the **Expo Go app** on your device (not the native camera).

**Platform-specific (simulators/emulators):**

```bash
npm run ios # iOS Simulator
npm run android # Android Emulator
npm run ios # iOS Simulator — native dev build (requires bundleIdentifier in app.json)
npm run android # Android Emulator — native dev build
npm run web # Browser at http://localhost:8081
```

> **Note:** `npm run ios` / `npm run android` compile a native development build. This is slower to start than Expo Go but reflects real device behaviour more accurately. The `bundleIdentifier` (`com.rlyhan.touchgrass`) in `apps/mobile/app.json` is required for these commands.

**Release build (accurate performance testing — animations, transitions):**

Expo Go and native dev builds both run JavaScript in debug mode, which can make animations appear slow. For a production-accurate test, build in release mode:

```bash
cd apps/mobile
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000 npx expo run:ios --configuration Release
```

This must be run from `apps/mobile`, not the repo root.

**Run workspaces individually:**

```bash
Expand Down
16 changes: 12 additions & 4 deletions apps/mobile/.storybook/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import AsyncStorage from "@react-native-async-storage/async-storage"

import { view } from "./storybook.requires"

const StorybookUIRoot = view.getStorybookUI({
storage: {
getItem: AsyncStorage.getItem,
setItem: AsyncStorage.setItem,
getItem: async (key: string) => {
try {
return (globalThis as any).localStorage?.getItem(key) ?? null
} catch {
return null
}
},
setItem: async (key: string, value: string) => {
try {
;(globalThis as any).localStorage?.setItem(key, value)
} catch {}
},
},
})

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"
import type { Preview } from "@storybook/react-native"

import "../global.css"
import "./storybook-web.css"

const preview: Preview = {
decorators: [
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/.storybook/storybook-web.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* Override the mobile-phone frame applied in global.css */
@media screen and (min-width: 768px) {
#root {
max-width: 100% !important;
box-shadow: none !important;
}

body {
background-color: #ffffff !important;
align-items: stretch !important;
}
}
21 changes: 16 additions & 5 deletions apps/mobile/__tests__/top-patterns-section.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { act, fireEvent, render, screen } from "@testing-library/react-native"
import {
act,
fireEvent,
render,
screen,
within,
} from "@testing-library/react-native"

// Replace the animated ring with a simple text stub so we don't have to render
// the SVG or run the reanimated animation in tests.
Expand Down Expand Up @@ -57,7 +63,11 @@ describe("TopPatternsSection", () => {
fireEvent.press(
screen.getByRole("button", { name: `Show details for ${pattern.name}` }),
)
expect(screen.getByText(pattern.shortDescription)).toBeTruthy()
expect(
within(screen.getByTestId("pattern-detail-panel")).getByText(
pattern.shortDescription,
),
).toBeTruthy()
})

it("hides the description when the selected ring is tapped again", () => {
Expand Down Expand Up @@ -93,12 +103,13 @@ describe("TopPatternsSection", () => {
fireEvent.press(
screen.getByRole("button", { name: `Show details for ${first.name}` }),
)
expect(screen.getByText(first.shortDescription)).toBeTruthy()
const panel = () => screen.getByTestId("pattern-detail-panel")
expect(within(panel()).getByText(first.shortDescription)).toBeTruthy()

fireEvent.press(
screen.getByRole("button", { name: `Show details for ${second.name}` }),
)
expect(screen.queryByText(first.shortDescription)).toBeNull()
expect(screen.getByText(second.shortDescription)).toBeTruthy()
expect(within(panel()).queryByText(first.shortDescription)).toBeNull()
expect(within(panel()).getByText(second.shortDescription)).toBeTruthy()
})
})
3 changes: 2 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"policy": "sdkVersion"
},
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.rlyhan.touchgrass"
},
"android": {
"adaptiveIcon": {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/app/(authed)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function AuthedLayout() {
}

if (!session?.user) {
return <Redirect href={"/onboarding/name" as Href} />
return <Redirect href={"/sign-in" as Href} />
}

return <Stack screenOptions={{ headerShown: false }} />
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/app/(authed)/activities/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PortableText } from "@/components/ui/portable-text"
// import { PrimaryButton } from "@/components/ui/primary-button"
import { resolveDominantPattern } from "@/lib/patterns/cache"
import { usePatternWeights } from "@/lib/patterns/use-pattern-weights"
import { UnauthenticatedError } from "@/lib/auth/errors"
import {
fetchActivityBySlug,
getCachedActivity,
Expand Down Expand Up @@ -76,8 +77,12 @@ export default function ActivityDetailPage() {
setActivityStatus((prev) => (prev === "ready" ? prev : "not-found"))
}
})
.catch(() => {
.catch((err) => {
if (cancelled) return
if (err instanceof UnauthenticatedError) {
router.replace("/sign-in" as Href)
return
}
// Network error: keep showing cached copy if we had one.
setActivityStatus((prev) => (prev === "ready" ? prev : "error"))
})
Expand Down
46 changes: 27 additions & 19 deletions apps/mobile/app/(authed)/recommendations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { router, type Href } from "expo-router"
import { useCallback, useState } from "react"
import { useCallback, useMemo, useState } from "react"
import { ActivityIndicator, FlatList, Pressable, Text, View } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"

Expand All @@ -15,6 +15,7 @@ import {
import {
fetchRecommendations,
ProfileNotFoundError,
UnauthenticatedError,
} from "@/lib/recommendations/api"
import { colors } from "@/lib/theme/colors"
import { useAsyncData } from "@/lib/use-async-data"
Expand All @@ -29,6 +30,10 @@ export default function RecommendationsPage() {
try {
return await fetchRecommendations()
} catch (err) {
if (err instanceof UnauthenticatedError) {
router.replace("/sign-in" as Href)
return []
}
// The user is authenticated but has no profile — most likely because
// onboarding was interrupted before profile creation completed. Send
// them back to finish it rather than leaving them on a dead-end error.
Expand All @@ -41,6 +46,26 @@ export default function RecommendationsPage() {
}, [])

const { data: recommendations = [], status, reload } = useAsyncData(fetcher)
const patternWeights = getCachedPatternWeights()

const listHeader = useMemo(
() => (
<>
<View className="items-center">
<GrassLogo />
</View>
{patternWeights ? (
<View className="mt-10">
<TopPatternsSection patternWeights={patternWeights} />
</View>
) : null}
<Text className="mb-8 mt-4 text-3xl font-bold tracking-tight text-gray-900">
Your recommendations
</Text>
</>
),
[patternWeights],
)

const handleSignOut = useCallback(async () => {
setSigningOut(true)
Expand Down Expand Up @@ -117,24 +142,7 @@ export default function RecommendationsPage() {
ItemSeparatorComponent={ItemSeparator}
initialNumToRender={5}
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 32 }}
ListHeaderComponent={
<>
<View className="items-center">
<GrassLogo />
</View>
{(() => {
const weights = getCachedPatternWeights()
return weights ? (
<View className="mt-10">
<TopPatternsSection patternWeights={weights} />
</View>
) : null
})()}
<Text className="mb-8 mt-4 text-3xl font-bold tracking-tight text-gray-900">
Your recommendations
</Text>
</>
}
ListHeaderComponent={listHeader}
ListFooterComponent={
<View className="mt-12 items-center">
<Pressable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react-native"

import { PatternMatchAccordion } from "./pattern-match-accordion"

const meta = {
title: "Patterns/PatternMatchAccordion",
component: PatternMatchAccordion,
args: {
patternName: "Personable",
shortDescription: "Warm, engaging, and naturally people-oriented.",
},
argTypes: {
patternName: { control: "text" },
shortDescription: { control: "text" },
},
} satisfies Meta<typeof PatternMatchAccordion>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const Visionary: Story = {
args: {
patternName: "Enchanting Visionary",
shortDescription:
"Imaginative, expressive, and drawn to novel ideas and aesthetics.",
},
}

export const LongDescription: Story = {
args: {
patternName: "Enlightened Traditionalist",
shortDescription:
"Grounded, principled, and reflective. You value time-tested approaches " +
"while staying open to insight, blending steadiness with a thoughtful " +
"curiosity about how things came to be the way they are.",
},
}
59 changes: 59 additions & 0 deletions apps/mobile/components/patterns/pattern-ring.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { View } from "react-native"
import type { Meta, StoryObj } from "@storybook/react-native"

import { PatternRing } from "./pattern-ring"

const meta = {
title: "Patterns/PatternRing",
component: PatternRing,
args: {
percent: 78,
label: "Personable",
isSelected: false,
anySelected: false,
},
argTypes: {
percent: { control: { type: "range", min: 0, max: 100, step: 1 } },
label: { control: "text" },
duration: { control: { type: "range", min: 0, max: 2000, step: 50 } },
isSelected: { control: "boolean" },
anySelected: { control: "boolean" },
},
} satisfies Meta<typeof PatternRing>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const Selected: Story = {
args: { isSelected: true, anySelected: true },
}

export const Dimmed: Story = {
args: { isSelected: false, anySelected: true },
}

export const HighMatch: Story = {
args: { percent: 95, label: "Enchanting Visionary" },
}

export const LowMatch: Story = {
args: { percent: 12, label: "Independent-Distant" },
}

export const Row: Story = {
render: () => (
<View style={{ flexDirection: "row", justifyContent: "center", gap: 32 }}>
<PatternRing percent={95} label="Enchanting Visionary" anySelected />
<PatternRing
percent={85}
label="Enlightened Traditionalist"
isSelected
anySelected
/>
<PatternRing percent={75} label="Personable" anySelected />
</View>
),
}
Loading
Loading