noteefy
recreationalcoder

Case Study: Noteefy Analytics

Published April 22, 2025 · Last updated April 30, 2026 · Neel Shah

Noteefy Analytics Overview Dashboard

I rebuilt Noteefy's admin analytics dashboard on Snowflake, Python FastAPI, and React, replacing a fragmented MongoDB Charts implementation. Lifetime dashboard load times for the largest multi-course operator dropped from up to ninety seconds to under twenty; pageviews on the new analytics pages grew steadily after launch and peaked at nearly ten times the traffic of the old admin analytics.

The existing analytics experience was built from a combination of MongoDB Charts and lightweight React components that embedded those charts into the admin application. This worked well enough for early reporting, but it became harder to scale as Noteefy needed to support larger multi-course operators, more product lines, and more sophisticated reporting.

I worked closely with Noteefy's COO, CTO, and Product leads to gather requirements and define the scope of the reimplementation. The goal was to make analytics a central part of the admin experience. The dashboard needed to load meaningful lifetime metrics for large operators, support universal filtering, help internal teams show the value of Noteefy to prospective customers, and give operators a clearer reason to return to the admin application.

One of the most important product decisions was making analytics the homepage of the admin application. That was not just a navigation change. It reflected the role analytics needed to play in the product. Noteefy wanted operators to see value as soon as they logged in. They also wanted internal teams to use the dashboard to impress potential customers, support renewals, and increase engagement with the admin application.

The dashboard was designed to tell a story quickly:

The value Noteefy created
Where that value came from
How activity changed over time
What operators should pay attention to next

That product framing influenced both the backend and frontend work. The goal was not to expose every possible metric. The goal was to organize the most important metrics into a clear, persuasive, and reusable experience.

I approached this challenge with the following plan:

  1. Replace the MongoDB Charts-based reporting experience with a custom analytics architecture
  2. Move the most important metric calculations into Snowflake using stored procedures
  3. Model the data around Noteefy's product value stories: recovered revenue, protected rounds, golfer engagement, search demand, communication demand, and operator activity
  4. Build a Python FastAPI microservice to connect the admin application to Snowflake through clean analytics APIs
  5. Rebuild the React dashboard experience with reusable components, universal filtering, MUI X Charts, consistent layouts, and stronger visual hierarchy
  6. Add PDF report downloads so internal teams and operators could turn filtered dashboard views into shareable reports
  7. Optimize lifetime metric loading for the largest multi-course operators across the Platform, Waitlist, and Confirm dashboards

The final result was a faster, more flexible analytics system that became the default homepage experience for the Noteefy admin application.

What stack made this work?

I arrived at the following stack for the implementation:

  1. MongoDB as the operational data source
  2. Estuary for syncing MongoDB data into Snowflake
  3. Snowflake for analytics storage, querying, and stored procedure execution
  4. SQL stored procedures for dashboard-specific analytics payloads
  5. Python FastAPI for the analytics microservice
  6. Redis for caching frequently requested metrics
  7. React for the admin dashboard frontend
  8. MUI X Charts for frontend visualizations
  9. dom-to-image and pdfmake for client-side PDF report generation
  10. Custom frontend logic for report formatting, anonymization, and interactive element replacement

The most important technical decision was treating the dashboard as a full analytics product, not just a frontend charting project.

Backend Architecture

The new architecture centered around Snowflake, with MongoDB data replicated into Snowflake using Estuary. A Python FastAPI microservice sat between the frontend and Snowflake, providing a stable API boundary with request validation, response formatting, and error handling so the React dashboard never needed to know how the analytics layer worked. This created a cleaner separation of concerns. Snowflake handled the heavy analytics work. FastAPI provided a stable application boundary. React handled filtering, layout, visualization, reporting, and user experience.

Throughout implementation, I gathered context and feedback from Noteefy's Engineering team and joined their daily scrums to stay aligned on data availability, API contracts, and integration timing.

Snowflake

Moving analytics into Snowflake gave Noteefy a dedicated environment where expensive reporting queries could run without relying directly on the operational MongoDB database. Once the data was available in Snowflake, the next challenge was deciding how to expose it to the dashboard.

