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
184
agrifine-extension/dist/background.js
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
42
agrifine-extension/dist/sidebar.css
vendored
|
|
@ -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));
|
||||
|
|
|
|||
744
agrifine-extension/dist/sidebar.js
vendored
BIN
agrifine-extension/screenshots/01_reading_list.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
agrifine-extension/screenshots/02_data_ingest.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
agrifine-extension/screenshots/03_field_profiles.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
agrifine-extension/screenshots/03b_field_expanded.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
agrifine-extension/screenshots/04_dashboard.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
agrifine-extension/screenshots/05_carbon.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
agrifine-extension/screenshots/06_agent.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
agrifine-extension/screenshots/07_settings_panel.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
agrifine-extension/screenshots/08_new_field_form.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
319
agrifine-extension/tests/seed_and_screenshot.mjs
Normal 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); });
|
||||