Compare commits

..

28 Commits

Author SHA1 Message Date
0a63139dee Add scrollX to listings and sales tables to prevent container overflow
All checks were successful
Deploy stAndrews / deploy (push) Successful in 5s
2026-04-16 19:26:50 -04:00
0b5b093d99 Fix deploy workflow: use self-hosted runner, eliminate SSH roundtrip
All checks were successful
Deploy stAndrews / deploy (push) Successful in 5s
Running on ubuntu-latest required SSH back to host to run docker commands.
Switch to self-hosted runner which executes directly on the analytics VM.
2026-04-16 06:43:10 -04:00
5fbff45a2e Sort listings table by most recent date; add SSH deploy fix to TODO
Some checks failed
Deploy stAndrews / deploy (push) Failing after 4s
2026-04-16 06:34:56 -04:00
bf5f736cf3 Add owner name to listings map popup
Some checks failed
Deploy stAndrews / deploy (push) Failing after 5s
Join owner_1 from owners.rds to listings via address match key.
Display in marker popup above price on the Listings tab map.
2026-04-16 06:29:23 -04:00
d953edce54 Fix listing coordinates using building footprint geometry
Some checks failed
Deploy stAndrews / deploy (push) Failing after 4s
RentCast geocoding is approximate and placed 878 Chalmers in the lake.
Override RentCast lat/lng with building centroid coords from owners.rds
when address matches, giving accurate per-structure placement.
2026-04-16 06:25:34 -04:00
29f48172fb Refresh data, move logs to project dir, update docs
Some checks failed
Deploy stAndrews / deploy (push) Failing after 16s
- Weekly refresh: 388 owners, 10 sales, 11 listings (2026-04-16)
- Move cron logs from ~/ to logs/ in each project dir
- Add logs/ to .gitignore and .dockerignore
- Update CLAUDE.md with log location and ops notes
- Update TODO.md with log relocation completion
2026-04-16 06:15:07 -04:00
ef5c62d2a6 Add Patios 1 plat doc
All checks were successful
Deploy stAndrews / deploy (push) Successful in 49s
2026-03-13 15:50:10 -04:00
6514c23398 Make Docs subdivisions collapsible via nested
All checks were successful
Deploy stAndrews / deploy (push) Successful in 18s
accordion
2026-03-13 15:28:13 -04:00
6614bfeb04 Fix f7BlockTitle size arg
All checks were successful
Deploy stAndrews / deploy (push) Successful in 45s
2026-03-13 15:22:35 -04:00
6264ebea66 Organize Docs accordion by subdivision
All checks were successful
Deploy stAndrews / deploy (push) Successful in 34s
2026-03-13 15:17:40 -04:00
4c6ab3d573 Reorganize docs into subdivision folders
All checks were successful
Deploy stAndrews / deploy (push) Successful in 49s
2026-03-13 15:09:31 -04:00
4ef1e5511a Replace Resources segment buttons with
All checks were successful
Deploy stAndrews / deploy (push) Successful in 25s
accordion (Beaches, Services, Docs)
2026-03-13 13:25:03 -04:00
8e4c4ebff2 Use outline dollarsign_circle icon for Sales tab
All checks were successful
Deploy stAndrews / deploy (push) Successful in 38s
2026-03-13 13:02:17 -04:00
138051c4c4 Use outline dollarsign_circle icon for Sales tab
All checks were successful
Deploy stAndrews / deploy (push) Successful in 38s
2026-03-13 12:49:47 -04:00
ce209d8898 Use outline dollarsign_circle icon for Sales tab
All checks were successful
Deploy stAndrews / deploy (push) Successful in 34s
2026-03-13 12:41:01 -04:00
b115ee1158 Use outline dollarsign_circle icon for Sales tab
All checks were successful
Deploy stAndrews / deploy (push) Successful in 27s
2026-03-13 12:39:23 -04:00
3b69dd3477 Use outline dollarsign_circle icon for Sales tab
All checks were successful
Deploy stAndrews / deploy (push) Successful in 43s
2026-03-13 12:36:12 -04:00
a816a570a0 Remove duplicate stub create_server that was
All checks were successful
Deploy stAndrews / deploy (push) Successful in 19s
overriding real implementation
2026-03-13 12:15:36 -04:00
dd7566ef8e Fix: hardcode subdivision choices to remove
All checks were successful
Deploy stAndrews / deploy (push) Successful in 40s
runtime data dep in UI
2026-03-13 09:02:22 -04:00
eb18ba4115 optimize code
All checks were successful
Deploy stAndrews / deploy (push) Successful in 41s
2026-03-13 08:51:28 -04:00
d8f1bc3110 fix: remove private_listings.csv chmod from deploy script
All checks were successful
Deploy stAndrews / deploy (push) Successful in 34s
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 08:46:46 -04:00
c02715409b feat: Display last sale date from owners data in UI
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 08:33:26 -04:00
28ced93180 refactor: modularize app into config, UI, and server components
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 08:22:15 -04:00
6700075e72 feat: add restart instructions to resources tab
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 06:41:44 -04:00
30c6535130 feat: remove private list section from UI and server
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 06:37:40 -04:00
6a11f96f33 refactor: improve filtering logic and map initialization
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 06:31:38 -04:00
1b75760496 fix: improve filtering logic and map updates in Shiny app
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 06:29:44 -04:00
e0cf03df2f fix: return unique subdivision IDs when "All" selected
Co-authored-by: aider (deepseek/deepseek-chat) <aider@aider.chat>
2026-03-13 06:24:30 -04:00
21 changed files with 762 additions and 675 deletions

View File

@@ -17,3 +17,5 @@ data-raw/
CLAUDE.md CLAUDE.md
TODO.md TODO.md
README.md README.md
logs/

View File

@@ -7,24 +7,13 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: self-hosted
steps: steps:
- name: Deploy via SSH - name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
SERVER_IP: ${{ secrets.DEPLOY_SERVER_IP }}
SERVER_USER: ${{ secrets.DEPLOY_SERVER_USER }}
run: | run: |
mkdir -p ~/.ssh cd /data/projects/r/stAndrews
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa git pull origin main
chmod 600 ~/.ssh/id_rsa docker compose build
ssh-keyscan -H $SERVER_IP >> ~/.ssh/known_hosts docker compose up -d
ssh ${SERVER_USER}@${SERVER_IP} " docker exec analytics-gateway caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
cd /data/projects/r/stAndrews &&
git pull origin main &&
docker compose build &&
docker compose up -d &&
chmod 666 /data/projects/r/stAndrews/data/private_listings.csv &&
docker exec analytics-gateway caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
"

