feat(home): multi-file upload + per-file analysis, drop tool grid

Home is now upload + analysis only. The page accepts multiple files in
one go, analyzes each independently, and renders findings grouped by
filename in bordered containers. The 3-section tool-card grid is gone —
discovery happens via the sidebar now.

Mechanics:
- file_uploader uses accept_multiple_files=True. Each file's findings
  cache in session_state["home_findings_by_file"] keyed by filename so
  removing a file via Streamlit's "x" button drops its findings too,
  and re-clicking Run only re-analyzes pending files.
- The first uploaded file is mirrored into the singular
  home_uploaded_{name,bytes,size} keys so tool pages continue to pick
  up an "active" upload through pickup_or_upload — no tool-page changes.
- New i18n keys: upload.intro_multi, upload.uploader_label_multi,
  upload.clear_results, upload.empty_state. upload.heading text is
  updated to "Upload one or more files to start" (EN + ES).

Dropped tests pinning the tool grid:
- TestHomeToolGridLocalization (test_chrome.py)
- test_home_tool_card_uses_es_name (test_smoke.py)
- TestLiteHomeGridBadges (test_lite_tier.py — locked-card lock-badge
  assertions; locking is still enforced per-tool-page via
  require_feature_or_render_upgrade)

2009 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:12:48 +00:00
parent dad744f17f
commit ff2eaeb6c4
6 changed files with 99 additions and 140 deletions

View File

