From d1100ece80d6d85303a7a1b6938ca2441b6182c9 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Sun, 6 Sep 2026 13:37:46 +1000 Subject: [PATCH] Add SQL insight dashboard widgets --- .gitignore | 3 +- app.py | 2 + features/dashboard.py | 55 ++++++++++++-- .../2026-09-06_add_dashboard_widgets.sql | 26 +++++++ routes/sql_explorer.py | 71 +++++++++++++++++-- scripts/run_migration.py | 20 +++--- templates/dashboard.html | 21 +++++- templates/partials/dashboard_insight.html | 28 ++++++++ .../partials/sql_explorer/saved_queries.html | 59 +++++++++++++++ .../partials/sql_explorer/sql_query.html | 12 ++-- templates/partials/sql_explorer/svg_plot.html | 4 +- 11 files changed, 273 insertions(+), 28 deletions(-) create mode 100644 migrations/2026-09-06_add_dashboard_widgets.sql create mode 100644 templates/partials/dashboard_insight.html create mode 100644 templates/partials/sql_explorer/saved_queries.html diff --git a/.gitignore b/.gitignore index 4d88e98..00b1b65 100644 --- a/.gitignore +++ b/.gitignore @@ -162,9 +162,10 @@ cython_debug/ # Exclude backup sql files **/*.sql +!migrations/2026-09-06_add_dashboard_widgets.sql # Exclude experimental juypter notebooks **/*.ipynb # Exclude node_modules -node_modules \ No newline at end of file +node_modules diff --git a/app.py b/app.py index 158fe19..ff7896c 100644 --- a/app.py +++ b/app.py @@ -112,6 +112,7 @@ def dashboard(): exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids) tags = db.get_tags_for_dashboard() dashboard = db.dashboard.get(selected_people_ids, min_date, max_date, selected_exercise_ids) + pinned_widgets = db.dashboard.get_pinned_widgets(current_user.id) if current_user.is_authenticated else [] # Render the appropriate response for HTMX or full page render_args = { @@ -119,6 +120,7 @@ def dashboard(): "people": people, "exercises": exercises, "tags": tags, + "pinned_widgets": pinned_widgets, "selected_people_ids": selected_people_ids, "max_date": max_date, "min_date": min_date, diff --git a/features/dashboard.py b/features/dashboard.py index c869f92..29c01f4 100644 --- a/features/dashboard.py +++ b/features/dashboard.py @@ -1,9 +1,56 @@ -from utils import calculate_estimated_1rm, get_exercise_graph_model +from utils import calculate_estimated_1rm, get_exercise_graph_model, prepare_svg_plot_data -class Dashboard: - def __init__(self, db_connection_method): - self.execute = db_connection_method +class Dashboard: + def __init__(self, db_connection_method): + self.execute = db_connection_method + + def get_pinned_widgets(self, person_id): + """Execute and prepare the current user's pinned SQL dashboard widgets.""" + widgets = self.execute(""" + SELECT + w.widget_id, + w.saved_query_id AS query_id, + w.visualization_type, + q.title, + q.query + FROM dashboard_widget w + INNER JOIN saved_query q ON q.id = w.saved_query_id + WHERE w.person_id = %s AND w.is_visible = TRUE + ORDER BY w.position, w.widget_id + """, [person_id]) or [] + + prepared_widgets = [] + for widget in widgets: + prepared = dict(widget) + prepared['results'] = [] + prepared['columns'] = [] + prepared['row_count'] = 0 + prepared['plot_data'] = None + prepared['error'] = None + + try: + results = self.execute(widget['query']) or [] + columns = list(results[0].keys()) if results else [] + prepared['row_count'] = len(results) + prepared['results'] = results[:100] + prepared['columns'] = columns + + if widget['visualization_type'] == 'graph' and results: + prepared['plot_data'] = prepare_svg_plot_data( + results[:100], columns, widget['title'] + ) + except Exception as error: + # A broken saved query should not take down the Home page. + try: + self.execute('ROLLBACK') + except Exception: + pass + prepared['error'] = str(error) + + prepared_widgets.append(prepared) + + return prepared_widgets def get_people_ids(self): query = """ diff --git a/migrations/2026-09-06_add_dashboard_widgets.sql b/migrations/2026-09-06_add_dashboard_widgets.sql new file mode 100644 index 0000000..5048962 --- /dev/null +++ b/migrations/2026-09-06_add_dashboard_widgets.sql @@ -0,0 +1,26 @@ +-- Saved SQL insights can be pinned as live widgets on the home dashboard. +-- Keep the existing saved_query table compatible with older installations. +CREATE TABLE IF NOT EXISTS saved_query ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + query TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS dashboard_widget ( + widget_id SERIAL PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES person(person_id) ON DELETE CASCADE, + saved_query_id INTEGER NOT NULL REFERENCES saved_query(id) ON DELETE CASCADE, + visualization_type VARCHAR(20) NOT NULL DEFAULT 'table', + position INTEGER NOT NULL DEFAULT 0, + width INTEGER NOT NULL DEFAULT 1, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT dashboard_widget_visualization_type_chk + CHECK (visualization_type IN ('table', 'graph')), + CONSTRAINT dashboard_widget_unique_view + UNIQUE (person_id, saved_query_id, visualization_type) +); + +CREATE INDEX IF NOT EXISTS dashboard_widget_person_position_idx + ON dashboard_widget (person_id, position, widget_id); diff --git a/routes/sql_explorer.py b/routes/sql_explorer.py index d18f03e..ecc751c 100644 --- a/routes/sql_explorer.py +++ b/routes/sql_explorer.py @@ -65,18 +65,75 @@ def _save_query(title, query): db.getDB().rollback() return error -def _list_saved_queries(): - """Lists all saved queries.""" - return db.execute("SELECT id, title, query FROM saved_query ORDER BY title") +def _list_saved_queries(): + """Lists all saved queries.""" + person_id = getattr(current_user, 'id', None) or -1 + return db.execute(""" + SELECT + q.id, + q.title, + q.query, + EXISTS ( + SELECT 1 FROM dashboard_widget w + WHERE w.saved_query_id = q.id + AND w.person_id = %s + AND w.visualization_type = 'table' + AND w.is_visible = TRUE + ) AS table_pinned, + EXISTS ( + SELECT 1 FROM dashboard_widget w + WHERE w.saved_query_id = q.id + AND w.person_id = %s + AND w.visualization_type = 'graph' + AND w.is_visible = TRUE + ) AS graph_pinned + FROM saved_query q + ORDER BY q.title + """, [person_id, person_id]) def _get_saved_query(query_id): """Fetches a specific saved query.""" result = db.execute("SELECT title, query FROM saved_query WHERE id=%s", [query_id], one=True) return (result['title'], result['query']) if result else (None, None) -def _delete_saved_query(query_id): - """Deletes a saved query.""" - db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True) +def _delete_saved_query(query_id): + """Deletes a saved query.""" + db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True) + + +@sql_explorer_bp.route('/widget/', methods=['POST', 'DELETE']) +@login_required +def dashboard_widget(query_id): + """Pin or unpin a saved query as a personal Home dashboard widget.""" + visualization_type = request.values.get('visualization_type', 'table') + if visualization_type not in ('table', 'graph'): + return "Unsupported visualization type", 400 + + query = db.execute("SELECT id FROM saved_query WHERE id=%s", [query_id], one=True) + if not query: + return "Query not found", 404 + + if request.method == 'POST': + db.execute(""" + INSERT INTO dashboard_widget (person_id, saved_query_id, visualization_type) + VALUES (%s, %s, %s) + ON CONFLICT (person_id, saved_query_id, visualization_type) + DO UPDATE SET is_visible = TRUE + """, [current_user.id, query_id, visualization_type], commit=True) + else: + db.execute(""" + DELETE FROM dashboard_widget + WHERE person_id=%s AND saved_query_id=%s AND visualization_type=%s + """, [current_user.id, query_id, visualization_type], commit=True) + + # Home widgets target themselves, while SQL Explorer refreshes its library. + if request.headers.get('HX-Target', '').startswith('dashboard-widget-'): + return '' + + return render_template( + 'partials/sql_explorer/saved_queries.html', + saved_queries=_list_saved_queries() + ) def _generate_sql_from_natural_language(natural_query): """Generates SQL query from natural language using Gemini REST API.""" @@ -268,4 +325,4 @@ def generate_sql(): # Return error message prepended, to be displayed in the textarea return f"-- Error generating SQL: {error}\n\n" - return generated_sql if generated_sql else "-- No SQL generated." \ No newline at end of file + return generated_sql if generated_sql else "-- No SQL generated." diff --git a/scripts/run_migration.py b/scripts/run_migration.py index 0808ea7..a84789b 100644 --- a/scripts/run_migration.py +++ b/scripts/run_migration.py @@ -2,19 +2,23 @@ Database migration runner Execute SQL migration files using the Flask app's database connection """ -import os -import sys - -# Add parent directory to path to import app -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from extensions import db as database +import os +import sys + +# Add the project root to the import path when this file is invoked directly. +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +from dotenv import load_dotenv +load_dotenv(os.path.join(PROJECT_ROOT, '.env')) + +from extensions import db as database def run_migration(migration_file): """Execute a SQL migration file""" try: # Read migration file - migration_path = os.path.join('migrations', migration_file) + migration_path = os.path.join(PROJECT_ROOT, 'migrations', migration_file) if not os.path.exists(migration_path): print(f"ERROR: Migration file not found: {migration_path}") sys.exit(1) diff --git a/templates/dashboard.html b/templates/dashboard.html index fe692c2..e5f53e7 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -94,7 +94,24 @@ hx-target="this" hx-swap="outerHTML"> -{% if dashboard|length == 0 %} +{% if pinned_widgets %} +
+
+
+

