Metro Vancouver Promotions — Project Story

Inspiration

Finding useful promotions across Metro Vancouver can be difficult because deals are often scattered across different stores, neighborhoods, websites, and social platforms. A promotion that is useful for one shopper may not be relevant to another person based on their interests, location, household income range, or shopping priorities.

We wanted to build a local promotion discovery experience that feels more personal than a simple list of discounts. The goal was to help shoppers discover fictional community promotions that match what matters to them while still allowing them to search and filter the results directly.

The project was also inspired by the challenge of designing a recommendation system that is helpful without exposing confusing or sensitive internal calculations. Users should see relevant results, not hidden matching scores or private profile classifications.

What it does

Metro Vancouver Promotions is a local promotion discovery application built with React, TypeScript, Vite, Python, and Flask.

Users can:

  • Complete a first-visit Profile and Preferences setup.
  • Select their interests, Metro Vancouver area, gender, and annual household-income range.
  • Browse promotion recommendations personalized to their profile.
  • Search promotions by name.
  • Apply independent filters for:
    • Category
    • Price level
    • Area
  • Adjust the importance of five recommendation factors:
    • Category match
    • Promotion area match
    • Publisher living-standard match
    • Publisher area match
    • Publisher gender match
  • Register, log in, log out, and delete their account.
  • Publish community promotions after completing a profile.
  • Add optional discount and weight or volume information.
  • Delete only promotions they published.

The application supports two local modes:

  1. A browser-only Demo that runs without Flask or Supabase.
  2. A React frontend connected to the included Flask JSON API.

All seeded promotions, prices, and images are fictional demo content.

The application deliberately does not show users the internal LivingStandard value or recommendation score. Users select an income range, and the system derives the internal value privately for recommendation ranking.

How we built it

We separated the application into frontend, backend, domain, service, repository, and UI layers.

The frontend uses React and TypeScript. The main application is initialized through main.tsx, which loads the AppProvider and the main App component. App.tsx coordinates the primary views:

  • Discover
  • Preferences
  • Profile

The frontend state and mutations are managed by frontend/src/context/AppContext.tsx. This provider handles sessions, user profiles, preferences, promotions, account actions, publishing, deletion, and demo reset operations.

We created two interchangeable frontend repositories:

  • DemoRepository for browser-only development
  • ApiRepository for Flask-backed development

The browser Demo stores:

  • Accounts, profiles, preferences, and promotion metadata in localStorage
  • The active login session in sessionStorage
  • Uploaded images in IndexedDB

The Flask backend is organized into several boundaries:

  • domain/ contains enums and dataclass models.
  • repositories/ contains the in-memory persistence boundary.
  • routes/ contains API controllers.
  • services/ contains authentication, recommendation, and verification logic.
  • tests/ contains backend tests.

The backend application is created in backend/app.py. It initializes the repository and services, registers the Flask blueprints, exposes the health endpoint, and serves the compiled frontend from the root-level dist/ directory.

The recommendation system uses five internal factors with the following base weights:

[ \text{Category}=5,\quad \text{Promotion Area}=3,\quad \text{Publisher Living Standard}=2,\quad \text{Publisher Area}=2,\quad \text{Publisher Gender}=1 ]

Each factor can be adjusted using a preference multiplier:

[ \text{Low}=0.5,\quad \text{Default}=1.0,\quad \text{High}=1.5 ]

For each promotion, only matching factors contribute to the total ranking value:

[

\text{Total Score}

\sum \left( \text{Base Weight} \times \text{Preference Multiplier} \right) ]

The score is used only internally. The application returns sorted promotions but never exposes the score to users or includes it in API responses.

Search normalization is implemented in both the frontend and backend. Search input is normalized using Unicode NFKC normalization, whitespace trimming, repeated-space collapsing, and case-insensitive matching.

Promotion validation is handled at the domain and route layers. Required fields include the promotion name, category, price, area, price level, and photo. Discount rates must be between 0 and 100. Weight or volume values require a supported unit. Images must be PNG, JPEG, WebP, or GIF files and must not exceed 10 MB.

The backend currently uses an in-memory repository so that the API remains easy to run locally. Supabase is intentionally reserved as a future integration target rather than being presented as an already implemented service.

Challenges we ran into

One challenge was balancing personalization with privacy. The recommendation system needs profile information such as income-derived living standards and publisher attributes, but users should not see internal classifications or raw matching values. We solved this by keeping those values inside the domain and service layers and returning only sorted promotion results.

Another challenge was keeping the browser-only Demo and the Flask API mode consistent. Both modes need to support the same user flows, including authentication, profile editing, preferences, filtering, promotion publishing, and deletion. We addressed this by defining repository boundaries and using DemoRepository and ApiRepository as interchangeable adapters.