@@ -30,20 +30,10 @@ if str(_project_root) not in sys.path:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _home_page() -> None: def _home_page() -> None:
"""Render the home page — upload section + tool grid + footer.""" """Render the home page — multi-file upload + per-file analysis."""
from src.gui.components import ( from src.gui.components import hide_streamlit_chrome, render_findings_panel
findings_count_for_tool, from src.gui.components._legacy import _run_analysis_on_upload
hide_streamlit_chrome,
upload_and_analyze_section,
)
from src.gui.tools_registry import (
TOOLS,
section_label,
tool_description,
tool_name,
)
from src.i18n import t from src.i18n import t
from src.license import get_manager
st.set_page_config( st.set_page_config(
page_title=t("home.page_title"), page_title=t("home.page_title"),
@@ -54,73 +44,95 @@ def _home_page() -> None:
st.title(t("home.title")) st.title(t("home.title"))
st.caption(t("home.caption")) st.caption(t("home.caption"))
st.divider() st.divider()
upload_and_analyze_section() st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
st.divider() uploaded_files = st.file_uploader(
t("upload.uploader_label_multi"),
# Per-tier feature set for badging — Ready tools that the user's type=["csv", "tsv", "xlsx", "xls"],
# tier doesn't unlock get a red "🔒 Locked" pill instead of the green accept_multiple_files=True,
# "Ready" so they see at a glance which tools an upgrade would key="home_upload",
# unlock. Dev-mode skips the lock check so the home grid renders the help=t("upload.uploader_help"),
# full SKU during development.
_license_state = get_manager().current_state()
_unlocked_features = (
set(_license_state.features) if _license_state.valid else set()
) )
_dev_mode = get_manager().dev_mode
# Group tool cards by sidebar section so the home grid mirrors the if not uploaded_files:
# left-nav layout — same vocabulary, same ordering. st.info(t("upload.empty_state"))
sections: list[tuple[str, list]] = [] return
for section in ("cleaners", "transformations", "automations"):
tools = [tool for tool in TOOLS if tool.section == section]
if not tools:
continue
sections.append((section_label(section), tools))
for header, tools in sections: # Keep tool pages working: they consume a single ``home_uploaded_*``
st.subheader(header) # set via ``pickup_or_upload``. Expose the first uploaded file as
for row_start in range(0, len(tools), 3): # the "active" upload for that contract; the rest live alongside
cols = st.columns(3) # for per-file analysis on this page.
for i, col in enumerate(cols): first = uploaded_files[0]
idx = row_start + i if (
if idx >= len(tools): st.session_state.get("home_uploaded_name") != first.name
break or st.session_state.get("home_uploaded_size") != first.size
tool = tools[idx] ):
with col: st.session_state["home_uploaded_name"] = first.name
tool_locked = ( st.session_state["home_uploaded_size"] = first.size
tool.status == "Ready" st.session_state["home_uploaded_bytes"] = first.getvalue()
and not _dev_mode
and tool.tool_id not in _unlocked_features
)
if tool_locked:
status_key = "license.status_locked"
status_color = "red"
elif tool.status == "Ready":
status_key = "status.ready"
status_color = "green"
else:
status_key = "status.coming_soon"
status_color = "orange"
badge = "" # Per-file findings live in a dict so removing a file from the
n = findings_count_for_tool(tool.tool_id) # uploader (Streamlit's "x" button) drops its results too. We only
if n: # re-analyze files we haven't already analyzed in this session.
badge_key = ( findings_by_file: dict = st.session_state.setdefault(
"home.findings_badge_one" "home_findings_by_file", {}
if n == 1 )
else "home.findings_badge_other" current_names = {f.name for f in uploaded_files}
) findings_by_file = {
badge = f" :red-background[**{t(badge_key, n=n)}**]" name: result for name, result in findings_by_file.items()
lock_glyph = "🔒 " if tool_locked else "" if name in current_names
st.markdown( }
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n" st.session_state["home_findings_by_file"] = findings_by_file
f"{tool_description(tool.tool_id)}\n\n"
f":{status_color}[**{lock_glyph}{t(status_key)}**]" pending = [f for f in uploaded_files if f.name not in findings_by_file]
)
col_run, col_clear, _ = st.columns([1, 1, 4])
with col_run:
run_clicked = st.button(
t("upload.run_button"),
type="primary",
key="home_run_analysis",
disabled=not pending,
use_container_width=True,
)
with col_clear:
clear_clicked = st.button(
t("upload.clear_results"),
key="home_clear_results",
disabled=not findings_by_file,
use_container_width=True,
)
if clear_clicked:
st.session_state["home_findings_by_file"] = {}
st.rerun()
if run_clicked:
progress = st.progress(0.0, text=t("upload.scanning"))
for i, f in enumerate(pending, start=1):
findings_by_file[f.name] = _run_analysis_on_upload(f)
progress.progress(i / len(pending), text=f"{f.name}")
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
# Preserve uploader order so the user sees results in the same
# order they appear in the file list above.
for f in uploaded_files:
if f.name not in findings_by_file:
continue
findings = findings_by_file[f.name]
with st.container(border=True):
if not findings:
st.markdown(f"### 📄 {f.name}")
st.success(t("findings.none"))
else:
render_findings_panel(findings, header=f"📄 {f.name}")
st.divider() st.divider()
st.caption(t("chrome.footer")) st.caption(t("chrome.footer"))

View File

@@ -15,7 +15,7 @@
"coming_soon": "Coming Soon" "coming_soon": "Coming Soon"
}, },
"upload": { "upload": {
"heading": "📤 Upload a file to start", "heading": "📤 Upload one or more files to start",
"intro": "Optional: scan an uploaded file for data quality issues and see which tools can fix each one. Skip if you already know what you need.", "intro": "Optional: scan an uploaded file for data quality issues and see which tools can fix each one. Skip if you already know what you need.",
"limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.", "limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.",
"uploader_label": "Upload CSV or Excel", "uploader_label": "Upload CSV or Excel",
@@ -27,7 +27,11 @@
"using_session_file": "Using **{name}** from the upload screen.", "using_session_file": "Using **{name}** from the upload screen.",
"use_different_file": "Use a different file", "use_different_file": "Use a different file",
"switch_back": "Switch back to upload-screen file", "switch_back": "Switch back to upload-screen file",
"pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page." "pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page.",
"intro_multi": "Drop files below. Each one is analyzed locally — no upload anywhere.",
"uploader_label_multi": "Upload CSV, TSV, or Excel files",
"clear_results": "Clear results",
"empty_state": "Upload one or more files to begin. Your data never leaves this computer."
}, },
"findings": { "findings": {
"header": "Detected issues", "header": "Detected issues",

View File

@@ -15,7 +15,7 @@
"coming_soon": "Próximamente" "coming_soon": "Próximamente"
}, },
"upload": { "upload": {
"heading": "📤 Sube un archivo para empezar", "heading": "📤 Sube uno o más archivos para empezar",
"intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.", "intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.",
"limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.", "limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.",
"uploader_label": "Sube un archivo CSV o Excel", "uploader_label": "Sube un archivo CSV o Excel",
@@ -27,7 +27,11 @@
"using_session_file": "Usando **{name}** de la pantalla de carga.", "using_session_file": "Usando **{name}** de la pantalla de carga.",
"use_different_file": "Usar otro archivo", "use_different_file": "Usar otro archivo",
"switch_back": "Volver al archivo de la pantalla de carga", "switch_back": "Volver al archivo de la pantalla de carga",
"pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar." "pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar.",
"intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — no se sube a ningún lado.",
"uploader_label_multi": "Sube archivos CSV, TSV o Excel",
"clear_results": "Borrar resultados",
"empty_state": "Sube uno o más archivos para empezar. Tus datos nunca salen de este equipo."
}, },
"findings": { "findings": {
"header": "Problemas detectados", "header": "Problemas detectados",

View File

@@ -114,8 +114,8 @@ class TestLocalizedChrome:
with_language(home_app, "es") with_language(home_app, "es")
home_app.run() home_app.run()
text = collected_text(home_app) text = collected_text(home_app)
# ``📤 Sube un archivo para empezar`` from the es pack. # ``📤 Sube uno o más archivos para empezar`` from the es pack.
assert "Sube un archivo" in text assert "Sube uno o más archivos" in text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -157,25 +157,3 @@ class TestQuitButtonRenders:
assert "trabajo sin guardar" in text assert "trabajo sin guardar" in text
# ---------------------------------------------------------------------------
# Tool cards use localized names on the home grid
# ---------------------------------------------------------------------------
class TestHomeToolGridLocalization:
"""The home grid pulls tool display names through ``tool_name()`` in
``tools_registry``. The Spanish pack provides translations for every
tool id; a regression in that wiring would make Spanish users see
English names. Pin a few representative ones."""
@pytest.mark.parametrize("needle", [
"Buscar duplicados",
"Limpiar texto",
"Estandarizar formatos",
"Corregir valores faltantes",
"Mapear columnas",
])
def test_es_tool_name_on_home_grid(self, home_app, needle):
with_language(home_app, "es")
home_app.run()
text = collected_text(home_app)
assert needle in text, f"missing localized tool name {needle!r}"

View File

@@ -94,36 +94,6 @@ class TestLiteLockedPages:
) )
# ---------------------------------------------------------------------------
# Home grid shows lock badges
# ---------------------------------------------------------------------------
class TestLiteHomeGridBadges:
def test_locked_tool_card_shows_lock_badge(
self, lite_license, home_app,
):
home_app.run()
text = collected_text(home_app)
# Fix Missing Values is locked under Lite — its card should
# have a 🔒 Locked badge.
# We assert the lock glyph appears alongside the locked tool's
# display name. Streamlit renders the markdown verbatim so the
# ``🔒 Locked`` text appears in the page markdown stream.
assert "🔒" in text or "Locked" in text, (
"home grid missing lock badge for Lite-locked tool"
)
def test_unlocked_tool_card_no_lock(self, lite_license, home_app):
home_app.run()
# Dedup is unlocked under Lite. Its card markdown should NOT
# contain a lock glyph adjacent to its name. We can't easily
# scope by card without parsing the markdown stream, but we
# can confirm both ``Ready`` (unlocked) and ``Locked``
# (locked) badges coexist on the page.
text = collected_text(home_app)
assert "Ready" in text or "Listo" in text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sidebar status shows Lite # Sidebar status shows Lite
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -87,15 +87,6 @@ class TestHomePageRenders:
text = collected_text(home_app) text = collected_text(home_app)
assert "Tus datos nunca salen" in text or "Se ejecuta localmente" in text assert "Tus datos nunca salen" in text or "Se ejecuta localmente" in text
def test_home_tool_card_uses_es_name(self, home_app):
"""When the home grid renders in Spanish, the dedup card title
must use the Spanish display name, not the English fallback."""
with_language(home_app, "es")
home_app.run()
text = collected_text(home_app)
assert "Buscar duplicados" in text
class TestEveryPageRenders: class TestEveryPageRenders:
"""Parametrize over (page, language). Failure tells you exactly which """Parametrize over (page, language). Failure tells you exactly which
page + which language broke.""" page + which language broke."""