beaumontmeteo/app.R

493 lines
12 KiB
R

# Load necessary libraries
library(shiny)
library(httr)
library(shinycssloaders)
invisible(lapply(
list.files(path = "R", pattern = "\\.R$", full.names = TRUE),
source
))
locations <- get_rain_locations()
secondary_metrics <- get_weather_metric_catalog(include_rain = FALSE)
db_path <- default_rain_db_path()
ensure_weather_db(db_path)
get_window_slider_config <- function(unit = "days") {
switch(
unit,
days = list(label = "Past days", max = 60L, value = 10L),
months = list(label = "Past months", max = 24L, value = 6L),
years = list(label = "Past years", max = 10L, value = 1L),
list(label = "Past days", max = 60L, value = 10L)
)
}
last_day_of_month <- function(year, month) {
next_month_year <- year + if (month == 12L) 1L else 0L
next_month <- if (month == 12L) 1L else month + 1L
next_month_start <- as.Date(sprintf("%04d-%02d-01", next_month_year, next_month))
as.integer(format(next_month_start - 1L, "%d"))
}
subtract_calendar_period <- function(date, amount, unit) {
date <- as.Date(date)
amount <- as.integer(amount)
if (amount <= 0L) {
return(date)
}
date_lt <- as.POSIXlt(date, tz = "UTC")
year <- date_lt$year + 1900L
month <- date_lt$mon + 1L
day <- date_lt$mday
if (identical(unit, "months")) {
total_months <- year * 12L + (month - 1L) - amount
target_year <- total_months %/% 12L
target_month <- total_months %% 12L + 1L
target_day <- min(day, last_day_of_month(target_year, target_month))
return(as.Date(sprintf("%04d-%02d-%02d", target_year, target_month, target_day)))
}
if (identical(unit, "years")) {
target_year <- year - amount
target_day <- min(day, last_day_of_month(target_year, month))
return(as.Date(sprintf("%04d-%02d-%02d", target_year, month, target_day)))
}
date - amount
}
window_start_date <- function(end_date, amount, unit) {
end_date <- as.Date(end_date)
amount <- max(1L, as.integer(amount))
if (identical(unit, "days")) {
return(end_date - amount + 1L)
}
subtract_calendar_period(end_date, amount, unit) + 1L
}
format_window_label <- function(amount, unit) {
amount <- max(1L, as.integer(amount))
unit_label <- if (amount == 1L) sub("s$", "", unit) else unit
sprintf("last %s %s", amount, unit_label)
}
ui <- fluidPage(
tags$head(
tags$style(HTML("
.container-fluid {
max-width: 1480px;
}
.status-block {
margin-top: 16px;
padding: 12px;
background: #f6f8f9;
border: 1px solid #d9e1e5;
border-radius: 6px;
}
.help-block {
margin-top: 12px;
}
.panel-card {
min-height: 100%;
padding: 16px;
background: #fbfcfd;
border: 1px solid #d9e1e5;
border-radius: 8px;
}
.panel-title {
margin-top: 0;
}
"))
),
titlePanel("Beaumont Weather Cache"),
sidebarLayout(
sidebarPanel(
width = 3,
selectInput(
inputId = "location_id",
label = "Location",
choices = stats::setNames(locations$location_id, locations$label),
selected = locations$location_id[1]
),
radioButtons(
inputId = "window_unit",
label = "Time window",
choices = c(
"Days" = "days",
"Months" = "months",
"Years" = "years"
),
selected = "days",
inline = TRUE
),
sliderInput(
inputId = "window_amount",
label = "Past days",
min = 1,
max = 60,
value = 10,
step = 1
),
radioButtons(
inputId = "rain_view_mode",
label = "Rain display",
choices = c(
"6-minute rain" = "raw",
"Daily total" = "daily"
),
selected = "raw"
),
checkboxInput(
inputId = "hide_zero",
label = "Hide zero rainfall",
value = TRUE
),
selectInput(
inputId = "metric_id",
label = "Second panel",
choices = stats::setNames(secondary_metrics$metric_id, secondary_metrics$label),
selected = "air_temperature"
),
radioButtons(
inputId = "metric_view_mode",
label = "Second panel display",
choices = c(
"Raw observations" = "raw",
"Daily aggregate" = "daily"
),
selected = "raw"
),
uiOutput("syncControls"),
div(
class = "status-block",
verbatimTextOutput("cacheStatus")
)
),
mainPanel(
width = 9,
fluidRow(
column(
width = 6,
div(
class = "panel-card",
h3(class = "panel-title", "Rain"),
withSpinner(plotOutput("rainPlot", height = "420px")),
h4("Daily rain totals"),
tableOutput("dailySummary")
)
),
column(
width = 6,
div(
class = "panel-card",
h3(class = "panel-title", textOutput("metricTitle", container = span)),
withSpinner(plotOutput("metricPlot", height = "420px")),
h4("Latest values by station"),
tableOutput("metricLatest")
)
)
)
)
)
)
server <- function(input, output, session) {
api_headers <- load_api_headers()
data_version <- reactiveVal(0L)
last_sync_message <- reactiveVal("")
selected_location <- reactive({
get_rain_location(input$location_id, locations = locations)
})
selected_metric <- reactive({
get_weather_metric(input$metric_id)
})
observeEvent(input$window_unit, {
settings <- get_window_slider_config(input$window_unit)
current_value <- if (is.null(input$window_amount)) {
settings$value
} else {
as.integer(input$window_amount)
}
updateSliderInput(
session = session,
inputId = "window_amount",
label = settings$label,
min = 1,
max = settings$max,
value = min(max(current_value, 1L), settings$max),
step = 1
)
}, ignoreInit = TRUE)
selected_start_date <- reactive({
window_start_date(
end_date = Sys.Date(),
amount = input$window_amount,
unit = input$window_unit
)
})
selected_window_label <- reactive({
format_window_label(
amount = input$window_amount,
unit = input$window_unit
)
})
selected_window_days <- reactive({
as.integer(Sys.Date() - selected_start_date()) + 1L
})
cached_rain <- reactive({
data_version()
query_cached_rainfall(
location_id = input$location_id,
start_date = selected_start_date(),
end_date = Sys.Date(),
aggregate = input$rain_view_mode,
db_path = db_path
)
})
cached_metric <- reactive({
data_version()
query_cached_metric(
location_id = input$location_id,
metric_id = input$metric_id,
start_date = selected_start_date(),
end_date = Sys.Date(),
aggregate = input$metric_view_mode,
db_path = db_path
)
})
daily_summary <- reactive({
data_version()
query_cached_rainfall(
location_id = input$location_id,
start_date = selected_start_date(),
end_date = Sys.Date(),
aggregate = "daily",
db_path = db_path
)
})
metric_latest <- reactive({
data_version()
query_latest_metric_values(
location_id = input$location_id,
metric_id = input$metric_id,
db_path = db_path
)
})
output$metricTitle <- renderText({
selected_metric()$label
})
output$syncControls <- renderUI({
if (is.null(api_headers)) {
return(
div(
class = "help-block",
helpText(
"This app is reading the local SQLite cache only. Add token4 to data/secrets and run",
"`Rscript scripts/update_rain_db.R`",
"to refresh rain plus the second-panel weather metrics."
),
helpText(
"Rain can be backfilled. Temperature, humidity, wind and pressure come from the rolling observation feed, so sync that script daily if you want a continuous history."
)
)
)
}
tagList(
actionButton("sync_now", "Sync location from API"),
div(
class = "help-block",
helpText(
"This refresh pulls historical rain plus the latest station observations into the same SQLite dataset."
)
)
)
})
observeEvent(input$sync_now, {
req(!is.null(api_headers))
location <- selected_location()
result <- tryCatch(
withProgress(
message = sprintf("Syncing %s into SQLite", location$label[1]),
value = 0.3,
{
sync_location_weather(
location_id = input$location_id,
db_path = db_path,
headers = api_headers,
initial_backfill_days = max(21L, selected_window_days() + 7L)
)
}
),
error = function(error) {
last_sync_message(
sprintf("Last sync failed: %s", conditionMessage(error))
)
NULL
}
)
if (is.null(result)) {
return()
}
latest_observed_at <- result$latest_observed_at[1]
if (is.na(latest_observed_at) || !nzchar(latest_observed_at)) {
latest_observed_at <- "no data returned"
}
last_sync_message(
sprintf(
paste(
"Last sync: %s rows written",
"(rain %s, observations %s).",
"Cache now reaches %s."
),
format(result$rows_written[1], big.mark = ","),
format(result$rain_rows_written[1], big.mark = ","),
format(result$observation_rows_written[1], big.mark = ","),
latest_observed_at
)
)
data_version(data_version() + 1L)
})
output$cacheStatus <- renderText({
data_version()
status_text <- describe_cache_status(
location_id = input$location_id,
db_path = db_path,
locations = locations
)
if (!nzchar(last_sync_message())) {
return(status_text)
}
paste(status_text, last_sync_message(), sep = "\n\n")
})
output$rainPlot <- renderPlot({
plot_data <- cached_rain()
shiny::validate(
shiny::need(
nrow(plot_data) > 0,
"No cached rainfall for this period yet. Run the sync script or use the API sync button if credentials are configured."
)
)
if (input$hide_zero) {
plot_data <- plot_data[
is.na(plot_data$rain_mm) | plot_data$rain_mm > 0,
,
drop = FALSE
]
}
shiny::validate(
shiny::need(
nrow(plot_data) > 0,
"No non-zero rainfall in this window. Untick 'Hide zero rainfall' if you want to inspect dry periods too."
)
)
plot_cached_rainfall(
rain_data = plot_data,
view = input$rain_view_mode,
hide_zero = FALSE,
main = sprintf(
"%s - %s",
selected_location()$label[1],
selected_window_label()
)
)
})
output$metricPlot <- renderPlot({
plot_data <- cached_metric()
metric <- selected_metric()
shiny::validate(
shiny::need(
nrow(plot_data) > 0,
sprintf(
"No cached %s data for this window yet. Run the sync script more frequently if you want a continuous history for that metric.",
tolower(metric$label)
)
)
)
plot_cached_metric(
metric_data = plot_data,
metric_id = input$metric_id,
view = input$metric_view_mode,
main = sprintf(
"%s - %s - %s",
selected_location()$label[1],
metric$label,
selected_window_label()
)
)
})
output$dailySummary <- renderTable({
summary_data <- daily_summary()
if (!nrow(summary_data)) {
return(NULL)
}
names(summary_data) <- c("Station ID", "Station", "Day", "Metric", "Label", "Unit", "Rain")
summary_data[, c("Station", "Day", "Rain")]
}, striped = TRUE, spacing = "s", digits = 2)
output$metricLatest <- renderTable({
latest_data <- metric_latest()
if (!nrow(latest_data)) {
return(NULL)
}
names(latest_data) <- c("Station ID", "Station", "Observed At", "Metric", "Label", "Unit", "Value")
latest_data[, c("Station", "Observed At", "Value", "Unit")]
}, striped = TRUE, spacing = "s", digits = 2)
}
shinyApp(ui = ui, server = server)