diff --git a/app/sectors/api_routes.py b/app/sectors/api_routes.py index 6d0de4a3..e01f68e7 100644 --- a/app/sectors/api_routes.py +++ b/app/sectors/api_routes.py @@ -571,3 +571,54 @@ def get_sector_sections(): 'icon': s.icon, 'description': s.description } for s in sections]) + + +# ============================================================================ +# RESEARCH TIME TRACKING +# ============================================================================ + +# Upper bound on a single heartbeat increment. The client flushes roughly every +# 30s, so anything much larger than a couple of minutes indicates a stale or +# tampered payload and is rejected rather than trusted. +MAX_TRACK_TIME_SECONDS = 300 + + +@sectors_bp.route('/api/sectors/analysis//track-time', methods=['POST']) +@login_required +def track_research_time(analysis_id): + """Increment active research time for a sector analysis. + + Called periodically by the client-side heartbeat while the sector research + page is open, visible, and the user is active. Each request adds a small + number of seconds to ``total_time_spent``. + """ + analysis = SectorAnalysis.query.filter_by( + id=analysis_id, user_id=current_user.id + ).first() + + if not analysis: + return json_unauthorized('Access denied') + + data = request.get_json(silent=True) or {} + + try: + seconds = int(data.get('seconds', 0)) + except (TypeError, ValueError): + return json_error('Invalid seconds value') + + if seconds <= 0 or seconds > MAX_TRACK_TIME_SECONDS: + return json_error('Invalid seconds value') + + analysis.total_time_spent = (analysis.total_time_spent or 0) + seconds + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return json_error(str(e), status_code=500) + + return jsonify({ + 'success': True, + 'total_time_spent': analysis.total_time_spent, + 'time_spent_formatted': analysis.time_spent_formatted, + }) diff --git a/app/sectors/routes.py b/app/sectors/routes.py index 0ede5c38..61f16ef6 100644 --- a/app/sectors/routes.py +++ b/app/sectors/routes.py @@ -1268,6 +1268,66 @@ def detect_companies(): }) +@sectors_bp.route('//scan-companies', methods=['POST']) +@login_required +def scan_document_companies(sector_name): + """Scan this sector's research text for mentions of tracked companies. + + Unlike the per-note detection that fires on canvas note save, this scans the + full research corpus for the sector โ€” the document view content plus every + canvas note โ€” and reports which tracked companies are mentioned, along with + whether each is already in the sector, in the portfolio, or on the watchlist. + The client uses this to let the user pull mentioned holdings into the sector + so watchlist/portfolio metrics capture them. + """ + from app.utils.company_detection import detect_company_mentions + + sector = get_sector_by_name_or_slug(sector_name, current_user.id) + if not sector: + return json_not_found('Sector') + + analysis = SectorAnalysis.query.filter_by( + user_id=current_user.id, sector_id=sector.id + ).first() + if not analysis: + return json_not_found('Sector research') + + # Gather the full research corpus: document view + all canvas notes. + text_parts = [] + if analysis.document_content: + text_parts.append(analysis.document_content) + for note in analysis.canvas_notes.all(): + if note.title: + text_parts.append(note.title) + if note.content: + text_parts.append(note.content) + combined_text = '\n'.join(text_parts) + + matches = detect_company_mentions(combined_text, current_user.id) + + favorite_ids = {c.id for c in current_user.favorites.all()} + + companies = [] + for match in matches: + company = match['company'] + companies.append({ + 'id': company.id, + 'name': company.name, + 'ticker': company.ticker_symbol or '', + 'matched_text': match['matched_text'], + 'confidence': match['confidence'], + 'in_sector': company.sector_id == sector.id, + 'is_in_portfolio': company.is_in_portfolio, + 'is_in_watchlist': company.id in favorite_ids, + }) + + return jsonify({ + 'success': True, + 'companies': companies, + 'total': len(companies), + }) + + @sectors_bp.route('/note//link-companies', methods=['POST']) @login_required def link_companies_to_note(note_id): diff --git a/app/sectors/templates/components/archive_modal.html b/app/sectors/templates/components/archive_modal.html index 69b59b5c..2f871501 100644 --- a/app/sectors/templates/components/archive_modal.html +++ b/app/sectors/templates/components/archive_modal.html @@ -34,10 +34,12 @@

๐Ÿ“Š {{ analysis.sector_name }}