8
.gitignore vendored
View File

@@ -27,7 +27,15 @@ data-raw/uscb/
# Derived data (rebuilt from data-raw via data-raw/main.R) # Derived data (rebuilt from data-raw via data-raw/main.R)
data/owners.rds data/owners.rds
# Downloaded HOA documents (large, not committed)
www/docs/
data/venice.rds data/venice.rds
data/venice_facts.rds data/venice_facts.rds
data/beaches.rds data/beaches.rds
.Renviron .Renviron
.aider*
.env
# Logs
logs/

View File

@@ -189,3 +189,8 @@ operation with no manual steps.
- Geocoding used Google API via `tidygeocoder`; results cached in `data-raw/geotagged_street_addresses.rds` to avoid re-calling the API. - Geocoding used Google API via `tidygeocoder`; results cached in `data-raw/geotagged_street_addresses.rds` to avoid re-calling the API.
- Point deduplication (multiple units at same address) was done manually in QGIS — not scripted. `owners_moved.gpkg` is the authoritative geocoded dataset. - Point deduplication (multiple units at same address) was done manually in QGIS — not scripted. `owners_moved.gpkg` is the authoritative geocoded dataset.
- `data-raw/` is gitignored except for the shapefiles in `data-raw/PlatBoundary/` and `data-raw/SarasotaCountyBoundary/` which are committed. - `data-raw/` is gitignored except for the shapefiles in `data-raw/PlatBoundary/` and `data-raw/SarasotaCountyBoundary/` which are committed.
## Ops
- **Check latest refresh log:** `tail -50 /data/projects/r/stAndrews/logs/refresh.log`
- Cron runs every Sunday at 11pm; logs go to `logs/refresh.log` (not `~/`)

320
R/app_server.R Normal file
View File

@@ -0,0 +1,320 @@
# Server logic for St. Andrews Shiny App
create_server <- function(input, output, session) {
# Load data
owners <- readRDS(app_config$data_paths$owners)
listings <- readRDS(app_config$data_paths$listings) |>
arrange(desc(listed_date)) |>
mutate(
price_fmt = scales::dollar(price),
ppsf_fmt = scales::dollar(price_per_sqft)
)
sales <- readRDS(app_config$data_paths$sales) |>
arrange(desc(listed_date)) |>
mutate(
price_fmt = scales::dollar(price),
ppsf_fmt = scales::dollar(price_per_sqft)
)
last_sale_date <- format(attr(owners, "last_sale_date"), "%Y-%m-%d")
output$sales_date <- renderText({
paste("Last sale date included was", last_sale_date)
})
sbdvn <- sf::st_read(app_config$data_paths$plats)
beaches <- readRDS(app_config$data_paths$beaches)
# update tabs depending on side panel
observeEvent(input$menu, {
updateF7Tabs(id = "tabs",
selected = input$menu)
})
# Initialize filteredOwners with all owners
filteredOwners <- reactiveVal(owners)
# Function to filter owners based on inputs
filterOwners <- function() {
filtered <- owners
# Filter by subdivision if specified
if (!is.null(input$sub_name) && input$sub_name != "All") {
# Get the subdivision IDs for the selected subdivision name
sub_ids <- sbdvn %>%
filter(sub_name == input$sub_name) %>%
pull(max_sub_id)
filtered <- filtered %>%
filter(subdivision %in% sub_ids)
}
# Filter by name if specified
if (!is.null(input$name) && input$name != "") {
filtered <- filtered %>%
filter(
grepl(input$name, owner_1, ignore.case = TRUE) |
grepl(input$name, owner_2, ignore.case = TRUE)
)
}
# Filter by location if specified
if (!is.null(input$location) && input$location != "") {
filtered <- filtered %>%
filter(grepl(input$location, location, ignore.case = TRUE))
}
return(filtered)
}
# Update filtered owners when filter button is clicked
observeEvent(input$filterButton, {
filteredOwners(filterOwners())
})
# Also update when any of the filter inputs change
observe({
# Track dependencies
input$name
input$location
input$sub_name
# Update filtered owners, but only if the filter button has been clicked at least once
# To prevent immediate filtering on app start
if (!is.null(input$filterButton)) {
if (input$filterButton > 0) {
filteredOwners(filterOwners())
}
}
})
mean_lat <- reactive({
if (!is.null(filteredOwners()) && nrow(filteredOwners()) > 0) {
filteredOwners() %>%
st_coordinates() %>%
.[, "Y"] %>%
mean()
} else {
app_config$map_config$default_lat
}
})
mean_lng <- reactive({
if (!is.null(filteredOwners()) && nrow(filteredOwners()) > 0) {
filteredOwners() %>%
st_coordinates() %>%
.[, "X"] %>%
mean()
} else {
app_config$map_config$default_lng
}
})
output$map <- renderLeaflet({
leaflet() %>%
addProviderTiles("CartoDB.Voyager") %>%
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name,
group = "Subdivisions"
)
})
# Update map markers when filteredOwners changes
observe({
leafletProxy("map") %>%
clearGroup("Owners") %>%
addMarkers(
data = filteredOwners(),
popup = popupTable(
filteredOwners(),
row.numbers = FALSE,
feature.id = FALSE,
zcol = c(
"label",
"owner_1",
"owner_2"
)
),
group = "Owners"
) %>%
addLayersControl(
overlayGroups = c("Subdivisions", "Owners"),
options = layersControlOptions(collapsed = FALSE)
) %>%
setView(lng = mean_lng(), lat = mean_lat(), zoom = app_config$map_config$default_zoom)
})
output$table <- renderDT({
if (!is.null(filteredOwners()) && nrow(filteredOwners()) > 0) {
my_table <-
filteredOwners() %>%
st_drop_geometry() %>%
select(label, owner_1, owner_2, homestead)
datatable(my_table,
colnames = c("Address", "Owner 1", "Owner 2", "Homestead"),
rownames = FALSE,
options = list(
pageLength = 10,
scrollX = TRUE,
searching = FALSE,
lengthMenu = c(5, 10, 25, 50),
dom = 'tpi'
)
)
} else {
# Return empty table with same structure
my_table <- data.frame(
label = character(0),
owner_1 = character(0),
owner_2 = character(0),
homestead = numeric(0)
)
datatable(my_table,
colnames = c("Address", "Owner 1", "Owner 2", "Homestead"),
rownames = FALSE,
options = list(
pageLength = 10,
scrollX = TRUE,
searching = FALSE,
lengthMenu = c(5, 10, 25, 50),
dom = 'tpi'
)
)
}
})
prep_mailing <- function(data) {
data |>
sf::st_drop_geometry() |>
dplyr::select(owner_1, owner_2, mailing_address_1, mailing_address_2,
mailing_city, mailing_state, mailing_zip_code) |>
dplyr::mutate(
owner_2 = ifelse(is.na(owner_2), "", owner_2),
mailing_address_2 = ifelse(is.na(mailing_address_2), "", mailing_address_2)
) |>
dplyr::rename(
address_1 = mailing_address_1,
address_2 = mailing_address_2,
city = mailing_city,
state = mailing_state,
zip = mailing_zip_code
)
}
output$download_filtered <- downloadHandler(
filename = "st_andrews_owners_filtered.csv",
content = function(file) {
write.csv(prep_mailing(filteredOwners()), file, row.names = FALSE)
}
)
output$download_all <- downloadHandler(
filename = "st_andrews_owners_all.csv",
content = function(file) {
write.csv(prep_mailing(owners), file, row.names = FALSE)
}
)
# listings map ----
output$listings_map <- renderLeaflet({
leaflet(listings) |>
addProviderTiles("CartoDB.Voyager") |>
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name
) |>
addMarkers(
lat = ~latitude,
lng = ~longitude,
popup = ~paste0(
"<b>", address, "</b><br>",
"Owner: ", owner_1, "<br>",
"Price: ", price_fmt, "<br>",
"Sq Ft: ", sqft, "<br>",
"$/Sq Ft: ", ppsf_fmt
)
) |>
setView(lng = app_config$map_config$default_lng,
lat = app_config$map_config$default_lat,
zoom = app_config$map_config$default_zoom)
})
# listings table ----
output$listings_table <- renderDT({
datatable(
listings |> select(listed_date, address, sqft, price_fmt, ppsf_fmt),
colnames = c("Listed", "Address", "Sq Ft", "Price", "$/Sq Ft"),
rownames = FALSE,
options = list(
pageLength = 25,
searching = FALSE,
scrollX = TRUE,
dom = 't'
)
)
})
# sales map ----
output$sales_map <- renderLeaflet({
leaflet(sales) |>
addProviderTiles("CartoDB.Voyager") |>
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name
) |>
addMarkers(
lat = ~latitude,
lng = ~longitude,
popup = ~paste0(
"<b>", address, "</b><br>",
"Sale Date: ", listed_date, "<br>",
"Price: ", price_fmt, "<br>",
"Sq Ft: ", sqft, "<br>",
"$/Sq Ft: ", ppsf_fmt
)
) |>
setView(lng = app_config$map_config$default_lng,
lat = app_config$map_config$default_lat,
zoom = app_config$map_config$default_zoom)
})
# sales table ----
output$sales_table <- renderDT({
datatable(
sales |> select(listed_date, address, sqft, price_fmt, ppsf_fmt),
colnames = c("Date", "Address", "Sq Ft", "Price", "$/Sq Ft"),
rownames = FALSE,
options = list(
pageLength = 10,
searching = FALSE,
scrollX = TRUE,
dom = 't'
)
)
})
# beach map ----
output$beach_map <- renderLeaflet({
leaflet() %>%
addProviderTiles("CartoDB.Voyager") %>%
setView(lng = app_config$map_config$beach_lng,
lat = app_config$map_config$beach_lat,
zoom = app_config$map_config$beach_zoom) %>%
addMarkers(
data = beaches,
lat = ~lat,
lng = ~lng,
popup = ~beach_name
)
})
}

