Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ local_backup_*
*.woff2
#docs/
mockups/
docs/superpowers/*

# ── Local utilities ──
config_backup.py
Expand Down
33 changes: 33 additions & 0 deletions app/companies/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from app.services.currency_service import CurrencyService
from app.utils.response_utils import json_error, json_not_found
from app.utils.time_utils import now_utc
from app.utils.blocknote_utils import append_note, blocknote_to_text

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -344,6 +345,38 @@ def save_journey_notes(company_id):
return jsonify({'success': True})


@companies_bp.route('/api/<int:company_id>/journey-notes/append', methods=['POST'])
@login_required
def append_journey_note(company_id):
"""Append a dated note to a company's notes without loading the editor.

Used by the quick Add Note button on the research project page. The heading
names the project when one is given, so you can tell which research session
produced the note.
"""
company = Company.query.filter_by(id=company_id, user_id=current_user.id).first()
if not company:
return json_not_found('Company not found')

data = request.get_json() or {}
note = data.get('content') or data.get('text') or ''
# The editor always sends a document, so emptiness is about the text inside
# it, not the string being blank.
if not blocknote_to_text(note).strip():
return json_error('Note text is required')

heading = now_utc().strftime('%d %b %Y').lstrip('0')
project_name = (data.get('project_name') or '').strip()
if project_name:
heading = f'{heading} — {project_name}'

company.journey_notes = append_note(company.journey_notes, note, heading)
company.journey_notes_updated_at = now_utc()
db.session.commit()

return jsonify({'success': True})


# ===================================================================
# Standalone Research Questions API (list + create only;
# PUT/DELETE reuse existing /research/workflow/api/questions/<id>)
Expand Down
13 changes: 2 additions & 11 deletions app/research_workflow/templates/free_research_step.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,18 +168,9 @@ <h5 class="mb-1">Ready to move on?</h5>
companyName: {{ project.company.name|tojson }},
projectId: {{ project.id }},
stepIndex: {{ step_index }},
defaultTab: "upload"
defaultTab: "add"
})'>
<i class="bi bi-cloud-upload me-1"></i> Upload File
</button>
<button class="btn btn-sm btn-outline-primary" onclick='CompanyResources.open({
companyId: {{ project.company.id }},
companyName: {{ project.company.name|tojson }},
projectId: {{ project.id }},
stepIndex: {{ step_index }},
defaultTab: "link"
})'>
<i class="bi bi-link-45deg me-1"></i> Save Link
<i class="bi bi-plus-lg me-1"></i> Add Resource
</button>
<button class="btn btn-sm btn-outline-secondary" onclick='CompanyResources.open({
companyId: {{ project.company.id }},
Expand Down
206 changes: 190 additions & 16 deletions app/research_workflow/templates/project_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -370,17 +370,9 @@ <h3 class="font-poppins fw-bold mb-3">
companyId: {{ project.company.id }},
companyName: {{ project.company.name|tojson }},
projectId: {{ project.id }},
defaultTab: "upload"
defaultTab: "add"
})'>
<i class="bi bi-cloud-upload me-1"></i> Upload File
</button>
<button class="btn btn-sm btn-outline-primary" onclick='CompanyResources.open({
companyId: {{ project.company.id }},
companyName: {{ project.company.name|tojson }},
projectId: {{ project.id }},
defaultTab: "link"
})'>
<i class="bi bi-link-45deg me-1"></i> Save Link
<i class="bi bi-plus-lg me-1"></i> Add Resource
</button>
<button class="btn btn-sm btn-outline-secondary" onclick='CompanyResources.open({
companyId: {{ project.company.id }},
Expand All @@ -397,26 +389,93 @@ <h3 class="font-poppins fw-bold mb-3">
<h3 class="font-poppins fw-bold mb-3">Quick Actions</h3>
<div>
{% if project.company %}
<a href="#" class="quick-action-link" onclick="event.preventDefault(); showQuickNoteModal();">
<i class="bi bi-file-earmark-text"></i> Add Note
</a>
<a href="#" class="quick-action-link" onclick="event.preventDefault(); showQuickJournalModal();">
<i class="bi bi-journal-plus"></i> Add Journal Entry
</a>
<a href="{{ url_for('companies.company_detail', company_id=project.company.id) }}" class="quick-action-link">
<i class="bi bi-building"></i> Company Dashboard
</a>
<a href="{{ url_for('journal_enhanced.knowledge_hub', company_id=project.company.id) }}" class="quick-action-link">
<i class="bi bi-journal-text"></i> View Journal Entries
</a>
<a href="{{ url_for('journal_enhanced.new_entry', company_id=project.company.id) }}" class="quick-action-link">
<i class="bi bi-journal-plus"></i> New Journal Entry
</a>
{% endif %}
<a href="{{ url_for('research_workflow.my_projects') }}" class="quick-action-link">
<i class="bi bi-folder"></i> All Projects
</a>
</div>
</div>

</div>
</div>
</div>

<!-- Quick Note Modal — appends to the company's Notes -->
<div class="modal fade" id="quickNoteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-file-earmark-text me-2"></i>Add Note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">Appended to this company's notes, dated and tagged with this project.</p>
<div id="quickNoteEditorHost"></div>
<div class="alert alert-danger d-none mt-2" id="quickNoteError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="quickNoteSave">Save Note</button>
</div>
</div>
</div>
</div>

<!-- Quick Journal Modal -->
<div class="modal fade" id="quickJournalModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-journal-plus me-2"></i>Add Journal Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="quickJournalEditorHost" class="mb-3"></div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="quickJournalType" class="form-label">Entry Type</label>
<select class="form-select" id="quickJournalType">
<option value="observation" selected>Observation</option>
<option value="thesis_update">Thesis Update</option>
<option value="question">Question</option>
<option value="insight">Insight</option>
<option value="lesson_learned">Lesson Learned</option>
<option value="market_thought">Market Thought</option>
<option value="meeting_notes">Meeting Notes</option>
<option value="earnings_reaction">Earnings Reaction</option>
<option value="news_analysis">News Analysis</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="quickJournalSentiment" class="form-label">Sentiment</label>
<select class="form-select" id="quickJournalSentiment">
<option value="" selected>None</option>
<option value="bullish">Bullish</option>
<option value="bearish">Bearish</option>
<option value="neutral">Neutral</option>
<option value="uncertain">Uncertain</option>
</select>
</div>
</div>
<div class="alert alert-danger d-none" id="quickJournalError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="quickJournalSave">Save Entry</button>
</div>
</div>
</div>
</div>

<!-- Watchlist Modal -->
<div class="modal fade" id="watchlistModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
Expand Down Expand Up @@ -521,6 +580,121 @@ <h5 class="modal-title" id="findingModalTitle">Add Finding</h5>
{% include 'components/blocknote_editor_include.html' %}
<script src="{{ url_for('static', filename='js/company-resources.js') }}"></script>
<script>
// Quick Note / Quick Journal — save without leaving the page.
{% if project.company %}
const QUICK_COMPANY_ID = {{ project.company.id }};
const QUICK_PROJECT_NAME = {{ (project.project_name or '')|tojson }};

let quickNoteContent = '';
let quickJournalContent = '';

// BlockNote has no documented reset, so each open gets a fresh container and a
// fresh editor mounted into it. Mounting happens on shown.bs.modal because the
// editor needs a visible box to size itself against.
function mountQuickEditor(hostId, editorId, placeholder, onChange) {
const host = document.getElementById(hostId);
host.innerHTML = '<div id="' + editorId + '" class="blocknote-form-editor"></div>';
const wait = setInterval(function () {
if (window.initBlockNoteEditor) {
clearInterval(wait);
window.initBlockNoteEditor(editorId, {
initialContent: '',
placeholder: placeholder,
onChange: onChange
});
}
}, 100);
}

// An editor is never truly "empty" — it always holds at least one block — so
// emptiness means no text inside any of them.
function blocknoteHasText(json) {
if (!json) return false;
try {
return /"text"\s*:\s*"[^"]+"/.test(JSON.stringify(JSON.parse(json)));
} catch (e) {
return json.trim().length > 0;
}
}

function showQuickNoteModal() {
document.getElementById('quickNoteError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('quickNoteModal')).show();
}

function showQuickJournalModal() {
document.getElementById('quickJournalType').value = 'observation';
document.getElementById('quickJournalSentiment').value = '';
document.getElementById('quickJournalError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('quickJournalModal')).show();
}

document.getElementById('quickNoteModal').addEventListener('shown.bs.modal', function () {
quickNoteContent = '';
mountQuickEditor('quickNoteEditorHost', 'quickNoteEditor',
'What did you learn?',
function (json) { quickNoteContent = json; });
});

document.getElementById('quickJournalModal').addEventListener('shown.bs.modal', function () {
quickJournalContent = '';
mountQuickEditor('quickJournalEditorHost', 'quickJournalEditor',
'What happened?',
function (json) { quickJournalContent = json; });
});

function showQuickError(id, message) {
const el = document.getElementById(id);
el.textContent = message;
el.classList.remove('d-none');
}

async function saveQuick(modalId, url, payload, errorId, button) {
button.disabled = true;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
showQuickError(errorId, data.message || data.error || 'Could not save.');
return;
}
bootstrap.Modal.getInstance(document.getElementById(modalId)).hide();
} catch (err) {
showQuickError(errorId, 'Connection error: ' + err.message);
} finally {
button.disabled = false;
}
}

document.getElementById('quickNoteSave').addEventListener('click', function () {
if (!blocknoteHasText(quickNoteContent)) {
showQuickError('quickNoteError', 'Please write something first.');
return;
}
saveQuick('quickNoteModal',
"{{ url_for('companies.append_journey_note', company_id=project.company.id) }}",
{ content: quickNoteContent, project_name: QUICK_PROJECT_NAME },
'quickNoteError', this);
});

document.getElementById('quickJournalSave').addEventListener('click', function () {
if (!blocknoteHasText(quickJournalContent)) {
showQuickError('quickJournalError', 'Please write something first.');
return;
}
saveQuick('quickJournalModal', "{{ url_for('portfolio.quick_add_note') }}", {
company_id: QUICK_COMPANY_ID,
content: quickJournalContent,
entry_type: document.getElementById('quickJournalType').value,
sentiment: document.getElementById('quickJournalSentiment').value
}, 'quickJournalError', this);
});
{% endif %}

// Thesis modal BlockNote editor
let thesisModalContent = '';
const thesisModalEl = document.getElementById('thesisModal');
Expand Down
20 changes: 16 additions & 4 deletions app/services/step_progress_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,24 @@ def calculate_thesis_progress(project, step, step_index):
"""
Calculate progress for a thesis writing step.

Binary: 100% if investment_thesis has content, 0% otherwise.
Binary: 100% if investment_thesis has content the user actually wrote,
0% otherwise.

Starting research from an idea copies the idea's thesis_summary into
investment_thesis as a starting point. That's not work done on this step —
counting it made brand-new projects open at partial progress — so a thesis
still identical to the seed reads as untouched.
"""
try:
if project.investment_thesis:
return 100.0
return 0.0
thesis = project.investment_thesis
if not thesis:
return 0.0

idea = project.idea
if idea and idea.thesis_summary and thesis.strip() == idea.thesis_summary.strip():
return 0.0

return 100.0
except Exception as e:
logger.error(f"Error calculating thesis progress for project {project.id}, step {step_index}: {e}")
return 0.0
Expand Down
21 changes: 17 additions & 4 deletions app/static/js/company-resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* companyName: 'Apple Inc.',
* projectId: 456, // optional - research project context
* stepIndex: 2, // optional - research step context
* defaultTab: 'upload', // optional - 'list', 'upload', or 'link'
* defaultTab: 'add', // optional - 'list' or 'add' ('upload'/'link' also accepted)
* });
*/
const CompanyResources = (() => {
Expand Down Expand Up @@ -45,10 +45,11 @@ const CompanyResources = (() => {
// Load resources list
loadResources();

// Activate the requested tab
// Activate the requested tab. 'upload' and 'link' predate the merged Add
// pane and still work — they just preselect the matching type.
if (currentConfig.defaultTab !== 'list') {
const tabId = currentConfig.defaultTab === 'upload' ? 'cr-tab-upload' : 'cr-tab-link';
const tabEl = document.getElementById(tabId);
showResourceType(currentConfig.defaultTab === 'link' ? 'link' : 'file');
const tabEl = document.getElementById('cr-tab-add');
if (tabEl) {
const tab = new bootstrap.Tab(tabEl);
tab.show();
Expand All @@ -58,6 +59,16 @@ const CompanyResources = (() => {
m.show();
}

function showResourceType(type) {
const isLink = type === 'link';
const fileForm = document.getElementById('cr-file-form');
const linkForm = document.getElementById('cr-link-form');
if (fileForm) fileForm.classList.toggle('d-none', isLink);
if (linkForm) linkForm.classList.toggle('d-none', !isLink);
const radio = document.getElementById(isLink ? 'cr-type-link' : 'cr-type-file');
if (radio) radio.checked = true;
}

function resetForms() {
// Upload form
const fileInput = document.getElementById('cr-upload-file');
Expand Down Expand Up @@ -387,6 +398,8 @@ const CompanyResources = (() => {
document.getElementById('cr-filter-category').addEventListener('change', loadResources);
document.getElementById('cr-upload-btn').addEventListener('click', uploadFile);
document.getElementById('cr-link-btn').addEventListener('click', saveLink);
document.getElementById('cr-type-file').addEventListener('change', () => showResourceType('file'));
document.getElementById('cr-type-link').addEventListener('change', () => showResourceType('link'));
}

if (document.readyState === 'loading') {
Expand Down
Loading
Loading