Pinned insights

+

Live tables and graphs from your saved SQL queries.

+
+ Manage insights +
+
+ {% for widget in pinned_widgets %} + {{ render_partial('partials/dashboard_insight.html', widget=widget) }} + {% endfor %} +
+
+{% endif %} + +{% if dashboard|length == 0 %} @@ -193,4 +210,4 @@ hx-target="#stats" hx-swap="innerHTML"> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/partials/dashboard_insight.html b/templates/partials/dashboard_insight.html new file mode 100644 index 0000000..60993ca --- /dev/null +++ b/templates/partials/dashboard_insight.html @@ -0,0 +1,28 @@ +
+
+
+

{{ widget.title }}

+

Live SQL insight ยท {{ widget.row_count }} rows

+
+ +
+ {% if widget.error %} +
This insight could not be refreshed: {{ widget.error }}
+ {% elif widget.visualization_type == 'graph' and widget.plot_data %} + {{ render_partial('partials/sql_explorer/svg_plot.html', show_title=False, **widget.plot_data) }} + {% elif widget.results %} +
+ + {% for col in widget.columns %}{% endfor %} + + {% for row in widget.results %}{% for col in widget.columns %}{% endfor %}{% endfor %} + +
{{ col }}
{{ row[col] if row[col] is not none else 'NULL' }}
+
+ {% else %} +

