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
18 changes: 18 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,21 @@ Adopts the covenant scale (LBTAS, v2 floor) and carries its own record instance.
## Product-specific notes (last, per the ordering rule)

The reference economy. Its operator/federation model is the named reference for the substrate's frontend-as-operator design target.

### Browser garden planner

The Next.js `/garden-planner` page is a member-local planning aid, not a ledger
or a new protocol implementation. It reuses the existing square-foot gardening
utility for spacing, adjacent-crop rules, and fixed maturity estimates. The UI
states the limits of those estimates.

Saving is explicit and browser-local; versioned JSON export/import provides a
portable editable copy without account or vendor services. The format contains
only version, planting date, and crop layout. It is not sent to the shared record
or federation. No settlement, covenant, reputation, identity, or governance
binding changes, and the in-review statuses above remain in review.

`frontend/tests/gardenPlanner.test.cjs` checks crop totals, leap-day harvest
dates, adjacent-crop conflicts, resize impact, portable round-tripping, invalid
imports, and storage errors. This is feature verification, not a claim that the
delegated JFA invariants are now enforced by this repository.
32 changes: 32 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ This directory hosts the web interfaces for Fruitful.
- `app/` – Next.js site providing the main user interface.
- `chat-ui/` – Conversational interface built with Vite and React. See [chat-ui/ARCHITECTURE.md](chat-ui/ARCHITECTURE.md) for architectural details.

## Garden planner

Run `npm install` then `npm run dev` in this directory and open `/garden-planner`,
or follow **Garden planner** in the site navigation. This page needs no backend
server, account, API key, or external service.

Choose a bed from 1–8 feet in each dimension, set the planting date, select a crop,
then click or keyboard-activate a square. **Erase a square** clears a planting.
Counts, adjacent-crop warnings, and estimated harvest dates reuse
`backend/utils/squareFootGardening.js`. The four supported crops are tomato,
lettuce, carrot, and basil. Harvest estimates use fixed maturity intervals;
they do not account for climate, soil, variety, or the local growing season.

**Save plan** explicitly writes one plan to this browser's local storage.
**Export JSON** downloads a portable copy; **Import JSON** restores an editable
plan. Imports accept version 1 rectangular beds, supported crops, and a real
date, with a 32 KB file limit. Invalid imports leave the current bed intact.
Shrinking a bed requires confirmation if planted squares would be removed.
Browser storage can be cleared or unavailable, so export a file for a backup.
This is a local planning format, not a federated Plan protocol message.

Run `npm run test:planner` for dependency-free planner tests, `npm run lint` for
frontend lint, or `npm test` for planner tests followed by a production build.

