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);
}
}
};

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.