`).join('') || `
No experiments configured.
`;
}
function populateExperimentDropdowns() {
const options = dbState.experiments.map(exp => `
`).join('');
experimentFilterSelect.innerHTML = options;
obsExperimentSelect.innerHTML = options;
// Set filter to current, or default to first
if (activeExperimentFilter) experimentFilterSelect.value = activeExperimentFilter;
else if (dbState.experiments.length > 0) activeExperimentFilter = dbState.experiments[0].id;
updateObservationForm(); // Update form based on default selection
}
function updateObservationForm() {
const selectedId = obsExperimentSelect.value;
const experiment = dbState.experiments.find(ex => ex.id === selectedId);
if (experiment) {
obsHypothesisDisplay.textContent = experiment.hypothesis || "N/A";
obsQuantLabel.textContent = experiment.variableName || "Quantitative Value";
obsQuantValueInput.placeholder = `Enter ${experiment.variableName || 'Value'}`;
} else {
obsHypothesisDisplay.textContent = "N/A";
obsQuantLabel.textContent = "Quantitative Value";
obsQuantValueInput.placeholder = "Enter Value";
}
}
function renderDashboard() {
const experiment = dbState.experiments.find(ex => ex.id === activeExperimentFilter);
if (!experiment) {
dashboardTitle.textContent = "Observation Dashboard";
kpiTotal.textContent = "N/A";
kpiAverage.textContent = "N/A";
kpiAverageLabel.textContent = "Average Value";
observationFeed.innerHTML = `
Please select an experiment to view data.
`;
if (observationChartInstance) observationChartInstance.destroy();
pdfDownloadBtn.style.display = 'none';
return;
}
dashboardTitle.textContent = `Dashboard: ${escapeHTML(experiment.name)}`;
pdfDownloadBtn.style.display = dbState.observations.length > 0 ? 'inline-block' : 'none';
// 1. KPIs
const values = dbState.observations.map(obs => obs.quantitativeValue).filter(v => typeof v === 'number');
const total = values.length;
const average = total > 0 ? (values.reduce((a, b) => a + b, 0) / total) : 0;
kpiTotal.textContent = total;
kpiAverage.textContent = average.toFixed(2);
kpiAverageLabel.textContent = `Average ${escapeHTML(experiment.variableName) || 'Value'}`;
// 2. Chart
renderObservationChart(experiment.variableName);
// 3. Feed
renderObservationFeed();
}
function renderObservationChart(variableName) {
if (!observationChartCanvas) return;
const ctx = observationChartCanvas.getContext('2d');
const chartData = dbState.observations
.filter(obs => obs.obsTime?.toDate && typeof obs.quantitativeValue === 'number' && !isNaN(obs.obsTime.toDate().getTime()))
.map(obs => ({
x: obs.obsTime.toDate(),
y: obs.quantitativeValue
}));
// Data is already sorted by query
if (observationChartInstance) {
observationChartInstance.destroy();
}
if (chartData.length < 2) {
ctx.clearRect(0, 0, observationChartCanvas.width, observationChartCanvas.height);
ctx.font = "14px Inter";
ctx.fillStyle = "#a8a29e"; // stone-400
ctx.textAlign = "center";
ctx.fillText("Need at least two valid data points to plot chart.", observationChartCanvas.width / 2, observationChartCanvas.height / 2);
return;
}
observationChartInstance = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: escapeHTML(variableName) || 'Value',
data: chartData,
borderColor: 'var(--seo-primary, #3b82f6)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false, // Per spec
scales: {
x: {
type: 'time',
time: { unit: 'day', tooltipFormat: 'MMM d, yyyy HH:mm' },
title: { display: true, text: 'Observation Time' }
},
y: {
title: { display: true, text: escapeHTML(variableName) || 'Value' },
beginAtZero: true
}
},
plugins: {
tooltip: {
callbacks: {
label: (context) => `${context.dataset.label}: ${context.raw.y.toFixed(2)}`
}
}
}
}
});
}
function renderObservationFeed() {
if (dbState.observations.length === 0) {
observationFeed.innerHTML = `
No observations logged for this experiment yet.
`;
return;
}
observationFeed.innerHTML = dbState.observations.map(obs => {
const obsDate = obs.obsTime?.toDate ? obs.obsTime.toDate() : new Date();
const formattedTime = !isNaN(obsDate.getTime()) ? obsDate.toLocaleString() : 'Invalid Date';
return `
${formattedTime}
Observer: ${escapeHTML(obs.observer)}
${escapeHTML(obs.variableName) || 'Value'}: ${obs.quantitativeValue}
Qualitative Notes: ${escapeHTML(obs.qualitative) || 'N/A'}
`
}).reverse().join(''); // Show newest first in the list
}
// --- Event Handlers ---
function handleFilterChange() {
activeExperimentFilter = experimentFilterSelect.value;
loadObservations(); // This will trigger a re-render
}
async function downloadPDF() {
// Per user request, ensuring this is robust
pdfTarget.classList.add('seo-pdf-view');
try {
// 1. Capture Chart
const chartCanvasForPdf = document.createElement('canvas');
chartCanvasForPdf.width = observationChartCanvas.width;
chartCanvasForPdf.height = observationChartCanvas.height;
const chartCtxPdf = chartCanvasForPdf.getContext('2d');
chartCtxPdf.fillStyle = '#FFFFFF';
chartCtxPdf.fillRect(0, 0, chartCanvasForPdf.width, chartCanvasForPdf.height);
if (observationChartInstance) {
chartCtxPdf.drawImage(observationChartCanvas, 0, 0);
} else {
chartCtxPdf.font = "14px Inter";
chartCtxPdf.fillStyle = "#a8a29e";
chartCtxPdf.textAlign = "center";
chartCtxPdf.fillText("No chart data available.", chartCanvasForPdf.width / 2, chartCanvasForPdf.height / 2);
}
const chartImage = chartCanvasForPdf.toDataURL('image/png');
// 2. Capture Main Content (without live chart)
const canvas = await html2canvas(pdfTarget, {
scale: 2, logging: false, useCORS: true,
ignoreElements: (element) => element.id === 'seo-observation-chart'
});
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const margin = 15;
const imgWidth = pdfWidth - (margin * 2);
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let yPosition = margin;
// 3. Add Content Image to PDF
pdf.addImage(imgData, 'PNG', margin, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
// 4. Add Chart Image to PDF
const chartHeightInPdf = (chartCanvasForPdf.height * imgWidth) / chartCanvasForPdf.width;
if (yPosition + chartHeightInPdf > pdfHeight - margin) {
pdf.addPage();
yPosition = margin;
}
pdf.setFontSize(14);
pdf.setTextColor(31, 41, 55); // gray-800
pdf.text("Observation Chart", margin, yPosition);
yPosition += 8;
pdf.addImage(chartImage, 'PNG', margin, yPosition, imgWidth, chartHeightInPdf);
pdf.save(`Observation_Sheet_${experimentFilterSelect.options[experimentFilterSelect.selectedIndex].text}.pdf`);
} catch (error) {
console.error("Error generating PDF:", error);
showMessage("Error generating PDF. Please try again.", "error");
} finally {
pdfTarget.classList.remove('seo-pdf-view');
}
}
// --- Utilities ---
function showMessage(message, type = "info") {
let feedbackEl = document.getElementById('feedback-message');
if (!feedbackEl) {
feedbackEl = document.createElement('div');
feedbackEl.id = 'feedback-message';
feedbackEl.style.position = 'fixed';
feedbackEl.style.bottom = '20px';
feedbackEl.style.left = '50%';
feedbackEl.style.transform = 'translateX(-50%)';
feedbackEl.style.padding = '10px 20px';
feedbackEl.style.borderRadius = '8px';
feedbackEl.style.zIndex = '1000';
feedbackEl.style.opacity = '0';
feedbackEl.style.transition = 'opacity 0.5s ease';
document.body.appendChild(feedbackEl);
}
feedbackEl.textContent = message;
feedbackEl.style.opacity = '1';
const colors = { error: { bg: '#fef2f2', text: '#991b1b' }, success: { bg: '#f0fdf4', text: '#14532d' }, info: { bg: '#eff6ff', text: '#1e3a8a' }};
feedbackEl.style.backgroundColor = colors[type].bg;
feedbackEl.style.color = colors[type].text;
setTimeout(() => { feedbackEl.style.opacity = '0'; }, 3000);
}
function escapeHTML(str) {
if (typeof str !== 'string') return '';
return str ? str.replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])) : '';
}
function setFormDateTimeToNow() {
// Set default date/time for new entry
const now = new Date();
const yyyy = now.getFullYear();
const mm = String(now.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed
const dd = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
// Format for datetime-local input: YYYY-MM-DDThh:mm
if (obsTimeInput) {
obsTimeInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
}
// --- Event Listeners ---
navTabs.forEach((tab, index) => tab.addEventListener('click', () => showTab(index)));
navPrev.addEventListener('click', () => showTab(currentTab - 1));
navNext.addEventListener('click', () => showTab(currentTab + 1));
// Tab 1
pdfDownloadBtn.addEventListener('click', downloadPDF);
experimentFilterSelect.addEventListener('change', handleFilterChange);
observationFeed.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn') && e.target.dataset.type === 'observation') {
deleteObservation(e.target.dataset.id);
}
});
// Tab 2
observationForm.addEventListener('submit', handleLogObservation);
obsExperimentSelect.addEventListener('change', updateObservationForm);
// Tab 3
addExperimentForm.addEventListener('submit', handleAddExperiment);
loadSampleBtn.addEventListener('click', () => {
// Sample data (USA-centric)
addConfigItem('experiments', { name: "Corn Growth (USA)", hypothesis: "Corn plants with fertilizer X will grow taller.", variableName: "Plant Height (cm)" });
addConfigItem('experiments', { name: "Soil pH (Iowa)", hypothesis: "Cover crops will increase soil pH over 6 months.", variableName: "Soil pH" });
});
experimentsList.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn') && e.target.dataset.type === 'experiment') {
deleteExperiment(e.target.dataset.id);
}
});
// --- Initial Load ---
setFormDateTimeToNow();
showTab(0); // Start on dashboard
Observation Dashboard
Observation Trend
Observation Log