Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 60 additions & 48 deletions finance_tracker/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import html as _html
import json
import streamlit as st
import streamlit.components.v1 as st_components
import os
from finance_tracker.web.db import get_session, get_db_path, get_engine
from finance_tracker.web.navigation import build_pages
Expand Down Expand Up @@ -118,60 +119,71 @@ def render_db_manager():

st.rerun()

# Animated arrow hinting at the sidebar toggle after 20 s of inactivity.
# st.html() (Streamlit ≥1.36) renders inline and executes <script> tags
# directly in the main window — no iframe, no window.parent needed.
# The full innerHTML is built in Python so translation is guaranteed.
# _sbShowing guards the poll so it only dismisses after the hint is visible,
# preventing the poll from firing sbVisited before the hint ever appears.
# Animated ↖ arrow hinting at the sidebar toggle after 20 s of inactivity.
#
# Architecture note: both st.html() and st.components.v1.html() render
# inside an iframe. Code in an iframe cannot reliably set long-lived timers
# because the iframe may be destroyed on Streamlit re-renders. The solution
# is to inject a <script> element into window.parent.document.head: inline
# scripts execute immediately and synchronously in the MAIN window context,
# so their timers survive re-renders. The iframe script runs once (guarded
# by window.parent._sbHintInit) and delegates everything else to the
# main-context script.
_hint_inner = json.dumps(
'<div class="_a">&#x2196;</div>'
f'<div class="_t">{_html.escape(t("app.sidebar_hint"))}</div>'
)
st.html(f"""<script>
# Plain Python string — no f-string, so { } are JS literals, no escaping.
_main_script = (
"(function(){"
"if(window._sbRunInit)return;"
"window._sbRunInit=true;"
"if(sessionStorage.getItem('sbVisited'))return;"
"var s=document.createElement('style');"
"s.textContent='@keyframes sb-bounce{0%,100%{transform:translate(0,0);opacity:1}"
"50%{transform:translate(-9px,-9px);opacity:.75}}"
"#_sb_hint{display:none;position:fixed;top:68px;left:44px;z-index:99999;"
"flex-direction:column;align-items:flex-start;gap:3px;pointer-events:none}"
"#_sb_hint ._a{font-size:22px;color:#0088ff;"
"animation:sb-bounce 1.1s ease-in-out infinite;line-height:1}"
"#_sb_hint ._t{background:#0088ff;color:#fff;padding:3px 9px;"
"border-radius:10px;font-size:11px;white-space:nowrap;"
"font-family:sans-serif;font-weight:600;"
"box-shadow:0 2px 6px rgba(0,136,255,.4)}';"
"document.head.appendChild(s);"
"var h=document.createElement('div');"
"h.id='_sb_hint';"
f"h.innerHTML={_hint_inner};"
"document.body.appendChild(h);"
"function closed(){"
"var b=document.querySelector('[data-testid=\"stSidebarCollapsedControl\"]');"
"return !!b&&getComputedStyle(b).display!=='none';}"
"function dismiss(){"
"sessionStorage.setItem('sbVisited','1');"
"window._sbShowing=false;"
"h.style.display='none';"
"if(window._sbTimer){clearTimeout(window._sbTimer);window._sbTimer=null;}"
"if(window._sbPoll){clearInterval(window._sbPoll);window._sbPoll=null;}}"
"window._sbPoll=setInterval(function(){"
"if(sessionStorage.getItem('sbVisited')){clearInterval(window._sbPoll);return;}"
"if(window._sbShowing&&!closed())dismiss();},400);"
"window._sbTimer=setTimeout(function(){"
"if(!sessionStorage.getItem('sbVisited')&&closed()){"
"h.style.display='flex';window._sbShowing=true;}},20000);"
"})()"
)
_main_script_json = json.dumps(_main_script)
st_components.html(f"""<script>
(function(){{
if(window._sbHintInit)return;
window._sbHintInit=true;
if(sessionStorage.getItem('sbVisited'))return;
var s=document.createElement('style');
s.textContent='@keyframes sb-bounce{{0%,100%{{transform:translate(0,0);opacity:1}}'
+'50%{{transform:translate(-9px,-9px);opacity:.75}}}}'
+'#_sb_hint{{display:none;position:fixed;top:68px;left:44px;z-index:99999;'
+'flex-direction:column;align-items:flex-start;gap:3px;pointer-events:none}}'
+'#_sb_hint ._a{{font-size:22px;color:#0088ff;'
+'animation:sb-bounce 1.1s ease-in-out infinite;line-height:1}}'
+'#_sb_hint ._t{{background:#0088ff;color:#fff;padding:3px 9px;'
+'border-radius:10px;font-size:11px;white-space:nowrap;'
+'font-family:sans-serif;font-weight:600;'
+'box-shadow:0 2px 6px rgba(0,136,255,.4)}}';
document.head.appendChild(s);
var h=document.createElement('div');
h.id='_sb_hint';
h.innerHTML={_hint_inner};
document.body.appendChild(h);
function sidebarIsClosed(){{
var btn=document.querySelector('[data-testid="stSidebarCollapsedControl"]');
return !!btn&&getComputedStyle(btn).display!=='none';
}}
function dismiss(){{
sessionStorage.setItem('sbVisited','1');
window._sbShowing=false;
h.style.display='none';
if(window._sbTimer){{clearTimeout(window._sbTimer);window._sbTimer=null;}}
if(window._sbPoll){{clearInterval(window._sbPoll);window._sbPoll=null;}}
}}
window._sbPoll=setInterval(function(){{
if(sessionStorage.getItem('sbVisited')){{clearInterval(window._sbPoll);return;}}
if(window._sbShowing&&!sidebarIsClosed())dismiss();
}},400);
window._sbTimer=setTimeout(function(){{
if(!sessionStorage.getItem('sbVisited')&&sidebarIsClosed()){{
h.style.display='flex';
window._sbShowing=true;
}}
}},20000);
var p=window.parent;
if(!p||p._sbHintInit)return;
p._sbHintInit=true;
var ps=p.document.createElement('script');
ps.textContent={_main_script_json};
p.document.head.appendChild(ps);
ps.remove();
}})();
</script>""")
</script>""", height=0)

# Show documentation (needs no DB) so users aren't stranded on a blank page
from finance_tracker.web.views.documentation import render as doc_render
Expand Down
Loading