Spaces:
Sleeping
Sleeping
| """ | |
| FocusTrack - Page 4: Settings | |
| Configure tracker behavior, categories, and data management. | |
| """ | |
| import streamlit as st | |
| import json | |
| import shutil | |
| from datetime import datetime | |
| from pathlib import Path | |
| from ui_utils import privacy_badge, page_header, CATEGORY_COLORS, get_db | |
| DB_PATH = Path(__file__).parent.parent / "data" / "activity.db" | |
| def render(): | |
| db = get_db() | |
| privacy_badge() | |
| page_header("Settings", "Configure FocusTrack to your workflow") | |
| settings = db.get_all_settings() | |
| tab1, tab2, tab3, tab4 = st.tabs(["βοΈ Tracker", "π·οΈ Categories", "ποΈ Data & Backup", "π¨ Appearance"]) | |
| # βββ Tab 1: Tracker Settings ββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab1: | |
| st.markdown("#### Tracking Behavior") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| idle_threshold = st.number_input( | |
| "Idle threshold (seconds)", | |
| min_value=30, max_value=3600, | |
| value=int(settings.get("idle_threshold_seconds", 300)), | |
| step=30, | |
| help="User is considered idle after this many seconds without mouse/keyboard activity." | |
| ) | |
| heartbeat = st.number_input( | |
| "Heartbeat interval (seconds)", | |
| min_value=1, max_value=60, | |
| value=int(settings.get("heartbeat_interval", 5)), | |
| step=1, | |
| help="How often activity is logged. Lower = more granular, higher = less CPU." | |
| ) | |
| with col2: | |
| ignored_raw = settings.get("ignored_apps", '[]') | |
| try: | |
| ignored_list = json.loads(ignored_raw) | |
| except Exception: | |
| ignored_list = [] | |
| ignored_text = st.text_area( | |
| "Ignored apps (one per line)", | |
| value="\n".join(ignored_list), | |
| height=120, | |
| help="Apps listed here will not be tracked." | |
| ) | |
| auto_start = st.toggle( | |
| "Auto-start tracker on launch", | |
| value=settings.get("auto_start", "false") == "true" | |
| ) | |
| if st.button("πΎ Save Tracker Settings", use_container_width=False): | |
| db.set_setting("idle_threshold_seconds", str(idle_threshold)) | |
| db.set_setting("heartbeat_interval", str(heartbeat)) | |
| db.set_setting("auto_start", "true" if auto_start else "false") | |
| new_ignored = [a.strip() for a in ignored_text.strip().split("\n") if a.strip()] | |
| db.set_setting("ignored_apps", json.dumps(new_ignored)) | |
| st.toast("β Tracker settings saved!", icon="β ") | |
| st.rerun() | |
| st.markdown("---") | |
| st.markdown("#### Tracker Status") | |
| status = db.get_setting("tracker_running", "true") | |
| status_map = {"true": ("π’ Running", "#10b981"), "paused": ("π‘ Paused", "#f59e0b"), "false": ("π΄ Stopped", "#f43f5e")} | |
| label, color = status_map.get(status, ("Unknown", "#6366f1")) | |
| st.markdown( | |
| f"<span style='color:{color}; font-size:1rem; font-weight:600;'>{label}</span>", | |
| unsafe_allow_html=True | |
| ) | |
| col_s1, col_s2, col_s3 = st.columns([1, 1, 1]) | |
| with col_s1: | |
| if st.button("βΆ Start", use_container_width=True): | |
| db.set_setting("tracker_running", "true") | |
| st.toast("Tracker started") | |
| st.rerun() | |
| with col_s2: | |
| if st.button("βΈ Pause", use_container_width=True): | |
| db.set_setting("tracker_running", "paused") | |
| st.toast("Tracker paused") | |
| st.rerun() | |
| with col_s3: | |
| if st.button("βΉ Stop", use_container_width=True): | |
| db.set_setting("tracker_running", "false") | |
| st.toast("Tracker stopped") | |
| st.rerun() | |
| # βββ Tab 2: Categories ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab2: | |
| st.markdown("#### Category Rules") | |
| st.markdown("<p style='color:#9090b0; font-size:0.85rem;'>Define which apps and keywords map to which category. The tracker uses these rules to auto-categorize activity.</p>", unsafe_allow_html=True) | |
| cats = db.get_categories() | |
| for cat in cats: | |
| with st.expander(f"{'β'} {cat['name'].title()}", expanded=False): | |
| col_n, col_c = st.columns([3, 1]) | |
| with col_n: | |
| cat_name = st.text_input("Name", value=cat["name"], key=f"cname_{cat['id']}") | |
| with col_c: | |
| cat_color = st.color_picker("Color", value=cat["color"], key=f"ccolor_{cat['id']}") | |
| try: | |
| kw_list = json.loads(cat["keywords"] or "[]") | |
| app_list = json.loads(cat["apps"] or "[]") | |
| except Exception: | |
| kw_list, app_list = [], [] | |
| col_k, col_a = st.columns(2) | |
| with col_k: | |
| kw_text = st.text_area( | |
| "Keywords (one per line)", | |
| value="\n".join(kw_list), | |
| height=100, | |
| key=f"kw_{cat['id']}", | |
| help="These words are matched against app name and window title." | |
| ) | |
| with col_a: | |
| app_text = st.text_area( | |
| "App names (one per line)", | |
| value="\n".join(app_list), | |
| height=100, | |
| key=f"apps_{cat['id']}", | |
| help="Partial matches against the process/app name." | |
| ) | |
| col_save, col_del = st.columns([1, 1]) | |
| with col_save: | |
| if st.button("πΎ Save", key=f"save_cat_{cat['id']}", use_container_width=True): | |
| new_kw = [k.strip() for k in kw_text.strip().split("\n") if k.strip()] | |
| new_app = [a.strip() for a in app_text.strip().split("\n") if a.strip()] | |
| db.upsert_category(cat_name, cat_color, new_kw, new_app) | |
| st.toast(f"β '{cat_name}' saved") | |
| st.rerun() | |
| with col_del: | |
| if cat["name"] not in ("idle", "uncategorized"): | |
| if st.button("π Delete", key=f"del_cat_{cat['id']}", use_container_width=True): | |
| db.delete_category(cat["name"]) | |
| st.toast(f"Deleted '{cat['name']}'") | |
| st.rerun() | |
| st.markdown("---") | |
| st.markdown("#### Add New Category") | |
| nc1, nc2, nc3 = st.columns([2, 1, 1]) | |
| with nc1: | |
| new_cat_name = st.text_input("Category name", placeholder="e.g. learning") | |
| with nc2: | |
| new_cat_color = st.color_picker("Color", value="#6366f1") | |
| with nc3: | |
| st.markdown("<div style='margin-top:28px'></div>", unsafe_allow_html=True) | |
| if st.button("β Add Category", use_container_width=True): | |
| if new_cat_name.strip(): | |
| db.upsert_category(new_cat_name.strip(), new_cat_color, [], []) | |
| st.toast(f"β Added '{new_cat_name}'") | |
| st.rerun() | |
| # βββ Tab 3: Data & Backup βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab3: | |
| st.markdown("#### Database Info") | |
| db_size = db.get_db_size_mb() | |
| from datetime import date | |
| from datetime import datetime as dt2 | |
| from ui_utils import get_date_range | |
| start, end = get_date_range("Last 30 days") | |
| count = db.get_activity_count(start, end) | |
| col_i1, col_i2, col_i3 = st.columns(3) | |
| with col_i1: | |
| st.metric("Database Size", f"{db_size:.2f} MB") | |
| with col_i2: | |
| st.metric("Records (30d)", f"{count:,}") | |
| with col_i3: | |
| st.metric("DB Location", "Local only β ") | |
| st.code(str(DB_PATH), language=None) | |
| st.markdown("---") | |
| st.markdown("#### Backup Database") | |
| col_b1, col_b2 = st.columns([1, 2]) | |
| with col_b1: | |
| if st.button("π¦ Download Backup", use_container_width=True): | |
| if DB_PATH.exists(): | |
| with open(DB_PATH, "rb") as f: | |
| data = f.read() | |
| st.download_button( | |
| "πΎ Save activity.db", | |
| data, | |
| file_name=f"focustrack_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db", | |
| mime="application/octet-stream", | |
| use_container_width=True, | |
| ) | |
| st.markdown("---") | |
| st.markdown("#### Restore Database") | |
| uploaded = st.file_uploader("Upload a backup .db file", type=["db"]) | |
| if uploaded: | |
| if st.button("β οΈ Restore from backup (overwrites current data)"): | |
| DB_PATH.write_bytes(uploaded.read()) | |
| if "db" in st.session_state: | |
| del st.session_state["db"] | |
| st.toast("β Database restored! Please refresh the page.", icon="β ") | |
| st.markdown("---") | |
| st.markdown("#### Danger Zone") | |
| st.markdown( | |
| "<div style='background:#1a0a0f; border:1px solid #4a1a2a; border-radius:12px; padding:1.25rem; margin-bottom:1rem;'>" | |
| "<p style='color:#f43f5e; font-weight:600; margin:0 0 8px;'>β οΈ Clear All Data</p>" | |
| "<p style='color:#9090b0; font-size:0.85rem; margin:0;'>This permanently deletes all tracked activity. This action cannot be undone.</p>" | |
| "</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| confirm = st.text_input("Type DELETE to confirm") | |
| if st.button("π Clear All Activity Data", type="secondary"): | |
| if confirm == "DELETE": | |
| db.clear_all_data() | |
| st.toast("All data cleared.") | |
| st.rerun() | |
| else: | |
| st.error("Please type DELETE to confirm.") | |
| # βββ Tab 4: Appearance ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab4: | |
| st.markdown("#### Theme") | |
| current_theme = db.get_setting("theme", "dark") | |
| theme = st.radio( | |
| "Color mode", | |
| ["dark", "light"], | |
| index=0 if current_theme == "dark" else 1, | |
| horizontal=True, | |
| ) | |
| if theme != current_theme: | |
| db.set_setting("theme", theme) | |
| st.toast("Theme updated β refresh to apply.") | |
| st.rerun() | |
| st.markdown("---") | |
| st.markdown("#### About FocusTrack") | |
| st.markdown(""" | |
| <div style="background:#16161f; border:1px solid #2a2a3a; border-radius:12px; padding:1.5rem;"> | |
| <h3 style="font-family:'Syne',sans-serif; margin:0 0 8px; color:#f0f0ff;">FocusTrack v1.0</h3> | |
| <p style="color:#9090b0; font-size:0.85rem; margin:0 0 4px;">100% local, privacy-first productivity tracker.</p> | |
| <p style="color:#9090b0; font-size:0.85rem; margin:0 0 4px;">Built with Python 3.11+ Β· Streamlit Β· SQLite Β· Plotly</p> | |
| <p style="color:#5a5a7a; font-size:0.8rem; margin:0;">No internet β’ No accounts β’ No cloud β’ Your data never leaves your device.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |