Fix field import pipeline and deepen AG-Refine integration

- Data Ingest: after AI extraction, detect field names in structured data
  and offer one-click import of harvest records + crop history into matching
  field profiles; "Re-import" link persists on each ingested file card

- Field Profiles: expanded card now renders full Crop History table (year,
  crop, yield) and Harvest Records (date, yield, moisture) pulled from
  stored data; previously these were stored but never displayed

- Field Profiles: added separate Pull (sync from AG-Refine) and Push (write
  to AG-Refine localStorage) buttons making the integration bidirectional;
  synced fields fire a CustomEvent so a listening AG-Refine app can react

- Field Profiles: sync log panel shows last 5 syncs with timestamp, field
  counts, and source URL; card summary line shows "X yr history" count
  and source badge (manual / AG-Refine / AG-Refine + manual)

- Background: added AGREFINE_PUSH message handler that serializes all
  field profiles and calls pushToAgRefine

- Added seeder test (tests/seed_and_screenshot.mjs) with realistic
  Hendricks Family Farms data and screenshots of all 6 tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
Claude 2026-06-27 14:23:52 +00:00
parent 9b17585756
commit bd9ca7e8dc
No known key found for this signature in database
17 changed files with 1439 additions and 275 deletions

View file

@ -12,6 +12,7 @@ __webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ getAgRefineUrl: () => (/* binding */ getAgRefineUrl),
/* harmony export */ getSyncLog: () => (/* binding */ getSyncLog),
/* harmony export */ pushToAgRefine: () => (/* binding */ pushToAgRefine),
/* harmony export */ setAgRefineUrl: () => (/* binding */ setAgRefineUrl),
/* harmony export */ syncFromAgRefine: () => (/* binding */ syncFromAgRefine)
/* harmony export */ });
@ -267,38 +268,117 @@ function extractLoads(raw) {
}
return loads;
}
function syncFromAgRefine() {
return _syncFromAgRefine.apply(this, arguments);
// Injected into AG-Refine tab to write field data back into its localStorage
function writeFieldsToAgRefineTab(fields) {
try {
localStorage.setItem('agrifine_pushed_fields', JSON.stringify(fields));
localStorage.setItem('agrifine_pushed_at', new Date().toISOString());
// Dispatch an event so a listening AG-Refine app can react immediately
window.dispatchEvent(new CustomEvent('agrifine:fields-updated', {
detail: {
fields: fields
}
}));
return {
ok: true,
count: fields.length
};
} catch (err) {
return {
ok: false,
error: err.message
};
}
}
function _syncFromAgRefine() {
_syncFromAgRefine = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee4() {
var configuredUrl, allTabs, agRefineTabs, tab, raw, _yield$chrome$scripti, _yield$chrome$scripti2, result, fields, loads, existing, added, updated, _iterator3, _step3, _loop, log, history, _t7, _t8;
return _regenerator().w(function (_context5) {
while (1) switch (_context5.p = _context5.n) {
function pushToAgRefine(_x2) {
return _pushToAgRefine.apply(this, arguments);
}
function _pushToAgRefine() {
_pushToAgRefine = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee4(profiles) {
var configuredUrl, allTabs, agRefineTabs, tab, _yield$chrome$scripti, _yield$chrome$scripti2, result, _t7;
return _regenerator().w(function (_context4) {
while (1) switch (_context4.p = _context4.n) {
case 0:
_context5.n = 1;
_context4.n = 1;
return getAgRefineUrl();
case 1:
configuredUrl = _context5.v;
_context5.n = 2;
configuredUrl = _context4.v;
_context4.n = 2;
return chrome.tabs.query({});
case 2:
allTabs = _context5.v;
allTabs = _context4.v;
agRefineTabs = allTabs.filter(function (t) {
return tabMatchesAgRefine(t, configuredUrl);
});
if (!(agRefineTabs.length === 0)) {
_context5.n = 3;
_context4.n = 3;
break;
}
return _context5.a(2, {
return _context4.a(2, {
ok: false,
error: 'No AG-Refine tab found. Open AG-Refine first.'
});
case 3:
tab = agRefineTabs[0];
_context4.p = 4;
_context4.n = 5;
return chrome.scripting.executeScript({
target: {
tabId: tab.id
},
func: writeFieldsToAgRefineTab,
args: [profiles]
});
case 5:
_yield$chrome$scripti = _context4.v;
_yield$chrome$scripti2 = _slicedToArray(_yield$chrome$scripti, 1);
result = _yield$chrome$scripti2[0];
return _context4.a(2, result.result);
case 6:
_context4.p = 6;
_t7 = _context4.v;
return _context4.a(2, {
ok: false,
error: "Cannot write to AG-Refine tab: ".concat(_t7.message)
});
}
}, _callee4, null, [[4, 6]]);
}));
return _pushToAgRefine.apply(this, arguments);
}
function syncFromAgRefine() {
return _syncFromAgRefine.apply(this, arguments);
}
function _syncFromAgRefine() {
_syncFromAgRefine = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee5() {
var configuredUrl, allTabs, agRefineTabs, tab, raw, _yield$chrome$scripti3, _yield$chrome$scripti4, result, fields, loads, existing, added, updated, _iterator3, _step3, _loop, log, history, _t8, _t9;
return _regenerator().w(function (_context6) {
while (1) switch (_context6.p = _context6.n) {
case 0:
_context6.n = 1;
return getAgRefineUrl();
case 1:
configuredUrl = _context6.v;
_context6.n = 2;
return chrome.tabs.query({});
case 2:
allTabs = _context6.v;
agRefineTabs = allTabs.filter(function (t) {
return tabMatchesAgRefine(t, configuredUrl);
});
if (!(agRefineTabs.length === 0)) {
_context6.n = 3;
break;
}
return _context6.a(2, {
ok: false,
error: 'No AG-Refine tab found. Open AG-Refine in a browser tab first.'
});
case 3:
tab = agRefineTabs[0];
_context5.p = 4;
_context5.n = 5;
_context6.p = 4;
_context6.n = 5;
return chrome.scripting.executeScript({
target: {
tabId: tab.id
@ -306,41 +386,41 @@ function _syncFromAgRefine() {
func: scrapeAgRefineTab
});
case 5:
_yield$chrome$scripti = _context5.v;
_yield$chrome$scripti2 = _slicedToArray(_yield$chrome$scripti, 1);
result = _yield$chrome$scripti2[0];
_yield$chrome$scripti3 = _context6.v;
_yield$chrome$scripti4 = _slicedToArray(_yield$chrome$scripti3, 1);
result = _yield$chrome$scripti4[0];
raw = result.result;
_context5.n = 7;
_context6.n = 7;
break;
case 6:
_context5.p = 6;
_t7 = _context5.v;
return _context5.a(2, {
_context6.p = 6;
_t8 = _context6.v;
return _context6.a(2, {
ok: false,
error: "Cannot read AG-Refine tab: ".concat(_t7.message)
error: "Cannot read AG-Refine tab: ".concat(_t8.message)
});
case 7:
fields = extractFields(raw);
loads = extractLoads(raw); // Merge fields — update existing by name, insert new ones
_context5.n = 8;
_context6.n = 8;
return (0,_storage_js__WEBPACK_IMPORTED_MODULE_0__.getFieldProfiles)();
case 8:
existing = _context5.v;
existing = _context6.v;
added = 0;
updated = 0;
_iterator3 = _createForOfIteratorHelper(fields);
_context5.p = 9;
_context6.p = 9;
_loop = /*#__PURE__*/_regenerator().m(function _loop() {
var f, match, _match$coordinates, _match$cropHistory, _match$notes, _match$cluId, merged;
return _regenerator().w(function (_context4) {
while (1) switch (_context4.n) {
return _regenerator().w(function (_context5) {
while (1) switch (_context5.n) {
case 0:
f = _step3.value;
match = existing.find(function (e) {
return e.name.toLowerCase() === f.name.toLowerCase();
});
if (!match) {
_context4.n = 2;
_context5.n = 2;
break;
}
// Merge: fill in missing data without overwriting user edits
@ -351,43 +431,43 @@ function _syncFromAgRefine() {
cluId: (_match$cluId = match.cluId) !== null && _match$cluId !== void 0 ? _match$cluId : f.cluId,
_source: 'ag-refine-merged'
});
_context4.n = 1;
_context5.n = 1;
return (0,_storage_js__WEBPACK_IMPORTED_MODULE_0__.saveFieldProfile)(merged);
case 1:
updated++;
_context4.n = 4;
_context5.n = 4;
break;
case 2:
_context4.n = 3;
_context5.n = 3;
return (0,_storage_js__WEBPACK_IMPORTED_MODULE_0__.saveFieldProfile)(f);
case 3:
added++;
case 4:
return _context4.a(2);
return _context5.a(2);
}
}, _loop);
});
_iterator3.s();
case 10:
if ((_step3 = _iterator3.n()).done) {
_context5.n = 12;
_context6.n = 12;
break;
}
return _context5.d(_regeneratorValues(_loop()), 11);
return _context6.d(_regeneratorValues(_loop()), 11);
case 11:
_context5.n = 10;
_context6.n = 10;
break;
case 12:
_context5.n = 14;
_context6.n = 14;
break;
case 13:
_context5.p = 13;
_t8 = _context5.v;
_iterator3.e(_t8);
_context6.p = 13;
_t9 = _context6.v;
_iterator3.e(_t9);
case 14:
_context5.p = 14;
_context6.p = 14;
_iterator3.f();
return _context5.f(14);
return _context6.f(14);
case 15:
log = {
at: new Date().toISOString(),
@ -397,15 +477,15 @@ function _syncFromAgRefine() {
loadsFound: loads.length,
rawKeys: Object.keys(_objectSpread(_objectSpread({}, raw.localStorage), raw.sessionStorage))
};
_context5.n = 16;
_context6.n = 16;
return getSyncLog();
case 16:
history = _context5.v;
history = _context6.v;
history.unshift(log);
_context5.n = 17;
_context6.n = 17;
return (0,_storage_js__WEBPACK_IMPORTED_MODULE_0__.localSet)(SYNC_LOG_KEY, history.slice(0, 20));
case 17:
return _context5.a(2, {
return _context6.a(2, {
ok: true,
added: added,
updated: updated,
@ -414,7 +494,7 @@ function _syncFromAgRefine() {
tabUrl: tab.url
});
}
}, _callee4, null, [[9, 13, 14, 15], [4, 6]]);
}, _callee5, null, [[9, 13, 14, 15], [4, 6]]);
}));
return _syncFromAgRefine.apply(this, arguments);
}
@ -1391,6 +1471,16 @@ chrome.runtime.onMessage.addListener(function (message, _sender, sendResponse) {
});
});
return true;
case 'AGREFINE_PUSH':
(0,_utils_storage_js__WEBPACK_IMPORTED_MODULE_0__.getFieldProfiles)().then(function (profiles) {
return (0,_utils_agrefine_bridge_js__WEBPACK_IMPORTED_MODULE_2__.pushToAgRefine)(profiles);
}).then(sendResponse)["catch"](function (err) {
return sendResponse({
ok: false,
error: err.message
});
});
return true;
default:
return false;
}

View file

@ -579,6 +579,9 @@ video {
.mt-2 {
margin-top: 0.5rem;
}
.mt-2\.5 {
margin-top: 0.625rem;
}
.mt-3 {
margin-top: 0.75rem;
}
@ -609,6 +612,9 @@ video {
.h-3 {
height: 0.75rem;
}
.h-3\.5 {
height: 0.875rem;
}
.h-4 {
height: 1rem;
}
@ -645,6 +651,9 @@ video {
.w-3 {
width: 0.75rem;
}
.w-3\.5 {
width: 0.875rem;
}
.w-4 {
width: 1rem;
}
@ -663,6 +672,9 @@ video {
.min-w-0 {
min-width: 0px;
}
.max-w-\[180px\] {
max-width: 180px;
}
.max-w-\[85\%\] {
max-width: 85%;
}
@ -730,11 +742,21 @@ video {
.gap-y-0\.5 {
row-gap: 0.125rem;
}
.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.125rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.125rem * var(--tw-space-y-reverse));
}
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.25rem * var(--tw-space-y-reverse));
}
.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.375rem * var(--tw-space-y-reverse));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
@ -857,6 +879,10 @@ video {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-1\.5 {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@ -968,6 +994,9 @@ video {
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.leading-none {
line-height: 1;
}
@ -1014,6 +1043,10 @@ video {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.text-night-300 {
--tw-text-opacity: 1;
color: rgb(61 79 102 / var(--tw-text-opacity, 1));
@ -1026,6 +1059,9 @@ video {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.underline {
text-decoration-line: underline;
}
.accent-agri-500 {
accent-color: #22c55e;
}
@ -1226,6 +1262,9 @@ body {
.last\:border-0:last-child {
border-width: 0px;
}
.last\:pb-0:last-child {
padding-bottom: 0px;
}
.hover\:border-agri-500:hover {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
@ -1261,6 +1300,9 @@ body {
.hover\:underline:hover {
text-decoration-line: underline;
}
.hover\:no-underline:hover {
text-decoration-line: none;
}
.disabled\:bg-night-500:disabled {
--tw-bg-opacity: 1;
background-color: rgb(37 48 71 / var(--tw-bg-opacity, 1));

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -1,6 +1,6 @@
import { sessionGet, sessionSet, localGet, localSet, KEYS } from '../utils/storage.js';
import { sessionGet, sessionSet, localGet, localSet, KEYS, getFieldProfiles } from '../utils/storage.js';
import { fetchAnthropic } from '../utils/api.js';
import { syncFromAgRefine } from '../utils/agrefine-bridge.js';
import { syncFromAgRefine, pushToAgRefine } from '../utils/agrefine-bridge.js';
// Open the side panel when the action icon is clicked
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(console.error);
@ -67,6 +67,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true;
case 'AGREFINE_PUSH':
getFieldProfiles()
.then((profiles) => pushToAgRefine(profiles))
.then(sendResponse)
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true;
default:
return false;
}

View file

@ -1,4 +1,4 @@
import { getIngestedFiles, saveIngestedFile, deleteIngestedFile } from '../../utils/storage.js';
import { getIngestedFiles, saveIngestedFile, deleteIngestedFile, getFieldProfiles, saveFieldProfile } from '../../utils/storage.js';
import { callAnthropic } from '../../utils/api.js';
const SUPPORTED_TYPES = {
@ -47,10 +47,24 @@ export function DataIngestModule() {
<div id="ingest-status" class="text-xs text-center text-gray-500 mt-2 min-h-[1rem]"></div>
</div>
<!-- Field import banner (shown after processing if field data found) -->
<div id="field-import-banner" class="hidden px-4 mb-3">
<div class="bg-night-700 border border-agri-600 rounded-xl p-3">
<p class="text-xs font-semibold text-agri-400 mb-1">Field data detected</p>
<p id="field-import-desc" class="text-xs text-gray-400 mb-2"></p>
<p id="field-import-status" class="text-xs text-gray-500 mb-2 hidden"></p>
<button id="import-to-fields-btn"
class="w-full bg-agri-600 hover:bg-agri-700 text-white text-xs font-medium py-1.5 rounded-lg transition">
Import harvest data to field profiles
</button>
</div>
</div>
<!-- File list -->
<div id="file-list" class="px-4 pb-4"></div>
`;
this._pendingImport = null;
this._bindEvents(container);
await this._renderFileList(container);
},
@ -80,6 +94,10 @@ export function DataIngestModule() {
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) this._processFile(fileInput.files[0], container);
});
container.querySelector('#import-to-fields-btn').addEventListener('click', () =>
this._importToFields(container)
);
},
async _processFile(file, container) {
@ -94,6 +112,10 @@ export function DataIngestModule() {
return;
}
// Hide previous import banner
container.querySelector('#field-import-banner').classList.add('hidden');
this._pendingImport = null;
status.textContent = `Parsing ${typeName}`;
let extractedText = '';
@ -121,7 +143,7 @@ export function DataIngestModule() {
try {
const raw = await callAnthropic({
system: 'You are an agricultural data analyst. Extract and return structured JSON from this document. Identify: operation type, field names, dates, quantities, equipment, crop types, financial figures, and any carbon or emissions data. Return only valid JSON.',
system: 'You are an agricultural data analyst. Extract and return structured JSON from this document. Identify: operation type, field names (as "fields" array of strings), dates, quantities, equipment, crop types, financial figures, and any carbon or emissions data. For harvest data include avg_yield_bu_ac, avg_moisture_pct, harvest_date, and crop. Return only valid JSON.',
userMessage: extractedText.slice(0, 6000),
maxTokens: 1024,
});
@ -137,7 +159,7 @@ export function DataIngestModule() {
uploadedAt: new Date().toISOString(),
structuredData,
preview: Object.entries(structuredData ?? {})
.filter(([k]) => k !== 'raw_preview')
.filter(([k]) => k !== 'raw_preview' && k !== 'parse_error')
.slice(0, 5)
.map(([k, v]) => `${k}: ${JSON.stringify(v).slice(0, 80)}`)
.join('\n'),
@ -147,11 +169,116 @@ export function DataIngestModule() {
status.textContent = 'File processed!';
setTimeout(() => { status.textContent = ''; }, 2000);
await this._renderFileList(container);
// Offer to import field data if field names were found
await this._offerFieldImport(record, container);
},
async _offerFieldImport(record, container) {
const sd = record.structuredData;
if (!sd || sd.parse_error) return;
// Collect field names from multiple possible keys
const rawFields = [
...(Array.isArray(sd.fields) ? sd.fields : []),
...(Array.isArray(sd.field_names) ? sd.field_names : []),
].map((f) => String(f).trim()).filter(Boolean);
if (rawFields.length === 0) return;
const profiles = await getFieldProfiles();
const matched = rawFields.filter((name) =>
profiles.some((p) => p.name.toLowerCase() === name.toLowerCase())
);
if (matched.length === 0) return;
this._pendingImport = { record, matched };
const banner = container.querySelector('#field-import-banner');
const desc = container.querySelector('#field-import-desc');
const btn = container.querySelector('#import-to-fields-btn');
const importStatus = container.querySelector('#field-import-status');
desc.textContent = `Found harvest data for: ${matched.join(', ')}`;
importStatus.classList.add('hidden');
btn.disabled = false;
btn.textContent = 'Import harvest data to field profiles';
btn.style.opacity = '';
banner.classList.remove('hidden');
},
async _importToFields(container) {
if (!this._pendingImport) return;
const { record, matched } = this._pendingImport;
const sd = record.structuredData;
const importStatus = container.querySelector('#field-import-status');
const btn = container.querySelector('#import-to-fields-btn');
btn.disabled = true;
btn.textContent = 'Importing…';
importStatus.textContent = '';
importStatus.classList.remove('hidden');
const profiles = await getFieldProfiles();
let count = 0;
for (const fieldName of matched) {
const profile = profiles.find((p) => p.name.toLowerCase() === fieldName.toLowerCase());
if (!profile) continue;
const harvestDate = sd.harvest_date ?? sd.date ?? new Date().toISOString().slice(0, 10);
const cropYear = parseInt(harvestDate.slice(0, 4), 10);
const crop = sd.crop ?? sd.operation_type?.replace(/\s*harvest\s*/i, '').trim() ?? 'Unknown';
const yieldVal = sd.avg_yield_bu_ac ?? sd.yield_bu_ac ?? sd.yield ?? null;
const moisture = sd.avg_moisture_pct ?? sd.moisture_pct ?? null;
const harvestRecord = {
date: harvestDate,
crop,
yield: yieldVal ? parseFloat(yieldVal) : null,
unit: 'bu/ac',
moisture: moisture ? parseFloat(moisture) : null,
source: record.filename,
};
const cropHistoryEntry = {
year: cropYear,
crop,
yield: harvestRecord.yield,
unit: 'bu/ac',
};
const existingHarvests = profile.harvestRecords ?? [];
const existingHistory = profile.cropHistory ?? [];
const updated = {
...profile,
harvestRecords: [
harvestRecord,
...existingHarvests.filter((r) => !(r.date === harvestRecord.date && r.crop === harvestRecord.crop)),
],
cropHistory: [
cropHistoryEntry,
...existingHistory.filter((r) => !(r.year === cropHistoryEntry.year && r.crop === cropHistoryEntry.crop)),
].sort((a, b) => b.year - a.year),
};
await saveFieldProfile(updated);
count++;
}
importStatus.textContent = `✓ Updated ${count} field profile${count !== 1 ? 's' : ''} — check Fields tab`;
importStatus.style.color = '#4ade80';
importStatus.classList.remove('hidden');
btn.textContent = 'Imported';
btn.style.opacity = '0.5';
this._pendingImport = null;
},
_parseCSV(file) {
return new Promise((resolve, reject) => {
// PapaParse is loaded dynamically to keep the background bundle lean
import('papaparse').then(({ default: Papa }) => {
Papa.parse(file, {
complete: (results) => {
@ -250,6 +377,7 @@ export function DataIngestModule() {
</button>
</div>
${f.preview ? `<pre class="text-xs text-gray-400 mt-2 whitespace-pre-wrap bg-night-800 rounded p-2 overflow-hidden max-h-20">${f.preview}</pre>` : ''}
${this._hasFieldData(f) ? `<p class="text-xs text-agri-400 mt-1.5">↗ Contains field data · <button class="reimport-btn underline hover:no-underline" data-id="${f.id}">Re-import to profiles</button></p>` : ''}
</div>
`).join('');
@ -259,6 +387,22 @@ export function DataIngestModule() {
await this._renderFileList(container);
});
});
listEl.querySelectorAll('.reimport-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const file = files.find((f) => f.id === btn.dataset.id);
if (file) await this._offerFieldImport(file, container);
});
});
},
_hasFieldData(file) {
const sd = file.structuredData;
if (!sd || sd.parse_error) return false;
return (
(Array.isArray(sd.fields) && sd.fields.length > 0) ||
(Array.isArray(sd.field_names) && sd.field_names.length > 0)
);
},
};
}

View file

@ -1,5 +1,5 @@
import { getFieldProfiles, saveFieldProfile, deleteFieldProfile } from '../../utils/storage.js';
import { getAgRefineUrl, setAgRefineUrl } from '../../utils/agrefine-bridge.js';
import { getAgRefineUrl, getSyncLog } from '../../utils/agrefine-bridge.js';
export function FieldProfileModule() {
let showForm = false;
@ -21,12 +21,19 @@ export function FieldProfileModule() {
</svg>
New Field
</button>
<button id="fp-agrefine-sync-btn" title="Sync fields from AG-Refine"
<button id="fp-agrefine-sync-btn" title="Pull fields from AG-Refine"
class="flex items-center justify-center gap-1.5 border border-night-500 text-gray-300 hover:border-agri-500 hover:text-agri-400 text-xs font-medium px-3 py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
AG-Refine
Pull
</button>
<button id="fp-agrefine-push-btn" title="Push fields to AG-Refine"
class="flex items-center justify-center gap-1.5 border border-night-500 text-gray-300 hover:border-agri-500 hover:text-agri-400 text-xs font-medium px-3 py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Push
</button>
</div>
<div id="fp-sync-status" class="px-4 text-xs min-h-[1rem] mb-2" style="color:#3d4f66;"></div>
@ -62,10 +69,14 @@ export function FieldProfileModule() {
<!-- Profiles list -->
<div id="fp-list" class="px-4 pb-4"></div>
<!-- Sync log (shown after a sync) -->
<div id="fp-sync-log" class="hidden px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderList(container);
await this._renderSyncLog(container);
},
_bindEvents(container) {
@ -74,7 +85,13 @@ export function FieldProfileModule() {
container.querySelector('#fp-form').classList.toggle('hidden', !showForm);
});
container.querySelector('#fp-agrefine-sync-btn').addEventListener('click', () => this._syncAgRefine(container));
container.querySelector('#fp-agrefine-sync-btn').addEventListener('click', () =>
this._pullFromAgRefine(container)
);
container.querySelector('#fp-agrefine-push-btn').addEventListener('click', () =>
this._pushToAgRefine(container)
);
container.querySelector('#fp-cancel-btn').addEventListener('click', () => {
showForm = false;
@ -96,11 +113,12 @@ export function FieldProfileModule() {
lon: parseFloat(container.querySelector('#fp-lon').value) || null,
},
notes: container.querySelector('#fp-notes').value.trim() || null,
cropHistory: [], // populated from ingested data in Phase 3
harvestRecords: [], // populated from ingested CSVs in Phase 3
weatherData: null, // Phase 6
carbonPotential: null, // Phase 7
cropHistory: [],
harvestRecords: [],
weatherData: null,
carbonPotential: null,
createdAt: new Date().toISOString(),
_source: 'manual',
};
await saveFieldProfile(profile);
@ -110,9 +128,9 @@ export function FieldProfileModule() {
});
},
async _syncAgRefine(container) {
async _pullFromAgRefine(container) {
const statusEl = container.querySelector('#fp-sync-status');
statusEl.textContent = 'Connecting to AG-Refine tab…';
statusEl.textContent = 'Connecting to AG-Refine…';
statusEl.style.color = '#3d4f66';
const result = await chrome.runtime.sendMessage({ type: 'AGREFINE_SYNC' });
@ -120,18 +138,80 @@ export function FieldProfileModule() {
if (!result.ok) {
statusEl.textContent = `${result.error}`;
statusEl.style.color = '#f87171';
setTimeout(() => { statusEl.textContent = ''; }, 5000);
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = '#3d4f66'; }, 6000);
return;
}
const parts = [];
if (result.added) parts.push(`${result.added} added`);
if (result.added) parts.push(`${result.added} field${result.added !== 1 ? 's' : ''} added`);
if (result.updated) parts.push(`${result.updated} updated`);
if (result.loadsFound) parts.push(`${result.loadsFound} loads found`);
statusEl.textContent = parts.length ? `✓ Synced: ${parts.join(', ')}` : '✓ No new fields found in AG-Refine';
statusEl.textContent = parts.length
? `✓ Pull complete — ${parts.join(', ')}`
: '✓ No new fields in AG-Refine';
statusEl.style.color = '#4ade80';
setTimeout(() => { statusEl.textContent = ''; }, 4000);
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = '#3d4f66'; }, 5000);
await this._renderList(container);
await this._renderSyncLog(container);
},
async _pushToAgRefine(container) {
const statusEl = container.querySelector('#fp-sync-status');
statusEl.textContent = 'Pushing to AG-Refine…';
statusEl.style.color = '#3d4f66';
const result = await chrome.runtime.sendMessage({ type: 'AGREFINE_PUSH' });
if (!result.ok) {
statusEl.textContent = `${result.error}`;
statusEl.style.color = '#f87171';
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = '#3d4f66'; }, 6000);
return;
}
statusEl.textContent = `✓ Pushed ${result.count} field${result.count !== 1 ? 's' : ''} to AG-Refine`;
statusEl.style.color = '#4ade80';
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = '#3d4f66'; }, 4000);
},
async _renderSyncLog(container) {
const log = await getSyncLog();
const logEl = container.querySelector('#fp-sync-log');
if (log.length === 0) {
logEl.classList.add('hidden');
return;
}
const latest = log[0];
logEl.classList.remove('hidden');
logEl.innerHTML = `
<div class="border border-night-600 rounded-xl p-3 text-xs" style="background:#131c2b;">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold text-agri-400">AG-Refine Sync Log</span>
<span class="text-gray-500">${log.length} sync${log.length !== 1 ? 's' : ''}</span>
</div>
<div class="space-y-1.5">
${log.slice(0, 5).map((entry) => `
<div class="flex items-start justify-between gap-2 pb-1.5 border-b border-night-600 last:border-0 last:pb-0">
<div>
<span class="text-gray-300">${new Date(entry.at).toLocaleString()}</span>
<div class="text-gray-500 mt-0.5">
${entry.fieldsAdded ? `+${entry.fieldsAdded} added` : ''}
${entry.fieldsUpdated ? `${entry.fieldsAdded ? ' · ' : ''}${entry.fieldsUpdated} updated` : ''}
${!entry.fieldsAdded && !entry.fieldsUpdated ? 'No changes' : ''}
${entry.loadsFound ? ` · ${entry.loadsFound} loads` : ''}
</div>
${entry.tabUrl ? `<div class="text-night-300 truncate max-w-[180px]" title="${entry.tabUrl}">${entry.tabUrl.replace(/^https?:\/\//, '').slice(0, 40)}</div>` : ''}
</div>
<span class="text-agri-400 flex-shrink-0"> Pull</span>
</div>
`).join('')}
</div>
${log.length > 5 ? `<p class="text-gray-600 mt-1">${log.length - 5} older entries hidden</p>` : ''}
</div>
`;
},
async _renderList(container) {
@ -147,47 +227,98 @@ export function FieldProfileModule() {
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No field profiles yet.</p>
<p class="mt-1 text-xs">Create a profile for each field in your operation.</p>
<p class="mt-1 text-xs">Create a profile or sync from AG-Refine.</p>
</div>`;
return;
}
listEl.innerHTML = profiles.map((p) => `
<div class="agri-card cursor-pointer" data-id="${p.id}">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-bold text-white">${p.name}</h3>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
${p.acres ? `<span class="text-xs text-gray-400">${p.acres} ac</span>` : ''}
${p.soilType ? `<span class="text-xs text-gray-400">${p.soilType}</span>` : ''}
${p.cluId ? `<span class="text-xs text-agri-400">CLU ${p.cluId}</span>` : ''}
listEl.innerHTML = profiles.map((p) => {
const isExpanded = expandedId === p.id;
const sourceLabel = p._source === 'ag-refine' ? 'AG-Refine'
: p._source === 'ag-refine-merged' ? 'AG-Refine + manual'
: p._source === 'manual' ? 'manual'
: null;
const cropHistoryHtml = (p.cropHistory ?? []).length > 0
? `<div class="mt-2.5">
<p class="text-agri-400 font-semibold uppercase tracking-wide text-[9px] mb-1">Crop History</p>
<div class="space-y-0.5">
${(p.cropHistory ?? []).slice(0, 5).map((h) => `
<div class="flex justify-between">
<span>${h.year} ${h.crop}</span>
${h.yield != null ? `<span class="text-white">${h.yield} ${h.unit ?? 'bu/ac'}</span>` : ''}
</div>
`).join('')}
</div>
</div>`
: '';
const harvestHtml = (p.harvestRecords ?? []).length > 0
? `<div class="mt-2.5">
<p class="text-agri-400 font-semibold uppercase tracking-wide text-[9px] mb-1">Harvest Records</p>
<div class="space-y-0.5">
${(p.harvestRecords ?? []).slice(0, 4).map((h) => `
<div class="flex justify-between gap-2">
<span>${h.date?.slice(0, 10) ?? '?'} ${h.crop}</span>
<span class="text-white flex-shrink-0">
${h.yield != null ? `${h.yield} ${h.unit ?? ''}` : ''}
${h.moisture != null ? ` @ ${h.moisture}%` : ''}
</span>
</div>
`).join('')}
</div>
</div>`
: '';
return `
<div class="agri-card cursor-pointer" data-id="${p.id}">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<h3 class="text-sm font-bold text-white">${p.name}</h3>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
${p.acres != null ? `<span class="text-xs text-gray-400">${p.acres} ac</span>` : ''}
${p.soilType ? `<span class="text-xs text-gray-400">${p.soilType}</span>` : ''}
${p.cluId ? `<span class="text-xs text-agri-400">CLU ${p.cluId}</span>` : ''}
${(p.cropHistory ?? []).length > 0 ? `<span class="text-xs text-gray-500">${p.cropHistory.length} yr history</span>` : ''}
${sourceLabel ? `<span class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded" style="background:#1a2438;color:#3d4f66;">${sourceLabel}</span>` : ''}
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button class="fp-delete-btn text-night-300 hover:text-red-400 transition" data-id="${p.id}" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<svg class="fp-chevron h-4 w-4 text-gray-500 transition-transform ${isExpanded ? 'rotate-90' : ''}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div class="flex items-center gap-2">
<button class="fp-delete-btn text-night-300 hover:text-red-400 transition" data-id="${p.id}" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<svg class="fp-chevron h-4 w-4 text-gray-500 transition-transform ${expandedId === p.id ? 'rotate-90' : ''}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<!-- Expanded detail -->
<div class="fp-detail ${isExpanded ? '' : 'hidden'} mt-3 pt-3 border-t border-night-600 text-xs text-gray-400 space-y-1">
${p.coordinates?.lat != null && p.coordinates?.lon != null
? `<p>📍 ${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}</p>`
: ''}
${p.notes ? `<p class="text-gray-300">📝 ${p.notes}</p>` : ''}
${cropHistoryHtml}
${harvestHtml}
${!cropHistoryHtml && !harvestHtml
? `<p class="text-gray-600 italic">No crop history yet — ingest a harvest file to populate.</p>`
: ''}
<div class="mt-2.5 pt-2 border-t border-night-600 flex items-center justify-between">
<p class="text-gray-600">Added ${new Date(p.createdAt).toLocaleDateString()}</p>
<div class="flex gap-3">
${agRefineUrl ? `<a href="${agRefineUrl}" target="_blank" rel="noopener noreferrer"
class="text-agri-400 hover:underline" onclick="event.stopPropagation()">Open in AG-Refine </a>` : ''}
<span class="text-gray-600">Carbon: <span class="coming-soon">Phase 7</span></span>
</div>
</div>
</div>
</div>
<!-- Expanded detail -->
<div class="fp-detail ${expandedId === p.id ? '' : 'hidden'} mt-3 pt-3 border-t border-night-600 text-xs text-gray-400 space-y-1">
${p.coordinates?.lat != null && p.coordinates?.lon != null ? `<p>📍 ${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}</p>` : ''}
${p.notes ? `<p>📝 ${p.notes}</p>` : ''}
${p._source?.includes('ag-refine') ? `<p class="text-agri-400">↗ Synced from AG-Refine</p>` : ''}
${agRefineUrl ? `<a href="${agRefineUrl}" target="_blank" rel="noopener noreferrer" class="text-agri-400 hover:underline">Open in AG-Refine ↗</a>` : ''}
<p class="text-gray-500">Weather data: <span class="coming-soon">Phase 6</span></p>
<p class="text-gray-500">Carbon potential: <span class="coming-soon">Phase 7</span></p>
<p class="text-gray-500">Added ${new Date(p.createdAt).toLocaleDateString()}</p>
</div>
</div>
`).join('');
`;
}).join('');
listEl.querySelectorAll('.agri-card').forEach((card) => {
card.addEventListener('click', async (e) => {

View file

@ -135,6 +135,41 @@ function extractLoads(raw) {
return loads;
}
// Injected into AG-Refine tab to write field data back into its localStorage
function writeFieldsToAgRefineTab(fields) {
try {
localStorage.setItem('agrifine_pushed_fields', JSON.stringify(fields));
localStorage.setItem('agrifine_pushed_at', new Date().toISOString());
// Dispatch an event so a listening AG-Refine app can react immediately
window.dispatchEvent(new CustomEvent('agrifine:fields-updated', { detail: { fields } }));
return { ok: true, count: fields.length };
} catch (err) {
return { ok: false, error: err.message };
}
}
export async function pushToAgRefine(profiles) {
const configuredUrl = await getAgRefineUrl();
const allTabs = await chrome.tabs.query({});
const agRefineTabs = allTabs.filter((t) => tabMatchesAgRefine(t, configuredUrl));
if (agRefineTabs.length === 0) {
return { ok: false, error: 'No AG-Refine tab found. Open AG-Refine first.' };
}
const tab = agRefineTabs[0];
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: writeFieldsToAgRefineTab,
args: [profiles],
});
return result.result;
} catch (err) {
return { ok: false, error: `Cannot write to AG-Refine tab: ${err.message}` };
}
}
export async function syncFromAgRefine() {
const configuredUrl = await getAgRefineUrl();
const allTabs = await chrome.tabs.query({});

View file

@ -0,0 +1,319 @@
/**
* Agrifine seeder + screenshot test
* Seeds all storage with realistic farm data and screenshots every tab.
* Run: PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers node tests/seed_and_screenshot.mjs
*/
import { chromium } from 'playwright';
import { mkdirSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dir = dirname(fileURLToPath(import.meta.url));
const UNIT_ROOT = resolve(__dir, '..');
const DIST = resolve(UNIT_ROOT, 'dist');
const SCREENSHOTS = resolve(UNIT_ROOT, 'screenshots');
const CHROMIUM = process.env.PLAYWRIGHT_BROWSERS_PATH
? `${process.env.PLAYWRIGHT_BROWSERS_PATH}/chromium-1194/chrome-linux/chrome`
: '/opt/pw-browsers/chromium-1194/chrome-linux/chrome';
mkdirSync(SCREENSHOTS, { recursive: true });
// ── Seed data ────────────────────────────────────────────────────────────────
const SEED = {
agrifine_field_profiles: [
{
id: 'fp_001', name: 'North 80', acres: 78.4, soilType: 'Silty Clay Loam',
cluId: 'IL-147-0412', coordinates: { lat: 41.8827, lon: -88.0071 },
notes: 'Tile-drained. Historically strong corn yields. Slight compaction issue in SW corner.',
cropHistory: [
{ year: 2023, crop: 'Corn', yield: 212, unit: 'bu/ac' },
{ year: 2022, crop: 'Soybeans', yield: 58, unit: 'bu/ac' },
{ year: 2021, crop: 'Corn', yield: 198, unit: 'bu/ac' },
{ year: 2020, crop: 'Soybeans', yield: 54, unit: 'bu/ac' },
],
harvestRecords: [
{ date: '2023-10-14', crop: 'Corn', yield: 212, unit: 'bu/ac', moisture: 17.2 },
{ date: '2022-09-28', crop: 'Soybeans', yield: 58, unit: 'bu/ac', moisture: 13.1 },
],
carbonPotential: null, weatherData: null,
createdAt: '2024-01-15T10:00:00Z', _source: 'manual',
},
{
id: 'fp_002', name: 'South Bottoms', acres: 112.0, soilType: 'Silt Loam',
cluId: 'IL-147-0413', coordinates: { lat: 41.8750, lon: -88.0120 },
notes: 'Flood risk near creek. Reduced tillage program since 2021. Good organic matter.',
cropHistory: [
{ year: 2023, crop: 'Soybeans', yield: 62, unit: 'bu/ac' },
{ year: 2022, crop: 'Corn', yield: 205, unit: 'bu/ac' },
{ year: 2021, crop: 'Soybeans', yield: 57, unit: 'bu/ac' },
],
harvestRecords: [
{ date: '2023-09-22', crop: 'Soybeans', yield: 62, unit: 'bu/ac', moisture: 12.8 },
],
carbonPotential: null, weatherData: null,
createdAt: '2024-01-15T10:30:00Z', _source: 'manual',
},
{
id: 'fp_003', name: 'East Pivot', acres: 134.5, soilType: 'Sandy Loam',
cluId: 'IL-147-0414', coordinates: { lat: 41.8900, lon: -87.9980 },
notes: 'Center pivot irrigation. Drought-susceptible. Cover crop trial started 2022.',
cropHistory: [
{ year: 2023, crop: 'Corn', yield: 225, unit: 'bu/ac' },
{ year: 2022, crop: 'Corn', yield: 218, unit: 'bu/ac' },
],
harvestRecords: [
{ date: '2023-10-20', crop: 'Corn', yield: 225, unit: 'bu/ac', moisture: 15.9 },
],
carbonPotential: null, weatherData: null,
createdAt: '2024-01-16T08:00:00Z', _source: 'manual',
},
],
agrifine_ingested_files: [
{
id: 'file_001', filename: 'harvest_2023_fall.csv', type: 'CSV',
uploadedAt: '2024-01-10T14:22:00Z',
structuredData: {
operation_type: 'Corn Harvest',
fields: ['North 80', 'East Pivot'],
total_acres: 212.9,
avg_yield_bu_ac: 218.5,
avg_moisture_pct: 16.6,
equipment: 'Case IH 8250',
total_bushels: 46520,
},
preview: 'operation_type: "Corn Harvest" | fields: ["North 80","East Pivot"] | total_acres: 212.9 | avg_yield_bu_ac: 218.5',
},
{
id: 'file_002', filename: 'soil_test_results_2023.xlsx', type: 'Excel',
uploadedAt: '2024-01-08T09:15:00Z',
structuredData: {
test_date: '2023-08-15',
fields_tested: 3,
avg_ph: 6.8,
avg_p_ppm: 42,
avg_k_ppm: 180,
cec: 22.4,
organic_matter_pct: 3.8,
recommendations: 'Apply 200 lbs/ac 0-46-0 on South Bottoms before corn planting',
},
preview: 'test_date: "2023-08-15" | avg_ph: 6.8 | avg_p_ppm: 42 | organic_matter_pct: 3.8',
},
{
id: 'file_003', filename: 'input_expense_report_2023.pdf', type: 'PDF',
uploadedAt: '2024-01-05T16:40:00Z',
structuredData: {
year: 2023,
total_seed_cost: 48750,
total_fertilizer_cost: 112400,
total_chemical_cost: 31200,
total_fuel_cost: 22800,
total_expenses: 215150,
cost_per_acre: 652,
},
preview: 'year: 2023 | total_seed_cost: 48750 | total_fertilizer_cost: 112400 | cost_per_acre: 652',
},
],
agrifine_reading_list: [
{
id: 'rl_001',
url: 'https://www.farmprogress.com/corn/usda-raises-corn-yield-forecast',
title: 'USDA Raises 2023 Corn Yield Forecast to 174.9 Bu/Acre',
savedAt: '2024-01-18T11:30:00Z',
summary: 'USDA updated its corn yield forecast to 174.9 bushels per acre for the 2023 crop, up 1.2 bu from the November estimate, citing favorable harvest conditions across the Corn Belt.',
tags: ['USDA', 'agriculture', 'finance'],
},
{
id: 'rl_002',
url: 'https://www.dtnpf.com/agriculture/web/ag/news/article/2024/01/15/la-nina-threat-corn-belt-spring',
title: 'La Niña Threat Could Dry Out Corn Belt This Spring',
savedAt: '2024-01-17T09:15:00Z',
summary: 'Meteorologists warn a developing La Niña pattern may reduce spring rainfall across Illinois and Indiana, raising drought risk for early-planted corn. Irrigation demand could spike 20-30% above normal.',
tags: ['weather', 'agriculture'],
},
{
id: 'rl_003',
url: 'https://www.agriculture.com/carbon-markets-eqip-2024',
title: 'EQIP Carbon Market Signup Opens February 2024',
savedAt: '2024-01-16T14:00:00Z',
summary: 'USDA NRCS opened signup for the Environmental Quality Incentives Program carbon track, offering $45-65 per acre for verified no-till and cover crop practices across enrolled fields.',
tags: ['carbon', 'USDA', 'finance'],
},
],
agrifine_farm_memory: {
lastUpdated: '2024-01-18T12:00:00Z',
farm_name: 'Hendricks Family Farms',
total_acres: 324.9,
primary_crops: ['Corn', 'Soybeans'],
soil_overview: 'Mixed silty clay loam and sandy loam soils. Average OM 3.8%. Tile drainage on North 80. Flood risk on South Bottoms near creek.',
aiGeneratedSummary: 'Hendricks Family Farms operates 324.9 acres across three fields in Kane County, IL. The 2023 corn crop averaged 218.5 bu/ac — above county average — driven by strong yields on the irrigated East Pivot (225 bu/ac). South Bottoms carries meaningful flood risk but benefits from 3+ years of reduced tillage. Soil tests indicate adequate fertility; phosphorus application recommended on South Bottoms ahead of 2024 corn rotation. Total 2023 input costs of $215,150 ($652/ac) leave healthy margin at current corn prices.',
key_insights: [
'East Pivot leads yield performance at 225 bu/ac with irrigation advantage',
'South Bottoms organic matter at 3.8% — reduced tillage program paying off',
'Total operation averaged 218.5 bu/ac corn in 2023 vs 174.9 national average',
'EQIP carbon program could generate $14,620$21,118 annually across enrolled acres',
],
action_items: [
'Apply 200 lbs/ac 0-46-0 on South Bottoms before corn planting',
'Evaluate EQIP carbon signup before February deadline',
'Address SW corner compaction on North 80 — consider subsoiling pass',
'Extend cover crop trial from East Pivot to North 80',
],
risk_flags: [
'La Niña pattern threatens spring drought — irrigation scheduling critical for East Pivot',
'South Bottoms flood risk near creek — monitor spring soil moisture before planting',
'Input costs at $652/ac — watch corn futures for margin compression',
],
opportunities: [
'Carbon credit program: $45-65/ac for documented no-till + cover crops',
'East Pivot irrigation gives drought hedge — consider expanding irrigated acres',
'Soybean rotation on North 80 in 2024 — lower input cost year',
],
},
};
async function main() {
if (!existsSync(DIST + '/manifest.json')) {
console.error('ERROR: dist/ not found. Run: npm run build');
process.exit(1);
}
console.log('Launching Chrome with seeded Agrifine extension…');
const context = await chromium.launchPersistentContext('', {
executablePath: CHROMIUM,
headless: true,
args: [
`--disable-extensions-except=${DIST}`,
`--load-extension=${DIST}`,
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
const page = await context.newPage();
// Stub chrome.* with SEEDED data
await page.addInitScript((seed) => {
const store = { ...seed };
const sessionStore = {};
window.chrome = {
storage: {
local: {
get: (k, cb) => setTimeout(() => cb({ [k]: store[k] ?? null }), 0),
set: (obj, cb) => { Object.assign(store, obj); cb && cb(); },
},
session: {
get: (k, cb) => setTimeout(() => cb({ [k]: sessionStore[k] ?? null }), 0),
set: (obj, cb) => { Object.assign(sessionStore, obj); cb && cb(); },
},
},
runtime: {
sendMessage: (_msg, cb) => cb && setTimeout(() => cb({ error: 'No background in test mode' }), 0),
connect: () => ({ onDisconnect: { addListener: () => {} } }),
lastError: null,
},
tabs: {
query: (_q, cb) => cb([{ id: 1, url: 'https://farmprogress.com/corn', title: 'Farm Progress' }]),
sendMessage: (_id, _msg, cb) => cb && cb({ text: 'test page content' }),
},
sidePanel: { setPanelBehavior: () => Promise.resolve() },
scripting: { executeScript: (_opts, cb) => cb && cb([{ result: {} }]) },
};
}, SEED);
await page.goto(`file://${DIST}/sidebar.html`);
await page.waitForSelector('#main-content', { timeout: 8000 });
await page.waitForTimeout(600);
async function ss(name) {
const file = `${SCREENSHOTS}/${name}.png`;
await page.screenshot({ path: file });
console.log(` Screenshot: ${file}`);
return file;
}
async function tab(name) {
const TAB_MAP = {
reading: '[data-tab="reading-list"]',
ingest: '[data-tab="data-ingest"]',
fields: '[data-tab="field-profile"]',
dashboard: '[data-tab="dashboard"]',
carbon: '[data-tab="carbon-estimator"]',
agent: '[data-tab="ag-refine"]',
};
await page.click(TAB_MAP[name]);
await page.waitForTimeout(500);
}
// ── 1. Reading List (default tab) ──────────────────────────────────────────
console.log('\n[1/6] Reading List tab');
await ss('01_reading_list');
// ── 2. Data Ingest ─────────────────────────────────────────────────────────
console.log('\n[2/6] Data Ingest tab');
await tab('ingest');
await ss('02_data_ingest');
// ── 3. Field Profiles ──────────────────────────────────────────────────────
console.log('\n[3/6] Field Profiles tab');
await tab('fields');
await page.waitForTimeout(300);
await ss('03_field_profiles');
// Expand first field
const firstCard = page.locator('.agri-card').first();
if (await firstCard.count()) {
await firstCard.click();
await page.waitForTimeout(400);
await ss('03b_field_expanded');
}
// ── 4. Dashboard ──────────────────────────────────────────────────────────
console.log('\n[4/6] Dashboard tab');
await tab('dashboard');
await page.waitForTimeout(400);
await ss('04_dashboard');
// ── 5. Carbon Estimator ───────────────────────────────────────────────────
console.log('\n[5/6] Carbon Estimator tab');
await tab('carbon');
await ss('05_carbon');
// ── 6. AgriAgent ─────────────────────────────────────────────────────────
console.log('\n[6/6] AgriAgent tab');
await tab('agent');
await ss('06_agent');
// ── Probe: settings panel ─────────────────────────────────────────────────
console.log('\n[probe] Settings panel');
await page.click('#btn-settings');
await page.waitForTimeout(300);
await ss('07_settings_panel');
// ── Probe: new field form ─────────────────────────────────────────────────
console.log('\n[probe] New field form');
await tab('fields');
await page.waitForTimeout(300);
await page.click('#fp-new-btn');
await page.waitForTimeout(300);
await ss('08_new_field_form');
// ── Report what is and isn't visible ─────────────────────────────────────
const fieldCount = await page.evaluate(() =>
document.querySelectorAll('.agri-card').length
);
console.log(`\nField cards visible after form open: ${fieldCount}`);
await context.close();
console.log('\nAll screenshots saved to:', SCREENSHOTS);
}
main().catch(err => { console.error(err); process.exit(1); });