Compare commits
43 Commits
48559e7906
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a63139dee | |||
| 0b5b093d99 | |||
| 5fbff45a2e | |||
| bf5f736cf3 | |||
| d953edce54 | |||
| 29f48172fb | |||
| ef5c62d2a6 | |||
| 6514c23398 | |||
| 6614bfeb04 | |||
| 6264ebea66 | |||
| 4c6ab3d573 | |||
| 4ef1e5511a | |||
| 8e4c4ebff2 | |||
| 138051c4c4 | |||
| ce209d8898 | |||
| b115ee1158 | |||
| 3b69dd3477 | |||
| a816a570a0 | |||
| dd7566ef8e | |||
| eb18ba4115 | |||
| d8f1bc3110 | |||
| c02715409b | |||
| 28ced93180 | |||
| 6700075e72 | |||
| 30c6535130 | |||
| 6a11f96f33 | |||
| 1b75760496 | |||
| e0cf03df2f | |||
| 50f0964de7 | |||
| e2f5f7a9d2 | |||
| 1ee81f20b6 | |||
| 6a57a18660 | |||
| ec90f8236c | |||
| 267a279359 | |||
| 77dbe8cc7f | |||
| 77a6721a51 | |||
| 26d8c7b05c | |||
| 25d7427ab3 | |||
| f466db6a9c | |||
| 3a0ecf72f2 | |||
| 5bc3300ceb | |||
| 971838cfde | |||
| f029462e5f |
@@ -17,3 +17,5 @@ data-raw/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
TODO.md
|
TODO.md
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -7,23 +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} "
|
|
||||||
cd /data/projects/r/stAndrews &&
|
|
||||||
git pull &&
|
|
||||||
docker compose build &&
|
|
||||||
docker compose up -d &&
|
|
||||||
docker exec analytics-gateway caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
docker exec analytics-gateway caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
"
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
320
R/app_server.R
Normal 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
310
R/app_ui.R
Normal 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
38
R/config.R
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
4
TODO.md
4
TODO.md
@@ -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/
|
||||||
|
|||||||
539
app.R
539
app.R
@@ -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,532 +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")
|
|
||||||
last_sale_date <- format(attr(owners, "last_sale_date"), "%Y-%m-%d")
|
|
||||||
sbdvn <- sf::st_read("./data/plats/plats.shp")
|
|
||||||
venice_bndry <- readRDS("./data/venice.rds")
|
|
||||||
venice_facts <- readRDS("./data/venice_facts.rds")
|
|
||||||
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 = "Venice",
|
|
||||||
title = "Venice",
|
|
||||||
icon = f7Icon("map_pin")
|
|
||||||
),
|
|
||||||
f7PanelItem(
|
|
||||||
tabName = "Beach",
|
|
||||||
title = "Beach",
|
|
||||||
icon = f7Icon("sun_max_fill")
|
|
||||||
),
|
|
||||||
f7PanelItem(
|
|
||||||
tabName = "Owners",
|
|
||||||
title = "Owners",
|
|
||||||
icon = f7Icon("person_2_fill")
|
|
||||||
),
|
|
||||||
f7PanelItem(
|
|
||||||
tabName = "Listings",
|
|
||||||
title = "Listings",
|
|
||||||
icon = f7Icon("tag_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")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
#### venice ----
|
|
||||||
f7Tab(
|
|
||||||
title = "Venice",
|
|
||||||
tabName = "Venice",
|
|
||||||
icon = f7Icon("map_pin"),
|
|
||||||
active = FALSE,
|
|
||||||
f7Card(
|
|
||||||
title = "Venice",
|
|
||||||
divider = "TRUE",
|
|
||||||
leafletOutput("venice_map")
|
|
||||||
),
|
|
||||||
f7Card(
|
|
||||||
title = "Facts",
|
|
||||||
divider = "TRUE",
|
|
||||||
DTOutput("venice_facts")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
#### beaches ----
|
|
||||||
f7Tab(
|
|
||||||
title = "Beach",
|
|
||||||
tabName = "Beach",
|
|
||||||
icon = f7Icon("sun_max_fill"),
|
|
||||||
active = TRUE,
|
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
#### 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 = "Active Listings:",
|
|
||||||
divider = TRUE,
|
|
||||||
DTOutput("listings_table")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
#### services ----
|
|
||||||
f7Tab(
|
|
||||||
title = "Resources",
|
|
||||||
tabName = "Resources",
|
|
||||||
icon = f7Icon("hammer_fill"),
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
### 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) {
|
server <- create_server
|
||||||
# update tabs depending on side panel
|
|
||||||
observeEvent(input$menu, {
|
|
||||||
updateF7Tabs(id = "tabs",
|
|
||||||
selected = input$menu)
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# listings table ----
|
|
||||||
output$listings_table <- renderDT({
|
|
||||||
datatable(
|
|
||||||
listings |>
|
|
||||||
select(listed_date, address, sqft, price, price_per_sqft) |>
|
|
||||||
mutate(
|
|
||||||
price = scales::dollar(price),
|
|
||||||
price_per_sqft = scales::dollar(price_per_sqft)
|
|
||||||
),
|
|
||||||
colnames = c("Listed", "Address", "Sq Ft", "Price", "$/Sq Ft"),
|
|
||||||
rownames = FALSE,
|
|
||||||
options = list(
|
|
||||||
pageLength = 25,
|
|
||||||
searching = FALSE,
|
|
||||||
dom = 't'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
# venice map ----
|
|
||||||
output$venice_map <- renderLeaflet({
|
|
||||||
leaflet() %>%
|
|
||||||
addProviderTiles("CartoDB.Voyager") %>%
|
|
||||||
setView(lng = -82.4313, lat = 27.1059, zoom = 12) %>%
|
|
||||||
addPolygons(
|
|
||||||
data = venice_bndry,
|
|
||||||
color = "red",
|
|
||||||
weight = 2,
|
|
||||||
opacity = 0.5,
|
|
||||||
fillOpacity = 0.2
|
|
||||||
)
|
|
||||||
})
|
|
||||||
# venice facts ----
|
|
||||||
output$venice_facts <- renderDT({
|
|
||||||
datatable(
|
|
||||||
venice_facts,
|
|
||||||
rownames = FALSE,
|
|
||||||
options = list(
|
|
||||||
pageLength = 10,
|
|
||||||
scrollX = TRUE,
|
|
||||||
searching = FALSE,
|
|
||||||
lengthMenu = c(5, 10, 25, 50),
|
|
||||||
dom = 'tpi'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
# 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)
|
||||||
|
|||||||
39
data-raw/refresh_all.R
Normal file
39
data-raw/refresh_all.R
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# refresh_all.R
|
||||||
|
# Weekly orchestrator. Downloads SCPA data files once, then updates
|
||||||
|
# owners and sales. Run via cron; container restart handled by cron.
|
||||||
|
|
||||||
|
options(timeout = 300)
|
||||||
|
|
||||||
|
cat("=== stAndrews weekly refresh", format(Sys.time()), "===\n")
|
||||||
|
|
||||||
|
# ── Download SCPA Public.xlsx ─────────────────────────────────────────────────
|
||||||
|
cat("Downloading SCPA Public.xlsx...\n")
|
||||||
|
download.file(
|
||||||
|
url = "https://www.sc-pa.com/downloads/SCPA%20Public.xlsx",
|
||||||
|
destfile = "./data-raw/property/SCPA Public.xlsx",
|
||||||
|
mode = "wb"
|
||||||
|
)
|
||||||
|
cat("Done.\n")
|
||||||
|
|
||||||
|
# ── Download SCPA_Parcels_Sales_CSV.zip ───────────────────────────────────────
|
||||||
|
cat("Downloading SCPA_Parcels_Sales_CSV.zip...\n")
|
||||||
|
download.file(
|
||||||
|
url = "https://www.sc-pa.com/downloads/SCPA_Parcels_Sales_CSV.zip",
|
||||||
|
destfile = "./data-raw/property/SCPA_Parcels_Sales_CSV.zip",
|
||||||
|
mode = "wb"
|
||||||
|
)
|
||||||
|
cat("Done.\n")
|
||||||
|
|
||||||
|
# ── Update owners ─────────────────────────────────────────────────────────────
|
||||||
|
cat("\n--- update_owners.R ---\n")
|
||||||
|
source("./data-raw/update_owners.R")
|
||||||
|
|
||||||
|
# ── Update sales ──────────────────────────────────────────────────────────────
|
||||||
|
cat("\n--- update_sales.R ---\n")
|
||||||
|
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")
|
||||||
@@ -51,11 +51,50 @@ 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,
|
||||||
|
owner_1,
|
||||||
sqft = as.numeric(squareFootage),
|
sqft = as.numeric(squareFootage),
|
||||||
price = as.numeric(price),
|
price = as.numeric(price),
|
||||||
price_per_sqft = round(price / sqft, 0),
|
price_per_sqft = round(price / sqft, 0),
|
||||||
|
|||||||
@@ -12,23 +12,16 @@ 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")
|
||||||
|
|
||||||
# download fresh scpa data ----
|
|
||||||
download.file(
|
|
||||||
url = "https://www.sc-pa.com/downloads/SCPA%20Public.xlsx",
|
|
||||||
destfile = "./data-raw/property/SCPA Public.xlsx",
|
|
||||||
mode = "wb"
|
|
||||||
)
|
|
||||||
|
|
||||||
# load and clean scpa data ----
|
# load and clean scpa data ----
|
||||||
owners_raw <-
|
owners_raw <-
|
||||||
readxl::read_xlsx(
|
readxl::read_xlsx(
|
||||||
@@ -80,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")
|
||||||
|
|||||||
53
data-raw/update_sales.R
Normal file
53
data-raw/update_sales.R
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# update_sales.R
|
||||||
|
# Pull the 10 most recent arm's-length sales in St. Andrews from
|
||||||
|
# SCPA_Parcels_Sales_CSV.zip (Sarasota.csv). Joins to geometry_lookup
|
||||||
|
# for coordinates. Downloads the zip fresh each run.
|
||||||
|
# Input: data-raw/addresses/geometry_lookup.rds (static)
|
||||||
|
# Output: data/sales.rds
|
||||||
|
|
||||||
|
library(readr)
|
||||||
|
library(dplyr)
|
||||||
|
library(stringr)
|
||||||
|
library(sf)
|
||||||
|
|
||||||
|
subdivisions <- c(
|
||||||
|
"8120", "8113", "8171", "8195", "8221",
|
||||||
|
"8163", "8240", "8159", "8149", "8110", "8254", "8215", "8143"
|
||||||
|
)
|
||||||
|
|
||||||
|
geometry_lookup <- readRDS("./data-raw/addresses/geometry_lookup.rds")
|
||||||
|
|
||||||
|
# ── Load Sarasota.csv from cached zip ────────────────────────────────────────
|
||||||
|
csv_con <- unz("./data-raw/property/SCPA_Parcels_Sales_CSV.zip", "Parcel_Sales_CSV/Sarasota.csv")
|
||||||
|
|
||||||
|
# ── Load and filter ───────────────────────────────────────────────────────────
|
||||||
|
sales <-
|
||||||
|
read_csv(csv_con, show_col_types = FALSE) |>
|
||||||
|
filter(SUBD %in% subdivisions) |>
|
||||||
|
filter(QUAL_CODE %in% c("01", "03")) |>
|
||||||
|
filter(SALE_AMT > 0, LIVING > 0) |>
|
||||||
|
mutate(
|
||||||
|
listed_date = as.Date(SALE_DATE, format = "%m/%d/%Y"),
|
||||||
|
address = str_squish(paste(LOCN, LOCS, LOCCITY, LOCSTATE, LOCZIP)),
|
||||||
|
sqft = as.integer(LIVING),
|
||||||
|
price = as.integer(SALE_AMT),
|
||||||
|
price_per_sqft = round(price / sqft, 0),
|
||||||
|
account_number = str_trim(ACCOUNT)
|
||||||
|
) |>
|
||||||
|
arrange(desc(listed_date)) |>
|
||||||
|
slice_head(n = 10) |>
|
||||||
|
select(account_number, listed_date, address, sqft, price, price_per_sqft)
|
||||||
|
|
||||||
|
# ── Join geometry ─────────────────────────────────────────────────────────────
|
||||||
|
sales <- sales |>
|
||||||
|
inner_join(geometry_lookup, by = "account_number") |>
|
||||||
|
st_as_sf(sf_column_name = "geom") |>
|
||||||
|
mutate(
|
||||||
|
longitude = st_coordinates(geom)[, 1],
|
||||||
|
latitude = st_coordinates(geom)[, 2]
|
||||||
|
) |>
|
||||||
|
st_drop_geometry() |>
|
||||||
|
select(listed_date, address, sqft, price, price_per_sqft, latitude, longitude)
|
||||||
|
|
||||||
|
cat("Sales written:", nrow(sales), "\n")
|
||||||
|
saveRDS(sales, "./data/sales.rds")
|
||||||
BIN
data/listings.rds
Normal file
BIN
data/listings.rds
Normal file
Binary file not shown.
2
data/private_listings.csv
Normal file
2
data/private_listings.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"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"
|
||||||
|
BIN
data/sales.rds
Normal file
BIN
data/sales.rds
Normal file
Binary file not shown.
BIN
www/docs/patios_1/1995_12_04_patios_1_plat.pdf
Normal file
BIN
www/docs/patios_1/1995_12_04_patios_1_plat.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user