10 · Project: Shiny Sales Dashboard¶
This project pulls together everything from Level 3: dplyr filtering
and grouping, ggplot2 for the chart, and shiny for interactivity —
built into one small but complete dashboard with a region filter, a date
range, a summary table, and a bar chart, all driven by reactive
expressions.
The data¶
date,region,product,units,revenue
2024-01-01,East,Widget,10,200
2024-01-01,West,Gadget,5,150
2024-01-02,East,Gadget,8,240
2024-01-02,West,Widget,12,240
2024-01-03,East,Widget,7,140
2024-01-03,West,Gadget,9,270
2024-01-04,East,Gizmo,4,180
2024-01-04,West,Widget,15,300
Save this as data.csv alongside app.R.
The full app¶
library(shiny)
library(dplyr)
library(ggplot2)
sales <- read.csv("data.csv", stringsAsFactors = FALSE)
sales$date <- as.Date(sales$date)
ui <- fluidPage(
titlePanel("Sales Dashboard"),
sidebarLayout(
sidebarPanel(
selectInput("region", "Region", choices = c("All", unique(sales$region))),
dateRangeInput("dates", "Date range",
start = min(sales$date), end = max(sales$date))
),
mainPanel(
textOutput("total_revenue"),
tableOutput("summary_table"),
plotOutput("revenue_plot")
)
)
)
server <- function(input, output, session) {
filtered <- reactive({
df <- sales %>% filter(date >= input$dates[1], date <= input$dates[2])
if (input$region != "All") df <- df %>% filter(region == input$region)
df
})
output$total_revenue <- renderText({
paste("Total revenue:", sum(filtered()$revenue))
})
output$summary_table <- renderTable({
filtered() %>% group_by(product) %>%
summarise(units = sum(units), revenue = sum(revenue), .groups = "drop")
})
output$revenue_plot <- renderPlot({
ggplot(filtered(), aes(x = date, y = revenue, fill = product)) +
geom_col() + theme_minimal()
})
}
shinyApp(ui, server)
Design notes¶
filtered() is the single reactive() all three outputs depend on — the
region and date filtering logic is written exactly once, and every output
downstream automatically sees the same filtered slice. This is the same
"compute once, reuse everywhere" pattern from Module 5: writing the
filter() calls three separate times, once per output, would triple the
maintenance burden and risk the three outputs drifting out of sync if one
copy got edited and the others didn't.
The if (input$region != "All") branch inside filtered() is a common
dashboard pattern — an "All" option that means "skip this filter
entirely" rather than being a real value to filter on. It's tempting to
instead add "All" as a literal value in the region column, but that
would corrupt the underlying data just to make one UI control simpler.
Running it¶
Testing a Shiny app's logic doesn't require a browser — testServer()
runs the server function directly and lets you simulate widget input:
source("app.R", local = (env <- new.env()))
testServer(env$server, {
session$setInputs(region = "All", dates = c(as.Date("2024-01-01"), as.Date("2024-01-04")))
output$total_revenue
# [1] "Total revenue: 1720"
session$setInputs(region = "East")
output$total_revenue
# [1] "Total revenue: 760"
})
Both numbers check out against the raw data: summing all 8 rows' revenue
gives 1720; summing only the East rows (200 + 240 + 140 + 180) gives
760. Changing input$region from "All" to "East" automatically
recomputed filtered() and every output built on top of it — no manual
recalculation code, which is the entire point of Shiny's reactive model.
To actually see the dashboard, run shiny::runApp("path/to/app_dir")
from an interactive R session, which launches the app in a browser tab
with live sliders and dropdowns.
How It Actually Works¶
A multi-output Shiny dashboard relies entirely on the reactive graph from
Module 5 to stay responsive: each renderPlot()/renderTable() output
only reruns when the specific reactive values or reactive() expressions
it reads actually change, so filtering one chart's input doesn't force
every other output on the page to recompute — Shiny's invalidation walk
(triggered on every input event) only touches the subgraph downstream of
what changed.
The WebSocket connection underlying the dashboard means the R process backing your session stays alive between interactions (unlike a stateless HTTP request/response cycle) — every widget interaction sends a small message over that open socket, the server reruns just the invalidated reactive nodes, and only the changed output HTML/JSON gets pushed back and patched into the DOM, rather than reloading the page. This persistent per-session R process is also why each connected user gets their own independent copy of any reactive state (no accidental cross-user data leakage) but also why a slow reactive computation for one user doesn't block Shiny's ability to serve other users on the default async-friendly deployment configurations.
Stretch goals¶
- Add a
selectInputforproductalongside the existingregionfilter, wire it into the samefiltered()reactive, and confirm the table and plot both respect it without any other code changes. - Replace the static bar chart with a
plotOutputthat switches between a bar chart and a line chart based on aradioButtons()input — practice branching inside arender*()block based on another input. - Add a CSV download button (
downloadHandler()+downloadButton()) that lets the user export the currently filtered data, not the full dataset. - Write a
testServer()test suite covering at least three input combinations (all regions, one specific region, a narrowed date range) and assert onoutput$total_revenuefor each, so a future edit to the filtering logic can be checked against these expected totals.
Completing this project means you're ready for Level 4 · Expert.