310
R/app_ui.R Normal file
View File

@@ -0,0 +1,310 @@
# UI components for St. Andrews Shiny App
create_ui <- function() {
f7Page(
title = app_config$app_config$title,
## header ----
tags$head(
tags$link(rel = "manifest", href = "manifest.json"),
tags$link(rel = "apple-touch-icon", sizes = "180x180", href = "images/apple-touch-icon.png"),
tags$meta(name = "apple-mobile-web-app-capable", content = "yes"),
tags$meta(name = "apple-mobile-web-app-status-bar-style", content = "default"),
tags$style(HTML("
.dataTables_wrapper {
color: white;
}
.dataTables_wrapper table.dataTable thead th,
.dataTables_wrapper table.dataTable tbody td {
color: white;
}
.dataTables_wrapper table.dataTable tbody tr.odd {
background-color: #333;
}
.dataTables_wrapper table.dataTable tbody tr.even {
background-color: #444;
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: white !important;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current{
color: black !important;
}
.dataTables_filter {
display: none; /* Hide the search box */
}
"))
),
## options ----
options = list(
theme = app_config$app_config$theme,
dark = app_config$app_config$dark,
pullToRefresh = TRUE
),
## layout ----
f7TabLayout(
### panels ----
panels = tagList(
f7Panel(
id = "panel-left",
side = "left",
effect = "push",
title = "Menu",
f7PanelMenu(
id = "menu",
f7PanelItem(
tabName = "About",
title = "About",
icon = f7Icon("info_circle"),
active = TRUE
),
f7PanelItem(
tabName = "Owners",
title = "Owners",
icon = f7Icon("person_2_fill")
),
f7PanelItem(
tabName = "Listings",
title = "Listings",
icon = f7Icon("tag_fill")
),
f7PanelItem(
tabName = "Sales",
title = "Sales",
icon = f7Icon("money_dollar")
),
f7PanelItem(
tabName = "Resources",
title = "Resources",
icon = f7Icon("hammer_fill")
)
)
)
),
### navbar ----
navbar = f7Navbar(
title = app_config$app_config$title,
hairline = TRUE,
leftPanel = TRUE
),
### begin tabs ----
f7Tabs(
id = "tabs",
animated = TRUE,
#### about ----
f7Tab(
title = "About",
tabName = "About",
icon = f7Icon("info_circle"),
active = TRUE,
f7Card(
title = "About",
divider = "TRUE",
tags$img(src = "images/st_andrews.jpg", width = "100%"),
"St. Andrews Park is located in Venice, Florida. The condominiums are a mix of single-family homes, villas (2 and 4 units) and multi-unit buildings (8 units). There are 388 separate properties within 14 subdivisions. St. Andrews Park is one of many communities in the Plantation Golf and Country Club. It is should not be confused with the adjacent community St. Andrews East.",
tags$em(textOutput("sales_date", inline = TRUE)),
footer = tagList(
f7Link("More Info", href = "http://www.cpmi.us/standrews-plantation/outside_home.asp")
)
)
),
#### owners ----
f7Tab(
title = "Owners",
tabName = "Owners",
icon = f7Icon("person_2_fill"),
f7Card(
title = "Owners:",
divider = TRUE,
raised = TRUE,
footer = textOutput("last_sale_date_display"),
f7List(
inset = TRUE,
dividers = TRUE,
strong = TRUE,
outline = FALSE,
f7Text(
inputId = "name",
label = "Last Name:",
placeholder = "\"Patel\""
),
f7Text(
inputId = "location",
label = "Address:",
placeholder = "\"123 Chalmers\""
),
f7Select(
inputId = "sub_name",
label = "Select Subdivision:",
choices = c("All", "FAIRWAY GLEN", "GARDENS 1", "GARDENS 2",
"GARDENS 3", "GARDENS 4", "PATIOS 1", "PATIOS 2",
"PATIOS 3", "STRATFORD GLENN", "TERRACE HOMES",
"TERRACE VILLAS", "VILLAS 1 ST", "VILLAS 2",
"WEST LAKE GARDENS")
),
tags$br(),
f7Button(
inputId = "filterButton",
label = "Find Owners",
icon = "",
color = "blue"
)
)
),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("map")
),
f7Card(
title = "Table:",
divider = TRUE,
DTOutput("table")
),
f7Card(
title = "Download:",
divider = TRUE,
f7List(
inset = TRUE,
downloadButton("download_filtered", "Download Filtered"),
downloadButton("download_all", "Download All (388)")
)
)
),
#### listings ----
f7Tab(
title = "Listings",
tabName = "Listings",
icon = f7Icon("tag_fill"),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("listings_map")
),
f7Card(
title = "Active Listings:",
divider = TRUE,
DTOutput("listings_table")
)
),
#### sales ----
f7Tab(
title = "Sales",
tabName = "Sales",
icon = f7Icon("money_dollar"),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("sales_map")
),
f7Card(
title = "Recent Sales:",
divider = TRUE,
DTOutput("sales_table")
)
),
#### resources ----
f7Tab(
title = "Resources",
tabName = "Resources",
icon = f7Icon("hammer_fill"),
f7Accordion(
id = "resources_accordion",
f7AccordionItem(
title = "Beaches",
f7Card(
divider = TRUE,
leafletOutput("beach_map"),
footer = p("Source: Environmental Protection Agency")
),
f7Block(
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "EPA Beaches", href = "https://www.epa.gov/beaches", external = TRUE),
f7ListItem(title = "Red Tide Forecast", href = "https://habforecast.gcoos.org/", external = TRUE),
f7ListItem(title = "Healthy Beaches Program", href = "https://fdoh.maps.arcgis.com/apps/instant/nearby/index.html?appid=7106a20597de4bff98cc5ebc7f932047&findSource=0&find=1600%2520Harbor%2520Dr%2520S%252C%2520Venice%252C%2520Florida%252C%252034285&sliderDistance=2", external = TRUE),
f7ListItem(title = "MOTE Beach Conditions", href = "https://visitbeaches.org/beach/6/report/53033", external = TRUE),
f7ListItem(title = "National Hurricane Center", href = "https://www.nhc.noaa.gov/", external = TRUE)
)
)
),
f7AccordionItem(
title = "Services",
f7Block(
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "City of Venice", href = "https://www.venicegov.com/", external = TRUE),
f7ListItem(title = "Florida Power & Light", href = "https://www.fpl.com/", external = TRUE),
f7ListItem(title = "Sarasota County", href = "https://www.scgov.net/", external = TRUE),
f7ListItem(title = "Property Appraiser", href = "https://www.sc-pa.com/", external = TRUE),
f7ListItem(title = "Open GIS Portal", href = "https://data-sarco.opendata.arcgis.com/", external = TRUE),
f7ListItem(title = "Waste & Recycling", href = "https://www.venicegov.com/government/public-works/waste-and-recycling", external = TRUE),
f7ListItem(title = "Condo Regulation", href = "https://condos.myfloridalicense.com/", external = TRUE),
f7ListItem(title = "Property Records Search", href = "https://www.sarasotaclerk.com/records/official-records/search-land-records", external = TRUE)
)
)
),
f7AccordionItem(
title = "Docs",
f7Accordion(
id = "docs_accordion",
f7AccordionItem(
title = "St. Andrews Park",
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "Covenants", href = "docs/st_andrews/2000_01_01_st_andrews_covenants.pdf"),
f7ListItem(title = "Map (unrecorded)", href = "docs/st_andrews/2004_06_23_sap_map.pdf")
)
),
f7AccordionItem(
title = "Patios 1",
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "Plat", href = "docs/patios_1/1995_12_04_patios_1_plat.pdf")
)
),
f7AccordionItem(
title = "Patios 2",
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "Plat", href = "docs/patios_2/1997_08_12_patios_2_plat.pdf")
)
),
f7AccordionItem(
title = "Patios 3",
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "Plat", href = "docs/patios_3/1998_11_17_patios_3_plat.pdf")
)
),
f7AccordionItem(
title = "Villas 2",
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "Plat", href = "docs/villas_2/1998_09_14_villas_2_plat.pdf")
)
)
)
)
)
)
### end tabs----
),
### begin scripts ----
tags$script(
HTML(
"
$(document).on('click', '#pdfLink', function(event) {
event.preventDefault();
window.open($(this).attr('href'), '_blank');
});
$(document).on('click', '#download_filtered, #download_all', function(event) {
event.preventDefault();
window.open($(this).attr('href'), '_self');
});
"
)
)
### end scripts ----
)
)
}

