diff --git a/CONFORMANCE.md b/CONFORMANCE.md index f3b9f0a1..a9843d48 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -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. diff --git a/frontend/README.md b/frontend/README.md index f5a7b57b..c3431f32 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/app/garden-planner/page.js b/frontend/app/garden-planner/page.js new file mode 100644 index 00000000..218763ea --- /dev/null +++ b/frontend/app/garden-planner/page.js @@ -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 ; +} diff --git a/frontend/components/GardenPlanner.js b/frontend/components/GardenPlanner.js new file mode 100644 index 00000000..6b7b2a3c --- /dev/null +++ b/frontend/components/GardenPlanner.js @@ -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

Garden planner

Opening your garden bed…

; + + 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 ( +
+
+

AGRINET / GROWING TOGETHER

+

A little space.
A growing plan.

+

Choose a crop, then plant a square. See what fits and when it could be ready.

+
+ +
+ {dirty ? 'Unsaved plan' : 'Saved in this browser'} +
+ + + +
+
+

Your plan stays on this device. Saving uses this browser’s storage; export a file to move it.

+ {notice &&

{notice}

} + {(error || validationError) &&

{error || validationError}

} + {pendingResize &&
+

Resizing to {pendingResize.plan.layout[0].length} × {pendingResize.plan.layout.length} ft removes {pendingResize.removedSquares} planted {pendingResize.removedSquares === 1 ? 'square' : 'squares'}.

+
+ + +
+
} + +
+ + +
+

{plan.layout[0].length} × {plan.layout.length} ft bed

Each square = 1 sq ft
+

{selectedCrop ? `Planting ${cropNames[selectedCrop].toLowerCase()}. Select any square to place it.` : 'Erasing. Select a planted square to clear it.'}

+
+
+ {plan.layout.flatMap((row, r) => row.map((crop, c) => ))} +
+
+
+
Bed area
{plan.layout.length * plan.layout[0].length} sq ft
+
Planted squares
{analysis?.occupiedSquares ?? '—'}
+
Total plants
{analysis?.totalPlants ?? '—'}
+
+
+
+ + {analysis?.conflicts.length > 0 &&
+

Give these crops different neighbors

+
    {analysis.conflicts.map(pair =>
  • + {cropNames[pair.crops[0]]} ({squareLabel(pair.first)}) and {cropNames[pair.crops[1]]} ({squareLabel(pair.second)}) are incompatible in Agrinet’s current crop rules. +
  • )}
+

Choose another crop for either square, or erase it.

+
} + +
+

A look ahead

Estimated harvests
+ {!analysis ?

Choose a valid planting date to see estimates.

: analysis.crops.length === 0 ?

Your harvest starts with one square. Choose a crop above to begin.

:
+ + + {analysis.crops.map(crop => )} +
Crop counts and estimated harvest dates
CropSquaresPlantsEstimated harvest
{crop.squares}{crop.plants}
} +

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.

+
+
+ ); +} diff --git a/frontend/components/GardenPlanner.module.css b/frontend/components/GardenPlanner.module.css new file mode 100644 index 00000000..20588ace --- /dev/null +++ b/frontend/components/GardenPlanner.module.css @@ -0,0 +1,94 @@ +.planner { --ink: #243d31; --muted: #5c6a60; --line: #d5dfd4; color: var(--ink); padding: 32px 0 48px; } +.planner * { box-sizing: border-box; } +.heading { margin-bottom: 30px; } +.kicker { font-size: 11px; letter-spacing: .14em; font-weight: 700; color: #587258; margin: 0 0 16px; } +.heading h1 { font-family: Georgia, 'Times New Roman', serif; font-weight: 400; font-size: clamp(40px, 6vw, 62px); line-height: 1.06; letter-spacing: -.035em; margin: 0 0 18px; } +.heading h1 span { color: #617b45; } +.heading > p:last-child { color: var(--muted); font-size: 16px; line-height: 1.6; max-width: 540px; margin: 0; } +.toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; padding: 18px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.saveState { font-size: 13px; color: var(--muted); } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +.planner button, .importButton { font: inherit; font-size: 13px; border: 1px solid #bfcdbf; border-radius: 6px; padding: 10px 14px; background: #fff; color: var(--ink); cursor: pointer; min-height: 42px; transition: background .12s; } +.planner button:hover, .importButton:hover { background: #edf3e9; } +.planner button:focus-visible, .importButton:focus-within, .planner input:focus-visible { outline: 3px solid #557b2f; outline-offset: 3px; } +.planner button:disabled { cursor: not-allowed; opacity: .5; } +.planner .primary { background: #2d523b; color: white; border-color: #2d523b; } +.planner .primary:hover { background: #1e3d2a; } +.importButton { position: relative; display: inline-flex; align-items: center; overflow: hidden; } +.importButton input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +.privacy { color: var(--muted); font-size: 12px; line-height: 1.6; margin: 12px 0 24px; } +.notice, .error { padding: 13px 16px; border-radius: 5px; font-size: 14px; line-height: 1.5; } +.notice { background: #eaf3e7; border: 1px solid #c9dcc2; } +.error { background: #fff0e9; color: #82330f; border: 1px solid #e5bba5; } +.workspace { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 34px; margin-top: 28px; } +.planner h2 { font-size: 17px; letter-spacing: -.02em; margin: 0 0 18px; } +.sidebar { min-width: 0; } +.dimensions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.planner label { font-size: 12px; color: var(--muted); } +.planner input:not([type='file']) { width: 100%; border: 1px solid #bccab9; padding: 10px; border-radius: 5px; margin-top: 8px; background: #fff; color: var(--ink); font: inherit; min-height: 42px; } +.sizeForm > button { width: 100%; margin-top: 10px; } +.dateField { display: block; margin-top: 20px; } +.planner .cropHeading { margin-top: 30px; } +.palette { display: grid; gap: 8px; } +.planner .cropOption { display: flex; align-items: center; gap: 12px; text-align: left; padding: 10px; position: relative; } +.planner .cropOption[aria-pressed='true'] { border-color: #42653e; box-shadow: 0 0 0 1px #42653e; background: #f1f5ed; } +.cropOption strong, .cropOption small { display: block; } +.cropOption strong { font-size: 13px; font-weight: 600; } +.cropOption small { font-size: 11px; color: var(--muted); margin-top: 3px; } +.cropMark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; font: 19px Georgia, serif; flex-shrink: 0; } +.selectedMark { margin-left: auto; color: #3f633d; font-size: 16px; } +.planner .erase { margin-top: 4px; background: transparent; border-style: dashed; } +.planner .erase[aria-pressed='true'] { border-style: solid; border-color: #42653e; background: #edf3e9; } +.bedPanel { min-width: 0; } +.bedHeader { display: flex; gap: 12px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; } +.bedHeader h2 { margin-bottom: 0; } +.bedHeader > span { font-size: 11px; color: var(--muted); } +.instruction { font-size: 12px; color: var(--muted); line-height: 1.5; margin: 10px 0 18px; } +.bedScroll { overflow-x: auto; padding: 5px; margin: -5px; } +.bed { display: grid; grid-template-columns: repeat(var(--columns), minmax(65px, 1fr)); gap: 7px; padding: 12px; background: #e6e9da; border: 1px solid #c5ccb8; border-radius: 9px; min-width: min-content; } +.planner .square { aspect-ratio: 1; border-radius: 5px; min-height: 65px; padding: 6px 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; position: relative; border: 1px solid #ffffffa0; } +.planner .square:hover { outline: 2px solid #53744a; outline-offset: -2px; filter: brightness(.97); } +.square strong { font: 29px Georgia, serif; line-height: 1; } +.square > span:not(.conflictMark) { font-size: 12px; font-weight: 600; } +.square small { font-size: 10px; opacity: .85; } +.tomato, .planner .tomato { background: #f0d9cf; color: #823b29; } +.lettuce, .planner .lettuce { background: #d7e5b9; color: #36522d; } +.carrot, .planner .carrot { background: #f2dfb7; color: #825116; } +.basil, .planner .basil { background: #c9ddd1; color: #2b5847; } +.planner .empty { background: #f8f9f0; border: 1px dashed #c5cbb7; color: #6b795f; } +.square.empty > span { font-size: 25px; font-weight: 300; line-height: 1; } +.planner .conflictSquare { border: 2px solid #a85c23; } +.conflictMark { position: absolute; right: 3px; top: 3px; width: 16px; height: 16px; background: #85400f; color: #fff; font-size: 11px; font-weight: 700; border-radius: 50%; } +.totals { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; border-top: 1px solid var(--line); margin: 24px 0 0; padding-top: 20px; } +.totals dt { font-size: 11px; color: var(--muted); } +.totals dd { margin: 7px 0 0; font: 29px Georgia, serif; } +.totals dd small { font: 12px -apple-system, BlinkMacSystemFont, sans-serif; color: var(--muted); } +.conflicts { background: #fff5e5; border-left: 3px solid #b97a30; padding: 20px; margin-top: 26px; font-size: 13px; line-height: 1.6; } +.conflicts h2 { margin-bottom: 8px; color: #74461b; } +.conflicts ul { padding-left: 18px; } +.conflicts p { margin-bottom: 0; color: #74461b; } +.harvest { border-top: 1px solid var(--line); margin-top: 40px; padding-top: 24px; } +.harvestHeading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; } +.harvestHeading h2 { font: 28px Georgia, serif; } +.harvestHeading > span { color: var(--muted); font-size: 12px; } +.emptyHarvest { padding: 20px 0; color: var(--muted); font-size: 14px; } +.tableScroll { overflow-x: auto; } +.harvest table { border-collapse: collapse; width: 100%; text-align: left; white-space: nowrap; } +.harvest thead th { font-size: 11px; font-weight: 500; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--line); } +.harvest tbody th, .harvest td { padding: 15px 12px; font-size: 13px; border-bottom: 1px solid #e7ece3; } +.harvest tbody th { font-weight: 600; } +.tableDot { display: inline-block; width: 11px; height: 11px; margin-right: 10px; border: 1px solid currentColor; border-radius: 50%; } +.estimateNote { color: var(--muted); font-size: 12px; line-height: 1.7; margin-top: 20px; max-width: 690px; } +.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } +@media (max-width: 680px) { + .planner { padding-top: 20px; } + .workspace { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .palette { grid-template-columns: 1fr 1fr; } + .palette .erase { grid-column: 1 / -1; } + .cropMark { width: 28px; height: 28px; } + .heading h1 { font-size: 44px; } + .planner .cropHeading { margin-top: 24px; } + .bed { grid-template-columns: repeat(var(--columns), minmax(60px, 1fr)); } + .harvest tbody th, .harvest td, .harvest thead th { padding-left: 6px; padding-right: 6px; } +} +@media (prefers-reduced-motion: reduce) { .planner button { transition: none; } } diff --git a/frontend/components/Header.js b/frontend/components/Header.js index 3660c4c3..1fead598 100644 --- a/frontend/components/Header.js +++ b/frontend/components/Header.js @@ -5,6 +5,7 @@ export default function Header() {
diff --git a/frontend/docs/screenshots/garden-planner-conflicts.png b/frontend/docs/screenshots/garden-planner-conflicts.png new file mode 100644 index 00000000..3bdb62e7 Binary files /dev/null and b/frontend/docs/screenshots/garden-planner-conflicts.png differ diff --git a/frontend/docs/screenshots/garden-planner.png b/frontend/docs/screenshots/garden-planner.png new file mode 100644 index 00000000..6cfc38d7 Binary files /dev/null and b/frontend/docs/screenshots/garden-planner.png differ diff --git a/frontend/lib/gardenPlanner.js b/frontend/lib/gardenPlanner.js new file mode 100644 index 00000000..88eff0ad --- /dev/null +++ b/frontend/lib/gardenPlanner.js @@ -0,0 +1,116 @@ +const { + PLANT_SPACING, arePlantsCompatible, getPlantsPerSquare, projectGrowth, +} = require('../../backend/utils/squareFootGardening'); + +const CROPS = Object.keys(PLANT_SPACING); +const STORAGE_KEY = 'agrinet:garden-plan:v1'; +const MAX_PLAN_BYTES = 32768; + +function checkDimensions(rows, columns) { + if (![rows, columns].every(value => Number.isInteger(value) && value >= 1 && value <= 8)) { + throw new Error('Bed dimensions must be whole numbers from 1 to 8 feet.'); + } +} + +function checkDate(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) { + throw new Error('Choose a valid planting date.'); + } + const parsed = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().split('T')[0] !== value) { + throw new Error('Choose a valid planting date.'); + } +} + +function validatePlan(plan) { + if (!plan || plan.version !== 1 || !Array.isArray(plan.layout) || !Array.isArray(plan.layout[0])) { + throw new Error('This is not a supported Agrinet garden plan.'); + } + checkDate(plan.startDate); + checkDimensions(plan.layout.length, plan.layout[0].length); + if (!plan.layout.every(row => Array.isArray(row) && row.length === plan.layout[0].length)) { + throw new Error('The garden bed must be rectangular.'); + } + if (!plan.layout.every(row => row.every(crop => crop === null || CROPS.includes(crop)))) { + throw new Error('The plan contains an unsupported crop.'); + } + return plan; +} + +function createPlan(rows, columns, startDate) { + checkDimensions(rows, columns); + checkDate(startDate); + return { version: 1, startDate, layout: Array.from({ length: rows }, () => Array(columns).fill(null)) }; +} + +function resizePlan(plan, rows, columns) { + validatePlan(plan); + const resized = createPlan(rows, columns, plan.startDate); + resized.layout = resized.layout.map((row, r) => row.map((_, c) => plan.layout[r]?.[c] ?? null)); + return resized; +} + +function plantSquare(plan, row, column, crop) { + validatePlan(plan); + if (!Number.isInteger(row) || !Number.isInteger(column) || row < 0 || column < 0 || row >= plan.layout.length || column >= plan.layout[0].length) { + throw new Error('Choose a square inside the garden bed.'); + } + if (crop !== null && !CROPS.includes(crop)) throw new Error('Choose a supported crop.'); + return { ...plan, layout: plan.layout.map((cells, r) => cells.map((value, c) => r === row && c === column ? crop : value)) }; +} + +function previewResize(plan, rows, columns) { + const resized = resizePlan(plan, rows, columns); + return { plan: resized, removedSquares: analyzePlan(plan).occupiedSquares - analyzePlan(resized).occupiedSquares }; +} + +function analyzePlan(plan) { + validatePlan(plan); + const counts = new Map(); + const conflicts = []; + plan.layout.forEach((row, r) => row.forEach((crop, c) => { + if (!crop) return; + counts.set(crop, (counts.get(crop) || 0) + 1); + for (const [nr, nc] of [[r, c + 1], [r + 1, c]]) { + const neighbor = plan.layout[nr]?.[nc]; + if (neighbor && !arePlantsCompatible(crop, neighbor)) { + conflicts.push({ first: [r, c], second: [nr, nc], crops: [crop, neighbor] }); + } + } + })); + const crops = Array.from(counts, ([crop, squares]) => ({ + crop, squares, plants: squares * getPlantsPerSquare(crop), + harvestDate: projectGrowth(crop, plan.startDate).harvestDate.toISOString().split('T')[0], + })).sort((a, b) => a.harvestDate.localeCompare(b.harvestDate) || a.crop.localeCompare(b.crop)); + return { + area: plan.layout.length * plan.layout[0].length, + occupiedSquares: crops.reduce((sum, crop) => sum + crop.squares, 0), + totalPlants: crops.reduce((sum, crop) => sum + crop.plants, 0), + crops, conflicts, + }; +} + +function serializePlan(plan) { + validatePlan(plan); + return JSON.stringify({ version: 1, startDate: plan.startDate, layout: plan.layout }, null, 2); +} + +function parsePlan(text) { + if (typeof text !== 'string' || text.length > MAX_PLAN_BYTES) throw new Error('Plan file is too large. Maximum size is 32 KB.'); + let plan; + try { plan = JSON.parse(text); } catch { throw new Error('Choose a valid garden plan JSON file.'); } + validatePlan(plan); + // Only portable planning fields survive import; no extra metadata is retained. + return JSON.parse(serializePlan(plan)); +} + +function saveLocalPlan(storage, plan) { + storage.setItem(STORAGE_KEY, serializePlan(plan)); +} + +function loadLocalPlan(storage) { + const saved = storage.getItem(STORAGE_KEY); + return saved === null ? null : parsePlan(saved); +} + +module.exports = { CROPS, MAX_PLAN_BYTES, createPlan, analyzePlan, resizePlan, previewResize, plantSquare, serializePlan, parsePlan, saveLocalPlan, loadLocalPlan }; diff --git a/frontend/package.json b/frontend/package.json index 9fcb21a6..8a457185 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint --ext .js,.jsx,.mjs . --max-warnings 0", - "test": "next build", + "test": "npm run test:planner && next build", + "test:planner": "node --test tests/gardenPlanner.test.cjs", "federation-status": "node federationStatusCLI.js", "federation-status:ci": "BACKEND_URL=$BACKEND_URL node federationStatusCLI.js", "test-connection": "node test-connection.js" diff --git a/frontend/tests/gardenPlanner.test.cjs b/frontend/tests/gardenPlanner.test.cjs new file mode 100644 index 00000000..275b0fbc --- /dev/null +++ b/frontend/tests/gardenPlanner.test.cjs @@ -0,0 +1,103 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + createPlan, analyzePlan, resizePlan, previewResize, plantSquare, serializePlan, parsePlan, saveLocalPlan, loadLocalPlan, +} = require('../lib/gardenPlanner'); + +test('counts plants using existing spacing and estimates harvest across a leap day', () => { + const plan = { version: 1, startDate: '2028-02-01', layout: [['tomato', 'basil'], ['lettuce', null]] }; + const result = analyzePlan(plan); + assert.equal(result.area, 4); + assert.equal(result.occupiedSquares, 3); + assert.equal(result.totalPlants, 6); + assert.deepEqual(result.crops, [ + { crop: 'lettuce', squares: 1, plants: 4, harvestDate: '2028-03-27' }, + { crop: 'basil', squares: 1, plants: 1, harvestDate: '2028-04-01' }, + { crop: 'tomato', squares: 1, plants: 1, harvestDate: '2028-04-11' }, + ]); + assert.deepEqual(result.conflicts, []); +}); + +test('reports each incompatible adjacent pair once, without treating diagonals as neighbors', () => { + const result = analyzePlan({ version: 1, startDate: '2026-03-01', layout: [['tomato', 'carrot'], ['basil', 'lettuce']] }); + assert.deepEqual(result.conflicts, [{ first: [0, 0], second: [0, 1], crops: ['tomato', 'carrot'] }, { first: [1, 0], second: [1, 1], crops: ['basil', 'lettuce'] }]); +}); + +test('an empty bed has zero plants and no invented crop estimates', () => { + const result = analyzePlan(createPlan(3, 4, '2026-03-01')); + assert.equal(result.area, 12); + assert.equal(result.totalPlants, 0); + assert.equal(result.occupiedSquares, 0); + assert.deepEqual(result.crops, []); +}); + +test('planting and erasing update only the target square without mutating the original', () => { + const original = createPlan(2, 2, '2026-03-01'); + const planted = plantSquare(original, 0, 1, 'carrot'); + assert.deepEqual(planted.layout, [[null, 'carrot'], [null, null]]); + assert.equal(analyzePlan(planted).totalPlants, 16); + assert.equal(original.layout[0][1], null); + assert.equal(plantSquare(planted, 0, 1, null).layout[0][1], null); + assert.throws(() => plantSquare(original, 2, 0, 'basil'), /square/i); + assert.throws(() => plantSquare(original, 0, 0, 'potato'), /crop/i); +}); + +test('resizing preserves overlapping squares and fills new squares without shared rows', () => { + const plan = { version: 1, startDate: '2026-03-01', layout: [['tomato', null], [null, 'basil']] }; + const grown = resizePlan(plan, 3, 3); + assert.deepEqual(grown.layout, [['tomato', null, null], [null, 'basil', null], [null, null, null]]); + grown.layout[2][0] = 'lettuce'; + assert.equal(grown.layout[0][0], 'tomato'); + assert.deepEqual(resizePlan(plan, 1, 1).layout, [['tomato']]); + assert.equal(plan.layout[1][1], 'basil'); +}); + +test('previewing a smaller bed reports planted squares that would be removed without changing the plan', () => { + const plan = { version: 1, startDate: '2026-03-01', layout: [['tomato', 'basil'], ['lettuce', null]] }; + const preview = previewResize(plan, 1, 1); + assert.equal(preview.removedSquares, 2); + assert.deepEqual(preview.plan.layout, [['tomato']]); + assert.deepEqual(plan.layout, [['tomato', 'basil'], ['lettuce', null]]); + assert.equal(previewResize(plan, 3, 3).removedSquares, 0); +}); + +test('export and import preserve an editable plan, excluding unrelated fields', () => { + const plan = { version: 1, startDate: '2026-03-01', layout: [['basil', 'tomato']], privateNote: 'must not export' }; + const serialized = serializePlan(plan); + assert.deepEqual(JSON.parse(serialized), { version: 1, startDate: '2026-03-01', layout: [['basil', 'tomato']] }); + assert.deepEqual(parsePlan(serialized), { version: 1, startDate: '2026-03-01', layout: [['basil', 'tomato']] }); + assert.equal(analyzePlan(parsePlan(serialized)).totalPlants, 2); +}); + +test('rejects malformed, oversized, unsupported and nonrectangular imported plans', () => { + for (const invalid of [ + 'not JSON', ' '.repeat(33000), 'null', + JSON.stringify({ version: 2, startDate: '2026-03-01', layout: [[null]] }), + JSON.stringify({ version: 1, startDate: '2026-02-30', layout: [[null]] }), + JSON.stringify({ version: 1, startDate: '2026-03-01T00:00:00Z', layout: [[null]] }), + JSON.stringify({ version: 1, startDate: '2026-03-01', layout: [['potato']] }), + JSON.stringify({ version: 1, startDate: '2026-03-01', layout: [[null], [null, null]] }), + JSON.stringify({ version: 1, startDate: '2026-03-01', layout: [] }), + ]) assert.throws(() => parsePlan(invalid), /plan|date|crop|bed/i); + for (const size of [0, -1, 1.5, 9, NaN, '4']) assert.throws(() => createPlan(size, 4, '2026-03-01'), /bed/i); +}); + +test('saving and restoring preserves a portable plan in browser storage', () => { + const values = new Map(); + const storage = { setItem: (key, value) => values.set(key, value), getItem: key => values.get(key) ?? null }; + assert.equal(loadLocalPlan(storage), null); + const plan = { version: 1, startDate: '2026-03-01', layout: [['lettuce', null]] }; + saveLocalPlan(storage, plan); + assert.deepEqual(loadLocalPlan(storage), plan); + assert.equal(values.size, 1); + saveLocalPlan(storage, plantSquare(plan, 0, 1, 'carrot')); + assert.equal(analyzePlan(loadLocalPlan(storage)).totalPlants, 20); + assert.equal(values.size, 1); +}); + +test('storage failures and damaged saved data surface instead of claiming a save succeeded', () => { + const plan = createPlan(1, 1, '2026-03-01'); + const unavailable = { setItem: () => { throw new Error('storage unavailable'); }, getItem: () => '{broken' }; + assert.throws(() => saveLocalPlan(unavailable, plan), /storage unavailable/); + assert.throws(() => loadLocalPlan(unavailable), /valid garden plan/i); +});