Skip to content

feat: multi-page navigation with st.navigation and Material icons#29

Merged
jschloman merged 6 commits intomainfrom
feat/modern-navigation
Mar 31, 2026
Merged

feat: multi-page navigation with st.navigation and Material icons#29
jschloman merged 6 commits intomainfrom
feat/modern-navigation

Conversation

@jschloman
Copy link
Copy Markdown
Owner

@jschloman jschloman commented Mar 29, 2026

Closes #28

Summary

  • visualize.py is now a ~40-line entrypoint; all chart and data logic extracted into focused modules
  • components/sidebar.py — shared data loading, caching, and global date filter; stores result in st.session_state['df'] so navigation doesn't reload data from disk
  • pages/overview.py — metrics + top charts
  • pages/music.py — listening timeline + cumulative growth
  • pages/places.py — 3D geographic map (fly-through, export)
  • pages/insights.py — patterns, narrative, granular filters
  • pages/fitness.py, pages/culture.py, pages/beer.py — friendly empty states ready for future source plugins
  • Navigation uses st.navigation + st.Page with Material icons; pages grouped into Overview / Music / Places / Health / Culture nav sections

Source plugin contract (must hold before merge)

All source plugins — including those wired through components/sidebar.py and DataBroker — must satisfy two architectural principles:

1. Download-then-display

No plugin may make outbound network or API calls at Streamlit runtime. SourcePlugin.fetch() reads only from a previously downloaded local file. If the file is absent it raises FileNotFoundError with a clear message; it does not fall back to a live fetch.

2. Data sovereignty

Each plugin is the sole authority over its own data and has no knowledge of any other source. fetch() returns only that source's canonical DataFrame. No cross-source filtering, join hints, or foreign schema references belong inside a plugin. All joining and merging is the exclusive responsibility of DataBroker.

Checklist for this PR

  • components/sidebar.py does not pass cross-source context into any plugin's fetch() call
  • No page triggers a network call (directly or via a plugin) during Streamlit startup or page render
  • Pages with absent data show a friendly empty state (guides user to run the download step) rather than raising an unhandled exception
  • Consider adding is_data_available() -> bool to the SourcePlugin ABC so pages can branch cleanly without catching exceptions

Test plan

  • CI goes green (ruff, mypy, pytest 78/78)
  • streamlit run visualize.py launches with sidebar nav and all existing charts intact
  • Navigating between pages does not reload data from disk
  • Pages with no data show empty state message rather than erroring

🤖 Generated with Claude Code

Restructures visualize.py from a single-file monolith into a proper
multi-page Streamlit app using the 2024 st.navigation + st.Page API.

New structure:
- visualize.py — thin entrypoint (~40 lines); calls render_sidebar()
  then pg.run() with 6 pages across 5 nav groups
- components/sidebar.py — all data loading, caching, and global filter
  logic extracted from main(); stores result in st.session_state['df']
- pages/overview.py — metrics + top charts (render_top_charts)
- pages/music.py — timeline + cumulative growth (render_timeline_analysis)
- pages/places.py — 3D geographic map (render_spatial_analysis)
- pages/insights.py — patterns, narrative, granular filters
  (render_insights_and_narrative)
- pages/fitness.py — empty state (Strava/Garmin/Runkeeper not yet wired)
- pages/culture.py — empty state (Letterboxd/Goodreads not yet wired)
- pages/beer.py — empty state (Untappd not yet wired)

All render functions re-exported from visualize.py for backward compat.
Navigation persists loaded data across page switches via session_state.
Pages show a friendly empty state when no data source is configured.
Tests updated to import from new page module locations; main() test
simplified to assert set_page_config + navigation + pg.run() are called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jschloman
Copy link
Copy Markdown
Owner Author

Architectural note: download-then-display for all source plugins

