diff --git a/backend/package-lock.json b/backend/package-lock.json
index 0147cea..371105f 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1903,9 +1903,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
diff --git a/backend/package.json b/backend/package.json
index a764fe2..a53a6cd 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -54,5 +54,8 @@
"ts-node": "^10.9.2",
"typescript": "^5.7.2",
"vitest": "^4.0.18"
+ },
+ "overrides": {
+ "ip-address": "^10.4.0"
}
}
diff --git a/backend/prisma/activityCatalog.ts b/backend/prisma/activityCatalog.ts
new file mode 100644
index 0000000..e7dab4f
--- /dev/null
+++ b/backend/prisma/activityCatalog.ts
@@ -0,0 +1,56 @@
+export const ACTIVITY_CATEGORIES = [
+ 'Academic / Study',
+ 'Sports',
+ 'Fitness & Wellness',
+ 'Social & Events',
+ 'Gaming',
+ 'Volunteering',
+ 'Food',
+ 'Music & Arts',
+] as const;
+
+export const ACTIVITY_CATALOG = [
+ { id: '72d0fe6d-53f3-46f3-96ef-036e4abb2e1c', title: 'Study group', description: 'Meet classmates for a focused study session. Put the class or subject in the pod title.', category: 'Academic / Study', defaultLocation: 'Thompson Library', artworkKey: 'study-group', sortOrder: 1 },
+ { id: 'e39c7e9d-beb9-4a97-b6a8-23e67c870747', title: 'Reading / book club', description: 'Read together or meet to discuss a book, article, or series.', category: 'Academic / Study', defaultLocation: 'Thompson Library', artworkKey: 'reading-book-club', sortOrder: 2 },
+ { id: '1d155e8f-1f8d-4c6d-a2f6-835c6326ff70', title: 'Pickup basketball', description: 'Start a casual basketball game for any skill level.', category: 'Sports', defaultLocation: 'RPAC', artworkKey: 'pickup-basketball', sortOrder: 3 },
+ { id: 'cbf6ccf6-8865-498d-9b2a-8a7299f6207d', title: 'Pickup soccer', description: 'Get a group together for a casual soccer game or kickaround.', category: 'Sports', defaultLocation: 'Lincoln Tower Fields', artworkKey: 'pickup-soccer', sortOrder: 4 },
+ { id: '8092ae28-02aa-4cd3-98ca-0546eaf4ed6e', title: 'Pickup volleyball', description: 'Play a casual indoor, grass, or sand volleyball game.', category: 'Sports', defaultLocation: 'RPAC', artworkKey: 'pickup-volleyball', sortOrder: 5 },
+ { id: '679b4e61-84d7-40c5-bdf3-8d5034732800', title: 'Tennis / pickleball', description: 'Find a partner or group for tennis or pickleball.', category: 'Sports', defaultLocation: 'RPAC', artworkKey: 'tennis-pickleball', sortOrder: 6 },
+ { id: '28b42d34-e447-4abf-bbed-8d69564ac345', title: 'Running / jogging', description: 'Meet for a run or jog at the pace named in the pod title.', category: 'Sports', defaultLocation: 'The Oval', artworkKey: 'running-jogging', sortOrder: 7 },
+ { id: '9728004d-2e19-45bb-884b-25ac34f3fa85', title: 'Swimming', description: 'Swim laps, train, or enjoy the pool with other students.', category: 'Sports', defaultLocation: 'Aquatic Center', artworkKey: 'swimming', sortOrder: 8 },
+ { id: 'cd08f480-6715-4f50-8a02-1c6bed0f1a6a', title: 'Golf', description: 'Find people for a round, the driving range, or putting practice.', category: 'Sports', defaultLocation: 'OSU Golf Club', artworkKey: 'golf', sortOrder: 9 },
+ { id: 'f22d21db-de3d-4e34-9abb-4ab6081c30c0', title: 'Bowling', description: 'Meet up for a casual game or a full night at the lanes.', category: 'Sports', defaultLocation: 'Columbus Square Bowling Palace', artworkKey: 'bowling', sortOrder: 10 },
+ { id: '9349d2fa-681b-4362-abfb-1184e0b3b56e', title: 'Frisbee', description: 'Toss a disc or organize a casual ultimate game.', category: 'Sports', defaultLocation: 'The Oval', artworkKey: 'frisbee', sortOrder: 11 },
+ { id: 'a54f0bb9-f422-4264-ad52-97b65859b98d', title: 'Gym partner', description: 'Find a partner for lifting, cardio, or a shared workout plan.', category: 'Fitness & Wellness', defaultLocation: 'RPAC', artworkKey: 'gym-partner', sortOrder: 12 },
+ { id: '685728ff-703c-4069-92e9-c960254aa849', title: 'Yoga', description: 'Practice yoga together at the style and level named in the pod title.', category: 'Fitness & Wellness', defaultLocation: 'RPAC – Mind Body Studio', artworkKey: 'yoga', sortOrder: 13 },
+ { id: '61eab34d-b1ed-47b8-9717-72614dab36a1', title: 'Meditation', description: 'Meet for guided or quiet meditation and reflection.', category: 'Fitness & Wellness', defaultLocation: 'RPAC – Wellness Space', artworkKey: 'meditation', sortOrder: 14 },
+ { id: '3e6d1eb8-6836-4012-8f3b-d178a743d5dd', title: 'Nature walk', description: 'Take a relaxed walk through a trail, park, or green space.', category: 'Fitness & Wellness', defaultLocation: 'Olentangy Trail', artworkKey: 'nature-walk', sortOrder: 15 },
+ { id: 'd90ef6da-2d3f-42b8-9b77-e475de983a2b', title: 'Casual hangout', description: 'Make a low-pressure plan to meet, talk, and spend time together.', category: 'Social & Events', defaultLocation: 'Ohio Union', artworkKey: 'casual-hangout', sortOrder: 16 },
+ { id: '7e3da896-a1c5-49b2-b035-590ddca48b0a', title: 'Movie / watch party', description: 'Watch a movie, show, game, or special event together.', category: 'Social & Events', defaultLocation: 'Ohio Union', artworkKey: 'movie-watch-party', sortOrder: 17 },
+ { id: 'd202b3ec-71f6-4e51-a80d-f2cddcdef4bd', title: 'Go to an event', description: 'Find people to join you at a campus event, show, festival, or game.', category: 'Social & Events', defaultLocation: 'Ohio Union', artworkKey: 'go-to-event', sortOrder: 18 },
+ { id: '383ecbb2-64d5-4b10-ad4f-5ca933449b9f', title: 'Video games', description: 'Play console or PC games together, online or in person.', category: 'Gaming', defaultLocation: 'Ohio Union – Esports Arena', artworkKey: 'video-games', sortOrder: 19 },
+ { id: '6b14c650-68ff-4fa5-b86f-9e8fcfde8b80', title: 'Board games', description: 'Meet for a board game session from party games to strategy games.', category: 'Gaming', defaultLocation: 'Ohio Union – Game Room', artworkKey: 'board-games', sortOrder: 20 },
+ { id: '1661a3f8-ba69-4df3-8477-3b1d2e6ba146', title: 'Card games', description: 'Play traditional, party, or trading card games together.', category: 'Gaming', defaultLocation: 'Ohio Union – Game Room', artworkKey: 'card-games', sortOrder: 21 },
+ { id: 'e927a37b-5f02-4683-9b8a-645707528aad', title: 'Tabletop RPGs', description: 'Start or join a tabletop role-playing session, including D&D and other systems.', category: 'Gaming', defaultLocation: 'Ohio Union – Game Room', artworkKey: 'tabletop-rpgs', sortOrder: 22 },
+ { id: 'a39d73d8-1450-4173-974f-80d977453c42', title: 'Mobile games', description: 'Play multiplayer mobile games together in person or online.', category: 'Gaming', defaultLocation: 'Ohio Union', artworkKey: 'mobile-games', sortOrder: 23 },
+ { id: 'effe238b-8544-44c8-9272-ba1d01ee8300', title: 'Trivia', description: 'Form a team for trivia or host a casual question night.', category: 'Gaming', defaultLocation: 'Ohio Union', artworkKey: 'trivia', sortOrder: 24 },
+ { id: '36291822-ce76-4cb5-85bf-aa6c2132cfb7', title: 'Chess', description: 'Play casual or competitive chess at any experience level.', category: 'Gaming', defaultLocation: 'Ohio Union – Game Room', artworkKey: 'chess', sortOrder: 25 },
+ { id: '82e17e62-90fd-4cd1-b1a2-900f78ec42b8', title: 'Food bank volunteering', description: 'Volunteer together sorting, packing, or distributing food.', category: 'Volunteering', defaultLocation: 'Ohio Union – Service Hub', artworkKey: 'food-bank-volunteering', sortOrder: 26 },
+ { id: '66db8b24-0096-448e-b694-0deed5a37788', title: 'Animal shelter volunteering', description: 'Volunteer together to support shelter animals and staff.', category: 'Volunteering', defaultLocation: 'Ohio Union – Service Hub', artworkKey: 'animal-shelter-volunteering', sortOrder: 27 },
+ { id: 'a765a5eb-586c-4723-8981-0e0c6f6ab73d', title: 'Medical center volunteering', description: 'Coordinate an approved volunteer opportunity at a medical center.', category: 'Volunteering', defaultLocation: 'Wexner Medical Center', artworkKey: 'medical-center-volunteering', sortOrder: 28 },
+ { id: 'bcd2c629-0871-4e9b-bc5f-749c14fc5178', title: 'Other volunteering', description: 'Organize another service opportunity and name it in the pod title.', category: 'Volunteering', defaultLocation: 'Ohio Union – Service Hub', artworkKey: 'other-volunteering', sortOrder: 29 },
+ { id: '56a9cab0-330c-489b-b77a-792b433e29df', title: 'Cook together', description: 'Plan and cook a meal together in a shared kitchen.', category: 'Food', defaultLocation: 'Residence Hall – Community Kitchen', artworkKey: 'cook-together', sortOrder: 30 },
+ { id: '3f8d1ccf-b120-4275-9db8-238e082fe312', title: 'Eat at a restaurant', description: 'Choose a restaurant and meet for a meal together.', category: 'Food', defaultLocation: 'High Street', artworkKey: 'eat-at-restaurant', sortOrder: 31 },
+ { id: '71578f3e-2d48-4707-a36e-583d5fe4caa9', title: 'Eat at a dining hall', description: 'Meet for breakfast, lunch, or dinner at a campus dining hall.', category: 'Food', defaultLocation: 'Traditions at Scott', artworkKey: 'eat-at-dining-hall', sortOrder: 32 },
+ { id: 'fb5a4165-6947-483d-a2d7-9b1627c58c9d', title: 'Grab coffee or tea', description: 'Meet at a café for coffee, tea, or conversation.', category: 'Food', defaultLocation: 'Berry Café', artworkKey: 'grab-coffee-tea', sortOrder: 33 },
+ { id: '6bf75ec3-0f5f-412c-b945-3292ef362b57', title: 'Bake something', description: 'Bake a dessert, bread, or snack together.', category: 'Food', defaultLocation: 'Residence Hall – Community Kitchen', artworkKey: 'bake-something', sortOrder: 34 },
+ { id: '7041a05d-2346-4d19-bc24-7909e1b53dd1', title: 'Picnic', description: 'Bring food or snacks and meet for a picnic outdoors.', category: 'Food', defaultLocation: 'The Oval', artworkKey: 'picnic', sortOrder: 35 },
+ { id: 'c2132a3d-2c08-4668-bb42-e4f9bdfaba63', title: 'Draw / paint together', description: 'Bring art supplies and make visual art together at any skill level.', category: 'Music & Arts', defaultLocation: 'Hopkins Hall', artworkKey: 'draw-paint', sortOrder: 36 },
+ { id: 'dd90d5bc-46d1-47e2-97b3-47a85978ff0c', title: 'Crafting', description: 'Meet to make, build, sew, or craft something together.', category: 'Music & Arts', defaultLocation: 'Ohio Union', artworkKey: 'crafting', sortOrder: 37 },
+ { id: '5749bd6e-295d-4a8e-b9ef-7ab225c18638', title: 'Creative writing', description: 'Write, share, or workshop stories, poetry, and other creative work.', category: 'Music & Arts', defaultLocation: 'Denney Hall', artworkKey: 'creative-writing', sortOrder: 38 },
+ { id: 'fa8b556c-ced4-4722-9dbe-1ded282eb2a7', title: 'Play / practice music', description: 'Practice, jam, or make music together with instruments or voice.', category: 'Music & Arts', defaultLocation: 'Hughes Hall', artworkKey: 'play-practice-music', sortOrder: 39 },
+ { id: 'c7c364fd-1583-4788-9747-f4323437789a', title: 'Photography', description: 'Take photos together, practice techniques, or go on a photo walk.', category: 'Music & Arts', defaultLocation: 'The Oval', artworkKey: 'photography', sortOrder: 40 },
+ { id: '9be77ee1-8b32-424b-b32d-e9885ff2af92', title: 'Dance', description: 'Practice, learn, or freestyle a dance style together.', category: 'Music & Arts', defaultLocation: 'Sullivant Hall', artworkKey: 'dance', sortOrder: 41 },
+] as const;
+
+export type ActivityCatalogEntry = (typeof ACTIVITY_CATALOG)[number];
diff --git a/backend/prisma/cleanup-screenshots.ts b/backend/prisma/cleanup-screenshots.ts
index 6aeba7f..3afbd82 100644
--- a/backend/prisma/cleanup-screenshots.ts
+++ b/backend/prisma/cleanup-screenshots.ts
@@ -71,10 +71,8 @@ async function main() {
const threads = await prisma.directMessageThread.deleteMany(inIds(m.dmThreads));
const friendships = await prisma.friendship.deleteMany(inIds(m.friendships));
const friendRequests = await prisma.friendRequest.deleteMany(inIds(m.friendRequests ?? []));
- // Pod.activityId is onDelete: Restrict, so only delete manifest activities
- // that no remaining (real) pod still references. The seed also reuses
- // pre-existing activities by title, so some manifest activities may be
- // shared with the main seed — skipping in-use ones keeps those intact.
+ // Current screenshot seeds never create activities. This remains for cleanup
+ // of older manifests and only removes explicitly recorded, now-unreferenced rows.
const activities = await prisma.activity.deleteMany({
where: { id: { in: m.activities }, pods: { none: {} } },
});
diff --git a/backend/prisma/migrations/20260804010000_activity_catalog_v2/migration.sql b/backend/prisma/migrations/20260804010000_activity_catalog_v2/migration.sql
new file mode 100644
index 0000000..14e5182
--- /dev/null
+++ b/backend/prisma/migrations/20260804010000_activity_catalog_v2/migration.sql
@@ -0,0 +1,54 @@
+ALTER TABLE "Activity"
+ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
+ADD COLUMN "artworkKey" TEXT,
+ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
+
+CREATE UNIQUE INDEX "Activity_artworkKey_key" ON "Activity"("artworkKey");
+CREATE INDEX "Activity_isActive_category_sortOrder_idx" ON "Activity"("isActive", "category", "sortOrder");
+
+-- Preserve legacy activities for existing pods and analytics, but retire them
+-- from discovery and new pod creation.
+UPDATE "Activity" SET "isActive" = false;
+
+INSERT INTO "Activity" ("id", "title", "description", "category", "defaultLocation", "artworkKey", "sortOrder", "isActive") VALUES
+('72d0fe6d-53f3-46f3-96ef-036e4abb2e1c', 'Study group', 'Meet classmates for a focused study session. Put the class or subject in the pod title.', 'Academic / Study', 'Thompson Library', 'study-group', 1, TRUE),
+('e39c7e9d-beb9-4a97-b6a8-23e67c870747', 'Reading / book club', 'Read together or meet to discuss a book, article, or series.', 'Academic / Study', 'Thompson Library', 'reading-book-club', 2, TRUE),
+('1d155e8f-1f8d-4c6d-a2f6-835c6326ff70', 'Pickup basketball', 'Start a casual basketball game for any skill level.', 'Sports', 'RPAC', 'pickup-basketball', 3, TRUE),
+('cbf6ccf6-8865-498d-9b2a-8a7299f6207d', 'Pickup soccer', 'Get a group together for a casual soccer game or kickaround.', 'Sports', 'Lincoln Tower Fields', 'pickup-soccer', 4, TRUE),
+('8092ae28-02aa-4cd3-98ca-0546eaf4ed6e', 'Pickup volleyball', 'Play a casual indoor, grass, or sand volleyball game.', 'Sports', 'RPAC', 'pickup-volleyball', 5, TRUE),
+('679b4e61-84d7-40c5-bdf3-8d5034732800', 'Tennis / pickleball', 'Find a partner or group for tennis or pickleball.', 'Sports', 'RPAC', 'tennis-pickleball', 6, TRUE),
+('28b42d34-e447-4abf-bbed-8d69564ac345', 'Running / jogging', 'Meet for a run or jog at the pace named in the pod title.', 'Sports', 'The Oval', 'running-jogging', 7, TRUE),
+('9728004d-2e19-45bb-884b-25ac34f3fa85', 'Swimming', 'Swim laps, train, or enjoy the pool with other students.', 'Sports', 'Aquatic Center', 'swimming', 8, TRUE),
+('cd08f480-6715-4f50-8a02-1c6bed0f1a6a', 'Golf', 'Find people for a round, the driving range, or putting practice.', 'Sports', 'OSU Golf Club', 'golf', 9, TRUE),
+('f22d21db-de3d-4e34-9abb-4ab6081c30c0', 'Bowling', 'Meet up for a casual game or a full night at the lanes.', 'Sports', 'Columbus Square Bowling Palace', 'bowling', 10, TRUE),
+('9349d2fa-681b-4362-abfb-1184e0b3b56e', 'Frisbee', 'Toss a disc or organize a casual ultimate game.', 'Sports', 'The Oval', 'frisbee', 11, TRUE),
+('a54f0bb9-f422-4264-ad52-97b65859b98d', 'Gym partner', 'Find a partner for lifting, cardio, or a shared workout plan.', 'Fitness & Wellness', 'RPAC', 'gym-partner', 12, TRUE),
+('685728ff-703c-4069-92e9-c960254aa849', 'Yoga', 'Practice yoga together at the style and level named in the pod title.', 'Fitness & Wellness', 'RPAC – Mind Body Studio', 'yoga', 13, TRUE),
+('61eab34d-b1ed-47b8-9717-72614dab36a1', 'Meditation', 'Meet for guided or quiet meditation and reflection.', 'Fitness & Wellness', 'RPAC – Wellness Space', 'meditation', 14, TRUE),
+('3e6d1eb8-6836-4012-8f3b-d178a743d5dd', 'Nature walk', 'Take a relaxed walk through a trail, park, or green space.', 'Fitness & Wellness', 'Olentangy Trail', 'nature-walk', 15, TRUE),
+('d90ef6da-2d3f-42b8-9b77-e475de983a2b', 'Casual hangout', 'Make a low-pressure plan to meet, talk, and spend time together.', 'Social & Events', 'Ohio Union', 'casual-hangout', 16, TRUE),
+('7e3da896-a1c5-49b2-b035-590ddca48b0a', 'Movie / watch party', 'Watch a movie, show, game, or special event together.', 'Social & Events', 'Ohio Union', 'movie-watch-party', 17, TRUE),
+('d202b3ec-71f6-4e51-a80d-f2cddcdef4bd', 'Go to an event', 'Find people to join you at a campus event, show, festival, or game.', 'Social & Events', 'Ohio Union', 'go-to-event', 18, TRUE),
+('383ecbb2-64d5-4b10-ad4f-5ca933449b9f', 'Video games', 'Play console or PC games together, online or in person.', 'Gaming', 'Ohio Union – Esports Arena', 'video-games', 19, TRUE),
+('6b14c650-68ff-4fa5-b86f-9e8fcfde8b80', 'Board games', 'Meet for a board game session from party games to strategy games.', 'Gaming', 'Ohio Union – Game Room', 'board-games', 20, TRUE),
+('1661a3f8-ba69-4df3-8477-3b1d2e6ba146', 'Card games', 'Play traditional, party, or trading card games together.', 'Gaming', 'Ohio Union – Game Room', 'card-games', 21, TRUE),
+('e927a37b-5f02-4683-9b8a-645707528aad', 'Tabletop RPGs', 'Start or join a tabletop role-playing session, including D&D and other systems.', 'Gaming', 'Ohio Union – Game Room', 'tabletop-rpgs', 22, TRUE),
+('a39d73d8-1450-4173-974f-80d977453c42', 'Mobile games', 'Play multiplayer mobile games together in person or online.', 'Gaming', 'Ohio Union', 'mobile-games', 23, TRUE),
+('effe238b-8544-44c8-9272-ba1d01ee8300', 'Trivia', 'Form a team for trivia or host a casual question night.', 'Gaming', 'Ohio Union', 'trivia', 24, TRUE),
+('36291822-ce76-4cb5-85bf-aa6c2132cfb7', 'Chess', 'Play casual or competitive chess at any experience level.', 'Gaming', 'Ohio Union – Game Room', 'chess', 25, TRUE),
+('82e17e62-90fd-4cd1-b1a2-900f78ec42b8', 'Food bank volunteering', 'Volunteer together sorting, packing, or distributing food.', 'Volunteering', 'Ohio Union – Service Hub', 'food-bank-volunteering', 26, TRUE),
+('66db8b24-0096-448e-b694-0deed5a37788', 'Animal shelter volunteering', 'Volunteer together to support shelter animals and staff.', 'Volunteering', 'Ohio Union – Service Hub', 'animal-shelter-volunteering', 27, TRUE),
+('a765a5eb-586c-4723-8981-0e0c6f6ab73d', 'Medical center volunteering', 'Coordinate an approved volunteer opportunity at a medical center.', 'Volunteering', 'Wexner Medical Center', 'medical-center-volunteering', 28, TRUE),
+('bcd2c629-0871-4e9b-bc5f-749c14fc5178', 'Other volunteering', 'Organize another service opportunity and name it in the pod title.', 'Volunteering', 'Ohio Union – Service Hub', 'other-volunteering', 29, TRUE),
+('56a9cab0-330c-489b-b77a-792b433e29df', 'Cook together', 'Plan and cook a meal together in a shared kitchen.', 'Food', 'Residence Hall – Community Kitchen', 'cook-together', 30, TRUE),
+('3f8d1ccf-b120-4275-9db8-238e082fe312', 'Eat at a restaurant', 'Choose a restaurant and meet for a meal together.', 'Food', 'High Street', 'eat-at-restaurant', 31, TRUE),
+('71578f3e-2d48-4707-a36e-583d5fe4caa9', 'Eat at a dining hall', 'Meet for breakfast, lunch, or dinner at a campus dining hall.', 'Food', 'Traditions at Scott', 'eat-at-dining-hall', 32, TRUE),
+('fb5a4165-6947-483d-a2d7-9b1627c58c9d', 'Grab coffee or tea', 'Meet at a café for coffee, tea, or conversation.', 'Food', 'Berry Café', 'grab-coffee-tea', 33, TRUE),
+('6bf75ec3-0f5f-412c-b945-3292ef362b57', 'Bake something', 'Bake a dessert, bread, or snack together.', 'Food', 'Residence Hall – Community Kitchen', 'bake-something', 34, TRUE),
+('7041a05d-2346-4d19-bc24-7909e1b53dd1', 'Picnic', 'Bring food or snacks and meet for a picnic outdoors.', 'Food', 'The Oval', 'picnic', 35, TRUE),
+('c2132a3d-2c08-4668-bb42-e4f9bdfaba63', 'Draw / paint together', 'Bring art supplies and make visual art together at any skill level.', 'Music & Arts', 'Hopkins Hall', 'draw-paint', 36, TRUE),
+('dd90d5bc-46d1-47e2-97b3-47a85978ff0c', 'Crafting', 'Meet to make, build, sew, or craft something together.', 'Music & Arts', 'Ohio Union', 'crafting', 37, TRUE),
+('5749bd6e-295d-4a8e-b9ef-7ab225c18638', 'Creative writing', 'Write, share, or workshop stories, poetry, and other creative work.', 'Music & Arts', 'Denney Hall', 'creative-writing', 38, TRUE),
+('fa8b556c-ced4-4722-9dbe-1ded282eb2a7', 'Play / practice music', 'Practice, jam, or make music together with instruments or voice.', 'Music & Arts', 'Hughes Hall', 'play-practice-music', 39, TRUE),
+('c7c364fd-1583-4788-9747-f4323437789a', 'Photography', 'Take photos together, practice techniques, or go on a photo walk.', 'Music & Arts', 'The Oval', 'photography', 40, TRUE),
+('9be77ee1-8b32-424b-b32d-e9885ff2af92', 'Dance', 'Practice, learn, or freestyle a dance style together.', 'Music & Arts', 'Sullivant Hall', 'dance', 41, TRUE);
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 852b456..8e5ebb7 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -107,10 +107,15 @@ model Activity {
description String
category String @default("Social")
defaultLocation String
+ isActive Boolean @default(true)
+ artworkKey String? @unique
+ sortOrder Int @default(0)
createdAt DateTime @default(now())
pods Pod[]
demands PodDemand[]
+
+ @@index([isActive, category, sortOrder])
}
model ActivityRequest {
@@ -504,7 +509,7 @@ model Club {
// --- Club lifecycle: four orthogonal axes ---
isDiscoverable Boolean @default(false)
- discoveryPreference String @default("CAMPUS") // CAMPUS | INVITE_ONLY
+ discoveryPreference String @default("CAMPUS") // CAMPUS | INVITE_ONLY
verification String @default("UNVERIFIED") // UNVERIFIED | PENDING_REVIEW | VERIFIED
joinPolicy String @default("OPEN") // OPEN | REQUEST | APPLICATION | INVITE_ONLY
status String @default("ACTIVE") // ACTIVE | SUSPENDED | ARCHIVED
diff --git a/backend/prisma/seed-launch.ts b/backend/prisma/seed-launch.ts
index db0f7f7..5cf8a21 100644
--- a/backend/prisma/seed-launch.ts
+++ b/backend/prisma/seed-launch.ts
@@ -4,13 +4,13 @@ import { getLocationsForCategory } from '../src/config/locations';
const prisma = new PrismaClient();
-const LAUNCH_ACTIVITY_TITLES = [
- 'Morning Coffee Walk',
- 'Study Group Sprint',
- 'Basketball Pickup Game',
- 'Board Game Night',
- 'Sketch & Chat',
- 'Campus Cleanup',
+const LAUNCH_PLANS = [
+ { activityTitle: 'Grab coffee or tea', podTitle: 'Coffee after class' },
+ { activityTitle: 'Study group', podTitle: 'Midterm study sprint' },
+ { activityTitle: 'Pickup basketball', podTitle: '3v3 at the RPAC' },
+ { activityTitle: 'Board games', podTitle: 'Catan at the Union' },
+ { activityTitle: 'Draw / paint together', podTitle: 'Watercolor on the Oval' },
+ { activityTitle: 'Other volunteering', podTitle: 'Campus cleanup crew' },
];
function ambassadorEmails(): string[] {
@@ -46,15 +46,15 @@ async function main() {
}
const activities = await prisma.activity.findMany({
- where: { title: { in: LAUNCH_ACTIVITY_TITLES } },
+ where: { title: { in: LAUNCH_PLANS.map((plan) => plan.activityTitle) }, isActive: true },
});
const activityByTitle = new Map(activities.map((activity) => [activity.title, activity]));
let created = 0;
- for (const [index, title] of LAUNCH_ACTIVITY_TITLES.entries()) {
- const activity = activityByTitle.get(title);
+ for (const [index, plan] of LAUNCH_PLANS.entries()) {
+ const activity = activityByTitle.get(plan.activityTitle);
if (!activity) {
- console.log(`Skipping "${title}" because the activity does not exist.`);
+ console.log(`Skipping "${plan.activityTitle}" because the activity does not exist.`);
continue;
}
@@ -62,6 +62,7 @@ async function main() {
const existing = await prisma.pod.findFirst({
where: {
activityId: activity.id,
+ title: plan.podTitle,
creatorId: { in: ambassadors.map((user) => user.id) },
meetupTime: { gt: new Date() },
status: { in: ['FORMING', 'LOCKED'] },
diff --git a/backend/prisma/seed-screenshots.ts b/backend/prisma/seed-screenshots.ts
index f6265c8..bc684eb 100644
--- a/backend/prisma/seed-screenshots.ts
+++ b/backend/prisma/seed-screenshots.ts
@@ -90,13 +90,13 @@ const INTEREST_POOL = ['Coffee', 'Running', 'Music', 'Coding', 'Soccer', 'Photog
const POOL_SIZE = 50;
-const ACTIVITIES = [
- { title: 'Frisbee on the Oval', description: 'Casual frisbee toss on the Oval. No experience needed.', category: 'Sports & Fitness', defaultLocation: 'The Oval' },
- { title: 'Trivia Night', description: 'Form a team and battle it out at weekly trivia. No expertise required.', category: 'Social', defaultLocation: 'The Lounge – High Street' },
- { title: 'Library Study Sprint', description: 'Focused co-working session. Bring your hardest assignment.', category: 'Study', defaultLocation: 'Thompson Library – 11th Floor' },
- { title: 'Boba Run', description: 'Walk to grab boba off campus. Try a new flavor every time.', category: 'Food & Drink', defaultLocation: 'High Street' },
- { title: 'Sunset Hammock Hang', description: 'String up a hammock and watch the sunset with good company.', category: 'Outdoors', defaultLocation: 'The Oval' },
- { title: 'Intramural Soccer', description: 'Friendly pickup soccer. All skill levels welcome.', category: 'Sports & Fitness', defaultLocation: 'Lincoln Tower Fields' },
+const SCREENSHOT_ACTIVITY_KEYS = [
+ 'frisbee',
+ 'trivia',
+ 'study-group',
+ 'grab-coffee-tea',
+ 'casual-hangout',
+ 'pickup-soccer',
];
const POD_CHAT = [
@@ -172,15 +172,20 @@ async function main() {
console.log(`[seed] created ${users.length} demo users`);
// --- Activities ---
- const activityIds: string[] = [];
- for (const a of ACTIVITIES) {
- // Reuse an existing activity with the same title so re-running this seed
- // (or running it alongside the main seed) doesn't create duplicates.
- const existing = await prisma.activity.findFirst({ where: { title: a.title } });
- const act = existing ?? (await prisma.activity.create({ data: a }));
- manifest.activities.push(act.id);
- activityIds.push(act.id);
- }
+ const screenshotActivities = await prisma.activity.findMany({
+ where: { artworkKey: { in: SCREENSHOT_ACTIVITY_KEYS }, isActive: true },
+ orderBy: { sortOrder: 'asc' },
+ });
+ const activityByKey = new Map(
+ screenshotActivities.map((activity) => [activity.artworkKey, activity]),
+ );
+ const activityIds = SCREENSHOT_ACTIVITY_KEYS.map((key) => {
+ const activity = activityByKey.get(key);
+ if (!activity) {
+ throw new Error(`Missing active catalog activity "${key}". Deploy migrations before seeding screenshots.`);
+ }
+ return activity.id;
+ });
// --- Pods (first one gets a full chat thread) ---
// Fallback anchor: center of the Oval. Real pins come from the named-location
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index fe59e02..9d83e01 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -1,331 +1,11 @@
///
import { PrismaClient } from '@prisma/client';
+import { ACTIVITY_CATALOG } from './activityCatalog';
const prisma = new PrismaClient();
-const activities = [
- // Sports & Fitness
- {
- title: 'Frisbee on the Lawn',
- description: 'Casual frisbee toss on the Oval. No experience needed.',
- category: 'Sports & Fitness',
- defaultLocation: 'The Oval',
- },
- {
- title: 'Go for a Jog',
- description: 'Easy-paced jog around campus. All levels welcome.',
- category: 'Sports & Fitness',
- defaultLocation: 'Jesse Owens Memorial Stadium – Track',
- },
- {
- title: 'Basketball Pickup Game',
- description: 'Friendly 3-on-3 or 5-on-5 pickup games. Show up and play.',
- category: 'Sports & Fitness',
- defaultLocation: 'RPAC – Court B',
- },
- {
- title: 'Swimming Laps',
- description: 'Lap swim session with fellow students. Split a lane, share the pace.',
- category: 'Sports & Fitness',
- defaultLocation: 'RPAC – Aquatic Center',
- },
- {
- title: 'Soccer Kickaround',
- description: 'Informal soccer on the turf field. Cleats optional.',
- category: 'Sports & Fitness',
- defaultLocation: 'RPAC – Outdoor Fields',
- },
- {
- title: 'Tennis Doubles',
- description: 'Find a doubles partner and hit the courts. Rackets available to borrow.',
- category: 'Sports & Fitness',
- defaultLocation: 'Varsity Tennis Center',
- },
-
- // Food & Drink
- {
- title: 'Morning Coffee Walk',
- description: 'A casual walk to grab coffee and chat with new people.',
- category: 'Food & Drink',
- defaultLocation: 'Dreaming Creek Coffee – High Street',
- },
- {
- title: 'Lunch Together',
- description: 'Meet up for lunch and good conversation.',
- category: 'Food & Drink',
- defaultLocation: 'Baker Hall Dining – South Campus',
- },
- {
- title: 'Boba Run',
- description: 'Walk to get boba off campus. Try a new flavor every time.',
- category: 'Food & Drink',
- defaultLocation: 'Kung Fu Tea – High Street',
- },
- {
- title: 'Farmers Market Trip',
- description: 'Explore the North Market together on Saturday mornings.',
- category: 'Food & Drink',
- defaultLocation: 'North Market – Downtown Columbus',
- },
- {
- title: 'Cooking Together',
- description: 'Cook a meal together in the dorm kitchen. Recipe provided each week.',
- category: 'Food & Drink',
- defaultLocation: 'Residence Hall – Community Kitchen',
- },
-
- // Academic
- {
- title: 'Study Group Sprint',
- description: '90-minute focused study session — bring your own work, share the energy.',
- category: 'Academic',
- defaultLocation: 'Thompson Library – Group Study Room',
- },
- {
- title: 'Homework Help Circle',
- description: 'Drop in with tricky homework problems. Peer help, no judgment.',
- category: 'Academic',
- defaultLocation: 'Thompson Library – Tutoring Center',
- },
- {
- title: 'Exam Prep Session',
- description: 'Collaborative review before midterms and finals. Share notes and quiz each other.',
- category: 'Academic',
- defaultLocation: 'Thompson Library – Room 201',
- },
- {
- title: 'Language Exchange',
- description: 'Practice a new language with native speakers. All languages welcome.',
- category: 'Academic',
- defaultLocation: 'Hagerty Hall – International Lounge',
- },
- {
- title: 'Book Club Meetup',
- description: 'Read a book each month and discuss it over snacks.',
- category: 'Academic',
- defaultLocation: 'Thompson Library – Reading Room',
- },
-
- // Arts & Creative
- {
- title: 'Sketch & Chat',
- description: 'Bring a sketchbook and draw while you hang out. Prompts provided.',
- category: 'Arts & Creative',
- defaultLocation: 'Hopkins Hall – Open Studio',
- },
- {
- title: 'Photography Walk',
- description: 'Explore campus through your lens. Phone cameras totally welcome.',
- category: 'Arts & Creative',
- defaultLocation: 'The Oval – Mirror Lake End',
- },
- {
- title: 'DIY Craft Night',
- description: 'Make something with your hands — supplies provided each session.',
- category: 'Arts & Creative',
- defaultLocation: 'Ohio Union – Craft Room',
- },
- {
- title: 'Open Mic Night',
- description: 'Perform poetry, comedy, music, or just come watch and cheer.',
- category: 'Arts & Creative',
- defaultLocation: 'Ohio Union – Senate Chamber',
- },
- {
- title: 'Creative Writing Circle',
- description: 'Write short pieces and share them in a supportive group.',
- category: 'Arts & Creative',
- defaultLocation: 'Denney Hall – Seminar Room',
- },
-
- // Social
- {
- title: 'Evening Campus Walk',
- description: 'A relaxed evening walk around campus to unwind and meet people.',
- category: 'Social',
- defaultLocation: 'The Oval – Main Entrance',
- },
- {
- title: 'Board Game Night',
- description: 'Classic and modern board games. Newcomers always welcome.',
- category: 'Social',
- defaultLocation: 'Ohio Union – Game Room',
- },
- {
- title: 'Movie Night',
- description: 'Watch a crowd-picked movie on the big screen with popcorn.',
- category: 'Social',
- defaultLocation: 'Ohio Union – Cartoon Room',
- },
- {
- title: 'Campus Tour',
- description: 'Show new students around or rediscover hidden campus gems.',
- category: 'Social',
- defaultLocation: 'Ohio Union – Main Lobby',
- },
- {
- title: 'Trivia Night',
- description: 'Team up for trivia covering everything from pop culture to science.',
- category: 'Social',
- defaultLocation: 'Ethyl & Tank – Grandview',
- },
-
- // Outdoors
- {
- title: 'Sunrise Walk',
- description: 'Early morning walk to catch the sunrise across the Oval.',
- category: 'Outdoors',
- defaultLocation: 'The Oval – East Entrance',
- },
- {
- title: 'Nature Trail Walk',
- description: 'A chill walk on the Olentangy Trail. Great for decompressing.',
- category: 'Outdoors',
- defaultLocation: 'Olentangy Trail – Kenny Road Entrance',
- },
- {
- title: 'Stargazing Night',
- description: 'Lay out blankets and watch the stars. Telescope provided when clear.',
- category: 'Outdoors',
- defaultLocation: 'Ohio Stadium – East Lawn',
- },
- {
- title: 'Mirror Lake Hangout',
- description: 'Hang out by Mirror Lake and meet new people.',
- category: 'Outdoors',
- defaultLocation: 'Mirror Lake – The Oval',
- },
- {
- title: 'Park Picnic',
- description: 'Bring a dish or snack and enjoy a group picnic.',
- category: 'Outdoors',
- defaultLocation: 'Schiller Park – Pavilion',
- },
-
- // Music & Entertainment
- {
- title: 'Jam Session',
- description: 'Bring your instrument (or your voice) and jam with other musicians.',
- category: 'Music & Entertainment',
- defaultLocation: 'Oxley Hall – Practice Room',
- },
- {
- title: 'Concert Buddy',
- description: 'Find someone to go to upcoming campus concerts and local shows with.',
- category: 'Music & Entertainment',
- defaultLocation: 'Nationwide Arena – Main Lobby',
- },
- {
- title: 'Karaoke Night',
- description: 'Belt out your favorite songs. No talent required, just enthusiasm.',
- category: 'Music & Entertainment',
- defaultLocation: 'Char Bar – Short North',
- },
- {
- title: 'Vinyl Listening Party',
- description: 'Listen to a curated album on vinyl together and discuss the music.',
- category: 'Music & Entertainment',
- defaultLocation: 'Used Kids Records – High Street',
- },
- {
- title: 'A Cappella Listening Hang',
- description: 'Share favorite vocal groups, campus covers, and concert clips with people who love harmonies.',
- category: 'Music & Entertainment',
- defaultLocation: 'Ohio Union – Performance Hall',
- },
-
- // Wellness
- {
- title: 'Meditation Circle',
- description: 'Guided 20-minute meditation followed by quiet reflection.',
- category: 'Wellness',
- defaultLocation: 'RPAC – Mind Body Studio',
- },
- {
- title: 'Journaling Hour',
- description: 'Quiet journaling with optional prompts. Tea and calm vibes.',
- category: 'Wellness',
- defaultLocation: 'Thompson Library – Reading Garden',
- },
- {
- title: 'Digital Detox Walk',
- description: 'Leave your phone behind and take a mindful walk with others.',
- category: 'Wellness',
- defaultLocation: 'Chadwick Arboretum – Main Path',
- },
- {
- title: 'Stretching & Recovery',
- description: 'Light stretching and foam rolling session. Perfect after a long day.',
- category: 'Wellness',
- defaultLocation: 'RPAC – Studio B',
- },
- {
- title: 'Yoga Reset',
- description: 'A low-pressure yoga flow for resetting after class. Beginners welcome.',
- category: 'Wellness',
- defaultLocation: 'RPAC – Mind Body Studio',
- },
-
- // Gaming
- {
- title: 'Smash Bros Tournament',
- description: 'Compete in casual Smash Bros brackets. All skill levels welcome.',
- category: 'Gaming',
- defaultLocation: 'Ohio Union – Esports Lounge',
- },
- {
- title: 'Chess Club',
- description: 'Play chess at any skill level. Boards provided, lessons available.',
- category: 'Gaming',
- defaultLocation: 'Ohio Union – Game Room',
- },
- {
- title: 'Card Game Night',
- description: 'Play card games from Uno to Magic: The Gathering. Bring your own or borrow.',
- category: 'Gaming',
- defaultLocation: 'Ohio Union – Game Room',
- },
- {
- title: 'Tabletop RPG One-Shot',
- description: 'Jump into a beginner-friendly tabletop adventure with pre-made characters.',
- category: 'Gaming',
- defaultLocation: 'Ohio Union – Game Room',
- },
-
- // Volunteering
- {
- title: 'Community Garden',
- description: 'Help tend the campus garden. No green thumb needed — just show up.',
- category: 'Volunteering',
- defaultLocation: 'Chadwick Arboretum – Community Garden',
- },
- {
- title: 'Tutoring Buddies',
- description: 'Volunteer to tutor local middle schoolers in math and reading.',
- category: 'Volunteering',
- defaultLocation: 'Weinland Park Community Center',
- },
- {
- title: 'Campus Cleanup',
- description: 'Spend an hour picking up litter and beautifying campus together.',
- category: 'Volunteering',
- defaultLocation: 'The Oval – Info Booth',
- },
- {
- title: 'Food Bank Volunteer',
- description: 'Sort and pack food donations at the Mid-Ohio Food Collective.',
- category: 'Volunteering',
- defaultLocation: 'Mid-Ohio Food Collective – Parsons Ave',
- },
- {
- title: 'Blood Drive Helpers',
- description: 'Help greet donors, organize supplies, and keep a campus blood drive moving.',
- category: 'Volunteering',
- defaultLocation: 'Ohio Union – Great Hall',
- },
-];
-
async function main() {
+ // This seed is intentionally destructive and is only for local/test databases.
await prisma.report.deleteMany();
await prisma.message.deleteMany();
await prisma.podMember.deleteMany();
@@ -333,13 +13,15 @@ async function main() {
await prisma.activity.deleteMany();
await prisma.block.deleteMany();
- await prisma.activity.createMany({ data: activities });
- console.log(`Seeded ${activities.length} activities.`);
+ await prisma.activity.createMany({
+ data: ACTIVITY_CATALOG.map((activity) => ({ ...activity, isActive: true })),
+ });
+ console.log(`Seeded ${ACTIVITY_CATALOG.length} activities.`);
}
main()
- .catch((e) => {
- console.error(e);
+ .catch((error) => {
+ console.error(error);
process.exit(1);
})
.finally(async () => {
diff --git a/backend/src/config/activityCatalog.test.ts b/backend/src/config/activityCatalog.test.ts
new file mode 100644
index 0000000..9dc1a32
--- /dev/null
+++ b/backend/src/config/activityCatalog.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { ACTIVITY_CATALOG, ACTIVITY_CATEGORIES } from '../../prisma/activityCatalog';
+
+describe('activity catalog', () => {
+ it('contains 41 ordered, uniquely keyed activities', () => {
+ expect(ACTIVITY_CATALOG).toHaveLength(41);
+ expect(new Set(ACTIVITY_CATALOG.map((activity) => activity.id)).size).toBe(41);
+ expect(new Set(ACTIVITY_CATALOG.map((activity) => activity.artworkKey)).size).toBe(41);
+ expect(ACTIVITY_CATALOG.map((activity) => activity.sortOrder)).toEqual(
+ Array.from({ length: 41 }, (_, index) => index + 1),
+ );
+ });
+
+ it('uses only the eight launch categories', () => {
+ const usedCategories = new Set(ACTIVITY_CATALOG.map((activity) => activity.category));
+ expect(usedCategories).toEqual(new Set(ACTIVITY_CATEGORIES));
+ });
+});
diff --git a/backend/src/config/interestTags.ts b/backend/src/config/interestTags.ts
index 1df6352..73038a9 100644
--- a/backend/src/config/interestTags.ts
+++ b/backend/src/config/interestTags.ts
@@ -22,21 +22,21 @@ export type InterestTag = (typeof INTEREST_TAGS)[number];
export const INTEREST_TAG_SET = new Set(INTEREST_TAGS);
export const TAG_TO_CATEGORY: Record = {
- Art: 'Arts & Creative',
- Dance: 'Arts & Creative',
- Engineering: 'Academic',
- Film: 'Music & Entertainment',
- Food: 'Food & Drink',
+ Art: 'Music & Arts',
+ Dance: 'Music & Arts',
+ Engineering: 'Academic / Study',
+ Film: 'Social & Events',
+ Food: 'Food',
Gaming: 'Gaming',
- 'Greek Life': 'Social',
- Gym: 'Sports & Fitness',
- Music: 'Music & Entertainment',
- Outdoors: 'Outdoors',
- Photography: 'Arts & Creative',
- 'Pre-Med': 'Academic',
- Reading: 'Academic',
- Sports: 'Sports & Fitness',
- Tech: 'Academic',
+ 'Greek Life': 'Social & Events',
+ Gym: 'Fitness & Wellness',
+ Music: 'Music & Arts',
+ Outdoors: 'Fitness & Wellness',
+ Photography: 'Music & Arts',
+ 'Pre-Med': 'Academic / Study',
+ Reading: 'Academic / Study',
+ Sports: 'Sports',
+ Tech: 'Academic / Study',
Volunteering: 'Volunteering',
};
diff --git a/backend/src/config/locations.test.ts b/backend/src/config/locations.test.ts
index b4b6f8f..fa4a93c 100644
--- a/backend/src/config/locations.test.ts
+++ b/backend/src/config/locations.test.ts
@@ -10,14 +10,14 @@ import { isInsideCampus } from '../routes/pods';
describe('getLocationsForCategory', () => {
it('returns locations for a known category', () => {
- const result = getLocationsForCategory('Sports & Fitness');
+ const result = getLocationsForCategory('Sports');
expect(result).toContain('RPAC');
expect(result).toContain('North Recreation Center');
expect(result.length).toBeGreaterThan(0);
});
- it('returns locations for Food & Drink', () => {
- const result = getLocationsForCategory('Food & Drink');
+ it('returns locations for Food', () => {
+ const result = getLocationsForCategory('Food');
expect(result).toContain("Sloopy's Diner");
expect(result).toContain('Ohio Union');
});
@@ -36,10 +36,10 @@ describe('getLocationsForCategory', () => {
describe('LOCATION_BY_CATEGORY', () => {
it('has entries for all expected categories', () => {
const categories = Object.keys(LOCATION_BY_CATEGORY);
- expect(categories).toContain('Sports & Fitness');
- expect(categories).toContain('Food & Drink');
- expect(categories).toContain('Academic');
- expect(categories).toContain('Wellness');
+ expect(categories).toContain('Sports');
+ expect(categories).toContain('Food');
+ expect(categories).toContain('Academic / Study');
+ expect(categories).toContain('Fitness & Wellness');
expect(categories).toContain('Gaming');
});
@@ -100,6 +100,7 @@ describe('getCoordinatesForLocation', () => {
'Urban Arts Space',
'Student Center – Game Room',
'Residence Hall – Common Room',
+ 'Residence Hall – Community Kitchen',
'Community Garden – Behind Science Hall',
'Student Center – Volunteer Office',
]);
diff --git a/backend/src/config/locations.ts b/backend/src/config/locations.ts
index e3d2dcf..d6c07f2 100644
--- a/backend/src/config/locations.ts
+++ b/backend/src/config/locations.ts
@@ -8,6 +8,49 @@
* Run `npx prisma db seed` after changing activities if needed.
*/
export const LOCATION_BY_CATEGORY: Record = {
+ 'Academic / Study': [
+ 'Thompson Library',
+ '18th Avenue Library',
+ 'FAES Library',
+ 'Health Sciences Library',
+ 'Research Commons',
+ ],
+ Sports: [
+ 'RPAC',
+ 'North Recreation Center',
+ 'Jesse Owens North',
+ 'Jesse Owens South',
+ 'Aquatic Center',
+ 'Lincoln Tower Fields',
+ 'The Oval',
+ ],
+ 'Fitness & Wellness': [
+ 'RPAC',
+ 'RPAC – Wellness Space',
+ 'RPAC – Mind Body Studio',
+ 'Student Wellness Center',
+ 'The Oval',
+ 'Olentangy Trail',
+ ],
+ 'Social & Events': ['Ohio Union', 'The Oval', 'Mirror Lake', 'Student Lounge'],
+ Food: [
+ 'Traditions at Scott',
+ 'Traditions at Kennedy',
+ 'Traditions at Morrill',
+ "Sloopy's Diner",
+ 'Ohio Union',
+ 'Berry Café',
+ 'High Street',
+ 'Residence Hall – Community Kitchen',
+ ],
+ 'Music & Arts': [
+ 'Wexner Center for the Arts',
+ 'Hopkins Hall',
+ 'Hughes Hall',
+ 'Sullivant Hall',
+ 'Ohio Union',
+ 'The Oval',
+ ],
'Sports & Fitness': [
'RPAC',
'North Recreation Center',
@@ -143,6 +186,7 @@ export const LOCATION_COORDINATES: Record {
it('does nothing outside production', () => {
expect(() => validateProductionEnvironment({ NODE_ENV: 'test' })).not.toThrow();
});
+
+ it('rejects a malformed Android App Links certificate fingerprint', () => {
+ expect(() => validateProductionEnvironment({
+ ...validEnv,
+ ANDROID_SHA256_CERT_FINGERPRINT: 'not-a-sha256-fingerprint',
+ })).toThrow(/ANDROID_SHA256_CERT_FINGERPRINT/);
+ });
});
diff --git a/backend/src/config/productionEnv.ts b/backend/src/config/productionEnv.ts
index 7221a20..40d3322 100644
--- a/backend/src/config/productionEnv.ts
+++ b/backend/src/config/productionEnv.ts
@@ -40,6 +40,11 @@ export function validateProductionEnvironment(
}
}
+ const androidFingerprint = env.ANDROID_SHA256_CERT_FINGERPRINT?.trim();
+ if (androidFingerprint && !/^(?:[0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$/.test(androidFingerprint)) {
+ errors.push('ANDROID_SHA256_CERT_FINGERPRINT must be a colon-delimited SHA-256 fingerprint');
+ }
+
if (errors.length > 0) {
throw new Error(`Invalid production environment:\n- ${errors.join('\n- ')}`);
}
@@ -52,4 +57,10 @@ export function validateProductionEnvironment(
'DISABLED; clients will fall back to polling for chat, typing, and inbox badges.',
);
}
+ if (!androidFingerprint) {
+ console.warn(
+ '[env] ANDROID_SHA256_CERT_FINGERPRINT is not set — Android App Links cannot verify ' +
+ 'until the Play App Signing certificate fingerprint is configured.',
+ );
+ }
}
diff --git a/backend/src/lib/NotificationService.ts b/backend/src/lib/NotificationService.ts
index ef16753..fc3890f 100644
--- a/backend/src/lib/NotificationService.ts
+++ b/backend/src/lib/NotificationService.ts
@@ -63,6 +63,13 @@ function podUrl(podId: string): string {
return `oval://pod/${podId}`;
}
+function podDisplayTitle(
+ pod: { title?: string | null; activity?: { title: string } | null },
+ fallback: string,
+): string {
+ return pod.title?.trim() || pod.activity?.title || fallback;
+}
+
function threadUrl(threadId: string): string {
return `oval://thread/${threadId}`;
}
@@ -294,7 +301,7 @@ export const NotificationService = {
await send([
{
to: pod.creator.pushToken,
- title: pod.activity?.title ?? 'Your Pod',
+ title: podDisplayTitle(pod, 'Your Pod'),
body: `${joinerName} joined your pod!`,
data: { type: 'pod_join', podId, url: podUrl(podId) },
sound: 'default',
@@ -341,11 +348,11 @@ export const NotificationService = {
})
: pod.members.map((m) => m.user);
- const title = pod.activity?.title ?? 'Your pod';
+ const title = podDisplayTitle(pod, 'Your pod');
const body =
kind === 'cancelled'
? 'This pod was cancelled by its creator.'
- : 'The meetup time or location changed — check the new plan.';
+ : 'The pod details changed — check the updated plan.';
const messages: ExpoPushMessage[] = [];
for (const user of recipients) {
@@ -397,7 +404,7 @@ export const NotificationService = {
if (!prefs.newMessage) continue;
messages.push({
to: member.user.pushToken,
- title: pod.activity?.title ?? 'Pod Message',
+ title: podDisplayTitle(pod, 'Pod Message'),
body: `${senderName} sent a message`,
data: { type: 'new_message', podId, url: podUrl(podId) },
sound: 'default',
@@ -486,7 +493,7 @@ export const NotificationService = {
messages.push({
to: member.user.pushToken,
title: 'Meetup in 1 hour!',
- body: `${pod.activity?.title ?? 'Your meetup'} at ${pod.location}`,
+ body: `${podDisplayTitle(pod, 'Your meetup')} at ${pod.location}`,
data: { type: 'meetup_reminder', podId: pod.id, url: podUrl(pod.id) },
sound: 'default',
});
@@ -539,7 +546,7 @@ export const NotificationService = {
messages.push({
to: member.user.pushToken,
title: 'How was it?',
- body: `Rate your ${pod.activity?.title ?? 'meetup'} experience`,
+ body: `Rate your ${podDisplayTitle(pod, 'meetup')} experience`,
data: { type: 'recap_prompt', podId: pod.id, url: podUrl(pod.id) },
sound: 'default',
});
@@ -593,7 +600,7 @@ export const NotificationService = {
messages.push({
to: entry.user.pushToken,
title: 'A second pod opened',
- body: `${newPod.activity?.title ?? 'That plan'} was full, so a twin just went up — grab a spot`,
+ body: `${podDisplayTitle(newPod, 'That plan')} was full, so a twin just went up — grab a spot`,
data: { type: 'waitlist_twin', podId: newPodId, url: podUrl(newPodId) },
sound: 'default',
});
@@ -738,7 +745,7 @@ export const NotificationService = {
messages.push({
to: membership.user.pushToken,
- title: `Tonight: ${membership.pod.activity?.title ?? 'your pod'} at ${meetupTime}`,
+ title: `Tonight: ${podDisplayTitle(membership.pod, 'your pod')} at ${meetupTime}`,
body: peopleLine,
data: { type: 'first_pod_nudge', podId: membership.podId, url: podUrl(membership.podId) },
sound: 'default',
@@ -799,7 +806,9 @@ export const NotificationService = {
});
if (openPodCount > 0) continue;
- const activity = await prisma.activity.findUnique({ where: { id: group.activityId } });
+ const activity = await prisma.activity.findFirst({
+ where: { id: group.activityId, isActive: true },
+ });
if (!activity) continue;
const demands = await prisma.podDemand.findMany({
where: { activityId: group.activityId, consumedAt: null, expiresAt: { gt: now } },
@@ -851,8 +860,11 @@ export const NotificationService = {
): Promise<{ attempted: number; sent: number }> {
const now = new Date();
try {
- const [activity, demands] = await Promise.all([
- prisma.activity.findUnique({ where: { id: activityId }, select: { title: true } }),
+ const [pod, demands] = await Promise.all([
+ prisma.pod.findUnique({
+ where: { id: podId },
+ select: { title: true, activity: { select: { title: true } } },
+ }),
prisma.podDemand.findMany({
where: { activityId, consumedAt: null, expiresAt: { gt: now } },
include: { user: { select: { id: true, pushToken: true, notificationPreferences: true } } },
@@ -872,7 +884,7 @@ export const NotificationService = {
const didSend = await sendNonTransactional(
demand.user,
{
- title: `${activity?.title ?? 'A pod'} just went up`,
+ title: `${pod ? podDisplayTitle(pod, 'A pod') : 'A pod'} just went up`,
body: `A plan just opened ${dayLabel}. Grab a spot while it is forming.`,
data: { type: 'demand_pod_created', activityId, podId, url: podUrl(podId) },
sound: 'default',
diff --git a/backend/src/lib/activityEmoji.ts b/backend/src/lib/activityEmoji.ts
index a9a1600..f768a98 100644
--- a/backend/src/lib/activityEmoji.ts
+++ b/backend/src/lib/activityEmoji.ts
@@ -21,6 +21,14 @@ export function getActivityEmoji(title: string, category: string): string {
[/movie|film|cinema/i, '🎬'],
[/music|concert|jam session|band/i, '🎵'],
[/game night|board game|gaming\b/i, '🎮'],
+ [/chess/i, '♟️'],
+ [/card game|poker/i, '🃏'],
+ [/trivia/i, '❓'],
+ [/swim/i, '🏊'],
+ [/golf/i, '⛳'],
+ [/bowling/i, '🎳'],
+ [/cook|bake/i, '🧑🍳'],
+ [/book|reading/i, '📖'],
[/photo|camera|shoot/i, '📷'],
[/art\b|paint|sketch|museum/i, '🎨'],
[/dance|salsa|swing dance/i, '💃'],
@@ -34,6 +42,12 @@ export function getActivityEmoji(title: string, category: string): string {
}
const byCategory: Record = {
+ 'Academic / Study': '📚',
+ Sports: '⚽',
+ 'Fitness & Wellness': '🌿',
+ 'Social & Events': '👋',
+ Food: '🍽️',
+ 'Music & Arts': '🎨',
'Sports & Fitness': '⚽',
'Food & Drink': '🍽️',
Academic: '📚',
diff --git a/backend/src/lib/contentModeration.test.ts b/backend/src/lib/contentModeration.test.ts
index 5d8731b..0f1db3a 100644
--- a/backend/src/lib/contentModeration.test.ts
+++ b/backend/src/lib/contentModeration.test.ts
@@ -37,6 +37,48 @@ describe('content moderation (local, no provider)', () => {
expect(await blocked('a$$hole')).toBe(true);
});
+ it('rejects likely keyboard-mash pod titles when requested', async () => {
+ const options = { rejectGibberish: true };
+ for (const title of [
+ 'asfoiashdoi',
+ 'AFOISUDOIAS',
+ 'qwertyuiop',
+ 'bcdfghjklm',
+ 'aaaaaaaaaa',
+ ]) {
+ expect(await blocked(title, options)).toBe(true);
+ }
+ });
+
+ it('keeps plausible titles, campus names, and acronyms', async () => {
+ const options = { rejectGibberish: true };
+ for (const title of [
+ 'Euchre at Morrill Tower',
+ 'CSE 2221',
+ 'MATH 1151 study group',
+ 'Algorithms exam cram',
+ 'Pickleball',
+ 'Thanksgiving potluck',
+ 'BuckeyeLink help session',
+ 'RPAC pickup basketball',
+ 'Mississippi watch party',
+ 'Ashwaganda supplements',
+ 'Ashwagandha discussion',
+ 'Availability',
+ 'Aboveboard',
+ 'Birthplace',
+ 'Postscript',
+ 'Backstroke',
+ 'Matchstick',
+ 'Swashbuckle',
+ 'Kalashnikov history discussion',
+ 'Oxybenzaldehyde study session',
+ 'Twelvefold',
+ ]) {
+ expect(await clean(title, options)).toBe(true);
+ }
+ });
+
it('does NOT flag innocent words (false-positive guards)', async () => {
for (const word of [
'class', 'classic', 'assassin', 'Scunthorpe', 'grape', 'scrape',
diff --git a/backend/src/lib/contentModeration.ts b/backend/src/lib/contentModeration.ts
index f770382..77f50a8 100644
--- a/backend/src/lib/contentModeration.ts
+++ b/backend/src/lib/contentModeration.ts
@@ -19,6 +19,8 @@
const BLOCKED_CONTENT_MESSAGE =
'This content appears to violate Oval safety rules. Please revise it before posting.';
+const GIBBERISH_CONTENT_MESSAGE =
+ 'Use a clear, descriptive pod title so people know what they are joining.';
export interface ModerationRejection {
status: 400;
@@ -28,6 +30,8 @@ export interface ModerationRejection {
export interface ModerationOptions {
/** Allow general profanity (for conversational content like chat messages). */
allowProfanity?: boolean;
+ /** Reject likely keyboard-mash text. Intended for short public labels such as pod titles. */
+ rejectGibberish?: boolean;
}
// Leetspeak / homoglyph folding. Conservative — only common substitutions.
@@ -96,6 +100,83 @@ const PROFANITY_WORD: RegExp[] = [
/\bb+o+l+l+o+c+k/, /\bmotherf+u+c?k/, /\bjackass/, /\bdouchebag/,
];
+// Top 1,000 character trigrams learned from macOS's 234k-word English
+// dictionaries (web2 + web2a). A trigram model proved much less prone to false
+// positives than the earlier repeated-bigram rule: the full dictionary audit
+// caught 25 rare-but-real words, which are explicitly preserved below.
+const COMMON_TRIGRAM_DATA = `
+e$$y$$s$$^^s^^pn$$^^c^^a^^ud$$r$$l$$t$$^un^^t^^ma$$ly$c$$^^ber$essic$^^dal$ion^^rss$teratied$^^hus$ingm$$on$te$^^i^^eous
+tiones^^oentatele$tic^^f^^g^co^^nicag$$ist^^lan$^prbleng$calant^reine^inablperh$$erint$allne$trast$ia$ver^sutricon^pary$
+prealiproite^^wstiene^caismoniis$ty$nte^diianntiratsm$ive^detor^nothe^anlitophste^^vlinerolatricmantinari^trasteraatoder
+^stresranmen^manonstr^peitigrandeherriaitytro^seintina^chlesmatolollyphorinometed^poaniectizeundiliae$overoplogereonaula
+ialdis^meid$chimetishtivostk$$^henicacese$phistanatum$ve$oriomarapin$emictilaroneniseleidaparaphnalforsisracestminze$ide
+^miphaelllen^mo^ba^sptal^phncecatgenmontisand^besto^^ko$$rouermoliillntaoid^sa^ovphyp$$eliersris^scmicngllislicculcartom
+inireduncetelanraltat^terosreaancchatence$^hyrom^thchelliar$comre$recarti$$araritardenish$plachollaencthoosior$misacteti
+athortotooreontniaetrhalicisubglynit^ta^larononoanghor^aroraachendunsdiainotanshirottur^enenoogrdenke$itaorm^al^^jrocton
+alacrondi^brhrountscoretsside$ful^ho^expol^plmernerurencoogilas^crntrdaecorageidianaler^haesiaryalenthnchcenporbilthinin
+hea^aces$ikearc^focol^shbrauriercliarentariouharquihinemahicdinvensuptabposium^gaetaberindliken$et$^lilleassamioseiat^si
+ge$hipelyomogerpleomimorsem^soertotholeons^ne^pillofer^totteout^raura^bihan^bongekerkindicdrospecertelcopuniincsiortiise
+^grcanpatrchrioserounearridpinogeicoabiramitholypenthrainedilacctoanetiaemeoroataephrmaens^leope^amhemupehyprepochizasin
+eryeouifianotre^muondcrapalotiholletamephewormalquaerreat^^qhilpicalohonopi^quip$gictitsitecolizmarlecolame$rabtopiscpan
+hy$adeimpern^dooponarunaileareoncultled^flgy$ck$velackead^veodi^clloscourm$radadi^ceansunpasispo^imspiemo^buma$epipti^ro
+^wahenchrsalomyficla$^fisnelornsintoaciceaoryote^ap^asustrd$oodelaocamitsen^vienara$cinsiaibldlyblyrol^geul$rogtillounos
+roocrenomdnerthdesthaghtcy$ongciaodohisniztleslynetnou^loich^ps^pu^fand$icuogyoceremealatrisoighpri^aueanmelsomcepqueune
+my$ras^orodeifoimeosp^^zzatrelwaretherlbrohelickinsollrrech$eptompclelea^blf$$decnedrte^tucritol^frshaiceampapphyddoniss
+ocoockuti^syoscel$unr^dagintchamarmiattbarrdina$rusaveorp^adnstcelautedlsseod$^tiacoscagalscrlum^cuystlocsheragthyclaerp
+^vata$itoscetoculiake^ouson^elopandandrliouto^epagielodradomsoluro^glescth$eveedniacneugislushettoseurraiisinrelopydreae
+ienlam^natestru^godalrerervx$$ily^wirsehotifeuslarrlabornorrdiohylaronne^cyannngisulnsepe$ilasurncyettungaccditcitacrilo
+rifossulltacreededbripte^fenoprigcurgonypeligmiamuldlehiaotanabhomsanpitrefndoll$nansmaimatlyganad$ckenaclotripunf^mysch
+ceoetootrmoueasoplicrsorecairemacrieiviiolasepsesiccidtrybalselordcostifmededeea$lidgatpisecilowmasierhedscisquimi^ci^sl
+ntleed^wherbw$$lonrmo^hibacextpedrilailponvalnsuee$poddrierghoucapapoeudrimnotrrirt$rphwinlnesilpulngumpetemmesmpherfbla
+esotogdemeseambrizdelbulsphmil^drglousetimootusipec^abusncocunbctaualtid^ispli^^yulosivvisysiutebatmidhooinfegarodrnispa
+belom$acasidot$nocoptgleeinoursedobinolpetatuvinbit^guamonorltiny$echituseugneprareghos^rilemtylempil$deaudomotagommenci
+`.replace(/\s/g, '');
+
+const COMMON_TRIGRAMS = new Set(
+ Array.from(
+ { length: COMMON_TRIGRAM_DATA.length / 3 },
+ (_, index) => COMMON_TRIGRAM_DATA.slice(index * 3, index * 3 + 3),
+ ),
+);
+
+const KNOWN_LOW_FREQUENCY_WORDS = new Set([
+ 'avoirdupois', 'azoxybenzoic', 'caughnawaga', 'fifteenfold', 'hlidhskjalf',
+ 'husbandfield', 'johnadreams', 'kalashnikov', 'kalymmocyte', 'kitkahaxki',
+ 'kitkehahki', 'kornskeppur', 'kotukutuku', 'kuskwogmiut', 'mushrebiyeh',
+ 'oxybenzaldehyde', 'razoumofskya', 'satyashodak', 'sixteenfold', 'swashbuckle',
+ 'twelvefold', 'whiffenpoof', 'xylobalsamum', 'yajnavalkya', 'zaklohpakap',
+]);
+
+const KEYBOARD_RUNS = [
+ 'qwerty', 'werty', 'asdfg', 'sdfgh', 'dfghj', 'fghjk', 'ghjkl',
+ 'zxcvb', 'xcvbn', 'cvbnm',
+];
+
+function looksLikeGibberish(text: string): boolean {
+ const words = normalize(text).match(/[a-z]+/g) ?? [];
+
+ return words.some((word) => {
+ // Short words/acronyms and normal multi-word connective text are too easy
+ // to misclassify, so only scrutinize long tokens.
+ if (word.length < 10) return false;
+ if (KEYBOARD_RUNS.some((run) => word.includes(run) || word.includes(run.split('').reverse().join('')))) {
+ return true;
+ }
+
+ const vowels = (word.match(/[aeiouy]/g) ?? []).length;
+ const vowelRatio = vowels / word.length;
+ if (vowelRatio < 0.15 || vowelRatio > 0.8) return true;
+ if (KNOWN_LOW_FREQUENCY_WORDS.has(word)) return false;
+
+ const padded = `^^${word}$$`;
+ let commonCount = 0;
+ for (let index = 0; index < padded.length - 2; index += 1) {
+ if (COMMON_TRIGRAMS.has(padded.slice(index, index + 3))) commonCount += 1;
+ }
+ return commonCount / (padded.length - 2) < 0.24;
+ });
+}
+
function matches(text: string, opts: ModerationOptions): boolean {
if (!text) return false;
const norm = normalize(text);
@@ -132,6 +213,9 @@ export async function moderateTextContent(
if (matches(text, options)) {
return { status: 400, message: BLOCKED_CONTENT_MESSAGE };
}
+ if (options.rejectGibberish && looksLikeGibberish(text)) {
+ return { status: 400, message: GIBBERISH_CONTENT_MESSAGE };
+ }
return null;
}
diff --git a/backend/src/lib/emailService.ts b/backend/src/lib/emailService.ts
index ec17b5f..e2ef538 100644
--- a/backend/src/lib/emailService.ts
+++ b/backend/src/lib/emailService.ts
@@ -163,7 +163,7 @@ export interface ModerationReportEmailPayload {
createdAt: Date;
reporter?: { id: string; name: string; email: string } | null;
target?: { id: string; name: string; email: string } | null;
- pod?: { id: string; location?: string | null; activity?: { title: string } | null } | null;
+ pod?: { id: string; title?: string | null; location?: string | null; activity?: { title: string } | null } | null;
message?: { id: string; content: string; createdAt?: Date } | null;
reportedContent?: string | null;
directMessageId?: string | null;
@@ -208,7 +208,7 @@ export async function sendModerationReportEmail(report: ModerationReportEmailPay
['Target type', report.targetType],
['Reporter', report.reporter ? `${report.reporter.name} <${report.reporter.email}> (${report.reporter.id})` : null],
['Target user', report.target ? `${report.target.name} <${report.target.email}> (${report.target.id})` : null],
- ['Pod', report.pod ? `${report.pod.activity?.title ?? 'Pod'} at ${report.pod.location ?? 'unknown location'} (${report.pod.id})` : null],
+ ['Pod', report.pod ? `${report.pod.title?.trim() || report.pod.activity?.title || 'Pod'} at ${report.pod.location ?? 'unknown location'} (${report.pod.id})` : null],
['Reported content', report.message ? `${report.message.content.slice(0, 500)} (${report.message.id})` : report.reportedContent?.slice(0, 500)],
['Direct message id', report.directMessageId],
['Club id', report.clubId],
diff --git a/backend/src/lib/expireOldPods.test.ts b/backend/src/lib/expireOldPods.test.ts
index 63e461d..54d718a 100644
--- a/backend/src/lib/expireOldPods.test.ts
+++ b/backend/src/lib/expireOldPods.test.ts
@@ -11,7 +11,7 @@ describe('expireOldPods', () => {
beforeEach(async () => {
resetExpireOldPodsThrottleForTests();
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/activities.test.ts b/backend/src/routes/activities.test.ts
index 75738fd..1068921 100644
--- a/backend/src/routes/activities.test.ts
+++ b/backend/src/routes/activities.test.ts
@@ -22,28 +22,64 @@ describe('GET /activities', () => {
expect(res.body[0]).toHaveProperty('title');
expect(res.body[0]).toHaveProperty('category');
expect(res.body[0]).toHaveProperty('description');
+ expect(res.body[0]).toHaveProperty('artworkKey');
+ expect(res.body.every((activity: { isActive: boolean }) => activity.isActive)).toBe(true);
});
it('filters by category when provided', async () => {
const { token } = await registerAndGetToken('Bob', 'bob@activities.test.com', 'password123');
const res = await request(app)
.get('/activities')
- .query({ category: 'Sports & Fitness' })
+ .query({ category: 'Sports' })
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
res.body.forEach((a: { category: string }) => {
- expect(a.category).toBe('Sports & Fitness');
+ expect(a.category).toBe('Sports');
});
});
+
+ it('hides retired activities from discovery and blocks new demand', async () => {
+ const { token } = await registerAndGetToken(
+ 'Legacy Activity Student',
+ 'legacy-activity@activities.test.com',
+ 'password123'
+ );
+ const retired = await prisma.activity.create({
+ data: {
+ title: `Retired Activity ${Date.now()}`,
+ description: 'Kept only so existing pod history remains valid.',
+ category: 'Social & Events',
+ defaultLocation: 'Ohio Union',
+ isActive: false,
+ },
+ });
+
+ const activities = await request(app)
+ .get('/activities')
+ .set('Authorization', `Bearer ${token}`)
+ .expect(200);
+ expect(activities.body.some((activity: { id: string }) => activity.id === retired.id)).toBe(false);
+
+ await request(app)
+ .post(`/activities/${retired.id}/demand`)
+ .set('Authorization', `Bearer ${token}`)
+ .expect(404);
+
+ await request(app)
+ .post('/pods/join')
+ .set('Authorization', `Bearer ${token}`)
+ .send({ activityId: retired.id })
+ .expect(404);
+ });
});
describe('GET /activities/locations', () => {
it('returns 401 without auth', async () => {
await request(app)
.get('/activities/locations')
- .query({ category: 'Food & Drink' })
+ .query({ category: 'Food' })
.expect(401);
});
@@ -51,7 +87,7 @@ describe('GET /activities/locations', () => {
const { token } = await registerAndGetToken('Carol', 'carol@activities.test.com', 'password123');
const res = await request(app)
.get('/activities/locations')
- .query({ category: 'Food & Drink' })
+ .query({ category: 'Food' })
.set('Authorization', `Bearer ${token}`)
.expect(200);
@@ -72,9 +108,9 @@ describe('GET /activities/:id/locations', () => {
it('returns locations for an activity by its category when authenticated', async () => {
const { token } = await registerAndGetToken('Eve', 'eve@activities.test.com', 'password123');
const activity = await prisma.activity.findFirst({
- where: { category: 'Food & Drink' },
+ where: { category: 'Food' },
});
- if (!activity) throw new Error('No Food & Drink activity in seed');
+ if (!activity) throw new Error('No Food activity in seed');
const res = await request(app)
.get(`/activities/${activity.id}/locations`)
@@ -105,7 +141,7 @@ describe('activity demand signals', () => {
data: {
title: `Demand Signal ${Date.now()}`,
description: 'Demand pooling test activity.',
- category: 'Food & Drink',
+ category: 'Food',
defaultLocation: 'Ohio Union',
},
});
@@ -157,7 +193,7 @@ describe('activity demand signals', () => {
data: {
title: `Demand Pool ${Date.now()}`,
description: 'Demand conversion test activity.',
- category: 'Food & Drink',
+ category: 'Food',
defaultLocation: 'Ohio Union',
},
});
@@ -207,7 +243,7 @@ describe('activity requests', () => {
.set('Authorization', `Bearer ${student.token}`)
.send({
title,
- category: 'Outdoors',
+ category: 'Sports',
description: 'Low-key lawn games around campus.',
defaultLocation: 'The Oval',
})
@@ -240,7 +276,7 @@ describe('activity requests', () => {
const duplicate = await request(app)
.post('/activities/requests')
.set('Authorization', `Bearer ${student.token}`)
- .send({ title: title.toUpperCase(), category: 'Outdoors' })
+ .send({ title: title.toUpperCase(), category: 'Sports' })
.expect(201);
const duplicateApproval = await request(app)
.post(`/admin/activity-requests/${duplicate.body.id}/approve`)
@@ -268,7 +304,7 @@ describe('activity requests', () => {
const submitted = await request(app)
.post('/activities/requests')
.set('Authorization', `Bearer ${student.token}`)
- .send({ title: `Rejected Activity ${Date.now()}`, category: 'Social' })
+ .send({ title: `Rejected Activity ${Date.now()}`, category: 'Social & Events' })
.expect(201);
const rejected = await request(app)
diff --git a/backend/src/routes/activities.ts b/backend/src/routes/activities.ts
index 3cd7b2c..12ef20c 100644
--- a/backend/src/routes/activities.ts
+++ b/backend/src/routes/activities.ts
@@ -9,16 +9,14 @@ import { consumeDurableRateLimit } from '../lib/durableRateLimit';
const router = Router();
const ACTIVITY_REQUEST_CATEGORIES = new Set([
- 'Sports & Fitness',
- 'Food & Drink',
- 'Academic',
- 'Arts & Creative',
- 'Social',
- 'Outdoors',
- 'Music & Entertainment',
- 'Wellness',
+ 'Academic / Study',
+ 'Sports',
+ 'Fitness & Wellness',
+ 'Social & Events',
'Gaming',
'Volunteering',
+ 'Food',
+ 'Music & Arts',
]);
const ACTIVITY_REQUEST_LIMIT = 5;
@@ -115,7 +113,7 @@ router.post('/:id/demand', requireAuth, async (req: AuthRequest, res: Response):
const now = new Date();
try {
- const activity = await prisma.activity.findUnique({ where: { id: activityId } });
+ const activity = await prisma.activity.findFirst({ where: { id: activityId, isActive: true } });
if (!activity) {
res.status(404).json({ error: 'Activity not found' });
return;
@@ -206,13 +204,16 @@ router.get('/', requireAuth, async (req: AuthRequest, res: Response): Promise {
const admin = await createTestUser({ email: `analytics-admin-${Date.now()}@osu.edu` });
const activated = await createTestUser({ email: `activated-${Date.now()}@osu.edu` });
const tooLate = await createTestUser({ email: `too-late-${Date.now()}@osu.edu` });
- const activity = await prisma.activity.findFirst({ where: { title: 'Boba Run' } });
- if (!activity) throw new Error('No Boba Run activity in seed');
+ const activity = await prisma.activity.findFirst({ where: { title: 'Grab coffee or tea' } });
+ if (!activity) throw new Error('No coffee activity in seed');
process.env.ADMIN_USER_IDS = admin.id;
const now = new Date();
diff --git a/backend/src/routes/attendance.test.ts b/backend/src/routes/attendance.test.ts
index b860322..a921672 100644
--- a/backend/src/routes/attendance.test.ts
+++ b/backend/src/routes/attendance.test.ts
@@ -78,7 +78,7 @@ describe('Attendance API', () => {
token2 = user2.token;
userId2 = user2.user.id;
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No Academic activity in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/inbox.test.ts b/backend/src/routes/inbox.test.ts
index 30692ce..69149cf 100644
--- a/backend/src/routes/inbox.test.ts
+++ b/backend/src/routes/inbox.test.ts
@@ -24,7 +24,7 @@ describe('Inbox summary', () => {
createTestUser({ name: 'Inbox Requester' }),
]);
const token = getAuthToken(me.id, me.email);
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No Academic activity in seed');
const thread = await prisma.directMessageThread.create({
diff --git a/backend/src/routes/interestTags.test.ts b/backend/src/routes/interestTags.test.ts
index 2be9a84..81bb119 100644
--- a/backend/src/routes/interestTags.test.ts
+++ b/backend/src/routes/interestTags.test.ts
@@ -7,16 +7,14 @@ import { INTEREST_TAGS, TAG_TO_CATEGORY } from '../config/interestTags';
// Must stay in sync with the `category` values in prisma/seed.ts — a mapping
// to a category that no activity uses silently disables the interest boost.
const KNOWN_ACTIVITY_CATEGORIES = new Set([
- 'Sports & Fitness',
- 'Food & Drink',
- 'Academic',
- 'Arts & Creative',
- 'Social',
- 'Outdoors',
- 'Music & Entertainment',
- 'Wellness',
+ 'Academic / Study',
+ 'Sports',
+ 'Fitness & Wellness',
+ 'Social & Events',
'Gaming',
'Volunteering',
+ 'Food',
+ 'Music & Arts',
]);
describe('Interest tag category mapping', () => {
diff --git a/backend/src/routes/messages.test.ts b/backend/src/routes/messages.test.ts
index 5867f7b..f4b9cfd 100644
--- a/backend/src/routes/messages.test.ts
+++ b/backend/src/routes/messages.test.ts
@@ -19,7 +19,7 @@ describe('Messages API (integration)', () => {
userId = user.id;
const activity = await prisma.activity.findFirst({
- where: { category: 'Academic' },
+ where: { category: 'Academic / Study' },
});
if (!activity) throw new Error('No Academic activity in seed');
diff --git a/backend/src/routes/peopleYouMet.test.ts b/backend/src/routes/peopleYouMet.test.ts
index 9f8ef91..5213941 100644
--- a/backend/src/routes/peopleYouMet.test.ts
+++ b/backend/src/routes/peopleYouMet.test.ts
@@ -56,7 +56,7 @@ describe('People You Met API', () => {
token2 = u2.token;
userId2 = u2.user.id;
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/podInvites.test.ts b/backend/src/routes/podInvites.test.ts
index e268ed3..36a658c 100644
--- a/backend/src/routes/podInvites.test.ts
+++ b/backend/src/routes/podInvites.test.ts
@@ -33,7 +33,7 @@ describe('Pod invites routes', () => {
await prisma.friendship.create({ data: { userAId, userBId } });
const activity = await prisma.activity.findFirst({
- where: { category: 'Academic' },
+ where: { category: 'Academic / Study' },
});
if (!activity) throw new Error('No Academic activity in seed');
diff --git a/backend/src/routes/podJoinNotification.test.ts b/backend/src/routes/podJoinNotification.test.ts
index 2b037a7..936d6b0 100644
--- a/backend/src/routes/podJoinNotification.test.ts
+++ b/backend/src/routes/podJoinNotification.test.ts
@@ -11,7 +11,7 @@ describe('notifyPodJoin — single call site', () => {
const validLocation = 'Thompson Library';
beforeEach(async () => {
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/podWaitlist.test.ts b/backend/src/routes/podWaitlist.test.ts
index 1f9c2e3..188391d 100644
--- a/backend/src/routes/podWaitlist.test.ts
+++ b/backend/src/routes/podWaitlist.test.ts
@@ -27,7 +27,7 @@ describe('Pod Waitlist API', () => {
waiterToken = w.token;
waiterId = w.user.id;
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/pods.public.test.ts b/backend/src/routes/pods.public.test.ts
index 15d5b92..738c9ba 100644
--- a/backend/src/routes/pods.public.test.ts
+++ b/backend/src/routes/pods.public.test.ts
@@ -10,7 +10,7 @@ describe('GET /pods/:id/public', () => {
const validLocation = 'Thompson Library';
beforeEach(async () => {
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
});
diff --git a/backend/src/routes/pods.test.ts b/backend/src/routes/pods.test.ts
index 5b306e2..b28e53c 100644
--- a/backend/src/routes/pods.test.ts
+++ b/backend/src/routes/pods.test.ts
@@ -21,7 +21,7 @@ describe('Pods API (integration)', () => {
userId = user.id;
const activity = await prisma.activity.findFirst({
- where: { category: 'Academic' },
+ where: { category: 'Academic / Study' },
});
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
@@ -98,6 +98,51 @@ describe('Pods API (integration)', () => {
expect(publicPreview.body.activityName).toBe(res.body.activity.title);
});
+ it('moderates user-authored pod titles locally', async () => {
+ const res = await request(app)
+ .post('/pods/join')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ activityId,
+ title: 'fuuuck this study group',
+ meetupTime: new Date(Date.now() + 86400000).toISOString(),
+ location: validLocation,
+ })
+ .expect(400);
+
+ expect(res.body.error).toContain('safety rules');
+ });
+
+ it('rejects keyboard-mash pod titles locally', async () => {
+ const res = await request(app)
+ .post('/pods/join')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ activityId,
+ title: 'AFOISUDOIAS',
+ meetupTime: new Date(Date.now() + 86400000).toISOString(),
+ location: validLocation,
+ })
+ .expect(400);
+
+ expect(res.body.error).toContain('clear, descriptive pod title');
+ });
+
+ it('allows course codes and numbers in descriptive titles', async () => {
+ const res = await request(app)
+ .post('/pods/join')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ activityId,
+ title: 'CSE 2221 exam review',
+ meetupTime: new Date(Date.now() + 86400000).toISOString(),
+ location: validLocation,
+ })
+ .expect(201);
+
+ expect(res.body.title).toBe('CSE 2221 exam review');
+ });
+
it('rejects without location', async () => {
await request(app)
.post('/pods/join')
@@ -111,7 +156,7 @@ describe('Pods API (integration)', () => {
it('rejects invalid location for activity category', async () => {
const activity = await prisma.activity.findFirst({
- where: { category: 'Sports & Fitness' },
+ where: { category: 'Sports' },
});
if (!activity) throw new Error('No Sports activity');
@@ -210,6 +255,35 @@ describe('Pods API (integration)', () => {
});
});
+ describe('PATCH /pods/:id (edit pod)', () => {
+ it('lets the creator edit the title and moderates the replacement', async () => {
+ const createRes = await request(app)
+ .post('/pods/join')
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ activityId,
+ title: 'Library study session',
+ meetupTime: new Date(Date.now() + 86400000).toISOString(),
+ location: validLocation,
+ })
+ .expect(201);
+
+ const updateRes = await request(app)
+ .patch(`/pods/${createRes.body.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ title: 'Algorithms exam cram' })
+ .expect(200);
+
+ expect(updateRes.body.title).toBe('Algorithms exam cram');
+
+ await request(app)
+ .patch(`/pods/${createRes.body.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ title: 'fuuuck this exam' })
+ .expect(400);
+ });
+ });
+
describe('PATCH /pods/:id/privacy', () => {
it('lets the creator make a pod private and removes it from discovery', async () => {
const createRes = await request(app)
@@ -302,7 +376,7 @@ describe('Pods API (integration)', () => {
);
const sportsActivity = await prisma.activity.findFirst({
- where: { category: 'Sports & Fitness' },
+ where: { category: 'Sports' },
});
if (!sportsActivity) throw new Error('No Sports activity');
@@ -338,13 +412,13 @@ describe('Pods API (integration)', () => {
it('filters by category', async () => {
const res = await request(app)
- .get('/pods/feed?category=Academic')
+ .get('/pods/feed?category=Academic%20%2F%20Study')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
res.body.forEach((p: { activity: { category: string } }) => {
- expect(p.activity.category).toBe('Academic');
+ expect(p.activity.category).toBe('Academic / Study');
});
});
@@ -372,7 +446,7 @@ describe('Pods API (integration)', () => {
);
const sportsActivity = await prisma.activity.findFirst({
- where: { category: 'Sports & Fitness' },
+ where: { category: 'Sports' },
});
if (!sportsActivity) throw new Error('No Sports activity');
@@ -419,7 +493,7 @@ describe('Pods API (integration)', () => {
);
await prisma.user.update({
where: { id: feedUser.id },
- data: { interestTags: JSON.stringify(['Gym']) },
+ data: { interestTags: JSON.stringify(['Sports']) },
});
const { token: academicCreator } = await registerAndGetToken(
@@ -433,7 +507,7 @@ describe('Pods API (integration)', () => {
'password123'
);
const sportsActivity = await prisma.activity.findFirst({
- where: { category: 'Sports & Fitness' },
+ where: { category: 'Sports' },
});
if (!sportsActivity) throw new Error('No Sports activity');
@@ -482,7 +556,7 @@ describe('Pods API (integration)', () => {
);
const sportsActivity = await prisma.activity.findFirst({
- where: { category: 'Sports & Fitness' },
+ where: { category: 'Sports' },
});
if (!sportsActivity) throw new Error('No Sports activity');
diff --git a/backend/src/routes/pods.ts b/backend/src/routes/pods.ts
index f8232f2..c3e0f8f 100644
--- a/backend/src/routes/pods.ts
+++ b/backend/src/routes/pods.ts
@@ -446,7 +446,7 @@ router.post('/join', requireAuth, async (req: AuthRequest, res: Response): Promi
}
// ── Option B: create a new pod for an activity ───────────────────────────
- const activity = await prisma.activity.findUnique({ where: { id: activityId } });
+ const activity = await prisma.activity.findFirst({ where: { id: activityId, isActive: true } });
if (!activity) {
res.status(404).json({ error: 'Activity not found' });
return;
@@ -510,8 +510,13 @@ router.post('/join', requireAuth, async (req: AuthRequest, res: Response): Promi
return;
}
+ const titleModeration = await moderateTextContent([titleInput], { rejectGibberish: true });
+ if (titleModeration) {
+ res.status(titleModeration.status).json({ error: titleModeration.message });
+ return;
+ }
+
const moderation = await moderateTextContent([
- titleInput,
noteInput,
locationInput,
locationAddressInput,
@@ -848,7 +853,7 @@ router.post('/:id/unlock', requireAuth, async (req: AuthRequest, res: Response):
}
});
-// PATCH /pods/:id — creator only; edit meetup time, location, and coordinates
+// PATCH /pods/:id — creator only; edit title, meetup time, location, and coordinates
router.patch('/:id', requireAuth, async (req: AuthRequest, res: Response): Promise => {
const userId = req.user!.userId;
const { id } = req.params;
@@ -876,6 +881,24 @@ router.patch('/:id', requireAuth, async (req: AuthRequest, res: Response): Promi
const data: Record = {};
+ if (req.body.title != null) {
+ const titleInput = typeof req.body.title === 'string' ? req.body.title.trim() : '';
+ if (!titleInput) {
+ res.status(400).json({ error: 'Pod title cannot be empty' });
+ return;
+ }
+ if (titleInput.length > MAX_POD_TITLE_LENGTH) {
+ res.status(400).json({ error: `Pod title cannot exceed ${MAX_POD_TITLE_LENGTH} characters` });
+ return;
+ }
+ const moderation = await moderateTextContent([titleInput], { rejectGibberish: true });
+ if (moderation) {
+ res.status(moderation.status).json({ error: moderation.message });
+ return;
+ }
+ data.title = titleInput;
+ }
+
if (req.body.meetupTime != null) {
const parsed = new Date(req.body.meetupTime);
if (isNaN(parsed.getTime())) {
diff --git a/backend/src/routes/reports.test.ts b/backend/src/routes/reports.test.ts
index 0e71104..f91c0d0 100644
--- a/backend/src/routes/reports.test.ts
+++ b/backend/src/routes/reports.test.ts
@@ -21,7 +21,7 @@ describe('Reports API', () => {
token = t;
userId = user.id;
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activity in seed');
const createRes = await request(app)
@@ -110,7 +110,7 @@ describe('Reports API', () => {
it('messageId must match podId when both provided', async () => {
const activity = await prisma.activity.findFirst({
- where: { category: 'Social' },
+ where: { category: 'Social & Events' },
});
if (!activity) throw new Error('No Social activity');
@@ -120,7 +120,7 @@ describe('Reports API', () => {
.send({
activityId: activity.id,
meetupTime: new Date(Date.now() + 86400000).toISOString(),
- location: 'Student Union',
+ location: 'Ohio Union',
})
.expect(201);
@@ -147,6 +147,8 @@ describe('Reports API', () => {
expect(report?.podId).toBe(podId);
expect(report?.messageId).toBeNull();
expect(report?.targetUserId).toBeNull();
+ expect(report?.reportedContent).toContain('Title:');
+ expect(report?.reportedContent).toContain('Location: Thompson Library');
});
it('creates report for user only', async () => {
diff --git a/backend/src/routes/users.test.ts b/backend/src/routes/users.test.ts
index 1b54230..52b710e 100644
--- a/backend/src/routes/users.test.ts
+++ b/backend/src/routes/users.test.ts
@@ -133,7 +133,7 @@ describe('GET /users/:id', () => {
'password123'
);
- const activity = await prisma.activity.findFirst({ where: { category: 'Academic' } });
+ const activity = await prisma.activity.findFirst({ where: { category: 'Academic / Study' } });
if (!activity) throw new Error('No activity');
// Create a COMPLETED pod and add the user as a member
@@ -589,7 +589,7 @@ describe('Block API (integration)', () => {
userIdB = b.user.id;
const activity = await prisma.activity.findFirst({
- where: { category: 'Academic' },
+ where: { category: 'Academic / Study' },
});
if (!activity) throw new Error('No activities in seed');
activityId = activity.id;
diff --git a/backend/src/services/reportService.ts b/backend/src/services/reportService.ts
index be59407..cf9fcf1 100644
--- a/backend/src/services/reportService.ts
+++ b/backend/src/services/reportService.ts
@@ -163,12 +163,29 @@ export async function createReport(
}
if (podId && !messageId) {
- const pod = await prisma.pod.findUnique({ where: { id: podId } });
+ const pod = await prisma.pod.findUnique({
+ where: { id: podId },
+ select: {
+ id: true,
+ title: true,
+ note: true,
+ location: true,
+ activity: { select: { title: true } },
+ },
+ });
if (!pod) {
throw new Error('Pod not found');
}
resolvedPodId = pod.id;
targetType = targetType ?? 'POD';
+ // Snapshot the user-authored plan text so it remains reviewable if the pod
+ // is later edited or deleted.
+ reportedContent = [
+ `Title: ${pod.title?.trim() || pod.activity.title}`,
+ `Activity: ${pod.activity.title}`,
+ pod.note ? `Note: ${pod.note}` : null,
+ `Location: ${pod.location}`,
+ ].filter(Boolean).join('\n').slice(0, 2000);
}
if (clubId && !clubMessageId && !clubOfficerMessageId && !clubAnnouncementId) {
@@ -292,6 +309,7 @@ export async function adminListReports(filters: AdminReportFilters) {
pod: {
select: {
id: true,
+ title: true,
activity: { select: { title: true } },
location: true,
},
@@ -355,6 +373,7 @@ export async function adminGetReport(id: string) {
pod: {
select: {
id: true,
+ title: true,
location: true,
activity: { select: { title: true } },
},
@@ -467,6 +486,7 @@ async function notifyModerationReport(reportId: string): Promise {
pod: {
select: {
id: true,
+ title: true,
location: true,
activity: { select: { title: true } },
},
diff --git a/docs/GOOGLE_PLAY_ROADMAP.md b/docs/GOOGLE_PLAY_ROADMAP.md
index 9f587f1..5168e9c 100644
--- a/docs/GOOGLE_PLAY_ROADMAP.md
+++ b/docs/GOOGLE_PLAY_ROADMAP.md
@@ -34,12 +34,12 @@ You want your name off the app anyway. A personal Play account displays your leg
## Phase 1 — Technical build (do in parallel with Phase 0)
-Current repo state: `app.json` already has the Android package (`com.bradyvb.ovalapp`), adaptive icon, and App Links intent filters; `web.ts` already serves `/.well-known/assetlinks.json`; an Android notification channel is registered at HIGH importance; backend push (expo-server-sdk) is platform-agnostic. Remaining gaps:
+Current repo state: `app.json` uses the Android package (`com.theovalapp.app`), includes adaptive icon and App Links intent filters, and the landing deployment proxies `/.well-known/assetlinks.json` to the backend; an Android notification channel is registered at HIGH importance; backend push (expo-server-sdk) is platform-agnostic. Remaining gaps:
-- [ ] **Package name check.** `com.bradyvb.ovalapp` is permanent once uploaded — it contains your name. If that bothers you, change it to e.g. `com.theovalapp.app` BEFORE the first upload (it's invisible-ish to users but shows in the Play URL: `play.google.com/store/apps/details?id=...`). Update `app.json`, `ANDROID_PACKAGE` on the backend, and assetlinks accordingly.
+- [x] **Package name check.** The release package is `com.theovalapp.app`; keep `app.json`, `ANDROID_PACKAGE`, Firebase, and the Play listing aligned to it.
- [ ] **FCM credentials.** Expo push on Android requires Firebase: create a Firebase project, add the Android app, download `google-services.json` (reference it via `android.googleServicesFile` in app.json), and upload the **FCM V1 service-account key** to EAS credentials (`eas credentials`). No backend changes needed — Expo's push service routes to FCM automatically.
- [ ] **First Android build:** `eas build --platform android --profile production` (produces .aab; EAS manages the upload keystore).
-- [ ] **App Links SHA-256.** After Play App Signing is set up (first upload), copy the app-signing cert SHA-256 from Play Console → set the `ANDROID_SHA256` env (backend `.env.example` + `productionEnv.ts` per repo convention) so `assetlinks.json` verifies. Until then Android deep links fall back to the browser interstitials — acceptable, not silent breakage.
+- [ ] **App Links SHA-256.** After Play App Signing is set up (first upload), copy the app-signing cert SHA-256 from Play Console → set `ANDROID_SHA256_CERT_FINGERPRINT` on the backend and redeploy. Then verify both `https://api.theovalapp.com/.well-known/assetlinks.json` and `https://www.theovalapp.com/.well-known/assetlinks.json` contain the fingerprint.
- [ ] **Target API level.** New apps must target API 35 now, **API 36 (Android 16) from Aug 31, 2026**. Expo SDK 56 should satisfy this — confirm `targetSdkVersion` in the build output; upgrade SDK if not. Don't cut it close to the Aug 31 boundary.
- [ ] **EAS submit config.** Add an `android` block to `eas.json` submit profile: create a Google Cloud service account in Play Console (Setup → API access), download its JSON key, reference as `serviceAccountKeyPath`. First submission must be manual through the Console UI; `eas submit` works after that.
- [ ] **Design QA on Android.** AUDIT_2026 D1 (blur on Android) is still open — Lumen leans on `expo-blur`, which is slow/glitchy on Android. Decide: ship Lumen 2.0's "blur only on chrome" surfaces for Android, or set a no-blur fallback. Also spot-check: predictive back (already disabled), keyboard behavior in chat, edge-to-edge insets, splash on cold start.
diff --git a/docs/LAUNCH_MANUAL_GATES_2026-08-03.md b/docs/LAUNCH_MANUAL_GATES_2026-08-03.md
new file mode 100644
index 0000000..80b5440
--- /dev/null
+++ b/docs/LAUNCH_MANUAL_GATES_2026-08-03.md
@@ -0,0 +1,39 @@
+# Launch manual gates — August 3, 2026
+
+The repository-side launch work is covered by the automated release checks. These items still require credentials, a store console, a deployment, or real people and must not be faked in code.
+
+## Deploy the catalog and link routing
+
+- Deploy the backend so migration `20260804010000_activity_catalog_v2` runs. It preserves old activities as inactive and inserts the 41 new active activities.
+- Deploy `landing/` so `https://www.theovalapp.com/.well-known/assetlinks.json` proxies to the API.
+- Confirm the API returns exactly 41 active activities in `sortOrder` order from an authenticated account.
+
+## Finish Android App Links
+
+1. Build the first production AAB for package `com.theovalapp.app` and upload it to the Play internal track.
+2. In Play Console, copy the **App signing key certificate** SHA-256 fingerprint—not the upload-key fingerprint.
+3. Set backend production env `ANDROID_SHA256_CERT_FINGERPRINT` to the colon-delimited fingerprint and redeploy.
+4. Confirm both association URLs contain `com.theovalapp.app` and the non-empty fingerprint:
+ - `https://api.theovalapp.com/.well-known/assetlinks.json`
+ - `https://www.theovalapp.com/.well-known/assetlinks.json`
+5. On a physical Android device with the internal-track build, open a real `/pod/:id`, `/clubs/:id`, and `/users/:id` web link and confirm each opens Oval directly.
+
+## Enable release crash reporting
+
+- Add `EXPO_PUBLIC_SENTRY_DSN` to the EAS production environment.
+- Add the Sentry organization, project, and auth-token build secrets, then remove `SENTRY_DISABLE_AUTO_UPLOAD=true` from the production EAS profile so release sourcemaps upload.
+- Force one test exception in a non-production build and confirm it arrives with the correct environment and readable stack trace before submission.
+
+## Put real supply in the app
+
+- Recruit and verify the ambassador accounts that will own launch pods.
+- Set `SEED_AMBASSADOR_EMAILS` and run `npm run seed:launch --prefix backend` against the verified production database. The script is idempotent and now creates specific pod titles under the generalized activities.
+- Confirm upcoming pods and club meetings are real, staffed, and still scheduled. Do not use screenshot/demo users as launch supply.
+
+## Store and device checks
+
+- Create a production Android build after the version, permission, and package changes; the July preview build used the retired package and is not a release candidate.
+- Complete Play Data Safety/App Access using the live privacy policy and a working pre-verified reviewer account.
+- Upload the Play feature graphic and current phone screenshots.
+- Test sign-up, reviewer bypass, location, photo picking, push permission/delivery, pod creation/join/chat, club flows, report/block, data export, and account deletion on current physical iOS and Android devices.
+- Run `npm run release:check` under Node 22 immediately before each store build.
diff --git a/docs/LAUNCH_PLAN_FALL2026.md b/docs/LAUNCH_PLAN_FALL2026.md
index f462cb9..02b8f5f 100644
--- a/docs/LAUNCH_PLAN_FALL2026.md
+++ b/docs/LAUNCH_PLAN_FALL2026.md
@@ -2,11 +2,120 @@
Every known task from today to school launch, with dependencies and order. OSU autumn semester starts ~Aug 25; move-in/welcome week ~Aug 19–24. That's ~5 weeks. Companion docs: `GOOGLE_PLAY_ROADMAP.md` (Play detail), `STATE_2026-07-06.md` (product backlog), `onboard-a-club.md` (club flow).
+## August 2 reset — this section overrides the older sequence below
+
+Assumption from Brady on August 2: none of the external/business/testing/outreach
+sessions below have been completed. The iOS app is already live. D-U-N-S is still
+pending. Columbus move-in begins August 19 and classes begin August 25.
+
+### Strategy change
+
+- **Do not wait for the Apple organization transfer to start outreach.** The live
+ iOS listing is enough for club conversations; moving the publisher name to the
+ LLC is now post-launch unless D-U-N-S and Apple verification clear unusually fast.
+- **Keep Google Play organization enrollment as the preferred Android path.** If
+ D-U-N-S arrives, create the Google organization account that same day and submit
+ as quickly as verification allows.
+- **Prepare an APK by August 7 as a contingency, not the public launch channel.**
+ Share it only with known Android club leaders/testers via a direct EAS install
+ link. Sideloading has security-warning/install friction and does not help iPhone
+ users. Public campus QR codes should lead to the live iOS listing plus a clearly
+ labeled Android early-access option until Play is live.
+ A successful July 20 preview APK proves the pipeline works, but its EAS artifact
+ expires August 3 and predates current changes, so it is not the launch binary.
+- **Launch density-first.** August 19 is a soft launch through 3–5 onboarded clubs
+ and one or two dense student clusters. Expand around August 25 only if the board
+ has real upcoming activity and the first cohort is activating.
+- **No new product features before launch.** Only crash/data-loss/auth/moderation,
+ broken onboarding, broken push/deep links, and empty-launch-supply fixes qualify.
+
+### Remaining calendar buffer
+
+| Workstream | Latest safe date | Buffer on Aug 2 | Status |
+|---|---:|---:|---|
+| Club list + first personalized outreach | **Aug 3** | ~1 day | Start now |
+| Android preview APK installed and fully smoke-tested | **Aug 7** | 5 days | Tight |
+| Play production submission, if org account clears | **Aug 10–12** | 8–10 days | Very tight |
+| 3–5 clubs committed with real first meetings | **Aug 15** | 13 days | Tight |
+| Launch content seeded + links/QRs rehearsed | **Aug 17** | 15 days | Tight |
+| Density-first soft launch | **Aug 19** | 17 days | Fixed |
+| First-day-of-classes expansion | **Aug 25** | 23 days | Conditional |
+
+### Revised execution schedule
+
+**Sunday Aug 2 — recovery day (4–6 focused hours)**
+
+1. Check D-U-N-S email/status and collect the exact LLC/D&B details in one note.
+2. Trigger the existing `preview` Android EAS build; it is configured for internal
+ distribution and should produce an installable APK.
+3. While it builds, create the first 30-club prospect list and write the outreach
+ email. Rank the top 10 by social/recreation fit and recent activity.
+4. Verify the deployed web account-deletion flow and create the pre-verified
+ reviewer account. Do not assume either is ready because the code exists.
+
+**Monday Aug 3 — first external motion**
+
+1. Send 5–10 personalized club emails from the OSU/domain address. Offer a
+ 15-minute setup where Brady does the work. Do not mention a mass campus launch;
+ pitch founding-club access and a live iOS app with Android early access.
+2. Install the APK on an Android Studio emulator using a Google Play system image.
+ Run the complete light/dark, signup, join, chat, push, deep-link, report/block,
+ account deletion, and data-export tour. Camera/library behavior can be exercised
+ with emulator media, but real hardware remains a later confidence check.
+3. Draft Play Data Safety, store copy, reviewer instructions, and feature graphic.
+
+**Tuesday Aug 4 — hard D-U-N-S decision**
+
+1. If the number has not arrived, contact D&B and pay for expedited processing if
+ that option is available for this request. Do not keep passively waiting.
+2. Fix only launch-blocking Android issues from the Aug 3 test.
+3. Send the second personalized club batch and book onboarding calls for Aug 5–11.
+
+**Aug 5–7 — freeze the installable build**
+
+1. Rebuild and re-test after blocker fixes; produce the final APK fallback by Aug 7.
+2. Finish Play listing/compliance assets so account-verification day is paste/upload,
+ not writing from scratch.
+3. Follow up with the first club batch after 48–72 hours; send 5–10 new contacts/day.
+4. Recruit one Android-owning friend or club leader to install the same APK remotely
+ by Aug 10 and check real-device camera/library, notification delivery, cold-start
+ notification routing, and a web invite link. Buying a device is not required.
+
+**Aug 8–12 — store deadline and club conversions**
+
+1. When D-U-N-S arrives, open the Google organization account immediately. Apple
+ enrollment may run in parallel but cannot consume launch-critical hours.
+2. When Google verification clears: upload the AAB to internal testing, verify Play
+ signing/deep links, then submit production immediately. Target Aug 10; Aug 12 is
+ the revised hard ceiling for a plausible welcome-week approval.
+3. Complete at least three live club onboardings. Each officer must post one real
+ Aug 19–31 meeting/event before the onboarding call ends.
+
+**Aug 13–17 — manufacture launch liquidity**
+
+1. Reach 3–5 committed clubs, 2–3 student ambassadors, and a seven-day calendar of
+ real activity. Seed only real officer/ambassador-hosted plans—no fake personas.
+2. Finalize QR destination, install instructions, invite links, analytics baseline,
+ Sentry access, moderation response, and launch-day support message.
+3. Run a complete launch rehearsal on Aug 17 using a new account and both platforms
+ available at that time.
+
+**Aug 18–25 — controlled launch**
+
+1. Aug 18: onboarded clubs announce to their own GroupMes/Instagram stories first.
+2. Aug 19–22: soft-launch into those dense clusters during move-in; monitor crashes,
+ signup verification, activation, and whether users see at least three joinable
+ options. Personally respond to every club/user issue.
+3. Aug 23–24: fix only blockers and reseed real upcoming supply.
+4. Aug 25: expand promotion only if the initial cohort has real activity. Otherwise
+ keep the launch narrow for another week rather than burn the campus-wide first
+ impression.
+
**Deadline math (work backwards from Aug 24):**
- Play review ≈ up to 7 days + fix buffer → **production submission by ~Aug 10**
- Play org verification ≈ days–2 weeks after D-U-N-S → account created the day the number arrives
- Apple org enrollment + app transfer ≈ 1–2 weeks total → also starts the day D-U-N-S arrives
-- Club outreach needs the App Store name changed (Apple transfer done) → realistically ~Aug 5–15 window, batched
+- Club outreach starts Aug 3 using the already-live iOS app; publisher-name transfer no longer blocks it
- **D-U-N-S is the fuse for everything above.** Requested July 21. If not arrived by ~Aug 4, pay for expedited — that's the decision date, not now.
Legend: ⏳ waiting on external party · 🔒 blocked by listed item · ✅ done
@@ -48,7 +157,7 @@ Ohio has no annual LLC report — no recurring state paperwork. CAT tax irreleva
| C3 | Play Console app setup: store listing, all App-content declarations (data safety, IARC content rating, target audience 18+, App access reviewer creds, no-ads) — drafts from E3/E4/E5 | 🔒 C2, E3–E5 | 2 hrs |
| C4 | Play Console → API access → service account for `eas submit`; add android block to eas.json submit config | 🔒 C1 | 30 min |
| C5 | Upload first .aab to **internal testing**; install via Play on emulator/device; smoke test | 🔒 C2, C4, D7 | 1 hr |
-| C6 | Set `ANDROID_SHA256` env from Play App Signing cert (Play Console → App signing) so assetlinks.json verifies; redeploy backend; test Android deep links | 🔒 C5 | 30 min |
+| C6 | Set `ANDROID_SHA256_CERT_FINGERPRINT` from the Play App Signing cert so assetlinks.json verifies on both API and landing hosts; redeploy backend; test Android deep links | 🔒 C5 | 30 min |
| C7 | (Optional) closed-testing track with club members / your Android friend for a week | 🔒 C5 | ongoing |
| C8 | **Production submission by ~Aug 10** | 🔒 C5 (C7 optional), E1 | 30 min + ⏳ ~7 days review |
| C9 | Post-approval: staged rollout → 100%; add Play steps to release checklist; ANDROID_STORE_URL env for invite pages | 🔒 C8 | 30 min |
@@ -82,7 +191,7 @@ Ohio has no annual LLC report — no recurring state paperwork. CAT tax irreleva
| F1 | Build club-leader list: OSU student org directory → name, org, category, email (spreadsheet; prioritize 30–50 active social/rec/hobby orgs) | — | 2–3 hrs |
| F2 | Outreach email: personalized template + 2 variants; from OSU address; small batches (deliverability + OSU bulk-mail policy) | — | 1 hr |
| F3 | Polish `onboard-a-club.md` into a repeatable 15-min flow (officer signup → club claim → first meeting posted) | — | 1–2 hrs |
-| F4 | **Send outreach in batches** (~10/day, personalized) | 🔒 B3 (name off App Store), F1, F2 | ~30 min/day |
+| F4 | **Send outreach in batches** (~5–10/day, personalized) | 🔒 F1, F2 | ~30 min/day |
| F5 | Pre-onboard 3–5 clubs with real officers before semester (audit E1 calls this a launch blocker, not a nicety) | 🔒 F4 | ongoing |
| F6 | Seed week-one content: ambassador pods + near-future pods so day-1 boards aren't empty | 🔒 F5 | welcome week |
| F7 | Welcome-week presence: QR posters/flyers, club fair if accessible, ask onboarded clubs to announce | 🔒 F5 | Aug 19–25 |
diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md
index e56d7d1..fa95abc 100644
--- a/docs/RUNBOOK.md
+++ b/docs/RUNBOOK.md
@@ -1,5 +1,10 @@
# Oval — Do-This-Next Runbook
+> **August 2 reset:** follow the reset section at the top of
+> `LAUNCH_PLAN_FALL2026.md` first. The older session order below remains useful
+> as detailed instructions, but club outreach and the Android APK fallback are
+> no longer blocked on the Apple organization transfer.
+
No decisions required. Work top to bottom. Each session is one sitting; each step says exactly what to type or click. Check things off as you go.
Companion docs: `LAUNCH_PLAN_FALL2026.md` (the why/dependencies), `GOOGLE_PLAY_ROADMAP.md` (Play detail).
@@ -99,7 +104,7 @@ eas build --platform android --profile preview
Wait ~20 min. While it builds, do step 3.2.
-**3.2 Boot the emulator** — Android Studio → More Actions → Virtual Device Manager → ▶ next to Pixel 10. Leave it running.
+**3.2 Boot the emulator** — Android Studio → More Actions → Virtual Device Manager → ▶ next to Pixel 10. Use a system image labeled **Google Play** so Play Services and FCM are available. Leave it running.
**3.3 Install**
@@ -123,6 +128,11 @@ eas build:run -p android --latest
**3.7** Send me the punch list and I'll fix them.
+**3.8 Real-device check (remote is fine):** by Aug 10, send the final preview-build
+link to one trusted Android owner. Ask them to test camera/library avatar selection,
+a background and force-killed push notification, notification tap routing, and a
+`theovalapp.com` invite link. You do not need to own or buy an Android phone.
+
---
# SESSION 4 — Operating agreement + business admin (~45 min)
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US.zip
new file mode 100644
index 0000000..c2631da
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/01-campus-pulse.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/01-campus-pulse.png
new file mode 100644
index 0000000..5bb42eb
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/01-campus-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/02-explore.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/02-explore.png
new file mode 100644
index 0000000..ef18903
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/02-explore.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/03-my-pods.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/03-my-pods.png
new file mode 100644
index 0000000..34dafa6
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/03-my-pods.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/04-inbox.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/04-inbox.png
new file mode 100644
index 0000000..1d3876b
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/04-inbox.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/05-clubs.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/05-clubs.png
new file mode 100644
index 0000000..a51a331
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/05-clubs.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/06-club-events.png b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/06-club-events.png
new file mode 100644
index 0000000..fbc68b5
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.5-inch-en-US/06-club-events.png differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.9-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.9-inch-en-US.zip
new file mode 100644
index 0000000..dcd90ef
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-6.9-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.5-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.5-inch-en-US.zip
new file mode 100644
index 0000000..b1c851d
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.5-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.9-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.9-inch-en-US.zip
new file mode 100644
index 0000000..2f0aa9b
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v2-6.9-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.5-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.5-inch-en-US.zip
new file mode 100644
index 0000000..4109815
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.5-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.9-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.9-inch-en-US.zip
new file mode 100644
index 0000000..67c18da
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-FINAL-v3-6.9-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.5-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.5-inch-en-US.zip
new file mode 100644
index 0000000..97d3a5c
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.5-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.9-inch-en-US.zip b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.9-inch-en-US.zip
new file mode 100644
index 0000000..2a397d8
Binary files /dev/null and b/docs/app-store-screenshots-2026/Oval-App-Store-Screenshots-SIMPLE-v4-6.9-inch-en-US.zip differ
diff --git a/docs/app-store-screenshots-2026/README.md b/docs/app-store-screenshots-2026/README.md
new file mode 100644
index 0000000..484dff7
--- /dev/null
+++ b/docs/app-store-screenshots-2026/README.md
@@ -0,0 +1,77 @@
+# Oval App Store screenshots — simple reference-matched v4
+
+The current direction is a six-image, English (US) iPhone App Store story for
+Oval that deliberately matches the supplied v1.0.1 references: warm-white
+backgrounds, oversized black headlines, one scarlet italic phrase, muted gray
+supporting copy, and clean angled phones. There are no collage textures,
+stickers, poster panels, campaign badges, decorative branding, or background
+illustrations.
+
+Final exports are flattened RGB PNGs in both Apple's current 6.9-inch size
+(`1320 × 2868`) and the provided 6.5-inch size (`1284 × 2778`).
+
+## Final sequence
+
+1. `01-find-your-people.png` — current Explore screen
+2. `02-join-the-plan.png` — current My Pods screen
+3. `03-plan-it-together.png` — current main Inbox screen
+4. `04-every-club-one-place.png` — current Clubs and Club Home screens
+5. `05-stay-in-the-loop.png` — current Club Home screen
+6. `06-rsvp-in-a-tap.png` — current meeting RSVP/check-in screen
+
+The screen content comes from Oval's deterministic development preview
+fixtures. It contains no production accounts or private user data, and the app
+interface itself is not fabricated.
+
+## Upload packages
+
+- `Oval-App-Store-Screenshots-SIMPLE-v4-6.9-inch-en-US.zip`
+- `Oval-App-Store-Screenshots-SIMPLE-v4-6.5-inch-en-US.zip`
+
+## Working files
+
+- `iteration-v4-simple/apple/en-US/6.9-inch/` — current upload-ready images
+- `iteration-v4-simple/apple/en-US/6.5-inch/` — 6.5-inch upload-ready images
+- `iteration-v4-simple/contact-sheet-simple-v4.png` — final visual review sheet
+- `captures/*-v2.png` — dense current UI captures at 390 × 844
+- `captures/inbox-pod-chats-v3.png` and `captures/inbox-invites-v3.png` — real filtered Inbox states
+- `generate_simple.py` — deterministic reference-matched compositor
+
+The earlier v1–v3 exports are retained for comparison rather than silently
+deleting the previous iterations. The generated collage background is used only
+by the superseded v3 direction, not by v4.
+
+## Regenerate v4
+
+```sh
+/Users/brady/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3 generate_simple.py
+```
+
+## Previous v3 background-generation prompt
+
+> Use case: ads-marketing. Asset type: reusable vertical background plate for a
+> six-image App Store screenshot campaign for Oval, an Ohio State campus
+> community app. Primary request: replace elegant editorial minimalism with
+> energetic, social, student-made campus bulletin-board energy. Scene/backdrop:
+> edge-to-edge layered torn paper, overlapping poster shapes,
+> photo-booth-frame silhouettes, hand-drawn oval loops, chunky
+> arrows, confetti, halftone dots, sticker stars, and friendly imperfect
+> marker-like accents. Style/medium: bold contemporary risograph and cut-paper
+> collage, youthful and communal, polished enough for the App Store but clearly
+> lively rather than luxurious. Composition/framing: vertical 9:19.5; visually
+> active across the full canvas with no large blank white or ivory region; keep
+> the center readable enough for a large phone screenshot, while the sides and
+> corners carry dense energy. Color palette: saturated Oval scarlet and warm
+> cream anchored by electric cobalt blue, sunflower yellow, mint green,
+> lavender, and small black accents. Constraints: no phones, no app interface,
+> no readable words, no letters, no logos, no brands, no watermarks, no
+> photorealistic people, no luxury watercolor, no elegant serif mood, no empty
+> upper third.
+
+## Final background cleanup edit
+
+The built-in image editor removed every beige, tan, peach, and pink masking-tape
+strip from the background while preserving the torn-paper composition, photo
+strip, arrows, stars, ovals, halftone pattern, palette, and texture. The edit
+explicitly prohibited text, logos, phones, app UI, people, watermarks, and new
+objects.
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/01-campus-pulse.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/01-campus-pulse.png
new file mode 100644
index 0000000..5bb42eb
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/01-campus-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/02-explore.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/02-explore.png
new file mode 100644
index 0000000..ef18903
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/02-explore.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/03-my-pods.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/03-my-pods.png
new file mode 100644
index 0000000..34dafa6
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/03-my-pods.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/04-inbox.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/04-inbox.png
new file mode 100644
index 0000000..1d3876b
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/04-inbox.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/05-clubs.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/05-clubs.png
new file mode 100644
index 0000000..a51a331
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/05-clubs.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/06-club-events.png b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/06-club-events.png
new file mode 100644
index 0000000..fbc68b5
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.5-inch/06-club-events.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/01-campus-pulse.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/01-campus-pulse.png
new file mode 100644
index 0000000..384e2a7
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/01-campus-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/02-explore.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/02-explore.png
new file mode 100644
index 0000000..b24ebf8
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/02-explore.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/03-my-pods.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/03-my-pods.png
new file mode 100644
index 0000000..c8000ef
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/03-my-pods.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/04-inbox.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/04-inbox.png
new file mode 100644
index 0000000..9090d10
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/04-inbox.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/05-clubs.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/05-clubs.png
new file mode 100644
index 0000000..7db35f0
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/05-clubs.png differ
diff --git a/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/06-club-events.png b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/06-club-events.png
new file mode 100644
index 0000000..588f0b7
Binary files /dev/null and b/docs/app-store-screenshots-2026/apple/en-US/6.9-inch/06-club-events.png differ
diff --git a/docs/app-store-screenshots-2026/captures/club-home-v2.png b/docs/app-store-screenshots-2026/captures/club-home-v2.png
new file mode 100644
index 0000000..d339dbc
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/club-home-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/club-home.png b/docs/app-store-screenshots-2026/captures/club-home.png
new file mode 100644
index 0000000..9ac750c
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/club-home.png differ
diff --git a/docs/app-store-screenshots-2026/captures/clubs-v2.png b/docs/app-store-screenshots-2026/captures/clubs-v2.png
new file mode 100644
index 0000000..c31a16a
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/clubs-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/clubs.png b/docs/app-store-screenshots-2026/captures/clubs.png
new file mode 100644
index 0000000..1aeec28
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/clubs.png differ
diff --git a/docs/app-store-screenshots-2026/captures/explore-v2.png b/docs/app-store-screenshots-2026/captures/explore-v2.png
new file mode 100644
index 0000000..b9dad40
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/explore-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/explore.png b/docs/app-store-screenshots-2026/captures/explore.png
new file mode 100644
index 0000000..3dc140e
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/explore.png differ
diff --git a/docs/app-store-screenshots-2026/captures/home-v2.png b/docs/app-store-screenshots-2026/captures/home-v2.png
new file mode 100644
index 0000000..71c2094
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/home-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/home.png b/docs/app-store-screenshots-2026/captures/home.png
new file mode 100644
index 0000000..857b254
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/home.png differ
diff --git a/docs/app-store-screenshots-2026/captures/inbox-invites-v3.png b/docs/app-store-screenshots-2026/captures/inbox-invites-v3.png
new file mode 100644
index 0000000..28cf071
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/inbox-invites-v3.png differ
diff --git a/docs/app-store-screenshots-2026/captures/inbox-pod-chats-v3.png b/docs/app-store-screenshots-2026/captures/inbox-pod-chats-v3.png
new file mode 100644
index 0000000..45644fc
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/inbox-pod-chats-v3.png differ
diff --git a/docs/app-store-screenshots-2026/captures/inbox-v2.png b/docs/app-store-screenshots-2026/captures/inbox-v2.png
new file mode 100644
index 0000000..fd5f2b1
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/inbox-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/inbox.png b/docs/app-store-screenshots-2026/captures/inbox.png
new file mode 100644
index 0000000..73f8091
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/inbox.png differ
diff --git a/docs/app-store-screenshots-2026/captures/meeting-v2.png b/docs/app-store-screenshots-2026/captures/meeting-v2.png
new file mode 100644
index 0000000..cc974c6
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/meeting-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/meeting.png b/docs/app-store-screenshots-2026/captures/meeting.png
new file mode 100644
index 0000000..f415468
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/meeting.png differ
diff --git a/docs/app-store-screenshots-2026/captures/pods-v2.png b/docs/app-store-screenshots-2026/captures/pods-v2.png
new file mode 100644
index 0000000..b460cf8
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/pods-v2.png differ
diff --git a/docs/app-store-screenshots-2026/captures/pods.png b/docs/app-store-screenshots-2026/captures/pods.png
new file mode 100644
index 0000000..866302d
Binary files /dev/null and b/docs/app-store-screenshots-2026/captures/pods.png differ
diff --git a/docs/app-store-screenshots-2026/contact-sheet.png b/docs/app-store-screenshots-2026/contact-sheet.png
new file mode 100644
index 0000000..3ab150d
Binary files /dev/null and b/docs/app-store-screenshots-2026/contact-sheet.png differ
diff --git a/docs/app-store-screenshots-2026/generate.py b/docs/app-store-screenshots-2026/generate.py
new file mode 100644
index 0000000..213ccc0
--- /dev/null
+++ b/docs/app-store-screenshots-2026/generate.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+"""Generate Oval's 6.5-inch App Store screenshot set at 1284x2778."""
+
+from __future__ import annotations
+
+import math
+import textwrap
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps
+
+
+ROOT = Path(__file__).resolve().parent
+CAPTURES = ROOT / "captures"
+GENERATED = ROOT / "generated"
+OUTPUT_65 = ROOT / "apple" / "en-US" / "6.5-inch"
+OUTPUT_69 = ROOT / "apple" / "en-US" / "6.9-inch"
+
+WIDTH = 1284
+HEIGHT = 2778
+
+SCARLET = (215, 9, 18)
+INK = (16, 18, 24)
+PAPER = (251, 248, 242)
+
+FONT_BOLD = "/System/Library/Fonts/Avenir Next.ttc"
+FONT_REGULAR = "/System/Library/Fonts/HelveticaNeue.ttc"
+FONT_SERIF_ITALIC = "/System/Library/Fonts/Supplemental/Georgia Bold Italic.ttf"
+
+
+STORIES = [
+ {
+ "file": "01-campus-pulse.png",
+ "tone": "paper",
+ "headline": [("Campus,", "bold"), ("actually happening.", "accent")],
+ "subhead": "See the plans, clubs, and people moving around you.",
+ "tag": "Your campus pulse",
+ "devices": [("home.png", 850, 217, 790, 0.0, 1.0)],
+ },
+ {
+ "file": "02-explore.png",
+ "tone": "scarlet",
+ "headline": [("Find your", "bold"), ("next move.", "accent")],
+ "subhead": "Browse real small-group plans happening around campus.",
+ "tag": "Explore what’s next",
+ "devices": [("explore.png", 850, 214, 805, -3.2, 1.0)],
+ },
+ {
+ "file": "03-my-pods.png",
+ "tone": "paper",
+ "headline": [("Turn “we should”", "bold"), ("into tonight.", "accent")],
+ "subhead": "Keep every plan—and the people in it—close at hand.",
+ "tag": "Small groups, real plans",
+ "devices": [("pods.png", 850, 214, 800, 3.0, 1.0)],
+ },
+ {
+ "file": "04-inbox.png",
+ "tone": "dark",
+ "headline": [("Stay close to", "bold"), ("your people.", "accent")],
+ "subhead": "Messages, pod chats, and invites—together in one place.",
+ "tag": "Always in the loop",
+ "devices": [("inbox.png", 850, 212, 825, -1.8, 1.0)],
+ },
+ {
+ "file": "05-clubs.png",
+ "tone": "paper",
+ "headline": [("Clubs that", "bold"), ("feel alive.", "accent")],
+ "subhead": "Discover organizations, events, and the people behind them.",
+ "tag": "One home for every club",
+ "devices": [
+ ("club-home.png", 690, 610, 920, 7.0, 0.90),
+ ("clubs.png", 740, -20, 835, -6.0, 1.0),
+ ],
+ },
+ {
+ "file": "06-club-events.png",
+ "tone": "scarlet",
+ "headline": [("RSVP & show up.", "bold"), ("Belong.", "accent")],
+ "subhead": "Club events from the first invite to check-in.",
+ "tag": "Make campus yours",
+ "devices": [
+ ("club-home.png", 650, -300, 945, -7.0, 0.82),
+ ("meeting.png", 825, 225, 820, 1.8, 1.0),
+ ],
+ },
+]
+
+
+def cover(image: Image.Image, size: tuple[int, int]) -> Image.Image:
+ target_w, target_h = size
+ scale = max(target_w / image.width, target_h / image.height)
+ resized = image.resize(
+ (math.ceil(image.width * scale), math.ceil(image.height * scale)),
+ Image.Resampling.LANCZOS,
+ )
+ left = (resized.width - target_w) // 2
+ top = (resized.height - target_h) // 2
+ return resized.crop((left, top, left + target_w, top + target_h))
+
+
+def vertical_gradient(top: tuple[int, int, int, int], bottom: tuple[int, int, int, int]) -> Image.Image:
+ image = Image.new("RGBA", (WIDTH, HEIGHT))
+ draw = ImageDraw.Draw(image)
+ for y in range(HEIGHT):
+ t = y / (HEIGHT - 1)
+ color = tuple(round(top[i] * (1 - t) + bottom[i] * t) for i in range(4))
+ draw.line((0, y, WIDTH, y), fill=color)
+ return image
+
+
+def base_background(tone: str) -> Image.Image:
+ art = cover(Image.open(GENERATED / "oval-editorial-background.png").convert("RGBA"), (WIDTH, HEIGHT))
+ if tone == "dark":
+ overlay = vertical_gradient((13, 17, 28, 245), (13, 17, 28, 142))
+ elif tone == "scarlet":
+ overlay = vertical_gradient((163, 0, 8, 247), (126, 3, 17, 118))
+ else:
+ overlay = vertical_gradient((255, 253, 248, 250), (255, 250, 241, 30))
+ art = Image.alpha_composite(art, overlay)
+
+ halo = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
+ hd = ImageDraw.Draw(halo)
+ if tone == "paper":
+ hd.ellipse((850, 200, 1450, 800), fill=(215, 9, 18, 25))
+ hd.ellipse((-220, 1180, 430, 1840), fill=(74, 99, 166, 22))
+ elif tone == "dark":
+ hd.ellipse((760, 120, 1510, 870), fill=(215, 9, 18, 46))
+ hd.ellipse((-280, 1380, 420, 2080), fill=(97, 115, 193, 38))
+ else:
+ hd.ellipse((780, 120, 1490, 830), fill=(255, 216, 206, 34))
+ hd.ellipse((-250, 1300, 440, 1990), fill=(24, 38, 75, 34))
+ art = Image.alpha_composite(art, halo.filter(ImageFilter.GaussianBlur(95)))
+
+ arcs = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 0))
+ ad = ImageDraw.Draw(arcs)
+ line = (255, 255, 255, 47) if tone in {"dark", "scarlet"} else (215, 9, 18, 34)
+ ad.ellipse((915, 350, 1470, 905), outline=line, width=3)
+ ad.ellipse((-150, 575, 120, 845), outline=line, width=3)
+ return Image.alpha_composite(art, arcs)
+
+
+def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
+ mask = Image.new("L", size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0], size[1]), radius=radius, fill=255)
+ return mask
+
+
+def paste_shadowed(
+ base: Image.Image,
+ layer: Image.Image,
+ xy: tuple[int, int],
+ *,
+ blur: int,
+ offset: tuple[int, int],
+ opacity: int,
+) -> None:
+ shadow = Image.new("RGBA", layer.size, (10, 10, 12, 0))
+ alpha = layer.getchannel("A").point(lambda p: p * opacity // 255)
+ shadow.putalpha(alpha)
+ shadow = shadow.filter(ImageFilter.GaussianBlur(blur))
+ base.alpha_composite(shadow, (xy[0] + offset[0], xy[1] + offset[1]))
+ base.alpha_composite(layer, xy)
+
+
+def build_device(filename: str, width: int, opacity: float = 1.0) -> Image.Image:
+ source = Image.open(CAPTURES / filename).convert("RGBA")
+ border = max(14, round(width * 0.021))
+ screen_w = width - 2 * border
+ screen_h = round(screen_w * source.height / source.width)
+ screen = source.resize((screen_w, screen_h), Image.Resampling.LANCZOS)
+ screen_mask = rounded_mask((screen_w, screen_h), round(screen_w * 0.105))
+ screen.putalpha(screen_mask)
+
+ device_h = screen_h + 2 * border
+ shell = Image.new("RGBA", (width, device_h), (0, 0, 0, 0))
+ sd = ImageDraw.Draw(shell)
+ radius = round(width * 0.122)
+ sd.rounded_rectangle((0, 0, width - 1, device_h - 1), radius=radius, fill=(7, 8, 11, 255), outline=(87, 89, 96, 255), width=2)
+ sd.rounded_rectangle((4, 4, width - 5, device_h - 5), radius=max(1, radius - 4), outline=(255, 255, 255, 28), width=2)
+ shell.alpha_composite(screen, (border, border))
+ if opacity < 1:
+ shell.putalpha(shell.getchannel("A").point(lambda p: round(p * opacity)))
+ return shell
+
+
+def place_device(
+ canvas: Image.Image,
+ filename: str,
+ width: int,
+ x: int,
+ y: int,
+ rotation: float,
+ opacity: float,
+) -> None:
+ device = build_device(filename, width, opacity)
+ if rotation:
+ device = device.rotate(rotation, resample=Image.Resampling.BICUBIC, expand=True)
+ x -= (device.width - width) // 2
+ paste_shadowed(canvas, device, (x, y), blur=34, offset=(0, 55), opacity=110)
+
+
+def font(path: str, size: int, variation: str | None = None) -> ImageFont.FreeTypeFont:
+ loaded = ImageFont.truetype(path, size)
+ if variation:
+ try:
+ loaded.set_variation_by_name(variation)
+ except (OSError, ValueError):
+ pass
+ return loaded
+
+
+def fitted_font(path: str, text: str, start: int, max_width: int, variation: str | None = None) -> ImageFont.FreeTypeFont:
+ size = start
+ while size > 60:
+ loaded = font(path, size, variation)
+ box = loaded.getbbox(text)
+ if box[2] - box[0] <= max_width:
+ return loaded
+ size -= 2
+ return font(path, size, variation)
+
+
+def draw_brand(canvas: Image.Image, tone: str) -> None:
+ x, y = 86, 96
+ pill_w, pill_h = 337, 68
+ pill = Image.new("RGBA", (pill_w, pill_h), (0, 0, 0, 0))
+ pd = ImageDraw.Draw(pill)
+ if tone == "paper":
+ fill, outline, text_color = (255, 255, 255, 214), (16, 18, 24, 28), INK
+ else:
+ fill, outline, text_color = (255, 255, 255, 30), (255, 255, 255, 58), (255, 255, 255)
+ pd.rounded_rectangle((0, 0, pill_w - 1, pill_h - 1), radius=34, fill=fill, outline=outline, width=1)
+ icon = Image.open(GENERATED / "oval-app-icon.png").convert("RGBA").resize((52, 52), Image.Resampling.LANCZOS)
+ icon.putalpha(rounded_mask(icon.size, 15))
+ pill.alpha_composite(icon, (8, 8))
+ pd.text((78, 18), "Oval for Ohio State", font=font(FONT_BOLD, 27), fill=text_color, anchor="la")
+ paste_shadowed(canvas, pill, (x, y), blur=18, offset=(0, 13), opacity=44)
+
+
+def draw_copy(canvas: Image.Image, story: dict) -> None:
+ tone = story["tone"]
+ draw = ImageDraw.Draw(canvas)
+ bold_color = (255, 255, 255) if tone in {"dark", "scarlet"} else INK
+ accent_color = (255, 240, 207) if tone == "scarlet" else ((255, 112, 119) if tone == "dark" else SCARLET)
+ sub_color = (255, 255, 255, 204) if tone == "scarlet" else ((255, 255, 255, 184) if tone == "dark" else (78, 80, 88, 255))
+
+ y = 220
+ for line, style in story["headline"]:
+ if style == "accent":
+ headline_font = fitted_font(FONT_SERIF_ITALIC, line, 116, 1110)
+ color = accent_color
+ else:
+ headline_font = fitted_font(FONT_BOLD, line, 116, 1110)
+ color = bold_color
+ draw.text((86, y), line, font=headline_font, fill=color, anchor="la", stroke_width=0)
+ y += 118
+
+ body_font = font(FONT_REGULAR, 38)
+ wrapped = textwrap.wrap(story["subhead"], width=54, break_long_words=False)
+ draw.multiline_text((86, y + 24), "\n".join(wrapped), font=body_font, fill=sub_color, spacing=10)
+
+
+def draw_tag(canvas: Image.Image, text: str, tone: str) -> None:
+ tag_font = font(FONT_BOLD, 25)
+ box = tag_font.getbbox(text)
+ text_w = box[2] - box[0]
+ w, h = text_w + 88, 66
+ x, y = WIDTH - w - 68, HEIGHT - h - 66
+ tag = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ td = ImageDraw.Draw(tag)
+ if tone == "paper":
+ fill, outline, text_color, dot = (255, 255, 255, 218), (255, 255, 255, 148), INK, SCARLET
+ else:
+ fill, outline, text_color, dot = (10, 12, 18, 118), (255, 255, 255, 48), (255, 255, 255), (255, 240, 207)
+ td.rounded_rectangle((0, 0, w - 1, h - 1), radius=33, fill=fill, outline=outline, width=1)
+ td.ellipse((24, 27, 36, 39), fill=dot)
+ td.ellipse((18, 21, 42, 45), outline=(*dot, 58), width=6)
+ td.text((53, h // 2), text, font=tag_font, fill=text_color, anchor="lm")
+ paste_shadowed(canvas, tag, (x, y), blur=18, offset=(0, 13), opacity=42)
+
+
+def generate_story(story: dict) -> Image.Image:
+ canvas = base_background(story["tone"])
+ for device in story["devices"]:
+ place_device(canvas, *device)
+ draw_brand(canvas, story["tone"])
+ draw_copy(canvas, story)
+ draw_tag(canvas, story["tag"], story["tone"])
+ return canvas.convert("RGB")
+
+
+def contact_sheet(images: list[Image.Image]) -> Image.Image:
+ thumb_w = 300
+ thumb_h = round(thumb_w * HEIGHT / WIDTH)
+ gap = 24
+ sheet = Image.new("RGB", (thumb_w * 3 + gap * 4, thumb_h * 2 + gap * 3), (238, 236, 232))
+ for index, image in enumerate(images):
+ thumb = image.resize((thumb_w, thumb_h), Image.Resampling.LANCZOS)
+ x = gap + (index % 3) * (thumb_w + gap)
+ y = gap + (index // 3) * (thumb_h + gap)
+ sheet.paste(thumb, (x, y))
+ return sheet
+
+
+def main() -> None:
+ OUTPUT_65.mkdir(parents=True, exist_ok=True)
+ OUTPUT_69.mkdir(parents=True, exist_ok=True)
+ rendered: list[Image.Image] = []
+ for story in STORIES:
+ image = generate_story(story)
+ image.save(OUTPUT_65 / story["file"], format="PNG", optimize=True)
+ modern = ImageOps.fit(image, (1320, 2868), method=Image.Resampling.LANCZOS)
+ modern.save(OUTPUT_69 / story["file"], format="PNG", optimize=True)
+ rendered.append(image)
+ print(f"wrote {OUTPUT_65 / story['file']}")
+ print(f"wrote {OUTPUT_69 / story['file']}")
+ contact_sheet(rendered).save(ROOT / "contact-sheet.png", format="PNG", optimize=True)
+ print(f"wrote {ROOT / 'contact-sheet.png'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/app-store-screenshots-2026/generate_simple.py b/docs/app-store-screenshots-2026/generate_simple.py
new file mode 100644
index 0000000..6eab367
--- /dev/null
+++ b/docs/app-store-screenshots-2026/generate_simple.py
@@ -0,0 +1,379 @@
+#!/usr/bin/env python3
+"""Generate the restrained, reference-matched Oval App Store screenshot set."""
+
+from __future__ import annotations
+
+import math
+import zipfile
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps
+
+
+ROOT = Path(__file__).resolve().parent
+CAPTURES = ROOT / "captures"
+ITERATION = ROOT / "iteration-v4-simple"
+OUTPUT_65 = ITERATION / "apple" / "en-US" / "6.5-inch"
+OUTPUT_69 = ITERATION / "apple" / "en-US" / "6.9-inch"
+
+W, H = 1284, 2778
+PAPER = (249, 249, 248)
+INK = (10, 13, 18)
+SUB = (119, 125, 139)
+SCARLET = (215, 9, 18)
+WHITE = (255, 255, 255)
+
+AVENIR = "/System/Library/Fonts/Avenir Next.ttc"
+
+
+SLIDES = [
+ {
+ "file": "01-find-your-people.png",
+ "lines": [[("Find Your", "ink")], [("People", "accent")]],
+ "subhead": "Discover real plans happening\naround campus",
+ "layout": "explore",
+ },
+ {
+ "file": "02-join-the-plan.png",
+ "lines": [[("Join the ", "ink"), ("Plan", "accent")]],
+ "subhead": "Small groups meeting up, in\nreal life",
+ "layout": "pods",
+ },
+ {
+ "file": "03-plan-it-together.png",
+ "lines": [[("Plan it", "ink")], [("Together", "accent")]],
+ "subhead": "Lock in the details in group chat",
+ "layout": "chat",
+ },
+ {
+ "file": "04-every-club-one-place.png",
+ "lines": [[("Every Club", "ink")], [("One", "accent"), (" Place", "ink")]],
+ "subhead": None,
+ "layout": "clubs",
+ },
+ {
+ "file": "05-stay-in-the-loop.png",
+ "lines": [[("Stay in the", "ink")], [("Loop", "accent")]],
+ "subhead": "Announcements, meetings, and\nclub chat",
+ "layout": "club_home",
+ },
+ {
+ "file": "06-rsvp-in-a-tap.png",
+ "lines": [[("RSVP in a ", "ink"), ("Tap", "accent")]],
+ "subhead": "Never miss a club meeting or\nevent",
+ "layout": "meeting",
+ },
+]
+
+
+def font(size: int, index: int) -> ImageFont.FreeTypeFont:
+ return ImageFont.truetype(AVENIR, size, index=index)
+
+
+def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
+ mask = Image.new("L", size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ (0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255
+ )
+ return mask
+
+
+def fit_line(
+ segments: list[tuple[str, str]], start_size: int, max_width: int
+) -> tuple[int, list[ImageFont.FreeTypeFont]]:
+ size = start_size
+ while size >= 70:
+ fonts = [font(size, 9 if style == "accent" else 8) for _, style in segments]
+ width = sum(f.getlength(text) for (text, _), f in zip(segments, fonts))
+ if width <= max_width:
+ return size, fonts
+ size -= 2
+ return size, fonts
+
+
+def draw_copy(
+ canvas: Image.Image,
+ lines: list[list[tuple[str, str]]],
+ subhead: str | None,
+ *,
+ x: int,
+ y: int,
+ max_width: int = 1120,
+ align: str = "left",
+ headline_size: int = 156,
+) -> None:
+ draw = ImageDraw.Draw(canvas)
+ cursor_y = y
+ for segments in lines:
+ size, fonts = fit_line(segments, headline_size, max_width)
+ widths = [f.getlength(text) for (text, _), f in zip(segments, fonts)]
+ line_width = sum(widths)
+ cursor_x = x if align == "left" else x + max_width - line_width
+ for (text, style), text_font, text_width in zip(segments, fonts, widths):
+ color = SCARLET if style == "accent" else INK
+ draw.text((round(cursor_x), cursor_y), text, font=text_font, fill=color)
+ cursor_x += text_width
+ cursor_y += round(size * 1.02)
+
+ if subhead:
+ sub_font = font(59, 2)
+ line_spacing = 15
+ box = draw.multiline_textbbox((0, 0), subhead, font=sub_font, spacing=line_spacing)
+ sub_width = box[2] - box[0]
+ sub_x = x if align == "left" else x + max_width - sub_width
+ draw.multiline_text(
+ (round(sub_x), cursor_y + 55),
+ subhead,
+ font=sub_font,
+ fill=SUB,
+ spacing=line_spacing,
+ align=align,
+ )
+
+
+def screen_with_status(capture_name: str, size: tuple[int, int]) -> Image.Image:
+ screen_w, screen_h = size
+ source = Image.open(CAPTURES / capture_name).convert("RGB")
+ status_h = round(screen_w * 0.09)
+ content_h = screen_h - status_h
+ source = source.resize((screen_w, content_h), Image.Resampling.LANCZOS)
+
+ screen = Image.new("RGB", (screen_w, screen_h), WHITE)
+ screen.paste(source, (0, status_h))
+ draw = ImageDraw.Draw(screen)
+
+ status_font = font(max(20, round(screen_w * 0.036)), 0)
+ draw.text((round(screen_w * 0.065), round(status_h * 0.30)), "10:19", font=status_font, fill=INK)
+
+ base_y = round(status_h * 0.70)
+ bar_w = max(4, round(screen_w * 0.0065))
+ gap = max(4, round(screen_w * 0.0055))
+
+ # Cellular bars: four evenly spaced rounded columns with a clean rise.
+ signal_x = screen_w - round(screen_w * 0.205)
+ heights = [round(status_h * value) for value in (0.19, 0.29, 0.39, 0.50)]
+ for i, height in enumerate(heights):
+ x = signal_x + i * (bar_w + gap)
+ draw.rounded_rectangle(
+ (x, base_y - height, x + bar_w, base_y),
+ radius=max(2, bar_w // 2),
+ fill=INK,
+ )
+
+ # Wi-Fi: two true concentric arcs with rounded ends plus a centered dot.
+ wifi_x = screen_w - round(screen_w * 0.123)
+ wifi_base = base_y + 1
+ arc_width = max(4, round(screen_w * 0.0055))
+ for radius in (round(screen_w * 0.029), round(screen_w * 0.018)):
+ points = []
+ for degree in range(225, 316, 5):
+ radians = math.radians(degree)
+ points.append(
+ (
+ round(wifi_x + radius * math.cos(radians)),
+ round(wifi_base + radius * math.sin(radians)),
+ )
+ )
+ draw.line(points, fill=INK, width=arc_width, joint="curve")
+ cap = arc_width // 2
+ for px, py in (points[0], points[-1]):
+ draw.ellipse((px - cap, py - cap, px + cap, py + cap), fill=INK)
+ dot_r = max(3, round(screen_w * 0.0045))
+ draw.ellipse(
+ (wifi_x - dot_r, wifi_base - dot_r, wifi_x + dot_r, wifi_base + dot_r),
+ fill=INK,
+ )
+
+ # Battery: the compact filled percentage capsule used in the references.
+ battery_w = round(screen_w * 0.061)
+ battery_h = round(status_h * 0.43)
+ battery_x = screen_w - round(screen_w * 0.081)
+ battery_y = base_y - battery_h
+ draw.rounded_rectangle(
+ (battery_x, battery_y, battery_x + battery_w, battery_y + battery_h),
+ radius=max(5, battery_h // 4),
+ fill=INK,
+ )
+ draw.rounded_rectangle(
+ (
+ battery_x + battery_w + 2,
+ battery_y + round(battery_h * 0.29),
+ battery_x + battery_w + 6,
+ battery_y + round(battery_h * 0.71),
+ ),
+ radius=2,
+ fill=INK,
+ )
+ battery_font = font(max(14, round(battery_h * 0.62)), 0)
+ draw.text(
+ (battery_x + battery_w / 2, battery_y + battery_h / 2 - 1),
+ "73",
+ font=battery_font,
+ fill=WHITE,
+ anchor="mm",
+ )
+ return screen.convert("RGBA")
+
+
+def build_phone(capture_name: str, outer_w: int, opacity: float = 1.0) -> Image.Image:
+ border = max(15, round(outer_w * 0.022))
+ screen_w = outer_w - border * 2
+ screen_h = round(screen_w * 844 / 390)
+ phone_h = screen_h + border * 2
+ radius = round(outer_w * 0.125)
+ screen_radius = max(1, radius - border)
+
+ shell = Image.new("RGBA", (outer_w, phone_h), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(shell)
+ draw.rounded_rectangle(
+ (0, 0, outer_w - 1, phone_h - 1),
+ radius=radius,
+ fill=(4, 5, 7, 255),
+ outline=(88, 91, 98, 255),
+ width=max(3, border // 5),
+ )
+ draw.rounded_rectangle(
+ (6, 6, outer_w - 7, phone_h - 7),
+ radius=radius - 6,
+ outline=(255, 255, 255, 34),
+ width=2,
+ )
+
+ screen = screen_with_status(capture_name, (screen_w, screen_h))
+ screen.putalpha(rounded_mask((screen_w, screen_h), screen_radius))
+ shell.alpha_composite(screen, (border, border))
+
+ notch_w = round(outer_w * 0.37)
+ notch_h = round(outer_w * 0.078)
+ notch_x = (outer_w - notch_w) // 2
+ draw.rounded_rectangle(
+ (notch_x, border - 1, notch_x + notch_w, border + notch_h),
+ radius=round(notch_h * 0.46),
+ fill=(2, 3, 5, 255),
+ )
+ draw.ellipse(
+ (
+ notch_x + round(notch_w * 0.77),
+ border + round(notch_h * 0.32),
+ notch_x + round(notch_w * 0.77) + round(notch_h * 0.25),
+ border + round(notch_h * 0.57),
+ ),
+ fill=(7, 20, 42, 255),
+ )
+
+ side = (52, 54, 60, 255)
+ draw.rounded_rectangle((-3, round(phone_h * 0.24), 5, round(phone_h * 0.34)), radius=4, fill=side)
+ draw.rounded_rectangle((outer_w - 6, round(phone_h * 0.28), outer_w + 2, round(phone_h * 0.43)), radius=4, fill=side)
+
+ if opacity < 1:
+ shell.putalpha(shell.getchannel("A").point(lambda value: round(value * opacity)))
+ return shell
+
+
+def place_phone(
+ canvas: Image.Image,
+ capture_name: str,
+ outer_w: int,
+ x: int,
+ y: int,
+ rotation: float = 0,
+ opacity: float = 1.0,
+ shadow_opacity: int = 90,
+) -> None:
+ phone = build_phone(capture_name, outer_w, opacity)
+ if rotation:
+ original_w = phone.width
+ phone = phone.rotate(rotation, resample=Image.Resampling.BICUBIC, expand=True)
+ x -= (phone.width - original_w) // 2
+
+ alpha = phone.getchannel("A").point(lambda value: value * shadow_opacity // 255)
+ shadow = Image.new("RGBA", phone.size, (8, 10, 14, 0))
+ shadow.putalpha(alpha)
+ shadow = shadow.filter(ImageFilter.GaussianBlur(30))
+ canvas.alpha_composite(shadow, (x + 12, y + 40))
+ canvas.alpha_composite(phone, (x, y))
+
+
+def render(slide: dict) -> Image.Image:
+ canvas = Image.new("RGBA", (W, H), (*PAPER, 255))
+ layout = slide["layout"]
+
+ if layout == "explore":
+ draw_copy(canvas, slide["lines"], slide["subhead"], x=64, y=110, max_width=960)
+ place_phone(canvas, "home-v2.png", 720, 1030, -240, -13, 0.90, 54)
+ place_phone(canvas, "explore-v2.png", 980, 110, 790, -12, 1.0, 102)
+ elif layout == "pods":
+ place_phone(canvas, "home-v2.png", 780, -620, 470, 11, 0.92, 60)
+ place_phone(canvas, "pods-v2.png", 940, 220, -430, -12, 1.0, 100)
+ draw_copy(
+ canvas,
+ slide["lines"],
+ slide["subhead"],
+ x=330,
+ y=1815,
+ max_width=870,
+ align="right",
+ headline_size=148,
+ )
+ elif layout == "chat":
+ draw_copy(canvas, slide["lines"], slide["subhead"], x=88, y=118, max_width=1080)
+ place_phone(canvas, "inbox-v2.png", 1075, 88, 690, 0, 1.0, 90)
+ elif layout == "clubs":
+ draw_copy(canvas, slide["lines"], slide["subhead"], x=88, y=135, max_width=1100)
+ place_phone(canvas, "club-home-v2.png", 810, 20, 760, 5, 0.90, 72)
+ place_phone(canvas, "clubs-v2.png", 865, 315, 980, -8, 1.0, 105)
+ elif layout == "club_home":
+ draw_copy(canvas, slide["lines"], slide["subhead"], x=88, y=105, max_width=1080)
+ place_phone(canvas, "club-home-v2.png", 1075, 88, 700, 0, 1.0, 90)
+ else:
+ draw_copy(canvas, slide["lines"], slide["subhead"], x=88, y=135, max_width=1120, headline_size=146)
+ place_phone(canvas, "meeting-v2.png", 965, 175, 670, -7, 1.0, 98)
+
+ return canvas.convert("RGB")
+
+
+def contact_sheet(images: list[Image.Image]) -> Image.Image:
+ thumb_w = 300
+ thumb_h = round(thumb_w * H / W)
+ gap = 24
+ sheet = Image.new("RGB", (thumb_w * 3 + gap * 4, thumb_h * 2 + gap * 3), (232, 232, 230))
+ for index, image in enumerate(images):
+ thumb = image.resize((thumb_w, thumb_h), Image.Resampling.LANCZOS)
+ x = gap + (index % 3) * (thumb_w + gap)
+ y = gap + (index // 3) * (thumb_h + gap)
+ sheet.paste(thumb, (x, y))
+ return sheet
+
+
+def write_zip(folder: Path, output: Path) -> None:
+ with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for path in sorted(folder.glob("*.png")):
+ archive.write(path, arcname=path.name)
+
+
+def main() -> None:
+ OUTPUT_65.mkdir(parents=True, exist_ok=True)
+ OUTPUT_69.mkdir(parents=True, exist_ok=True)
+ rendered: list[Image.Image] = []
+ for slide in SLIDES:
+ image = render(slide)
+ image.save(OUTPUT_65 / slide["file"], format="PNG", optimize=True)
+ modern = ImageOps.fit(image, (1320, 2868), method=Image.Resampling.LANCZOS)
+ modern.save(OUTPUT_69 / slide["file"], format="PNG", optimize=True)
+ rendered.append(image)
+ print(f"wrote {OUTPUT_65 / slide['file']}")
+ print(f"wrote {OUTPUT_69 / slide['file']}")
+
+ contact_sheet(rendered).save(ITERATION / "contact-sheet-simple-v4.png", format="PNG", optimize=True)
+ write_zip(
+ OUTPUT_65,
+ ROOT / "Oval-App-Store-Screenshots-SIMPLE-v4-6.5-inch-en-US.zip",
+ )
+ write_zip(
+ OUTPUT_69,
+ ROOT / "Oval-App-Store-Screenshots-SIMPLE-v4-6.9-inch-en-US.zip",
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/app-store-screenshots-2026/generate_v2.py b/docs/app-store-screenshots-2026/generate_v2.py
new file mode 100644
index 0000000..8156fca
--- /dev/null
+++ b/docs/app-store-screenshots-2026/generate_v2.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+"""Varied, community-first App Store screenshots for Oval."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps
+
+
+ROOT = Path(__file__).resolve().parent
+CAPTURES = ROOT / "captures"
+GENERATED = ROOT / "generated"
+OUTPUT_65 = ROOT / "iteration-v3" / "apple" / "en-US" / "6.5-inch"
+OUTPUT_69 = ROOT / "iteration-v3" / "apple" / "en-US" / "6.9-inch"
+
+W, H = 1284, 2778
+INK = (15, 17, 22)
+CREAM = (255, 246, 222)
+WHITE = (255, 255, 255)
+
+FONT_BOLD = "/System/Library/Fonts/Avenir Next.ttc"
+FONT_REGULAR = "/System/Library/Fonts/HelveticaNeue.ttc"
+
+
+STORIES = [
+ {
+ "file": "01-campus-together.png",
+ "header": (215, 9, 18),
+ "headline": [("CAMPUS,", None), ("TOGETHER.", (255, 210, 63))],
+ "headline_color": WHITE,
+ "highlight_color": INK,
+ "subhead": "Plans, clubs, and people—all moving right now.",
+ "layout": "hero",
+ },
+ {
+ "file": "02-find-your-people.png",
+ "header": (27, 85, 217),
+ "headline": [("FIND YOUR", None), ("PEOPLE.", (255, 210, 63))],
+ "headline_color": WHITE,
+ "highlight_color": INK,
+ "subhead": "See what’s happening, then jump into the plan.",
+ "layout": "duo",
+ },
+ {
+ "file": "03-bring-a-crew.png",
+ "header": (82, 196, 159),
+ "headline": [("MAKE A PLAN.", None), ("BRING A CREW.", (215, 9, 18))],
+ "headline_color": INK,
+ "highlight_color": WHITE,
+ "subhead": "Your next five hangouts, all ready to happen.",
+ "layout": "fan",
+ },
+ {
+ "file": "04-clubs-with-a-pulse.png",
+ "header": (255, 210, 63),
+ "headline": [("CLUBS WITH", None), ("A PULSE.", (215, 9, 18))],
+ "headline_color": INK,
+ "highlight_color": WHITE,
+ "subhead": "Find your communities and see what’s happening tonight.",
+ "layout": "club_stack",
+ },
+ {
+ "file": "05-in-the-loop.png",
+ "header": (126, 91, 204),
+ "headline": [("EVERYONE’S", None), ("IN THE LOOP.", (255, 210, 63))],
+ "headline_color": WHITE,
+ "highlight_color": INK,
+ "subhead": "Messages, pod chats, and invites—without the chaos.",
+ "layout": "detail_grid",
+ },
+ {
+ "file": "06-show-up-together.png",
+ "header": (215, 9, 18),
+ "headline": [("SHOW UP", None), ("TOGETHER.", (82, 196, 159))],
+ "headline_color": WHITE,
+ "highlight_color": INK,
+ "subhead": "From RSVP to check-in, keep the whole group moving.",
+ "layout": "spotlight",
+ },
+]
+
+
+def font(path: str, size: int) -> ImageFont.FreeTypeFont:
+ return ImageFont.truetype(path, size)
+
+
+def fit_font(text: str, start: int, max_width: int) -> ImageFont.FreeTypeFont:
+ size = start
+ while size >= 56:
+ candidate = font(FONT_BOLD, size)
+ box = candidate.getbbox(text)
+ if box[2] - box[0] <= max_width:
+ return candidate
+ size -= 2
+ return font(FONT_BOLD, size)
+
+
+def cover(image: Image.Image, size: tuple[int, int]) -> Image.Image:
+ return ImageOps.fit(image, size, method=Image.Resampling.LANCZOS)
+
+
+def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image:
+ mask = Image.new("L", size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
+ return mask
+
+
+def alpha_shadow(layer: Image.Image, opacity: int = 110, blur: int = 28) -> Image.Image:
+ shadow = Image.new("RGBA", layer.size, (0, 0, 0, 0))
+ alpha = layer.getchannel("A").point(lambda value: value * opacity // 255)
+ shadow.putalpha(alpha)
+ return shadow.filter(ImageFilter.GaussianBlur(blur))
+
+
+def base_canvas() -> Image.Image:
+ background = Image.open(GENERATED / "oval-community-background-v3-no-tape.png").convert("RGBA")
+ canvas = cover(background, (W, H))
+ return Image.alpha_composite(canvas, Image.new("RGBA", (W, H), (255, 246, 222, 18)))
+
+
+def wrap_text(text: str, text_font: ImageFont.FreeTypeFont, max_width: int) -> str:
+ lines: list[str] = []
+ current = ""
+ for word in text.split():
+ candidate = f"{current} {word}".strip()
+ if text_font.getlength(candidate) <= max_width or not current:
+ current = candidate
+ else:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return "\n".join(lines)
+
+
+def header_geometry(layout: str) -> tuple[Image.Image, tuple[int, int, int, int]]:
+ layer = Image.new("RGBA", (W, 1120), (0, 0, 0, 0))
+ if layout == "hero":
+ return layer, (54, 122, 900, 100)
+ if layout == "duo":
+ return layer, (62, 116, 850, 94)
+ if layout == "fan":
+ return layer, (70, 105, 1120, 90)
+ if layout == "club_stack":
+ return layer, (350, 98, 860, 100)
+ if layout == "detail_grid":
+ return layer, (250, 98, 930, 98)
+ return layer, (48, 92, 740, 104)
+
+
+def poster_header(canvas: Image.Image, story: dict) -> tuple[int, int, int, int]:
+ layout = story["layout"]
+ layer, text_spec = header_geometry(layout)
+ draw = ImageDraw.Draw(layer)
+ color = (*story["header"], 255)
+
+ if layout == "hero":
+ draw.polygon([(20, 28), (1034, 44), (1120, 420), (920, 566), (28, 524)], fill=color)
+ elif layout == "duo":
+ draw.polygon([(25, 82), (946, 24), (1034, 468), (114, 552)], fill=(255, 210, 63, 255))
+ draw.polygon([(38, 32), (944, 70), (982, 500), (16, 472)], fill=color)
+ elif layout == "fan":
+ draw.polygon([(22, 26), (1264, 50), (1198, 548), (840, 510), (604, 560), (230, 516), (38, 548)], fill=color)
+ draw.line((780, 42, 1190, 500), fill=(255, 255, 255, 72), width=11)
+ elif layout == "club_stack":
+ draw.polygon([(280, 28), (1264, 48), (1250, 558), (348, 524), (250, 330)], fill=color)
+ draw.polygon([(214, 48), (340, 24), (410, 542), (286, 560)], fill=(215, 9, 18, 255))
+ elif layout == "detail_grid":
+ draw.rounded_rectangle((190, 34, 1260, 524), radius=54, fill=color)
+ draw.polygon([(252, 508), (430, 508), (250, 626)], fill=color)
+ draw.rounded_rectangle((164, 76, 226, 426), radius=22, fill=(255, 210, 63, 255))
+ else:
+ draw.polygon([(22, 24), (846, 42), (908, 522), (792, 660), (32, 628)], fill=color)
+ draw.polygon([(760, 70), (892, 96), (872, 508), (804, 572)], fill=(82, 196, 159, 255))
+
+ # Halftone and marker accents vary with the shape, but remain text-free.
+ dot_x = 900 if layout not in {"club_stack", "detail_grid", "spotlight"} else 1080
+ dot_y = 80
+ for row in range(4):
+ for col in range(6):
+ x = dot_x + col * 32
+ y = dot_y + row * 32
+ draw.ellipse((x, y, x + 9, y + 9), fill=(255, 255, 255, 80))
+ canvas.alpha_composite(layer, (0, 0))
+ return text_spec
+
+
+def draw_headline(canvas: Image.Image, story: dict, spec: tuple[int, int, int, int]) -> None:
+ x, y, max_width, start_size = spec
+ draw = ImageDraw.Draw(canvas)
+ for text, highlight in story["headline"]:
+ text_font = fit_font(text, start_size, max_width)
+ box = draw.textbbox((0, 0), text, font=text_font)
+ text_w = box[2] - box[0]
+ text_h = box[3] - box[1]
+ if highlight:
+ pad_x, pad_y = 20, 8
+ draw.rounded_rectangle(
+ (x, y - 3, x + text_w + pad_x * 2, y + text_h + pad_y * 2),
+ radius=17,
+ fill=highlight,
+ )
+ draw.text((x + pad_x, y + pad_y - box[1]), text, font=text_font, fill=story["highlight_color"], anchor="la")
+ else:
+ draw.text((x, y - box[1]), text, font=text_font, fill=story["headline_color"], anchor="la")
+ y += max(91, text_h + 18)
+
+ body_font = font(FONT_REGULAR, 35 if max_width > 600 else 31)
+ wrapped = wrap_text(story["subhead"], body_font, max_width)
+ draw.multiline_text((x + 2, y + 18), wrapped, font=body_font, fill=story["headline_color"], spacing=7)
+
+
+def screen_slab(
+ capture_name: str,
+ outer_w: int,
+ crop: tuple[int, int, int, int] | None = None,
+ compact: bool = False,
+) -> Image.Image:
+ source = Image.open(CAPTURES / capture_name).convert("RGBA")
+ if crop is not None:
+ source = source.crop(crop)
+ elif capture_name != "meeting-v2.png":
+ source = source.crop((0, 0, source.width, 832))
+
+ border = max(7, round(outer_w * 0.011))
+ screen_w = outer_w - border * 2
+ screen_h = round(screen_w * source.height / source.width)
+ source = source.resize((screen_w, screen_h), Image.Resampling.LANCZOS)
+ radius = max(28, round(screen_w * (0.045 if compact else 0.066)))
+ source.putalpha(rounded_mask((screen_w, screen_h), radius))
+ slab = Image.new("RGBA", (outer_w, screen_h + border * 2), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(slab)
+ draw.rounded_rectangle(
+ (0, 0, slab.width - 1, slab.height - 1),
+ radius=radius + border,
+ fill=(*INK, 255),
+ outline=(*CREAM, 255),
+ width=max(3, border // 2),
+ )
+ slab.alpha_composite(source, (border, border))
+ return slab
+
+
+def place_center(
+ canvas: Image.Image,
+ layer: Image.Image,
+ center: tuple[int, int],
+ angle: float = 0,
+ shadow: int = 116,
+ blur: int = 30,
+) -> tuple[int, int, int, int]:
+ rotated = layer.rotate(angle, resample=Image.Resampling.BICUBIC, expand=True)
+ x = round(center[0] - rotated.width / 2)
+ y = round(center[1] - rotated.height / 2)
+ canvas.alpha_composite(alpha_shadow(rotated, shadow, blur), (x + 8, y + 34))
+ canvas.alpha_composite(rotated, (x, y))
+ return x, y, rotated.width, rotated.height
+
+
+def add_doodles(canvas: Image.Image, layout: str) -> None:
+ draw = ImageDraw.Draw(canvas)
+ if layout in {"hero", "fan", "detail_grid"}:
+ draw.ellipse((26, 2320, 104, 2398), fill=(255, 210, 63), outline=INK, width=5)
+ draw.line((51, 2359, 79, 2359), fill=INK, width=5)
+ draw.line((65, 2345, 65, 2373), fill=INK, width=5)
+ if layout in {"duo", "club_stack", "spotlight"}:
+ draw.polygon([(1162, 2320), (1214, 2386), (1133, 2378)], fill=(126, 91, 204), outline=INK)
+ draw.arc((1015, 2520, 1370, 2840), 170, 318, fill=(255, 246, 222), width=9)
+
+
+def layout_hero(canvas: Image.Image) -> None:
+ phone = screen_slab("home-v2.png", 1030)
+ place_center(canvas, phone, (690, 1710), -4.5, 125, 34)
+
+
+def layout_duo(canvas: Image.Image) -> None:
+ # A discovery screen and a home screen overlap like flyers on a board.
+ rear = screen_slab("home-v2.png", 690)
+ front = screen_slab("explore-v2.png", 790)
+ place_center(canvas, rear, (940, 1720), 9, 96, 26)
+ place_center(canvas, front, (430, 1715), -7, 128, 34)
+
+
+def layout_fan(canvas: Image.Image) -> None:
+ # Three real screens create a social "crew" fan instead of one repeated hero phone.
+ left = screen_slab("home-v2.png", 610)
+ right = screen_slab("explore-v2.png", 610)
+ center = screen_slab("pods-v2.png", 735)
+ place_center(canvas, left, (305, 1740), -14, 80, 24)
+ place_center(canvas, right, (985, 1740), 14, 80, 24)
+ place_center(canvas, center, (645, 1780), 0, 136, 36)
+
+
+def layout_club_stack(canvas: Image.Image) -> None:
+ # List-to-community progression in one frame: browse at back, live club page in front.
+ browse = screen_slab("clubs-v2.png", 760)
+ club = screen_slab("club-home-v2.png", 760)
+ place_center(canvas, browse, (420, 1725), -9, 90, 26)
+ place_center(canvas, club, (865, 1730), 7, 132, 34)
+
+
+def layout_detail_grid(canvas: Image.Image) -> None:
+ # A clean, aligned feature grid: the combined inbox plus two distinct filtered states.
+ full = screen_slab("inbox-v2.png", 700)
+ chats = screen_slab("inbox-pod-chats-v3.png", 475, crop=(0, 0, 390, 500), compact=True)
+ invites = screen_slab("inbox-invites-v3.png", 475, crop=(0, 0, 390, 460), compact=True)
+ place_center(canvas, full, (390, 1740), 0, 116, 30)
+ place_center(canvas, chats, (980, 1375), 0, 108, 26)
+ place_center(canvas, invites, (980, 2070), 0, 108, 26)
+
+
+def layout_spotlight(canvas: Image.Image) -> None:
+ # Supporting club frames sit behind the main RSVP/check-in flow.
+ club = screen_slab("club-home-v2.png", 520, crop=(0, 0, 390, 520), compact=True)
+ browse = screen_slab("clubs-v2.png", 520, crop=(0, 115, 390, 665), compact=True)
+ meeting = screen_slab("meeting-v2.png", 785)
+ place_center(canvas, club, (380, 1175), -9, 78, 22)
+ place_center(canvas, browse, (320, 2070), 8, 78, 22)
+ place_center(canvas, meeting, (870, 1660), 5, 138, 36)
+
+
+LAYOUTS = {
+ "hero": layout_hero,
+ "duo": layout_duo,
+ "fan": layout_fan,
+ "club_stack": layout_club_stack,
+ "detail_grid": layout_detail_grid,
+ "spotlight": layout_spotlight,
+}
+
+
+def render(story: dict) -> Image.Image:
+ canvas = base_canvas()
+ spec = poster_header(canvas, story)
+ add_doodles(canvas, story["layout"])
+ LAYOUTS[story["layout"]](canvas)
+ # Headlines are drawn last so phone overlap can feel intentional without hurting copy.
+ draw_headline(canvas, story, spec)
+ return canvas.convert("RGB")
+
+
+def contact_sheet(images: list[Image.Image]) -> Image.Image:
+ thumb_w = 300
+ thumb_h = round(thumb_w * H / W)
+ gap = 24
+ sheet = Image.new("RGB", (thumb_w * 3 + gap * 4, thumb_h * 2 + gap * 3), (18, 19, 23))
+ for index, image in enumerate(images):
+ thumb = image.resize((thumb_w, thumb_h), Image.Resampling.LANCZOS)
+ x = gap + (index % 3) * (thumb_w + gap)
+ y = gap + (index // 3) * (thumb_h + gap)
+ sheet.paste(thumb, (x, y))
+ return sheet
+
+
+def main() -> None:
+ OUTPUT_65.mkdir(parents=True, exist_ok=True)
+ OUTPUT_69.mkdir(parents=True, exist_ok=True)
+ rendered: list[Image.Image] = []
+ for story in STORIES:
+ image = render(story)
+ image.save(OUTPUT_65 / story["file"], format="PNG", optimize=True)
+ modern = ImageOps.fit(image, (1320, 2868), method=Image.Resampling.LANCZOS)
+ modern.save(OUTPUT_69 / story["file"], format="PNG", optimize=True)
+ rendered.append(image)
+ print(f"wrote {story['file']}")
+ contact_sheet(rendered).save(ROOT / "iteration-v3" / "contact-sheet-v3.png", format="PNG", optimize=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/app-store-screenshots-2026/generated/oval-app-icon.png b/docs/app-store-screenshots-2026/generated/oval-app-icon.png
new file mode 100644
index 0000000..b524245
Binary files /dev/null and b/docs/app-store-screenshots-2026/generated/oval-app-icon.png differ
diff --git a/docs/app-store-screenshots-2026/generated/oval-community-background-v2.png b/docs/app-store-screenshots-2026/generated/oval-community-background-v2.png
new file mode 100644
index 0000000..76e963e
Binary files /dev/null and b/docs/app-store-screenshots-2026/generated/oval-community-background-v2.png differ
diff --git a/docs/app-store-screenshots-2026/generated/oval-community-background-v3-no-tape.png b/docs/app-store-screenshots-2026/generated/oval-community-background-v3-no-tape.png
new file mode 100644
index 0000000..b0f1151
Binary files /dev/null and b/docs/app-store-screenshots-2026/generated/oval-community-background-v3-no-tape.png differ
diff --git a/docs/app-store-screenshots-2026/generated/oval-editorial-background.png b/docs/app-store-screenshots-2026/generated/oval-editorial-background.png
new file mode 100644
index 0000000..de8aedb
Binary files /dev/null and b/docs/app-store-screenshots-2026/generated/oval-editorial-background.png differ
diff --git a/docs/app-store-screenshots-2026/index.html b/docs/app-store-screenshots-2026/index.html
new file mode 100644
index 0000000..e4889dc
--- /dev/null
+++ b/docs/app-store-screenshots-2026/index.html
@@ -0,0 +1,382 @@
+
+
+
+
+
+ Oval App Store Screenshots
+
+
+
+
+
+
+
+
+

+
Oval for Ohio State
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/01-campus-together.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/01-campus-together.png
new file mode 100644
index 0000000..3e3bfcb
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/01-campus-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/02-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/02-find-your-people.png
new file mode 100644
index 0000000..8596272
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/02-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/03-bring-a-crew.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/03-bring-a-crew.png
new file mode 100644
index 0000000..c144121
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/03-bring-a-crew.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png
new file mode 100644
index 0000000..3c35e56
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/05-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/05-in-the-loop.png
new file mode 100644
index 0000000..0462f19
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/05-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/06-show-up-together.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/06-show-up-together.png
new file mode 100644
index 0000000..52d385b
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.5-inch/06-show-up-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/01-campus-together.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/01-campus-together.png
new file mode 100644
index 0000000..9702970
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/01-campus-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/02-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/02-find-your-people.png
new file mode 100644
index 0000000..edd24c1
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/02-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/03-bring-a-crew.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/03-bring-a-crew.png
new file mode 100644
index 0000000..bf1f594
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/03-bring-a-crew.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png
new file mode 100644
index 0000000..7938bb7
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/05-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/05-in-the-loop.png
new file mode 100644
index 0000000..78071fc
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/05-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/06-show-up-together.png b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/06-show-up-together.png
new file mode 100644
index 0000000..23a75f7
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/apple/en-US/6.9-inch/06-show-up-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v2/contact-sheet-v2.png b/docs/app-store-screenshots-2026/iteration-v2/contact-sheet-v2.png
new file mode 100644
index 0000000..000866b
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v2/contact-sheet-v2.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/01-campus-together.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/01-campus-together.png
new file mode 100644
index 0000000..d833976
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/01-campus-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/02-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/02-find-your-people.png
new file mode 100644
index 0000000..b4f3896
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/02-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/03-bring-a-crew.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/03-bring-a-crew.png
new file mode 100644
index 0000000..80105c8
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/03-bring-a-crew.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png
new file mode 100644
index 0000000..19c4696
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/04-clubs-with-a-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/05-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/05-in-the-loop.png
new file mode 100644
index 0000000..37eddd4
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/05-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/06-show-up-together.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/06-show-up-together.png
new file mode 100644
index 0000000..9494e50
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.5-inch/06-show-up-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/01-campus-together.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/01-campus-together.png
new file mode 100644
index 0000000..5b7b8e4
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/01-campus-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/02-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/02-find-your-people.png
new file mode 100644
index 0000000..e50c40b
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/02-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/03-bring-a-crew.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/03-bring-a-crew.png
new file mode 100644
index 0000000..9323d8a
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/03-bring-a-crew.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png
new file mode 100644
index 0000000..0d9b95e
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/04-clubs-with-a-pulse.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/05-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/05-in-the-loop.png
new file mode 100644
index 0000000..4897dc4
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/05-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/06-show-up-together.png b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/06-show-up-together.png
new file mode 100644
index 0000000..1cdd543
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/apple/en-US/6.9-inch/06-show-up-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v3/contact-sheet-v3.png b/docs/app-store-screenshots-2026/iteration-v3/contact-sheet-v3.png
new file mode 100644
index 0000000..374c016
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v3/contact-sheet-v3.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/01-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/01-find-your-people.png
new file mode 100644
index 0000000..9f0edc2
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/01-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/02-join-the-plan.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/02-join-the-plan.png
new file mode 100644
index 0000000..09743e5
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/02-join-the-plan.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/03-plan-it-together.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/03-plan-it-together.png
new file mode 100644
index 0000000..e314c83
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/03-plan-it-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/04-every-club-one-place.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/04-every-club-one-place.png
new file mode 100644
index 0000000..6ed8b5b
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/04-every-club-one-place.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/05-stay-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/05-stay-in-the-loop.png
new file mode 100644
index 0000000..80d9c9b
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/05-stay-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/06-rsvp-in-a-tap.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/06-rsvp-in-a-tap.png
new file mode 100644
index 0000000..9e2922e
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.5-inch/06-rsvp-in-a-tap.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/01-find-your-people.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/01-find-your-people.png
new file mode 100644
index 0000000..efe4cd3
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/01-find-your-people.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/02-join-the-plan.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/02-join-the-plan.png
new file mode 100644
index 0000000..b3bf658
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/02-join-the-plan.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/03-plan-it-together.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/03-plan-it-together.png
new file mode 100644
index 0000000..c368bb5
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/03-plan-it-together.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/04-every-club-one-place.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/04-every-club-one-place.png
new file mode 100644
index 0000000..7b76d13
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/04-every-club-one-place.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/05-stay-in-the-loop.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/05-stay-in-the-loop.png
new file mode 100644
index 0000000..597ec40
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/05-stay-in-the-loop.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/06-rsvp-in-a-tap.png b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/06-rsvp-in-a-tap.png
new file mode 100644
index 0000000..74de1da
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/apple/en-US/6.9-inch/06-rsvp-in-a-tap.png differ
diff --git a/docs/app-store-screenshots-2026/iteration-v4-simple/contact-sheet-simple-v4.png b/docs/app-store-screenshots-2026/iteration-v4-simple/contact-sheet-simple-v4.png
new file mode 100644
index 0000000..0876023
Binary files /dev/null and b/docs/app-store-screenshots-2026/iteration-v4-simple/contact-sheet-simple-v4.png differ
diff --git a/frontend/.env.example b/frontend/.env.example
index e1d710f..aef0114 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -5,3 +5,7 @@ EXPO_PUBLIC_API_URL=http://localhost:3000
# Enable Realtime on the "Message" table in Supabase Dashboard → Database → Replication.
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
+
+# Optional crash reporting. Set the DSN as an EAS environment secret for release builds.
+EXPO_PUBLIC_SENTRY_DSN=
+EXPO_PUBLIC_ENV=development
diff --git a/frontend/App.tsx b/frontend/App.tsx
index 7529769..091ced9 100644
--- a/frontend/App.tsx
+++ b/frontend/App.tsx
@@ -81,7 +81,11 @@ import {
import { Activity } from './src/types';
import { getInboxSummary } from './src/api';
import { BORDER_W, ThemeProvider, elevation, fonts, motion, radii, useTheme } from './src/theme';
-import { getUiPreviewMode } from './src/dev/previewMode';
+import {
+ getUiPreviewMode,
+ getUiPreviewNeutralDock,
+ getUiPreviewTab,
+} from './src/dev/previewMode';
import { AppBackdrop, CountBubble, SkeletonBlock, SkeletonCard } from './src/components/ui';
import { CURRENT_TERMS_VERSION } from './src/constants/legal';
import { REALTIME_INBOX_EVENTS, useRealtimeChannel } from './src/hooks/useRealtimeChannel';
@@ -267,6 +271,7 @@ function TabItem({
routeName,
label,
focused,
+ selected,
onPress,
onLongPress,
badge,
@@ -274,6 +279,7 @@ function TabItem({
routeName: keyof MainTabParamList;
label: string;
focused: boolean;
+ selected: boolean;
onPress: () => void;
onLongPress: () => void;
badge?: number | string;
@@ -299,7 +305,7 @@ function TabItem({
onLongPress={onLongPress}
accessibilityRole="tab"
accessibilityLabel={label}
- accessibilityState={{ selected: focused }}
+ accessibilityState={{ selected }}
style={styles.tabSlot}
>
@@ -328,6 +334,7 @@ function TabItem({
function OvalDock({ state, descriptors, navigation }: BottomTabBarProps) {
const { colors, isDark } = useTheme();
const insets = useSafeAreaInsets();
+ const neutralPreview = getUiPreviewNeutralDock();
const renderRoute = (route: BottomTabBarProps['state']['routes'][number], index: number) => {
const { options } = descriptors[route.key];
@@ -335,7 +342,8 @@ function OvalDock({ state, descriptors, navigation }: BottomTabBarProps) {
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: options.title ?? route.name;
- const focused = state.index === index;
+ const selected = state.index === index;
+ const focused = selected && !neutralPreview;
return (
{
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
- if (!focused && !event.defaultPrevented) {
+ if (!selected && !event.defaultPrevented) {
navigation.navigate(route.name);
}
}}
@@ -482,6 +491,7 @@ function FoundationPreviewTabs() {
}
function Stage2PreviewTabs({ mode }: { mode: Stage2PreviewMode }) {
+ const [initialTab] = React.useState(getUiPreviewTab);
const HomePreview = React.useCallback(
() => ,
[mode],
@@ -500,6 +510,7 @@ function Stage2PreviewTabs({ mode }: { mode: Stage2PreviewMode }) {
);
return (
}
screenOptions={{ headerShown: false }}
>
diff --git a/frontend/app.json b/frontend/app.json
index 4fd1bd5..35a37e6 100644
--- a/frontend/app.json
+++ b/frontend/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Oval",
"slug": "oval",
- "version": "1.0.1",
+ "version": "1.1.0",
"orientation": "portrait",
"icon": "./assets/brand/oval-open-1024.png",
"userInterfaceStyle": "automatic",
@@ -17,7 +17,33 @@
"sounds": []
}
],
- "expo-secure-store",
+ [
+ "expo-secure-store",
+ {
+ "faceIDPermission": false
+ }
+ ],
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "Oval lets you choose a photo from your library for your profile or club avatar.",
+ "cameraPermission": false,
+ "microphonePermission": false
+ }
+ ],
+ [
+ "expo-location",
+ {
+ "locationWhenInUsePermission": "Oval uses your location to show pods forming near you on campus.",
+ "locationAlwaysAndWhenInUsePermission": false,
+ "locationAlwaysPermission": false,
+ "motionUsagePermission": false,
+ "isIosBackgroundLocationEnabled": false,
+ "isAndroidBackgroundLocationEnabled": false,
+ "isAndroidForegroundServiceEnabled": false,
+ "isAndroidMotionActivityEnabled": false
+ }
+ ],
"@react-native-community/datetimepicker",
"expo-status-bar",
"expo-sharing",
diff --git a/frontend/assets/illustrations/activity/catalog/README.md b/frontend/assets/illustrations/activity/catalog/README.md
new file mode 100644
index 0000000..ca16ab0
--- /dev/null
+++ b/frontend/assets/illustrations/activity/catalog/README.md
@@ -0,0 +1,11 @@
+# Activity catalog artwork
+
+These 41 square illustrations were generated as new assets with the image-generation tool, using the existing `basketball.jpg`, `study-session.jpg`, `cafe-hangout.jpg`, and `gaming.jpg` files as style references. They were exported as 1024×1024 JPEGs at quality 82.
+
+Shared prompt direction:
+
+> Square activity-card illustration for the Oval college social app. Match the reference images' established warm, softly painterly editorial digital-illustration style, cohesive palette, gentle natural light, and friendly expressive college-age characters age 18+. Make the activity instantly readable at small thumbnail size with a balanced close-to-medium composition and clear faces and hands. No text, letters, logos, school branding, watermark, border, UI, or photographic realism. Avoid duplicated people, malformed hands, and background clutter.
+
+Each image used a separate generation call with an activity-specific scene appended to that direction—for example, a campus-library collaboration for `study-group`, a friendly table card game for `card-games`, food packing for `food-bank-volunteering`, and an ensemble practice-room jam for `play-practice-music`.
+
+The filenames are stable backend `artworkKey` values. Display-title changes should not rename these files or keys.
diff --git a/frontend/assets/illustrations/activity/catalog/animal-shelter-volunteering.jpg b/frontend/assets/illustrations/activity/catalog/animal-shelter-volunteering.jpg
new file mode 100644
index 0000000..4ffa175
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/animal-shelter-volunteering.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/bake-something.jpg b/frontend/assets/illustrations/activity/catalog/bake-something.jpg
new file mode 100644
index 0000000..9ca5a75
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/bake-something.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/board-games.jpg b/frontend/assets/illustrations/activity/catalog/board-games.jpg
new file mode 100644
index 0000000..3934f5b
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/board-games.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/bowling.jpg b/frontend/assets/illustrations/activity/catalog/bowling.jpg
new file mode 100644
index 0000000..917a66a
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/bowling.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/card-games.jpg b/frontend/assets/illustrations/activity/catalog/card-games.jpg
new file mode 100644
index 0000000..fd867d5
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/card-games.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/casual-hangout.jpg b/frontend/assets/illustrations/activity/catalog/casual-hangout.jpg
new file mode 100644
index 0000000..38f157e
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/casual-hangout.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/chess.jpg b/frontend/assets/illustrations/activity/catalog/chess.jpg
new file mode 100644
index 0000000..f81403c
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/chess.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/cook-together.jpg b/frontend/assets/illustrations/activity/catalog/cook-together.jpg
new file mode 100644
index 0000000..403f624
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/cook-together.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/crafting.jpg b/frontend/assets/illustrations/activity/catalog/crafting.jpg
new file mode 100644
index 0000000..c3443b2
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/crafting.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/creative-writing.jpg b/frontend/assets/illustrations/activity/catalog/creative-writing.jpg
new file mode 100644
index 0000000..c250d5b
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/creative-writing.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/dance.jpg b/frontend/assets/illustrations/activity/catalog/dance.jpg
new file mode 100644
index 0000000..1045fcc
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/dance.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/draw-paint.jpg b/frontend/assets/illustrations/activity/catalog/draw-paint.jpg
new file mode 100644
index 0000000..9cc88e4
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/draw-paint.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/eat-at-dining-hall.jpg b/frontend/assets/illustrations/activity/catalog/eat-at-dining-hall.jpg
new file mode 100644
index 0000000..4f696c2
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/eat-at-dining-hall.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/eat-at-restaurant.jpg b/frontend/assets/illustrations/activity/catalog/eat-at-restaurant.jpg
new file mode 100644
index 0000000..69bf197
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/eat-at-restaurant.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/food-bank-volunteering.jpg b/frontend/assets/illustrations/activity/catalog/food-bank-volunteering.jpg
new file mode 100644
index 0000000..c13e376
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/food-bank-volunteering.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/frisbee.jpg b/frontend/assets/illustrations/activity/catalog/frisbee.jpg
new file mode 100644
index 0000000..0e0f8be
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/frisbee.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/go-to-event.jpg b/frontend/assets/illustrations/activity/catalog/go-to-event.jpg
new file mode 100644
index 0000000..fff2dd6
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/go-to-event.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/golf.jpg b/frontend/assets/illustrations/activity/catalog/golf.jpg
new file mode 100644
index 0000000..d34e526
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/golf.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/grab-coffee-tea.jpg b/frontend/assets/illustrations/activity/catalog/grab-coffee-tea.jpg
new file mode 100644
index 0000000..e58ef34
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/grab-coffee-tea.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/gym-partner.jpg b/frontend/assets/illustrations/activity/catalog/gym-partner.jpg
new file mode 100644
index 0000000..dc66a89
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/gym-partner.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/medical-center-volunteering.jpg b/frontend/assets/illustrations/activity/catalog/medical-center-volunteering.jpg
new file mode 100644
index 0000000..91b9521
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/medical-center-volunteering.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/meditation.jpg b/frontend/assets/illustrations/activity/catalog/meditation.jpg
new file mode 100644
index 0000000..73abeac
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/meditation.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/mobile-games.jpg b/frontend/assets/illustrations/activity/catalog/mobile-games.jpg
new file mode 100644
index 0000000..eb93d84
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/mobile-games.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/movie-watch-party.jpg b/frontend/assets/illustrations/activity/catalog/movie-watch-party.jpg
new file mode 100644
index 0000000..c1d09f1
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/movie-watch-party.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/nature-walk.jpg b/frontend/assets/illustrations/activity/catalog/nature-walk.jpg
new file mode 100644
index 0000000..98f1e99
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/nature-walk.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/other-volunteering.jpg b/frontend/assets/illustrations/activity/catalog/other-volunteering.jpg
new file mode 100644
index 0000000..265f2d0
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/other-volunteering.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/photography.jpg b/frontend/assets/illustrations/activity/catalog/photography.jpg
new file mode 100644
index 0000000..d4cadec
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/photography.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/pickup-basketball.jpg b/frontend/assets/illustrations/activity/catalog/pickup-basketball.jpg
new file mode 100644
index 0000000..58cbbad
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/pickup-basketball.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/pickup-soccer.jpg b/frontend/assets/illustrations/activity/catalog/pickup-soccer.jpg
new file mode 100644
index 0000000..1ac4f59
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/pickup-soccer.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/pickup-volleyball.jpg b/frontend/assets/illustrations/activity/catalog/pickup-volleyball.jpg
new file mode 100644
index 0000000..d634cb6
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/pickup-volleyball.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/picnic.jpg b/frontend/assets/illustrations/activity/catalog/picnic.jpg
new file mode 100644
index 0000000..894ff0b
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/picnic.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/play-practice-music.jpg b/frontend/assets/illustrations/activity/catalog/play-practice-music.jpg
new file mode 100644
index 0000000..b4a431a
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/play-practice-music.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/reading-book-club.jpg b/frontend/assets/illustrations/activity/catalog/reading-book-club.jpg
new file mode 100644
index 0000000..4fe385b
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/reading-book-club.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/running-jogging.jpg b/frontend/assets/illustrations/activity/catalog/running-jogging.jpg
new file mode 100644
index 0000000..46b3d35
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/running-jogging.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/study-group.jpg b/frontend/assets/illustrations/activity/catalog/study-group.jpg
new file mode 100644
index 0000000..ba89104
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/study-group.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/swimming.jpg b/frontend/assets/illustrations/activity/catalog/swimming.jpg
new file mode 100644
index 0000000..3c97049
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/swimming.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/tabletop-rpgs.jpg b/frontend/assets/illustrations/activity/catalog/tabletop-rpgs.jpg
new file mode 100644
index 0000000..d56eeae
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/tabletop-rpgs.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/tennis-pickleball.jpg b/frontend/assets/illustrations/activity/catalog/tennis-pickleball.jpg
new file mode 100644
index 0000000..9cf7599
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/tennis-pickleball.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/trivia.jpg b/frontend/assets/illustrations/activity/catalog/trivia.jpg
new file mode 100644
index 0000000..910d420
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/trivia.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/video-games.jpg b/frontend/assets/illustrations/activity/catalog/video-games.jpg
new file mode 100644
index 0000000..a5e20b8
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/video-games.jpg differ
diff --git a/frontend/assets/illustrations/activity/catalog/yoga.jpg b/frontend/assets/illustrations/activity/catalog/yoga.jpg
new file mode 100644
index 0000000..9f063dc
Binary files /dev/null and b/frontend/assets/illustrations/activity/catalog/yoga.jpg differ
diff --git a/frontend/eas.json b/frontend/eas.json
index fe0fc69..fc3f9d7 100644
--- a/frontend/eas.json
+++ b/frontend/eas.json
@@ -14,6 +14,7 @@
"distribution": "internal",
"env": {
"EXPO_PUBLIC_API_URL": "https://api.theovalapp.com",
+ "EXPO_PUBLIC_ENV": "preview",
"SENTRY_DISABLE_AUTO_UPLOAD": "true",
"EXPO_PUBLIC_SUPABASE_URL": "https://vwxvtmyqjmihncufhkwj.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ3eHZ0bXlxam1paG5jdWZoa3dqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg2ODcyNzIsImV4cCI6MjA4NDI2MzI3Mn0.LKuIKs-MNY_OW0Nv0i9Ldivp2jki8U6pNS5HFFLRhJg"
@@ -24,6 +25,7 @@
"autoIncrement": true,
"env": {
"EXPO_PUBLIC_API_URL": "https://api.theovalapp.com",
+ "EXPO_PUBLIC_ENV": "production",
"SENTRY_DISABLE_AUTO_UPLOAD": "true",
"EXPO_PUBLIC_SUPABASE_URL": "https://vwxvtmyqjmihncufhkwj.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ3eHZ0bXlxam1paG5jdWZoa3dqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg2ODcyNzIsImV4cCI6MjA4NDI2MzI3Mn0.LKuIKs-MNY_OW0Nv0i9Ldivp2jki8U6pNS5HFFLRhJg"
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9004c08..1ea090c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -4023,9 +4023,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
- "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
diff --git a/frontend/package.json b/frontend/package.json
index 87ad135..42f075a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -66,7 +66,7 @@
"@react-native/babel-preset": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"@react-native/metro-config": "0.85.3",
- "brace-expansion": "^5.0.8",
+ "brace-expansion": "^5.0.9",
"shell-quote": "^1.8.4",
"uuid": "^11.1.1"
},
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index c6fcd6e..feba347 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1118,6 +1118,7 @@ export const leavePod = (podId: string) =>
request(`/pods/${podId}/leave`, { method: 'POST' });
export interface EditPodPayload {
+ title?: string;
meetupTime?: string;
location?: string;
latitude?: number;
diff --git a/frontend/src/constants/categories.ts b/frontend/src/constants/categories.ts
index 2e1b189..f5293da 100644
--- a/frontend/src/constants/categories.ts
+++ b/frontend/src/constants/categories.ts
@@ -7,27 +7,23 @@ export interface CategoryMeta {
}
export const CATEGORIES: string[] = [
- 'Sports & Fitness',
- 'Food & Drink',
- 'Academic',
- 'Arts & Creative',
- 'Social',
- 'Outdoors',
- 'Music & Entertainment',
- 'Wellness',
+ 'Academic / Study',
+ 'Sports',
+ 'Fitness & Wellness',
+ 'Social & Events',
'Gaming',
'Volunteering',
+ 'Food',
+ 'Music & Arts',
];
export const CATEGORY_META: Record = {
- 'Sports & Fitness': { label: 'Sports & Fitness', icon: 'fitness-outline', color: '#22c55e' },
- 'Food & Drink': { label: 'Food & Drink', icon: 'restaurant-outline', color: '#f59e0b' },
- 'Academic': { label: 'Academic', icon: 'book-outline', color: '#3b82f6' },
- 'Arts & Creative': { label: 'Arts & Creative', icon: 'color-palette-outline', color: '#ec4899' },
- 'Social': { label: 'Social', icon: 'people-outline', color: '#990000' },
- 'Outdoors': { label: 'Outdoors', icon: 'leaf-outline', color: '#14b8a6' },
- 'Music & Entertainment': { label: 'Music & Entertainment', icon: 'musical-notes-outline', color: '#f97316' },
- 'Wellness': { label: 'Wellness', icon: 'heart-outline', color: '#06b6d4' },
+ 'Academic / Study': { label: 'Academic / Study', icon: 'book-outline', color: '#3b82f6' },
+ Sports: { label: 'Sports', icon: 'basketball-outline', color: '#22c55e' },
+ 'Fitness & Wellness': { label: 'Fitness & Wellness', icon: 'fitness-outline', color: '#06b6d4' },
+ 'Social & Events': { label: 'Social & Events', icon: 'people-outline', color: '#990000' },
'Gaming': { label: 'Gaming', icon: 'game-controller-outline', color: '#ef4444' },
'Volunteering': { label: 'Volunteering', icon: 'hand-left-outline', color: '#10b981' },
+ Food: { label: 'Food', icon: 'restaurant-outline', color: '#f59e0b' },
+ 'Music & Arts': { label: 'Music & Arts', icon: 'color-palette-outline', color: '#ec4899' },
};
diff --git a/frontend/src/constants/contentImages.ts b/frontend/src/constants/contentImages.ts
index 0bd17fa..db76f72 100644
--- a/frontend/src/constants/contentImages.ts
+++ b/frontend/src/constants/contentImages.ts
@@ -8,12 +8,51 @@ import type { Activity } from '../types';
*/
const ACTIVITY_IMAGES_BY_ID: Record = {};
-/**
- * Explicit mappings for curated catalog activities. These are exact catalog
- * titles—not fuzzy matching and never user-entered pod copy. Once the backend
- * exposes a stable artwork key, this table can move to that key without
- * changing call sites.
- */
+const ACTIVITY_IMAGES_BY_ARTWORK_KEY: Record = {
+ 'study-group': require('../../assets/illustrations/activity/catalog/study-group.jpg'),
+ 'reading-book-club': require('../../assets/illustrations/activity/catalog/reading-book-club.jpg'),
+ 'pickup-basketball': require('../../assets/illustrations/activity/catalog/pickup-basketball.jpg'),
+ 'pickup-soccer': require('../../assets/illustrations/activity/catalog/pickup-soccer.jpg'),
+ 'pickup-volleyball': require('../../assets/illustrations/activity/catalog/pickup-volleyball.jpg'),
+ 'tennis-pickleball': require('../../assets/illustrations/activity/catalog/tennis-pickleball.jpg'),
+ 'running-jogging': require('../../assets/illustrations/activity/catalog/running-jogging.jpg'),
+ swimming: require('../../assets/illustrations/activity/catalog/swimming.jpg'),
+ golf: require('../../assets/illustrations/activity/catalog/golf.jpg'),
+ bowling: require('../../assets/illustrations/activity/catalog/bowling.jpg'),
+ frisbee: require('../../assets/illustrations/activity/catalog/frisbee.jpg'),
+ 'gym-partner': require('../../assets/illustrations/activity/catalog/gym-partner.jpg'),
+ yoga: require('../../assets/illustrations/activity/catalog/yoga.jpg'),
+ meditation: require('../../assets/illustrations/activity/catalog/meditation.jpg'),
+ 'nature-walk': require('../../assets/illustrations/activity/catalog/nature-walk.jpg'),
+ 'casual-hangout': require('../../assets/illustrations/activity/catalog/casual-hangout.jpg'),
+ 'movie-watch-party': require('../../assets/illustrations/activity/catalog/movie-watch-party.jpg'),
+ 'go-to-event': require('../../assets/illustrations/activity/catalog/go-to-event.jpg'),
+ 'video-games': require('../../assets/illustrations/activity/catalog/video-games.jpg'),
+ 'board-games': require('../../assets/illustrations/activity/catalog/board-games.jpg'),
+ 'card-games': require('../../assets/illustrations/activity/catalog/card-games.jpg'),
+ 'tabletop-rpgs': require('../../assets/illustrations/activity/catalog/tabletop-rpgs.jpg'),
+ 'mobile-games': require('../../assets/illustrations/activity/catalog/mobile-games.jpg'),
+ trivia: require('../../assets/illustrations/activity/catalog/trivia.jpg'),
+ chess: require('../../assets/illustrations/activity/catalog/chess.jpg'),
+ 'food-bank-volunteering': require('../../assets/illustrations/activity/catalog/food-bank-volunteering.jpg'),
+ 'animal-shelter-volunteering': require('../../assets/illustrations/activity/catalog/animal-shelter-volunteering.jpg'),
+ 'medical-center-volunteering': require('../../assets/illustrations/activity/catalog/medical-center-volunteering.jpg'),
+ 'other-volunteering': require('../../assets/illustrations/activity/catalog/other-volunteering.jpg'),
+ 'cook-together': require('../../assets/illustrations/activity/catalog/cook-together.jpg'),
+ 'eat-at-restaurant': require('../../assets/illustrations/activity/catalog/eat-at-restaurant.jpg'),
+ 'eat-at-dining-hall': require('../../assets/illustrations/activity/catalog/eat-at-dining-hall.jpg'),
+ 'grab-coffee-tea': require('../../assets/illustrations/activity/catalog/grab-coffee-tea.jpg'),
+ 'bake-something': require('../../assets/illustrations/activity/catalog/bake-something.jpg'),
+ picnic: require('../../assets/illustrations/activity/catalog/picnic.jpg'),
+ 'draw-paint': require('../../assets/illustrations/activity/catalog/draw-paint.jpg'),
+ crafting: require('../../assets/illustrations/activity/catalog/crafting.jpg'),
+ 'creative-writing': require('../../assets/illustrations/activity/catalog/creative-writing.jpg'),
+ 'play-practice-music': require('../../assets/illustrations/activity/catalog/play-practice-music.jpg'),
+ photography: require('../../assets/illustrations/activity/catalog/photography.jpg'),
+ dance: require('../../assets/illustrations/activity/catalog/dance.jpg'),
+};
+
+/** Legacy title mappings keep historical pods visually intact. */
const ACTIVITY_IMAGES_BY_CATALOG_TITLE: Record = {
'Basketball Pickup Game': require('../../assets/illustrations/activity/basketball.jpg'),
'Study Group Sprint': require('../../assets/illustrations/activity/study-session.jpg'),
@@ -23,6 +62,12 @@ const ACTIVITY_IMAGES_BY_CATALOG_TITLE: Record = {
};
const ACTIVITY_IMAGES_BY_CATEGORY: Record = {
+ 'Academic / Study': require('../../assets/illustrations/activity/study-session.jpg'),
+ Sports: require('../../assets/illustrations/activity/soccer.jpg'),
+ 'Fitness & Wellness': require('../../assets/illustrations/activity/outdoors.jpg'),
+ 'Social & Events': require('../../assets/illustrations/activity/campus-hangout.jpg'),
+ Food: require('../../assets/illustrations/activity/cafe-hangout.jpg'),
+ 'Music & Arts': require('../../assets/illustrations/activity/arts-music.jpg'),
'Sports & Fitness': require('../../assets/illustrations/activity/soccer.jpg'),
'Food & Drink': require('../../assets/illustrations/activity/cafe-hangout.jpg'),
Academic: require('../../assets/illustrations/activity/study-session.jpg'),
@@ -77,11 +122,12 @@ export function activityImageForId(activityId: string): ImageSourcePropType {
}
export function activityImageFor(
- activity?: Pick | null,
+ activity?: Pick | null,
): ImageSourcePropType {
if (!activity) return DEFAULT_ACTIVITY_IMAGE;
return (
ACTIVITY_IMAGES_BY_ID[activity.id] ??
+ (activity.artworkKey ? ACTIVITY_IMAGES_BY_ARTWORK_KEY[activity.artworkKey] : undefined) ??
ACTIVITY_IMAGES_BY_CATALOG_TITLE[activity.title] ??
ACTIVITY_IMAGES_BY_CATEGORY[activity.category] ??
DEFAULT_ACTIVITY_IMAGE
diff --git a/frontend/src/constants/podTemplates.ts b/frontend/src/constants/podTemplates.ts
index c80311f..87cd595 100644
--- a/frontend/src/constants/podTemplates.ts
+++ b/frontend/src/constants/podTemplates.ts
@@ -14,7 +14,7 @@ export type PodTemplate = {
export const POD_TEMPLATES: PodTemplate[] = [
{
id: 'boba-tonight',
- activityTitle: 'Boba Run',
+ activityTitle: 'Grab coffee or tea',
label: 'Boba run',
detail: 'Tonight near the Union',
location: 'Ohio Union',
@@ -23,7 +23,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'study-thompson',
- activityTitle: 'Study Group Sprint',
+ activityTitle: 'Study group',
label: 'Study grind',
detail: 'Focused work at Thompson',
location: 'Thompson Library',
@@ -32,7 +32,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'pickup-hoops',
- activityTitle: 'Basketball Pickup Game',
+ activityTitle: 'Pickup basketball',
label: 'Pickup hoops',
detail: 'Run a game at RPAC',
location: 'RPAC',
@@ -41,7 +41,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'soccer-sat',
- activityTitle: 'Soccer Kickaround',
+ activityTitle: 'Pickup soccer',
label: 'Soccer kickaround',
detail: 'Casual touches outside',
location: 'Lincoln Tower Park',
@@ -50,7 +50,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'board-games',
- activityTitle: 'Board Game Night',
+ activityTitle: 'Board games',
label: 'Board games',
detail: 'Low-key table at the Union',
location: 'Ohio Union',
@@ -59,7 +59,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'movie-night',
- activityTitle: 'Movie Night',
+ activityTitle: 'Movie / watch party',
label: 'Movie night',
detail: 'Pick something together',
location: 'Ohio Union',
@@ -68,7 +68,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'mirror-lake',
- activityTitle: 'Mirror Lake Hangout',
+ activityTitle: 'Casual hangout',
label: 'Mirror Lake hang',
detail: 'Walk, sit, decompress',
location: 'Mirror Lake',
@@ -77,7 +77,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'yoga-reset',
- activityTitle: 'Yoga Reset',
+ activityTitle: 'Yoga',
label: 'Yoga reset',
detail: 'Easy reset session',
location: 'RPAC – Wellness Space',
@@ -86,7 +86,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'smash-night',
- activityTitle: 'Smash Bros Tournament',
+ activityTitle: 'Video games',
label: 'Smash bracket',
detail: 'Friendly games, no sweat',
location: 'Ohio Union – Esports Arena',
@@ -95,7 +95,7 @@ export const POD_TEMPLATES: PodTemplate[] = [
},
{
id: 'campus-cleanup',
- activityTitle: 'Campus Cleanup',
+ activityTitle: 'Other volunteering',
label: 'Campus cleanup',
detail: 'Bring a friend, do some good',
location: 'Ohio Union – Service Hub',
diff --git a/frontend/src/dev/inboxFixtures.ts b/frontend/src/dev/inboxFixtures.ts
index 124496a..b49d9f5 100644
--- a/frontend/src/dev/inboxFixtures.ts
+++ b/frontend/src/dev/inboxFixtures.ts
@@ -11,9 +11,10 @@ const people: FriendUser[] = [
const activity: Activity = {
id: 'inbox-study',
- title: 'Data Science Study Group',
+ title: 'Study group',
description: 'Practice problems and exam prep.',
- category: 'Academic',
+ category: 'Academic / Study',
+ artworkKey: 'study-group',
defaultLocation: 'Thompson Library',
createdAt: new Date().toISOString(),
};
@@ -42,11 +43,42 @@ const pod: Pod = {
unreadCount: 2,
};
+const socialActivity: Activity = {
+ id: 'inbox-boba',
+ title: 'Grab coffee or tea',
+ description: 'A quick High Street walk with whoever is free.',
+ category: 'Food',
+ artworkKey: 'grab-coffee-tea',
+ defaultLocation: 'High Street',
+ createdAt: new Date().toISOString(),
+};
+
+const socialPod: Pod = {
+ ...pod,
+ id: 'inbox-pod-boba',
+ activityId: socialActivity.id,
+ meetupTime: new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString(),
+ location: 'High Street',
+ activity: socialActivity,
+ members: people.slice(1, 4).map((person, index) => ({
+ id: `inbox-boba-member-${index + 1}`,
+ userId: person.id,
+ joinedAt: new Date(Date.now() - (index + 1) * 35 * 60 * 1000).toISOString(),
+ user: {
+ id: person.id,
+ name: person.name,
+ avatarUrl: person.avatarUrl,
+ },
+ })),
+ unreadCount: 1,
+};
+
const inviteActivity: Activity = {
id: 'inbox-women-stem',
- title: 'Women in STEM',
+ title: 'Study group',
description: 'A welcoming pod for women across STEM majors.',
- category: 'Academic',
+ category: 'Academic / Study',
+ artworkKey: 'study-group',
defaultLocation: '18th Avenue Library',
createdAt: new Date().toISOString(),
};
@@ -71,11 +103,11 @@ const invitePod: Pod = {
const community: InboxPreviewData = {
summary: {
- dmUnread: 1,
- podUnread: 2,
- invites: 1,
+ dmUnread: 2,
+ podUnread: 3,
+ invites: 2,
friendRequests: 0,
- total: 4,
+ total: 7,
},
threads: [
{
@@ -94,10 +126,10 @@ const community: InboxPreviewData = {
otherUser: people[1]!,
lastMessage: {
content: 'Thanks for the notes!',
- createdAt: new Date(Date.now() - 20 * 60 * 60 * 1000).toISOString(),
+ createdAt: new Date(Date.now() - 52 * 60 * 1000).toISOString(),
senderId: people[1]!.id,
},
- updatedAt: new Date(Date.now() - 20 * 60 * 60 * 1000).toISOString(),
+ updatedAt: new Date(Date.now() - 52 * 60 * 1000).toISOString(),
hasUnread: false,
},
{
@@ -105,11 +137,11 @@ const community: InboxPreviewData = {
otherUser: people[2]!,
lastMessage: {
content: 'Want to grab coffee after class tomorrow?',
- createdAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
+ createdAt: new Date(Date.now() - 78 * 60 * 1000).toISOString(),
senderId: people[2]!.id,
},
- updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
- hasUnread: false,
+ updatedAt: new Date(Date.now() - 78 * 60 * 1000).toISOString(),
+ hasUnread: true,
},
],
invites: [
@@ -123,6 +155,16 @@ const community: InboxPreviewData = {
pod: { ...invitePod, activity: inviteActivity },
sender: people[3],
},
+ {
+ id: 'invite-boba',
+ podId: socialPod.id,
+ senderId: people[1]!.id,
+ receiverId: 'preview-user',
+ status: 'PENDING',
+ createdAt: new Date(Date.now() - 72 * 60 * 1000).toISOString(),
+ pod: { ...socialPod, activity: socialActivity },
+ sender: people[1],
+ },
],
requests: [],
outgoingRequests: [],
@@ -134,6 +176,12 @@ const community: InboxPreviewData = {
lastMessageSenderName: 'Casey',
lastMessagePreview: 'I added the practice problems',
},
+ {
+ ...socialPod,
+ lastMessageAt: new Date(Date.now() - 28 * 60 * 1000).toISOString(),
+ lastMessageSenderName: 'Taylor',
+ lastMessagePreview: 'Meet by the Union entrance?',
+ },
],
};
diff --git a/frontend/src/dev/previewMode.ts b/frontend/src/dev/previewMode.ts
index a1bd995..f1db755 100644
--- a/frontend/src/dev/previewMode.ts
+++ b/frontend/src/dev/previewMode.ts
@@ -14,6 +14,23 @@ export function getUiPreviewMode(): string | undefined {
return webQueryValue('preview') ?? process.env.EXPO_PUBLIC_UI_PREVIEW;
}
+export type UiPreviewTab = 'Home' | 'Explore' | 'Pods' | 'Clubs' | 'Inbox';
+
+export function getUiPreviewTab(): UiPreviewTab | undefined {
+ const value = webQueryValue('tab');
+ return value === 'Home' ||
+ value === 'Explore' ||
+ value === 'Pods' ||
+ value === 'Clubs' ||
+ value === 'Inbox'
+ ? value
+ : undefined;
+}
+
+export function getUiPreviewNeutralDock(): boolean {
+ return webQueryValue('dock') === 'neutral';
+}
+
export function getUiPreviewTheme(): 'light' | 'dark' | null {
const queryTheme = webQueryValue('theme');
if (queryTheme === 'light' || queryTheme === 'dark') return queryTheme;
diff --git a/frontend/src/dev/secondaryFixtures.ts b/frontend/src/dev/secondaryFixtures.ts
index 54dd3a8..18ac313 100644
--- a/frontend/src/dev/secondaryFixtures.ts
+++ b/frontend/src/dev/secondaryFixtures.ts
@@ -206,7 +206,7 @@ export const previewActivityRequests: ActivityRequest[] = [
userId: 'student-1',
title: 'Ceramics Studio Night',
description: 'A relaxed evening of ceramic making and glazing for all skill levels.',
- category: 'Arts & Creative',
+ category: 'Music & Arts',
defaultLocation: 'Hopkins Hall',
status: 'PENDING',
createdAt: minutesAgo(60),
diff --git a/frontend/src/dev/stage2Fixtures.ts b/frontend/src/dev/stage2Fixtures.ts
index e65448c..5a3b7e1 100644
--- a/frontend/src/dev/stage2Fixtures.ts
+++ b/frontend/src/dev/stage2Fixtures.ts
@@ -15,9 +15,10 @@ import type { ClubsPreviewData } from '../screens/clubs/ClubsHomeScreen';
const activities: Activity[] = [
{
id: 'activity-basketball',
- title: 'Basketball Pickup Game',
+ title: 'Pickup basketball',
description: 'Friendly pickup games for any skill level.',
- category: 'Sports & Fitness',
+ category: 'Sports',
+ artworkKey: 'pickup-basketball',
defaultLocation: 'RPAC courts',
createdAt: new Date().toISOString(),
demandCount: 8,
@@ -25,45 +26,50 @@ const activities: Activity[] = [
},
{
id: 'activity-study',
- title: 'Study Group Sprint',
+ title: 'Study group',
description: 'Quiet focus with a small group.',
- category: 'Academic',
+ category: 'Academic / Study',
+ artworkKey: 'study-group',
defaultLocation: 'Thompson Library',
createdAt: new Date().toISOString(),
demandCount: 6,
},
{
id: 'activity-trivia',
- title: 'Trivia Night',
+ title: 'Trivia',
description: 'Form a team for a lively trivia round.',
- category: 'Social',
+ category: 'Gaming',
+ artworkKey: 'trivia',
defaultLocation: 'Ohio Union',
createdAt: new Date().toISOString(),
demandCount: 4,
},
{
id: 'activity-walk',
- title: 'Sunset campus walk',
+ title: 'Nature walk',
description: 'Take a loop around campus after class.',
- category: 'Outdoors',
+ category: 'Fitness & Wellness',
+ artworkKey: 'nature-walk',
defaultLocation: 'Mirror Lake',
createdAt: new Date().toISOString(),
demandCount: 3,
},
{
id: 'activity-boba',
- title: 'Boba run',
+ title: 'Grab coffee or tea',
description: 'Walk over for an afternoon drink.',
- category: 'Food & Drink',
+ category: 'Food',
+ artworkKey: 'grab-coffee-tea',
defaultLocation: 'High Street',
createdAt: new Date().toISOString(),
demandCount: 2,
},
{
id: 'activity-games',
- title: 'Board game break',
+ title: 'Board games',
description: 'One quick game between classes.',
category: 'Gaming',
+ artworkKey: 'board-games',
defaultLocation: 'Ohio Union',
createdAt: new Date().toISOString(),
demandCount: 1,
@@ -102,7 +108,7 @@ function futureIso(hoursFromNow: number) {
const communityPods: Pod[] = Array.from({ length: 12 }, (_, index) => {
const activity = activities[index % activities.length]!;
- const memberTotal = 2 + (index % 3);
+ const memberTotal = 4 + (index % 3);
return {
id: `preview-pod-${index + 1}`,
activityId: activity.id,
@@ -123,7 +129,7 @@ const communityPods: Pod[] = Array.from({ length: 12 }, (_, index) => {
};
});
-const friends: FriendUser[] = people.slice(0, 3).map((person) => ({
+const friends: FriendUser[] = people.slice(0, 5).map((person) => ({
...person,
verifiedUniversity: true,
}));
@@ -156,6 +162,20 @@ const photographyMeeting: ClubMeetingToday = {
isMyClub: false,
};
+const volleyballMeeting: ClubMeetingToday = {
+ id: 'meeting-volleyball',
+ title: 'Open gym & team mixer',
+ location: 'RPAC Upper Gym',
+ meetingTime: futureIso(9),
+ isPublic: true,
+ visibility: 'PUBLIC',
+ clubId: 'club-volleyball',
+ clubName: 'Volleyball Club',
+ clubEmoji: '🏐',
+ attendeeCount: 26,
+ isMyClub: true,
+};
+
const clubMembership: MyClubMembershipRow = {
membershipId: 'membership-design',
role: 'MEMBER',
@@ -270,6 +290,67 @@ const clubDirectory: ClubDirectoryEntry[] = [
},
];
+const photographyMembership: MyClubMembershipRow = {
+ membershipId: 'membership-photography',
+ role: 'MEMBER',
+ joinedAt: new Date().toISOString(),
+ unreadCount: 4,
+ club: clubDirectory[1]!,
+ nextMeeting: {
+ id: photographyMeeting.id,
+ clubId: photographyMeeting.clubId,
+ title: photographyMeeting.title,
+ description: 'Bring any camera. We will pair up, walk the Oval, and swap favorite shots.',
+ location: photographyMeeting.location,
+ meetingTime: photographyMeeting.meetingTime,
+ isPublic: true,
+ visibility: 'PUBLIC',
+ createdById: 'person-jordan',
+ createdAt: new Date().toISOString(),
+ },
+ latestAnnouncement: {
+ id: 'announcement-photography',
+ clubId: photographyMeeting.clubId,
+ userId: 'person-jordan',
+ content: 'Golden hour walk is on. Meet by the Thompson statue and bring a friend.',
+ visibility: 'PUBLIC',
+ createdAt: new Date(Date.now() - 18 * 60 * 1000).toISOString(),
+ user: { id: 'person-jordan', name: 'Jordan', avatarUrl: null },
+ },
+};
+
+const volleyballMembership: MyClubMembershipRow = {
+ membershipId: 'membership-volleyball',
+ role: 'MEMBER',
+ joinedAt: new Date().toISOString(),
+ unreadCount: 1,
+ club: clubDirectory[3]!,
+ nextMeeting: {
+ id: volleyballMeeting.id,
+ clubId: volleyballMeeting.clubId,
+ title: volleyballMeeting.title,
+ description: 'Open play, quick team rotations, and a casual mixer afterward.',
+ location: volleyballMeeting.location,
+ meetingTime: volleyballMeeting.meetingTime,
+ isPublic: true,
+ visibility: 'PUBLIC',
+ createdById: 'person-sam',
+ createdAt: new Date().toISOString(),
+ },
+ latestAnnouncement: {
+ id: 'announcement-volleyball',
+ clubId: volleyballMeeting.clubId,
+ userId: 'person-sam',
+ content: 'Open gym teams are posted. New players are absolutely welcome.',
+ visibility: 'PUBLIC',
+ createdAt: new Date(Date.now() - 42 * 60 * 1000).toISOString(),
+ user: { id: 'person-sam', name: 'Sam', avatarUrl: null },
+ },
+};
+
+const communityMemberships = [clubMembership, photographyMembership, volleyballMembership];
+const communityMeetings = [clubMeeting, photographyMeeting, volleyballMeeting];
+
export type Stage2PreviewMode = 'zero' | 'spotlight' | 'community' | 'search';
export function homePreviewData(mode: Stage2PreviewMode): HomePreviewData {
@@ -277,9 +358,9 @@ export function homePreviewData(mode: Stage2PreviewMode): HomePreviewData {
mode === 'zero' ? [] : mode === 'spotlight' ? communityPods.slice(0, 4) : communityPods;
return {
pods,
- myPods: mode === 'zero' ? [] : [pods[0]!],
- clubMeetings: mode === 'community' ? [clubMeeting] : [],
- myClubs: mode === 'community' ? [clubMembership] : [],
+ myPods: mode === 'zero' ? [] : pods.slice(0, 3),
+ clubMeetings: mode === 'community' ? communityMeetings : [],
+ myClubs: mode === 'community' ? communityMemberships : [],
friends,
summary: {
dmUnread: 1,
@@ -313,7 +394,7 @@ export function podsPreviewData(mode: Stage2PreviewMode): PodsPreviewData {
? communityPods.slice(0, 4)
: communityPods;
return {
- activePods: mode === 'community' ? communityPods.slice(0, 3) : [],
+ activePods: mode === 'community' ? communityPods.slice(0, 5) : [],
historyPods: communityPods
.slice(6, 9)
.map((pod, index) => ({
@@ -342,7 +423,7 @@ export function clubsPreviewData(mode: Stage2PreviewMode): ClubsPreviewData {
}
return {
clubs: clubDirectory,
- myClubs: [clubMembership],
- meetings: [clubMeeting, photographyMeeting],
+ myClubs: communityMemberships,
+ meetings: communityMeetings,
};
}
diff --git a/frontend/src/screens/ActivityPodsScreen.tsx b/frontend/src/screens/ActivityPodsScreen.tsx
index 56aa342..4f6e14d 100644
--- a/frontend/src/screens/ActivityPodsScreen.tsx
+++ b/frontend/src/screens/ActivityPodsScreen.tsx
@@ -52,6 +52,7 @@ import {
} from '../constants/campusMap';
import { customCreateDefaults } from '../constants/podTemplates';
import { formatDateTime } from '../utils/format';
+import { getPodTitleValidationError } from '../utils/podTitleValidation';
import {
BORDER_W,
Theme,
@@ -82,6 +83,50 @@ function dedupeLocations(locations: string[]) {
});
}
+const POD_TITLE_EXAMPLES: Record = {
+ 'study-group': 'CSE 2231 midterm review',
+ 'reading-book-club': 'Sci-fi book club kickoff',
+ 'pickup-basketball': '3v3 at the RPAC',
+ 'pickup-soccer': 'Beginner soccer at Lincoln Fields',
+ 'pickup-volleyball': 'Sunset grass volleyball',
+ 'tennis-pickleball': 'Beginner pickleball doubles',
+ 'running-jogging': 'Easy 5K around campus',
+ swimming: 'Morning lap swim',
+ golf: 'Driving range after class',
+ bowling: 'Friday bowling night',
+ frisbee: 'Casual frisbee on the Oval',
+ 'gym-partner': 'Push day at the RPAC',
+ yoga: 'Beginner sunset yoga',
+ meditation: '20-minute guided reset',
+ 'nature-walk': 'Olentangy Trail walk',
+ 'casual-hangout': 'Mirror Lake sunset hang',
+ 'movie-watch-party': 'Buckeyes watch party',
+ 'go-to-event': 'Gallery opening at the Wex',
+ 'video-games': 'Mario Kart tournament',
+ 'board-games': 'Catan at the Union',
+ 'card-games': 'Poker night',
+ 'tabletop-rpgs': 'Beginner D&D one-shot',
+ 'mobile-games': 'Pokémon GO campus walk',
+ trivia: 'Thursday trivia team',
+ chess: 'Beginner chess meetup',
+ 'food-bank-volunteering': 'Saturday food packing shift',
+ 'animal-shelter-volunteering': 'Shelter volunteer afternoon',
+ 'medical-center-volunteering': 'Care-kit assembly night',
+ 'other-volunteering': 'Oval cleanup crew',
+ 'cook-together': 'Homemade pasta night',
+ 'eat-at-restaurant': 'Sushi on High Street',
+ 'eat-at-dining-hall': 'Dinner at Scott',
+ 'grab-coffee-tea': 'Coffee before class',
+ 'bake-something': 'Brownie bake night',
+ picnic: 'Sunday picnic on the Oval',
+ 'draw-paint': 'Watercolor at Mirror Lake',
+ crafting: 'Crochet and conversation',
+ 'creative-writing': 'Poetry workshop',
+ 'play-practice-music': 'Acoustic jam session',
+ photography: 'Golden-hour photo walk',
+ dance: 'Beginner salsa practice',
+};
+
export default function ActivityPodsScreen({ route, navigation, preview = false }: Props) {
const styles = useStyles();
const { colors, typography } = useTheme();
@@ -91,7 +136,11 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
const [pods, setPods] = useState([]);
const [busyId, setBusyId] = useState(null);
const [creating, setCreating] = useState(false);
- const [title, setTitle] = useState(activity.title);
+ // The activity is the broad category (for example, "Card Games"); the pod
+ // title should describe the specific plan people are joining.
+ const [title, setTitle] = useState('');
+ const [titleTouched, setTitleTouched] = useState(false);
+ const [titleServerError, setTitleServerError] = useState(null);
const [note, setNote] = useState('');
const [location, setLocation] = useState('');
const [locationAddress, setLocationAddress] = useState('');
@@ -113,6 +162,9 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
const [locationMessage, setLocationMessage] = useState(null);
const [loaded, setLoaded] = useState(preview);
const [loadWarning, setLoadWarning] = useState(null);
+ const liveTitleError = getPodTitleValidationError(title);
+ const titleFieldError =
+ titleServerError ?? (title.trim() || titleTouched ? liveTitleError : null);
useEffect(() => {
setDemandCount(activity.demandCount ?? 0);
@@ -185,8 +237,9 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
};
const handleCreate = async () => {
- if (!title.trim()) {
- toast.error('Add a pod title', 'Give people a quick, clear reason to join.');
+ setTitleTouched(true);
+ const titleError = getPodTitleValidationError(title);
+ if (titleError) {
return;
}
if (!location.trim() || !locationAddress.trim()) {
@@ -219,7 +272,12 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
});
navigation.replace('PodDetail', { podId: response.id, justCreated: true });
} catch (error) {
- toast.error('Could not start pod', getApiErrorMessage(error));
+ const message = getApiErrorMessage(error);
+ if (/pod title|descriptive pod title|safety rules/i.test(message)) {
+ setTitleServerError(message);
+ } else {
+ toast.error('Could not start pod', message);
+ }
} finally {
setCreating(false);
}
@@ -410,11 +468,22 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
{composerExpanded ? (
{
+ setTitle(value);
+ setTitleServerError(null);
+ }}
+ onBlur={() => setTitleTouched(true)}
+ placeholder={
+ (activity.artworkKey && POD_TITLE_EXAMPLES[activity.artworkKey]) ||
+ `A specific ${activity.title.toLowerCase()} plan`
+ }
maxLength={60}
+ error={titleFieldError}
+ hint={title.trim() && !liveTitleError
+ ? 'Looks good — this is what people will see in the feed.'
+ : `${activity.title} is the activity. Add the specific plan here.`}
/>
@@ -546,7 +615,13 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
{note.trim() ? {note.trim()} : null}
-
+
Only invited members can see private pods.
@@ -577,9 +652,14 @@ export default function ActivityPodsScreen({ route, navigation, preview = false
accessibilityLabel={`Open pod at ${pod.location}`}
>
-
- {formatDateTime(pod.meetupTime)}
-
+
+
+ {pod.title?.trim() || activity.title}
+
+
+ {formatDateTime(pod.meetupTime)}
+
+
{!isOpen ? (
diff --git a/frontend/src/screens/InboxScreen.tsx b/frontend/src/screens/InboxScreen.tsx
index 4e9a382..b06a509 100644
--- a/frontend/src/screens/InboxScreen.tsx
+++ b/frontend/src/screens/InboxScreen.tsx
@@ -55,6 +55,7 @@ import { activityImageFor } from '../constants/contentImages';
import { useAuth } from '../context/AuthContext';
import { toast } from '../lib/toast';
import { formatDateTime, relativeTime } from '../utils/format';
+import { getPodTitle } from '../utils/experience';
import {
BORDER_W,
Theme,
@@ -1218,6 +1219,7 @@ function PodChatRow({
const styles = useStyles();
const { colors } = useTheme();
const unread = pod.unreadCount ?? 0;
+ const title = getPodTitle(pod);
const members = pod.members.map((member) => ({
name: member.user.name,
uri: member.user.avatarUrl,
@@ -1226,7 +1228,7 @@ function PodChatRow({
[
styles.inboxRow,
showDivider && { borderBottomWidth: BORDER_W, borderBottomColor: colors.borderSoft },
@@ -1248,7 +1250,7 @@ function PodChatRow({
- {pod.activity?.title ?? 'Pod chat'}
+ {title}
{pod.lastMessageAt ? (
@@ -1290,7 +1292,7 @@ function PodChatCard({ pod, onPress }: { pod: InboxPodThread; onPress: () => voi
name: member.user.name,
uri: member.user.avatarUrl,
}));
- const title = pod.activity?.title ?? 'Pod chat';
+ const title = getPodTitle(pod);
return (
You're invited to join
- {invite.pod?.activity.title ?? 'A pod'}
+ {invite.pod ? getPodTitle(invite.pod) : 'A pod'}
@@ -1547,7 +1549,7 @@ function PodInviteCard({
You're invited to join
- {invite.pod?.activity.title ?? 'A pod'}
+ {invite.pod ? getPodTitle(invite.pod) : 'A pod'}
{memberCount
diff --git a/frontend/src/screens/PodChatScreen.tsx b/frontend/src/screens/PodChatScreen.tsx
index fc887d1..6aeb8a4 100644
--- a/frontend/src/screens/PodChatScreen.tsx
+++ b/frontend/src/screens/PodChatScreen.tsx
@@ -31,6 +31,7 @@ import {
import * as Clipboard from 'expo-clipboard';
import { RootStackParamList } from '../../App';
import { Message, Pod } from '../types';
+import { getPodTitle } from '../utils/experience';
import {
AppBackdrop,
Avatar,
@@ -324,7 +325,7 @@ export default function PodChatScreen({ route, navigation }: Props) {
See you soon
- {pod.activity?.title ?? 'Your pod'} at {formatTime(pod.meetupTime)} — let the
+ {getPodTitle(pod)} at {formatTime(pod.meetupTime)} — let the
others know you're coming.
diff --git a/frontend/src/screens/PodDetailScreen.tsx b/frontend/src/screens/PodDetailScreen.tsx
index 40c8a54..80a196e 100644
--- a/frontend/src/screens/PodDetailScreen.tsx
+++ b/frontend/src/screens/PodDetailScreen.tsx
@@ -20,6 +20,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
cancelPod,
confirmAttendance,
+ createReport,
createPod,
editPod,
getApiErrorMessage,
@@ -52,6 +53,7 @@ import {
ContentImage,
DateTimeField,
EmptyState,
+ Field,
IconButton,
ListRow,
ScreenHeader,
@@ -76,6 +78,8 @@ import { useAuth } from '../context/AuthContext';
import { INTEREST_TAG_META } from '../constants/interestTags';
import { activityImageFor } from '../constants/contentImages';
import { getPodTitle } from '../utils/experience';
+import { REPORT_REASON_OPTIONS } from '../constants/reportReasons';
+import { getPodTitleValidationError } from '../utils/podTitleValidation';
import { toast } from '../lib/toast';
type Props = NativeStackScreenProps;
@@ -125,8 +129,12 @@ export default function PodDetailScreen({ route, navigation }: Props) {
const [detailsOpen, setDetailsOpen] = useState(true);
const [inviteOpen, setInviteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
+ const [reportOpen, setReportOpen] = useState(false);
+ const [editTitle, setEditTitle] = useState('');
+ const [editTitleServerError, setEditTitleServerError] = useState(null);
const [editLocation, setEditLocation] = useState('');
const [editTime, setEditTime] = useState(new Date());
+ const editTitleError = editTitleServerError ?? getPodTitleValidationError(editTitle);
const load = useCallback(
async (showAlert = false) => {
@@ -380,6 +388,8 @@ export default function PodDetailScreen({ route, navigation }: Props) {
const openEditPod = () => {
if (!pod) return;
+ setEditTitle(getPodTitle(pod));
+ setEditTitleServerError(null);
setEditLocation(pod.location);
setEditTime(new Date(pod.meetupTime));
setEditOpen(true);
@@ -387,6 +397,7 @@ export default function PodDetailScreen({ route, navigation }: Props) {
const handleEditSave = async () => {
if (!pod) return;
+ if (getPodTitleValidationError(editTitle)) return;
if (!editLocation.trim()) {
toast.error('Add a meetup spot', 'The location cannot be empty.');
return;
@@ -394,13 +405,37 @@ export default function PodDetailScreen({ route, navigation }: Props) {
setActionBusy('edit');
try {
const updated = await editPod(pod.id, {
+ title: editTitle.trim(),
location: editLocation.trim(),
meetupTime: editTime.toISOString(),
});
setPod(updated);
setEditOpen(false);
} catch (error) {
- toast.error('Could not update pod', getApiErrorMessage(error));
+ const message = getApiErrorMessage(error);
+ if (/pod title|descriptive pod title|safety rules/i.test(message)) {
+ setEditTitleServerError(message);
+ } else {
+ toast.error('Could not update pod', message);
+ }
+ } finally {
+ setActionBusy(null);
+ }
+ };
+
+ const handleReportPod = async (reason: string) => {
+ if (!pod) return;
+ setActionBusy(`report-${reason}`);
+ try {
+ await createReport({
+ podId: pod.id,
+ reason,
+ details: `Pod title: ${getPodTitle(pod)}`,
+ });
+ setReportOpen(false);
+ toast.success('Report sent', 'Thanks. We logged this pod for review.');
+ } catch (error) {
+ toast.error('Could not send report', getApiErrorMessage(error));
} finally {
setActionBusy(null);
}
@@ -570,6 +605,14 @@ export default function PodDetailScreen({ route, navigation }: Props) {
accessibilityLabel="Share pod link"
size={48}
/>
+ {!isCreator ? (
+ setReportOpen(true)}
+ accessibilityLabel="Report pod"
+ size={48}
+ />
+ ) : null}
{isCreator && (pod.status === 'FORMING' || pod.status === 'LOCKED') ? (
+ setReportOpen(false)}
+ title="Report this pod"
+ kicker="SAFETY"
+ scrollable
+ >
+
+
+ Choose the reason that best describes the title or plan. Reports are sent to Oval for
+ manual review.
+
+ {REPORT_REASON_OPTIONS.map((option) => (
+
+
setEditOpen(false)}
@@ -1098,6 +1165,18 @@ export default function PodDetailScreen({ route, navigation }: Props) {
kicker="CREATOR TOOLS"
>
+ {
+ setEditTitle(value);
+ setEditTitleServerError(null);
+ }}
+ placeholder="Euchre at Morrill Tower"
+ maxLength={60}
+ error={editTitleError}
+ hint="This is what people will see in the feed."
+ />
Location
void handleEditSave()}
loading={actionBusy === 'edit'}
+ disabled={Boolean(editTitleError)}
size="lg"
/>