Back to case studies
Case study
Plans
Technology, Social, Mobile Apps
2025

Plans – Find Something to Do

A full case study on Plans — a real-world social activity platform designed and engineered end-to-end by Pixelvise.

Visit project
Activity feed — desktop
ClientPlans
IndustryTechnology, Social, Mobile Apps
Year2025
WebsiteVisit site

Overview

Plans is a real-world social activity platform for people who want to meet others and do things — not scroll endlessly. Users discover nearby plans across 22 activity categories, host their own, join with a single tap, and coordinate logistics in a per-plan chat. Location-aware filtering surfaces what’s relevant; a vibe system (Chill, Active, Talkative, Quiet) signals the tone before you commit. Plans runs as a full-stack web app and is packaged as a native iOS and Android app via Capacitor.

Pixelvise designed and engineered Plans end-to-end — from brand identity and landing page to the full-stack web application, REST API, mobile packaging, push notifications, and production deployment on Railway.

The Problem

Meeting people in real life is harder than it should be. Existing tools — Facebook Events, Meetup, WhatsApp groups — all assume you already have a social graph to invite from, or that you’re willing to join a large, anonymous public group. The friction between “I want to do something” and “I found real people to do it with” has never been properly solved.

  • No intent signal. Social apps show you who your friends are, not what they want to do right now.
  • Group chats require prior relationships. You can’t coordinate logistics with strangers before you’ve agreed to meet.
  • Discovery is location-blind. Event platforms show city-wide listings; filtering to “what’s happening within walking distance tonight” is buried or broken.
  • Vibe mismatch. Joining a group activity with no social cue about its energy — loud party vs. quiet book club — leads to awkward experiences and drop-offs.
  • No lightweight hosting. Creating an event on Facebook or Eventbrite is a 10-step form with publicity settings, RSVPs, and ticketing — overkill for “anyone want to grab coffee in an hour?”.

What Plans Does

Activity feed with smart filtering

The home screen is a filterable grid of upcoming plans near the user. Filters span category (22 types: Cafe, Fitness, Photography, Art, Book, Gaming, and more), time (Now, Today, Weekend, Later), distance (Near, 5 km, Anywhere), and vibe. The grid renders skeleton cards while data loads and falls back gracefully when offline. Each card shows the host’s avatar, category badge, date, area, slot count, and vibe pill at a glance.

Plan creation with geocoding and cover art

Creating a plan takes under a minute. The host picks a category, sets a date-time, types a location (resolved to coordinates via Google Places), selects a vibe, sets optional slot limits, optionally adds host-plus guests (partners, family, colleagues), toggles private/public, and uploads a cover photo. Plans with no cover fall back to a per-category illustration. On submit, the plan is posted to the REST API and a shareable link is generated immediately.

Private plans and join requests

Hosts can mark plans as private. Private plans require approval — joiners submit a request, the host sees a pending queue in the plan detail and can accept or reject individually. Approved joiners are immediately added to the participant list and gain access to the plan chat. Rejected joiners can re-request.

Per-plan group chat

Every plan has a dedicated chat room. Access is gated: only confirmed participants (and the host) can message. The chat page renders messages chronologically with sender avatars, relative timestamps, and a sticky composer that accounts for the mobile on-screen keyboard using a CSS --keyboard-height variable updated via Capacitor’s Keyboard plugin.

Location-aware map view

Each plan detail page embeds a Leaflet map centred on the plan’s coordinates. If the plan’s address has not been geocoded at creation time, the detail page geocodes it on the fly via the server-side Google Geocoding proxy. The map shows a single marker with a custom red pin; tapping it opens the address in the device’s native maps app via a deep link.

Social graph: follows and people discovery

Users follow each other. The Following page shows the full follow/follower list with mutual indicators. Plans hosted by people you follow surface with a visual badge. The user profile page shows hosted and joined counts, bio, interests, and a direct follow button — no DMs, no mutual-follow requirement, no algorithmic feed.

Notifications

Server-generated notifications are stored in MySQL and fetched on the notifications page. Types include plan_join, join_request, join_approved, follow, and chat. An unread dot on the tab bar updates in real time. On native, Firebase Cloud Messaging delivers push notifications via the server-side Firebase Admin SDK; the device registers its FCM token on login via a dedicated endpoint.

Admin portal

A fully separate admin application (React, accessible at /admin) provides user management (ban, unban, view profile), plan management (soft-delete, view details), report review (accept/dismiss), and site-wide statistics. The admin portal is protected by a separate session check against the is_admin flag on the profiles table.

Our Role at Pixelvise

