selftracker / analytics.py
Nakvi's picture
Upload 14 files
cd7bed1 verified
"""
FocusTrack - Page 2: Analytics & Reports
Date-range charts, heatmaps, export.
"""
import streamlit as st
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import json
import io
from datetime import datetime, timedelta, date
from ui_utils import (
fmt_duration, fmt_duration_long, get_focus_score,
privacy_badge, page_header, CATEGORY_COLORS, get_db, get_date_range
)
def render():
db = get_db()
privacy_badge()
page_header("Analytics & Reports", "Visualize your productivity trends")
# ── Date Range Selector ───────────────────────────────────────────────────
col_range, col_start, col_end = st.columns([1.5, 1, 1])
with col_range:
range_sel = st.selectbox(
"Date range",
["Today", "Yesterday", "Last 7 days", "Last 30 days", "Custom"],
index=2,
)
start, end = get_date_range(range_sel)
if range_sel == "Custom":
with col_start:
start_d = st.date_input("From", value=date.today() - timedelta(days=6))
with col_end:
end_d = st.date_input("To", value=date.today())
start = datetime.combine(start_d, datetime.min.time())
end = datetime.combine(end_d, datetime.max.time())
# ── Summary Metrics ───────────────────────────────────────────────────────
summary = db.get_summary(start, end)
focus_sec = summary.get("focus_seconds") or 0
idle_sec = summary.get("idle_seconds") or 0
total_sec = summary.get("total_seconds") or 0
c1, c2, c3, c4 = st.columns(4)
with c1: st.metric("Total Focus Time", fmt_duration_long(focus_sec))
with c2: st.metric("Total Idle Time", fmt_duration_long(idle_sec))
with c3: st.metric("Focus Score", f"{get_focus_score(focus_sec, total_sec)}%")
with c4: st.metric("Tracked Time", fmt_duration_long(total_sec))
st.markdown("<div style='height:0.75rem'></div>", unsafe_allow_html=True)
# ── Daily Activity Bar Chart ──────────────────────────────────────────────
daily = db.get_daily_totals(start, end)
if daily:
st.markdown("#### Daily Activity")
df_daily = pd.DataFrame(daily)
df_daily["focus_min"] = df_daily["focus_seconds"] / 60
df_daily["idle_min"] = df_daily["idle_seconds"] / 60
fig = go.Figure()
fig.add_bar(
x=df_daily["day"], y=df_daily["focus_min"],
name="Focus", marker_color="#6366f1",
hovertemplate="<b>%{x}</b><br>Focus: %{y:.0f} min<extra></extra>"
)
fig.add_bar(
x=df_daily["day"], y=df_daily["idle_min"],
name="Idle", marker_color="#374151",
hovertemplate="<b>%{x}</b><br>Idle: %{y:.0f} min<extra></extra>"
)
fig.update_layout(
barmode="stack", height=280,
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#9090b0", family="Inter"),
margin=dict(t=10, b=30, l=0, r=0),
xaxis=dict(showgrid=False),
yaxis=dict(showgrid=True, gridcolor="#2a2a3a", title="Minutes"),
legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="left", x=0),
)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
# ── Two-column: Category Breakdown + Top Apps ─────────────────────────────
col_l, col_r = st.columns(2)
with col_l:
st.markdown("#### Time by Category")
cat_data = db.get_by_category(start, end)
if cat_data:
df_cat = pd.DataFrame(cat_data)
df_cat["minutes"] = df_cat["total_seconds"] / 60
colors = [CATEGORY_COLORS.get(c, "#6366f1") for c in df_cat["category"]]
fig_cat = px.bar(
df_cat, x="minutes", y="category", orientation="h",
color="category", color_discrete_map=CATEGORY_COLORS,
labels={"minutes": "Minutes", "category": ""},
)
fig_cat.update_layout(
height=300, showlegend=False,
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#9090b0", family="Inter"),
margin=dict(t=10, b=30, l=0, r=0),
xaxis=dict(showgrid=True, gridcolor="#2a2a3a"),
yaxis=dict(showgrid=False),
)
st.plotly_chart(fig_cat, use_container_width=True, config={"displayModeBar": False})
else:
st.markdown("<p style='color:#5a5a7a'>No data for this range.</p>", unsafe_allow_html=True)
with col_r:
st.markdown("#### Top Apps Leaderboard")
apps_data = db.get_by_app(start, end, limit=10)
if apps_data:
df_apps = pd.DataFrame(apps_data)
df_apps["minutes"] = df_apps["total_seconds"] / 60
html = ""
max_min = df_apps["minutes"].max() or 1
for i, row in df_apps.iterrows():
cat = row.get("category", "uncategorized")
color = CATEGORY_COLORS.get(cat, "#6366f1")
pct = int((row["minutes"] / max_min) * 100)
rank = i + 1
html += f"""
<div style="display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #1e1e2e;">
<div style="font-family:'DM Mono',monospace; font-size:0.8rem; color:#5a5a7a; min-width:20px;">#{rank}</div>
<div style="flex:1; min-width:0;">
<div style="font-size:0.85rem; font-weight:500; color:#f0f0ff; margin-bottom:3px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{row['app_name'][:30]}</div>
<div style="background:#1e1e2e; border-radius:4px; height:4px;">
<div style="background:{color}; width:{pct}%; height:100%; border-radius:4px;"></div>
</div>
</div>
<div style="font-family:'DM Mono',monospace; font-size:0.8rem; color:#9090b0; min-width:55px; text-align:right;">{fmt_duration(row['total_seconds'])}</div>
</div>"""
st.markdown(f'<div style="font-family:Inter,sans-serif;">{html}</div>', unsafe_allow_html=True)
else:
st.markdown("<p style='color:#5a5a7a'>No data for this range.</p>", unsafe_allow_html=True)
# ── Weekly Heatmap ────────────────────────────────────────────────────────
if range_sel in ("Last 7 days", "Last 30 days", "Custom"):
st.markdown("#### Weekly Activity Heatmap (hours Γ— days)")
acts = db.get_activities(start, end, limit=50000)
if acts:
df_all = pd.DataFrame(acts)
df_all["dt"] = pd.to_datetime(df_all["timestamp"])
df_all["hour"] = df_all["dt"].dt.hour
df_all["day"] = df_all["dt"].dt.strftime("%a %b %d")
heatmap = df_all.groupby(["day", "hour"])["duration_seconds"].sum().reset_index()
heatmap["minutes"] = heatmap["duration_seconds"] / 60
pivot = heatmap.pivot_table(index="day", columns="hour", values="minutes", fill_value=0)
fig_hm = go.Figure(go.Heatmap(
z=pivot.values,
x=[f"{h:02d}:00" for h in pivot.columns],
y=pivot.index.tolist(),
colorscale=[[0, "#0a0a0f"], [0.3, "#1a1a3a"], [0.7, "#4040a0"], [1, "#6366f1"]],
showscale=True,
hovertemplate="<b>%{y}</b> at <b>%{x}</b><br>%{z:.0f} minutes<extra></extra>",
))
fig_hm.update_layout(
height=max(200, len(pivot) * 30 + 80),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#9090b0", family="Inter"),
margin=dict(t=10, b=30, l=80, r=0),
xaxis=dict(tickfont=dict(size=9)),
yaxis=dict(tickfont=dict(size=9)),
)
st.plotly_chart(fig_hm, use_container_width=True, config={"displayModeBar": False})
# ── Export ────────────────────────────────────────────────────────────────
st.markdown("---")
st.markdown("#### Export Data")
col_ex1, col_ex2, col_ex3 = st.columns([1, 1, 4])
acts_for_export = db.get_activities(start, end, limit=100000)
if acts_for_export:
df_export = pd.DataFrame(acts_for_export)
with col_ex1:
csv_buf = df_export.to_csv(index=False).encode("utf-8")
st.download_button(
"πŸ“₯ Export CSV", csv_buf,
file_name=f"focustrack_export_{start.date()}_{end.date()}.csv",
mime="text/csv", use_container_width=True
)
with col_ex2:
json_buf = df_export.to_json(orient="records", indent=2).encode("utf-8")
st.download_button(
"πŸ“₯ Export JSON", json_buf,
file_name=f"focustrack_export_{start.date()}_{end.date()}.json",
mime="application/json", use_container_width=True
)
else:
st.markdown("<p style='color:#5a5a7a; font-size:0.85rem;'>No data to export for selected range.</p>",
unsafe_allow_html=True)