Commit Graph

6 Commits

Author SHA1 Message Date
dd231f5a38 fix(footer): render sticky Close+Help footer on the home page too
The sticky footer was only wired into the 9 tool pages — the home
page (``_home.py``) called ``hide_streamlit_chrome`` but never
``render_sticky_footer``, so the app-level Close+Help bar was
missing whenever the user was on the home page. Add the call.

Also drop the home page's now-redundant trailing
``st.divider() + st.caption(t("chrome.footer"))`` block — same
"blank white bar above the sticky footer" symptom that motivated
removing the per-page version from the tool pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:32:16 +00:00
36510eee7b fix(findings): namespace per-tool button keys so multi-file render works
Reported: uploading multiple files on the home page and clicking Run
analysis blew up with

    StreamlitDuplicateElementKey: key='_findings_open_02_text_cleaner'

when two uploaded files both had Clean Text findings.

Root cause: ``render_findings_panel`` is invoked once per uploaded
file from ``_home.py``, but the per-tool jump button used a
filename-agnostic key:

    key=f"_findings_open_{tool_id}"

Two files both flagging Clean Text → two buttons with identical keys
→ Streamlit rejects the second one.

Fix:

- Add ``key_namespace: str = ""`` to ``render_findings_panel``. The
  helper hashes it (sha1 truncated to 8 chars) and appends to every
  button key, so different namespaces produce different keys but the
  same namespace stays stable across reruns.
- The home page now passes the filename:
  ``render_findings_panel(findings, header=f"📄 {name}", key_namespace=name)``.
- The single-call site in ``upload_and_analyze_section`` (the legacy
  helper, only used outside the new home-page path) keeps the default
  empty namespace, which is fine because that path renders findings
  for ONE file at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:17:03 +00:00
c73d716d06 feat(audit): JSONL audit log for support diagnostics
New ``src/audit.py`` module records GUI actions to a per-session
JSONL file under ``~/.datatools/logs/`` (overrideable via
``DATATOOLS_AUDIT_DIR``). The file is human-readable (one JSON
object per line, each with a ``message`` field) AND trivially
machine-parseable — the support flow is "client mails the file,
we read it and explain what went wrong."

Format example::

    {"ts":"2026-05-17T05:30:00.123+00:00","level":"info","category":"session",
     "session":"a1b2c3d4","message":"Session started",
     "platform":"Windows 11","python":"3.14.0","user":"Michael Dombaugh",
     "log_file":"C:\\Users\\Michael Dombaugh\\.datatools\\logs\\datatools-...jsonl"}
    {"ts":"...","category":"upload","message":"Uploaded customers.csv",
     "filename":"customers.csv","bytes":24813}
    {"ts":"...","category":"analyze","message":"Analyzed customers.csv (3 findings)",
     "filename":"customers.csv","findings":3,"rows":120,"cols":8}
    {"ts":"...","category":"tool_run","message":"Clean Text run",
     "page":"2_Text_Cleaner"}
    {"ts":"...","category":"error","level":"error",
     "message":"analyze(weird.csv): EmptyDataError: No columns to parse",
     "filename":"weird.csv","outcome":"empty_after_repair"}

Public API:

- ``log_event(category, message, **extra)``
- ``log_session_start()`` — idempotent banner with platform info
- ``log_page_open(slug)`` — emit a ``nav`` event, deduplicated per
  Streamlit session so reruns don't spam the log
- ``log_exception(where, exc, **extra)`` — convenience wrapper
- ``audit_log_path()`` / ``audit_log_dir()`` — for the UI

Wired in at:

- ``hide_streamlit_chrome``: stamps session start, mounts a small
  "🩺  Diagnostics" expander in the sidebar with the log path and
  an "Open log folder" button so the user can grab the file to
  attach to a support email.
- Home page: ``upload`` event on every new file, ``upload`` event
  on per-file remove, ``analyze`` event with file count when
  Run-analysis fires.
- ``_run_analysis_on_upload``: ``analyze`` event with rows / cols /
  findings count per file, plus ``error`` events on every caught
  exception (empty upload, empty after repair, pandas EmptyDataError,
  generic Exception).
- Every Ready tool page (1, 2, 3, 4, 5, 9): ``tool_run`` event
  immediately after the primary action stashes its result.
- Every tool page (1-9): ``log_page_open(slug)`` on render — deduped
  via session state so we don't get one event per Streamlit rerun.

Safety:

- ``log_event`` wraps every write in try/except. A broken audit
  log must NOT crash the GUI.
- Non-JSON-serializable extras are ``str()``-coerced before writing.
- File CONTENTS are never logged. We capture filename, byte count,
  and (in the analyzer) a 12-char sha1 fingerprint of the bytes so
  the same file re-uploaded gets the same trace.
- License keys, session cookies, etc. are not logged.
- ``DATATOOLS_AUDIT_DIR`` env var lets tests redirect writes into a
  tmp dir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:36:35 +00:00
84e4665ab0 fix(home): make per-file Remove button reliable
Reported: the "✕" buttons on the uploaded file list removed files
inconsistently — some clicks took, some didn't.

