From 478cee9e112d1f03efd2b0316ca2e796d8884085 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Sat, 30 May 2026 00:24:34 +1200 Subject: [PATCH 01/13] chore: add test for cookies + untrusted origin --- packages/core/src/app.test.ts | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/core/src/app.test.ts b/packages/core/src/app.test.ts index cd2195e..4df7ca7 100644 --- a/packages/core/src/app.test.ts +++ b/packages/core/src/app.test.ts @@ -121,3 +121,49 @@ test("unknown routes return a JSON 404", async () => { const body = (await res.json()) as { error: string } assert.equal(body.error, "Not found") }) + +test("auth: cookie POST with untrusted Origin is rejected before reaching the DB", async () => { + const res = await fetch(`${baseUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://evil.com", + }, + body: JSON.stringify({ + email: "anyone@example.com", + password: "irrelevant-since-we-should-never-get-here", + }), + }) + + // 401 would mean the credential path ran — we want a hard rejection before that. + assert.ok( + res.status >= 400 && res.status < 500 && res.status !== 401, + `expected origin rejection (4xx, not 401), got ${res.status}`, + ) + + assert.equal( + res.headers.get("set-cookie"), + null, + "no Set-Cookie should be emitted on untrusted-origin POST", + ) +}) + +test("auth: cookie POST with trusted Origin is not blocked at the origin layer", async () => { + const res = await fetch(`${baseUrl}/api/auth/sign-in/email`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://localhost:8081", + }, + body: JSON.stringify({ + email: "anyone@example.com", + password: "irrelevant", + }), + }) + + assert.notEqual( + res.status, + 403, + "trusted origin should not be blocked by the origin check", + ) +}) From 86d38132ce130b80c2f9e45aefd9d3fa1775d384 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Sat, 30 May 2026 00:25:14 +1200 Subject: [PATCH 02/13] fix: estimated time field mandatory in Sanity validation --- apps/sanity/schemaTypes/activity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sanity/schemaTypes/activity.ts b/apps/sanity/schemaTypes/activity.ts index f490a8d..5f189cb 100644 --- a/apps/sanity/schemaTypes/activity.ts +++ b/apps/sanity/schemaTypes/activity.ts @@ -83,6 +83,7 @@ export const activitySchema = defineType({ name: 'estimated_time', title: 'Estimated Time', type: 'string', + validation: (Rule) => Rule.required(), }), defineField({ name: 'tips', From a098222cd76f3f765941c0c64fea4c72c05c2096 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Mon, 1 Jun 2026 22:24:12 +1200 Subject: [PATCH 03/13] fix: redirect to sign-in on expired session across all authed screens Adds UnauthenticatedError (thrown on 401) to all authed API calls and catches it in the recommendations list, activity detail, and pattern weights hook to redirect to /sign-in. Also fixes the authed layout guard which was incorrectly sending expired sessions to the sign-up screen. --- apps/mobile/app/(authed)/_layout.tsx | 2 +- apps/mobile/app/(authed)/activities/[slug].tsx | 7 ++++++- apps/mobile/app/(authed)/recommendations/index.tsx | 5 +++++ apps/mobile/lib/auth/errors.ts | 6 ++++++ apps/mobile/lib/patterns/api.ts | 2 ++ apps/mobile/lib/patterns/use-pattern-weights.ts | 8 +++++++- apps/mobile/lib/recommendations/api.ts | 8 ++++++++ 7 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/lib/auth/errors.ts diff --git a/apps/mobile/app/(authed)/_layout.tsx b/apps/mobile/app/(authed)/_layout.tsx index c5a2e3c..0690487 100644 --- a/apps/mobile/app/(authed)/_layout.tsx +++ b/apps/mobile/app/(authed)/_layout.tsx @@ -16,7 +16,7 @@ export default function AuthedLayout() { } if (!session?.user) { - return + return } return diff --git a/apps/mobile/app/(authed)/activities/[slug].tsx b/apps/mobile/app/(authed)/activities/[slug].tsx index ac64c7b..763c258 100644 --- a/apps/mobile/app/(authed)/activities/[slug].tsx +++ b/apps/mobile/app/(authed)/activities/[slug].tsx @@ -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, @@ -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")) }) diff --git a/apps/mobile/app/(authed)/recommendations/index.tsx b/apps/mobile/app/(authed)/recommendations/index.tsx index 85e86db..bf099e7 100644 --- a/apps/mobile/app/(authed)/recommendations/index.tsx +++ b/apps/mobile/app/(authed)/recommendations/index.tsx @@ -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" @@ -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. diff --git a/apps/mobile/lib/auth/errors.ts b/apps/mobile/lib/auth/errors.ts new file mode 100644 index 0000000..6b8902b --- /dev/null +++ b/apps/mobile/lib/auth/errors.ts @@ -0,0 +1,6 @@ +export class UnauthenticatedError extends Error { + constructor() { + super("Not authenticated") + this.name = "UnauthenticatedError" + } +} diff --git a/apps/mobile/lib/patterns/api.ts b/apps/mobile/lib/patterns/api.ts index 7570ccf..bbd889f 100644 --- a/apps/mobile/lib/patterns/api.ts +++ b/apps/mobile/lib/patterns/api.ts @@ -1,5 +1,6 @@ import type { PatternWeightsResponse } from "@touchgrass/types" +import { UnauthenticatedError } from "@/lib/auth/errors" import { authedFetch } from "@/lib/auth/fetch" import { apiUrl } from "@/lib/config" @@ -10,6 +11,7 @@ export async function fetchPatternWeights(): Promise< > { const response = await authedFetch(apiUrl("/pattern-weights")) + if (response.status === 401) throw new UnauthenticatedError() if (!response.ok) { throw new Error(`Failed to fetch pattern weights (${response.status})`) } diff --git a/apps/mobile/lib/patterns/use-pattern-weights.ts b/apps/mobile/lib/patterns/use-pattern-weights.ts index 66c2a87..cad0bda 100644 --- a/apps/mobile/lib/patterns/use-pattern-weights.ts +++ b/apps/mobile/lib/patterns/use-pattern-weights.ts @@ -1,7 +1,9 @@ +import { router } from "expo-router" import { useEffect, useState } from "react" import type { UserPatternWeights } from "@touchgrass/types" +import { UnauthenticatedError } from "@/lib/auth/errors" import { fetchPatternWeights } from "./api" import { getCachedPatternWeights } from "./cache" @@ -34,8 +36,12 @@ export function usePatternWeights(): UsePatternWeightsResult { setWeights(w) setStatus("ready") }) - .catch(() => { + .catch((err) => { if (cancelled) return + if (err instanceof UnauthenticatedError) { + router.replace("/sign-in") + return + } setStatus("error") }) return () => { diff --git a/apps/mobile/lib/recommendations/api.ts b/apps/mobile/lib/recommendations/api.ts index e8fbc6c..cbb2ec9 100644 --- a/apps/mobile/lib/recommendations/api.ts +++ b/apps/mobile/lib/recommendations/api.ts @@ -1,5 +1,6 @@ import type { Activity, RecommendationsResponse } from "@touchgrass/types" +import { UnauthenticatedError } from "@/lib/auth/errors" import { authedFetch } from "@/lib/auth/fetch" import { apiUrl } from "@/lib/config" import { @@ -7,6 +8,8 @@ import { setCachedPatternWeights, } from "@/lib/patterns/cache" +export { UnauthenticatedError } from "@/lib/auth/errors" + const activityCache = new Map() export function getCachedActivity(slug: string): Activity | undefined { @@ -23,6 +26,10 @@ export class ProfileNotFoundError extends Error { export async function fetchRecommendations(): Promise { const response = await authedFetch(apiUrl("/recommendations")) + if (response.status === 401) { + throw new UnauthenticatedError() + } + if (response.status === 404) { throw new ProfileNotFoundError() } @@ -51,6 +58,7 @@ export async function fetchActivityBySlug(slug: string): Promise Date: Tue, 2 Jun 2026 15:27:38 +1200 Subject: [PATCH 04/13] fix: normalize image transform params across Sanity and mock activity URLs Append ?w=800&fit=crop&auto=format to the Sanity GROQ query and all mock Unsplash URLs so image sizing is consistent regardless of activity source. --- .../core/src/recommendations/sanity-source.ts | 2 +- packages/mocks/recommendations.ts | 280 +++++++++--------- 2 files changed, 141 insertions(+), 141 deletions(-) diff --git a/packages/core/src/recommendations/sanity-source.ts b/packages/core/src/recommendations/sanity-source.ts index e61f036..953a7fb 100644 --- a/packages/core/src/recommendations/sanity-source.ts +++ b/packages/core/src/recommendations/sanity-source.ts @@ -13,7 +13,7 @@ export type SanityClientConfig = { const ACTIVITIES_QUERY = `*[_type == "activity" && defined(slug.current) && defined(title)]{ "slug": slug.current, title, - "imageUrl": imageUrl.asset->url, + "imageUrl": imageUrl.asset->url + "?w=800&fit=crop&auto=format", type, field, estimated_time, diff --git a/packages/mocks/recommendations.ts b/packages/mocks/recommendations.ts index 884ab00..515aef6 100644 --- a/packages/mocks/recommendations.ts +++ b/packages/mocks/recommendations.ts @@ -15,7 +15,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-guitar-pedalboard", title: "Build a Guitar Pedalboard", - imageUrl: "https://images.unsplash.com/photo-1511379938547-c1f69419868d", + imageUrl: "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=800&fit=crop&auto=format", type: "Constructive", field: "Music", estimated_time: "A weekend", @@ -30,7 +30,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-beginner-boxing-class", title: "Join a Beginner Boxing Class", - imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438", + imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=800&fit=crop&auto=format", type: "Active", field: "Martial Arts", estimated_time: "1 hour", @@ -45,7 +45,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "write-a-short-horror-screenplay", title: "Write a Short Horror Screenplay", - imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba", + imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800&fit=crop&auto=format", type: "Artistic", field: "Writing", estimated_time: "A few hours", @@ -60,7 +60,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "journal-while-listening-to-jazz", title: "Journal While Listening to Jazz", - imageUrl: "https://images.unsplash.com/photo-1511192336575-5a79af67a629", + imageUrl: "https://images.unsplash.com/photo-1511192336575-5a79af67a629?w=800&fit=crop&auto=format", type: "Reflective", field: "Writing", estimated_time: "1 hour", @@ -75,7 +75,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "study-music-theory-basics", title: "Study Music Theory Basics", - imageUrl: "https://images.unsplash.com/photo-1514119412350-e174d90d280e", + imageUrl: "https://images.unsplash.com/photo-1514119412350-e174d90d280e?w=800&fit=crop&auto=format", type: "Educational", field: "Music", estimated_time: "A week", @@ -90,7 +90,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "compose-a-1-minute-song", title: "Compose a 1-Minute Song", - imageUrl: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f", + imageUrl: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=800&fit=crop&auto=format", type: "Creative", field: "Music", estimated_time: "A few hours", @@ -105,7 +105,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "host-a-vinyl-listening-night", title: "Host a Vinyl Listening Night", - imageUrl: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b", + imageUrl: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?w=800&fit=crop&auto=format", type: "Social", field: "Music", estimated_time: "An evening", @@ -120,7 +120,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-home-heavy-bag-stand", title: "Build a Home Heavy Bag Stand", - imageUrl: "https://images.unsplash.com/photo-1517838277536-f5f99be501cd", + imageUrl: "https://images.unsplash.com/photo-1517838277536-f5f99be501cd?w=800&fit=crop&auto=format", type: "Constructive", field: "Martial Arts", estimated_time: "A weekend", @@ -135,7 +135,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "learn-tai-chi-basics", title: "Learn Tai Chi Basics", - imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773", + imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=800&fit=crop&auto=format", type: "Mindful", field: "Martial Arts", estimated_time: "A week", @@ -150,7 +150,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-a-bruce-lee-biography", title: "Read a Bruce Lee Biography", - imageUrl: "https://images.unsplash.com/photo-1544717305-2782549b5136", + imageUrl: "https://images.unsplash.com/photo-1544717305-2782549b5136?w=800&fit=crop&auto=format", type: "Reflective", field: "Martial Arts", estimated_time: "A week", @@ -165,7 +165,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "plan-a-martial-arts-training-trip", title: "Plan a Martial Arts Training Trip", - imageUrl: "https://images.unsplash.com/photo-1544717684-1243da23b545", + imageUrl: "https://images.unsplash.com/photo-1544717684-1243da23b545?w=800&fit=crop&auto=format", type: "Adventurous", field: "Martial Arts", estimated_time: "A weekend", @@ -180,7 +180,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "start-a-30-day-reading-challenge", title: "Start a 30-Day Reading Challenge", - imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794", + imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?w=800&fit=crop&auto=format", type: "Reflective", field: "Writing", estimated_time: "A month", @@ -195,7 +195,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-local-book-club", title: "Join a Local Book Club", - imageUrl: "https://images.unsplash.com/photo-1521587760476-6c12a4b040da", + imageUrl: "https://images.unsplash.com/photo-1521587760476-6c12a4b040da?w=800&fit=crop&auto=format", type: "Social", field: "Writing", estimated_time: "Ongoing", @@ -210,7 +210,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "submit-a-story-to-a-literary-magazine", title: "Submit a Story to a Literary Magazine", - imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a", + imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&fit=crop&auto=format", type: "Professional", field: "Writing", estimated_time: "A weekend", @@ -225,7 +225,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "analyze-a-classic-novel-chapter-by-chapter", title: "Analyze a Classic Novel Chapter by Chapter", - imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794", + imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?w=800&fit=crop&auto=format", type: "Intellectual", field: "Writing", estimated_time: "A week", @@ -240,7 +240,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "develop-a-personal-cookbook", title: "Develop a Personal Cookbook", - imageUrl: "https://images.unsplash.com/photo-1490645935967-10de6ba17061", + imageUrl: "https://images.unsplash.com/photo-1490645935967-10de6ba17061?w=800&fit=crop&auto=format", type: "Creative", field: "Cooking", estimated_time: "A month", @@ -255,7 +255,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "cook-one-regions-cuisine-for-a-week", title: "Cook One Region's Cuisine for a Week", - imageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836", + imageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=800&fit=crop&auto=format", type: "Adventurous", field: "Cooking", estimated_time: "A week", @@ -270,7 +270,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "pitch-a-pop-up-restaurant-concept", title: "Pitch a Pop-Up Restaurant Concept", - imageUrl: "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4", + imageUrl: "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&fit=crop&auto=format", type: "Professional", field: "Cooking", estimated_time: "A week", @@ -285,7 +285,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "shoot-a-golden-hour-walk", title: "Shoot a Golden Hour Walk", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Reflective", field: "Photography", estimated_time: "1 hour", @@ -300,7 +300,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-working-pinhole-camera", title: "Build a Working Pinhole Camera", - imageUrl: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32", + imageUrl: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=800&fit=crop&auto=format", type: "Constructive", field: "Photography", estimated_time: "A weekend", @@ -315,7 +315,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "create-a-12-page-photo-zine", title: "Create a 12-Page Photo Zine", - imageUrl: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32", + imageUrl: "https://images.unsplash.com/photo-1516035069371-29a1b244cc32?w=800&fit=crop&auto=format", type: "Artistic", field: "Photography", estimated_time: "A weekend", @@ -330,7 +330,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "lead-a-saturday-photowalk", title: "Lead a Saturday Photowalk", - imageUrl: "https://images.unsplash.com/photo-1492691527719-9d1e07e534b4", + imageUrl: "https://images.unsplash.com/photo-1492691527719-9d1e07e534b4?w=800&fit=crop&auto=format", type: "Social", field: "Photography", estimated_time: "An afternoon", @@ -345,7 +345,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "capture-wildlife-in-a-state-park", title: "Capture Wildlife in a State Park", - imageUrl: "https://images.unsplash.com/photo-1474511320723-9a56873867b5", + imageUrl: "https://images.unsplash.com/photo-1474511320723-9a56873867b5?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Photography", estimated_time: "An afternoon", @@ -360,7 +360,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "run-a-tabletop-rpg-one-shot", title: "Run a Tabletop RPG One-Shot", - imageUrl: "https://images.unsplash.com/photo-1511512578047-dfb367046420", + imageUrl: "https://images.unsplash.com/photo-1511512578047-dfb367046420?w=800&fit=crop&auto=format", type: "Social", field: "Gaming", estimated_time: "An afternoon", @@ -375,7 +375,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "design-a-board-game-prototype", title: "Design a Board Game Prototype", - imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09", + imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09?w=800&fit=crop&auto=format", type: "Creative", field: "Gaming", estimated_time: "A weekend", @@ -390,7 +390,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "stream-your-first-twitch-session", title: "Stream Your First Twitch Session", - imageUrl: "https://images.unsplash.com/photo-1542751371-adc38448a05e", + imageUrl: "https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800&fit=crop&auto=format", type: "Professional", field: "Gaming", estimated_time: "1 hour", @@ -405,7 +405,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "beat-a-notoriously-difficult-rpg", title: "Beat a Notoriously Difficult RPG", - imageUrl: "https://images.unsplash.com/photo-1511512578047-dfb367046420", + imageUrl: "https://images.unsplash.com/photo-1511512578047-dfb367046420?w=800&fit=crop&auto=format", type: "Adventurous", field: "Gaming", estimated_time: "A month", @@ -420,7 +420,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "try-a-30-day-push-up-challenge", title: "Try a 30-Day Push-Up Challenge", - imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438", + imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=800&fit=crop&auto=format", type: "Active", field: "Fitness", estimated_time: "A month", @@ -435,7 +435,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-garage-gym-setup", title: "Build a Garage Gym Setup", - imageUrl: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48", + imageUrl: "https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&fit=crop&auto=format", type: "Constructive", field: "Fitness", estimated_time: "A weekend", @@ -450,7 +450,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "map-a-park-workout-loop", title: "Map a Park Workout Loop", - imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb", + imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Fitness", estimated_time: "An afternoon", @@ -465,7 +465,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "sign-up-for-your-first-5k", title: "Sign Up for Your First 5K", - imageUrl: "https://images.unsplash.com/photo-1483721310020-03333e577078", + imageUrl: "https://images.unsplash.com/photo-1483721310020-03333e577078?w=800&fit=crop&auto=format", type: "Adventurous", field: "Fitness", estimated_time: "A month", @@ -480,7 +480,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "get-personal-trainer-certified", title: "Get Personal Trainer Certified", - imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438", + imageUrl: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=800&fit=crop&auto=format", type: "Professional", field: "Fitness", estimated_time: "A month", @@ -495,7 +495,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-personal-site-from-scratch", title: "Build a Personal Site From Scratch", - imageUrl: "https://images.unsplash.com/photo-1461749280684-dccba630e2f6", + imageUrl: "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&fit=crop&auto=format", type: "Constructive", field: "Coding", estimated_time: "A weekend", @@ -510,7 +510,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-the-pragmatic-programmer", title: "Read The Pragmatic Programmer", - imageUrl: "https://images.unsplash.com/photo-1515879218367-8466d910aaa4", + imageUrl: "https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=800&fit=crop&auto=format", type: "Intellectual", field: "Coding", estimated_time: "A week", @@ -525,7 +525,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-local-hack-night", title: "Join a Local Hack Night", - imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3", + imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&fit=crop&auto=format", type: "Social", field: "Coding", estimated_time: "1 hour", @@ -540,7 +540,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "contribute-your-first-open-source-pr", title: "Contribute Your First Open Source PR", - imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3", + imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&fit=crop&auto=format", type: "Professional", field: "Coding", estimated_time: "A week", @@ -555,7 +555,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "run-a-kitchen-chemistry-experiment", title: "Run a Kitchen Chemistry Experiment", - imageUrl: "https://images.unsplash.com/photo-1532187643603-ba119ca4109e", + imageUrl: "https://images.unsplash.com/photo-1532187643603-ba119ca4109e?w=800&fit=crop&auto=format", type: "Constructive", field: "Science", estimated_time: "1 hour", @@ -570,7 +570,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-cosmos-by-carl-sagan", title: "Read Cosmos by Carl Sagan", - imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564", + imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=800&fit=crop&auto=format", type: "Reflective", field: "Science", estimated_time: "A week", @@ -585,7 +585,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "track-a-citizen-science-project", title: "Track a Citizen Science Project", - imageUrl: "https://images.unsplash.com/photo-1532094349884-543bc11b234d", + imageUrl: "https://images.unsplash.com/photo-1532094349884-543bc11b234d?w=800&fit=crop&auto=format", type: "Intellectual", field: "Science", estimated_time: "A month", @@ -600,7 +600,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "hike-to-a-geological-formation", title: "Hike to a Geological Formation", - imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b", + imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Science", estimated_time: "An afternoon", @@ -615,7 +615,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "apply-to-a-lab-volunteer-program", title: "Apply to a Lab Volunteer Program", - imageUrl: "https://images.unsplash.com/photo-1532187643603-ba119ca4109e", + imageUrl: "https://images.unsplash.com/photo-1532187643603-ba119ca4109e?w=800&fit=crop&auto=format", type: "Professional", field: "Science", estimated_time: "A week", @@ -630,7 +630,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "start-an-indoor-herb-garden", title: "Start an Indoor Herb Garden", - imageUrl: "https://images.unsplash.com/photo-1466692476868-aef1dfb1e735", + imageUrl: "https://images.unsplash.com/photo-1466692476868-aef1dfb1e735?w=800&fit=crop&auto=format", type: "Constructive", field: "Nature", estimated_time: "A few hours", @@ -645,7 +645,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "spend-an-hour-forest-bathing", title: "Spend an Hour Forest Bathing", - imageUrl: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e", + imageUrl: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800&fit=crop&auto=format", type: "Reflective", field: "Nature", estimated_time: "1 hour", @@ -660,7 +660,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "identify-ten-local-plant-species", title: "Identify Ten Local Plant Species", - imageUrl: "https://images.unsplash.com/photo-1466692476868-aef1dfb1e735", + imageUrl: "https://images.unsplash.com/photo-1466692476868-aef1dfb1e735?w=800&fit=crop&auto=format", type: "Intellectual", field: "Nature", estimated_time: "An afternoon", @@ -675,7 +675,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "volunteer-for-a-trail-cleanup", title: "Volunteer for a Trail Cleanup", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Social", field: "Nature", estimated_time: "An afternoon", @@ -690,7 +690,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "camp-solo-for-a-single-night", title: "Camp Solo for a Single Night", - imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb", + imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800&fit=crop&auto=format", type: "Adventurous", field: "Nature", estimated_time: "A weekend", @@ -705,7 +705,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "watch-a-directors-full-filmography", title: "Watch a Director's Full Filmography", - imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba", + imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800&fit=crop&auto=format", type: "Intellectual", field: "Film", estimated_time: "A month", @@ -720,7 +720,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "host-a-themed-film-night", title: "Host a Themed Film Night", - imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba", + imageUrl: "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800&fit=crop&auto=format", type: "Social", field: "Film", estimated_time: "1 hour", @@ -735,7 +735,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "pitch-a-tv-pilot-logline", title: "Pitch a TV Pilot Logline", - imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3", + imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&fit=crop&auto=format", type: "Professional", field: "Film", estimated_time: "A few hours", @@ -750,7 +750,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "reflect-on-your-five-favorite-films", title: "Reflect on Your Five Favorite Films", - imageUrl: "https://images.unsplash.com/photo-1485846234645-a62644f84728", + imageUrl: "https://images.unsplash.com/photo-1485846234645-a62644f84728?w=800&fit=crop&auto=format", type: "Reflective", field: "Film", estimated_time: "1 hour", @@ -765,7 +765,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "audition-for-community-theater", title: "Audition for Community Theater", - imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35", + imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35?w=800&fit=crop&auto=format", type: "Active", field: "Theater", estimated_time: "An afternoon", @@ -780,7 +780,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-set-piece-for-a-production", title: "Build a Set Piece for a Production", - imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35", + imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35?w=800&fit=crop&auto=format", type: "Constructive", field: "Theater", estimated_time: "A weekend", @@ -795,7 +795,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "write-a-10-minute-play", title: "Write a 10-Minute Play", - imageUrl: "https://images.unsplash.com/photo-1516280440614-37939bbacd81", + imageUrl: "https://images.unsplash.com/photo-1516280440614-37939bbacd81?w=800&fit=crop&auto=format", type: "Artistic", field: "Theater", estimated_time: "A few hours", @@ -810,7 +810,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "run-an-improv-night-with-friends", title: "Run an Improv Night With Friends", - imageUrl: "https://images.unsplash.com/photo-1529156069898-49953e39b3ac", + imageUrl: "https://images.unsplash.com/photo-1529156069898-49953e39b3ac?w=800&fit=crop&auto=format", type: "Social", field: "Theater", estimated_time: "1 hour", @@ -825,7 +825,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "direct-a-staged-reading", title: "Direct a Staged Reading", - imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35", + imageUrl: "https://images.unsplash.com/photo-1503095396549-807759245b35?w=800&fit=crop&auto=format", type: "Professional", field: "Theater", estimated_time: "A weekend", @@ -840,7 +840,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "take-a-pottery-wheel-class", title: "Take a Pottery Wheel Class", - imageUrl: "https://images.unsplash.com/photo-1515377905703-c4788e51af15", + imageUrl: "https://images.unsplash.com/photo-1515377905703-c4788e51af15?w=800&fit=crop&auto=format", type: "Constructive", field: "Art", estimated_time: "1 hour", @@ -855,7 +855,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "sketch-in-a-public-park", title: "Sketch in a Public Park", - imageUrl: "https://images.unsplash.com/photo-1513364776144-60967b0f800f", + imageUrl: "https://images.unsplash.com/photo-1513364776144-60967b0f800f?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Art", estimated_time: "1 hour", @@ -870,7 +870,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "curate-a-gallery-wall-in-your-home", title: "Curate a Gallery Wall in Your Home", - imageUrl: "https://images.unsplash.com/photo-1513694203232-719a280e022f", + imageUrl: "https://images.unsplash.com/photo-1513694203232-719a280e022f?w=800&fit=crop&auto=format", type: "Creative", field: "Art", estimated_time: "An afternoon", @@ -885,7 +885,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "sell-a-piece-at-a-local-market", title: "Sell a Piece at a Local Market", - imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865", + imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865?w=800&fit=crop&auto=format", type: "Professional", field: "Art", estimated_time: "A weekend", @@ -900,7 +900,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "draft-a-personal-essay", title: "Draft a Personal Essay", - imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a", + imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&fit=crop&auto=format", type: "Reflective", field: "Writing", estimated_time: "A few hours", @@ -915,7 +915,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "co-write-a-story-with-a-friend", title: "Co-Write a Story With a Friend", - imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f", + imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&fit=crop&auto=format", type: "Social", field: "Writing", estimated_time: "A few hours", @@ -930,7 +930,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "try-stream-of-consciousness-daily", title: "Try Stream-of-Consciousness Daily", - imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a", + imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&fit=crop&auto=format", type: "Creative", field: "Writing", estimated_time: "A month", @@ -945,7 +945,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "pitch-a-magazine-feature-article", title: "Pitch a Magazine Feature Article", - imageUrl: "https://images.unsplash.com/photo-1495020689067-958852a7765e", + imageUrl: "https://images.unsplash.com/photo-1495020689067-958852a7765e?w=800&fit=crop&auto=format", type: "Professional", field: "Writing", estimated_time: "A week", @@ -960,7 +960,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "travel-for-writing-inspiration", title: "Travel for Writing Inspiration", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Adventurous", field: "Writing", estimated_time: "A weekend", @@ -975,7 +975,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "take-a-beginner-salsa-class", title: "Take a Beginner Salsa Class", - imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4", + imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?w=800&fit=crop&auto=format", type: "Active", field: "Dance", estimated_time: "1 hour", @@ -990,7 +990,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "learn-a-tiktok-choreography", title: "Learn a TikTok Choreography", - imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4", + imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?w=800&fit=crop&auto=format", type: "Performative", field: "Dance", estimated_time: "1 hour", @@ -1005,7 +1005,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "attend-a-friday-night-dance-social", title: "Attend a Friday Night Dance Social", - imageUrl: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30", + imageUrl: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&fit=crop&auto=format", type: "Social", field: "Dance", estimated_time: "An afternoon", @@ -1020,7 +1020,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "choreograph-a-30-second-routine", title: "Choreograph a 30-Second Routine", - imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4", + imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?w=800&fit=crop&auto=format", type: "Artistic", field: "Dance", estimated_time: "A few hours", @@ -1035,7 +1035,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "study-the-history-of-modern-dance", title: "Study the History of Modern Dance", - imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4", + imageUrl: "https://images.unsplash.com/photo-1504609773096-104ff2c73ba4?w=800&fit=crop&auto=format", type: "Intellectual", field: "Dance", estimated_time: "A week", @@ -1050,7 +1050,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "ride-a-new-20-mile-loop", title: "Ride a New 20-Mile Loop", - imageUrl: "https://images.unsplash.com/photo-1507035895480-2b3156c31fc8", + imageUrl: "https://images.unsplash.com/photo-1507035895480-2b3156c31fc8?w=800&fit=crop&auto=format", type: "Adventurous", field: "Cycling", estimated_time: "An afternoon", @@ -1065,7 +1065,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-up-a-used-bike-frame", title: "Build Up a Used Bike Frame", - imageUrl: "https://images.unsplash.com/photo-1517649763962-0c623066013b", + imageUrl: "https://images.unsplash.com/photo-1517649763962-0c623066013b?w=800&fit=crop&auto=format", type: "Constructive", field: "Cycling", estimated_time: "A weekend", @@ -1080,7 +1080,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-sunday-group-ride", title: "Join a Sunday Group Ride", - imageUrl: "https://images.unsplash.com/photo-1517649763962-0c623066013b", + imageUrl: "https://images.unsplash.com/photo-1517649763962-0c623066013b?w=800&fit=crop&auto=format", type: "Social", field: "Cycling", estimated_time: "An afternoon", @@ -1095,7 +1095,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "race-your-first-local-crit", title: "Race Your First Local Crit", - imageUrl: "https://images.unsplash.com/photo-1541625602330-2277a4c46182", + imageUrl: "https://images.unsplash.com/photo-1541625602330-2277a4c46182?w=800&fit=crop&auto=format", type: "Active", field: "Cycling", estimated_time: "1 hour", @@ -1110,7 +1110,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "cycle-through-a-national-park", title: "Cycle Through a National Park", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Cycling", estimated_time: "A weekend", @@ -1125,7 +1125,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "hike-a-sunrise-summit", title: "Hike a Sunrise Summit", - imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b", + imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&fit=crop&auto=format", type: "Adventurous", field: "Hiking", estimated_time: "An afternoon", @@ -1140,7 +1140,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "plan-a-three-day-backpacking-trip", title: "Plan a Three-Day Backpacking Trip", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Hiking", estimated_time: "A weekend", @@ -1155,7 +1155,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "lead-a-friend-on-their-first-hike", title: "Lead a Friend on Their First Hike", - imageUrl: "https://images.unsplash.com/photo-1551632811-561732d1e306", + imageUrl: "https://images.unsplash.com/photo-1551632811-561732d1e306?w=800&fit=crop&auto=format", type: "Social", field: "Hiking", estimated_time: "An afternoon", @@ -1170,7 +1170,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "photograph-a-trail-in-a-series", title: "Photograph a Trail in a Series", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Artistic", field: "Hiking", estimated_time: "An afternoon", @@ -1185,7 +1185,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "journal-at-a-mountain-overlook", title: "Journal at a Mountain Overlook", - imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b", + imageUrl: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=800&fit=crop&auto=format", type: "Reflective", field: "Hiking", estimated_time: "1 hour", @@ -1200,7 +1200,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "plan-a-weekend-solo-trip", title: "Plan a Weekend Solo Trip", - imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", + imageUrl: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=800&fit=crop&auto=format", type: "Adventurous", field: "Travel", estimated_time: "A weekend", @@ -1215,7 +1215,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-travel-photo-book", title: "Build a Travel Photo Book", - imageUrl: "https://images.unsplash.com/photo-1488646953014-85cb44e25828", + imageUrl: "https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=800&fit=crop&auto=format", type: "Creative", field: "Travel", estimated_time: "A weekend", @@ -1230,7 +1230,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "learn-50-words-in-a-new-language", title: "Learn 50 Words in a New Language", - imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f", + imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&fit=crop&auto=format", type: "Intellectual", field: "Travel", estimated_time: "A week", @@ -1245,7 +1245,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "host-a-couchsurfer-for-a-weekend", title: "Host a Couchsurfer for a Weekend", - imageUrl: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267", + imageUrl: "https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?w=800&fit=crop&auto=format", type: "Social", field: "Travel", estimated_time: "A weekend", @@ -1260,7 +1260,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "apply-to-be-a-travel-writer", title: "Apply to Be a Travel Writer", - imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a", + imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&fit=crop&auto=format", type: "Professional", field: "Travel", estimated_time: "A week", @@ -1275,7 +1275,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-morning-routine", title: "Build a Morning Routine", - imageUrl: "https://images.unsplash.com/photo-1499209974431-9dddcece7f88", + imageUrl: "https://images.unsplash.com/photo-1499209974431-9dddcece7f88?w=800&fit=crop&auto=format", type: "Constructive", field: "Wellness", estimated_time: "A month", @@ -1290,7 +1290,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "take-a-yin-yoga-class", title: "Take a Yin Yoga Class", - imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773", + imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=800&fit=crop&auto=format", type: "Active", field: "Wellness", estimated_time: "1 hour", @@ -1305,7 +1305,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "track-your-sleep-for-two-weeks", title: "Track Your Sleep for Two Weeks", - imageUrl: "https://images.unsplash.com/photo-1495195134817-aeb325a55b65", + imageUrl: "https://images.unsplash.com/photo-1495195134817-aeb325a55b65?w=800&fit=crop&auto=format", type: "Intellectual", field: "Wellness", estimated_time: "A week", @@ -1320,7 +1320,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "become-a-certified-yoga-teacher", title: "Become a Certified Yoga Teacher", - imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773", + imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=800&fit=crop&auto=format", type: "Professional", field: "Wellness", estimated_time: "A month", @@ -1335,7 +1335,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-backyard-telescope-mount", title: "Build a Backyard Telescope Mount", - imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564", + imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=800&fit=crop&auto=format", type: "Constructive", field: "Astronomy", estimated_time: "A weekend", @@ -1350,7 +1350,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "reflect-on-the-cosmos-under-the-night-sky", title: "Reflect on the Cosmos Under the Night Sky", - imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564", + imageUrl: "https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=800&fit=crop&auto=format", type: "Reflective", field: "Astronomy", estimated_time: "1 hour", @@ -1365,7 +1365,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "photograph-the-milky-way", title: "Photograph the Milky Way", - imageUrl: "https://images.unsplash.com/photo-1502134249126-9f3755a50d78", + imageUrl: "https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800&fit=crop&auto=format", type: "Artistic", field: "Astronomy", estimated_time: "An afternoon", @@ -1380,7 +1380,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "follow-a-tech-deep-dive-channel-for-a-month", title: "Follow a Tech Deep-Dive Channel for a Month", - imageUrl: "https://images.unsplash.com/photo-1518770660439-4636190af475", + imageUrl: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&fit=crop&auto=format", type: "Educational", field: "Technology", estimated_time: "A month", @@ -1395,7 +1395,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "3d-print-a-custom-phone-stand", title: "3D Print a Custom Phone Stand", - imageUrl: "https://images.unsplash.com/photo-1515879218367-8466d910aaa4", + imageUrl: "https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=800&fit=crop&auto=format", type: "Constructive", field: "Engineering", estimated_time: "A weekend", @@ -1410,7 +1410,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-simple-arduino-robot", title: "Build a Simple Arduino Robot", - imageUrl: "https://images.unsplash.com/photo-1518770660439-4636190af475", + imageUrl: "https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&fit=crop&auto=format", type: "Experimental", field: "Engineering", estimated_time: "A weekend", @@ -1425,7 +1425,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "detail-your-car-to-showroom-standard", title: "Detail Your Car to Showroom Standard", - imageUrl: "https://images.unsplash.com/photo-1503376780353-7e6692767b70", + imageUrl: "https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=800&fit=crop&auto=format", type: "Constructive", field: "Cars", estimated_time: "An afternoon", @@ -1440,7 +1440,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "attend-a-weekend-track-day", title: "Attend a Weekend Track Day", - imageUrl: "https://images.unsplash.com/photo-1502877338535-766e1452684a", + imageUrl: "https://images.unsplash.com/photo-1502877338535-766e1452684a?w=800&fit=crop&auto=format", type: "Active", field: "Cars", estimated_time: "A weekend", @@ -1455,7 +1455,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "get-your-motorcycle-license", title: "Get Your Motorcycle License", - imageUrl: "https://images.unsplash.com/photo-1517846693594-1567da72af75", + imageUrl: "https://images.unsplash.com/photo-1517846693594-1567da72af75?w=800&fit=crop&auto=format", type: "Active", field: "Motorcycles", estimated_time: "A month", @@ -1470,7 +1470,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "plan-a-coastal-motorcycle-road-trip", title: "Plan a Coastal Motorcycle Road Trip", - imageUrl: "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429", + imageUrl: "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=800&fit=crop&auto=format", type: "Adventurous", field: "Motorcycles", estimated_time: "A weekend", @@ -1485,7 +1485,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "take-an-intro-flight-lesson", title: "Take an Intro Flight Lesson", - imageUrl: "https://images.unsplash.com/photo-1436491865332-7a61a109cc05", + imageUrl: "https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=800&fit=crop&auto=format", type: "Active", field: "Aviation", estimated_time: "1 hour", @@ -1500,7 +1500,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-remote-control-plane", title: "Build a Remote-Control Plane", - imageUrl: "https://images.unsplash.com/photo-1474302770737-173ee21bab63", + imageUrl: "https://images.unsplash.com/photo-1474302770737-173ee21bab63?w=800&fit=crop&auto=format", type: "Constructive", field: "Aviation", estimated_time: "A weekend", @@ -1515,7 +1515,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-recreational-soccer-league", title: "Join a Recreational Soccer League", - imageUrl: "https://images.unsplash.com/photo-1517466787929-bc90951d0974", + imageUrl: "https://images.unsplash.com/photo-1517466787929-bc90951d0974?w=800&fit=crop&auto=format", type: "Social", field: "Sports", estimated_time: "Ongoing", @@ -1530,7 +1530,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "attend-a-live-sporting-event", title: "Attend a Live Sporting Event", - imageUrl: "https://images.unsplash.com/photo-1508098682722-e99c643e7485", + imageUrl: "https://images.unsplash.com/photo-1508098682722-e99c643e7485?w=800&fit=crop&auto=format", type: "Social", field: "Sports", estimated_time: "An afternoon", @@ -1545,7 +1545,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-a-local-running-club", title: "Join a Local Running Club", - imageUrl: "https://images.unsplash.com/photo-1483721310020-03333e577078", + imageUrl: "https://images.unsplash.com/photo-1483721310020-03333e577078?w=800&fit=crop&auto=format", type: "Social", field: "Running", estimated_time: "Ongoing", @@ -1560,7 +1560,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "try-an-indoor-bouldering-gym", title: "Try an Indoor Bouldering Gym", - imageUrl: "https://images.unsplash.com/photo-1522163182402-834f871fd851", + imageUrl: "https://images.unsplash.com/photo-1522163182402-834f871fd851?w=800&fit=crop&auto=format", type: "Active", field: "Climbing", estimated_time: "1 hour", @@ -1575,7 +1575,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "take-an-outdoor-rock-climbing-course", title: "Take an Outdoor Rock Climbing Course", - imageUrl: "https://images.unsplash.com/photo-1522163182402-834f871fd851", + imageUrl: "https://images.unsplash.com/photo-1522163182402-834f871fd851?w=800&fit=crop&auto=format", type: "Adventurous", field: "Climbing", estimated_time: "A weekend", @@ -1590,7 +1590,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "host-a-monthly-board-game-night", title: "Host a Monthly Board Game Night", - imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09", + imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09?w=800&fit=crop&auto=format", type: "Social", field: "Board Games", estimated_time: "An afternoon", @@ -1605,7 +1605,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "solo-through-a-complex-strategy-game", title: "Solo Through a Complex Strategy Game", - imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09", + imageUrl: "https://images.unsplash.com/photo-1610890716171-6b1bb98ffd09?w=800&fit=crop&auto=format", type: "Intellectual", field: "Board Games", estimated_time: "An afternoon", @@ -1620,7 +1620,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "visit-five-local-independent-cafes", title: "Visit Five Local Independent Cafes", - imageUrl: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085", + imageUrl: "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=800&fit=crop&auto=format", type: "Exploratory", field: "Coffee", estimated_time: "A month", @@ -1635,7 +1635,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "curate-a-capsule-wardrobe", title: "Curate a Capsule Wardrobe", - imageUrl: "https://images.unsplash.com/photo-1496747611176-843222e1e57c", + imageUrl: "https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=800&fit=crop&auto=format", type: "Creative", field: "Fashion", estimated_time: "An afternoon", @@ -1650,7 +1650,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "thrift-a-full-outfit-under-20", title: "Thrift a Full Outfit Under $20", - imageUrl: "https://images.unsplash.com/photo-1483985988355-763728e1935b", + imageUrl: "https://images.unsplash.com/photo-1483985988355-763728e1935b?w=800&fit=crop&auto=format", type: "Adventurous", field: "Fashion", estimated_time: "An afternoon", @@ -1665,7 +1665,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "car-camp-at-a-state-park", title: "Car Camp at a State Park", - imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb", + imageUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=800&fit=crop&auto=format", type: "Outdoorsy", field: "Camping", estimated_time: "A weekend", @@ -1680,7 +1680,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "master-three-essential-wilderness-skills", title: "Master Three Essential Wilderness Skills", - imageUrl: "https://images.unsplash.com/photo-1472396961693-142e6e269027", + imageUrl: "https://images.unsplash.com/photo-1472396961693-142e6e269027?w=800&fit=crop&auto=format", type: "Skill-based", field: "Camping", estimated_time: "A weekend", @@ -1695,7 +1695,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-thinking-fast-and-slow", title: "Read Thinking, Fast and Slow", - imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794", + imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?w=800&fit=crop&auto=format", type: "Intellectual", field: "Psychology", estimated_time: "A week", @@ -1710,7 +1710,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "try-cognitive-behavioral-journaling", title: "Try Cognitive Behavioral Journaling", - imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a", + imageUrl: "https://images.unsplash.com/photo-1455390582262-044cdead277a?w=800&fit=crop&auto=format", type: "Reflective", field: "Psychology", estimated_time: "A month", @@ -1725,7 +1725,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "work-through-platos-republic", title: "Work Through Plato's Republic", - imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794", + imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?w=800&fit=crop&auto=format", type: "Intellectual", field: "Philosophy", estimated_time: "A week", @@ -1740,7 +1740,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "join-an-online-philosophy-discussion-group", title: "Join an Online Philosophy Discussion Group", - imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f", + imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&fit=crop&auto=format", type: "Social", field: "Philosophy", estimated_time: "Ongoing", @@ -1755,7 +1755,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "visit-a-local-historical-museum", title: "Visit a Local Historical Museum", - imageUrl: "https://images.unsplash.com/photo-1518998053901-5348d3961a04", + imageUrl: "https://images.unsplash.com/photo-1518998053901-5348d3961a04?w=800&fit=crop&auto=format", type: "Educational", field: "History", estimated_time: "An afternoon", @@ -1770,7 +1770,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "research-your-family-genealogy", title: "Research Your Family Genealogy", - imageUrl: "https://images.unsplash.com/photo-1517841905240-472988babdf9", + imageUrl: "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=800&fit=crop&auto=format", type: "Intellectual", field: "History", estimated_time: "A week", @@ -1785,7 +1785,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "start-a-30-day-duolingo-streak", title: "Start a 30-Day Duolingo Streak", - imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f", + imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&fit=crop&auto=format", type: "Disciplined", field: "Language", estimated_time: "A month", @@ -1800,7 +1800,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "find-a-language-exchange-partner", title: "Find a Language Exchange Partner", - imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f", + imageUrl: "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=800&fit=crop&auto=format", type: "Social", field: "Language", estimated_time: "Ongoing", @@ -1815,7 +1815,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "write-a-one-page-business-plan", title: "Write a One-Page Business Plan", - imageUrl: "https://images.unsplash.com/photo-1520607162513-77705c0f0d4a", + imageUrl: "https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?w=800&fit=crop&auto=format", type: "Professional", field: "Business", estimated_time: "A few hours", @@ -1830,7 +1830,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "attend-a-local-entrepreneur-meetup", title: "Attend a Local Entrepreneur Meetup", - imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865", + imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865?w=800&fit=crop&auto=format", type: "Social", field: "Business", estimated_time: "1 hour", @@ -1845,7 +1845,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-personal-budget-spreadsheet", title: "Build a Personal Budget Spreadsheet", - imageUrl: "https://images.unsplash.com/photo-1554224155-6726b3ff858f", + imageUrl: "https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=800&fit=crop&auto=format", type: "Analytical", field: "Finance", estimated_time: "A few hours", @@ -1860,7 +1860,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-the-psychology-of-money", title: "Read The Psychology of Money", - imageUrl: "https://images.unsplash.com/photo-1520607162513-77705c0f0d4a", + imageUrl: "https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?w=800&fit=crop&auto=format", type: "Intellectual", field: "Finance", estimated_time: "A week", @@ -1875,7 +1875,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "volunteer-to-lead-a-community-project", title: "Volunteer to Lead a Community Project", - imageUrl: "https://images.unsplash.com/photo-1529156069898-49953e39b3ac", + imageUrl: "https://images.unsplash.com/photo-1529156069898-49953e39b3ac?w=800&fit=crop&auto=format", type: "Leadership", field: "Leadership", estimated_time: "A month", @@ -1890,7 +1890,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "read-extreme-ownership", title: "Read Extreme Ownership", - imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794", + imageUrl: "https://images.unsplash.com/photo-1512820790803-83ca734da794?w=800&fit=crop&auto=format", type: "Intellectual", field: "Leadership", estimated_time: "A week", @@ -1905,7 +1905,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "complete-a-10-day-vipassana-course", title: "Complete a 10-Day Vipassana Course", - imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773", + imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=800&fit=crop&auto=format", type: "Reflective", field: "Meditation", estimated_time: "A week", @@ -1920,7 +1920,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "set-up-a-daily-5-minute-breath-practice", title: "Set Up a Daily 5-Minute Breath Practice", - imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773", + imageUrl: "https://images.unsplash.com/photo-1506126613408-eca07ce68773?w=800&fit=crop&auto=format", type: "Mindful", field: "Meditation", estimated_time: "A month", @@ -1935,7 +1935,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "enroll-in-a-free-online-course", title: "Enroll in a Free Online Course", - imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3", + imageUrl: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&fit=crop&auto=format", type: "Educational", field: "Education", estimated_time: "A month", @@ -1950,7 +1950,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "teach-a-skill-to-someone-you-know", title: "Teach a Skill to Someone You Know", - imageUrl: "https://images.unsplash.com/photo-1513258496099-48168024aec0", + imageUrl: "https://images.unsplash.com/photo-1513258496099-48168024aec0?w=800&fit=crop&auto=format", type: "Social", field: "Education", estimated_time: "A few hours", @@ -1965,7 +1965,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "attend-a-neighborhood-association-meeting", title: "Attend a Neighborhood Association Meeting", - imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865", + imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865?w=800&fit=crop&auto=format", type: "Community-oriented", field: "Community", estimated_time: "1 hour", @@ -1980,7 +1980,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "organize-a-block-party", title: "Organize a Block Party", - imageUrl: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30", + imageUrl: "https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=800&fit=crop&auto=format", type: "Social", field: "Community", estimated_time: "A weekend", @@ -1995,7 +1995,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "sign-up-with-a-local-food-bank", title: "Sign Up With a Local Food Bank", - imageUrl: "https://images.unsplash.com/photo-1488521787991-ed7bbaae773c", + imageUrl: "https://images.unsplash.com/photo-1488521787991-ed7bbaae773c?w=800&fit=crop&auto=format", type: "Community-oriented", field: "Volunteering", estimated_time: "Ongoing", @@ -2010,7 +2010,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "mentor-a-high-school-student", title: "Mentor a High School Student", - imageUrl: "https://images.unsplash.com/photo-1513258496099-48168024aec0", + imageUrl: "https://images.unsplash.com/photo-1513258496099-48168024aec0?w=800&fit=crop&auto=format", type: "Educational", field: "Volunteering", estimated_time: "Ongoing", @@ -2025,7 +2025,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "build-a-wooden-bookshelf-from-scratch", title: "Build a Wooden Bookshelf From Scratch", - imageUrl: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85", + imageUrl: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=800&fit=crop&auto=format", type: "Constructive", field: "DIY", estimated_time: "A weekend", @@ -2040,7 +2040,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "rewire-a-vintage-lamp", title: "Rewire a Vintage Lamp", - imageUrl: "https://images.unsplash.com/photo-1519710164239-da123dc03ef4", + imageUrl: "https://images.unsplash.com/photo-1519710164239-da123dc03ef4?w=800&fit=crop&auto=format", type: "Constructive", field: "DIY", estimated_time: "An afternoon", @@ -2055,7 +2055,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "redesign-a-room-with-no-new-purchases", title: "Redesign a Room With No New Purchases", - imageUrl: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85", + imageUrl: "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=800&fit=crop&auto=format", type: "Creative", field: "Home Design", estimated_time: "An afternoon", @@ -2070,7 +2070,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "learn-the-basics-of-interior-design", title: "Learn the Basics of Interior Design", - imageUrl: "https://images.unsplash.com/photo-1484154218962-a197022b5858", + imageUrl: "https://images.unsplash.com/photo-1484154218962-a197022b5858?w=800&fit=crop&auto=format", type: "Educational", field: "Home Design", estimated_time: "A week", @@ -2085,7 +2085,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "start-a-vinyl-record-collection", title: "Start a Vinyl Record Collection", - imageUrl: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b", + imageUrl: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?w=800&fit=crop&auto=format", type: "Reflective", field: "Collecting", estimated_time: "Ongoing", @@ -2100,7 +2100,7 @@ export const RECOMMENDATIONS: Activity[] = [ { slug: "spend-a-month-visiting-antique-markets", title: "Spend a Month Visiting Antique Markets", - imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865", + imageUrl: "https://images.unsplash.com/photo-1511578314322-379afb476865?w=800&fit=crop&auto=format", type: "Exploratory", field: "Collecting", estimated_time: "A month", From 26824d3f51e92188fd49da77610ab323e85e5153 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 15:27:58 +1200 Subject: [PATCH 05/13] fix: top pattern animation performance --- .../patterns/top-patterns-section.tsx | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/mobile/components/patterns/top-patterns-section.tsx b/apps/mobile/components/patterns/top-patterns-section.tsx index 037fc35..626a5d8 100644 --- a/apps/mobile/components/patterns/top-patterns-section.tsx +++ b/apps/mobile/components/patterns/top-patterns-section.tsx @@ -37,6 +37,19 @@ function pickTopThree(weights: UserPatternWeights): PatternTypeId[] { .map(([id]) => id) } +function PatternDetail({ pattern }: { pattern: PatternType }) { + return ( + <> + + {pattern.name} + + + {pattern.shortDescription} + + + ) +} + export function TopPatternsSection({ patternWeights }: Props) { const topThree = useMemo(() => pickTopThree(patternWeights), [patternWeights]) const [selectedId, setSelectedId] = useState(null) @@ -46,6 +59,7 @@ export function TopPatternsSection({ patternWeights }: Props) { const [contentHeight, setContentHeight] = useState(0) const heightValue = useSharedValue(0) const opacityValue = useSharedValue(0) + const marginTopValue = useSharedValue(0) const displayed = displayedId !== null ? PATTERN_BY_ID[displayedId] : null useEffect(() => { @@ -59,19 +73,28 @@ export function TopPatternsSection({ patternWeights }: Props) { return () => clearTimeout(timeout) }, [selectedId]) + // Wait for a real height measurement before expanding — onLayout inside the + // zero-height Animated.View reports h=0 on native (Yoga constrains children + // to the parent's explicit height). The measurement view below is outside + // that constraint, so contentHeight is always the true content height. useEffect(() => { const isOpen = selectedId !== null + if (isOpen && contentHeight === 0) return heightValue.value = withTiming(isOpen ? contentHeight : 0, { duration: ANIMATION_DURATION, }) opacityValue.value = withTiming(isOpen ? 1 : 0, { duration: ANIMATION_DURATION, }) - }, [selectedId, contentHeight, heightValue, opacityValue]) + marginTopValue.value = withTiming(isOpen ? 16 : 0, { + duration: ANIMATION_DURATION, + }) + }, [selectedId, contentHeight, heightValue, opacityValue, marginTopValue]) const animatedStyle = useAnimatedStyle(() => ({ height: heightValue.value, opacity: opacityValue.value, + marginTop: marginTopValue.value, })) function handleSelect(id: PatternTypeId) { @@ -112,31 +135,39 @@ export function TopPatternsSection({ patternWeights }: Props) { ) })} + + {/* + Measurement-only view: positioned off-screen so it doesn't affect layout, + but unconstrained by the Animated.View height so onLayout reports the + true content height on native. + */} + + { + if (!displayed) return + setContentHeight(e.nativeEvent.layout.height) + }} + > + {displayed ? : null} + + + - setContentHeight(e.nativeEvent.layout.height)} - style={{ padding: 16 }} - > - {displayed ? ( - <> - - {displayed.name} - - - {displayed.shortDescription} - - - ) : null} + + {displayed ? : null} From e9a6d930733410ae1a5fa4b75cb5652838bc2aca Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 15:38:52 +1200 Subject: [PATCH 06/13] chore: add iOS bundleIdentifier, update build scripts, document native build workflow - Added bundleIdentifier to apps/mobile/app.json ios config (required for expo run:ios) - Updated ios/android scripts from expo start to expo run:* for native dev builds - Documented physical-device Expo Go setup, native dev build, and release build workflows in README --- README.md | 33 ++++++++++++++++++++++++++++++--- apps/mobile/app.json | 3 ++- apps/mobile/package.json | 4 ++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 103cde5..6d82db8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e3d8397..23e4f4d 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -12,7 +12,8 @@ "policy": "sdkVersion" }, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.rlyhan.touchgrass" }, "android": { "adaptiveIcon": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7ac72ef..5e93472 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,8 +6,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "vercel-build": "expo export --platform web --output-dir dist", "lint": "expo lint", From 841e00d54851fa6110b29397f48bd7ec120c3a5a Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 15:39:11 +1200 Subject: [PATCH 07/13] chore: bump version to 1.2.7 and update CHANGELOG --- CHANGELOG.md | 14 ++++++++++++++ apps/mobile/package.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 230c34e..2393e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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: `onLayout` inside a `height: 0` `Animated.View` reports `h = 0` due to Yoga constraining child layout to the parent's explicit height. Measurement is now taken from an absolutely-positioned, opacity-0, non-interactive clone outside the animated container so the true content height is always available before the animation fires. +- 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. + +### 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. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5e93472..4b7a1b6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,7 +1,7 @@ { "name": "@touchgrass/mobile", "main": "index.js", - "version": "1.1.0", + "version": "1.2.7", "private": true, "scripts": { "start": "expo start", From 8f0e9b4c38cb39db0e6176fc9813f3514bb05e80 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 16:49:28 +1200 Subject: [PATCH 08/13] fix: disable Metro workspace root auto-detection for Android compatibility Without EXPO_NO_METRO_WORKSPACE_ROOT=1, monorepo detection sets the Metro server root to the repo root, producing a bundle URL that Android Expo Go cannot resolve. The flag keeps the server root as apps/mobile/ while watchFolders and nodeModulesPaths already handle cross-package resolution. --- apps/mobile/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4b7a1b6..ed7e074 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -4,7 +4,7 @@ "version": "1.2.7", "private": true, "scripts": { - "start": "expo start", + "start": "EXPO_NO_METRO_WORKSPACE_ROOT=1 expo start", "reset-project": "node ./scripts/reset-project.js", "android": "expo run:android", "ios": "expo run:ios", From de34b7a7802496e83228d184e654c1085c69112e Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 16:49:33 +1200 Subject: [PATCH 09/13] chore: ignore auto-generated root tsconfig.json Expo regenerates tsconfig.json at the repo root on every dev server start. Untrack and ignore it so git doesn't surface it as a change each session. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 34f9dfe..514c59a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn-error.* # typescript *.tsbuildinfo +tsconfig.json app-example From 762dddd053be129f89a8e2f320b305c2a98c0cd7 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 18:01:52 +1200 Subject: [PATCH 10/13] fix: reduce renders before animation start to fix Android jank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate contentHeight state and its setter chain. Previously, first-open of an uncached pattern caused 4 renders before withTiming could start (setSelectedId → setDisplayedId → setContentHeight(0) → setContentHeight(h)). Now handleLayout triggers the animation directly, and the open effect handles cached patterns — two renders max in all paths. Also extract TIMING_CONFIG constant, memoize handleSelect with useCallback, and move pointerEvents into style (correct for RN 0.76+). --- .../__tests__/top-patterns-section.test.tsx | 21 +++- .../app/(authed)/recommendations/index.tsx | 41 ++++--- .../patterns/top-patterns-section.tsx | 108 +++++++++++------- 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/apps/mobile/__tests__/top-patterns-section.test.tsx b/apps/mobile/__tests__/top-patterns-section.test.tsx index 90cc661..8c22fb7 100644 --- a/apps/mobile/__tests__/top-patterns-section.test.tsx +++ b/apps/mobile/__tests__/top-patterns-section.test.tsx @@ -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. @@ -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", () => { @@ -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() }) }) diff --git a/apps/mobile/app/(authed)/recommendations/index.tsx b/apps/mobile/app/(authed)/recommendations/index.tsx index bf099e7..8618bc1 100644 --- a/apps/mobile/app/(authed)/recommendations/index.tsx +++ b/apps/mobile/app/(authed)/recommendations/index.tsx @@ -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" @@ -46,6 +46,26 @@ export default function RecommendationsPage() { }, []) const { data: recommendations = [], status, reload } = useAsyncData(fetcher) + const patternWeights = getCachedPatternWeights() + + const listHeader = useMemo( + () => ( + <> + + + + {patternWeights ? ( + + + + ) : null} + + Your recommendations + + + ), + [patternWeights], + ) const handleSignOut = useCallback(async () => { setSigningOut(true) @@ -122,24 +142,7 @@ export default function RecommendationsPage() { ItemSeparatorComponent={ItemSeparator} initialNumToRender={5} contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 24, paddingBottom: 32 }} - ListHeaderComponent={ - <> - - - - {(() => { - const weights = getCachedPatternWeights() - return weights ? ( - - - - ) : null - })()} - - Your recommendations - - - } + ListHeaderComponent={listHeader} ListFooterComponent={ = Object.fromEntries( ) const ANIMATION_DURATION = 220 +const PANEL_GAP = 16 +const DETAIL_PADDING = 16 + +const TIMING_CONFIG = { + duration: ANIMATION_DURATION, + easing: Easing.out(Easing.cubic), +} type Props = { patternWeights: UserPatternWeights @@ -56,11 +64,13 @@ export function TopPatternsSection({ patternWeights }: Props) { // Keep the last-shown pattern mounted so the collapse animation has // something to fade/shrink instead of the content vanishing instantly. const [displayedId, setDisplayedId] = useState(null) - const [contentHeight, setContentHeight] = useState(0) + const heightById = useRef>>({}) const heightValue = useSharedValue(0) const opacityValue = useSharedValue(0) - const marginTopValue = useSharedValue(0) + const gapValue = useSharedValue(0) const displayed = displayedId !== null ? PATTERN_BY_ID[displayedId] : null + const needsMeasure = + displayedId !== null && heightById.current[displayedId] === undefined useEffect(() => { if (selectedId !== null) { @@ -73,34 +83,51 @@ export function TopPatternsSection({ patternWeights }: Props) { return () => clearTimeout(timeout) }, [selectedId]) - // Wait for a real height measurement before expanding — onLayout inside the - // zero-height Animated.View reports h=0 on native (Yoga constrains children - // to the parent's explicit height). The measurement view below is outside - // that constraint, so contentHeight is always the true content height. + // Open (cached path): when displayedId changes and the height is already + // known, start the animation immediately without going through a state + // update. Uncached patterns are handled in handleLayout instead. useEffect(() => { - const isOpen = selectedId !== null - if (isOpen && contentHeight === 0) return - heightValue.value = withTiming(isOpen ? contentHeight : 0, { - duration: ANIMATION_DURATION, - }) - opacityValue.value = withTiming(isOpen ? 1 : 0, { - duration: ANIMATION_DURATION, - }) - marginTopValue.value = withTiming(isOpen ? 16 : 0, { - duration: ANIMATION_DURATION, - }) - }, [selectedId, contentHeight, heightValue, opacityValue, marginTopValue]) + if (!displayedId) return + const cached = heightById.current[displayedId] + if (cached === undefined) return + heightValue.value = withTiming(cached, TIMING_CONFIG) + opacityValue.value = withTiming(1, TIMING_CONFIG) + gapValue.value = withTiming(PANEL_GAP, TIMING_CONFIG) + }, [displayedId, heightValue, opacityValue, gapValue]) + + // Close: animate to zero when deselected. + useEffect(() => { + if (selectedId !== null) return + heightValue.value = withTiming(0, TIMING_CONFIG) + opacityValue.value = withTiming(0, TIMING_CONFIG) + gapValue.value = withTiming(0, TIMING_CONFIG) + }, [selectedId, heightValue, opacityValue, gapValue]) + + const gapStyle = useAnimatedStyle(() => ({ + height: gapValue.value, + })) - const animatedStyle = useAnimatedStyle(() => ({ + const panelStyle = useAnimatedStyle(() => ({ height: heightValue.value, opacity: opacityValue.value, - marginTop: marginTopValue.value, })) - function handleSelect(id: PatternTypeId) { - setSelectedId((prev) => (prev === id ? null : id)) + function handleLayout(height: number) { + if (!displayedId || height === 0) return + const prev = heightById.current[displayedId] + if (prev === height) return + heightById.current[displayedId] = height + // Trigger animation directly — skips the setContentHeight → re-render → + // effect chain that causes visible jank on Android. + heightValue.value = withTiming(height, TIMING_CONFIG) + opacityValue.value = withTiming(1, TIMING_CONFIG) + gapValue.value = withTiming(PANEL_GAP, TIMING_CONFIG) } + const handleSelect = useCallback((id: PatternTypeId) => { + setSelectedId((prev) => (prev === id ? null : id)) + }, []) + return ( @@ -136,25 +163,26 @@ export function TopPatternsSection({ patternWeights }: Props) { })} - {/* - Measurement-only view: positioned off-screen so it doesn't affect layout, - but unconstrained by the Animated.View height so onLayout reports the - true content height on native. - */} - + + + {needsMeasure && displayed ? ( { - if (!displayed) return - setContentHeight(e.nativeEvent.layout.height) + style={{ + position: "absolute", + opacity: 0, + left: 0, + right: 0, + pointerEvents: "none", }} > - {displayed ? : null} + handleLayout(e.nativeEvent.layout.height)} + > + + - + ) : null} - + {displayed ? : null} From 6940a83bb61226fb0480fb4e18bae09f5fd28bd7 Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 18:31:41 +1200 Subject: [PATCH 11/13] fix: resolve Storybook infinite loading and add full-width web layout Replace AsyncStorage import in Storybook entry with a direct globalThis.localStorage adapter to avoid the merge-options ESM/CJS interop crash that prevented Storybook from loading on web. Also add a CSS override so the mobile-phone frame (max-width: 430px) is removed when viewing Storybook in a desktop browser. --- apps/mobile/.storybook/index.ts | 16 ++++++++++++---- apps/mobile/.storybook/preview.tsx | 1 + apps/mobile/.storybook/storybook-web.css | 12 ++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/.storybook/storybook-web.css diff --git a/apps/mobile/.storybook/index.ts b/apps/mobile/.storybook/index.ts index 9eb4fba..7940d65 100644 --- a/apps/mobile/.storybook/index.ts +++ b/apps/mobile/.storybook/index.ts @@ -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 {} + }, }, }) diff --git a/apps/mobile/.storybook/preview.tsx b/apps/mobile/.storybook/preview.tsx index b1b2372..940d1ec 100644 --- a/apps/mobile/.storybook/preview.tsx +++ b/apps/mobile/.storybook/preview.tsx @@ -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: [ diff --git a/apps/mobile/.storybook/storybook-web.css b/apps/mobile/.storybook/storybook-web.css new file mode 100644 index 0000000..96c89ad --- /dev/null +++ b/apps/mobile/.storybook/storybook-web.css @@ -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; + } +} From cc0812c27277405d20f6f601647e4f32a1cc236a Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 21:37:40 +1200 Subject: [PATCH 12/13] chore: add missing Storybook stories for pattern and portable-text components --- .../pattern-match-accordion.stories.tsx | 40 +++++++++++ .../patterns/pattern-ring.stories.tsx | 59 +++++++++++++++ .../patterns/top-patterns-section.stories.tsx | 52 ++++++++++++++ .../components/ui/portable-text.stories.tsx | 71 +++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 apps/mobile/components/patterns/pattern-match-accordion.stories.tsx create mode 100644 apps/mobile/components/patterns/pattern-ring.stories.tsx create mode 100644 apps/mobile/components/patterns/top-patterns-section.stories.tsx create mode 100644 apps/mobile/components/ui/portable-text.stories.tsx diff --git a/apps/mobile/components/patterns/pattern-match-accordion.stories.tsx b/apps/mobile/components/patterns/pattern-match-accordion.stories.tsx new file mode 100644 index 0000000..e2ac789 --- /dev/null +++ b/apps/mobile/components/patterns/pattern-match-accordion.stories.tsx @@ -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 + +export default meta + +type Story = StoryObj + +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.", + }, +} diff --git a/apps/mobile/components/patterns/pattern-ring.stories.tsx b/apps/mobile/components/patterns/pattern-ring.stories.tsx new file mode 100644 index 0000000..f0602b8 --- /dev/null +++ b/apps/mobile/components/patterns/pattern-ring.stories.tsx @@ -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 + +export default meta + +type Story = StoryObj + +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: () => ( + + + + + + ), +} diff --git a/apps/mobile/components/patterns/top-patterns-section.stories.tsx b/apps/mobile/components/patterns/top-patterns-section.stories.tsx new file mode 100644 index 0000000..ecfff92 --- /dev/null +++ b/apps/mobile/components/patterns/top-patterns-section.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-native" + +import type { UserPatternWeights } from "@touchgrass/types" +import { PATTERN_TYPES } from "@touchgrass/types/constants" + +import { TopPatternsSection } from "./top-patterns-section" + +function makeWeights( + overrides: Partial, +): UserPatternWeights { + const base: Partial = {} + for (const p of PATTERN_TYPES) base[p.id] = 0.1 + return { ...base, ...overrides } as UserPatternWeights +} + +const meta = { + title: "Patterns/TopPatternsSection", + component: TopPatternsSection, + args: { + patternWeights: makeWeights({ + "4-HH": 0.95, // Enchanting Visionary + "9-HH": 0.85, // Enlightened Traditionalist + "1-HH": 0.75, // Personable + }), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const CloseMatches: Story = { + args: { + patternWeights: makeWeights({ + "2-HH": 0.62, // Enterprising + "3-HH": 0.6, // Inquisitive-Achiever + "5-HH": 0.58, // Compassionate-Idealist + }), + }, +} + +export const LowConfidence: Story = { + args: { + patternWeights: makeWeights({ + "1-LL": 0.22, // Independent-Distant + "7-LL": 0.2, // tied lower group + "10-LL": 0.18, + }), + }, +} diff --git a/apps/mobile/components/ui/portable-text.stories.tsx b/apps/mobile/components/ui/portable-text.stories.tsx new file mode 100644 index 0000000..e8f3e22 --- /dev/null +++ b/apps/mobile/components/ui/portable-text.stories.tsx @@ -0,0 +1,71 @@ +import type { PortableTextBlock } from "@portabletext/types" +import type { Meta, StoryObj } from "@storybook/react-native" + +import { PortableText } from "./portable-text" + +function paragraph( + key: string, + spans: { text: string; marks?: string[] }[], +): PortableTextBlock { + return { + _type: "block", + _key: key, + children: spans.map((span, idx) => ({ + _type: "span", + _key: `${key}-${idx}`, + text: span.text, + marks: span.marks ?? [], + })), + } as PortableTextBlock +} + +const blocks: PortableTextBlock[] = [ + paragraph("a", [ + { text: "Sourdough baking " }, + { text: "rewards patience", marks: ["strong"] }, + { text: " more than precision. Start with a healthy starter and a " }, + { text: "long, slow ferment", marks: ["em"] }, + { text: "." }, + ]), + paragraph("b", [ + { + text: "Your first loaf will not be perfect, and that is exactly the point — each bake teaches you something about timing and feel.", + }, + ]), +] + +const meta = { + title: "UI/PortableText", + component: PortableText, + args: { + blocks, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const SingleParagraph: Story = { + args: { + blocks: [ + paragraph("only", [ + { text: "A short, single paragraph of body copy." }, + ]), + ], + }, +} + +export const DarkerText: Story = { + args: { + paragraphClassName: "mb-4 leading-relaxed text-gray-900", + }, +} + +export const Empty: Story = { + args: { + blocks: [], + }, +} From 0f58c039c2f5d443ac926213e835c168799b4cfa Mon Sep 17 00:00:00 2001 From: Richard Han Date: Tue, 2 Jun 2026 21:38:01 +1200 Subject: [PATCH 13/13] docs: add Storybook coverage and tighten TopPatternsSection animation note in CHANGELOG 1.2.7 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2393e31..cb26eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ Fixes a native-only bug where the pattern detail card failed to expand in Expo G ### Mobile (`apps/mobile`) -- Fixed `TopPatternsSection` expand animation on native: `onLayout` inside a `height: 0` `Animated.View` reports `h = 0` due to Yoga constraining child layout to the parent's explicit height. Measurement is now taken from an absolutely-positioned, opacity-0, non-interactive clone outside the animated container so the true content height is always available before the animation fires. +- 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`.