Spaces:
Sleeping
Sleeping
| """ | |
| 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> | |
| β’ 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 | |