Two compounding causes:

1. ``key=f"_home_remove_{name}"`` embedded the raw filename in the
   Streamlit widget key. Streamlit's widget-identity machinery
   normalizes keys differently across reruns when they contain
   spaces, dots, brackets, or non-ASCII characters, so a button's
   identity could shift between the render where the user clicked
   it and the rerun that should have processed the click. The click
   was registered, but the post-rerun render produced a new widget
   under a new effective key, and the original click was "lost".

2. The handler mutated ``home_uploads`` mid-loop while subsequent
   iterations were still creating buttons. ``st.rerun()`` raises
   synchronously, but if ANOTHER button's state changed in the same
   pass (e.g. a stale click held over from a fast double-tap), the
   ordering of state-mutation vs widget-key-update vs rerun could
   race.

Fixes:

- Stable widget keys: ``f"_home_remove_{sha1(name)[:10]}"``. The
  hash is identifier-safe regardless of spaces / dots / Unicode in
  the filename. Verified across "sample with spaces.csv",
  "sample.csv", and "日本語.csv" — three sequential Remove clicks
  each remove exactly one file with no clicks lost.

- Two-phase capture: the loop collects the target ``to_remove``
  filename, finishes rendering every other row at consistent widget
  identity, THEN mutates state once and reruns. No more mid-loop
  ``del`` racing other widgets' click handlers.

- Wider click target: column ratio ``[8, 1]`` (was ``[12, 1]``) and
  ``use_container_width=True`` on the Remove button so the click
  surface fills the entire column. Label changed to "Remove" for
  the same reason — "✕" is a thin glyph that compressed the
  hit-test region.

2220 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:34:20 +00:00
ecfc52499f fix(home): persist upload list across page navigation
Reported: clicking "Back to Home" from a tool page returned the user
to an empty home — their previously-uploaded files were gone.

Root cause: Streamlit's ``st.file_uploader`` widget state does not
reliably survive ``st.switch_page``. The widget gets unmounted on
navigation, and its ``UploadedFile`` objects don't always re-attach
on remount. The home page was treating the widget's return value as
the source of truth, so after navigation the list was empty.

Fix: introduce a session-state stash keyed by filename
(``home_uploads: dict[str, {"bytes": bytes, "size": int}]``) and
treat it as the source of truth for everything downstream — the
active-file pickup keys for tool pages, the per-file findings
cache, and the rendered file list. The widget is reduced to its
narrow role of capturing NEW uploads, which we merge into the stash
without ever removing.

Per-file remove: a "✕" button next to each filename drops just that
file (and its findings). The widget's own "✕" is bypassed by our
rendering, since trusting it would let the widget's state diverge
from the stash.

Clear-results button is unchanged: it wipes only the analysis cache,
leaving uploaded files intact (per the user's "persistent until
cleared" requirement — removal is per-file via "✕").

Tool-page compatibility: the singular ``home_uploaded_{name,size,
bytes}`` keys still get populated from the first entry in the stash
on every render, so ``pickup_or_upload`` on a tool page keeps
finding the active upload. When the user removes the active file,
those keys are cleared so the next render repopulates from whatever
file is now first.

``_StashedUpload`` is a small duck type ( ``.name``, ``.size``,
``.getvalue()`` ) so ``_run_analysis_on_upload`` accepts entries
restored from the stash without changes.

2220 tests pass. Smoke-verified via AppTest: pre-stashed
``home_uploads`` renders the file list with per-file remove buttons,
and the persistent state survives a simulated navigation round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:04:12 +00:00
21fd8a4cd7 fix(nav): switch_page resolves correctly + bottom-of-page back link
Two issues, same fix surface.

(1) Reported crash on Back-to-Home:

    StreamlitAPIException: Could not find page: app.py.

``st.switch_page("app.py")`` doesn't work under ``st.navigation`` —
the entry script is the nav manager itself and is not a registered
page. The fix needs to pass an ``st.Page`` object whose script
identity matches one registered in the nav.

First-pass attempt (``from src.gui.app import _home_page``) hit a
worse failure: importing ``app.py`` from inside a tool-page render
re-executes the nav setup with the WRONG "main script" context, so
every ``st.Page("pages/N_foo.py", ...)`` call in ``_build_navigation``
fails with "file could not be found".

Extract the home renderer into its own module ``src/gui/_home.py``
which has no top-level Streamlit side effects. Both the nav manager
and the back-link helper import ``_home_page`` from there. The Page
object built at click time has the same callable identity as the one
registered, so ``st.switch_page`` resolves it.

(2) Reported UX: the back button scrolled out of view on long pages.

Add a second ``back_to_home_link(key="_back_to_home_link_bottom")``
call near the footer of every tool page (1-9). The unique key avoids
widget-id collision with the top instance. Coming-Soon stubs get it
unconditionally; Ready tools render it only after a result exists
because the page short-circuits with ``st.stop()`` before then —
when no result is on screen the page is short enough that the top
link is sufficient.

2220 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:58:33 +00:00