38
R/config.R Normal file
View File

@@ -0,0 +1,38 @@
# Configuration file for St. Andrews Shiny App
app_config <- list(
data_paths = list(
owners = "./data/owners.rds",
plats = "./data/plats/plats.shp",
beaches = "./data/beaches.rds",
listings = "./data/listings.rds",
sales = "./data/sales.rds"
),
update_config = list(
subdivisions = c("8120", "8113", "8171", "8195", "8221", "8163",
"8240", "8159", "8149", "8110", "8254", "8215", "8143"),
timeout_seconds = 300
),
map_config = list(
default_lng = -82.362253,
default_lat = 27.076199,
default_zoom = 16,
beach_lng = -82.4603,
beach_lat = 27.0999,
beach_zoom = 12
),
app_config = list(
title = "St. Andrews Park",
theme = "ios",
dark = TRUE
)
)
# Helper function to get data path
get_data_path <- function(data_type) {
if (data_type %in% names(app_config$data_paths)) {
return(app_config$data_paths[[data_type]])
} else {
stop(paste("Unknown data type:", data_type))
}
}

View File

@@ -12,6 +12,9 @@
- [x] Add cron job to run `update_owners.R` weekly inside the rstudio container: - [x] Add cron job to run `update_owners.R` weekly inside the rstudio container:
Runs every Sunday at 11pm via crontab; restarts standrews_shiny after; logs to ~/standrews_update.log Runs every Sunday at 11pm via crontab; restarts standrews_shiny after; logs to ~/standrews_update.log
Tested end-to-end 2026-03-09 — 388 owners written, options(timeout=300) required for 87.5 MB download Tested end-to-end 2026-03-09 — 388 owners written, options(timeout=300) required for 87.5 MB download
- [x] Move cron log from ~/ to project dir (2026-04-16):
Log now writes to /data/projects/r/stAndrews/logs/refresh.log
logs/ added to .gitignore and .dockerignore
## Features ## Features
@@ -34,6 +37,7 @@
## Deployment ## Deployment
- [x] Fix SSH deploy workflow — switched to self-hosted runner (runs-on: self-hosted), eliminates SSH roundtrip
- [x] Create Dockerfile, docker-compose.yml, .gitea/workflows/deploy.yaml - [x] Create Dockerfile, docker-compose.yml, .gitea/workflows/deploy.yaml
- [x] Push to Gitea — act_runner deploys on push to main - [x] Push to Gitea — act_runner deploys on push to main
- [x] App live at apps.robwiederstein.org/stAndrews/ - [x] App live at apps.robwiederstein.org/stAndrews/

