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} -