Design context: [Agrinet's local-growing purpose (Slack, October 19, 2026)](https://ntari.slack.com/archives/C0AENHR5KG8/p1792416355639889)
and [modular planting coordination (Confluence)](https://ntari.atlassian.net/wiki/spaces/Agrinet/pages/6815770).
The current repository's `CLAUDE.md` and `CONFORMANCE.md` govern implementation;
older roadmap text does not supersede those invariants.

Screenshots: [editable bed](docs/screenshots/garden-planner.png) and
[neighbor conflicts](docs/screenshots/garden-planner-conflicts.png).

## Environment variables

Both sub-apps rely on environment variables to communicate with backend services.
Expand Down
10 changes: 10 additions & 0 deletions frontend/app/garden-planner/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import GardenPlanner from '../../components/GardenPlanner';

export const metadata = {
title: 'Garden planner · Agrinet',
description: 'Plan a square-foot garden with existing Agrinet crop spacing and harvest estimates. Your plan stays in your browser.',
};

export default function GardenPlannerPage() {
return <GardenPlanner />;
}
194 changes: 194 additions & 0 deletions frontend/components/GardenPlanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
'use client';

import { useEffect, useState } from 'react';
import {
CROPS, MAX_PLAN_BYTES, createPlan, analyzePlan, previewResize, plantSquare,
serializePlan, parsePlan, saveLocalPlan, loadLocalPlan,
} from '../lib/gardenPlanner';
import { getPlantsPerSquare } from '../../backend/utils/squareFootGardening';
import styles from './GardenPlanner.module.css';

const cropNames = { tomato: 'Tomato', lettuce: 'Lettuce', carrot: 'Carrot', basil: 'Basil' };
const dateLabel = value => new Date(`${value}T00:00:00Z`).toLocaleDateString('en', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
const squareLabel = ([r, c]) => `row ${r + 1}, column ${c + 1}`;

export default function GardenPlanner() {
const [plan, setPlan] = useState(null);
const [selectedCrop, setSelectedCrop] = useState('tomato');
const [dimensions, setDimensions] = useState({ rows: '4', columns: '4' });
const [savedText, setSavedText] = useState('');
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [pendingResize, setPendingResize] = useState(null);

useEffect(() => {
const today = new Date();
const localDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
let initial = createPlan(4, 4, localDate);
try {
const saved = loadLocalPlan(window.localStorage);
if (saved) {
initial = saved;
setSavedText(serializePlan(saved));
setNotice('Saved plan restored from this browser.');
}
} catch {
setError('Your saved plan could not be restored. You can still plan and export a file.');
}
setDimensions({ rows: String(initial.layout.length), columns: String(initial.layout[0].length) });
setPlan(initial);
}, []);

if (!plan) return <section className={styles.planner}><h1>Garden planner</h1><p role="status">Opening your garden bed…</p><noscript>Enable JavaScript to edit a bed. Plans are calculated and saved in your browser.</noscript></section>;

let analysis;
let validationError = '';
try { analysis = analyzePlan(plan); } catch (err) { validationError = err.message; }
const dirty = !analysis || serializePlan(plan) !== savedText;
const conflictSquares = new Set(analysis?.conflicts.flatMap(pair => [pair.first.join(','), pair.second.join(',')]) || []);

function edit(next) {
setPlan(next);
setNotice('');
setError('');
setPendingResize(null);
}

function applySize(event) {
event.preventDefault();
try {
const preview = previewResize(plan, Number(dimensions.rows), Number(dimensions.columns));
if (preview.removedSquares > 0) {
setPendingResize(preview);
return;
}
edit(preview.plan);
} catch (err) { setNotice(''); setError(err.message); }
}

function save() {
try {
saveLocalPlan(window.localStorage, plan);
setSavedText(serializePlan(plan));
setError('');
setNotice('Plan saved in this browser. Export a copy to keep it elsewhere.');
} catch { setNotice(''); setError('Could not save in this browser. Export your plan to keep a copy.'); }
}

function exportFile() {
const url = URL.createObjectURL(new Blob([serializePlan(plan)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'agrinet-garden-plan.json';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setNotice('Plan exported. Import the JSON file here to edit it again.');
}

async function importFile(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
if (file.size > MAX_PLAN_BYTES) throw new Error('Plan file is too large. Maximum size is 32 KB.');
const next = parsePlan(await file.text());
if (analysis?.occupiedSquares > 0 && !window.confirm('Replace the current bed with this imported plan? Unsaved changes will be lost.')) return;
edit(next);
setDimensions({ rows: String(next.layout.length), columns: String(next.layout[0].length) });
setNotice('Plan imported. Save in this browser to keep it after reloading.');
} catch (err) { setNotice(''); setError(err.message); }
}

return (
<section className={styles.planner}>
<div className={styles.heading}>
<p className={styles.kicker}>AGRINET / GROWING TOGETHER</p>
<h1>A little space.<br /><span>A growing plan.</span></h1>
<p>Choose a crop, then plant a square. See what fits and when it could be ready.</p>
</div>

<div className={styles.toolbar}>
<span className={styles.saveState}>{dirty ? 'Unsaved plan' : 'Saved in this browser'}</span>
<div className={styles.actions}>
<button type="button" className={styles.primary} disabled={!analysis} onClick={save}>Save plan</button>
<button type="button" disabled={!analysis} onClick={exportFile}>Export JSON</button>
<label className={styles.importButton}>Import JSON<input type="file" accept=".json,application/json" aria-label="Import garden plan" onChange={importFile} /></label>
</div>
</div>
<p className={styles.privacy}>Your plan stays on this device. Saving uses this browser’s storage; export a file to move it.</p>
{notice && <p className={styles.notice} role="status">{notice}</p>}
{(error || validationError) && <p className={styles.error} role="alert">{error || validationError}</p>}
{pendingResize && <div className={styles.conflicts} role="alert">
<p>Resizing to {pendingResize.plan.layout[0].length} × {pendingResize.plan.layout.length} ft removes {pendingResize.removedSquares} planted {pendingResize.removedSquares === 1 ? 'square' : 'squares'}.</p>
<div className={styles.actions}>
<button type="button" onClick={() => edit(pendingResize.plan)}>Resize and remove squares</button>
<button type="button" onClick={() => {
setPendingResize(null);
setDimensions({ rows: String(plan.layout.length), columns: String(plan.layout[0].length) });
}}>Keep current bed</button>
</div>
</div>}

<div className={styles.workspace}>
<aside className={styles.sidebar} aria-label="Bed settings and crops">
<h2>Your bed</h2>
<form onSubmit={applySize} className={styles.sizeForm}>
<div className={styles.dimensions}>
<label>Width (ft)<input type="number" min="1" max="8" step="1" required value={dimensions.columns} onChange={e => setDimensions({ ...dimensions, columns: e.target.value })} /></label>
<label>Length (ft)<input type="number" min="1" max="8" step="1" required value={dimensions.rows} onChange={e => setDimensions({ ...dimensions, rows: e.target.value })} /></label>
</div>
<button type="submit" disabled={!analysis}>Resize bed</button>
</form>
<label className={styles.dateField}>Planting date<input type="date" required value={plan.startDate} onChange={e => edit({ ...plan, startDate: e.target.value })} /></label>
<h2 className={styles.cropHeading}>Choose a crop</h2>
<div className={styles.palette}>
{CROPS.map(crop => <button key={crop} type="button" aria-pressed={selectedCrop === crop} onClick={() => setSelectedCrop(crop)} className={styles.cropOption}>
<span className={`${styles.cropMark} ${styles[crop]}`} aria-hidden="true">{cropNames[crop][0]}</span>
<span><strong>{cropNames[crop]}</strong><small>{getPlantsPerSquare(crop)} {getPlantsPerSquare(crop) === 1 ? 'plant' : 'plants'} / sq ft</small></span>
{selectedCrop === crop && <span className={styles.selectedMark} aria-hidden="true">✓</span>}
</button>)}
<button type="button" aria-pressed={selectedCrop === null} onClick={() => setSelectedCrop(null)} className={styles.erase}>Erase a square</button>
</div>
</aside>

<div className={styles.bedPanel}>
<div className={styles.bedHeader}><h2>{plan.layout[0].length} × {plan.layout.length} ft bed</h2><span>Each square = 1 sq ft</span></div>
<p className={styles.instruction}>{selectedCrop ? `Planting ${cropNames[selectedCrop].toLowerCase()}. Select any square to place it.` : 'Erasing. Select a planted square to clear it.'}</p>
<div className={styles.bedScroll}>
<div className={styles.bed} role="group" aria-label="Garden bed" style={{ '--columns': plan.layout[0].length }}>
{plan.layout.flatMap((row, r) => row.map((crop, c) => <button type="button" key={`${r}-${c}`} disabled={!analysis} onClick={() => edit(plantSquare(plan, r, c, selectedCrop))}
aria-label={`Row ${r + 1}, column ${c + 1}: ${crop ? cropNames[crop] : 'Empty'}`}
className={`${styles.square} ${crop ? styles[crop] : styles.empty} ${conflictSquares.has(`${r},${c}`) ? styles.conflictSquare : ''}`}>
{crop ? <><strong>{cropNames[crop][0]}</strong><span>{cropNames[crop]}</span><small>{getPlantsPerSquare(crop)} {getPlantsPerSquare(crop) === 1 ? 'plant' : 'plants'}</small></> : <><span aria-hidden="true">+</span><small>Plant here</small></>}
{conflictSquares.has(`${r},${c}`) && <span className={styles.conflictMark} aria-label="Neighbor conflict">!</span>}
</button>))}
</div>
</div>
<dl className={styles.totals} aria-live="polite">
<div><dt>Bed area</dt><dd>{plan.layout.length * plan.layout[0].length} <small>sq ft</small></dd></div>
<div><dt>Planted squares</dt><dd>{analysis?.occupiedSquares ?? '—'}</dd></div>
<div><dt>Total plants</dt><dd>{analysis?.totalPlants ?? '—'}</dd></div>
</dl>
</div>
</div>

{analysis?.conflicts.length > 0 && <section className={styles.conflicts} aria-labelledby="conflict-title" aria-live="polite">
<h2 id="conflict-title">Give these crops different neighbors</h2>
<ul>{analysis.conflicts.map(pair => <li key={pair.first.join(',') + ':' + pair.second.join(',')}>
{cropNames[pair.crops[0]]} ({squareLabel(pair.first)}) and {cropNames[pair.crops[1]]} ({squareLabel(pair.second)}) are incompatible in Agrinet’s current crop rules.
</li>)}</ul>
<p>Choose another crop for either square, or erase it.</p>
</section>}

<section className={styles.harvest} aria-labelledby="harvest-title">
<div className={styles.harvestHeading}><h2 id="harvest-title">A look ahead</h2><span>Estimated harvests</span></div>
{!analysis ? <p>Choose a valid planting date to see estimates.</p> : analysis.crops.length === 0 ? <p className={styles.emptyHarvest}>Your harvest starts with one square. Choose a crop above to begin.</p> : <div className={styles.tableScroll}><table>
<caption className={styles.srOnly}>Crop counts and estimated harvest dates</caption>
<thead><tr><th scope="col">Crop</th><th scope="col">Squares</th><th scope="col">Plants</th><th scope="col">Estimated harvest</th></tr></thead>
<tbody>{analysis.crops.map(crop => <tr key={crop.crop}><th scope="row"><span className={`${styles.tableDot} ${styles[crop.crop]}`} aria-hidden="true" />{cropNames[crop.crop]}</th><td>{crop.squares}</td><td>{crop.plants}</td><td><time dateTime={crop.harvestDate}>{dateLabel(crop.harvestDate)}</time></td></tr>)}</tbody>
</table></div>}
<p className={styles.estimateNote}>Uses Agrinet’s existing spacing, neighbor rules, and fixed days to maturity for four crops. Estimates do not account for variety, weather, soil, or your local growing season.</p>
</section>
</section>
);
}
Loading
Loading