Data analytics portfolio

CUPSAnalytics

SQL & Power BI analysis of a mobile coffee-ordering app across 108 cafés in Prague

10,043orders analysed
2.08MCZK revenue
1,000customers
108cafés in Prague
60 days14 Aug – 12 Oct 2026
27,114records · 6 tables

About the project

Brew Route (CUPS) is a mobile app for ordering coffee ahead and picking it up at a café. I analysed its 60-day demo database to answer business questions about customers, products, peak hours and operations — first with SQL, then with interactive Power BI dashboards.

Tools

PostgreSQLSQLitePower BIDAXExcelGit & GitHub

01 · Data model

How the tables connect

A star-like schema: orders is the central fact table, linked to customers, cafés and promo codes; each order has one or more order_items, each pointing to a product.

customers 1,000
  • PKcustomer_id
  • FKhome_cafe_id
  • gender
  • age_group
  • persona
  • segment
  • signup_date
orders 10,043
  • PKorder_id
  • FKcustomer_id
  • FKcafe_id
  • FKpromo_code
  • created_at
  • status
  • order_type
  • prep_minutes
  • total
  • platform
cafes 108
  • PKcafe_id
  • name
  • chain
  • district
  • lat, lng
  • prep_minutes
promo_codes 2
  • PKcode
  • percent
  • first_order_only
  • scope
order_items 15,926
  • PKitem_id
  • FKorder_id
  • FKproduct_id
  • qty
  • unit_price
  • line_total
  • size
  • milk
  • syrup
products 35
  • PKproduct_id
  • product
  • category
  • base_price
  • is_combo
one → manycustomer's home café
10,043orders
15,926order_items
1,000customers
108cafes
35products
2promo_codes

1 customer → N orders

Joined on customer_id for segmentation, frequency and spend.

1 café → N orders

Joined on cafe_id for district revenue and prep time.

1 order → N items

Line level: product, size, milk, syrup, price.

02 · SQL analysis

5 query sets · 23 queries

Each file in /sql answers one group of business questions. Written in PostgreSQL.

01 · basic_exploration.sql

Basic exploration

Totals, revenue by district, top products, orders by hour, platform split.

GROUP BYJOINFILTER
02 · customer_analysis.sql

Customer analysis

Returning vs one-time, order frequency, top spenders, personas, age × gender.

CASEsubqueryHAVING
03 · product_analysis.sql

Product analysis

Revenue share per product, categories, milk options, price with customisations.

CTE% of total
04 · time_and_trends.sql

Time & trends

Weekly orders, weekday comparison, new customers per week, WoW growth.

DATE_TRUNCLAG
05 · operations.sql

Operations

Prep time by café, cancellation rate, pickup modes, promo-code usage, dine-in vs takeaway.

HAVINGCOALESCEconditional aggregation

Example · Revenue by district

sql/01_basic_exploration.sql · 1.2
SELECT
    c.district,
    COUNT(o.order_id)       AS orders,
    SUM(o.total)            AS revenue,
    ROUND(AVG(o.total), 0) AS avg_check
FROM orders o
JOIN cafes c ON c.cafe_id = o.cafe_id
WHERE o.status = 'picked_up'
GROUP BY c.district
ORDER BY revenue DESC;
Result · top 5 districts, revenue CZK
Praha 1
806k
Praha 2
311k
Praha 8
305k
Praha 7
263k
Praha 6
158k

Praha 1 brings 39% of all revenue — more than the next two districts combined.

03 · SQL in practice

From query to insight

Orders by hour of day

sql/01_basic_exploration.sql · 1.4
SELECT
    hour,
    COUNT(*) AS orders
FROM orders
GROUP BY hour
ORDER BY hour;

Morning rush 8–10 AM = 27% of all orders; a second wave around 13:00.

Customer frequency buckets

sql/02_customer_analysis.sql · 2.2
SELECT
    CASE
        WHEN cnt = 1              THEN '1 order'
        WHEN cnt BETWEEN 2 AND 5   THEN '2–5 orders'
        WHEN cnt BETWEEN 6 AND 15  THEN '6–15 orders'
        ELSE '16+ orders'
    END AS bucket,
    COUNT(*) AS customers
FROM (
    SELECT customer_id, COUNT(*) AS cnt
    FROM orders
    WHERE status = 'picked_up'
    GROUP BY customer_id
) sub
GROUP BY bucket;
70%

returning customers

700 of 1,000 customers come back, averaging 13.9 orders each in 60 days.

1 order300
2–5148
6–15336
16+216

Week-over-week revenue growth

sql/04_time_and_trends.sql · 4.4
WITH weekly AS (
    SELECT DATE_TRUNC('week', date)::DATE AS week, SUM(total) AS revenue
    FROM orders WHERE status = 'picked_up'
    GROUP BY DATE_TRUNC('week', date)
)
SELECT week, revenue,
       LAG(revenue) OVER (ORDER BY week) AS prev_week,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY week))
             / LAG(revenue) OVER (ORDER BY week), 1) AS wow_growth_pct
FROM weekly ORDER BY week;

CTE + LAG window function: compares each week with the previous one — the standard way to track growth.

04 · Power BI

Interactive dashboards

Power BI Desktop · slicers by district, café chain, age group, category and date.

Sales overview

dashboards/01_overview.png
KPI cardsrevenue by categoryrevenue by productmatrix tableage & order-type donuts

Products & trends

dashboards/03_products.png
daily revenue trendtop-10 productsmilk preferencescup size splitdate slider

05 · DAX & findings

Measures behind the dashboards

-- total sales
Revenue = SUM( order_items[line_total] )

-- average order value
Avg Check = AVERAGE( orders[total] )
-- share of cancelled orders
Cancel Rate =
DIVIDE(
    CALCULATE( COUNTROWS(orders),
        orders[status] = "cancelled" ),
    COUNTROWS(orders) )

Key findings

39%

of revenue in Praha 1

806k CZK — the clear priority district.

70%

returning customers

~14 orders each over 60 days.

27%

orders 8–10 AM

Peak at 9:00 — 1,607 orders.

47%

from top-5 products

Latte, Flat White, Cappuccino, Matcha Latte, Iced Latte.

2.13%

cancellation rate

Avg prep time 5.5 minutes.

68%

takeaway orders

57% placed via the iOS app, 43% via web.

06 · Skills demonstrated

What this project shows

SQL

JOIN · GROUP BY · HAVING · CASE · subqueries · CTE · LAG · DATE_TRUNC · COALESCE · FILTER

Power BI & DAX

Data model · KPI cards · slicers · SUM · AVERAGE · DIVIDE · CALCULATE · COUNTROWS

Workflow

PostgreSQL · SQLite · Excel · Git & GitHub · README case study