Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,6 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
152 changes: 112 additions & 40 deletions app/observations/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import { getAllSavedObservations } from '@/lib/db';
import { getAllExperiments } from '@/data/labs';
import ExperimentCard from '@/components/ExperimentCard';
import { fetchExperimentData } from '@/lib/actions';
import Link from 'next/link';
import styles from '@/app/preferences/Preferences.module.css';
import homeStyles from '@/components/SearchBar.module.css';
import preferencesStyles from '@/app/preferences/Preferences.module.css';
import expStyles from '@/components/experiment/Experiment.module.css';

export default function ObservationsPage() {
const { user, loading: authLoading } = useAuth();
const [observationList, setObservationList] = useState([]);
const [savedTables, setSavedTables] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
Expand All @@ -21,45 +21,64 @@ export default function ObservationsPage() {
setLoading(false);
}

// Listen for instant updates
const handleUpdate = () => fetchObservations();
window.addEventListener('workspace-updated', handleUpdate);
return () => window.removeEventListener('workspace-updated', handleUpdate);
}, [user, authLoading]);

const fetchObservations = async () => {
// Safety timeout to prevent infinite "Loading..."
const timeout = setTimeout(() => {
setLoading(false);
console.warn('Fetch timed out (4s)');
}, 4000);
console.warn('Fetch timed out for observations');
}, 8000);