Views were not flexible enough because the dashboard needed to pass in dynamic parameters like start date, end date, and selected course IDs. Snowflake functions were also not the right fit. Scalar functions were useful for small reusable transformations, but they could not represent an entire dashboard response. Table functions were closer, but still awkward because each dashboard needed to return multiple kinds of data at once: KPI values, chart series, nested objects, product-specific metric groups, and report-ready structures.

Stored procedures gave me the flexibility I needed. They allowed me to accept parameters, run multi-step SQL logic, reuse intermediate result sets, build structured response objects, and return dashboard-ready payloads. Instead of duplicating calculations across MongoDB Charts, API routes, and React components, the most important business logic lived in one analytics layer.

Testing the stored procedures was its own challenge. Unit testing SQL logic in Snowflake was not straightforward, so I built integration tests that ran the full procedures against known datasets and validated the response payloads. This approach fit cleanly into the CI/CD workflow and gave me confidence that metric calculations stayed correct as the procedures evolved.

Data Modeling

A key challenge was deciding how the stored procedure responses and API payloads should be structured. I decided early on that the response shape should match all the way from Snowflake through FastAPI to the React frontend, so that each layer could pass the data through without restructuring it.

Each product got its own response contract. Waitlist focused on demand and recovered opportunity: searches, golfer signups, recaptured revenue, and referrer attribution. Confirm focused on operational visibility and booking protection: text confirmations, communication demand, and rounds recaptured. Platform had its own set of metrics that brought the larger story together across products, communicating overall value without double-counting activity or flattening product-specific metrics into something less meaningful.

On the Platform overview page, the stored procedure actually fetches all product metrics, but the dashboard only surfaces the top-level KPIs for each product in side-by-side panes.

Platform Performance and Product Analytics KPIsplatform overview with product-specific metric panes

Metric Calculations

Many of the metrics already existed in some form through MongoDB Charts, but they lacked standardization. I worked with Product to define the exact metric definitions and with Engineering to validate the formulas against the underlying data.

That standardization process surfaced real complexity. The hardest problem was deduplication across products. Waitlist and Confirm were not independent data sources. They were part of the same user flow, but without explicit linkage in the data. A golfer who found a tee time through Waitlist might later confirm that same booking through Confirm. Both products would record activity for the same round, but there was no shared booking ID connecting them. At the same time, a different golfer might book the same tee time through a regular channel and also confirm through Confirm. The data was stored in nested documents, and the challenge was ensuring that overlapping bookings were not double-counted while still correctly summing spend across distinct golfers for the same tee time.

That kind of detail mattered because the dashboard was being used to make real business claims. A polished chart was not enough. The metrics had to be credible.

Backend Architecture

Performance Optimization

Performance was the most critical part of the project, as the analytics experience would be the first thing operators saw when they logged in. It needed to be fast and reliable for the largest multi-course operators.

The optimization goal was clear: page load time for lifetime metrics for the largest multi-course operator on each dashboard.

In the original MongoDB Charts implementation, individual charts loaded independently. Some metrics loaded in a few seconds, while others could take multiple minutes depending on the chart and the size of the operator.

After the first pass of the Snowflake stored procedure implementation, the dashboard experience was more unified, but it still was not fast enough. The initial stored procedure performance was roughly 5 seconds per course, which meant the query still scaled too linearly as more courses were added.

A lot of the optimization involved SQL query tuning. Here are a few representative tactics.

Query Tactic: Extract and Cast Semi-Structured Fields Once

Because the source records retained their MongoDB document structure, fields like prices, timestamps, referrers, and entity names were stored as nested paths. The original queries passed raw records through the base CTE and re-extracted and cast those fields in every downstream branch that needed them:

-- Before: base CTE passes raw records through
base_events AS (
  SELECT e.*
  FROM events e
  JOIN selected_groups g ON e.group_id = g.group_id
),

-- Each downstream CTE re-extracts and casts the same fields
revenue_total AS (
  SELECT
    SUM((e."event_data/amount")::NUMBER) AS total_revenue
  FROM base_events e
  WHERE (e."event_data/event_time")::TIMESTAMP >= :start_ts
    AND (e."event_data/event_time")::TIMESTAMP <= :end_ts
),

revenue_by_source AS (
  SELECT
    (e."source_data/source_name")::STRING AS source,
    SUM((e."event_data/amount")::NUMBER) AS revenue
  FROM base_events e
  WHERE (e."event_data/event_time")::TIMESTAMP >= :start_ts
    AND (e."event_data/event_time")::TIMESTAMP <= :end_ts
  GROUP BY source
)