No data returned by this query.

+ {% endif %} +
diff --git a/templates/partials/sql_explorer/saved_queries.html b/templates/partials/sql_explorer/saved_queries.html new file mode 100644 index 0000000..57edecc --- /dev/null +++ b/templates/partials/sql_explorer/saved_queries.html @@ -0,0 +1,59 @@ +
+
+
+

Saved Queries Library

+

Pin a table or graph to your Home dashboard.

+
+ {{ saved_queries|length }} Queries Total +
+ + {% if saved_queries %} +
+
+ + + + + + + + + {% for saved in saved_queries %} + + + + + {% endfor %} + +
Query TitleQuick Actions
+ + +
+ + {% if saved.table_pinned %} + + {% else %} + + {% endif %} + {% if saved.graph_pinned %} + + {% else %} + + {% endif %} + +
+
+
+
+ {% else %} +
+

No saved queries

+

Create and save a query to pin it to Home.

+
+ {% endif %} +
diff --git a/templates/partials/sql_explorer/sql_query.html b/templates/partials/sql_explorer/sql_query.html index 1211505..bebd8d6 100644 --- a/templates/partials/sql_explorer/sql_query.html +++ b/templates/partials/sql_explorer/sql_query.html @@ -140,8 +140,11 @@
- -
+ {{ render_partial('partials/sql_explorer/saved_queries.html', saved_queries=saved_queries) }} + + + \ No newline at end of file +
+ + diff --git a/templates/partials/sql_explorer/svg_plot.html b/templates/partials/sql_explorer/svg_plot.html index 7bb4d7c..ff7330e 100644 --- a/templates/partials/sql_explorer/svg_plot.html +++ b/templates/partials/sql_explorer/svg_plot.html @@ -2,7 +2,7 @@ {% set unique_id = range(1000, 9999) | random %} {# Simple unique ID for elements #}
-

{{ title }}

+ {% if show_title|default(true) %}

{{ title }}

{% endif %} {% if plot_type == 'table' %} {# Fallback to rendering a table if plot type is not supported or data is unsuitable #} @@ -122,4 +122,4 @@
{% endif %} - \ No newline at end of file +