try {
const { data, error } = await getAllSavedObservations(user.id);
if (!error && data) {
if (!error && data && data.length > 0) {
const allExps = getAllExperiments();

// Group by experiment_id to avoid showing the same card multiple times
const uniqueExpIds = [...new Set(data.map(item => item.experiment_id))];

const enriched = uniqueExpIds.map(expId => {
// Fetch full experiment JSONs to get table headers
const tableViews = await Promise.all(data.map(async (obs) => {
let labId, eId;
if (String(expId).includes('/')) {
[labId, eId] = String(expId).split('/');
if (String(obs.experiment_id).includes('/')) {
[labId, eId] = String(obs.experiment_id).split('/');
} else {
eId = expId;
eId = obs.experiment_id;
}

const found = allExps.find(e =>
const foundExpMeta = allExps.find(e =>
String(e.id) === String(eId) &&
(!labId || String(e.labId) === String(labId))
);

// Skip if missing lab mapping or orphaned data
if (!foundExpMeta || !labId) return null;

// Fetch full JSON using Server Action
const expData = await fetchExperimentData(labId, eId);
if (!expData || !expData.sections || !expData.sections[obs.section_id]) return null;

const section = expData.sections[obs.section_id];
const tableBlock = section.content?.find(b => b.type === 'table');

const lastUpdated = data.find(d => d.experiment_id === expId)?.updated_at;
return found ? { ...found, viewed_at: lastUpdated } : null;
}).filter(Boolean);
// Fallback to empty structure if no table found (in case of schema changes)
const headers = tableBlock?.headers || [];

setObservationList(enriched);
return {
id: `${obs.experiment_id}-${obs.section_id}`,
experimentName: foundExpMeta.name,
labName: foundExpMeta.labName,
sectionTitle: section.title || obs.section_id,
updatedAt: obs.updated_at,
headers: headers,
rows: obs.data || [],
linkUrl: `/lab/${labId}/experiment/${eId}`
};
}));

setSavedTables(tableViews.filter(Boolean));
} else {
setSavedTables([]);
}
} finally {
clearTimeout(timeout);
Expand All @@ -69,16 +88,16 @@ export default function ObservationsPage() {

if (authLoading || loading) {
return (
<div className={styles.container}>
<div className={styles.loadingState}>Loading your saved data...</div>
<div className={preferencesStyles.container}>
<div className={preferencesStyles.loadingState}>Loading your saved data...</div>
</div>
);
}

if (!user) {
return (
<div className={styles.container} data-tour="observations-page">
<div className={styles.authPrompt}>
<div className={preferencesStyles.container} data-tour="observations-page">
<div className={preferencesStyles.authPrompt}>
<h2>Please Log In</h2>
<p>Log in to view your saved laboratory observations and cloud-synced data.</p>
</div>
Expand All @@ -87,28 +106,81 @@ export default function ObservationsPage() {
}

return (
<div className={styles.container} data-tour="observations-page">
<nav className={styles.breadcrumb}>
<div className={preferencesStyles.container} data-tour="observations-page">
<nav className={preferencesStyles.breadcrumb}>
<Link href="/">← Back to Home</Link>
<span> / Saved Observations</span>
</nav>
<header className={styles.header} data-tour="observations-page">
<h1 className={styles.title}>Saved Observations</h1>
<p className={styles.subtitle}>Experiments where you've recorded and saved data</p>
<header className={preferencesStyles.header} data-tour="observations-page">
<h1 className={preferencesStyles.title}>Saved Observations</h1>
<p className={preferencesStyles.subtitle}>Tables of experimental data you have recorded across various labs</p>
</header>

{observationList.length > 0 ? (
<div className={homeStyles.resultsGrid}>
{observationList.map((exp) => (
<ExperimentCard
key={`${exp.labId}-${exp.id}`}
exp={exp}
/>
{savedTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2.5rem', marginTop: '2rem' }}>
{savedTables.map((tableData) => (
<div key={tableData.id} className={preferencesStyles.sectionCard}>
<div className={preferencesStyles.sectionHeader} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '1rem' }}>
<div>
<h3 style={{ margin: '0 0 0.5rem 0', color: 'var(--text-color)', fontSize: '1.4rem', fontWeight: '600' }}>{tableData.experimentName}</h3>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ background: 'var(--bg-color)', color: 'var(--secondary-color)', padding: '2px 8px', borderRadius: '4px', fontSize: '0.8rem', fontWeight: '500', border: '1px solid var(--border-color)' }}>
{tableData.labName}
</span>
<span>•</span>
<span>Section: {tableData.sectionTitle}</span>
</p>
</div>
<div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.8rem' }}>
Last updated: {new Date(tableData.updatedAt).toLocaleDateString()} at {new Date(tableData.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<Link
href={tableData.linkUrl}
className={preferencesStyles.saveBtn}
style={{ textDecoration: 'none', padding: '0.5rem 1rem', fontSize: '0.85rem' }}
>
Go to Experiment →
</Link>
</div>
</div>

<div className={preferencesStyles.sectionBody}>
<div className={expStyles.tableScroll}>
<table className={expStyles.table}>
<thead>
<tr>
{tableData.headers.length > 0 ? (
tableData.headers.map((h, i) => <th key={i}>{h}</th>)
) : (
<th colSpan={tableData.rows[0]?.length || 1}>No Table Headers Found</th>
)}
</tr>
</thead>
<tbody>
{tableData.rows.length > 0 ? (
tableData.rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => <td key={j}>{cell}</td>)}
</tr>
))
) : (
<tr>
<td colSpan={tableData.headers.length || 1} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '2rem' }}>
Table structure recognized, but no experimental rows saved.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
<span className={styles.emptyIcon}>📊</span>
<div className={preferencesStyles.emptyState}>
<span className={preferencesStyles.emptyIcon}>📊</span>
<h3>No observations saved</h3>
<p>When you edit and save tables within an experiment, they will appear here.</p>
</div>
Expand Down
14 changes: 11 additions & 3 deletions components/experiment/EditableTableBlock.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useAuth } from '@/context/AuthContext';
import { saveObservation, getSavedObservations } from '@/lib/db';
import { saveObservation, getSavedObservations, deleteObservation } from '@/lib/db';
import styles from './Experiment.module.css';

const PlotPanel = dynamic(() => import('./PlotPanel'), { ssr: false });
Expand Down Expand Up @@ -201,12 +201,20 @@ export default function EditableTableBlock({ block, sectionId, experimentId }) {
}
};

const handleReset = () => {
if (confirm("Reset table to its default experimental values? Current changes will be lost.")) {
const handleReset = async () => {
if (confirm("Reset table to its default experimental values? Current changes will be lost and deleted from the cloud.")) {
setCurrentRows(block.rows || []);
setIsEditing(false);
setIsTweaking(false);
setPreTweakRows(null);

if (user) {
const { error } = await deleteObservation(user.id, experimentId, sectionId);
if (error) console.error('Failed to reset observation in cloud', error);
} else {
const localKey = `${experimentId}-draftData-${sectionId}`;
localStorage.removeItem(localKey);
}
Comment on lines +204 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reset UI and cloud can diverge on delete failure.

Unlike handleSave (which reverts the UI when persistence fails), handleReset resets the UI to defaults before the cloud delete and only logs on error. If deleteObservation fails, the next loadSavedData re-hydrates currentRows from the still-present cloud row, silently undoing the reset. Consider surfacing the failure to the user (as handleSave does) so the divergence is visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/experiment/EditableTableBlock.js` around lines 204 - 217, The
handleReset flow in EditableTableBlock resets the UI before confirming the cloud
delete, so a failed deleteObservation can later rehydrate stale data and undo
the reset. Update handleReset to surface the delete failure to the user
similarly to handleSave: keep the reset action in sync with persistence by
either reverting the UI state or showing an explicit error when
deleteObservation fails, using the existing handleReset, deleteObservation,
setCurrentRows, and setIsEditing/setIsTweaking flow as the anchor points.

}
};

Expand Down
21 changes: 21 additions & 0 deletions lib/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use server';

import { getExperiment } from '@/data/experiments';

/**
* Server Action to fetch full experiment JSON payload.
* Useful for Client Components that need original structure (e.g. table headers).
*
* @param {string} slug - The lab slug (e.g. "sensor-lab")
* @param {string} experimentId - The experiment ID (e.g. "exp-1")
* @returns {object|null} The experiment data or null if not found
*/
export async function fetchExperimentData(slug, experimentId) {
try {
const experiment = await getExperiment(slug, experimentId);
return experiment;
} catch (error) {
console.error(`fetchExperimentData failed for ${slug}/${experimentId}`, error);
return null;
}
}
17 changes: 16 additions & 1 deletion lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export const getAllSavedObservations = async (userId) => {
if (!userId) return { data: [], error: 'No user ID' };
const { data, error } = await supabase
.from('saved_observations')
.select('experiment_id, section_id, updated_at')
.select('experiment_id, section_id, updated_at, data')
.eq('user_id', userId)
.order('updated_at', { ascending: false });
if (error) console.error('db:getAllSavedObservations error', error);
Expand Down Expand Up @@ -245,6 +245,21 @@ export const clearAllObservations = async (userId) => {
.delete()
.eq('user_id', userId);
if (error) console.error('db:clearAllObservations error', error);
else notifyWorkspaceUpdate();
return { error };
};

export const deleteObservation = async (userId, experimentId, sectionId) => {
if (!userId || !experimentId || !sectionId) return { error: 'Missing params' };
console.info('db:deleteObservation', { userId, experimentId, sectionId });
const { error } = await supabase
.from('saved_observations')
.delete()
.eq('user_id', userId)
.eq('experiment_id', String(experimentId))
.eq('section_id', String(sectionId));
if (error) console.error('db:deleteObservation error', error);
else notifyWorkspaceUpdate();
return { error };
};

Expand Down
6 changes: 5 additions & 1 deletion next.config.mjs

Large diffs are not rendered by default.