The fix was to project all needed fields as typed columns once in the base CTE, so every downstream reference was a simple column read:

-- After: base CTE projects typed columns once
base_events AS (
  SELECT
    e.group_id,
    e.created_at,
    (e."event_data/amount")::NUMBER AS amount,
    (e."event_data/event_time")::TIMESTAMP AS event_time,
    LOWER(TRIM(e."user_data/email"::STRING)) AS user_email,
    UPPER(TRIM(e."source_data/source_name"::STRING)) AS source_name
  FROM events e
  JOIN selected_groups g ON e.group_id = g.group_id
),

-- Downstream CTEs use typed columns directly
revenue_total AS (
  SELECT SUM(e.amount) AS total_revenue
  FROM base_events e
  WHERE e.event_time >= :start_ts AND e.event_time <= :end_ts
),

revenue_by_source AS (
  SELECT e.source_name, SUM(e.amount) AS revenue
  FROM base_events e
  WHERE e.event_time >= :start_ts AND e.event_time <= :end_ts
  GROUP BY e.source_name
)

This eliminated redundant JSON-path extraction and casting across every branch of the query. In stored procedures with many metric branches, the savings compounded significantly.

Query Tactic: Single-Pass Monthly Aggregation

The monthly trend logic generated a list of month windows and then joined raw event data into those windows. That meant the query was fitting every row into a range predicate against generated month boundaries:

-- Before: generate month windows, join raw events into them
months AS (
  SELECT
    DATEADD(MONTH, -seq, DATE_TRUNC(MONTH, :end_ts)) AS month_start,
    DATEADD(MONTH, 1,
      DATEADD(MONTH, -seq, DATE_TRUNC(MONTH, :end_ts))) AS month_end
  FROM TABLE(GENERATOR(ROWCOUNT => :num_months)) g
),

events_by_month AS (
  SELECT
    m.month_start,
    (e."event_data/amount")::NUMBER AS amount
  FROM months m
  JOIN events e
    ON (e."event_data/event_time")::TIMESTAMP >= m.month_start
   AND (e."event_data/event_time")::TIMESTAMP <  m.month_end
)

The rewrite derived the month bucket directly from each event's timestamp, aggregated once by month, and then joined to the month list at the end to preserve the output shape:

-- After: derive month from events, aggregate once, join for shape
months AS (
  SELECT
    DATEADD(MONTH, -seq, DATE_TRUNC(MONTH, :end_ts)) AS month_start
  FROM TABLE(GENERATOR(ROWCOUNT => :num_months)) g
),

monthly_totals AS (
  SELECT
    DATE_TRUNC(MONTH, event_time) AS month_start,
    SUM(amount) AS total
  FROM base_events
  WHERE event_time >= :start_ts AND event_time <= :end_ts
  GROUP BY 1
),

trend AS (
  SELECT m.month_start, COALESCE(t.total, 0) AS total
  FROM months m
  LEFT JOIN monthly_totals t ON t.month_start = m.month_start
)

This changed the work from fitting raw rows into generated windows to deriving the month once and aggregating in a single pass.

Query Tactic: Consolidating Deduplication and Counting

Some metric paths involved two separate uniqueness-heavy operations. One user counting path first deduplicated records with GROUP BY and MIN(created_at) to find each user's earliest entry, then later counted with COUNT(DISTINCT user_id):

-- Before: GROUP BY + MIN for dedup, then COUNT(DISTINCT) later
users_all AS (
  SELECT
    group_id,
    user_id,
    MIN(created_at) AS first_seen
  FROM base_events
  GROUP BY 1, 2
),

active_users_total AS (
  SELECT CAST(COUNT(DISTINCT user_id) AS NUMBER) AS active_users
  FROM users_in_window
)

After refining the metric definition with Product, the underlying requirement shifted enough that a window function could handle both the deduplication and the ordering in one pass:

-- After: single window function dedupes and preserves earliest row
users_first AS (
  SELECT group_id, user_id, created_at AS first_seen
  FROM (
    SELECT group_id, user_id, created_at,
      ROW_NUMBER() OVER (
        PARTITION BY group_id, user_id ORDER BY created_at
      ) AS rn
    FROM base_events
    WHERE user_id IS NOT NULL
  )
  WHERE rn = 1
),