Pixelvise handled the full product lifecycle:

  • Product strategy — concept definition, feature scoping, and competitive positioning against Facebook Events, Meetup, and WhatsApp group chats
  • Brand & visual design — the coral-red identity (#F4455A), warm off-white palette (#fef9f4), custom CSS design tokens, iconography system, and default avatar illustrations
  • UI/UX design — mobile-first layouts, bottom tab bar navigation, responsive card grids, skeleton loading states, vibe pills, slot indicators, and 44 pt minimum tap targets throughout
  • Frontend engineering — React 19 SPA, Vite, Tailwind CSS v4, React Router v7, React Leaflet, context-based auth
  • Backend engineering — Node.js/Express REST API (~3 000 lines), MySQL schema with 9 tables, JWT auth, bcrypt, Google Places geocoding proxy, Mailtrap email, Firebase Admin SDK
  • Mobile packaging — Capacitor 8 integration for iOS and Android, keyboard handling, status bar theming, push notifications, CapacitorHttp for native HTTP
  • Public landing page — separate Vite/React app with Framer Motion hero, 3-phone tilt showcase, category grid, safety section, team section, and legal pages (Privacy, Terms, Cookies)
  • Production deployment — Railway (Node.js server + MySQL), Netlify (landing page), custom domain app.joinmyplans.com

The Stack

LayerTechnology
App frameworkReact 19 + Vite 7
StylingTailwind CSS v4 (custom @theme design tokens)
StateReact Context API + custom hooks
MapsReact Leaflet 5 + Leaflet 1.9
MobileCapacitor 8 (iOS + Android)
AuthJWT (jsonwebtoken) + bcrypt, stored in Capacitor Preferences / localStorage
DatabaseMySQL 8 (9 tables)
BackendNode.js 20 + Express (single index.js, ~3 000 lines)
GeocodingGoogle Places API (server-side proxy)
EmailMailtrap HTTP API
Push notificationsFirebase Admin SDK (FCM)
DeploymentRailway (server + DB), Netlify (landing page)
Domainapp.joinmyplans.com (custom Railway domain)
Fontssystem-ui / -apple-system (body)
IconsCustom SVG icon components (Lucide-style)
Landing page extrasFramer Motion, canvas-confetti

Why this stack?

Capacitor over React Native. The team chose Capacitor so the same React codebase serves the web app and both native shells without maintaining separate codebases. Capacitor’s CapacitorHttp plugin is enabled to route all network requests through the native layer, bypassing CORS issues that arise on iOS with cookie-based sessions.

Express monolith over microservices. A single index.js file handles all API routes. For a product at this stage, the operational simplicity of one Node.js process on Railway outweighs the scalability advantages of splitting concerns. The file is large (~3 000 lines) but well-structured into route groups and testable in isolation.

MySQL over PostgreSQL or SQLite. Railway’s managed MySQL offering with MYSQL_URL connection string is zero-config in production. The relational model — nine tables with explicit foreign keys and ON DELETE CASCADE — maps cleanly to the social graph (follows, blocks, notifications) and plan lifecycle (participants, join requests, chat messages).

Mailtrap over SMTP. Railway blocks outbound port 587, making traditional SMTP impossible from the server. The Mailtrap HTTP API sends transactional emails (password reset, welcome) over HTTPS with a single fetch call, eliminating the need for an SMTP transport entirely.

Key Engineering Challenges

1. Keyboard height on iOS with Capacitor

Problem: When a user opened the chat composer on iOS, the on-screen keyboard pushed the sticky header upward and left it stuck above its original position after the keyboard dismissed. Standard window.resize events fire unreliably inside a Capacitor WKWebView.

Solution: Capacitor’s Keyboard plugin fires keyboardWillShow and keyboardWillHide events before the animation runs. The main.tsx entry point registers listeners that write a --keyboard-height CSS custom property to document.documentElement. Chat and form pages read this property via a pb-keyboard utility class (padding-bottom: calc(var(--keyboard-height, 0px) + env(safe-area-inset-bottom, 0px) + 2rem)). The header displacement was fixed separately by replacing position: sticky with position: fixed on affected headers and compensating with a matching padding-top on the scroll container.

2. CORS and cookie auth on native iOS

Problem: The app uses JWT tokens stored via Capacitor.Preferences on native and localStorage on web. Fetch calls from a Capacitor WKWebView to a Railway-hosted Express server were blocked by iOS’s strict CORS enforcement for file:// origins, breaking authentication headers on native builds.

Solution: CapacitorHttp was enabled in capacitor.config.ts ("CapacitorHttp": { "enabled": true }). This intercepts all fetch and XMLHttpRequest calls and routes them through the native HTTP layer, which does not apply CORS restrictions. The server’s CORS configuration was updated to allow the app.joinmyplans.com origin explicitly, and the api.ts client was audited to ensure all requests send the Authorization: Bearer header consistently.

3. Slot count accuracy with host-plus guests

Problem: Plan slots fill up based on participant count, but hosts can bring extra guests (“host-plus”). A plan with 2 slots where the host brings 1 guest was showing 1 slot filled (for the host alone), letting a second joiner in when the plan was already effectively full.

Solution: The slotsFilled column is computed server-side as SUM(plus_count + 1) across plan_participants, where each participant row stores their own plus_count. The host is inserted into plan_participants on plan creation with their host_plus_count value, so the host’s guests consume slots from the start. The join endpoint re-runs this aggregation before accepting a new participant and rejects with a 409 if the sum would exceed slots_max.

4. Mailtrap HTTP API on Railway (port 587 blocked)

Problem: Railway’s networking policy blocks outbound port 587, making SMTP-based email sending impossible from the Express server. The initial nodemailer + SMTP configuration worked locally but failed silently in production — password reset emails were never delivered.

Solution: The email layer was rewritten to use Mailtrap’s HTTP API (https://send.api.mailtrap.io/api/send) with a single fetch POST and an Authorization: Bearer header. The MAILTRAP_API_KEY is stored as a Railway environment variable. HTML email templates use the brand’s #F4455A coral-red, the Plans logo hosted at https://app.joinmyplans.com/Brand/plans-logo.png, and a Pixelvise footer, consistent with the app’s visual identity.

5. Real-time-feeling chat without WebSockets

Problem: A full WebSocket server would add operational complexity on Railway (persistent connections, scaling concerns). But a chat feature that requires a manual page refresh to see new messages is a non-starter for users.

Solution: The PlanChatPage component polls the /api/plans/:id/chat endpoint every 4 seconds using a setInterval inside a useEffect. New messages are diffed by id and appended without re-rendering the full list. The scroll position is preserved unless the user is already at the bottom, in which case new messages auto-scroll into view. This approach adds ~40 requests/hour per active chat user — acceptable at current scale, with a clear upgrade path to Server-Sent Events when traffic warrants it.

Design Philosophy

Plans’ visual identity is built around one word: warmth.

  • Coral-red primary#F4455A as the single action colour, used for CTAs, category badges, and the bottom tab indicator. A linear gradient from #f4455a to #d93550 gives buttons depth without a design library.
  • Warm off-white backgrounds#fef9f4 (body) and #fef3c7/#fff7ed (card surfaces) keep the interface from feeling clinical. Pure white is reserved for input fields and modal backgrounds.
  • Skeleton shimmer — loading states use a CSS @keyframes gradient sweep (skeleton-shimmer) in amber-warm tones rather than grey, so even empty states feel on-brand.
  • Card lift — plan cards animate with a cubic-bezier(0.34, 1.56, 0.64, 1) spring on hover (translateY(-3px)) and a scale(0.975) press — physical without being overdone.
  • Stagger children — list renders use a .stagger > * CSS selector with incrementing animation-delay values so cards slide in sequentially rather than all at once.
  • system-ui font stack — no custom font is loaded. The app uses the device’s native system font for maximum rendering speed and zero layout shift.

Mobile was the primary design target. The bottom tab bar, 44 pt minimum tap targets, pb-tab scroll padding (7 rem + safe-area-inset-bottom), and --vh CSS variable (updated on every resize) all reflect a product designed to be used one-handed on a phone, not a desktop app ported to mobile.

Results

MetricValue
Frontend TypeScript/TSX lines~8 500 (app) + ~2 200 (landing page)
Backend JS lines~3 000 (Express API)
Database tables9
API endpoints45+
Activity categories22
Platforms shippedWeb, iOS (Capacitor), Android (Capacitor)
TypeScript strict errors0
ESLint warnings0
Deployment uptimeRailway managed, 99.9% SLA
Custom domainapp.joinmyplans.com (live)

Reflections

Building Plans reinforced that real-time UX expectations are non-negotiable in social products — even a four-second chat polling interval felt instant enough during testing, but the moment it wasn’t, users noticed. The discipline of choosing “good enough now with a clear upgrade path” over engineering for scale that doesn’t exist yet was the defining product decision on the backend.

The Capacitor integration was smoother than expected for most features, but the iOS keyboard and CORS issues consumed more time than anticipated. Both were fixable, but they required understanding the gap between “works in a browser” and “works inside a WKWebView” — a distinction that matters more with every new native API the app touches.

The warm-toned design system earned consistent positive feedback in testing. The choice to use system-ui instead of a web font eliminated a 150–300 ms render-blocking resource on mobile — a win that no font however beautiful could justify undoing.

If you’d like to discuss the project or commission something similar, get in touch at [email protected].

Share

No comments yet

Be the first to share your thoughts on this article.

Join the conversation

Your email address will not be published. By submitting, you agree to our Privacy Policy.