+ {# Research Progress bar hidden (issue #304): a 0-100% "completeness" score + is misleading โ€” research is never "done". Scoring code (research_progress_score, + progress_stage, progress_breakdown) is kept dormant pending a future + capital-at-risk signal that replaces it. To revive, un-comment this block + and the progressBreakdownModal include below.
Research Progress: @@ -118,6 +123,7 @@

๐Ÿ“Š {{ analysis.sector_name }}

+ #}
@@ -494,6 +500,12 @@

+ + +

@@ -566,7 +578,8 @@

{% block modals %} -{% include 'components/progress_breakdown_modal.html' %} +{# Progress breakdown ("How Scoring Works") modal hidden with the progress bar (issue #304) #} +{# {% include 'components/progress_breakdown_modal.html' %} #} {% include 'components/templates_modal.html' %} {% set tips_title = "Sector Research Tips" %} @@ -593,6 +606,11 @@

{% block scripts %} {{ super() }} {% include 'components/_tabulator_js.html' %} + + + + + + + + + {% endblock %} {% block modals %} diff --git a/app/static/js/sector-time-tracker.js b/app/static/js/sector-time-tracker.js new file mode 100644 index 00000000..db5e963d --- /dev/null +++ b/app/static/js/sector-time-tracker.js @@ -0,0 +1,84 @@ +/** + * Sector research time tracker. + * + * Accumulates *active* research time on the sector analysis page and flushes it + * to the server periodically. "Active" means the tab is visible AND the user has + * interacted within the idle window โ€” so leaving the tab open in the background + * or walking away does not inflate the recorded research time. + * + * Requires an element with id="sector-time-tracker" carrying a + * data-analysis-id attribute, rendered by the sector analysis template. + */ +(function () { + 'use strict'; + + var el = document.getElementById('sector-time-tracker'); + if (!el) return; + + var analysisId = el.getAttribute('data-analysis-id'); + if (!analysisId) return; + + var endpoint = '/api/sectors/analysis/' + analysisId + '/track-time'; + + var IDLE_LIMIT_MS = 60 * 1000; // treat user as idle after 60s of no interaction + var FLUSH_INTERVAL_MS = 30 * 1000; // push accumulated time to server every 30s + var TICK_MS = 1000; + + var lastActivity = Date.now(); + var lastTick = Date.now(); + var pendingSeconds = 0; // active seconds accumulated but not yet flushed + + var activityEvents = ['mousemove', 'keydown', 'scroll', 'click', 'touchstart']; + activityEvents.forEach(function (evt) { + document.addEventListener(evt, function () { + lastActivity = Date.now(); + }, { passive: true }); + }); + + // Accumulate elapsed wall-clock time, but only count it while the page is + // visible and the user has been active recently. + setInterval(function () { + var now = Date.now(); + var deltaSeconds = (now - lastTick) / 1000; + lastTick = now; + + var isVisible = document.visibilityState === 'visible'; + var isActive = (now - lastActivity) < IDLE_LIMIT_MS; + + if (isVisible && isActive) { + pendingSeconds += deltaSeconds; + } + }, TICK_MS); + + function flush(useBeacon) { + var seconds = Math.round(pendingSeconds); + if (seconds <= 0) return; + + pendingSeconds -= seconds; + var payload = JSON.stringify({ seconds: seconds }); + + if (useBeacon && navigator.sendBeacon) { + var blob = new Blob([payload], { type: 'application/json' }); + var ok = navigator.sendBeacon(endpoint, blob); + if (!ok) pendingSeconds += seconds; // re-queue if the beacon was rejected + return; + } + + fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: payload, + keepalive: true, + }).catch(function () { + pendingSeconds += seconds; // re-queue on network failure + }); + } + + setInterval(function () { flush(false); }, FLUSH_INTERVAL_MS); + + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') flush(true); + }); + + window.addEventListener('beforeunload', function () { flush(true); }); +})(); diff --git a/app/utils/company_detection.py b/app/utils/company_detection.py index b62cca31..1d0a03c1 100644 --- a/app/utils/company_detection.py +++ b/app/utils/company_detection.py @@ -65,18 +65,21 @@ def detect_company_mentions(text: str, user_id: int) -> List[Dict]: continue # Priority 1: Exact ticker match (highest confidence) - ticker = company.ticker_symbol.upper() - # Match ticker as standalone word (not part of another word) - ticker_pattern = r'\b' + re.escape(ticker) + r'\b' - if re.search(ticker_pattern, text_upper): - matches.append({ - 'company': company, - 'confidence': 'high', - 'matched_text': company.ticker_symbol, - 'match_type': 'ticker' - }) - seen_company_ids.add(company.id) - continue + # Companies may not have a ticker (e.g. private/manually added), so guard + # against None before matching. + ticker = (company.ticker_symbol or '').strip().upper() + if ticker: + # Match ticker as standalone word (not part of another word) + ticker_pattern = r'\b' + re.escape(ticker) + r'\b' + if re.search(ticker_pattern, text_upper): + matches.append({ + 'company': company, + 'confidence': 'high', + 'matched_text': company.ticker_symbol, + 'match_type': 'ticker' + }) + seen_company_ids.add(company.id) + continue # Priority 2: Exact company name match (high confidence) company_name = company.name diff --git a/frontend/src/components/sector-canvas/CompanySidebar.jsx b/frontend/src/components/sector-canvas/CompanySidebar.jsx index bfe5a151..7b7c9354 100644 --- a/frontend/src/components/sector-canvas/CompanySidebar.jsx +++ b/frontend/src/components/sector-canvas/CompanySidebar.jsx @@ -185,6 +185,129 @@ export function CompanySidebar({ sectorName }) { companiesList.addEventListener('click', handleRemoveClick); } + // ---- detect companies mentioned in the research text ---- + + function renderScanResults(companies) { + var container = document.getElementById('scanCompaniesResults'); + if (!container) return; + + if (!companies || companies.length === 0) { + container.innerHTML = + '

' + + 'No tracked companies were mentioned in your research text.' + + '

'; + return; + } + + var rows = companies.map(function (c) { + var badges = ''; + if (c.is_in_portfolio) badges += 'Portfolio'; + else if (c.is_in_watchlist) badges += 'Watchlist'; + + var action = c.in_sector + ? ' In sector' + : ''; + + return ( + '
' + + '
' + + '' + escapeText(c.name) + '' + + (c.ticker ? ' ' + escapeText(c.ticker) + '' : '') + + badges + + '
' + + '
' + action + '
' + + '
' + ); + }).join(''); + + var notInSector = companies.filter(function (c) { return !c.in_sector; }); + var addAll = notInSector.length > 1 + ? '' + : ''; + + container.innerHTML = + '
' + + ' Mentioned in your research:' + + '
' + rows + addAll; + } + + function markScanRowAdded(companyId) { + var container = document.getElementById('scanCompaniesResults'); + if (!container) return; + var row = container.querySelector('.scan-result-row[data-company-id="' + companyId + '"]'); + if (!row) return; + var actionCell = row.querySelector('.scan-result-action'); + if (actionCell) { + actionCell.innerHTML = + ' In sector'; + } + var allBtn = container.querySelector('#scanAddAllBtn'); + if (allBtn && !container.querySelector('.scan-add-btn')) allBtn.remove(); + } + + var scanBtn = document.getElementById('scanCompaniesBtn'); + function handleScan() { + scanBtn.disabled = true; + var original = scanBtn.innerHTML; + scanBtn.innerHTML = ' Scanningโ€ฆ'; + + fetch('/sectors/' + sectorName + '/scan-companies', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.success) { + renderScanResults(data.companies); + } else { + showToast(data.error || 'Failed to scan research', 'error'); + } + }) + .catch(function () { showToast('Network error', 'error'); }) + .finally(function () { + scanBtn.disabled = false; + scanBtn.innerHTML = original; + }); + } + if (scanBtn) { + scanBtn.addEventListener('click', handleScan); + } + + var scanResults = document.getElementById('scanCompaniesResults'); + function handleScanAdd(e) { + var addBtn = e.target.closest('.scan-add-btn'); + var addAllBtn = e.target.closest('#scanAddAllBtn'); + + if (addBtn) { + var companyId = addBtn.dataset.companyId; + addBtn.disabled = true; + addCompanyToSector(companyId, function (company) { + appendCompanyToList(company); + removeOptionFromSelect(companyId); + markScanRowAdded(companyId); + showToast(company.name + ' added to sector', 'success'); + }); + } else if (addAllBtn) { + var pending = Array.from(scanResults.querySelectorAll('.scan-add-btn')); + addAllBtn.disabled = true; + pending.forEach(function (btn) { + var id = btn.dataset.companyId; + addCompanyToSector(id, function (company) { + appendCompanyToList(company); + removeOptionFromSelect(id); + markScanRowAdded(id); + }); + }); + showToast('Adding ' + pending.length + ' companies to sector', 'success'); + } + } + if (scanResults) { + scanResults.addEventListener('click', handleScanAdd); + } + var newCompanyBtn = document.getElementById('newCompanyBtn'); function handleNewCompany() { if (typeof window.openCompanyModal === 'function') { @@ -208,6 +331,8 @@ export function CompanySidebar({ sectorName }) { if (addForm) addForm.removeEventListener('submit', handleAddSubmit); if (companiesList) companiesList.removeEventListener('click', handleRemoveClick); if (newCompanyBtn) newCompanyBtn.removeEventListener('click', handleNewCompany); + if (scanBtn) scanBtn.removeEventListener('click', handleScan); + if (scanResults) scanResults.removeEventListener('click', handleScanAdd); }; }, [sectorName]);