active_users_total AS (
  SELECT CAST(COUNT(*) AS NUMBER) AS active_users
  FROM users_in_window
)

This reduced two uniqueness-heavy operations to one.

Caching and Precomputation

Redis caching was added to the FastAPI microservice to cache frequently requested metrics. This reduced the load time for the most popular metrics and helped keep the page responsive even when the database was under heavy load. However, this was not enough to meet the performance goal. Another option was to precompute lifetime metrics, but this would only enhance the initial load time without addressing the scaling problem.

Because the source data came from MongoDB, many of the records synced into Snowflake retained their document structure with nested arrays and embedded objects. LATERAL FLATTEN was the natural way to work with this data in SQL, and the first implementation leaned on it heavily. That approach was correct for getting the metrics right, but it created performance problems that compounded at scale. We were able to avoid a few of these cases by refining metric definitions, but it wasn't enough to meet the performance goal. For cases where lateral flattening was still genuinely needed, I worked with the team to move that work out of the stored procedures entirely. By leveraging the Estuary data pipeline and writing trigger functions in Snowflake, I could flatten the nested document structures once at sync time rather than on every dashboard request. That way the stored procedures could query against pre-flattened tables without paying the flattening cost at read time.

The Principle

The production queries involved more metric branches and nested output structures, but the same principles applied across all of the optimization work.

The integration tests I had built for the stored procedures were critical throughout this process. Each optimization pass changed how intermediate results were computed, and the tests ensured that the final metric values stayed accurate through every iteration.

Not all of the improvements were pure SQL. Some of the most impactful changes came from refining the metric requirements with Product, which eliminated unnecessary calculations entirely.

Performance Results

The largest benchmark was lifetime metric loading for the largest multi-course operator. Before the reimplementation, individual MongoDB Charts loaded independently and could range from a few seconds to multiple minutes depending on the metric. The first iteration of the stored procedures unified the experience but still scaled linearly with course count. After the optimization work, the major dashboards loaded in seconds.

Largest MCO Lifetime Dashboard Load Times

Old Chartsv0Optimized
PlatformVariable~60s~12s
WaitlistVariable~90s~20s
ConfirmVariable~45s~8s

In prose: Platform dropped from roughly sixty seconds to twelve. Waitlist dropped from roughly ninety seconds to twenty. Confirm dropped from roughly forty-five seconds to eight. The largest multi-course operator went from staring at a loading spinner to seeing meaningful data fast enough to use it during a live customer conversation.

Performance Optimization

React Dashboard Reimplementation

The frontend was rebuilt as a custom React dashboard experience instead of a collection of embedded MongoDB Charts.

This gave me more control over layout, filtering, visual hierarchy, loading states, chart behavior, report generation, and product consistency.

The new dashboard experience included:

  • KPI cards for headline metrics
  • Product-specific dashboard sections
  • Universal date and course filtering
  • Reusable chart and table components
  • MUI X Charts for visualizations
  • Loading and empty states
  • A unified structure across Platform, Waitlist, Confirm, and AI Assistant
  • PDF report downloads
  • A homepage-style admin experience that immediately showed operators the value Noteefy was creating

The AI Assistant dashboard was already implemented differently, so that work was more about bringing it into the same admin UI structure. Platform, Waitlist, and Confirm were the primary analytics reimplementation targets.

Platform

The Platform dashboard opened with headline KPI cards for total golfers served and total economic impact, then broke down product-specific metrics for Waitlist, Confirm, and AI Assistant side by side.

Platform Performance and Product Analytics KPIsplatform performance and product analytics

Waitlist

The Waitlist dashboard surfaced revenue recaptured, engaged users, and searches created as top-level KPIs, with deeper views into confirmed booking revenue by lead time, search demand across time slots, referrer attribution, and golfer engagement metrics.

Waitlist KPIs and Confirmed Bookings Revenue Chartwaitlist KPIs and confirmed bookings revenue by lead time
Waitlist Demand Crystal Ball Chartssearch demand crystal ball, 0-30 and 30-90 day windows
Waitlist Traffic by Referrer and Engagement Metricstraffic by referrer and engagement metrics

Confirm

