# Pick-Style Console System - Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a keyboard-driven Pick/VT220-inspired console application for the autoparts catalog with two rendering modes (curses and textual). **Architecture:** Three-layer architecture — DB layer (SQLite, swappable to PostgreSQL), business logic screens, and pluggable renderers (curses for VT220 aesthetic, textual for modern TUI). All screens define data/actions abstractly; renderers handle drawing. **Tech Stack:** Python 3, curses (stdlib), textual/rich (pip), sqlite3 (stdlib), requests (for NHTSA VIN API) --- ## Task 1: Project Scaffold and Config **Files:** - Create: `console/__init__.py` - Create: `console/main.py` - Create: `console/config.py` - Create: `console/core/__init__.py` - Create: `console/screens/__init__.py` - Create: `console/renderers/__init__.py` - Create: `console/utils/__init__.py` **Step 1: Create directory structure** ```bash mkdir -p /home/Autopartes/console/{core,screens,renderers,utils} touch /home/Autopartes/console/__init__.py touch /home/Autopartes/console/core/__init__.py touch /home/Autopartes/console/screens/__init__.py touch /home/Autopartes/console/renderers/__init__.py touch /home/Autopartes/console/utils/__init__.py ``` **Step 2: Write config.py** ```python # console/config.py import os VERSION = "1.0.0" APP_NAME = "AUTOPARTES" APP_SUBTITLE = "Sistema de Catálogo de Autopartes" # Database DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vehicle_database', 'vehicle_database.db') # VIN API NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin" VIN_CACHE_DAYS = 30 # Display DEFAULT_MODE = "vt220" # "vt220" or "modern" PAGE_SIZE = 15 # rows per page in lists/tables # Color schemes for curses (name: (fg, bg)) COLORS_VT220 = { 'header': ('green', 'black'), 'footer': ('black', 'green'), 'normal': ('green', 'black'), 'highlight': ('black', 'green'), 'border': ('green', 'black'), 'title': ('white', 'black'), 'error': ('red', 'black'), 'info': ('cyan', 'black'), 'field_label': ('green', 'black'), 'field_value': ('white', 'black'), 'field_active': ('black', 'cyan'), } ``` **Step 3: Write main.py entry point** ```python # console/main.py import argparse import sys from console.config import VERSION, DEFAULT_MODE def parse_args(): parser = argparse.ArgumentParser(description='Autopartes Console - Pick-Style Catalog System') parser.add_argument('--mode', choices=['vt220', 'modern'], default=DEFAULT_MODE, help='Rendering mode: vt220 (curses) or modern (textual)') parser.add_argument('--version', action='version', version=f'Autopartes Console v{VERSION}') return parser.parse_args() def main(): args = parse_args() if args.mode == 'vt220': from console.renderers.curses_renderer import CursesRenderer renderer = CursesRenderer() else: from console.renderers.textual_renderer import TextualRenderer renderer = TextualRenderer() from console.db import Database from console.core.app import App db = Database() app = App(renderer, db) app.run() if __name__ == '__main__': main() ``` **Step 4: Commit** ```bash git add console/ git commit -m "feat(console): scaffold project structure and config" ``` --- ## Task 2: Database Abstraction Layer **Files:** - Create: `console/db.py` - Create: `console/tests/__init__.py` - Create: `console/tests/test_db.py` **Step 1: Write test for DB layer** ```python # console/tests/test_db.py import os import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) from console.db import Database @pytest.fixture def db(): return Database() def test_get_brands(db): brands = db.get_brands() assert isinstance(brands, list) assert len(brands) > 0 assert 'name' in brands[0] def test_get_models_no_filter(db): models = db.get_models() assert isinstance(models, list) assert len(models) > 0 def test_get_models_by_brand(db): models = db.get_models(brand='TOYOTA') assert isinstance(models, list) assert len(models) > 0 def test_get_categories(db): cats = db.get_categories() assert isinstance(cats, list) assert len(cats) == 12 def test_get_groups(db): groups = db.get_groups(category_id=2) assert isinstance(groups, list) assert len(groups) > 0 assert 'name' in groups[0] def test_get_parts_by_group(db): parts = db.get_parts(group_id=17) assert isinstance(parts, list) def test_get_part_detail(db): part = db.get_part(1) assert part is not None assert 'oem_part_number' in part def test_get_alternatives(db): alts = db.get_alternatives(1) assert isinstance(alts, list) def test_get_cross_references(db): refs = db.get_cross_references(1) assert isinstance(refs, list) def test_search_parts_fts(db): results = db.search_parts('brake') assert isinstance(results, list) assert len(results) > 0 def test_search_part_number(db): results = db.search_part_number('04465') assert isinstance(results, list) def test_get_stats(db): stats = db.get_stats() assert 'brands' in stats assert 'models' in stats assert 'parts' in stats assert stats['brands'] > 0 def test_get_years(db): years = db.get_years(brand='TOYOTA', model='CAMRY') assert isinstance(years, list) def test_get_engines(db): engines = db.get_engines(brand='TOYOTA', model='CAMRY') assert isinstance(engines, list) ``` **Step 2: Run test to verify it fails** ```bash cd /home/Autopartes && python -m pytest console/tests/test_db.py -v ``` Expected: FAIL — `ModuleNotFoundError: No module named 'console.db'` **Step 3: Implement db.py** ```python # console/db.py import sqlite3 import os from console.config import DB_PATH class Database: def __init__(self, db_path=None): self.db_path = db_path or DB_PATH def _connect(self): conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def _query(self, sql, params=(), one=False): conn = self._connect() cursor = conn.cursor() cursor.execute(sql, params) rows = cursor.fetchall() conn.close() if one: return dict(rows[0]) if rows else None return [dict(r) for r in rows] def _execute(self, sql, params=()): conn = self._connect() cursor = conn.cursor() cursor.execute(sql, params) conn.commit() last_id = cursor.lastrowid conn.close() return last_id # === Vehicle Navigation === def get_brands(self): return self._query(""" SELECT id, name, country FROM brands ORDER BY name """) def get_models(self, brand=None): if brand: return self._query(""" SELECT DISTINCT m.id, m.name FROM models m JOIN brands b ON m.brand_id = b.id WHERE UPPER(b.name) = UPPER(?) ORDER BY m.name """, (brand,)) return self._query("SELECT id, name FROM models ORDER BY name") def get_years(self, brand=None, model=None): sql = """ SELECT DISTINCT y.id, y.year FROM years y JOIN model_year_engine mye ON mye.year_id = y.id JOIN models m ON mye.model_id = m.id JOIN brands b ON m.brand_id = b.id WHERE 1=1 """ params = [] if brand: sql += " AND UPPER(b.name) = UPPER(?)" params.append(brand) if model: sql += " AND UPPER(m.name) = UPPER(?)" params.append(model) sql += " ORDER BY y.year DESC" return self._query(sql, params) def get_engines(self, brand=None, model=None, year=None): sql = """ SELECT DISTINCT e.id, e.name, e.displacement_cc, e.cylinders, e.fuel_type, e.power_hp, e.torque_nm FROM engines e JOIN model_year_engine mye ON mye.engine_id = e.id JOIN models m ON mye.model_id = m.id JOIN brands b ON m.brand_id = b.id WHERE 1=1 """ params = [] if brand: sql += " AND UPPER(b.name) = UPPER(?)" params.append(brand) if model: sql += " AND UPPER(m.name) = UPPER(?)" params.append(model) if year: sql += " AND mye.year_id IN (SELECT id FROM years WHERE year = ?)" params.append(int(year)) sql += " ORDER BY e.name" return self._query(sql, params) def get_model_year_engine(self, brand, model, year, engine_id): return self._query(""" SELECT mye.id, mye.trim_level, mye.drivetrain, mye.transmission FROM model_year_engine mye JOIN models m ON mye.model_id = m.id JOIN brands b ON m.brand_id = b.id JOIN years y ON mye.year_id = y.id WHERE UPPER(b.name) = UPPER(?) AND UPPER(m.name) = UPPER(?) AND y.year = ? AND mye.engine_id = ? """, (brand, model, int(year), engine_id)) # === Parts Catalog === def get_categories(self): return self._query(""" SELECT id, name, name_es, icon_name, slug, display_order FROM part_categories ORDER BY display_order """) def get_groups(self, category_id): return self._query(""" SELECT id, name, name_es, slug, display_order FROM part_groups WHERE category_id = ? ORDER BY display_order """, (category_id,)) def get_parts(self, group_id=None, mye_id=None, page=1, per_page=15): offset = (page - 1) * per_page if mye_id and group_id: return self._query(""" SELECT p.id, p.oem_part_number, p.name, p.name_es, pg.name AS group_name, vp.quantity_required, vp.position, (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count FROM parts p JOIN vehicle_parts vp ON vp.part_id = p.id JOIN part_groups pg ON p.group_id = pg.id WHERE vp.model_year_engine_id = ? AND p.group_id = ? ORDER BY p.name LIMIT ? OFFSET ? """, (mye_id, group_id, per_page, offset)) elif mye_id: return self._query(""" SELECT p.id, p.oem_part_number, p.name, p.name_es, pg.name AS group_name, vp.quantity_required, vp.position, (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count FROM parts p JOIN vehicle_parts vp ON vp.part_id = p.id JOIN part_groups pg ON p.group_id = pg.id WHERE vp.model_year_engine_id = ? ORDER BY pg.display_order, p.name LIMIT ? OFFSET ? """, (mye_id, per_page, offset)) elif group_id: return self._query(""" SELECT p.id, p.oem_part_number, p.name, p.name_es, pg.name AS group_name, (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count FROM parts p JOIN part_groups pg ON p.group_id = pg.id WHERE p.group_id = ? ORDER BY p.name LIMIT ? OFFSET ? """, (group_id, per_page, offset)) return self._query(""" SELECT p.id, p.oem_part_number, p.name, p.name_es, pg.name AS group_name, (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count FROM parts p JOIN part_groups pg ON p.group_id = pg.id ORDER BY p.name LIMIT ? OFFSET ? """, (per_page, offset)) def get_part(self, part_id): return self._query(""" SELECT p.*, pg.name AS group_name, pg.category_id, pc.name AS category_name, pc.name_es AS category_name_es FROM parts p JOIN part_groups pg ON p.group_id = pg.id JOIN part_categories pc ON pg.category_id = pc.id WHERE p.id = ? """, (part_id,), one=True) def get_alternatives(self, part_id): return self._query(""" SELECT ap.*, m.name AS manufacturer_name, m.country AS manufacturer_country FROM aftermarket_parts ap JOIN manufacturers m ON ap.manufacturer_id = m.id WHERE ap.oem_part_id = ? ORDER BY CASE ap.quality_tier WHEN 'premium' THEN 1 WHEN 'standard' THEN 2 WHEN 'economy' THEN 3 ELSE 4 END """, (part_id,)) def get_cross_references(self, part_id): return self._query(""" SELECT * FROM part_cross_references WHERE part_id = ? ORDER BY reference_type """, (part_id,)) # === Search === def search_parts(self, query, page=1, per_page=15): offset = (page - 1) * per_page return self._query(""" SELECT p.id, p.oem_part_number, p.name, p.name_es, pg.name AS group_name, pc.name AS category_name FROM parts_fts fts JOIN parts p ON fts.rowid = p.id JOIN part_groups pg ON p.group_id = pg.id JOIN part_categories pc ON pg.category_id = pc.id WHERE parts_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ? """, (query, per_page, offset)) def search_part_number(self, number): like = f'%{number}%' results = [] # OEM oem = self._query(""" SELECT p.id, p.oem_part_number AS part_number, p.name, p.name_es, 'OEM' AS type, (SELECT b.name FROM brands b JOIN models m ON m.brand_id = b.id JOIN model_year_engine mye ON mye.model_id = m.id JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id WHERE vp.part_id = p.id LIMIT 1) AS source FROM parts p WHERE p.oem_part_number LIKE ? """, (like,)) results.extend(oem) # Aftermarket after = self._query(""" SELECT ap.id, ap.part_number, ap.name, ap.name_es, 'AFTERMARKET' AS type, m.name AS source FROM aftermarket_parts ap JOIN manufacturers m ON ap.manufacturer_id = m.id WHERE ap.part_number LIKE ? """, (like,)) results.extend(after) # Cross-references xref = self._query(""" SELECT pcr.id, pcr.cross_reference_number AS part_number, p.name, p.name_es, 'CROSS-REF (' || pcr.reference_type || ')' AS type, pcr.source FROM part_cross_references pcr JOIN parts p ON pcr.part_id = p.id WHERE pcr.cross_reference_number LIKE ? """, (like,)) results.extend(xref) return results # === VIN === def get_vin_cache(self, vin): return self._query(""" SELECT * FROM vin_cache WHERE vin = ? AND expires_at > datetime('now') """, (vin,), one=True) def save_vin_cache(self, vin, data, make, model, year, engine_info, body_class, drive_type): self._execute(""" INSERT OR REPLACE INTO vin_cache (vin, decoded_data, make, model, year, engine_info, body_class, drive_type, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+30 days')) """, (vin, data, make, model, year, engine_info, body_class, drive_type)) # === Stats === def get_stats(self): stats = {} tables = ['brands', 'models', 'years', 'engines', 'model_year_engine', 'part_categories', 'part_groups', 'parts', 'vehicle_parts', 'manufacturers', 'aftermarket_parts', 'part_cross_references', 'diagrams', 'diagram_hotspots', 'vin_cache'] conn = self._connect() cursor = conn.cursor() for table in tables: cursor.execute(f"SELECT COUNT(*) FROM [{table}]") stats[table] = cursor.fetchone()[0] # Top brands with parts cursor.execute(""" SELECT b.name, COUNT(vp.id) AS cnt FROM brands b JOIN models m ON m.brand_id = b.id JOIN model_year_engine mye ON mye.model_id = m.id JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id GROUP BY b.name ORDER BY cnt DESC LIMIT 5 """) stats['top_brands'] = [dict(r) for r in cursor.fetchall()] conn.close() return stats # === Admin CRUD === def create_part(self, data): return self._execute(""" INSERT INTO parts (oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'], data.get('description'), data.get('description_es'), data.get('weight_kg'), data.get('material'))) def update_part(self, part_id, data): self._execute(""" UPDATE parts SET oem_part_number=?, name=?, name_es=?, group_id=?, description=?, description_es=?, weight_kg=?, material=?, is_discontinued=? WHERE id=? """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'], data.get('description'), data.get('description_es'), data.get('weight_kg'), data.get('material'), data.get('is_discontinued', 0), part_id)) def delete_part(self, part_id): self._execute("DELETE FROM parts WHERE id=?", (part_id,)) def create_manufacturer(self, data): return self._execute(""" INSERT INTO manufacturers (name, type, quality_tier, country, website) VALUES (?, ?, ?, ?, ?) """, (data['name'], data.get('type', 'aftermarket'), data.get('quality_tier', 'standard'), data.get('country'), data.get('website'))) def update_manufacturer(self, mfr_id, data): self._execute(""" UPDATE manufacturers SET name=?, type=?, quality_tier=?, country=?, website=? WHERE id=? """, (data['name'], data.get('type'), data.get('quality_tier'), data.get('country'), data.get('website'), mfr_id)) def delete_manufacturer(self, mfr_id): self._execute("DELETE FROM manufacturers WHERE id=?", (mfr_id,)) def get_manufacturers(self): return self._query("SELECT * FROM manufacturers ORDER BY name") def create_crossref(self, data): return self._execute(""" INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source, notes) VALUES (?, ?, ?, ?, ?) """, (data['part_id'], data['cross_reference_number'], data.get('reference_type', 'interchange'), data.get('source'), data.get('notes'))) def update_crossref(self, xref_id, data): self._execute(""" UPDATE part_cross_references SET part_id=?, cross_reference_number=?, reference_type=?, source=?, notes=? WHERE id=? """, (data['part_id'], data['cross_reference_number'], data.get('reference_type'), data.get('source'), data.get('notes'), xref_id)) def delete_crossref(self, xref_id): self._execute("DELETE FROM part_cross_references WHERE id=?", (xref_id,)) def get_crossrefs_paginated(self, page=1, per_page=15): offset = (page - 1) * per_page return self._query(""" SELECT pcr.*, p.oem_part_number, p.name AS part_name FROM part_cross_references pcr JOIN parts p ON pcr.part_id = p.id ORDER BY pcr.id LIMIT ? OFFSET ? """, (per_page, offset)) def get_vehicles_for_part(self, part_id): return self._query(""" SELECT b.name AS brand, m.name AS model, y.year, e.name AS engine FROM vehicle_parts vp JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id JOIN models m ON mye.model_id = m.id JOIN brands b ON m.brand_id = b.id JOIN years y ON mye.year_id = y.id JOIN engines e ON mye.engine_id = e.id WHERE vp.part_id = ? ORDER BY b.name, m.name, y.year """, (part_id,)) ``` **Step 4: Run tests** ```bash cd /home/Autopartes && python -m pytest console/tests/test_db.py -v ``` Expected: All PASS **Step 5: Commit** ```bash git add console/db.py console/tests/ git commit -m "feat(console): add database abstraction layer with tests" ``` --- ## Task 3: Core Framework — Key Bindings and Navigation **Files:** - Create: `console/core/keybindings.py` - Create: `console/core/navigation.py` - Create: `console/core/screens.py` - Create: `console/tests/test_core.py` **Step 1: Write tests** ```python # console/tests/test_core.py import os, sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) from console.core.keybindings import Key, KeyBindings from console.core.navigation import Navigation from console.core.screens import Screen def test_key_constants(): assert Key.ESCAPE == 27 assert Key.ENTER == 10 assert Key.F1 is not None def test_keybindings_register(): kb = KeyBindings() called = [] kb.bind(Key.F1, lambda: called.append('help')) kb.handle(Key.F1) assert called == ['help'] def test_keybindings_unbound_key(): kb = KeyBindings() result = kb.handle(999) assert result is False def test_navigation_push_pop(): nav = Navigation() nav.push('menu', {}) nav.push('brands', {'filter': 'T'}) assert nav.current() == ('brands', {'filter': 'T'}) nav.pop() assert nav.current() == ('menu', {}) def test_navigation_breadcrumb(): nav = Navigation() nav.push('menu', {}, label='Menu') nav.push('brands', {}, label='Marcas') nav.push('models', {}, label='TOYOTA') bc = nav.breadcrumb() assert bc == ['Menu', 'Marcas', 'TOYOTA'] def test_navigation_empty(): nav = Navigation() assert nav.current() is None nav.pop() # should not crash def test_screen_base(): s = Screen('test', 'Test Screen') assert s.name == 'test' assert s.title == 'Test Screen' ``` **Step 2: Run test to verify failure** ```bash cd /home/Autopartes && python -m pytest console/tests/test_core.py -v ``` **Step 3: Implement keybindings.py** ```python # console/core/keybindings.py import curses class Key: """Key constants matching curses key codes.""" ESCAPE = 27 ENTER = 10 TAB = 9 BACKSPACE = 127 UP = curses.KEY_UP DOWN = curses.KEY_DOWN LEFT = curses.KEY_LEFT RIGHT = curses.KEY_RIGHT PGUP = curses.KEY_PPAGE PGDN = curses.KEY_NPAGE HOME = curses.KEY_HOME END = curses.KEY_END F1 = curses.KEY_F1 F2 = curses.KEY_F2 F3 = curses.KEY_F3 F4 = curses.KEY_F4 F5 = curses.KEY_F5 F6 = curses.KEY_F6 F7 = curses.KEY_F7 F8 = curses.KEY_F8 F9 = curses.KEY_F9 F10 = curses.KEY_F10 class KeyBindings: """Registry of key-to-action bindings.""" def __init__(self): self._bindings = {} def bind(self, key, action): self._bindings[key] = action def handle(self, key): if key in self._bindings: self._bindings[key]() return True return False def get_footer_labels(self): """Return list of (key_label, description) for footer display.""" return self._footer_labels if hasattr(self, '_footer_labels') else [] def set_footer(self, labels): """Set footer labels: [('F1', 'Ayuda'), ('ESC', 'Atrás'), ...]""" self._footer_labels = labels ``` **Step 4: Implement navigation.py** ```python # console/core/navigation.py class Navigation: """Screen navigation stack with breadcrumb support.""" def __init__(self): self._stack = [] # list of (screen_name, context, label) def push(self, screen_name, context=None, label=None): self._stack.append((screen_name, context or {}, label or screen_name)) def pop(self): if self._stack: return self._stack.pop() return None def current(self): if self._stack: entry = self._stack[-1] return (entry[0], entry[1]) return None def breadcrumb(self): return [entry[2] for entry in self._stack] def clear(self): self._stack.clear() def depth(self): return len(self._stack) ``` **Step 5: Implement screens.py** ```python # console/core/screens.py class Screen: """Base class for all application screens.""" def __init__(self, name, title): self.name = name self.title = title def on_enter(self, context, db, renderer): """Called when screen becomes active. Override in subclasses.""" pass def on_key(self, key, context, db, renderer, nav): """Handle key press. Return screen_name to navigate, None to stay, 'back' to pop.""" return None def render(self, context, db, renderer): """Draw the screen. Override in subclasses.""" pass ``` **Step 6: Run tests** ```bash cd /home/Autopartes && python -m pytest console/tests/test_core.py -v ``` Expected: All PASS **Step 7: Commit** ```bash git add console/core/ console/tests/test_core.py git commit -m "feat(console): add core framework - keybindings, navigation, screen base" ``` --- ## Task 4: Utilities — Formatting and VIN API **Files:** - Create: `console/utils/formatting.py` - Create: `console/utils/vin_api.py` - Create: `console/tests/test_utils.py` **Step 1: Write tests** ```python # console/tests/test_utils.py import os, sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) from console.utils.formatting import format_currency, format_number, truncate, pad_right, format_table_row def test_format_currency(): assert format_currency(45.99) == '$45.99' assert format_currency(None) == '──' assert format_currency(0) == '$0.00' def test_format_number(): assert format_number(13685) == '13,685' assert format_number(0) == '0' def test_truncate(): assert truncate('Hello World', 8) == 'Hello...' assert truncate('Short', 10) == 'Short' def test_pad_right(): assert pad_right('Hi', 5) == 'Hi ' assert pad_right('Hello World', 5) == 'Hello' def test_format_table_row(): row = format_table_row(['ID', 'Name', 'Price'], [4, 20, 8]) assert len(row) > 0 assert 'ID' in row ``` **Step 2: Run test to verify failure** ```bash cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v ``` **Step 3: Implement formatting.py** ```python # console/utils/formatting.py def format_currency(value): if value is None: return '──' return f'${value:,.2f}' def format_number(value): if value is None: return '0' return f'{int(value):,}' def truncate(text, max_len): if text is None: return '' text = str(text) if len(text) <= max_len: return text return text[:max_len - 3] + '...' def pad_right(text, width): if text is None: text = '' text = str(text) if len(text) > width: return text[:width] return text.ljust(width) def format_table_row(values, widths, separator=' │ '): """Format a row with fixed column widths.""" parts = [] for val, width in zip(values, widths): parts.append(pad_right(str(val) if val is not None else '', width)) return separator.join(parts) def quality_bar(tier): """Return visual quality bar for quality tier.""" bars = { 'premium': '██████████', 'oem': '███████████', 'standard': '███████░░░', 'economy': '█████░░░░░', } return bars.get(tier, '░░░░░░░░░░') ``` **Step 4: Implement vin_api.py** ```python # console/utils/vin_api.py import json import requests from console.config import NHTSA_API_URL def decode_vin_nhtsa(vin): """Decode a VIN using NHTSA API. Returns dict with vehicle info.""" try: response = requests.get( NHTSA_API_URL, params={'format': 'json'}, timeout=10 ) # NHTSA uses path-based VIN response = requests.get( f"{NHTSA_API_URL}/{vin}", params={'format': 'json'}, timeout=10 ) response.raise_for_status() data = response.json() results = data.get('Results', []) info = {} for item in results: var = item.get('Variable', '') val = item.get('Value') if val and val.strip(): if var == 'Make': info['make'] = val.strip() elif var == 'Model': info['model'] = val.strip() elif var == 'Model Year': info['year'] = int(val.strip()) elif var == 'Body Class': info['body_class'] = val.strip() elif var == 'Drive Type': info['drive_type'] = val.strip() elif var == 'Displacement (L)': info.setdefault('engine_info', {})['displacement_l'] = val.strip() elif var == 'Engine Number of Cylinders': info.setdefault('engine_info', {})['cylinders'] = val.strip() elif var == 'Fuel Type - Primary': info.setdefault('engine_info', {})['fuel_type'] = val.strip() elif var == 'Engine Brake (hp) From': info.setdefault('engine_info', {})['power_hp'] = val.strip() engine = info.get('engine_info', {}) if engine: parts = [] if engine.get('displacement_l'): parts.append(f"{engine['displacement_l']}L") if engine.get('cylinders'): parts.append(f"{engine['cylinders']} cil.") if engine.get('fuel_type'): parts.append(engine['fuel_type']) engine['raw'] = ' '.join(parts) return info except Exception as e: return {'error': str(e)} ``` **Step 5: Run tests** ```bash cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v ``` Expected: All PASS **Step 6: Commit** ```bash git add console/utils/ console/tests/test_utils.py git commit -m "feat(console): add formatting utils and VIN API client" ``` --- ## Task 5: Curses Renderer (VT220 Mode) **Files:** - Create: `console/renderers/curses_renderer.py` - Create: `console/renderers/base.py` **Step 1: Write base renderer interface** ```python # console/renderers/base.py class BaseRenderer: """Abstract renderer interface. All renderers must implement these methods.""" def init_screen(self): raise NotImplementedError def cleanup(self): raise NotImplementedError def get_size(self): """Return (height, width) of terminal.""" raise NotImplementedError def clear(self): raise NotImplementedError def refresh(self): raise NotImplementedError def get_key(self): """Block and return key code.""" raise NotImplementedError def draw_header(self, title, subtitle=''): raise NotImplementedError def draw_footer(self, key_labels): """key_labels: list of (key, description) tuples.""" raise NotImplementedError def draw_menu(self, items, selected_index=0, title=''): """items: list of (number, label) tuples.""" raise NotImplementedError def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): raise NotImplementedError def draw_detail(self, fields, title=''): """fields: list of (label, value) tuples.""" raise NotImplementedError def draw_form(self, fields, focused_index=0, title=''): """fields: list of {label, value, width, type} dicts.""" raise NotImplementedError def draw_filter_list(self, items, filter_text, selected_index, title=''): raise NotImplementedError def draw_comparison(self, columns, title=''): """columns: list of {header, rows} dicts for side-by-side.""" raise NotImplementedError def draw_text(self, row, col, text, style='normal'): raise NotImplementedError def draw_box(self, top, left, height, width, title=''): raise NotImplementedError def show_message(self, text, msg_type='info'): """msg_type: 'info', 'error', 'confirm'. Returns True/False for confirm.""" raise NotImplementedError def show_input(self, prompt, max_len=40): """Show input prompt and return typed string.""" raise NotImplementedError ``` **Step 2: Implement curses_renderer.py** This is the largest single file. It implements the full VT220 aesthetic with green-on-black, box drawing characters, and Pick-style screen layout. ```python # console/renderers/curses_renderer.py import curses from console.renderers.base import BaseRenderer from console.config import COLORS_VT220 from console.utils.formatting import pad_right, truncate class CursesRenderer(BaseRenderer): def __init__(self): self.stdscr = None self.colors = {} def init_screen(self): self.stdscr = curses.initscr() curses.noecho() curses.cbreak() curses.curs_set(0) self.stdscr.keypad(True) if curses.has_colors(): curses.start_color() curses.use_default_colors() self._init_colors() def _init_colors(self): color_map = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3, 'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7} for i, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), 1): if i < curses.COLOR_PAIRS: curses.init_pair(i, color_map.get(fg, 7), color_map.get(bg, 0)) self.colors[name] = curses.color_pair(i) # Ensure defaults self.colors.setdefault('normal', curses.color_pair(0)) def _style(self, name): return self.colors.get(name, self.colors.get('normal', 0)) def cleanup(self): if self.stdscr: curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() def get_size(self): h, w = self.stdscr.getmaxyx() return h, w def clear(self): self.stdscr.clear() def refresh(self): self.stdscr.refresh() def get_key(self): return self.stdscr.getch() def draw_header(self, title, subtitle=''): h, w = self.get_size() header_text = f' {title}' if subtitle: space = w - len(title) - len(subtitle) - 4 header_text = f' {title}{" " * max(space, 2)}{subtitle} ' header_text = header_text[:w].ljust(w) try: self.stdscr.addstr(0, 0, header_text, self._style('header') | curses.A_BOLD) except curses.error: pass # Separator line try: self.stdscr.addstr(1, 0, '─' * w, self._style('border')) except curses.error: pass def draw_footer(self, key_labels): h, w = self.get_size() # Separator try: self.stdscr.addstr(h - 2, 0, '─' * w, self._style('border')) except curses.error: pass # Key labels footer = ' ' + ' '.join(f'{k}={v}' for k, v in key_labels) + ' ' footer = footer[:w].ljust(w) try: self.stdscr.addstr(h - 1, 0, footer[:w-1], self._style('footer')) except curses.error: pass def draw_menu(self, items, selected_index=0, title=''): h, w = self.get_size() start_row = 3 if title: try: self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) except curses.error: pass start_row += 2 for i, (num, label) in enumerate(items): row = start_row + i if row >= h - 3: break style = self._style('highlight') if i == selected_index else self._style('normal') prefix = '>' if i == selected_index else ' ' text = f' {prefix} {num}. {label}' try: self.stdscr.addstr(row, 0, pad_right(text, w), style) except curses.error: pass def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1): h, w = self.get_size() start_row = 3 # Header row header_line = ' # │ ' for hdr, width in zip(headers, widths): header_line += pad_right(hdr, width) + ' │ ' try: self.stdscr.addstr(start_row, 0, header_line[:w], self._style('title') | curses.A_BOLD) self.stdscr.addstr(start_row + 1, 0, '─' * w, self._style('border')) except curses.error: pass # Data rows for i, row in enumerate(rows): y = start_row + 2 + i if y >= h - 4: break style = self._style('highlight') if i == selected_row else self._style('normal') line = f' {i + 1:<3}│ ' for val, width in zip(row, widths): line += pad_right(truncate(str(val) if val is not None else '', width), width) + ' │ ' try: self.stdscr.addstr(y, 0, line[:w], style) except curses.error: pass # Page info if page_info: info_row = h - 3 info_text = f' Página {page_info.get("page", 1)}/{page_info.get("total_pages", 1)}' \ f' {page_info.get("showing", "")} de {page_info.get("total", "")} registros' try: self.stdscr.addstr(info_row, 0, info_text, self._style('info')) except curses.error: pass def draw_detail(self, fields, title=''): h, w = self.get_size() start_row = 3 if title: try: self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) except curses.error: pass start_row += 2 max_label = max(len(f[0]) for f in fields) if fields else 10 for i, (label, value) in enumerate(fields): row = start_row + i if row >= h - 3: break dots = '.' * (max_label - len(label) + 2) line = f' {label}{dots}: {value}' try: self.stdscr.addstr(row, 2, label, self._style('field_label')) self.stdscr.addstr(row, 2 + len(label), dots + ': ', self._style('border')) self.stdscr.addstr(row, 2 + max_label + 4, str(value)[:w - max_label - 8], self._style('field_value')) except curses.error: pass def draw_form(self, fields, focused_index=0, title=''): h, w = self.get_size() start_row = 3 if title: try: self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) except curses.error: pass start_row += 2 max_label = max(len(f['label']) for f in fields) if fields else 10 for i, field in enumerate(fields): row = start_row + i if row >= h - 3: break label = field['label'] value = field.get('value', '') field_width = field.get('width', 30) dots = '.' * (max_label - len(label) + 2) is_focused = (i == focused_index) try: self.stdscr.addstr(row, 2, f'{i + 1}.', self._style('info')) self.stdscr.addstr(row, 5, label, self._style('field_label')) self.stdscr.addstr(row, 5 + len(label), dots + ': ', self._style('border')) val_col = 5 + max_label + 4 display_val = f'[{pad_right(str(value), field_width)}]' style = self._style('field_active') if is_focused else self._style('field_value') self.stdscr.addstr(row, val_col, display_val[:w - val_col - 2], style) hint = field.get('hint', '') if hint: hint_col = val_col + field_width + 3 if hint_col < w - 2: self.stdscr.addstr(row, hint_col, hint[:w - hint_col - 1], self._style('info')) except curses.error: pass def draw_filter_list(self, items, filter_text, selected_index, title=''): h, w = self.get_size() start_row = 3 if title: try: self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) except curses.error: pass start_row += 1 self.stdscr.addstr(start_row, 0, '─' * w, self._style('border')) start_row += 1 # Filter input try: self.stdscr.addstr(start_row, 2, 'Filtro: ', self._style('field_label')) self.stdscr.addstr(start_row, 10, filter_text + '_', self._style('field_active')) except curses.error: pass start_row += 1 try: self.stdscr.addstr(start_row, 0, '─' * w, self._style('border')) except curses.error: pass start_row += 1 # Items visible_items = h - start_row - 4 scroll_offset = max(0, selected_index - visible_items + 1) for i, item in enumerate(items[scroll_offset:scroll_offset + visible_items]): row = start_row + i actual_idx = scroll_offset + i is_selected = actual_idx == selected_index prefix = '> ' if is_selected else ' ' num = f'{actual_idx + 1}.' style = self._style('highlight') if is_selected else self._style('normal') text = f' {prefix}{num:>4} {item}' try: self.stdscr.addstr(row, 0, pad_right(text, w), style) except curses.error: pass # Count count_text = f' {len(items)} coincidencias' try: self.stdscr.addstr(h - 3, w - len(count_text) - 2, count_text, self._style('info')) except curses.error: pass def draw_comparison(self, columns, title=''): h, w = self.get_size() start_row = 3 if title: try: self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD) except curses.error: pass start_row += 2 n_cols = len(columns) if n_cols == 0: return label_w = 16 col_w = max(12, (w - label_w - 4) // n_cols - 3) # Column headers header_line = pad_right('', label_w) + ' │ ' for col in columns: header_line += pad_right(truncate(col['header'], col_w), col_w) + ' │ ' try: self.stdscr.addstr(start_row, 2, header_line[:w - 4], self._style('title') | curses.A_BOLD) self.stdscr.addstr(start_row + 1, 2, '─' * (w - 4), self._style('border')) except curses.error: pass start_row += 2 # Find max rows max_rows = max(len(col.get('rows', [])) for col in columns) if columns else 0 for r in range(max_rows): row = start_row + r if row >= h - 4: break # Row label from first column row_label = columns[0]['rows'][r][0] if r < len(columns[0].get('rows', [])) else '' line = pad_right(row_label, label_w) + ' │ ' for col in columns: val = col['rows'][r][1] if r < len(col.get('rows', [])) else '' line += pad_right(truncate(str(val), col_w), col_w) + ' │ ' try: self.stdscr.addstr(row, 2, line[:w - 4], self._style('normal')) except curses.error: pass def draw_text(self, row, col, text, style='normal'): h, w = self.get_size() if row < h and col < w: try: self.stdscr.addstr(row, col, text[:w - col], self._style(style)) except curses.error: pass def draw_box(self, top, left, height, width, title=''): try: # Top border self.stdscr.addstr(top, left, '┌' + '─' * (width - 2) + '┐', self._style('border')) if title: self.stdscr.addstr(top, left + 2, f' {title} ', self._style('title') | curses.A_BOLD) # Sides for i in range(1, height - 1): self.stdscr.addstr(top + i, left, '│', self._style('border')) self.stdscr.addstr(top + i, left + width - 1, '│', self._style('border')) # Bottom border self.stdscr.addstr(top + height - 1, left, '└' + '─' * (width - 2) + '┘', self._style('border')) except curses.error: pass def show_message(self, text, msg_type='info'): h, w = self.get_size() box_w = min(len(text) + 6, w - 4) box_h = 5 top = h // 2 - 2 left = (w - box_w) // 2 style = self._style('error') if msg_type == 'error' else self._style('info') self.draw_box(top, left, box_h, box_w) try: self.stdscr.addstr(top + 2, left + 3, truncate(text, box_w - 6), style) if msg_type == 'confirm': self.stdscr.addstr(top + 3, left + 3, '(S/N)', self._style('field_label')) else: self.stdscr.addstr(top + 3, left + 3, 'Presione cualquier tecla...', self._style('field_label')) except curses.error: pass self.refresh() if msg_type == 'confirm': while True: key = self.get_key() if key in (ord('s'), ord('S'), ord('y'), ord('Y')): return True if key in (ord('n'), ord('N'), 27): return False else: self.get_key() return True def show_input(self, prompt, max_len=40): h, w = self.get_size() box_w = max(len(prompt) + max_len + 8, 40) box_h = 5 top = h // 2 - 2 left = (w - box_w) // 2 self.draw_box(top, left, box_h, box_w, prompt) value = '' curses.curs_set(1) while True: try: display = f'[{pad_right(value, max_len)}]' self.stdscr.addstr(top + 2, left + 3, display, self._style('field_active')) self.stdscr.move(top + 2, left + 4 + len(value)) except curses.error: pass self.refresh() key = self.get_key() if key == 10: # ENTER break elif key == 27: # ESC curses.curs_set(0) return None elif key in (127, curses.KEY_BACKSPACE, 8): value = value[:-1] elif 32 <= key <= 126 and len(value) < max_len: value += chr(key) curses.curs_set(0) return value ``` **Step 3: Commit** ```bash git add console/renderers/ git commit -m "feat(console): add curses VT220 renderer with full widget set" ``` --- ## Task 6: App Controller and Main Menu Screen **Files:** - Create: `console/core/app.py` - Create: `console/screens/menu_principal.py` - Create: `console/screens/estadisticas.py` **Step 1: Implement app.py** ```python # console/core/app.py from console.core.navigation import Navigation from console.core.keybindings import Key class App: """Main application controller. Manages screen lifecycle and navigation.""" def __init__(self, renderer, db): self.renderer = renderer self.db = db self.nav = Navigation() self.screens = {} self.running = False self._register_screens() def _register_screens(self): from console.screens.menu_principal import MenuPrincipal from console.screens.estadisticas import EstadisticasScreen from console.screens.vehiculo_nav import VehiculoNavScreen from console.screens.buscar_parte import BuscarParteScreen from console.screens.buscar_texto import BuscarTextoScreen from console.screens.vin_decoder import VinDecoderScreen from console.screens.catalogo import CatalogoScreen from console.screens.parte_detalle import ParteDetalleScreen from console.screens.comparador import ComparadorScreen from console.screens.admin_partes import AdminPartesScreen from console.screens.admin_fabricantes import AdminFabricantesScreen from console.screens.admin_crossref import AdminCrossrefScreen from console.screens.admin_import import AdminImportScreen self.screens = { 'menu': MenuPrincipal(), 'estadisticas': EstadisticasScreen(), 'vehiculo_nav': VehiculoNavScreen(), 'buscar_parte': BuscarParteScreen(), 'buscar_texto': BuscarTextoScreen(), 'vin_decoder': VinDecoderScreen(), 'catalogo': CatalogoScreen(), 'parte_detalle': ParteDetalleScreen(), 'comparador': ComparadorScreen(), 'admin_partes': AdminPartesScreen(), 'admin_fabricantes': AdminFabricantesScreen(), 'admin_crossref': AdminCrossrefScreen(), 'admin_import': AdminImportScreen(), } def run(self): self.renderer.init_screen() self.running = True self.nav.push('menu', {}, label='Menú') try: while self.running: current = self.nav.current() if current is None: break screen_name, context = current screen = self.screens.get(screen_name) if screen is None: self.renderer.show_message(f'Pantalla "{screen_name}" no encontrada', 'error') self.nav.pop() continue # Render self.renderer.clear() screen.render(context, self.db, self.renderer) self.renderer.refresh() # Get input key = self.renderer.get_key() # Global keys if key == Key.F10: self.nav.clear() self.nav.push('menu', {}, label='Menú') continue # Screen-specific handling result = screen.on_key(key, context, self.db, self.renderer, self.nav) if result == 'quit': self.running = False elif result == 'back': self.nav.pop() elif result and isinstance(result, tuple): # (screen_name, context, label) name, ctx, label = result self.nav.push(name, ctx, label=label) elif result and isinstance(result, str): self.nav.push(result, {}, label=result) except KeyboardInterrupt: pass finally: self.renderer.cleanup() ``` **Step 2: Implement menu_principal.py** ```python # console/screens/menu_principal.py from console.core.screens import Screen from console.core.keybindings import Key from console.config import APP_NAME, APP_SUBTITLE, VERSION class MenuPrincipal(Screen): def __init__(self): super().__init__('menu', 'Menú Principal') self.selected = 0 self.items = [ ('1', 'Consulta por Vehículo'), ('2', 'Búsqueda por Número de Parte'), ('3', 'Búsqueda por Descripción'), ('4', 'Decodificador VIN'), ('5', 'Catálogo de Categorías'), ('─', '─────────────────────────'), ('6', 'Administración de Partes'), ('7', 'Administración de Fabricantes'), ('8', 'Cross-References'), ('9', 'Importar / Exportar Datos'), ('─', '─────────────────────────'), ('0', 'Estadísticas del Sistema'), ] self.selectable = [i for i, (num, _) in enumerate(self.items) if num != '─'] self.actions = { '1': ('vehiculo_nav', {}, 'Vehículo'), '2': ('buscar_parte', {}, 'Buscar Parte'), '3': ('buscar_texto', {}, 'Buscar Texto'), '4': ('vin_decoder', {}, 'VIN Decoder'), '5': ('catalogo', {}, 'Catálogo'), '6': ('admin_partes', {}, 'Admin Partes'), '7': ('admin_fabricantes', {}, 'Admin Fabricantes'), '8': ('admin_crossref', {}, 'Admin Cross-Ref'), '9': ('admin_import', {}, 'Importar/Exportar'), '0': ('estadisticas', {}, 'Estadísticas'), } def render(self, context, db, renderer): renderer.draw_header(f'{APP_NAME} v{VERSION}', APP_SUBTITLE) renderer.draw_menu(self.items, self.selected) renderer.draw_footer([ ('F1', 'Ayuda'), ('F3', 'Buscar'), ('F10', 'Menú'), ('ESC', 'Salir') ]) def on_key(self, key, context, db, renderer, nav): if key == Key.ESCAPE: if renderer.show_message('¿Desea salir del sistema?', 'confirm'): return 'quit' return None # Number keys if 48 <= key <= 57: # 0-9 num = chr(key) if num in self.actions: return self.actions[num] # Arrow navigation if key == Key.DOWN: idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0 idx = min(idx + 1, len(self.selectable) - 1) self.selected = self.selectable[idx] elif key == Key.UP: idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0 idx = max(idx - 1, 0) self.selected = self.selectable[idx] elif key == Key.ENTER: num = self.items[self.selected][0] if num in self.actions: return self.actions[num] return None ``` **Step 3: Implement estadisticas.py** ```python # console/screens/estadisticas.py from console.core.screens import Screen from console.core.keybindings import Key from console.utils.formatting import format_number class EstadisticasScreen(Screen): def __init__(self): super().__init__('estadisticas', 'Estadísticas del Sistema') def render(self, context, db, renderer): import datetime now = datetime.datetime.now().strftime('%d/%b/%Y %H:%M') renderer.draw_header('ESTADÍSTICAS DEL SISTEMA', now) stats = db.get_stats() fields = [ ('', '── BASE DE DATOS ─────────────────────────────────'), ('Marcas', format_number(stats.get('brands', 0))), ('Modelos', format_number(stats.get('models', 0))), ('Motores', format_number(stats.get('engines', 0))), ('Configuraciones', format_number(stats.get('model_year_engine', 0))), ('Fabricantes', format_number(stats.get('manufacturers', 0))), ('Categorías', format_number(stats.get('part_categories', 0))), ('Grupos', format_number(stats.get('part_groups', 0))), ('Partes OEM', format_number(stats.get('parts', 0))), ('Aftermarket', format_number(stats.get('aftermarket_parts', 0))), ('Cross-Refs', format_number(stats.get('part_cross_references', 0))), ('Diagramas', format_number(stats.get('diagrams', 0))), ('', '── COBERTURA ─────────────────────────────────────'), ('Fitments', format_number(stats.get('vehicle_parts', 0))), ('VINs en caché', format_number(stats.get('vin_cache', 0))), ] # Add top brands top_brands = stats.get('top_brands', []) if top_brands: brands_str = ' '.join(f"{b['name']}({format_number(b['cnt'])})" for b in top_brands) fields.append(('Top marcas', brands_str)) renderer.draw_detail(fields, 'ESTADÍSTICAS DEL SISTEMA') renderer.draw_footer([('F5', 'Actualizar'), ('ESC', 'Menú')]) def on_key(self, key, context, db, renderer, nav): if key == Key.ESCAPE: return 'back' if key == Key.F5: return None # Re-render return None ``` **Step 4: Commit** ```bash git add console/core/app.py console/screens/menu_principal.py console/screens/estadisticas.py git commit -m "feat(console): add app controller, main menu and statistics screen" ``` --- ## Task 7: Vehicle Navigation Screen (Drill-Down) **Files:** - Create: `console/screens/vehiculo_nav.py` **Step 1: Implement drill-down navigation** The vehicle navigation screen handles the full drill-down flow: Brand → Model → Year → Engine → Categories → Groups → Parts, each as a filterable list. It uses the renderer's `draw_filter_list` method and maintains state for each drill-down level within its context dict. **Key behaviors:** - Typing characters filters the list in real time - Arrow keys move selection cursor - ENTER advances to next drill-down level - ESC goes back one level (or exits to menu if at brand level) - After selecting engine, transitions to catalogo screen with the vehicle's mye_id **Step 2: Commit** ```bash git add console/screens/vehiculo_nav.py git commit -m "feat(console): add vehicle drill-down navigation screen" ``` --- ## Task 8: Catalog, Search, and VIN Screens **Files:** - Create: `console/screens/catalogo.py` - Create: `console/screens/buscar_parte.py` - Create: `console/screens/buscar_texto.py` - Create: `console/screens/vin_decoder.py` **Step 1: Implement catalogo.py** Categories → Groups → Parts drill-down, similar structure to vehiculo_nav but for the parts hierarchy. Uses `draw_filter_list` for categories/groups and `draw_table` for parts list. **Step 2: Implement buscar_parte.py** Single input field (via `show_input`), calls `db.search_part_number()`, displays results in `draw_table`. Selecting a result navigates to `parte_detalle`. **Step 3: Implement buscar_texto.py** Same pattern as buscar_parte but uses `db.search_parts()` (FTS5) with a text query. Paginated results via PgUp/PgDn. **Step 4: Implement vin_decoder.py** Input 17-char VIN, check `db.get_vin_cache()` first, else call `decode_vin_nhtsa()` and cache result. Display decoded info via `draw_detail`. Option to navigate to vehicle parts. **Step 5: Commit** ```bash git add console/screens/catalogo.py console/screens/buscar_parte.py console/screens/buscar_texto.py console/screens/vin_decoder.py git commit -m "feat(console): add catalog, search, and VIN decoder screens" ``` --- ## Task 9: Part Detail and Comparator Screens **Files:** - Create: `console/screens/parte_detalle.py` - Create: `console/screens/comparador.py` **Step 1: Implement parte_detalle.py** Top section: `draw_detail` with part fields (OEM number, name, group, category, description, material, weight). Bottom section: `draw_table` with aftermarket alternatives. F4 navigates to cross-references view. F6 shows vehicles that use this part. Number key selects an alternative to compare. **Step 2: Implement comparador.py** Uses `draw_comparison` to show OEM part and selected aftermarket alternatives side by side. Shows quality bars, prices, savings percentages, warranty info. **Step 3: Commit** ```bash git add console/screens/parte_detalle.py console/screens/comparador.py git commit -m "feat(console): add part detail and comparator screens" ``` --- ## Task 10: Admin CRUD Screens **Files:** - Create: `console/screens/admin_partes.py` - Create: `console/screens/admin_fabricantes.py` - Create: `console/screens/admin_crossref.py` - Create: `console/screens/admin_import.py` **Step 1: Implement admin_partes.py** List view with `draw_table` (paginated). F2 or ENTER to edit selected part via `draw_form`. F3 to add new part. F9 saves, ESC cancels. Delete with confirmation dialog. **Step 2: Implement admin_fabricantes.py** Same CRUD pattern as admin_partes but for manufacturers table. **Step 3: Implement admin_crossref.py** Same CRUD pattern for cross-references. F1 on part_id field opens lookup list of parts. **Step 4: Implement admin_import.py** Menu to select data type (categories, groups, parts, manufacturers, aftermarket, crossref, fitment). File path input via `show_input`. Reads CSV and calls appropriate db methods. Shows progress/results. **Step 5: Commit** ```bash git add console/screens/admin_*.py git commit -m "feat(console): add admin CRUD screens for parts, manufacturers, crossref, import" ``` --- ## Task 11: Textual/Rich Modern Renderer **Files:** - Create: `console/renderers/textual_renderer.py` **Step 1: Install textual dependency** ```bash pip install textual rich ``` **Step 2: Implement textual_renderer.py** Implements the same `BaseRenderer` interface but using textual/rich for a modern TUI experience. Uses Rich tables, panels, and styled text instead of curses box drawing. The same screen logic drives both renderers. Note: textual uses an async event loop, so this renderer wraps textual's App in a synchronous interface matching BaseRenderer. An alternative is a thin adapter that translates `draw_*` calls into Rich console output for simpler integration. **Step 3: Commit** ```bash git add console/renderers/textual_renderer.py git commit -m "feat(console): add textual/rich modern renderer" ``` --- ## Task 12: Integration Testing and Polish **Files:** - Create: `console/tests/test_integration.py` - Modify: `console/main.py` **Step 1: Write integration tests** Test that the app can be instantiated, screens registered, and basic navigation works (using a mock renderer that records draw calls instead of painting to screen). **Step 2: Add error handling to main.py** Wrap `app.run()` in try/except to always clean up the terminal on crash. Add `--db` flag to override database path. **Step 3: Test full app manually** ```bash cd /home/Autopartes && python -m console.main --mode vt220 cd /home/Autopartes && python -m console.main --mode modern ``` **Step 4: Commit** ```bash git add console/ git commit -m "feat(console): add integration tests and polish error handling" ``` --- ## Task 13: Documentation and Final Commit **Files:** - Create: `console/README.md` - Modify: `README.md` (root) **Step 1: Write console README** Usage instructions, keybindings reference, screenshots placeholder, mode descriptions. **Step 2: Update root README** Add section about the console system with quickstart command. **Step 3: Final commit** ```bash git add console/README.md README.md git commit -m "docs: add console system documentation" ``` --- ## Summary | Task | Description | Est. Steps | |------|-------------|------------| | 1 | Project scaffold + config | 4 | | 2 | Database abstraction layer | 5 | | 3 | Core framework (keys, nav, screens) | 7 | | 4 | Utilities (formatting, VIN API) | 6 | | 5 | Curses VT220 renderer | 3 | | 6 | App controller + main menu + stats | 4 | | 7 | Vehicle drill-down navigation | 2 | | 8 | Catalog, search, VIN screens | 5 | | 9 | Part detail + comparator | 3 | | 10 | Admin CRUD screens | 5 | | 11 | Textual modern renderer | 3 | | 12 | Integration tests + polish | 4 | | 13 | Documentation | 3 | | **Total** | | **54 steps** | **Dependencies:** Tasks 1-5 must be completed first (foundation). Tasks 6-10 can be done in any order after foundation. Task 11 is independent of 6-10. Tasks 12-13 are final.