A decision has been made (see #30) that no SourcePlugin should make outbound network calls at Streamlit runtime. All plugins must follow a two-phase model:

  1. Download phase — a separate CLI script/helper fetches data from the external source and writes it to a local file in data/.
  2. Display phaseSourcePlugin.fetch() reads only from that local file. Zero network I/O at render time.

Impact on this PR

Please audit components/sidebar.py and any page that calls plugin.fetch() (or DataBroker) to confirm:

  • No plugin is instantiated in a way that could trigger a network call during Streamlit startup or page render.
  • If the local data file is absent, the plugin surfaces a clear FileNotFoundError or empty-state message rather than attempting a live fetch as a fallback.

This may require updating the SourcePlugin ABC (from #8) to formalize the contract — e.g. adding a is_data_available() -> bool helper so pages can render a friendly "run the download script first" empty state instead of erroring.

Flagging before merge so the pattern is locked in consistently across all future plugins.

@jschloman
Copy link
Copy Markdown
Owner Author

Second architectural note: data sovereignty

Building on the download-then-display constraint (previous comment), a second principle applies to all SourcePlugin implementations:

Each plugin is the sole authority over its own data and must be completely unaware of any other source.

Concretely, a plugin must not:

  • Reference columns, schemas, or IDs from another plugin
  • Accept parameters that reflect another source's shape (e.g. a timestamp range derived from Last.fm data)
  • Perform any filtering or transformation that implies knowledge of how its output will be joined

All cross-source logic — joins, merges, correlations, shared time-range filtering — belongs exclusively in DataBroker (or an equivalent orchestration layer).

Why this matters for this PR

Please check components/sidebar.py and any page that coordinates data from multiple plugins:

  • Is any plugin's fetch() call parameterised by data derived from another plugin?
  • Does any page pass a cross-source filter into a plugin rather than applying it after loading?
  • Does DataBroker encapsulate all join/merge logic, or has any of it leaked into individual plugins?

If the SourcePlugin ABC in #8 doesn't yet enforce this boundary (e.g. via signature constraints or documentation), this PR is a good opportunity to tighten that contract before more plugins are built on top of it.

jschloman and others added 5 commits March 30, 2026 21:11
… dark theme

- components/sidebar.py: replace text inputs with native file/dir pickers
  (tkinter dialog in a dedicated thread to avoid main-thread-loop error);
  paths persisted to data/config.json and reloaded on startup so users
  don't re-select files each session; sidebar iterates plugin registry
  so each plugin gets its own labelled selector section
- plugins/sources/base.py: document file_path / dir_path field types and
  optional file_types key in get_config_fields() contract
- plugins/sources/lastfm/loader.py: type → file_path, add file_types
- plugins/sources/swarm/loader.py: swarm_dir → dir_path, assumptions_file
  → file_path with JSON file_types
- components/theme.py: new single source of truth for the dark palette
  (TEAL #00C8C8, AMBER #FFA014, COLORWAY, SEQUENTIAL_SCALE, map RGBA
  constants, apply_dark_theme() helper)
- pages/overview.py, music.py, insights.py: apply_dark_theme() on every
  Plotly figure; heatmap uses SEQUENTIAL_SCALE so all charts share the
  teal-to-amber identity instead of Plotly's default blues
- pages/places.py: map_style dark, column spectrum teal→amber, country
  overlays and borders reference theme constants; inline RGBA literals removed
- .gitignore: explicit data/config.json entry
- tests: coverage for config persistence, path-input widget, new field types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lugin

Each source plugin now renders as a collapsible expander in the sidebar with
its Material icon and display name matching the nav panel's visual style.
Unconfigured plugins auto-expand so first-time users see what needs filling in;
configured ones collapse to keep the sidebar clean.

- SourcePlugin ABC: add ICON class attribute (default :material/database:)
- LastFmPlugin: ICON = :material/headphones:
- SwarmPlugin: ICON = :material/location_on:
- sidebar: replace header+subheader with markdown "DATA SOURCES" section header
  and st.sidebar.expander per plugin, expanded state driven by whether the
  primary path field is already configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inject CSS that strips expander box-borders and matches Streamlit's
st.navigation typography for both section headers and list items:

- visualize.py: inject _SIDEBAR_CSS once at startup via st.markdown;
  .autobio-section-header class mirrors st.navigation group-label
  (0.75rem, 600 weight, uppercase, 55% opacity); expander details
  rendered borderless/transparent so plugins read as flat list items
  at nav-item font size (0.875rem) with matching hover highlight
- sidebar.py: replace st.sidebar.header/subheader calls with
  st.sidebar.markdown using .autobio-section-header class for
  Data Sources, Cache Management, and Global Filters sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_path_input used st.sidebar.columns() and st.sidebar.text_input() which
explicitly address the sidebar container, bypassing the active expander
context and causing file selectors to render outside their plugin section.

Replace with st.columns() and st.text_input() so widgets are placed in
whatever container is currently active (the expander when called from
_render_plugin_config). Update tests to patch streamlit.columns /
streamlit.button / streamlit.text_input instead of streamlit.sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- lastfm/loader.py: guard against None return from load_listening_data
  before calling .empty / indexing (union-attr / index errors)
- sidebar.py: annotate json.load result as dict[str, str] to satisfy
  the no-any-return check on _read_config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jschloman jschloman merged commit b9a5d33 into main Mar 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: modern multi-page navigation with st.navigation and Material icons

1 participant