The Confirm dashboard led with revenue recaptured, rounds recaptured, and confirmations sent, then provided communication demand breakdowns by booking status and cancellation patterns by days before tee time.

Confirm KPIs and Communication Crystal Ball Chartconfirm KPIs and communication crystal ball
Confirm Cancellations by Days Before Tee Time and Engagement Metricscancellations by days before tee time and engagement metrics

AI Assistant

The AI Assistant dashboard tracked conversation volume, session duration, calls saved, and topic distribution, along with session ratings and booking engagement metrics.

AI Assistant Conversations, Topics Discussed, and Engagement Metricsconversations, topics discussed, and engagement metrics
AI Assistant Session Ratingssession ratings

Universal Filtering

Universal filtering was one of the most important UX improvements.

Instead of having each chart behave like its own separate report, the dashboard let users select a date range and course group once, then see the entire dashboard update around that context.

Universal Analytics Filtering with Multi-Course Operator, Course, and Date Selectionuniversal filtering: operator, course, and date selection

This was especially important for multi-course operators. A user could view one course, a subset of courses, or the full operator account without switching between disconnected reports.

It also made internal reporting easier. Sales and customer success teams could prepare a focused view for a customer conversation, then export that view into a report.

The backend complexity was hidden from the user. Different metrics used different event timestamps, but the UI presented one simple filtering experience.

That made the dashboard feel much more like a product and less like a reporting tool.

PDF Report Downloads

The PDF report feature turned the dashboard into a sales and customer success asset.

Reports were generated on the client using a custom function built with dom-to-image, pdfmake, and additional export logic.

That custom logic handled details like:

  • Capturing dashboard sections as report-ready images
  • Hiding or replacing interactive elements that did not belong in a PDF
  • Preserving the selected filter context
  • Anonymizing sensitive data when needed
  • Formatting the report so it could be shared outside the live admin interface

This mattered because Noteefy wanted the dashboard to support both internal teams and external operators. The live dashboard helped users explore the data, while the PDF report helped turn that data into a shareable business review.

Example Reports

Each report below is a PDF snapshot of a dashboard filtered to a specific product. They include the same KPI cards, charts, and breakdowns visible in the live dashboard, formatted for print and external sharing with Noteefy branding applied.

Frontend & Product

Results

The reimplementation gave Noteefy a stronger analytics foundation and a much more useful admin homepage.

The biggest wins were:

Page visit comparison showing new analytics pages peaking at 200 weekly pageviews vs 21 for the old admin analyticsNew analytics pages: 200 weekly pageviews vs. old admin analytics: 21

The page visit data confirmed that the new dashboard was not just a better reporting tool. It changed how users engaged with the admin application. The old analytics pages faded out naturally as operators and internal teams adopted the new experience as their default.

The result was a dashboard that did not just report data. It helped Noteefy communicate product value.

We hired Neel to help us with some AI and data initiatives. We needed somebody who could solve problems for us really quickly and that's exactly what he was able to do. What really impressed me with Neel was his ability to ramp up on our code base and gain context so quickly. He's very much a self starter and was able to take control and lead the project we needed.

Dathan Wong
Dathan WongCo-Founder, CTO, Noteefy

Conclusion

This project was a great example of combining backend architecture, data modeling, frontend development, and product strategy into one focused reimplementation.

Noteefy needed more than better charts. They needed an analytics experience that could scale to large multi-course operators, support internal sales and customer success workflows, and give operators a clearer reason to engage with the admin application.

By moving the analytics layer into Snowflake stored procedures, connecting it through a Python FastAPI microservice, rebuilding the frontend in React, adding universal filtering, using MUI X Charts for visualizations, and supporting PDF report downloads, I turned a fragmented reporting experience into a central part of the product.

The final result was a faster, more flexible, and more persuasive analytics dashboard that helped Noteefy tell a clearer story about recovered revenue, protected rounds, golfer engagement, and operator value.

The architecture under the hood (a clean operational source, a stored-procedure analytics layer, a stable API boundary, and a presentation layer focused on storytelling) is exactly the shape a nonprofit needs when funder reporting starts to look like a real product. Same pattern, different vocabulary: protected rounds becomes meals served, recovered revenue becomes outcomes attributed, multi-course operator becomes multi-program organization. Reach out if your team is staring at the same problem from a different industry.

Working on something similar?