659
app.R
View File

@@ -1,4 +1,7 @@
# load libraries ---- # St. Andrews Shiny App - Main entry point
# Modularized version with separate UI and server components
# Load libraries
library(shiny) library(shiny)
library(shinyMobile) library(shinyMobile)
library(leaflet) library(leaflet)
@@ -7,652 +10,16 @@ library(dplyr)
library(leafpop) library(leafpop)
library(DT) library(DT)
# load data ---- # Load configuration
owners <- readRDS("./data/owners.rds") source("./R/config.R")
listings <- readRDS("./data/listings.rds") |>
arrange(price_per_sqft) |>
mutate(
price_fmt = scales::dollar(price),
ppsf_fmt = scales::dollar(price_per_sqft)
)
sales <- readRDS("./data/sales.rds") |>
arrange(desc(listed_date)) |>
mutate(
price_fmt = scales::dollar(price),
ppsf_fmt = scales::dollar(price_per_sqft)
)
last_sale_date <- format(attr(owners, "last_sale_date"), "%Y-%m-%d")
sbdvn <- sf::st_read("./data/plats/plats.shp")
beaches <- readRDS("./data/beaches.rds")
# define ui ---- # Load UI and server components
ui <- f7Page( source("./R/app_ui.R")
title = "St. Andrews", source("./R/app_server.R")
## header ----
tags$head(
tags$link(rel = "manifest", href = "manifest.json"),
tags$link(rel = "apple-touch-icon", sizes = "180x180", href = "images/apple-touch-icon.png"),
tags$meta(name = "apple-mobile-web-app-capable", content = "yes"),
tags$meta(name = "apple-mobile-web-app-status-bar-style", content = "default"),
tags$style(HTML("
.dataTables_wrapper {
color: white;
}
.dataTables_wrapper table.dataTable thead th,
.dataTables_wrapper table.dataTable tbody td {
color: white;
}
.dataTables_wrapper table.dataTable tbody tr.odd {
background-color: #333;
}
.dataTables_wrapper table.dataTable tbody tr.even {
background-color: #444;
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: white !important;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current{
color: black !important;
}
.dataTables_filter {
display: none; /* Hide the search box */
}
"))
),
## options ----
options = list(
theme = "ios",
dark = TRUE,
pullToRefresh = TRUE
),
## layout ----
f7TabLayout(
### panels ----
panels = tagList(
f7Panel(
id = "panel-left",
side = "left",
effect = "push",
title = "Menu",
f7PanelMenu(
id = "menu",
f7PanelItem(
tabName = "About",
title = "About",
icon = f7Icon("info_circle"),
active = TRUE
),
f7PanelItem(
tabName = "Owners",
title = "Owners",
icon = f7Icon("person_2_fill")
),
f7PanelItem(
tabName = "Listings",
title = "Listings",
icon = f7Icon("tag_fill")
),
f7PanelItem(
tabName = "Sales",
title = "Sales",
icon = f7Icon("dollarsign_circle_fill")
),
f7PanelItem(
tabName = "PrivateList",
title = "Private List",
icon = f7Icon("lock_fill")
),
f7PanelItem(
tabName = "Resources",
title = "Resources",
icon = f7Icon("hammer_fill")
)
)
)
),
### navbar ----
navbar = f7Navbar(
title = "St. Andrews Park",
hairline = TRUE,
leftPanel = TRUE
),
### begin tabs ----
f7Tabs(
id = "tabs",
animated = TRUE,
#### about ----
f7Tab(
title = "About",
tabName = "About",
icon = f7Icon("info_circle"),
active = TRUE,
f7Card(
title = "About",
divider = "TRUE",
tags$img(src = "images/st_andrews.jpg", width = "100%"),
"St. Andrews Park is located in Venice, Florida. The condominiums are a mix of single-family homes, villas (2 and 4 units) and multi-unit buildings (8 units). There are 388 separate properties within 14 subdivisions. St. Andrews Park is one of many communities in the Plantation Golf and Country Club. It is should not be confused with the adjacent community St. Andrews East.",
footer = tagList(
f7Link("More Info", href = "http://www.cpmi.us/standrews-plantation/outside_home.asp")
)
)
),
#### owners ----
f7Tab(
title = "Owners",
tabName = "Owners",
icon = f7Icon("person_2_fill"),
f7Card(
title = "Owners:",
divider = TRUE,
raised = TRUE,
footer = paste0("Last sale date: ", last_sale_date),
f7List(
inset = TRUE,
dividers = TRUE,
strong = TRUE,
outline = FALSE,
f7Text(
inputId = "name",
label = "Last Name:",
placeholder = "\"Patel\""
),
f7Text(
inputId = "location",
label = "Address:",
placeholder = "\"123 Chalmers\""
),
f7Select(
inputId = "sub_name",
label = "Select Subdivision:",
choices = c("All", sort(sbdvn$sub_name))
),
tags$br(),
f7Button(
inputId = "filterButton",
label = "Find Owners",
icon = "",
color = "blue"
)
)
),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("map")
),
f7Card(
title = "Table:",
divider = TRUE,
DTOutput("table")
),
f7Card(
title = "Download:",
divider = TRUE,
f7List(
inset = TRUE,
downloadButton("download_filtered", "Download Filtered"),
downloadButton("download_all", "Download All (388)")
)
)
),
#### listings ----
f7Tab(
title = "Listings",
tabName = "Listings",
icon = f7Icon("tag_fill"),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("listings_map")
),
f7Card(
title = "Active Listings:",
divider = TRUE,
DTOutput("listings_table")
)
),
#### sales ----
f7Tab(
title = "Sales",
tabName = "Sales",
icon = f7Icon("dollarsign_circle_fill"),
f7Card(
title = "Map:",
divider = TRUE,
leafletOutput("sales_map")
),
f7Card(
title = "Recent Sales:",
divider = TRUE,
DTOutput("sales_table")
)
),
#### private list ----
f7Tab(
title = "Private List",
tabName = "PrivateList",
icon = f7Icon("lock_fill"),
f7Block(
tags$p(
style = "text-align:center; color:#ff9500; font-weight:bold;",
"Listings must be renewed every 45 days"
)
),
f7Card(
title = "Submit a Listing:",
divider = TRUE,
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE,
f7Text("pl_address", "Address:", placeholder = "895 Chalmers Dr"),
f7Text("pl_price", "Price ($):", placeholder = "325000"),
f7Text("pl_sqft", "Sq Ft:", placeholder = "1800"),
f7Text("pl_name", "Your Name:", placeholder = "Jane Smith"),
f7Text("pl_email", "Email:", placeholder = "jane@email.com"),
f7Text("pl_cell", "Cell:", placeholder = "941-555-1234")
),
tags$br(),
f7Button("pl_submit", "Submit Listing", color = "blue"),
uiOutput("pl_message")
),
f7Card(
title = "Active Private Listings:",
divider = TRUE,
DTOutput("pl_table")
)
),
#### resources ----
f7Tab(
title = "Resources",
tabName = "Resources",
icon = f7Icon("hammer_fill"),
f7Segment(
f7Button(inputId = "res_beaches", label = "Beaches"),
f7Button(inputId = "res_links", label = "Links")
),
uiOutput("resources_content")
)
### end tabs----
),
### begin scripts ----
tags$script(
HTML(
"
$(document).on('click', '#pdfLink', function(event) {
event.preventDefault();
window.open($(this).attr('href'), '_blank');
});
$(document).on('click', '#download_filtered, #download_all', function(event) {
event.preventDefault();
window.open($(this).attr('href'), '_self');
});
"
)
)
### end scripts ----
)
)
# end ui ----
# Create the app
# define server ---- ui <- create_ui()
server <- function(input, output, session) { server <- create_server
# update tabs depending on side panel
observeEvent(input$menu, {
updateF7Tabs(id = "tabs",
selected = input$menu)
})
# resources sub-section toggle
res_section <- reactiveVal("beaches")
observeEvent(input$res_beaches, { res_section("beaches") })
observeEvent(input$res_links, { res_section("links") })
output$resources_content <- renderUI({
if (res_section() == "beaches") {
tagList(
f7Card(
title = "Beaches",
divider = TRUE,
leafletOutput("beach_map"),
footer = p("Source: Environmental Protection Agency")
),
f7Block(
h3("Helpful Links:"),
f7List(
inset = TRUE, dividers = TRUE, strong = TRUE, outline = FALSE,
f7ListItem(title = "EPA Beaches", href = "https://www.epa.gov/beaches", external = TRUE),
f7ListItem(title = "Red Tide Forecast", href = "https://habforecast.gcoos.org/", external = TRUE),
f7ListItem(title = "Healthy Beaches Program", href = "https://fdoh.maps.arcgis.com/apps/instant/nearby/index.html?appid=7106a20597de4bff98cc5ebc7f932047&findSource=0&find=1600%2520Harbor%2520Dr%2520S%252C%2520Venice%252C%2520Florida%252C%252034285&sliderDistance=2", external = TRUE),
f7ListItem(title = "MOTE Beach Conditions", href = "https://visitbeaches.org/beach/6/report/53033", external = TRUE),
f7ListItem(title = "National Hurricane Center", href = "https://www.nhc.noaa.gov/", external = TRUE)
)
)
)
} else {
tagList(
f7BlockTitle(title = "Services:", size = "medium"),
f7Block(
f7List(
mode = "links", inset = TRUE, outline = TRUE, dividers = TRUE, strong = TRUE,
f7Link(label = "City of Venice", href = "https://www.venicegov.com/"),
f7Link(label = "Florida Power & Light", href = "https://www.fpl.com/"),
f7Link(label = "Sarasota County", href = "https://www.scgov.net/"),
f7Link(label = "Property Appraiser", href = "https://www.sc-pa.com/"),
f7Link(label = "Open GIS Portal", href = "https://data-sarco.opendata.arcgis.com/"),
f7Link(label = "Waste & Recycling", href = "https://www.venicegov.com/government/public-works/waste-and-recycling"),
f7Link(label = "Condo Regulation", href = "https://condos.myfloridalicense.com/"),
f7Link(label = "Property Records Search", href = "https://www.sarasotaclerk.com/records/official-records/search-land-records")
)
),
f7BlockTitle(title = "Documents:", size = "medium"),
f7Block(
f7List(
mode = "links", inset = TRUE, outline = TRUE, dividers = TRUE, strong = TRUE,
f7Link(label = "St. Andrews Covenants", href = "docs/2000_01_01_st_andrews_covenants.pdf"),
f7Link(label = "St. Andrews (unrecorded)", href = "docs/2004_06_23_sap_map.pdf"),
f7Link(label = "Patios 2", href = "docs/1997_08_12_patios_2_plat.pdf"),
f7Link(label = "Patios 3", href = "docs/1998_11_17_patios_3_plat.pdf"),
f7Link(label = "Villas 2", href = "docs/1998_09_14_villas_2_plat.pdf")
)
)
)
}
})
filteredSbdvn <- reactive({
if (is.null(input$sub_name) || input$sub_name == "All") {
return(sbdvn$max_sub_id)
} else {
return(
sbdvn %>%
filter(sub_name == input$sub_name) %>%
pull(max_sub_id)
)
}
})
filteredOwners <- reactiveVal(owners)
observeEvent(input$filterButton, {
filtered_owners <-
owners %>%
filter(subdivision %in% filteredSbdvn()) %>%
filter(
grepl(input$name, owner_1, ignore.case = TRUE) |
grepl(input$name, owner_2, ignore.case = TRUE)
) %>%
filter(grepl(input$location, location, ignore.case = TRUE))
filteredOwners(filtered_owners)
})
mean_lat <- reactive({
filteredOwners() %>%
st_coordinates() %>%
.[, "Y"] %>%
mean()
})
mean_lng <- reactive({
filteredOwners() %>%
st_coordinates() %>%
.[, "X"] %>%
mean()
})
output$map <- renderLeaflet({
if (!is.null(filteredOwners()) && nrow(filteredOwners()) > 0) {
leaflet() %>%
addProviderTiles("CartoDB.Voyager") %>%
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name,
group = "Subdivisions"
) %>%
addMarkers(
data = filteredOwners(),
#color = ~ifelse(homestead == 1, "green", "red"),
popup = popupTable(
filteredOwners(),
row.numbers = FALSE,
feature.id = FALSE,
zcol = c(
"label",
"owner_1",
"owner_2"
)
),
group = "Owners"
) %>%
addLayersControl(
overlayGroups = c("Subdivisions", "Owners"),
options = layersControlOptions(collapsed = FALSE)
) %>%
setView(lng = mean_lng(), lat = mean_lat(), zoom = 16)
} else {
leaflet() %>%
addProviderTiles("CartoDB.Voyager") %>%
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name,
group = "Subdivisions"
) %>%
setView(lng = -82.362253, lat = 27.076199, zoom = 16)
}
})
output$table <- renderDT({
my_table <-
filteredOwners() %>%
st_drop_geometry() %>%
select(label, owner_1, owner_2, homestead)
datatable(my_table,
colnames = c("Address", "Owner 1", "Owner 2", "Homestead"),
rownames = FALSE,
options = list(
pageLength = 10,
scrollX = TRUE,
searching = FALSE,
lengthMenu = c(5, 10, 25, 50),
dom = 'tpi'
)
)
})
prep_mailing <- function(data) {
data |>
sf::st_drop_geometry() |>
dplyr::select(owner_1, owner_2, mailing_address_1, mailing_address_2,
mailing_city, mailing_state, mailing_zip_code) |>
dplyr::mutate(
owner_2 = ifelse(is.na(owner_2), "", owner_2),
mailing_address_2 = ifelse(is.na(mailing_address_2), "", mailing_address_2)
) |>
dplyr::rename(
address_1 = mailing_address_1,
address_2 = mailing_address_2,
city = mailing_city,
state = mailing_state,
zip = mailing_zip_code
)
}
output$download_filtered <- downloadHandler(
filename = "st_andrews_owners_filtered.csv",
content = function(file) {
write.csv(prep_mailing(filteredOwners()), file, row.names = FALSE)
}
)
output$download_all <- downloadHandler(
filename = "st_andrews_owners_all.csv",
content = function(file) {
write.csv(prep_mailing(owners), file, row.names = FALSE)
}
)
# private listings ----
pl_path <- "./data/private_listings.csv"
pl_data <- reactivePoll(
intervalMillis = 5000,
session = session,
checkFunc = function() file.info(pl_path)$mtime,
valueFunc = function() {
df <- read.csv(pl_path, stringsAsFactors = FALSE)
if (nrow(df) == 0) return(df)
df$submitted_at <- as.POSIXct(df$submitted_at)
df[difftime(Sys.time(), df$submitted_at, units = "days") <= 45, ]
}
)
observeEvent(input$pl_submit, {
req(input$pl_address, input$pl_price, input$pl_name, input$pl_email, input$pl_cell)
new_row <- data.frame(
submitted_at = format(Sys.time(), "%Y-%m-%d %H:%M:%S"),
address = input$pl_address,
price = as.numeric(gsub("[^0-9.]", "", input$pl_price)),
sqft = as.numeric(gsub("[^0-9.]", "", input$pl_sqft)),
name = input$pl_name,
email = input$pl_email,
cell = input$pl_cell,
stringsAsFactors = FALSE
)
write.table(new_row, pl_path, sep = ",", append = TRUE,
col.names = FALSE, row.names = FALSE, quote = TRUE)
output$pl_message <- renderUI(
tags$p(style = "color:#4cd964;", "Listing submitted successfully!")
)
updateF7Text("pl_address", value = "")
updateF7Text("pl_price", value = "")
updateF7Text("pl_sqft", value = "")
updateF7Text("pl_name", value = "")
updateF7Text("pl_email", value = "")
updateF7Text("pl_cell", value = "")
})
output$pl_table <- renderDT({
df <- pl_data()
if (nrow(df) == 0) {
return(datatable(
data.frame(Message = "No active listings"),
rownames = FALSE, options = list(dom = 't', searching = FALSE)
))
}
datatable(
df |> dplyr::mutate(
price = scales::dollar(price),
sqft = formatC(sqft, format = "d", big.mark = ",")
) |> dplyr::select(address, price, sqft, name, email, cell),
colnames = c("Address", "Price", "Sq Ft", "Name", "Email", "Cell"),
rownames = FALSE,
options = list(pageLength = 25, searching = FALSE, dom = 't')
)
})
# listings map ----
output$listings_map <- renderLeaflet({
leaflet(listings) |>
addProviderTiles("CartoDB.Voyager") |>
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name
) |>
addMarkers(
lat = ~latitude,
lng = ~longitude,
popup = ~paste0(
"<b>", address, "</b><br>",
"Price: ", price_fmt, "<br>",
"Sq Ft: ", sqft, "<br>",
"$/Sq Ft: ", ppsf_fmt
)
) |>
setView(lng = -82.362253, lat = 27.076199, zoom = 16)
})
# listings table ----
output$listings_table <- renderDT({
datatable(
listings |> select(listed_date, address, sqft, price_fmt, ppsf_fmt),
colnames = c("Listed", "Address", "Sq Ft", "Price", "$/Sq Ft"),
rownames = FALSE,
options = list(
pageLength = 25,
searching = FALSE,
dom = 't'
)
)
})
# sales map ----
output$sales_map <- renderLeaflet({
leaflet(sales) |>
addProviderTiles("CartoDB.Voyager") |>
addPolygons(
data = sbdvn,
color = "red",
weight = 2,
opacity = 0.5,
fillOpacity = 0.2,
label = ~sub_name
) |>
addMarkers(
lat = ~latitude,
lng = ~longitude,
popup = ~paste0(
"<b>", address, "</b><br>",
"Sale Date: ", listed_date, "<br>",
"Price: ", price_fmt, "<br>",
"Sq Ft: ", sqft, "<br>",
"$/Sq Ft: ", ppsf_fmt
)
) |>
setView(lng = -82.362253, lat = 27.076199, zoom = 16)
})
# sales table ----
output$sales_table <- renderDT({
datatable(
sales |> select(listed_date, address, sqft, price_fmt, ppsf_fmt),
colnames = c("Date", "Address", "Sq Ft", "Price", "$/Sq Ft"),
rownames = FALSE,
options = list(
pageLength = 10,
searching = FALSE,
dom = 't'
)
)
})
# beach map ----
output$beach_map <- renderLeaflet({
leaflet() %>%
addProviderTiles("CartoDB.Voyager") %>%
setView(lng = -82.4603, lat = 27.0999, zoom = 12) %>%
addMarkers(
data = beaches,
lat = ~lat,
lng = ~lng,
popup = ~beach_name
)
})
}
# end server ----
# Run the app # Run the app
shinyApp(ui, server) shinyApp(ui = ui, server = server)

