selftracker / ui_utils.py
Nakvi's picture
Upload 14 files
cd7bed1 verified
"""
FocusTrack - Shared UI utilities, CSS themes, and helper functions.
"""
import streamlit as st
from datetime import datetime, timedelta, date
from typing import Optional
import pandas as pd
# ─── CSS Theme ───────────────────────────────────────────────────────────────
DARK_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&family=Inter:wght@300;400;500;600&display=swap');
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #111118;
--bg-card: #16161f;
--bg-card-hover: #1c1c27;
--border: #2a2a3a;
--border-glow: #6366f133;
--text-primary: #f0f0ff;
--text-secondary:#9090b0;
--text-muted: #5a5a7a;
--accent: #6366f1;
--accent-glow: #6366f144;
--accent-2: #22d3ee;
--accent-3: #f59e0b;
--accent-4: #10b981;
--success: #10b981;
--warning: #f59e0b;
--danger: #f43f5e;
--radius: 12px;
--radius-lg: 20px;
}
/* Global resets */
html, body, [class*="css"] {
font-family: 'Inter', sans-serif;
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
/* Hide Streamlit chrome */
#MainMenu, footer, .stDeployButton { display: none !important; }
.block-container { padding-top: 1.5rem !important; padding-bottom: 2rem !important; max-width: 1400px; }
/* Sidebar */
[data-testid="stSidebar"] {
background: var(--bg-secondary) !important;
border-right: 1px solid var(--border) !important;
}
[data-testid="stSidebar"] * { color: var(--text-primary) !important; }
/* Metric cards */
[data-testid="stMetric"] {
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
padding: 1.25rem 1.5rem !important;
transition: all 0.2s ease;
}
[data-testid="stMetric"]:hover {
border-color: var(--accent) !important;
box-shadow: 0 0 20px var(--accent-glow) !important;
}
[data-testid="stMetricLabel"] { color: var(--text-secondary) !important; font-size: 0.8rem !important; }
[data-testid="stMetricValue"] { color: var(--text-primary) !important; font-family: 'Syne', sans-serif !important; }
[data-testid="stMetricDelta"] { font-size: 0.75rem !important; }
/* Buttons */
.stButton > button {
background: var(--bg-card) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
font-family: 'Inter', sans-serif !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
.stButton > button:hover {
border-color: var(--accent) !important;
background: var(--accent-glow) !important;
color: var(--accent) !important;
}
.stButton > button:active { transform: scale(0.98); }
/* DataFrames */
[data-testid="stDataFrame"] {
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
}
/* Inputs / selects */
.stTextInput > div > div > input,
.stSelectbox > div > div,
.stDateInput > div > div > input,
.stNumberInput > div > div > input {
background: var(--bg-card) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
}
/* Radio */
.stRadio > div { gap: 0.5rem !important; }
/* Tabs */
.stTabs [data-baseweb="tab-list"] {
background: var(--bg-secondary) !important;
border-radius: var(--radius) !important;
padding: 4px !important;
gap: 4px !important;
border: 1px solid var(--border) !important;
}
.stTabs [data-baseweb="tab"] {
background: transparent !important;
color: var(--text-secondary) !important;
border-radius: 8px !important;
font-weight: 500 !important;
}
.stTabs [aria-selected="true"] {
background: var(--bg-card) !important;
color: var(--text-primary) !important;
border: 1px solid var(--border) !important;
}
/* Dividers */
hr { border-color: var(--border) !important; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
</style>
"""
LIGHT_CSS = """
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&family=Inter:wght@300;400;500;600&display=swap');
:root {
--bg-primary: #f8f8fc;
--bg-secondary: #f0f0f8;
--bg-card: #ffffff;
--bg-card-hover: #f5f5fd;
--border: #e2e2f0;
--border-glow: #6366f122;
--text-primary: #1a1a2e;
--text-secondary:#6060a0;
--text-muted: #9090b8;
--accent: #6366f1;
--accent-glow: #6366f122;
--accent-2: #0891b2;
--accent-3: #d97706;
--accent-4: #059669;
--success: #059669;
--warning: #d97706;
--danger: #e11d48;
--radius: 12px;
--radius-lg: 20px;
}
html, body, [class*="css"] {
font-family: 'Inter', sans-serif;
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
}
#MainMenu, footer, .stDeployButton { display: none !important; }
.block-container { padding-top: 1.5rem !important; max-width: 1400px; }
[data-testid="stSidebar"] {
background: var(--bg-secondary) !important;
border-right: 1px solid var(--border) !important;
}
[data-testid="stMetric"] {
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
padding: 1.25rem 1.5rem !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.06) !important;
}
</style>
"""
CATEGORY_COLORS = {
"coding": "#22d3ee",
"browsing": "#f59e0b",
"communication": "#10b981",
"design": "#a78bfa",
"documents": "#fb923c",
"media": "#f43f5e",
"system": "#64748b",
"idle": "#374151",
"uncategorized": "#6366f1",
}
# ─── Helpers ─────────────────────────────────────────────────────────────────
def fmt_duration(seconds: Optional[float]) -> str:
"""Format seconds to human-readable HH:MM or Xh Ym."""
if not seconds:
return "0m"
seconds = int(seconds)
h, m = divmod(seconds // 60, 60) if seconds >= 3600 else (0, seconds // 60)
if seconds >= 3600:
return f"{h}h {m:02d}m"
return f"{m}m {seconds % 60:02d}s" if m == 0 else f"{m}m"
def fmt_duration_long(seconds: Optional[float]) -> str:
if not seconds:
return "0 min"
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
parts = []
if h: parts.append(f"{h}h")
if m: parts.append(f"{m}m")
return " ".join(parts) or "< 1m"
def get_focus_score(focus_sec: float, total_sec: float) -> float:
if not total_sec:
return 0.0
return round((focus_sec / total_sec) * 100, 1)
def privacy_badge():
"""Render the privacy badge present on every screen."""
st.markdown("""
<div style="
display:inline-flex; align-items:center; gap:6px;
background:rgba(99,102,241,0.08); border:1px solid rgba(99,102,241,0.2);
border-radius:20px; padding:4px 12px; font-size:0.72rem;
color:#9090b0; font-family:'Inter',sans-serif; margin-bottom:0.5rem;
">
<span style="color:#10b981; font-size:0.85rem;">●</span>
<strong style="color:#6366f1;">100% private</strong>
&nbsp;β€’&nbsp; All data stays on your device
</div>
""", unsafe_allow_html=True)
def page_header(title: str, subtitle: str = ""):
"""Render a styled page header."""
st.markdown(f"""
<div style="margin-bottom:1.5rem;">
<h1 style="
font-family:'Syne',sans-serif; font-weight:800;
font-size:1.9rem; margin:0; letter-spacing:-0.02em;
background: linear-gradient(135deg, #f0f0ff, #9090d0);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
">{title}</h1>
{"" if not subtitle else f'<p style="color:#9090b0; margin:4px 0 0; font-size:0.88rem;">{subtitle}</p>'}
</div>
""", unsafe_allow_html=True)
def status_dot(status: str) -> str:
colors = {"running": "#10b981", "paused": "#f59e0b", "stopped": "#f43f5e"}
labels = {"running": "Tracking", "paused": "Paused", "stopped": "Stopped"}
c = colors.get(status, "#6366f1")
l = labels.get(status, status.title())
return f'<span style="color:{c}; font-size:0.8rem;">● {l}</span>'
def category_pill(category: str) -> str:
color = CATEGORY_COLORS.get(category, "#6366f1")
return (
f'<span style="background:{color}22; color:{color}; '
f'border:1px solid {color}44; border-radius:20px; '
f'padding:2px 10px; font-size:0.72rem; font-weight:600; '
f'font-family:DM Mono,monospace">{category}</span>'
)
def get_db():
"""Get cached database instance via session state."""
from database import Database
if "db" not in st.session_state:
db = Database()
db.initialize()
st.session_state.db = db
return st.session_state.db
def get_date_range(selection: str):
today = date.today()
if selection == "Today":
start = datetime.combine(today, datetime.min.time())
end = datetime.combine(today, datetime.max.time())
elif selection == "Yesterday":
y = today - timedelta(days=1)
start = datetime.combine(y, datetime.min.time())
end = datetime.combine(y, datetime.max.time())
elif selection == "Last 7 days":
start = datetime.combine(today - timedelta(days=6), datetime.min.time())
end = datetime.combine(today, datetime.max.time())
elif selection == "Last 30 days":
start = datetime.combine(today - timedelta(days=29), datetime.min.time())
end = datetime.combine(today, datetime.max.time())
else: # Custom
return None, None
return start, end