Add SQL insight dashboard widgets

This commit is contained in:
Peter Stockings
2026-09-06 13:37:46 +10:00
parent ff6a921550
commit d1100ece80
11 changed files with 273 additions and 28 deletions
+2 -1
View File
@@ -162,9 +162,10 @@ cython_debug/
# Exclude backup sql files # Exclude backup sql files
**/*.sql **/*.sql
!migrations/2026-09-06_add_dashboard_widgets.sql
# Exclude experimental juypter notebooks # Exclude experimental juypter notebooks
**/*.ipynb **/*.ipynb
# Exclude node_modules # Exclude node_modules
node_modules node_modules
+2
View File
@@ -112,6 +112,7 @@ def dashboard():
exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids) exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids)
tags = db.get_tags_for_dashboard() tags = db.get_tags_for_dashboard()
dashboard = db.dashboard.get(selected_people_ids, min_date, max_date, selected_exercise_ids) 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 the appropriate response for HTMX or full page
render_args = { render_args = {
@@ -119,6 +120,7 @@ def dashboard():
"people": people, "people": people,
"exercises": exercises, "exercises": exercises,
"tags": tags, "tags": tags,
"pinned_widgets": pinned_widgets,
"selected_people_ids": selected_people_ids, "selected_people_ids": selected_people_ids,
"max_date": max_date, "max_date": max_date,
"min_date": min_date, "min_date": min_date,
+51 -4
View File
@@ -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: class Dashboard:
def __init__(self, db_connection_method): def __init__(self, db_connection_method):
self.execute = 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): def get_people_ids(self):
query = """ query = """
@@ -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);
+64 -7
View File
@@ -65,18 +65,75 @@ def _save_query(title, query):
db.getDB().rollback() db.getDB().rollback()
return error return error
def _list_saved_queries(): def _list_saved_queries():
"""Lists all saved queries.""" """Lists all saved queries."""
return db.execute("SELECT id, title, query FROM saved_query ORDER BY title") 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): def _get_saved_query(query_id):
"""Fetches a specific saved query.""" """Fetches a specific saved query."""
result = db.execute("SELECT title, query FROM saved_query WHERE id=%s", [query_id], one=True) 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) return (result['title'], result['query']) if result else (None, None)
def _delete_saved_query(query_id): def _delete_saved_query(query_id):
"""Deletes a saved query.""" """Deletes a saved query."""
db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True) db.execute("DELETE FROM saved_query WHERE id=%s", [query_id], commit=True)
@sql_explorer_bp.route('/widget/<int:query_id>', 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): def _generate_sql_from_natural_language(natural_query):
"""Generates SQL query from natural language using Gemini REST API.""" """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 error message prepended, to be displayed in the textarea
return f"-- Error generating SQL: {error}\n\n" return f"-- Error generating SQL: {error}\n\n"
return generated_sql if generated_sql else "-- No SQL generated." return generated_sql if generated_sql else "-- No SQL generated."
+12 -8
View File
@@ -2,19 +2,23 @@
Database migration runner Database migration runner
Execute SQL migration files using the Flask app's database connection Execute SQL migration files using the Flask app's database connection
""" """
import os import os
import sys import sys
# Add parent directory to path to import app # Add the project root to the import path when this file is invoked directly.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
from extensions import db as database
from dotenv import load_dotenv
load_dotenv(os.path.join(PROJECT_ROOT, '.env'))
from extensions import db as database
def run_migration(migration_file): def run_migration(migration_file):
"""Execute a SQL migration file""" """Execute a SQL migration file"""
try: try:
# Read migration file # 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): if not os.path.exists(migration_path):
print(f"ERROR: Migration file not found: {migration_path}") print(f"ERROR: Migration file not found: {migration_path}")
sys.exit(1) sys.exit(1)
+19 -2
View File
@@ -94,7 +94,24 @@
hx-target="this" hx-swap="outerHTML"> hx-target="this" hx-swap="outerHTML">
</div> </div>
{% if dashboard|length == 0 %} {% if pinned_widgets %}
<section class="mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-2xl font-bold text-gray-900">Pinned insights</h2>
<p class="text-sm text-gray-500">Live tables and graphs from your saved SQL queries.</p>
</div>
<a hx-get="{{ url_for('sql_explorer.sql_explorer') }}" hx-push-url="true" hx-target="#container" class="text-sm font-medium text-cyan-600 hover:text-cyan-800 cursor-pointer">Manage insights</a>
</div>
<div class="w-full grid grid-cols-1 xl:grid-cols-2 gap-4">
{% for widget in pinned_widgets %}
{{ render_partial('partials/dashboard_insight.html', widget=widget) }}
{% endfor %}
</div>
</section>
{% endif %}
{% if dashboard|length == 0 %}
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert" id="no-workouts"> <div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert" id="no-workouts">
No workouts selected. No workouts selected.
</div> </div>
@@ -193,4 +210,4 @@
hx-target="#stats" hx-swap="innerHTML"> hx-target="#stats" hx-swap="innerHTML">
</div> </div>
{% endblock %} {% endblock %}
+28
View File
@@ -0,0 +1,28 @@
<article id="dashboard-widget-{{ widget.widget_id }}" class="bg-white shadow rounded-lg p-4 sm:p-6 border border-gray-100">
<div class="flex items-start justify-between gap-3 mb-4">
<div>
<h3 class="text-lg font-bold text-gray-900">{{ widget.title }}</h3>
<p class="text-xs text-gray-500 mt-1">Live SQL insight · {{ widget.row_count }} rows</p>
</div>
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=widget.query_id) }}" hx-vals='{"visualization_type":"{{ widget.visualization_type }}"}' hx-target="#dashboard-widget-{{ widget.widget_id }}" hx-swap="outerHTML" hx-confirm="Unpin '{{ widget.title }}' from Home?" class="text-gray-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50" title="Unpin insight">
<span class="sr-only">Unpin insight</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
</button>
</div>
{% if widget.error %}
<div class="p-3 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg">This insight could not be refreshed: {{ widget.error }}</div>
{% 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 %}
<div class="overflow-x-auto max-h-96">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50"><tr>{% for col in widget.columns %}<th class="px-3 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{{ col }}</th>{% endfor %}</tr></thead>
<tbody class="divide-y divide-gray-100">
{% for row in widget.results %}<tr>{% for col in widget.columns %}<td class="px-3 py-2 whitespace-nowrap text-gray-700">{{ row[col] if row[col] is not none else 'NULL' }}</td>{% endfor %}</tr>{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm text-gray-500 py-4">No data returned by this query.</p>
{% endif %}
</article>
@@ -0,0 +1,59 @@
<div id="saved-queries-library" class="pt-10 border-t border-gray-100">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
<p class="text-sm text-gray-500 mt-1">Pin a table or graph to your Home dashboard.</p>
</div>
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries Total</span>
</div>
{% if saved_queries %}
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<div class="overflow-x-auto">
<table class="min-w-full table-zebra">
<thead class="bg-gray-50/50">
<tr>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-500 uppercase tracking-wider border-b">Query Title</th>
<th class="px-6 py-4 text-right text-xs font-bold text-gray-500 uppercase tracking-wider border-b">Quick Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for saved in saved_queries %}
<tr class="group transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<button hx-get="{{ url_for('sql_explorer.load_sql_query', query_id=saved.id) }}" hx-target="#sql-query" class="flex items-center text-sm font-medium text-gray-900 hover:text-blue-600 group-hover:translate-x-1 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2.5 text-gray-400 group-hover:text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 3.057 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-3.057-9.542-7z" /></svg>
{{ saved.title or 'Untitled Query' }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<button hx-get="{{ url_for('sql_explorer.plot_query', query_id=saved.id) }}" hx-target="#sql-plot-results" class="text-green-600 hover:text-green-800 px-2 py-1 rounded-lg hover:bg-green-50 transition-colors text-sm" title="Visualize plot">Preview graph</button>
{% if saved.table_pinned %}
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"table"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-blue-700 bg-blue-50 hover:bg-blue-100 px-2 py-1 rounded-lg transition-colors text-sm" title="Unpin table">Table pinned</button>
{% else %}
<button hx-post="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"table"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-gray-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors text-sm" title="Pin table to Home">Pin table</button>
{% endif %}
{% if saved.graph_pinned %}
<button hx-delete="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"graph"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-purple-700 bg-purple-50 hover:bg-purple-100 px-2 py-1 rounded-lg transition-colors text-sm" title="Unpin graph">Graph pinned</button>
{% else %}
<button hx-post="{{ url_for('sql_explorer.dashboard_widget', query_id=saved.id) }}" hx-vals='{"visualization_type":"graph"}' hx-target="#saved-queries-library" hx-swap="outerHTML" class="text-gray-600 hover:text-purple-700 px-2 py-1 rounded-lg hover:bg-purple-50 transition-colors text-sm" title="Pin graph to Home">Pin graph</button>
{% endif %}
<button hx-delete="{{ url_for('sql_explorer.delete_sql_query', query_id=saved.id) }}" hx-target="#sql-query" hx-confirm="Delete query '{{ saved.title }}'?" class="text-red-400 hover:text-red-600 p-1 rounded-lg hover:bg-red-50 transition-colors" title="Delete query">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5-4h4a2 2 0 012 2v1H7V5a2 2 0 012-2z" /></svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center py-12 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<h3 class="mt-2 text-sm font-medium text-gray-900">No saved queries</h3>
<p class="mt-1 text-sm text-gray-500">Create and save a query to pin it to Home.</p>
</div>
{% endif %}
</div>
@@ -140,8 +140,11 @@
<!-- Plot Results --> <!-- Plot Results -->
<div id="sql-plot-results" class="transition-all duration-300"></div> <div id="sql-plot-results" class="transition-all duration-300"></div>
<!-- Saved Queries Section --> {{ render_partial('partials/sql_explorer/saved_queries.html', saved_queries=saved_queries) }}
<div class="pt-10 border-t border-gray-100">
<!-- Legacy Saved Queries Section (kept hidden for compatibility while the partial is adopted) -->
<div class="hidden">
<div class="pt-10 border-t border-gray-100">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2> <h2 class="text-xl font-bold text-gray-800">Saved Queries Library</h2>
<span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries <span class="text-xs font-medium text-gray-400 uppercase tracking-widest">{{ saved_queries|length }} Queries
@@ -220,5 +223,6 @@
<p class="mt-1 text-sm text-gray-500">Get started by creating and saving your first SQL query.</p> <p class="mt-1 text-sm text-gray-500">Get started by creating and saving your first SQL query.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
@@ -2,7 +2,7 @@
{% set unique_id = range(1000, 9999) | random %} {# Simple unique ID for elements #} {% set unique_id = range(1000, 9999) | random %} {# Simple unique ID for elements #}
<div class="sql-plot-container p-4 border rounded bg-white shadow" id="sql-plot-{{ unique_id }}"> <div class="sql-plot-container p-4 border rounded bg-white shadow" id="sql-plot-{{ unique_id }}">
<h4 class="text-lg font-semibold text-gray-700 text-center mb-2">{{ title }}</h4> {% if show_title|default(true) %}<h4 class="text-lg font-semibold text-gray-700 text-center mb-2">{{ title }}</h4>{% endif %}
{% if plot_type == 'table' %} {% if plot_type == 'table' %}
{# Fallback to rendering a table if plot type is not supported or data is unsuitable #} {# Fallback to rendering a table if plot type is not supported or data is unsuitable #}
@@ -122,4 +122,4 @@
</svg> </svg>
</div> </div>
{% endif %} {% endif %}
</div> </div>