View File

@@ -32,4 +32,8 @@ source("./data-raw/update_owners.R")
cat("\n--- update_sales.R ---\n") cat("\n--- update_sales.R ---\n")
source("./data-raw/update_sales.R") source("./data-raw/update_sales.R")
# ── Update listings ───────────────────────────────────────────────────────────
cat("\n--- update_listings.R ---\n")
source("./data-raw/update_listings.R")
cat("\n=== Refresh complete", format(Sys.time()), "===\n") cat("\n=== Refresh complete", format(Sys.time()), "===\n")

View File

@@ -51,13 +51,52 @@ in_plat <- lengths(sf::st_within(listings_sf, plats)) > 0
listings <- listings_raw[in_plat, ] listings <- listings_raw[in_plat, ]
cat("After plat clip:", nrow(listings), "listings\n") cat("After plat clip:", nrow(listings), "listings\n")
# ── Override RentCast coordinates with building footprint geometry ────────────
# RentCast geocoding is approximate. Our owners data uses building centroids
# from Sarasota County GIS footprints — far more accurate. Match on house
# number + street name and substitute when found.
owners_sf <- readRDS("./data/owners.rds")
# Extract house number from owners location field (e.g. "878 CHALMERS DR, Venice FL")
owners_coords <- owners_sf |>
mutate(
house_num = trimws(sub("^(\\d+).*", "\\1", location)),
street_raw = trimws(sub("^\\d+\\s+(.*),.*$", "\\1", location)),
match_key = paste(house_num, toupper(street_raw))
) |>
select(match_key, owner_1, geom) |>
distinct(match_key, .keep_all = TRUE)
# Extract house number + street from RentCast address
# e.g. "878 Chalmers Dr, Unit 878, Venice, FL 34293" -> "878 CHALMERS DR"
listings <- listings |>
mutate(
house_num = sub("^(\\d+)\\s.*", "\\1", addressLine1),
street_raw = gsub("[^A-Za-z ]", "", sub("^\\d+\\s+(\\S+\\s+\\S+).*", "\\1", addressLine1)),
match_key = paste(house_num, toupper(trimws(street_raw)))
)
matched <- merge(listings, owners_coords, by = "match_key", all.x = TRUE)
# For matched rows, replace RentCast lat/lng with footprint centroid coords
has_geom <- !is.na(matched$geom)
if (any(has_geom)) {
coords <- sf::st_coordinates(sf::st_as_sf(matched[has_geom, ], sf_column_name = "geom"))
matched$longitude[has_geom] <- coords[, "X"]
matched$latitude[has_geom] <- coords[, "Y"]
cat("Coordinates corrected from building footprints:", sum(has_geom), "listing(s)\n")
}
listings <- matched
# ── Select and clean columns ────────────────────────────────────────────────── # ── Select and clean columns ──────────────────────────────────────────────────
listings <- listings |> listings <- listings |>
transmute( transmute(
listed_date = as.Date(listedDate), listed_date = as.Date(listedDate),
address = formattedAddress, address = formattedAddress,
sqft = as.numeric(squareFootage), owner_1,
price = as.numeric(price), sqft = as.numeric(squareFootage),
price = as.numeric(price),
price_per_sqft = round(price / sqft, 0), price_per_sqft = round(price / sqft, 0),
latitude, latitude,
longitude longitude

View File

@@ -12,12 +12,12 @@ library(dplyr)
library(stringr) library(stringr)
library(sf) library(sf)
options(timeout = 300) # Load configuration
source("./R/config.R")
subdivisions <- c( options(timeout = app_config$update_config$timeout_seconds)
"8120", "8113", "8171", "8195", "8221",
"8163", "8240", "8159", "8149", "8110", "8254", "8215", "8143" subdivisions <- app_config$update_config$subdivisions
)
# load geometry lookup (static) ---- # load geometry lookup (static) ----
geometry_lookup <- readRDS("./data-raw/addresses/geometry_lookup.rds") geometry_lookup <- readRDS("./data-raw/addresses/geometry_lookup.rds")
@@ -73,6 +73,6 @@ latest_sale <-
cat("Owners written:", nrow(owners), "\n") cat("Owners written:", nrow(owners), "\n")
attr(owners, "last_sale_date") <- latest_sale$last_sale_date attr(owners, "last_sale_date") <- latest_sale$last_sale_date
saveRDS(owners, "./data/owners.rds") saveRDS(owners, app_config$data_paths$owners)
cat("Saved to data/owners.rds\n") cat("Saved to", app_config$data_paths$owners, "\n")
cat("Most recent sale date:", format(latest_sale$last_sale_date, "%B %d, %Y"), "\n") cat("Most recent sale date:", format(latest_sale$last_sale_date, "%B %d, %Y"), "\n")

BIN
data/listings.rds Normal file

Binary file not shown.

View File

@@ -1 +1,2 @@
"submitted_at","address","price","sqft","name","email","cell" "submitted_at","address","price","sqft","name","email","cell"
"2026-03-09 21:45:25","863 Tartan",325000,1800,"Cuba Gooding Jr.","khuon68@gmail.com","2708695112"
1 submitted_at address price sqft name email cell
2 2026-03-09 21:45:25 863 Tartan 325000 1800 Cuba Gooding Jr. khuon68@gmail.com 2708695112

BIN
data/sales.rds Normal file

Binary file not shown.

Binary file not shown.