Maintaining independent search and recommendation filters was another important design problem. If both modes shared the same filter state, changing a search filter could unexpectedly change recommendation results. We therefore modeled the discovery mode explicitly and kept the filter state for each mode separate.

The recommendation algorithm also had to remain synchronized between TypeScript and Python. The frontend implementation is in frontend/src/utils/recommendations.ts, while the backend implementation is in backend/services/recommendation_service.py. Both implementations use the same five factors, base weights, multipliers, and stable tie-breaking behavior.

Image uploads introduced another validation challenge. The application must support multiple image formats while enforcing a 10 MB limit. The frontend validates the selected file before saving it, and the backend validates the Base64 image data before creating a promotion.

Authentication was another area that required careful scope control. The project includes local registration, password hashing, login, logout, and session handling, but it is still a prototype. We intentionally did not describe the current authentication system as production-ready and did not claim that real email verification or Supabase Auth has been implemented.

Finally, the Flask backend currently stores data only in memory. This makes the local setup simple, but accounts, sessions, and community promotions disappear whenever the Flask process restarts. We documented this limitation clearly instead of hiding it behind a misleading persistence claim.

Accomplishments that we're proud of

We are proud that the project supports a complete interactive local experience instead of only demonstrating a static interface.

Important accomplishments include:

  • Building both a browser-only Demo and a Flask-backed API mode.
  • Creating a clear separation between UI components, repositories, services, and domain models.
  • Implementing first-visit Profile and Preferences onboarding.
  • Deriving the private LivingStandard from a user-selected income range.
  • Implementing a five-factor recommendation system with adjustable priorities.
  • Keeping recommendation scores completely private.
  • Supporting independent search and recommendation filters.
  • Implementing Unicode-, whitespace-, and case-normalized promotion-name search.
  • Adding account registration, login, logout, and account deletion.
  • Enforcing promotion ownership rules.
  • Automatically deleting a user’s profile and promotions when the account is deleted.
  • Supporting optional discounts and weight or volume information.
  • Validating image formats and the 10 MB image-size limit.
  • Creating responsive desktop and mobile layouts.
  • Adding backend tests for recommendation weighting and score leakage.
  • Keeping fictional data clearly separated from real business claims.

We are also proud of the architecture because it leaves clear boundaries for future improvements. The in-memory repository can eventually be replaced without rewriting the entire frontend or API layer.

What we learned

We learned that product requirements are most useful when they are translated into explicit architectural boundaries. For example, the rule that recommendation scores must remain private affects the domain models, service layer, serializers, UI components, accessibility text, and tests. It cannot be treated as only a visual design decision.

We also learned the importance of maintaining one source of truth for domain values. Categories, areas, price levels, income bands, recommendation factors, and weight units must remain synchronized between the frontend, backend, documentation, and tests.

Another lesson was that adapters make it easier to support multiple runtime modes. By separating DemoRepository and ApiRepository from the React components, the same user interface can work offline in a browser or connect to the Flask API.

We learned that search behavior must be deliberately specified. Normalizing Unicode, trimming whitespace, collapsing repeated spaces, and ignoring case makes search more predictable and accessible.

We also gained experience designing around incomplete infrastructure. Supabase, permanent persistence, and real email verification are future goals, but the current application remains useful as a local prototype. Clearly documenting what is implemented and what is not prevents users and future developers from misunderstanding the system.

Finally, we learned that testing should verify product constraints, not just individual functions. It is important to test not only recommendation order, but also that recommendation scores never appear in serialized Promotion responses.

What's next for Metro Vancouver Promotions

The next major step is replacing the in-memory backend repository with a durable Supabase-backed implementation. This would allow accounts, profiles, preferences, and community promotions to survive server restarts.

Future improvements include:

  • Supabase Auth for production-oriented authentication.
  • Supabase Postgres for durable data storage.
  • Supabase Storage for uploaded promotion images.
  • Real email verification through VerificationService.
  • Database constraints and row-level security.
  • Production-grade authorization and rate limiting.
  • Structured logging and improved error monitoring.
  • Stronger API validation and security protections.
  • More comprehensive frontend and backend integration tests.
  • Additional browser testing for onboarding, profile editing, filtering, publishing, ownership deletion, account deletion, and mobile layouts.
  • Better support for real-time promotion updates.
  • Additional accessibility testing.
  • More localized content and improved Metro Vancouver area coverage.

The local browser Demo should remain available as long as offline demonstration is still a product requirement. The long-term goal is to preserve the simplicity of the current architecture while adding durable storage, real authentication, and reliable real-time data sources.

Share this project:

Updates

Submission history