array( 'label' => __( 'Per-table status', 'ai-eshop-optimizer' ), 'page' => 'remote_export', 'anchor' => 'remote-export-per-table-status', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 12, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'The first adoption. Rows come from a callable that walks a table registry and counts, so there is no query, no LIMIT and no integer row id.', ), 'data_tables.problems' => array( 'label' => __( 'Registry problems', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-problems', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Renders even when it is empty, on purpose: a conformance report that disappears when it has nothing to say cannot be told apart from one that never ran, and a card that comes and goes is a pin that dies with it.', ), 'data_tables.storefront' => array( 'label' => __( 'Storefront tables', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-storefront', 'kind' => 'grid', 'phone' => 'list', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => '2026-09-06', 'note' => 'The tables a visitor may read: the AIEO_Public_Tables allowlist, with the pages that hold the aieo/data-table block for each.', ), 'data_tables.columns' => array( 'label' => __( 'Columns and filters', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-storefront', 'kind' => 'grid', 'phone' => 'list', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 12, 'caps' => array( 'sort' => 'client', 'editable' => true ), 'adopted' => '2026-09-06', 'note' => 'One per storefront table: each column of its definition with Shown and Filter toggles, stored in aieo_report_columns and applied to the definition before validation.', ), 'fast_finder.storefront_product_finder' => array( 'label' => __( 'Fast Finder', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-storefront', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'free', 'scope' => 'local', 'source' => 'sql', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => false ), 'adopted' => true, 'note' => 'Replaces wpDataTables table 45 on /fast-finder, placed as the aieo/data-table block. Reads aieo_core_product_vitals WHERE status = 1 (11,714 of 13,831 rows), which is why it pages and filters in SQL. Five single-choice dropdowns shipped full in the markup, cascading, applied on change; section, product group and category are filters only, not columns. Price stays hidden and the professional discount stays shown, matching the original. Public through the AIEO_Public_Tables allowlist; see the Storefront tables card.', ), 'questionnaire_results.by_question' => array( 'label' => __( 'Every question, and whether it moved', 'ai-eshop-optimizer' ), 'page' => 'questionnaire_results', 'anchor' => 'questionnaire-results-every-question', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'The change column is suppressed, not fudged, in the three cases where it would be a lie: too few answers on either side, no prior data at all, and — the one that actually fires here — a question whose scale is not the same on both sides of the date. nps is declared 0-10 and holds 1-5 before the native survey shipped, because a 1-5 Gravity likert was migrated onto that code, so its two averages are not measured on the same ruler.', ), 'questionnaire_results.by_answer' => array( 'label' => __( 'What people actually chose', 'ai-eshop-optimizer' ), 'page' => 'questionnaire_results', 'anchor' => 'questionnaire-results-what-people-chose', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'Exists because half a survey has no average. A nine-option ranking returns a mean of exactly 5.0000 in every window — (N+1)/2, arithmetic rather than opinion — so it reports average position and how many people put each option first instead.', ), 'questionnaire_results.by_response' => array( 'label' => __( 'Every response, one per person', 'ai-eshop-optimizer' ), 'page' => 'questionnaire_results', 'anchor' => 'questionnaire-results-every-response', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'The only table in the reporting layer that returns customer prose, which is why its export is gated on manage_woocommerce. It carries no per-person average on purpose: one survey mixes a 1-5 satisfaction with a 0-10 recommendation, and a mean across two rulers is a number with no meaning.', ), 'questionnaires.blueprints' => array( 'label' => __( 'User Surveys', 'ai-eshop-optimizer' ), 'page' => 'questionnaires', 'anchor' => 'questionnaires-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Carries the two columns that make the difference between a survey that works and one that only looks like it does: how many responses ATTACHED TO A CUSTOMER, and how many questions are collecting nothing. The reference shop ran for three years at 1 attached response in 2,862 and nobody could see it, because a response with no customer looks exactly like a guest.', ), 'questionnaires.questions' => array( 'label' => __( 'Question health', 'ai-eshop-optimizer' ), 'page' => 'questionnaires', 'anchor' => 'questionnaires-questions', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Answered / skipped / not shown are three states and not two. Skipped means shown and left blank; not shown means a rule hid it. Collapsing them puts people who were never asked into the denominator, which moves every percentage in the flattering direction.', ), 'questionnaires.surfaces' => array( 'label' => __( 'Forms this reads', 'ai-eshop-optimizer' ), 'page' => 'questionnaires', 'anchor' => 'questionnaires-mapping', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Reports BOTH directions of the mapping. A question no field answers draws a blank column on a report and is therefore visible; a field no question claims is collecting answers into nothing and is not. A card that showed one and not the other would read as healthy exactly half the time it should not.', ), 'questionnaires.worklist' => array( 'label' => __( 'Answers with no ruling', 'ai-eshop-optimizer' ), 'page' => 'questionnaires', 'anchor' => 'questionnaires-worklist', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Deliberately narrow: only MAPPED fields whose question has a vocabulary. A scale has nothing to rule on and an unmapped field is a mapping problem rather than a vocabulary one, and listing either buries the real worklist under hundreds of rows nobody will ever action.', ), 'data_tables.permissions' => array( 'label' => __( 'Who can do what', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-permissions', 'kind' => 'form', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client', 'editable' => true ), 'adopted' => true, 'note' => 'Every role is listed, not only the ones holding something, because "is there anyone else who can export?" is the question somebody opens this to ask and a filtered list cannot answer it.', ), 'data_tables.registry' => array( 'label' => __( 'Registered data tables', 'ai-eshop-optimizer' ), 'page' => 'data_tables', 'anchor' => 'data-tables-registered', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client', 'search' => true, 'editable' => true ), 'adopted' => true, 'note' => 'This registry, rendered with the library it governs. If it cannot be built with AIEO_Table then the library is not finished, which is the point.', ), 'connections.overview' => array( 'label' => __( 'Rooster', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_2' => array( 'label' => __( 'Advertising Spend', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 1, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_3' => array( 'label' => __( 'Publishing', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 3, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_4' => array( 'label' => __( 'Catalogue & orders', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 3, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_5' => array( 'label' => __( 'Search', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_6' => array( 'label' => __( 'Customers', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 3, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_7' => array( 'label' => __( 'Measurement', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 1, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'connections.overview_8' => array( 'label' => __( 'Storefront', 'ai-eshop-optimizer' ), 'page' => 'connections', 'anchor' => 'connections-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other seven groups on this screen: one columns array and a row-shaping loop replaced a hand-written thead, four inline column widths, an inline-styled status pill with its own colour map, an inline PRO badge and an inline grey. The eight tables are one declaration; the group heading above each is what differs. Rows come from integrations(), a PHP array with a live check per entry, so there is no query and no endpoint - the T0 case exactly.', ), 'ratings.rating_coverage_can_ratings_rank_your_ca' => array( 'label' => __( 'Catalogue rating coverage', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-rating-coverage-can-ratings-rank-your-catalo', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 4, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.where_to_ask_first' => array( 'label' => __( 'Where to ask first', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-where-to-ask-first', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 15, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.site_wide_rating_distribution' => array( 'label' => __( 'Vote distribution per star', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-site-wide-rating-distribution', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.where_ratings_come_from' => array( 'label' => __( 'Where ratings come from', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-where-ratings-come-from', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.star_ratings_analytics' => array( 'label' => __( 'Top rated', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-top-rated', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 13, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.star_ratings_analytics_2' => array( 'label' => __( 'Lowest rated', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-lowest-rated', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 13, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'ratings.star_ratings_analytics_3' => array( 'label' => __( 'Most votes', 'ai-eshop-optimizer' ), 'page' => 'ratings', 'anchor' => 'ratings-most-votes', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16e, with the other six tables on this screen. The three lists (top rated, lowest rated, most votes) share ONE renderer, so converting it converted three of the seven. This page also retired its borrowing of the attribution sorter: aieo-attr-sortable-table, data-sort-type and aieo-attr-dt-sort-ind are gone from it, so it no longer depends on the attribution area JS being present. Two hand-rolled bar charts with hardcoded colours became the `bar` column type.', ), 'surveys.customer_priorities_f18_q8' => array( 'label' => __( 'Customer priorities (F18 Q8)', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-customer-priorities-f18-q8', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. TWO hand-rolled bars, each four nested divs and a hardcoded colour, became `bar` columns. Both widths stay computed in PHP against max => 100 because neither is a plain share: the importance bar is INVERTED and normalised from rank 1 rather than from zero, so letting the component divide by a max would change what it means. Each bar carries sort_key so the header sorts on the number the bar is drawn from. The card blurb described the two bars by their COLOUR ("teal bar", "purple bar"); it names the columns now, which is the only version that works once the component owns colour.', ), 'surveys.inferred_priorities_tier_1_heuristic_2' => array( 'label' => __( '④ Estimated customer priorities', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-inferred-priorities-tier-1-heuristic-2', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The bar column keeps its own scale deliberately - drawn against the BIGGEST bucket rather than against 100, so the distribution stays readable when the top slug holds 12% of the base - and declares it as `max => max_c`, a row field, rather than pre-multiplying a width. Reads inferred_top_priority off wp_aieo_customer_intelligence, so scope is local until that table is fleet-wide.', ), 'surveys.survey_respondent_loyalty_journey' => array( 'label' => __( 'F18 respondent loyalty journey', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-survey-respondent-loyalty-journey', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. A CROSSTAB, so sorting is OFF: the two rows are "bought again" and "did not", and a reader who reorders them has destroyed the only thing the table says. Same ruling as the ratings star distribution. The quadrant notes were an inline with a hardcoded colour each and are sublines now.', ), 'surveys.sentiment_drift_on_repeat_f18' => array( 'label' => __( 'Sentiment drift on repeat F18', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-sentiment-drift-on-repeat-f18', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, carrying two defects out. B75: both change cells were coloured by the COMBINED swing, so a customer whose NPS fell four while satisfaction rose six had the -4 rendered green - each column bands on its own value now. B26: the 20-row cap was silent and is disclosed, "Showing the first 20 of 278 rows" as measured. The first/last pairs are the `diff` type and the two deltas the new format sign, so the leading + survives the conversion.', ), 'surveys.actionable_lists_2' => array( 'label' => __( 'Actionable lists', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-actionable-lists', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. ONE renderer serving TWO tables - the rescue list and the referral list, which differ only by an open-feedback column - so one conversion covered both. B74: the LTV column hardcoded a euro sign on a value the store currency governs, the same class as the out-of-stock report hardcoding a production domain; it is the `money` type now and reads wc_price(). B26: both callers LIMIT 10 and neither said so. The truncated feedback also gained a title, so the cut text is reachable instead of thrown away.', ), 'surveys.competition_engagement' => array( 'label' => __( 'Competition engagement', 'ai-eshop-optimizer' ), 'page' => 'surveys', 'anchor' => 'surveys-competition-engagement', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. Rows come from AIEO_Competition_Analytics::aggregate(10), so the cap is the caller argument and the universe is unique_emails: measured here as the first 10 of 1,694. Lifetime value is `money` rather than a symbol concatenated in PHP.', ), 'session_analytics.overview_2' => array( 'label' => __( 'Session tracking totals', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-overview-2', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. A key/value summary that had NO at all, which is the one shape the contract cannot express; it gained a "Metric / Value" header, and both msgids already existed and are translated. The value column is `text` rather than a number type because it genuinely holds four counts AND a timestamp, and a column cannot be `integer` and `datetime` at once. Sorting is off: the order of the five IS the reading order. The separate empty-state paragraph is gone, the component owns it.', ), 'session_analytics.overview' => array( 'label' => __( 'Top converters (view → cart)', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-top-converters-view-cart', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client', 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, with the other two product rankings: one call site, three tables, differing only in their trailing three columns. Rows come from the processor stored procedures. B26: the top-20 cap was silent. NO total is claimed with it - products_with_metrics counts products that have metrics at all while each ranking narrows further (>= 10 views), so quoting it would report a universe larger than the report, which is B37 one layer up. The XLSX export button is the page own and was left alone.', ), 'session_analytics.overview_3' => array( 'label' => __( 'Most engaging products', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-most-engaging-products', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client', 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, from the same call site as the other two product rankings. See session_analytics.overview.', ), 'session_analytics.overview_4' => array( 'label' => __( 'Lowest bounce', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-lowest-bounce', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client', 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, from the same call site as the other two product rankings. See session_analytics.overview.', ), 'session_analytics.content_session_analytics' => array( 'label' => __( 'Top content by engagement', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-content-session-analytics', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, with the other two content rankings: one call site, three tables, IDENTICAL shape, differing only in the ORDER BY the module used. Reads wp_aieo_core_content_vitals. goal_rate_30d is stored as a RATIO and the old cell multiplied it by 100 inside the markup; the arithmetic moved into the row shaping. ⚠️ It did NOT move to the `percent` type, which expresses it properly, because that renames the header msgid and the rename lost the Greek translation on a live screen - see the log for session 16h.', ), 'session_analytics.content_session_analytics_2' => array( 'label' => __( 'Top content by demand', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-content-session-analytics', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, from the same call site as the other two content rankings. See session_analytics.content_session_analytics.', ), 'session_analytics.content_session_analytics_3' => array( 'label' => __( 'Top content by goal conversions', 'ai-eshop-optimizer' ), 'page' => 'session_analytics', 'anchor' => 'session-analytics-content-session-analytics', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, from the same call site as the other two content rankings. See session_analytics.content_session_analytics.', ), 'wishlist.where_products_are_added_from' => array( 'label' => __( 'Where products are added from', 'ai-eshop-optimizer' ), 'page' => 'wishlist', 'anchor' => 'wishlist-where-products-are-added-from', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, with the other two tables on this screen. A fourth hand-rolled bar with its own hardcoded colour became the `bar` type. B22: the empty row carried a hand-passed colspan="3".', ), 'wishlist.detailed_source_breakdown' => array( 'label' => __( 'Detailed source breakdown', 'ai-eshop-optimizer' ), 'page' => 'wishlist', 'anchor' => 'wishlist-detailed-source-breakdown', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The ID second line was printed behind an `if`; an absent subline prints nothing at all, so the component expresses the same rule declaratively. B26: LIMIT 20, undisclosed. ⚠️ Its row key is literally `count`, which is one of the NINE names that collide with a global function - harmless on this column because it renders as an integer and never reaches a callback resolver, but it is exactly the shape B78 is about.', ), 'wishlist.most_popular_products' => array( 'label' => __( 'Most popular products', 'ai-eshop-optimizer' ), 'page' => 'wishlist', 'anchor' => 'wishlist-most-popular-products', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The View button is a declared row ACTION with a callable href, so the component decides whether the column earns its place: a list where no product resolves to a permalink no longer renders a column of em dashes. A T0 table MAY pass a callable href; a definition may not.', ), 'web-push.web_push_triggers_phase_4_5' => array( 'label' => __( 'Web push triggers', 'ai-eshop-optimizer' ), 'page' => 'web-push', 'anchor' => 'web-push-triggers-phase-4-5', 'kind' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The Failed column coloured itself red per row with an inline ternary; that is a non-overlapping tone BAND now (B72). "Last sent" was a hand-built human_time_diff() with an "ago" suffix appended and its own inline UTC parse - it is the `relative` type with tz => utc, which reuses the existing "%s ago" msgid rather than minting one.', ), 'web-push.web_push_dispatch_log_last_100' => array( 'label' => __( 'Last 100 push dispatches', 'ai-eshop-optimizer' ), 'page' => 'web-push', 'anchor' => 'web-push-dispatch-log-last-100', 'kind' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 100, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The State cell carried a six-entry inline colour map; it is a `badge` with a tone map, which is what that map was reaching for. The sent -> clicked -> queued fallback for "when" stays in PHP because it is a data decision, not a display one. B26: LIMIT 100, and the card title says 100 while the cap notice now says what was actually returned.', ), 'web-push.web_push_templates_phase_6_per_trigger_per_segme' => array( 'label' => __( 'Web push notification templates', 'ai-eshop-optimizer' ), 'page' => 'web-push', 'anchor' => 'web-push-templates-phase-6-per-trigger-per-segment-we', 'kind' => 'form', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 9, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. The old ruling - that this was a T1 job because the component\x27s `editable` posts to AIEO_Report_Edit and half-converting would leave two save paths - was answering the wrong question. The screen does not want the component\x27s write path, it wants its table. Every input keeps .aieo-tpl-input and its data-field, data-template-id rides on the , and there is still exactly ONE save path here: the module\x27s own. The inline font-size:12px became `density => compact`.', ), 'product_needs.most_impactful_products_without_ne' => array( 'label' => __( 'Best sellers with no needs', 'ai-eshop-optimizer' ), 'page' => 'product_needs', 'anchor' => 'product-needs-most-impactful-products-without-needs', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => '🚨 MIGRATED to T0, session 16h, AND IT FOUND B78. This page carried THREE sorters between them: the shared aieo-attr-sortable-table class AND its own 49-line inline copy of the same routine bound to one table id. Both gone, along with the `▼` baked into the Turnover header text. **The conversion then killed the whole screen**: it declared href => edit_link naming the row field holding the edit URL, is_callable("edit_link") is TRUE because WordPress defines edit_link(), and the resolver CALLED wp-admin link editor, which checks manage_links and wp_die()s. Fixed in the component for every option that takes a key-or-callback. See B78.', ), 'promotions.at_a_glance' => array( 'label' => __( 'At a glance', 'ai-eshop-optimizer' ), 'page' => 'promotions', 'anchor' => 'promotions-at-a-glance', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. A key/value summary with NO , built through a private row() helper that emitted markup; the helper is deleted. Sorting is OFF and that is load-bearing: the paragraph under the card explains that the two middle rows OVERLAP and only the BOTH row is the intersection, a reading that depends entirely on the order. The one amber row was raw HTML inside a label and is a row tone now.', ), 'promotions.channel_value_and_what_your_free_traf' => array( 'label' => __( 'Value and cost per channel', 'ai-eshop-optimizer' ), 'page' => 'promotions', 'anchor' => 'promotions-channel-value-and-what-your-free-traffic-would-cost-to-buy', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. ⚠️ "unknown" and the em dash are DIFFERENT ANSWERS and both survive the conversion: a paid channel with no ledger rows shows "unknown", never zero and never the same marker as "this question does not apply to organic traffic". The card own closing paragraph says so, so collapsing them would have made the screen contradict itself.', ), 'promotions.our_internal_promotions' => array( 'label' => __( 'Our internal promotions', 'ai-eshop-optimizer' ), 'page' => 'promotions', 'anchor' => 'promotions-our-internal-promotions', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 7, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. TWO tables in this card - the surface summary and the anchor drill-down under it - both converted. B26: the drill-down is LIMIT 15 and never said so.', ), 'promotions.who_brought_them_x_who_closed_them' => array( 'label' => __( 'Who brought them x who closed them', 'ai-eshop-optimizer' ), 'page' => 'promotions', 'anchor' => 'promotions-who-brought-them-x-who-closed-them', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 18, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The "(destroyed)" amber was an inline style on ONE cell; it is a row tone now, so the whole row reads as the exception it is. B26: LIMIT 25.', ), 'promotions.contributors_external_partners' => array( 'label' => __( 'Contributors (external partners)', 'ai-eshop-optimizer' ), 'page' => 'promotions', 'anchor' => 'promotions-contributors-external-partners', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 8, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. TWO tables in this card: the contributor leaderboard and the unclaimed-values worklist. Status was two inline-coloured spans chosen by a ternary and is a `badge` tone map. Dates were substr(...,0,10) on a datetime string, which is the hand-rolled `date` type. B26 matters more than usual here - the paragraph under the worklist explains that the list is what makes the RECONCILIATION line add up, so a silent cap undermined the card own argument.', ), 'cancellations.top_reasons_last_30_days' => array( 'label' => __( 'Top cancellation reasons (30 days)', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-top-reasons-last-30-days', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. B26: the card title says 30 days but never that it is the TOP TEN reasons. Revenue lost is `money` and its label is plain (B74 ruling, 2026-08-27): it was `decimal` only because the header hardcoded "(€)", which was wrong on any non-euro shop. The Greek was ported with the rename.', ), 'cancellations.recent_cancellations_last_25' => array( 'label' => __( 'Recent cancellations (last 25)', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-recent-cancellations-last-25', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 25, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. cancelled_at was echoed as the RAW stored string; the column declares tz => utc so the vocabulary converts it to the shop clock, which is the ratified date rule. The reason NOTE was an inline grey span appended after the label and is a subline. B26: LIMIT 25.', ), 'cancellations.1_the_four_event_types_different_c' => array( 'label' => __( 'Cancellations', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-1-the-four-event-types-different-costs-and-d', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 4, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, and the first user of `prose`. It was ⛔ "static documentation" whose conversion "would need the `html` escape hatch on roughly twenty cells". Right about the hatch, wrong about the conclusion: those cells are TRANSLATED SENTENCES that legitimately contain , a shape the vocabulary simply did not have. `prose` is that shape and it sanitises with wp_kses_post() rather than trusting the caller - a real gain here, because these strings come from a .po file on the two installs that run Greek. sortable=false: the four types are explained in ascending cost and an alphabetical re-order would lose the argument.', ), 'cancellations.cancellation_hotspots_10_orders' => array( 'label' => __( 'Cancellation hotspot products', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-cancellation-hotspots-10-orders', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 1805, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `cancellations_hotspots`. It sorted on the SERVER through cn_porderby / cn_porder against an allowlist and returned the top 50 under that sort, so a T0 client sorter would have reordered those 50 and looked identical — B44 exactly. 🚨 B26: 1,805 of 2,740 products pass the 10-order threshold and FIFTY were shown, with the card saying neither number. The threshold is `base_where`, not a filter, so meta.total counts the report rather than the table. B84: last_cancelled_at is MAX(cancelled_at), written GMT, and was echoed raw beside a sibling card on the SAME SCREEN that already converts the identical value. get_the_title() per row is gone: `object` primes the whole page in one pass.', ), 'cancellations.high_risk_users_risk_band_1' => array( 'label' => __( 'Users at cancellation risk', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-high-risk-users-risk-band-1', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 25, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `cancellations_risk_users`, same reasoning and the same allowlist sort pattern as the hotspots table above. 🚨 IT NAMES CUSTOMERS, so the definition declares its own EXPORT capability (aieo_report_export) rather than inheriting VIEW: a reader who can also export has taken a permanent copy of a list of people the shop considers a risk, and that is a different decision from letting them read the screen. It is the first report to use the four-way split for the reason the split exists. The old cell concatenated "display_name " into the LINK TEXT; the `user` type keeps the address as a subline.', ), 'popups.overview' => array( 'label' => __( 'Popups', 'ai-eshop-optimizer' ), 'page' => 'popups', 'anchor' => 'popups-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via AIEO_Table::card(), session 16h. Four link-actions per row became declared row ACTIONS, so the delete confirm and its destructive styling are the component\'s rather than an inline onclick. Status was two inline-coloured spans plus a hand-rolled TEST MODE pill built from six inline properties; it is a badge with a subline.', ), 'popups.performance_last_30_days' => array( 'label' => __( 'Popup performance (30 days)', 'ai-eshop-optimizer' ), 'page' => 'popups', 'anchor' => 'popups-performance-last-30-days', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 6, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. 🚨 The TOTAL row lived in the , so the client sorter carried it up and down with the data. It is a real now (totals_at => foot), which is what B35 asked for in as many words.', ), 'popups.conv_rate_trend' => array( 'label' => __( 'Daily efficacy', 'ai-eshop-optimizer' ), 'page' => 'popups', 'anchor' => 'popups-conv-rate-trend', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 30, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The date stays `text`, not `date`: it is a Y-m-d BUCKET KEY, and converting it to the site clock would shift the boundary the bucket was built on.', ), 'price_history.reports' => array( 'label' => __( 'Products with the most price changes', 'ai-eshop-optimizer' ), 'page' => 'price_history', 'anchor' => 'price-history-reports', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. Dates were get_date_from_gmt() per cell; the columns declare tz => utc.', ), 'price_history.price_change_log_top_25_movers_unp' => array( 'label' => __( 'Price-change log', 'ai-eshop-optimizer' ), 'page' => 'price_history', 'anchor' => 'price-history-price-change-log-top-25-movers-unpivoted', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2349, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. on_sale was a hand-written Yes/em-dash ternary, which is the `boolean` type - rendered monochrome, because the emoji variants it replaced differed per platform.', ), 'price_history.omnibus_compliance_sale_price_isn' => array( 'label' => __( 'Sale prices breaking Omnibus', 'ai-eshop-optimizer' ), 'page' => 'price_history', 'anchor' => 'price-history-omnibus-compliance-sale-price-isn-t-the-30-d', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 148, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. "Over by" was inline red + bold and is a `danger` tone, which the component also renders bold.', ), 'landing_income.credit_that_arrives_later_woocomm' => array( 'label' => __( 'Revenue from later purchases', 'ai-eshop-optimizer' ), 'page' => 'landing_income', 'anchor' => 'landing-income-credit-that-arrives-later-woocommerce-attribution', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 7, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h.', ), 'landing_income.how_people_reach_your_busiest_pag' => array( 'label' => __( 'Channels per landing page', 'ai-eshop-optimizer' ), 'page' => 'landing_income', 'anchor' => 'landing-income-how-people-reach-your-busiest-pages', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 9, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. ONE declaration renders 25 times, once per busy page, which is why the census counts it once.', ), 'landing_income.organic_traffic_that_produces_not' => array( 'label' => __( 'Organic traffic, no cart adds', 'ai-eshop-optimizer' ), 'page' => 'landing_income', 'anchor' => 'landing-income-organic-traffic-that-produces-nothing', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'rows_est' => 12, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `landing_income_zeroes`. It sorted on the SERVER via pzs/pzd, so a T0 client sorter would have reordered the loaded page and looked identical (B44). B26: LIMIT 20, undisclosed, on the card whose whole question is "which pages produce nothing" — the twenty-first is the one nobody had looked at. The window and channel are SCOPE PARAMETERS, not filters: the query GROUPs BY path over a date range, so the range is not in the result set and the wrapper cannot narrow to it. That pair is what made definition-level `params` real (START HERE item 4).', ), 'customer_intelligence.web_push_segments_2' => array( 'label' => __( 'Segments', 'ai-eshop-optimizer' ), 'page' => 'customer_intelligence', 'anchor' => 'web-push-segments', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17, with the other four tables AIEO_DMM_Web_Push renders onto this page. The hand-rolled share bar (two nested divs carrying the segment colour) is the `bar` type; the colour repeated the label beside it.', ), 'customer_intelligence.web_push_reactivation_opportunity_2' => array( 'label' => __( 'Reactivation opportunity', 'ai-eshop-optimizer' ), 'page' => 'customer_intelligence', 'anchor' => 'web-push-reactivation-opportunity', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. TWO tables under this anchor: the cohort funnel, and the candidate queue that returns nothing on ffnew and was therefore converted BLIND - its score traffic-light is a `bands` map now, and score_color() is deleted. B26: LIMIT 50 disclosed.', ), 'customer_intelligence.web_push_lost_cohort_playbook_365_days_2' => array( 'label' => __( 'Lost-customer playbook (365+ days)', 'ai-eshop-optimizer' ), 'page' => 'customer_intelligence', 'anchor' => 'web-push-lost-cohort-playbook-365-days', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. "Last seen" sorts on the day count rather than on the phrase "%d days ago". B26: LIMIT 20 disclosed.', ), 'customer_intelligence.web_push_top_50_customers_by_ltv_2' => array( 'label' => __( 'Top 50 customers by LTV', 'ai-eshop-optimizer' ), 'page' => 'customer_intelligence', 'anchor' => 'web-push-top-50-customers-by-ltv', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Segment pills carrying a per-slug colour became `list` chips; fmt_money() became `money`. B26: LIMIT 50 disclosed.', ), 'theme_compat.detected' => array( 'label' => __( 'Detected theme', 'ai-eshop-optimizer' ), 'page' => 'theme_compat', 'anchor' => 'theme-compat-detected', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, as a PANEL: `head => false` + `row_header`. It was ⛔ "not a data table" for having no header row, and that shape was the whole objection - it is our markup, it wears `widefat`, and an invented "Setting / Value" header would have been a new msgid on a Greek install. sort=none because there is no header to click. The explanation paragraph is the `subline`.', ), 'store_pages.store_pages' => array( 'label' => __( 'Each page and its bound location', 'ai-eshop-optimizer' ), 'page' => 'store_pages', 'anchor' => 'store-pages-store-pages', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 6, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via AIEO_Table::card(), session 17. Edit / View were two anchors and a `·` in a nowrap cell; they are row actions, and View is the first user of the action `external` flag.', ), 'suppliers.list' => array( 'label' => __( 'Every supplier in one table', 'ai-eshop-optimizer' ), 'page' => 'suppliers', 'anchor' => 'suppliers-list', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 169, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The 500-term cap was disclosed in a sentence inside the description paragraph on this ONE screen; it is the component cap notice now. The Edit button keeps its behaviour by binding data-action="edit".', ), 'stock_import.recent_imports' => array( 'label' => __( 'Recent imports', 'ai-eshop-optimizer' ), 'page' => 'stock_import', 'anchor' => 'stock-import-recent-imports', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17, with the price-export and orders-export job lists: one shape written three times. `created_at` stays a raw string - it is a MySQL DEFAULT CURRENT_TIMESTAMP, so its zone is the DB server\'s. See DATA-TRUTH-QUESTIONS.md.', ), 'price_export.recent_exports' => array( 'label' => __( 'Recent price exports', 'ai-eshop-optimizer' ), 'page' => 'price_export', 'anchor' => 'price-export-recent-exports', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 7, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Same declaration as the stock-import and orders-export job lists; the file link is `link` with format.external.', ), 'orders_export.recent_exports' => array( 'label' => __( 'Recent order exports', 'ai-eshop-optimizer' ), 'page' => 'orders_export', 'anchor' => 'orders-export-recent-exports', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Third of the three job lists. The shared dmm.js delete handler reads the row key off the button now, with the old data-job-id kept as a fallback.', ), 'orders_export.field_map' => array( 'label' => __( 'Order field map', 'ai-eshop-optimizer' ), 'page' => 'orders_export', 'anchor' => 'orders-export-field-map', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 31, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. The old note said the component "can render editable cells, but its write path needs a DEFINITION" - true, and beside the point: this table wanted the component\'s TABLE, not its write path. Every input keeps its `fields[$i][...]` name and index, so the save handler never learns the conversion happened. `data-key` rides on the through `row_data`; the table keeps `aieo-dmm-field-map` for the drag-handle CSS. sortable=false because the row ORDER is the data the drag handle edits.', ), 'product_requests.list' => array( 'label' => __( 'Product Requests', 'ai-eshop-optimizer' ), 'page' => 'product_requests', 'anchor' => 'product-requests', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array( 'sort' => 'none', 'paginate' => true ), 'adopted' => true, 'note' => 'Born on T0. sortable=false for the SAME reason as waitlist.subscribers - it pages on the SERVER, so a client sorter would reorder the fifty rows on screen while looking as though it had sorted the set (B44). The Items and Viewed columns are two DIFFERENT things and must never be summed: Items is stated demand (what they asked to be quoted), Viewed is passive context. Both counts are resolved in the LIST query as correlated subqueries, not per row, so a 50-row page stays one query rather than 101.', ), 'contact_form.list' => array( 'label' => __( 'Contact Form', 'ai-eshop-optimizer' ), 'page' => 'contact_form', 'anchor' => 'contact-form', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true ), 'adopted' => true, 'note' => 'Born on T0. sortable=false for the same reason as product_requests.list: it pages on the SERVER (50 per page), so a client sorter would reorder the rows on screen while looking as though it had sorted the set. The bulk bar (set status, delete) is handled by the screen through the cancelable aieo-table:bulk event, posting to its own endpoints. One query per page plus one COUNT; no per-row lookups (country names come from one memoised list).', ), 'product_requests.items' => array( 'label' => __( 'Requested for quotation', 'ai-eshop-optimizer' ), 'page' => 'product_requests', 'anchor' => 'product-requests-requested', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array( 'sort' => 'none' ), 'adopted' => false, 'note' => 'Plain widefat on the detail screen, deliberately NOT the component: it sits beside two sibling panels (viewed before / viewed since) whose visual distinctness from it is the whole point of the screen, and a component table would make all three read as one list. ▶ T0 candidate once the component can express a three-panel group.', ), 'waitlist.subscribers' => array( 'label' => __( 'Waitlist Subscribers', 'ai-eshop-optimizer' ), 'page' => 'waitlist', 'anchor' => 'waitlist-subscribers', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 196, 'caps' => array( 'sort' => 'none', 'paginate' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0 with SORTING OFF, session 17, and that is deliberate: it pages on the SERVER, so a client sorter would reorder the fifty rows on screen and look as though it had sorted all 196 - B44. ▶ T1 candidate. The two inline status pills are a badge tone map.', ), 'conversions.recent_server_events' => array( 'label' => __( 'Recent server events', 'ai-eshop-optimizer' ), 'page' => 'conversions', 'anchor' => 'conversions-recent-server-events', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 15, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via card(), session 17. Status was a with one of three inline hex values and is a badge tone map; the fbtrace id stays a title= through `tooltip`. B26: LIMIT 15 disclosed.', ), 'conversion_goals.overview' => array( 'label' => __( 'Goals from forms and actions', 'ai-eshop-optimizer' ), 'page' => 'conversion_goals', 'anchor' => 'conversion-goals-overview', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 24, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. The row is still the record: data-key/data-provider/data-form all ride on the through `row_data`, and .aieo-goal-on / .aieo-goal-analyze / .aieo-goal-context keep their classes. ⚠️ The context cell’s class and `data-context` moved from the to a inside it, because a component cell owns its own class list - querySelector, textContent and setAttribute all behave the same.', ), 'branch_scanner.branches' => array( 'label' => __( 'Branch Stock Scanner', 'ai-eshop-optimizer' ), 'page' => 'branch_scanner', 'anchor' => 'branch-scanner-branches', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 4, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The delete action declares the AJAX handler name AS its action id, so the shared `.aieo-dmm-delete-row` binding in dmm.js keeps working unchanged. Since 2026-08-26 the page carries a bs_section tab strip (Scanning + the four branch worksheets).', ), 'branch_scanner.worksheet_piraeus' => array( 'label' => __( 'Worksheet: Piraeus', 'ai-eshop-optimizer' ), 'page' => 'branch_scanner', 'anchor' => 'branch-scanner-worksheet-piraeus', 'query' => array( 'bs_section' => 'worksheet_piraeus' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'NEW T1 `preorder_worksheet_piraeus` (branch-preorder approval feature, 2026-08-26; see the keratsini row for the full contract note — the four worksheets are one definition file).', ), 'branch_scanner.worksheet_keratsini' => array( 'label' => __( 'Worksheet: Keratsini', 'ai-eshop-optimizer' ), 'page' => 'branch_scanner', 'anchor' => 'branch-scanner-worksheet-keratsini', 'query' => array( 'bs_section' => 'worksheet_keratsini' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'NEW T1 `preorder_worksheet_keratsini` — the intake surface that retires wp_store_board_keratsini (t34 ruling: the feature lives in OURS, the report never migrates). Catalogue + this branch\'s 30/60d sales read from preorder_vitals_stats at query time; the staff-typed existing_stock / desired_qty live on the sparse wp_aieo_dmm_preorder_worksheet, so the nightly TRUNCATE+rebuild cannot eat them (the per-branch desired_qty_{slug} columns ON the stats table stay unwritten for exactly that reason). Cell saves ride aieo_report_save_cell; Submit requests / Clear quantities are scope-filter bulk actions into aieo_report_bulk → AIEO_DMM_Branch_Scanner (open request updated, never duplicated; sheet line cleared; wp_aieo_dmm_preorder_log written). PER-BRANCH definitions rather than a branch param because save_cell carries no scope params — the branch must be nameable from the report id alone. Q2 RULED 2026-08-27: view/edit/execute all gate on the branch\'s OWN capability aieo_preorder_ws_{slug} (per-branch rooster_branch_{slug} staff roles; administrators seeded with all four; shop_manager deliberately none) — no branch reads another branch\'s sheet, and the all-branches queue stays behind the Rooster report caps.', ), 'branch_scanner.worksheet_korydallos' => array( 'label' => __( 'Worksheet: Korydallos', 'ai-eshop-optimizer' ), 'page' => 'branch_scanner', 'anchor' => 'branch-scanner-worksheet-korydallos', 'query' => array( 'bs_section' => 'worksheet_korydallos' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'NEW T1 `preorder_worksheet_korydallos` (see the keratsini row for the full contract note).', ), 'branch_scanner.worksheet_kallithea' => array( 'label' => __( 'Worksheet: Kallithea', 'ai-eshop-optimizer' ), 'page' => 'branch_scanner', 'anchor' => 'branch-scanner-worksheet-kallithea', 'query' => array( 'bs_section' => 'worksheet_kallithea' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'NEW T1 `preorder_worksheet_kallithea` (see the keratsini row for the full contract note).', ), 'loyalty_classes.customer_discount_classes' => array( 'label' => __( 'Customer discount classes', 'ai-eshop-optimizer' ), 'page' => 'loyalty_classes', 'anchor' => 'loyalty-classes-customer-discount-classes', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Both numeric columns used number_format(), which is the C locale, so a Greek install printed "12.50" beside its own "12,50"; `decimal` goes through number_format_i18n(). The assignments table below it is NOT converted - its row action carries a composite (user, class) key and it does not render on this install.', ), 'problematic_variations.multi_attribute_variation' => array( 'label' => __( 'Block a second variation axis', 'ai-eshop-optimizer' ), 'page' => 'problematic_variations', 'anchor' => 'problematic-variations-multi-attribute-variations', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The attribute pills took their colour from a page-level SETTING rather than from the row, so they are plain chips. B26: the query asks for 1000 and said nothing.', ), 'as_cleanup.current_status' => array( 'label' => __( 'Action queue status', 'ai-eshop-optimizer' ), 'page' => 'as_cleanup', 'anchor' => 'as-cleanup-current-status', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. TWO tables in this card. B75: the duplicate warning was appended inside the "Distinct Args" cell while being a verdict about "Pending" - it moved onto the number it is about. B26: the top-20 hook cap is disclosed.', ), 'plugin_usage.plugin_usage' => array( 'label' => __( 'By user', 'ai-eshop-optimizer' ), 'page' => 'plugin_usage', 'anchor' => 'plugin-usage-by-user', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. THREE tables in this card, all three converted. "Last active" sorts on the timestamp rather than on the rendered "3 days ago".', ), 'plugin_usage.most_used_cards' => array( 'label' => __( 'Most-used cards', 'ai-eshop-optimizer' ), 'page' => 'plugin_usage', 'anchor' => 'plugin-usage-most-used-cards', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Split from plugin_usage.plugin_usage on 31 Oct 2026: that card\'s three tables became three cards (by user, most-used cards, by page), and the phone kinds find a table by its card. The row of the first keeps the old id.', ), 'plugin_usage.by_page' => array( 'label' => __( 'By page', 'ai-eshop-optimizer' ), 'page' => 'plugin_usage', 'anchor' => 'plugin-usage-by-page', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'Split from plugin_usage.plugin_usage on 31 Oct 2026 (see plugin_usage.most_used_cards).', ), 'checkout_timing.overall_d_checkouts_2' => array( 'label' => __( 'Overall', 'ai-eshop-optimizer' ), 'page' => 'checkout_timing', 'anchor' => 'checkout-timing-overall-d-checkouts-2', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 2, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Two rows comparing baseline against all orders, so sorting is off. The durations keep this screen\'s own "2m 30s" formatter rather than the `duration` type, which is human_time_diff and would round a recommended delay to "3 mins".', ), 'checkout_timing.per_product_coupon_excluded' => array( 'label' => __( 'Time per product (no coupon)', 'ai-eshop-optimizer' ), 'page' => 'checkout_timing', 'anchor' => 'checkout-timing-per-product-coupon-excluded', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 30, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Every duration column sorts on its raw second count, not on the rendered string. "Recommended delay" repeats the average active time by design and has its own field rather than a second column over the same key.', ), 'supplier_orders.outstanding_pre_orders' => array( 'label' => __( 'Outstanding pre-orders by supplier', 'ai-eshop-optimizer' ), 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-outstanding-pre-orders', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 49, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Two raw (int) echoes lost the thousands separator every other count on the screen has.', ), 'supplier_orders.recent_signals' => array( 'label' => __( 'Recent staff pre-order signals', 'ai-eshop-optimizer' ), 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-recent-signals', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 1110, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'PROMOTED to T1 as `supplier_preorders_queue` (branch-preorder approval feature, 2026-08-26) — the T0 signals table (session 17) was a 50-row slice built for scanner volume and could not carry board-scale intake. Same frozen anchor; the four status buttons keep their contract (the STATUS is the action id, the received qty rides on the button — B80) but visibility is `when` over SQL-computed flags and the click is the delegated aieo-table:action event, which survives the T1 refresh. Bulk Approve / Reject / Mark ordered (scope filter) dispatch through aieo_report_bulk → apply_preorder_status(), the one transition path, which now also writes wp_aieo_dmm_preorder_log. Mark ordered stamps batch_ref = PO-{site day}-{supplier term id} — chunk-stable by construction. This closes the old B26: the cap is gone, the pager is real.', ), 'supplier_orders.order_batches' => array( 'label' => __( 'Ordered batches', 'ai-eshop-optimizer' ), 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-order-batches', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'NEW T1 `preorder_order_batches` (branch-preorder approval feature, 2026-08-26). GROUP BY batch_ref over supplier_preorders. The two row actions (XLSX / email) are screen-handled aieo-table:action events because both need a nonce, which a declarative href cannot carry; both outputs name the batch so the supplier confirmation matches back.', ), 'supplier_orders.receive_reconcile' => array( 'label' => __( 'Received vs ERP', 'ai-eshop-optimizer' ), 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-receive-reconcile', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'NEW T1 `preorder_receive_reconcile` (spec 3e, 2026-08-26). READ-ONLY by ruling: received qty vs requested, ERP catalogue stock as context. erp_stock is NULL (—) when the ERP has no row for the SKU, never coerced to 0 — an unknown is not an empty shelf. LOCAL: reads the ffnew s1erp staging.', ), 'supplier_orders.preorder_audit' => array( 'label' => __( 'Pre-order audit trail', 'ai-eshop-optimizer' ), 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-preorder-audit', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'NEW T1 `preorder_audit_log` (spec 3d, 2026-08-26). Append-only wp_aieo_dmm_preorder_log, written by the SAME PHP paths that make each change — never a trigger (the ruling\'s hard constraint; the old board\'s triggers logged origin_user=anonymous on 95% of rows because a trigger cannot see the WP user).', ), 'promo_surfaces.overview' => array( 'label' => __( 'Surface list and shortcodes', 'ai-eshop-optimizer' ), 'page' => 'promo_surfaces', 'anchor' => 'promo-surfaces-list', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The legacy-theme shortcode stays a selectable line under the slug, as a subline.', ), 'promo_surfaces.promo_surface_performance_last_d' => array( 'label' => __( 'Promo surface performance', 'ai-eshop-optimizer' ), 'page' => 'promo_surfaces', 'anchor' => 'promo-surfaces-promo-surface-performance-last-d-days', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. B35: its TOTAL row lived in the , where the client sorter carries it along with the data; it is a real through totals_at => foot.', ), 'social.boost_slate' => array( 'label' => __( 'Boost slate', 'ai-eshop-optimizer' ), 'page' => 'social', 'anchor' => 'social-boost-slate', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The channels cell is the one `html` hatch on the screen and stays one deliberately: it is a three-state DECISION (channels / held back by ratings / waiting on stock), computed in slate_channel_cell(), not a value. B26: LIMIT 20 disclosed.', ), 'social.restock_to_unlock' => array( 'label' => __( 'Restock to unlock', 'ai-eshop-optimizer' ), 'page' => 'social', 'anchor' => 'social-restock-to-unlock', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 24, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17, with the slate above it — same declaration shape, one row action.', ), 'user_vitals.overview' => array( 'label' => __( 'ERP fields per user', 'ai-eshop-optimizer' ), 'page' => 'user_vitals', 'anchor' => 'user-vitals-fields', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 14, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. A field catalogue: it reads self::fields() rather than restating the table shape a second time in markup. The Gravity-Forms signup table on the same page is NOT converted - it is a settings form.', ), 'supplier_extras.directory' => array( 'label' => __( 'Supplier directory', 'ai-eshop-optimizer' ), 'page' => 'supplier_extras', 'anchor' => 'supplier-extras-directory', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 169, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Seven cells hand-wrote the same "not set" span; the empty marker is one decision now. B26: the row cap was a sentence under the table and is the component cap notice.', ), 'campaigns.admin_advertising_campaigns' => array( 'label' => __( 'Ad Spend & Campaigns', 'ai-eshop-optimizer' ), 'page' => 'campaigns', 'anchor' => 'campaigns-admin-advertising-campaigns', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 500, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Five inline status colours became a badge tone map. "Budget planned" is `money` with a plain label (B74 ruling, 2026-08-27) — it was `decimal` only to avoid double-printing against a hardcoded "(€)" header. The settings FORM keeps a symbol in its field label, from get_woocommerce_currency_symbol(), because a bare number input has nowhere else to say what it is.', ), 'seo_import.import_field_mapping_no_silent_drops' => array( 'label' => __( 'AIOSEO field mapping', 'ai-eshop-optimizer' ), 'page' => 'seo_import', 'anchor' => 'import-field-mapping-no-silent-drops', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 17, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Four inline hex values keyed by status became a badge tone map, and the $status_colors array they came from is deleted.', ), 'seo_health.health_seo_health' => array( 'label' => __( 'AI SEO suggestions — review', 'ai-eshop-optimizer' ), 'page' => 'seo_health', 'anchor' => 'health-seo-health', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 100, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via card(), session 17. The demand bar was two nested divs with a hardcoded violet and the score a carrying one of three inline hex values - a `bar` and a `bands` map. Rendered through card() rather than render() because `footnote` is a CARD option and would have been silently dropped. B26: the top-100 cap was prose under the table.', ), 'brand_discounts.current_product_discounts' => array( 'label' => __( 'Current product discounts', 'ai-eshop-optimizer' ), 'page' => 'brand_discounts', 'anchor' => 'brand-discounts-current-product-discounts', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Four cells hand-wrote the same "(any)" placeholder - a per-column empty marker, because "(any)" is a rule that matches everything, not a missing value. The 70-line client-side row builder in dmm.js is deleted: a save reloads. number_format() (C locale) became `decimal`.', ), 'brand_gifts.current_offers' => array( 'label' => __( 'Current offers', 'ai-eshop-optimizer' ), 'page' => 'brand_gifts', 'anchor' => 'brand-gifts-current-offers', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. render_offer_row() is DELETED - it produced a in PHP and the save handler returned the same as `row_html` to splice in, so two producers had to agree. The offer JSON the Edit button needs rides on the button through the action `data` map.', ), 'contributors.assign_a_campaign_value' => array( 'label' => __( 'Assign a campaign value', 'ai-eshop-optimizer' ), 'page' => 'contributors', 'anchor' => 'contributors-assign-a-campaign-value', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 17, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Revenue went through AIEO_DMM_Conversions_Registry::money(), one of the five money renderers the vocabulary replaced; it is `money` now. The Use button keeps its data-v through the action data map.', ), 'contributors.how_to_tag_your_ad_urls_and_the_mis' => array( 'label' => __( 'UTM parameters in ad URLs', 'ai-eshop-optimizer' ), 'page' => 'contributors', 'anchor' => 'contributors-how-to-tag-your-ad-urls-and-the-mistake-to-avoid', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'saas', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17, with sorting OFF: five Meta URL macros, wrong beside right, where the PAIRING is the content and reordering teaches nothing.', ), 'competitions.your_competitions' => array( 'label' => __( 'Your competitions', 'ai-eshop-optimizer' ), 'page' => 'competitions', 'anchor' => 'competitions-your-competitions', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 18, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. The Income cell built its own currency with get_woocommerce_currency_symbol(), which ignores the shop\'s symbol POSITION; it is `money`. The Share cell stays the one `html` hatch: it is a VARIABLE cluster of copy buttons, one per configured network.', ), 'complementary.current_sets' => array( 'label' => __( 'Current sets', 'ai-eshop-optimizer' ), 'page' => 'complementary', 'anchor' => 'complementary-current-sets', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 99, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17. Same shape as brand_gifts and treated the same way: render_row() and its render_product_link() helper are deleted, the save reloads, and the row JSON rides on the Edit button.', ), 'consent.cookie_inventory_scanner' => array( 'label' => __( 'Cookie inventory (scanner)', 'ai-eshop-optimizer' ), 'page' => 'consent', 'anchor' => 'consent-cookie-inventory-scanner', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 64, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. All three controls keep their classes and `data-name` rides on the , so the per-row save to the module\'s own endpoint is untouched. Still renders once PER CATEGORY, so the census still counts five tables as one. The stale-row grey on Last seen was an inline colour and is a badge `tone_key` now; the reason that column has to exist is kept verbatim in the code - the inventory ACCUMULATES, so without it the table reads as a list of what the site sets today.', ), 'local_pickup.pickup_locations_blocks' => array( 'label' => __( 'Pickup locations (Blocks)', 'ai-eshop-optimizer' ), 'page' => 'local_pickup', 'anchor' => 'local-pickup-pickup-locations-blocks', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 0, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, and the first user of `tbody_id` - THE HALF-AND-HALF CASE. The rows genuinely cannot come from PHP (the operator adds and removes locations with no page load and nothing is saved until Save), but the header, the classes, the scroll wrapper and the empty state are server markup. `tbody_id` keeps #aieo-lp-rows where addRow() expects it. The client builder emits the component cell contract now. 🚨 TWO REAL FIXES CAME WITH IT: an empty shop used to get a bare header with no explanation, and collect() would have thrown a TypeError on the new empty-state - guarded.', ), 'variations_gallery.wc_10_9_compatibility_migrati' => array( 'label' => __( 'WC 10.9 compatibility migration', 'ai-eshop-optimizer' ), 'page' => 'variations_gallery', 'anchor' => 'variations-gallery-wc-10-9-compatibility-migration', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 4, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, as a PANEL: `head => false` + `row_header` + `control`. The live counters are why `control` was needed - every id the migration JS looks up (aieo-dmm-vg-mig-vars-remaining, -terms-remaining, -as-pending and the completed cell) is the markup that shipped, handed back rather than re-expressed. The completed cell is now a INSIDE the td carrying that id; innerHTML on it behaves the same.', ), 'badges.badge_families' => array( 'label' => __( 'Badge families', 'ai-eshop-optimizer' ), 'page' => 'badges', 'anchor' => 'badges-badge-families', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control` - and it is the widest one: ten columns, nine of them controls. Every input keeps its class, because the inline save reads tr[data-family] and then each field by class; data-family rides on the . The label input still shows the RAW value with the translated default as its PLACEHOLDER, which is what stops an untouched Save re-baking it.', ), 'social_login.providers_2' => array( 'label' => __( 'Providers', 'ai-eshop-optimizer' ), 'page' => 'social_login', 'anchor' => 'social-login-providers-2', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 8, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. Every input keeps its providers[$code][...] name so this screen’s own save is untouched. 🚨 CONVERTING IT FOUND B88: the Magic link row spans the three columns that do not apply to it, but the header order shipped as ID, Secret, BUTTON TEXT, Callback - so those three were never adjacent, the old colspan=3 covered Button text instead of Callback, and the button-text input has ALWAYS rendered under the “Callback URL” header. Fixed by moving Callback URL before Button text. The span is a per-row callable now and the component owns the colspan.', ), 'captcha.per_form' => array( 'label' => __( 'Protect individual forms', 'ai-eshop-optimizer' ), 'page' => 'captcha', 'anchor' => 'captcha-per-form', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 24, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, and the first user of `header_html`. The checkbox per row was `control`; what actually blocked it was the HEADER, a select-all checkbox with a label, and `label` is esc_html’d so there was no way to express one. #aieo-captcha-toggle-all keeps its id and handler. Orphan rows (a rule whose form plugin is gone) were style=“opacity:.75” inline and are the new `aieo-t-row--muted` tone. B26: the omitted-count sentence STAYS rather than becoming the generic cap notice - it says the omitted forms keep their setting, which the generic notice cannot.', ), 'product_display.overview' => array( 'label' => __( 'Dashboard', 'ai-eshop-optimizer' ), 'page' => 'product_display', 'anchor' => 'product-display-overview', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 14, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20. The toggle is a `control` and keeps its .aieo-dmm-toggle markup and its data-aieo-pd-toggle attribute, so the page\'s own autosave is untouched; the Configure link became a real row ACTION rather than a hand-written . Two of the four columns are ordinary text.', ), 'google_reviews.overview' => array( 'label' => __( 'Locations & Reviews', 'ai-eshop-optimizer' ), 'page' => 'google_reviews', 'anchor' => 'google-reviews-overview', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 6, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 19. 🚨 THIS ROW NAMES THE LOCATIONS LIST, NOT THE HISTORY. Session 17 wrote "the review history sorts on the SERVER through $sort_link on five columns" onto it, which is true of a table this row has never pointed at: the anchor is the section wrapper and the table the census renders under it is the LOCATIONS list on the default tab. The history is behind grv_section=history and is registered separately as `google_reviews.history`, T1. It sat in the ⛔ pile for two sessions because the judgement was written from a heading rather than from the markup — session 18\'s lesson a third time. The conversion itself was ordinary: six rows, no inline input anywhere. The ▲▼ reorder buttons moved into the Actions column with the other three, and the reorder handler now RELOADS rather than moving the and renumbering every position in JS — a second implementation of a cell the server renders. B84 a third time: last_fetched_at is GMT and was echoed with " UTC" appended. ⚠️ ONE `html` hatch cell: the stock readout is a code element plus an optional disabled line plus an optional scanner link.', ), 'seo_redirects.redirects_d' => array( 'label' => __( 'Redirects', 'ai-eshop-optimizer' ), 'page' => 'seo_redirects', 'anchor' => 'redirects-d', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 13905, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_redirects_rules`. 🚨 B26 at the worst ratio yet: the screen rendered AIEO_SEO_Redirects::all( 200 ) under a heading that counted what it had been handed, so "Redirects (200)" was the whole visible truth about 13,905 rules. Now server-paged with the real total in the heading. The enable toggle stays the SCREEN\'S write, not T1\'s generic save-cell: set_enabled() re-runs the loop guard and can refuse a rule that would create an A→…→A cycle, and the generic UPDATE path knows nothing about redirect chains. Both handlers are delegated on the wrap now, because a T1 table is re-rendered on every page click.', ), 'duplicate_simples.duplicate_products' => array( 'label' => __( 'Duplicate products', 'ai-eshop-optimizer' ), 'page' => 'duplicate_simples', 'anchor' => 'duplicate-simples-duplicate-products', 'kind' => 'form', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 89, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control` + `table_data` (added for it). ONE table per duplicate group - 242 instances on this install - and each row carries the checkbox that chooses which product survives. The checkbox keeps its class, value and data-group; `table_data` puts data-group back on the so every closest(\'[data-group]\') in the page JS still lands; the select-all is a `header_html`. 🚨 THE COLUMNS ARE PER GROUP: each group compares on its own AXES, so the column list is BUILT per group rather than declared once - which needed nothing special, because columns are data. sortable=false: the members are a SET being decided together, not a ranking.', ), 'catalog_importer.sources' => array( 'label' => __( 'Your feed sources', 'ai-eshop-optimizer' ), 'page' => 'catalog_importer', 'anchor' => 'catalog-importer-your-feed-sources', 'query' => array( 'cati_section' => 'sources' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'both', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'CONVERTED to T0 2026-08-27 (field report: hand-rolled widefat, no sorting, and three JS handlers addressing the Last-run cell by POSITION td.eq(6) — they now address td[data-key="last_run"]). The five row actions (Edit / Run now / Refresh all details / Learn vocabulary / Delete) keep their exact classes so the module\'s delegated handlers bind unchanged. Renders only when at least one aieo_feed_source post exists; the empty state is a plain card.', ), 'catalog_importer.run_history' => array( 'label' => __( 'Import run history', 'ai-eshop-optimizer' ), 'page' => 'catalog_importer', 'anchor' => 'catalog-importer-import-run-history', 'query' => array( 'cati_section' => 'sources' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'both', 'source' => 'query', 'rows_est' => 30, 'caps' => array( 'sort' => 'client', 'paginate' => true ), 'per_page' => 10, 'adopted' => true, 'note' => 'NEW 2026-08-27 (field report: "no detailed log of what was created, updated, abandoned"). One row per finished run from wp_aieo_dmm_feed_run_log (AIEO_Feed_Run_Log, 30-day retention, written at logical-run completion — a paused slice records nothing). detail_row carries the created/updated sku lists (capped 200 each), the error skus with reasons (50) and the feed-quality notes (50). Modelled on the exchanger run log, the path that got a history the day it was built.', ), 'catalog_importer.catmap' => array( 'label' => __( 'Match the feed categories to yours', 'ai-eshop-optimizer' ), 'page' => 'catalog_importer', 'anchor' => 'catalog-importer-match-the-feeds-categories', 'query' => array( 'cati_section' => 'sources' ), 'kind' => 'form', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'both', 'source' => 'callable', 'rows_est' => 60, 'caps' => array( 'sort' => 'none', 'bulk' => true, 'editable' => true ), 'adopted' => true, 'note' => 'JS-BUILT into #aieo-cati-catmap-table per edited feed (the card only opens inside the Edit flow, so a server render has no feed to draw) — the marketplace_feeds.field_map precedent. 2026-08-27: gained a selector column, select/deselect-all, and a bulk bar (Confirm selected / Reject selected / Set category on selected → aieo_catalog_feed_catmap_bulk, which loops AIEO_Feed_Cat_Map::set_status so every mass ruling still TEACHES the global match register). Suggest matches + Accept-at-threshold buttons and the per-row Confirm/Reject are untouched.', ), 'marketplace_feeds.field_map' => array( 'label' => __( 'Feed field map', 'ai-eshop-optimizer' ), 'page' => 'marketplace_feeds', 'anchor' => 'marketplace-feeds-field-map', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 22, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`, with the orders-export field map. 🚨 THE TABLE ID IS LOAD-BEARING: dmm.js adds a mapping row by cloning the LAST of `#aieo-dmm-feed-fields tbody`, so letting the component mint its own sequential id would have left a "+" button that silently does nothing. The marketplace-template dropdown ALSO builds rows in JS and had to stay (it rebuilds from an unsaved payload, so there is nothing to reload) - it now emits the component cell contract rather than bare style and is a row_class returning the existing .aieo-t-tone--muted.', ), 'missing_hot_sellers.priority' => array( 'label' => __( 'Priority list', 'ai-eshop-optimizer' ), 'page' => 'missing_hot_sellers', 'anchor' => 'missing-hot-sellers-priority-list', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 5678, 'caps' => array( 'sort' => 'server', 'export' => true, 'paginate' => 'server', 'bulk' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'CONVERTED to T1 `missing_hot_sellers_priority` (branch shape N session, 2026-08-27 — B9 AND B33 closed together). B9: the four phys_ wide columns and the Greek label literals are GONE — per-branch units are a read-time GROUP_CONCAT pivot of the NARROW wp_aieo_dmm_branch_phys joined to the branches registry for labels (any shop, any number of locations). B33: the hand pager+sorter+export button left with the T0 table; sort/paging/filters/export are the contract\'s, over the SAME option-filtered set as the KPI totals (both compose AIEO_DMM_Physical_Recovery::gap_filter_sql — ONE source). 🔒 The # is a PRIORITY, not a row counter: ROW_NUMBER() frozen INSIDE the definition SQL over the whole filtered set (demand order), so a row keeps its number under any user sort — the constraint the old client sorter preserved by hand. Dismiss is a row action promoted to selection bulk through aieo_report_bulk (AIEO_DMM_Physical_Recovery::report_bulk — same blocklist write as the old per-row AJAX). Not PERMANENTLY local any more: the definition reads only aieo_* tables; scope stays local because the data pipeline is this install\'s ERP staging.', ), 'missing_hot_sellers.published_events' => array( 'label' => __( 'Revenue from products you published', 'ai-eshop-optimizer' ), 'page' => 'missing_hot_sellers', 'anchor' => 'missing-hot-sellers-published-from-this-list-what-it-earned-onli', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'query', 'rows_est' => 50, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — first found by the fixed census (class aieo-mhs-table, no widefat: never in any denominator before). published_at is GMT (writer comments say so) and was printed raw; `date` tz=>utc converts it. Thumbnails are the lazy `image` type (K1). Renders only when a publish event exists, so a fresh install never shows it.', ), 'saved_abandoned_carts.lost_demand' => array( 'label' => __( 'Lost demand by product', 'ai-eshop-optimizer' ), 'page' => 'saved_abandoned_carts', 'anchor' => 'saved-abandoned-carts-most-abandoned-products-lost-demand', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 25, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — first found by the fixed census (class aieo-c-table). A ranked top-25, so sorting stays OFF, same ruling as the fulfilment best/worst lists. The restocked chip rides as a suffix; Success is `percent` with its converted-of-N explanation as the cell tooltip.', ), 'saved_abandoned_carts.recent_carts' => array( 'label' => __( 'Recent carts', 'ai-eshop-optimizer' ), 'page' => 'saved_abandoned_carts', 'anchor' => 'saved-abandoned-carts-recent-carts', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 100, 'caps' => array( 'sort' => 'client', 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — first found by the fixed census (class aieo-c-table). created_at is stored GMT (AIEO_Carts_Capture writes current_time(mysql, true)) and the old cell printed the raw substring, hours off the shop clock — `datetime` tz=>utc fixed it (B84 family). Component client sort is ON (an upgrade over none); the outcome pill is the component badge with translated labels.', ), 'ai_chat_analytics.conversations' => array( 'label' => __( 'Conversations', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'query' => array( 'cac_section' => 'conversations' ), 'anchor' => 'ai-chat-analytics-conversations', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'per_page' => 50, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Server sort/paging via the screen\'s own filter form + pager (cac_sort/cac_p) stay. 🚨 The old cells ran date_i18n() over a UTC epoch, which prints UTC wall-clock as if local — every timestamp on this page was hours off; `datetime` tz=>utc fixed all four tables at once. Question/Answer keep the shared expander() through `control` cells; the drill-down thread view is prose, not a table.', ), 'ai_chat_analytics.happiness_bands' => array( 'label' => __( 'Happiness', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-happiness', 'query' => array( 'cac_section' => 'happiness' ), 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 3, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, and the caller that forced the column-level `tone_key`: one bar column, three rows, three fills (success/warning/danger). The inline CSS bars are the component `bar` type now.', ), 'ai_chat_analytics.lowest_rated' => array( 'label' => __( 'Lowest-rated answers', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-lowest-rated-answers', 'query' => array( 'cac_section' => 'happiness' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 25, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Hard LIMIT 25 now disclosed; the rating pill is an integer with band tones (>60 success, 40–60 warning, <40 danger — the same ladder the SQL buckets by).', ), 'ai_chat_analytics.origin_types' => array( 'label' => __( 'Where shoppers start chats', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-where-shoppers-start-chats', 'query' => array( 'cac_section' => 'origins' ), 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Renders only when chat_open/chat_message session events exist — ffnew has none, so even the fixed census sees only the landing-page fallback below. Verified by direct render with the section arg.', ), 'ai_chat_analytics.origin_pages' => array( 'label' => __( 'Top pages', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-where-shoppers-start-chats', 'query' => array( 'cac_section' => 'origins' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — was an `aieo-attr-sortable-table` consumer; the shipped sorter took over and the top-20 slice of 500 is disclosed.', ), 'ai_chat_analytics.landing_types' => array( 'label' => __( 'Chat session landing pages', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-chat-session-landing-pages', 'query' => array( 'cac_section' => 'origins' ), 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. The fallback origin table (session landing pages), bar cells through the component with the success fill.', ), 'ai_chat_analytics.top_products' => array( 'label' => __( 'Most-recommended products', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-most-recommended-products', 'query' => array( 'cac_section' => 'products' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — the second `aieo-attr-sortable-table` consumer on this page, retired the same way. Reads the precomputed chat-analytics cache; renders a building note until the background build lands.', ), 'ai_chat_analytics.no_products' => array( 'label' => __( 'Questions that returned no products', 'ai-eshop-optimizer' ), 'page' => 'ai_chat_analytics', 'anchor' => 'ai-chat-analytics-questions-that-returned-no-products', 'query' => array( 'cac_section' => 'products' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 50, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. LIMIT 50 disclosed; same datetime/expander/rating shapes as the conversations log.', ), 'badges.placements' => array( 'label' => __( 'Placement per surface', 'ai-eshop-optimizer' ), 'page' => 'badges', 'kind' => 'form', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'rows_est' => 4, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 (other-class tail — aieo-badges-placements). A settings matrix inside a form-table cell: `bare` so it does not read as data, `narrow` so the 900px table minimum cannot blow up the settings column, `control` selects owned by the screen\'s save handler.', ), 'shipping_rates.legacy_audit' => array( 'label' => __( 'Legacy data audit', 'ai-eshop-optimizer' ), 'page' => 'shipping_rates', 'anchor' => 'shipping-rates-legacy-data-audit-2', 'query' => array( 'trs_section' => 'migrator' ), 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Renders only while the legacy wp_woocommerce_shipping_table_rates table exists (a conditional tab — the census sees it through the declared-tabs walk).', ), 'duplicate_simples.manual_results' => array( 'label' => __( 'Results', 'ai-eshop-optimizer' ), 'page' => 'duplicate_simples', 'anchor' => 'duplicate-simples-group-by-hand', 'query' => array( 'ds_section' => 'manual' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, on the tbody_id shape (local_pickup pattern): rows arrive per search from the screen\'s jQuery, which now emits the component cell contract; the select-all checkbox is header_html; `narrow` because it lives in a half-width flex column.', ), 'duplicate_simples.attributes' => array( 'label' => __( 'Attribute axes', 'ai-eshop-optimizer' ), 'page' => 'duplicate_simples', 'anchor' => 'duplicate-simples-attributes', 'query' => array( 'ds_section' => 'attributes' ), 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'rows_est' => 400, 'caps' => array( 'sort' => 'none', 'editable' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, on the `control` pattern — each row is an attribute record whose axis identity - the id its toggle targets, `hidden`, and the parent row\'s shade. Striping is still computed per DECLARATION (a declaration can emit two rows, so nth-child would miscount) - a `row_class` now. The four-way Actions state machine is a `control`. The "any re-rendering component breaks the handlers" objection is about T1 and stays true of T1: this table has no definition and re-renders nothing.', ), 'invoice_scanner.review' => array( 'label' => __( 'Invoice review', 'ai-eshop-optimizer' ), 'page' => 'invoice_scanner', 'kind' => 'bespoke', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'query', 'note' => 'NEVER migrate, and it carries a hard non-functional constraint: per-row images must lazy-load through an IntersectionObserver with a request budget. A naive per row fired 78 admin-ajax calls and got the operator\'s IP banned. The screen ships in Rooster One Pro.', ), 'insights_t34.branch_preorder' => array( 'label' => __( 'Branch pre-orders', 'ai-eshop-optimizer' ), 'page' => 'insights_t34', 'kind' => 'bespoke', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 38560, 'adopted' => true, 'note' => 'RETIRED 2026-08-26 (P6 closure) — never migrated, BY RULING: its successor is the branch worksheet FEATURE (SPEC-2026-08-26-BRANCH-PREORDER-APPROVAL-FEATURE.md; per-branch worksheet definitions + approval queue + batches on OUR tables, shipped s24). The vendor page died with the wpDataTables registrar. The reason it could never be a report stands as the record: clicking Excel/CSV/Copy WROTE — it copied desired_qty into latest_desired_qty, NULLed desired_qty and stamped the modification time for the filtered rows. The wp_store_board_* tables\' own decommission (triggers, upsert_boards(), scanner board writes) is a separate operator decision recorded in the t34 spec.', ), 'insights_t18.stockout_cost' => array( 'label' => __( 'Stockout cost', 'ai-eshop-optimizer' ), 'page' => 'insights_t18', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 1, 'caps' => array( 'sort' => 'client', 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_out_of_stock', 'superseded_filter' => array( 'cost_known' => 'predicted' ), 'note' => 'MIGRATED, session 20, by FOLDING INTO insights_out_of_stock rather than by getting a definition of its own. It was never a second report: its WHERE is character-identical to t20\'s, and it returns exactly one aggregate row by construction, so on its own it can validate nothing. Its four honest figures now reproduce exactly in that report\'s filtered footer - 1,824 products and 485841.8736226 / 203270.2125044 / 51081.9430037. 🚨 IT NEEDED TWO CONTRACT ADDITIONS AND THAT IS WHY IT WAITED: `COUNT_DISTINCT` (COUNT is count($values), so the only expressible answer was the ROW count, which happens to equal 1,824 here and would have looked right while meaning something else) and `aggregate_of` (product_id is HIDDEN and foot() renders only visible columns, so the number had nowhere to appear). ⛔ TWO OF ITS FIVE SUMS ARE REFUSED: `linear_estimator_value` and `ewma_estimator_value` are ITEMS PER DAY, and the vendor added 1,824 daily depletion rates together under the heading "Loss Estimate" (B71). ⛔ `Parent Products` (1,313 distinct parents) is also dropped: it would need a second visible column of ids to sit under, and the report is per product. ⚠️ THE COUNT/MONEY DENOMINATOR PROBLEM IS FIXED, NOT FOOTNOTED. average_cost is NULL on 641 of 1,824 rows, so on the full report 1,824 products sit beside euro totals covering 1,183 - the `footnote` key says so. But it also gave t18 the PRESET it needed to be a real view instead of a duplicate menu row: `cost_known = predicted` filters to the 1,183 rows that HAVE a cost, which is all t18 was ever about (a NULL does not sum). On that view the count and the money finally share a denominator: 1,183 / 1,183 / 203270.2125044.', ), 'insights_t20.out_of_stock' => array( 'label' => __( 'Out of stock products', 'ai-eshop-optimizer' ), 'page' => 'insights_t20', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 1824, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_out_of_stock', 'note' => 'MIGRATED, session 16e, as insights_out_of_stock. CORRECTION: 112 COLUMNS, of which 35 are visible and FIVE are filterable; the earlier note read the column count as a filter count. 1,824 rows reproduce exactly and so do all three cost totals. 14 of the 112 are NULL AS ... in the SELECT and empty by construction, two of those visible; linear_estimator_value duplicates linear_items_per_day on every row; the Edit column hardcodes the production domain (B70) and the one footer sums a rate (B71).' ), 'insights_t27.stockout_rate' => array( 'label' => __( 'Stockout rate by period', 'ai-eshop-optimizer' ), 'page' => 'insights_t27', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 5, 'caps' => array( 'sort' => 'client', 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_stockout_rate', 'note' => 'MIGRATED 2026-08-26 (vendor wave) as insights_stockout_rate, applying the ruling: DELETED PRODUCTS DO NOT COUNT AS OUT OF STOCK. The vendor\'s LEFT JOIN counted 856 orphan product ids (45.1% of the two-year numerator) as stockouts, printing 36.61% where the true rate is 24% — the join is INNER now, and the NULL-product_id phantom leaves with them. Measured on migration day: 4,332 / 1,043 / 24.08% (the ruling\'s 4,333/24.07% was the audit day\'s rolling window; one product aged out overnight, out_of_stock matches exactly). period_rank (ordinal 1-5) is the B36 answer: row key, default sort, and the Period header\'s sort_key, so the month row can never alphabetise below the year row — the vendor\'s own grid broke on the first Period click. Every window now anchors UTC_TIMESTAMP() against the GMT column (the vendor compared against server-local NOW() fifteen times). ⛔ NO aggregate on ANY column: the periods NEST, so a footer sums the same orders five times (audit: 1,535,155.97 against a true 816,819.19; the rate column would print 125.21%). One scan per window instead of the vendor\'s fifteen: 0.53s measured. The empty-period division-by-zero cannot occur: a period with no rows produces no row, not a 0/0.', ), 'insights_t26.out_of_stock_variations' => array( 'label' => __( 'Out of stock variations', 'ai-eshop-optimizer' ), 'page' => 'insights_t26', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 452, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_variations_stock', 'note' => 'MIGRATED 2026-08-26 (vendor wave) as insights_variations_stock — by REBUILDING THE LOST PIPELINE, not by porting the query. The Pods source froze 2026-05-31 and had NO aieo twin (verified by SHOW TABLES); the operator ruled: create the table and the writer. Analytics schema v28 adds the variations_stock job (AIEO_GenerateVariationsStockCondition → wp_aieo_variations_stock_condition): one row per PUBLISHED variable parent, stock counts from wc_product_meta_lookup, vitals labels. ⛔ THE FROZEN 627 ROWS DO NOT REPRODUCE AND MUST NOT: the live recompute finds 996 parents, 452 with an out-of-stock variation — 87 days of stock movement plus unpublished/deleted parents is the difference, and a port that reproduced 627 forever would merely have LOOKED correct (the audit\'s named trap). 🚨 latest_sale_gmt is honest where the Pods latest_variation_sale_date was not (a CREATION date on 50 of 627 rows, the parent\'s own date on 11 more): MAX(wc_orders.date_created_gmt) over the parent\'s real order lines, NULL when never sold. NOT wc_order_product_lookup.date_created — that column is SITE-LOCAL wall clock (measured +3h on 5,000 of 5,000 recent lines). The baked link column is replaced by a real Edit action; image_url rehosts (B65).', ), 'insights_t14.stock_lookup' => array( 'label' => __( 'Quick stock lookup', 'ai-eshop-optimizer' ), 'page' => 'insights_t14', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 3174, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_stock_check', 'superseded_filter' => array( 'stock_state' => 'out_of_stock' ), 'note' => 'MIGRATED, session 16c, as insights_stock_check TOGETHER WITH t15: the two SELECTs are identical apart from the WHERE, so they are one report and a filter. Row counts reproduce the vendor exactly, 3,174 here and 234 there, 3,408 in the union.', ), 'insights_t15.stock_not_set' => array( 'label' => __( 'Stock not set', 'ai-eshop-optimizer' ), 'page' => 'insights_t15', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 234, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_stock_check', 'superseded_filter' => array( 'stock_state' => 'not_set' ), 'note' => 'MIGRATED, session 16c, INTO insights_stock_check together with t14 rather than as a screen of its own: pick State = Stock not set. Its 234 rows are reproduced exactly. Do not build a second definition for it.', ), 'insights_t8.bought_alone' => array( 'label' => __( 'Bought alone', 'ai-eshop-optimizer' ), 'page' => 'insights_t8', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 4555, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_basket_position', 'superseded_filter' => array( 'cart_position' => 'only' ), 'note' => 'MIGRATED, session 16e, INTO insights_basket_position with t6 and t7: pick Position = The only product. Its 4,555 rows are reproduced exactly. It is also the report the cascade filtering was validated against byte for byte in session 9, so it stays renderable in the vendor until the replacement has been clicked on roosterp.', ), 'insights_t6.first_in_cart' => array( 'label' => __( 'First product in the cart', 'ai-eshop-optimizer' ), 'page' => 'insights_t6', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 6845, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_basket_position', 'superseded_filter' => array( 'cart_position' => 'first' ), 'note' => 'MIGRATED, session 16e, as insights_basket_position TOGETHER WITH t7 and t8. CORRECTION: these are three SEPARATE tables (freq_entry / freq_exit / freq_single_item_orders), not one wp_aieo_order_products_frequency, so the collapse is a UNION ALL with a position discriminator rather than a base_where. Eleven COLUMNS, seven filters; the earlier note said eleven filters. Row counts reproduce the vendor exactly: 6,845 here, 7,513 on t7, 4,555 on t8, 18,913 in the union. B66 RULED 2026-08-26: single-product orders KEEP counting as first AND last AND only on this screen; AIEO_Basket_Role keeps its narrower opener/closer rule for scoring. Both stand, each labelled — the 44% divergence is by design, not a defect.', ), 'insights_t7.last_before_checkout' => array( 'label' => __( 'Last product before checkout', 'ai-eshop-optimizer' ), 'page' => 'insights_t7', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 7513, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_basket_position', 'superseded_filter' => array( 'cart_position' => 'last' ), 'note' => 'MIGRATED, session 16e, INTO insights_basket_position with t6 and t8: pick Position = Last before checkout. Its 7,513 rows are reproduced exactly. Do not build a second definition for it. Reads wp_aieo_order_products_freq_exit, a sibling TABLE of t6 and t8 rather than the same one.', ), 'insights_t43.ga_product_journey' => array( 'label' => __( 'Google Analytics product journey', 'ai-eshop-optimizer' ), 'page' => 'insights_t43', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 0, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'defects' => array( 'B13' ), 'adopted' => true, 'note' => 'RETIRED 2026-08-26 (P6 closure). The closure ruling: wp_pods_google_analytics holds 0 rows and has NO writer, the Universal-Analytics-era pipeline that fed it is gone, and the funnel question (view → basket → checkout per product) is answered natively by session analytics — commissioning a GA ingest would duplicate an existing capability. Formally retired, not parked: the page died with the wpDataTables registrar, its menu entry and tier id are deleted, no alias (there is nothing to alias to). Its one lasting contribution was B58: the vendor DivisionByZeroError its empty aggregate exposed, patched in the tree until the vendor plugin leaves.', ), 'insights_t32.unit_sales' => array( 'label' => __( 'Unit sales, online and branches', 'ai-eshop-optimizer' ), 'page' => 'insights_t32', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 10986, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_unit_sales', 'note' => 'MIGRATED, session 20, as insights_unit_sales, which also covers wpDataTable 31 ("Total Sales Quick Stats") - t31 selects a strict SUBSET of the same table with no joins and no WHERE, so it is one report and a shorter view of it. ⚠️ t31 has no registry row because it is not one of the 22 LIVE reports; the collapse is ready for it if it ever gets a page. 10,986 rows and 29,825 / 91,062 units reproduce exactly. THREE THINGS THE DATA DECIDED: (1) `supplier` is DROPPED - it is empty on 10,986 of 10,986 rows because the vitals supplier backfill does not exist (6 of 13,828 vitals rows carry one, none matching a store_sales SKU), so both vendor reports have always shown a permanently blank column; (2) the eshop figure is the ERP one - total_30d reconciles against eshop_soft1 with 0 mismatches and against eshop_woo with 480; (3) only the two TOTAL columns carry a SUM, because total_30d equals the five channels on every row and a footer over both reports the same 29,825 units twice. Trends are rates and carry no aggregate (B71 family). 2,852 rows have no Woo product, so the name falls back to the SKU in the SELECT - a `suffix` could not do it, because render() returns the empty marker BEFORE decorate() runs. image_url is rehosted (B65, 8,055 rows on localhost).', ), 'insights_t33.erp_raw_orders' => array( 'label' => __( 'ERP raw order data', 'ai-eshop-optimizer' ), 'page' => 'insights_t33', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 3673517, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_erp_raw_orders', 'note' => 'MIGRATED 2026-08-26 (vendor wave), APPROVED work first: bin/apply-erp-orders-date-index.php adds idx_orders_date (OrderDate) — the table\'s only date index led on SKU, so the report\'s defining filter was type=ALL over 3.67M rows (~6.6s/page in the vendor; the index built in 3.5s and InnoDB\'s implicit PK suffix serves ORDER BY OrderDate, Id both ways). B36 was answered ONE COLUMN LEFT of the vendor\'s SELECT: the table has an AUTO_INCREMENT Id the vendor never selected — row_key and tie-break; within the twelve selected columns the audit\'s "no unique column" stands (worst tie 694 rows, 6.66% exact duplicate rows). ⛔ NO aggregate on Quantity: THREE corrupt rows, not two — the ruling carries the verification\'s find that cleaning the audit\'s two leaves a -516,450,961 footer, and a footer needing a hand-maintained exclusion list is not a footer. ✅ Line_price DOES sum: the ±124M "sentinel" rows are two exact reversal pairs netting 0 (false alarm, killed — do not spend time on it again). Quantity/Line_price are typed via CAST select expressions (varchar storage sorts \'10\' before \'2\'), NULL-preserving so the 156,091 priced-less rows render empty, not €0.00. OrderDate is ATHENS WALL CLOCK (proven on the t42 family: equals the site\'s invoice date on all 29,212 matched rows) so it declares no tz. The definition file returns NOTHING where the staging table does not exist, so the page exists exactly on ERP-sync installs. Local forever; export gated aieo_report_export (customer names).', ), 'insights_t5.cart_products' => array( 'label' => __( 'All cart products', 'ai-eshop-optimizer' ), 'page' => 'insights_t5', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 145389, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_cart_products', 'note' => 'MIGRATED, session 16b, as the second definition on the contract. One row per ORDER LINE, not per order: 145,389 lines across 51,003 orders. order_total is the ORDER total repeated on every line, so summing that column overstates by 4.4x (7,446,938 against a true 1,702,515). The definition declares NO aggregate on it, deliberately, and says why.', ), 'insights_t4.customer_order_averages' => array( 'label' => __( 'Customer order averages', 'ai-eshop-optimizer' ), 'page' => 'insights_t4', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 59311, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_customer_order_averages', 'note' => 'MIGRATED, session 20. 🚨 IT WAS BLOCKED ON ITS SOURCE TABLE, NOT ON ANYTHING IN A DEFINITION: the cum_order_stats job opened with `drop table if exists wp_aieo_cumulative_order_stats` and rebuilt it with CREATE ... SELECT, so the table did not exist for the whole length of a 59,311-row aggregate, and a report pointed at it would have thrown a hard SQL error nightly rather than returning nothing. Fixed FIRST (analytics schema v27: build into _new, atomic RENAME into place), then the definition was written. 59,311 rows and SUM(OrderSpend) 2,083,911.08 reproduce exactly. ⛔ TWO COLUMNS DROPPED: `TotalAverageOrder` is byte-identical to `AveSpendOfUserPerOrder` on all 59,311 rows (both are Round(avg_spend,2) in the builder) - the third vendor report to display one number twice; and `link_to_order_details` is dead on every row, storing markup (B12) at a URL that does not open an order on an HPOS store. Replaced by a real action using the new `{order_edit_url}` template token, which asks WooCommerce where orders are edited on THIS install. ⚠️ NO FOOTER EXCEPT OrderSpend: every per-customer column repeats once per order, so a SUM down one is order-weighted read as customer-weighted (overstates 4.16x). ⚠️ Two data-register findings are DISCLOSED rather than fixed - the four Buyers* columns are blanked on 12,513 rows (21.1%) by an undocumented `WHERE orderID < 49338` UPDATE, and 122 empty-email orders are grouped as one synthetic customer so their averages are fabricated. ⚠️ Its column labels are new msgids and render English on a Greek install until the catalogue round; that is true of all 18 definitions, not this one.', ), 'insights_t13.survey_results' => array( 'label' => __( 'Post-order survey results', 'ai-eshop-optimizer' ), 'page' => 'insights_t13', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 2866, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED 2026-08-26 (vendor wave) as insights_survey_results — the ruling reframed the audit\'s "revive or retire?": THE MIGRATION ALREADY EXISTED. wp_aieo_survey_responses is fed live from the survey forms and kept collecting after Pods died (feed NOT dead — latest response 2026-08-09). Coverage verified then CLOSED: all 2,687 cssurvey rows carry an entry id, 2,661 were present, and bin/backfill-survey-gap.php recovered the 26 missing (18 form-18 entries with a legacy NULL status the active-only ingest skips, 4 form-25 early-variant entries, 4 entries deleted from the form plugin, mapped from the cssurvey copy) — 0 missing after. ⛔ NO AGGREGATE ON ANY SCORE: the ingest stores 0 for an unanswered NPS/satisfaction, so an AVG mixes answers with non-answers — the exact 4.0975-instead-of-4.3795 trap the audit predicted for this report, in our schema\'s shape. ⛔ The FIND_IN_SET filter capability was NOT built: the comma-joined ranking columns hold opaque form tokens (grank…) nobody can read — the vendor offered filters over 289 raw hash strings; ours carries the column hidden for export and renders people-readable scores instead. ⛔ CustomerLink (deep link into another report\'s filter) dropped: no prefill contract yet — the cross-report deep-link item in the finishing plan §5. Export gated by aieo_report_export (free-text answers + emails). OrderLink\'s hardcoded host replaced by {order_edit_url}.', ), 'insights_t41.woo_vs_erp_this_month' => array( 'label' => __( 'Woo against ERP turnover, this month', 'ai-eshop-optimizer' ), 'page' => 'insights_t41', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 308, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_invoice_match', 'superseded_filter' => array( 'month_slot' => 'current' ), 'note' => 'MIGRATED, session 16e, INTO insights_invoice_match with t40: pick Month = 2026-08. Its 308 rows are reproduced exactly and so are its money totals. ON THESE THREE THE FOOTER IS THE ANSWER. Nobody reads the rows: they read the filtered totals and compare them. A migration that gets the footer wrong has failed even if every row is right.', ), 'insights_t40.woo_vs_erp_last_month' => array( 'label' => __( 'Woo against ERP turnover, last month', 'ai-eshop-optimizer' ), 'page' => 'insights_t40', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 1032, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_invoice_match', 'superseded_filter' => array( 'month_slot' => 'previous' ), 'note' => 'MIGRATED, session 16e, as insights_invoice_match TOGETHER WITH t41. CORRECTION: t40 and t41 are two SEPARATE tables (aieo_order_invoice_match_report and ..._current_month), not one table with a date window, so the collapse is a UNION ALL keyed on the report_month column both of them already carry and which BOTH vendor reports hide. 1,032 rows here, 308 on t41, 1,340 in the union; SUM(woo_total) 32,236.63 and 9,791.07 reproduce exactly.', ), 'insights_t42.woo_vs_erp_history' => array( 'label' => __( 'Woo against ERP turnover, history', 'ai-eshop-optimizer' ), 'page' => 'insights_t42', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 36836, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_invoice_match', 'superseded_filter' => array( 'month_slot' => 'historical' ), 'note' => 'MIGRATED 2026-08-26 (vendor wave), APPROVED work first, INTO insights_invoice_match as the third UNION branch (preset month_slot=historical). What its admission required: (1) bin/load-mysql-tz-tables.php loads the MySQL timezone tables from PHP\'s own zoneinfo — mysql.time_zone_name held 0 rows, so the DST-correct named-zone CONVERT_TZ silently returned NULL (verified after: winter +2, summer +3); (2) the PER-BRANCH CLOCK HOOK, AIEO_Report_Query::branch_site_time_expression() — the archive stores order_datetime in GMT while the live pair stores site time, and a column-level tz declaration cannot vary per row, so the branch normalises AT THE BRANCH and the union holds one clock (a fixed +03:00 would be an hour wrong on 13,941 rows; the missing-tz-tables fallback is raw GMT via COALESCE, never NULLs). The branch is CONDITIONAL on the table existing, so the collapsed definition STAYS scope both — this row stays local because the archive itself is. ⛔ The SUM on invoice_total stays refused — with matched archive rows in the union the 5.82% double-count (786,416.02 vs a true 743,162.63; 2,788 rows share an invoice) is now LIVE if anyone restores it. ⚠️ date_diff_days is the archive\'s own stored arithmetic against the ORIGINAL clocks; the converted display desynchronises from it on 2,505 midnight-crossing rows — stored values are not repaired by code. ⚠️ Both previously-published wrong figures were verified already fixed at source before this migration (the ~90% claim here and in the definition docblock; the shipped SUM): the audit corrected them, this session re-checked. The 8 missing months (2025-09..2026-03, 2026-06) are disclosed in the definition footnote. row_uid now carries the slot so a revived matcher re-running an archived month cannot mint two rows with one identity.', ), 'insights_t35.reorder_summary' => array( 'label' => __( 'Reorder summary', 'ai-eshop-optimizer' ), 'page' => 'insights_t35', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 1112, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_reorder_summary', 'note' => 'MIGRATED 2026-08-26 (vendor wave, the LAST ruling) as insights_reorder_summary. THE RULING ON THE TWO NULL COLUMNS: origin_type DROPPED (the vendor SELECTed literal NULL and gave it a multiselect filter with zero options; the only origin_type column anywhere is on wp_store_board_keratsini, which this query never joined — nobody could say what it meant); parent_image_url RECOVERED via a second vitals join on pv.parent_id (550 of 550 vitals-resolvable rows have a parent with an image — the vendor selected literal NULL). Fixed with the conversion per the defect policy: (1) the supplier_id ID-SPACE COLLISION — the vendor coalesced termmeta aieo_supplier_erp_id with the raw term id, printing a WP term id under an ERP-id heading on 746 of 1,112 rows; the column is now ERP id or EMPTY. (2) THE CLOCK SEAM CLOSED AT THE DATA: bin/normalize-preorder-clocks.php converted the 1,110 legacy Athens-wall-clock rows to GMT (DST-correct via named-zone CONVERT_TZ, option-guarded idempotent, refuses to run without the MySQL tz tables) — the mixed-clock column the audit called the likeliest failure is one clock now, which also fixes the approval queue\'s already-declared tz=utc rendering of the same rows. (3) baked markup replaced by the link type with a render-time href that is EMPTY for the 562 rows whose product resolves nowhere (they stay listed as #id — the ledger records the request even when the product is gone). Still the read-only companion to t34\'s FEATURE (the queue): keep them apart.', ), 'insights_t28.needs_filters' => array( 'label' => __( 'Needs coverage', 'ai-eshop-optimizer' ), 'page' => 'insights_t28', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 4306, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'superseded_by' => 'insights_product_needs', 'note' => 'MIGRATED 2026-08-26 (vendor wave) as insights_product_needs. The ruling named wp_aieo_dmm_product_needs canonical — verified 0 of 4,306 selected_needs differing from the Pods copy on migration day — and the populator VERIFIED Pods-free: the Product Needs module\'s own editor writes it (ajax_save → $wpdb->replace + pa_need term sync); Pods is inactive here and nothing in the write path references it. Product attributes join LIVE aieo_core_product_vitals at read time, which retires the frozen copy\'s measured drift (566 names, 1,009 SKUs); the Pods varchar(255)-unindexed product_id is a bigint PK in ours. The vendor\'s only button — a baked /product-need-editor?pid= link to the retired mu-plugin\'s page — is replaced by an Edit-needs action opening our editor (admin.php?page=ai-eshop-dmm&dmm_tab=product_needs&pid=). ⛔ FIND_IN_SET filtering NOT built: text search finds a need token; a per-token select stays a contract capability question. ⚠️ 1,959 rows carry no needs (now filterable via has_needs — the report\'s stated purpose); 383 rows have no vitals match (deleted products, kept as records); the 1,173 published products with NO row at all remain data-register item 8 — a definition cannot filter rows into existence.', ), 'erp_purchases_by_supplier.by_supplier' => array( 'label' => __( 'Purchases by supplier', 'ai-eshop-optimizer' ), 'page' => 'erp_purchases_by_supplier', 'anchor' => 'erp-purchases-by-supplier', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 5, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'P4 report 1 of 4, born on the contract 2026-08-26. Grouped per supplier per currency; supplier identity = VAT number where read (1,150 of 1,162 docs), else the snapshot name; displayed via the curated supplier term where matched. First report to declare chart (D10).', ), 'erp_vat_reconciliation.by_month_rate' => array( 'label' => __( 'VAT reconciliation', 'ai-eshop-optimizer' ), 'page' => 'erp_vat_reconciliation', 'anchor' => 'erp-vat-reconciliation', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 6, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'P4 report 2 of 4. Buckets by the CALENDAR DATE printed on the document (a DATE, no clock — nothing to convert; the aieo_time_convention question answers itself here), by VAT rate, by currency. VAT is COMPUTED (line_net × vat_pct; line_vat is unpopulated in scanner data); the no-readable-rate bucket keeps its base visible and its VAT blank rather than guessed. Undated documents (12 on ffnew) get their own bucket rather than vanishing.', ), 'erp_landed_cost_margin.by_product' => array( 'label' => __( 'Landed cost vs sale price', 'ai-eshop-optimizer' ), 'page' => 'erp_landed_cost_margin', 'anchor' => 'erp-landed-cost-margin', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 269, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'P4 report 3 of 4. Per matched product (variation-first). Margin is NET vs NET: the gross sale price is netted with the VAT rate from the product\'s own latest purchase line, and the margin renders BLANK where that rate is unreadable — never guessed. ⚠ On ffnew the matched products are test fixtures absent from vitals AND the price lookup (0 of 269 resolve), so sale price/margin render empty locally; the join populates on production-shaped data.', ), 'erp_supplier_price_drift.by_item' => array( 'label' => __( 'Supplier price drift', 'ai-eshop-optimizer' ), 'page' => 'erp_supplier_price_drift', 'anchor' => 'erp-supplier-price-drift', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 5, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'P4 report 4 of 4, the second chart (D10). Drift on the PRINTED unit price before line discounts (a discount is a campaign, not a reprice), first/latest by the document\'s printed date (scan order lies — batches arrive late), invoices only (a credit note reverses money, not the list price), keyed on the SUPPLIER\'s sku so two suppliers of one product drift independently. HAVING >1: one price is not a trend.', ), 'data_prep_scheduler.last_run' => array( 'label' => __( 'Last run', 'ai-eshop-optimizer' ), 'page' => 'data_prep_scheduler', 'anchor' => 'data-prep-scheduler-last-run', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 25, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. Duration was the raw number with an "s" concatenated — a decimal with a literal `suffix` now, and NOT the `duration` type: that one is for a SPAN and renders "1 min" through human_time_diff, which would turn a 3.2-second stage into a minute. A failed stage was one red inside an otherwise grey cell; it is the row tone `aieo-t-row--danger`, so the whole failed row reads as failed.', ), 'data_prep_scheduler.feed_imports' => array( 'label' => __( 'Feed imports on a clock', 'ai-eshop-optimizer' ), 'page' => 'data_prep_scheduler', 'anchor' => 'data-prep-scheduler-feed-imports', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'rows_est' => 5, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'CONVERTED to T0 2026-08-27. The census could never see this table on an install with no feed sources (it renders only when one exists), so the sweep missed it — surfaced the day a fixture source gave it rows, the same failure class the field report caught on the catalog importer\'s own tables. Read-only audit view; schedules are set on the sources tab.', ), 'erp_database.connections' => array( 'label' => __( 'Connections', 'ai-eshop-optimizer' ), 'page' => 'erp_database', 'anchor' => 'erp-database-connections', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via card(), session 18, and it is the FIRST USER of `row_data`. 🚨 The Edit button used to REBUILD THE RECORD OUT OF THE RENDERED CELLS: cells[1] split on ":" for host and port, and cells[4] compared against the literal "★" for the default flag — a decorative glyph made load-bearing, and both break the moment a column moves. The record rides on the row now. Delete confirms through the component action and the screen listens for `aieo-table:action`, not a raw click, so a cancelled confirm no longer deletes. ⚠️ Renders EMPTY on ffnew (no connections configured), so the row cells are unverified on screen.', ), 'lookalike_export.web_push_lookalike_audience_export_meta_google_2' => array( 'label' => __( 'Lookalike audience export', 'ai-eshop-optimizer' ), 'page' => 'lookalike_export', 'anchor' => 'web-push-lookalike-audience-export-meta-google', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 2, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. Rendered by AIEO_DMM_Web_Push, not by the lookalike_export shim. The addressable share was a with an inline colour (a `suffix`) and the cohort slug a under the label (a `subline`). The download is a row action.', ), 'roi_wizard.who_drove_the_revenue_rooster_vs_your' => array( 'label' => __( 'Rooster vs your own campaigns', 'ai-eshop-optimizer' ), 'page' => 'roi_wizard', 'anchor' => 'roi-wizard-who-drove-the-revenue-rooster-vs-your-own-ca', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18, and the first user of `header_tip`. Three separate money cells each guarded wc_price() themselves; `money` guards it once. The pale-blue highlight on the Rooster-driven rows was an inline background and is the `info` row tone. ROAS is banded rather than inline-coloured, with an UNBOUNDED danger band under a `min` success band — band_tone picks the highest matching `min`, so break-even itself reads success, which is what the original `<` did.', ), 'seo_redirects.redirects_top_404s_d' => array( 'label' => __( 'Top 404s', 'ai-eshop-optimizer' ), 'page' => 'seo_redirects', 'anchor' => 'redirects-top-404s-d', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via card(), session 18. The RULE LIST beside it on the same screen stays hand-written and is registered separately — every row of that one carries an autosaving toggle. B26: the heading counts the rows it SHOWS and top_404() caps at 50, so "Top 404s (50)" could always have meant 50 of 4,000; the component discloses it. Both buttons arrive as one delegated `aieo-table:action`, which also survives a re-render — the two querySelectorAll loops it replaces did not.', ), 'competition_insights.table' => array( 'label' => __( 'Top participants by lifetime value', 'ai-eshop-optimizer' ), 'page' => 'competition_insights', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 10, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0 via card(), session 18. NOT a B73 phantom despite having no card anchor: it is a real server-rendered table inside a plain `.card` div under an

, so the census took the heading from the

above it and its harvested label was a run-on of the whole refresh toolbar. Rendered by AIEO_DMM_Competitions::render_insights(), not by the competition_insights shim. B74: the LTV cell concatenated get_woocommerce_currency_symbol() onto number_format_i18n, which puts the symbol on the wrong side in any currency that leads with it.', ), 'ad_connectors.ad_spend_connectors' => array( 'label' => __( 'One Google account, three jobs', 'ai-eshop-optimizer' ), 'page' => 'ad_connectors', 'anchor' => 'ad-connectors-ad-spend-connectors', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 7, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. TWO tables of one shape (the wiring card and the boost-governance card) that the census counts as one row; wiring_row() returns data now and one wiring_table() renders both. `sortable => false` on purpose — the order IS the story (spend, boost, campaigns; enabled, sandbox, kill, caps) and alphabetising a list of gates is worse than the list. First user of the badge `tone_key`: "off" is GREEN for paid boosting (off is the shipped, safe state) and grey for sandbox (off means live spend), so the tone is a per-row judgement the value => tone map cannot express. No row_key: there is no record behind these rows and keying them on the translated label would put a Greek sentence in a data-row-key.', ), 'form_insights.table' => array( 'label' => __( 'Answer distribution per field', 'ai-eshop-optimizer' ), 'page' => 'form_insights', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 4, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. NOT a B73 phantom: it is real markup, one table PER FORM FIELD inside its own card, so the census saw no card anchor of its own and took a run-on label from the field heading. The share column was a flex row wrapping two nested divs with a hardcoded #2271b1 — the `bar` type. Rows stay clickable through `row_data` (the drill needs field, stored value AND label, which is more than an identity). ⚠️ `tabindex` and `role` are NOT data-* so row_data cannot carry them; the screen re-applies those two attributes after render, which is the one thing left that touches a rendered row. Since 2026-08-30 it renders from assets/pages/dmm/form-insights-body.php, which the AJAX refresh path also uses — there is no longer a second hand-built table in the screen JS.', ), 'form_insights.section_scores' => array( 'label' => __( 'Average score per question', 'ai-eshop-optimizer' ), 'page' => 'form_insights', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 7, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'Added 2026-08-30 with the field-shape classifier. One row per RATING question in a section, sorted worst-first — `sortable => false` because that order is the whole point of the table. The bar is scaled to the question\'s OWN top of scale via a `scale_max` row key, never a hardcoded 5, so a 1-10 question beside a 1-5 one does not share an axis it does not share on the form. `label => false` on the bar because the Average column already prints the number. Tone comes from a `tone_key` computed per row as a fraction of that scale, for the same reason.', ), 'form_insights.tick_answers' => array( 'label' => __( 'Answer distribution per field', 'ai-eshop-optimizer' ), 'page' => 'form_insights', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 4, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'Added 2026-08-30. Several standalone "tick if this applies" checkboxes under one section heading are not several questions — they are the answers to the heading ("how did you hear about us"), and one one-row table each is the shape that hid it. Share is out of ALL entries, not out of the people who ticked, which would score every one of them at 100%. Only forms when the section holds two or more; a lone tick keeps its own block.', ), 'form_insights.ranked_options' => array( 'label' => __( 'Ranked in order', 'ai-eshop-optimizer' ), 'page' => 'form_insights', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 9, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'Added 2026-08-30. A Gravity `rank` field stores ONE comma-separated row per entry and the POSITION is the answer, so the summary is a mean position and LOW WINS — the bar carries `invert => true` for exactly that, or it would grow as the answer got worse. Rows are deliberately NOT drillable: "who put shipping third" is a different question from "who chose shipping" and the row does not carry the answer that would make the drill honest.', ), 'form_insights.matrix_rows' => array( 'label' => __( 'Rating grid', 'ai-eshop-optimizer' ), 'page' => 'form_insights', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 5, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'Added 2026-08-30. One row per SUB-INPUT of a multi-row likert. ⚠️ The row ids are not guessable — the add-on skips .10 and .20, so a ten-row likert ends at 7.11 — and each sub-input stores ":", so only the half after the colon is the answer. Neither form on the reference install has one; the shape is built from the add-on source and exercised by the classifier, not by live data.', ), 'delivery_stats.fulfillment_delivery_time_metrics_by_postal_area' => array( 'label' => __( 'Delivery times by postcode area', 'ai-eshop-optimizer' ), 'page' => 'delivery_stats', 'anchor' => 'fulfillment-delivery-time-metrics-by-postal-area', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 8, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. ⚠️ Same table as `fulfillment.delivery_metrics`; both ids are frozen and both are marked adopted. Three things went out with it: B74 — $fmt_money HARDCODED "€" and the C locale (number_format($f, 2, ".", ",")), so a shop that writes 1.234,56 € everywhere else read €1,234.56 here with the separators the wrong way round; its own 48-line sorter with its own arrow spans and data-sort-type; and two

and stay one. The drill-down reads `data-row-key` instead of data-prefix, and its context moved from data-* on the
s, so the two implementations cannot drift.', ), 'seo_opportunities.opportunities_content_opportunities' => array( 'label' => __( 'Traffic × conversion grid', 'ai-eshop-optimizer' ), 'page' => 'seo_opportunities', 'anchor' => 'opportunities-content-opportunities', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 284, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'editable' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_opportunities_grid`. The per-row APPLY PANEL is gone and nothing went with it: one held a keyphrase box, a Set button, a "suggest" link, a priority number, a second Set button and a status span. The two boxes are `editable` COLUMNS that autosave through the T1 write path — so both Set buttons had nothing left to do — and suggest is a row action. The write is routed by the module\'s `aieo_report_save_cell` filter to AIEO_SEO_Store, NOT to aieo_seo_posts directly, because the store is what marks a keyphrase behaviour-sourced so the AI and import paths do not overwrite it. B26: rows( 100 ) of 284 scored objects, with the quadrant chips above the table counting the whole set — four numbers that did not describe the rows beneath them. ⚠️ The recommendation is still resolved from the quadrant CODE in the VIEWING admin\'s locale, now in decorate_rows(); the scorer used to translate it at cron time under the site locale and freeze it, so an English-profile admin read Greek.', ), 'seo_meta_batch.meta_batch_catalogue' => array( 'label' => __( 'Descriptions & Keywords', 'ai-eshop-optimizer' ), 'page' => 'seo_meta_batch', 'anchor' => 'meta-batch-catalogue', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 5316, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'editable' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_meta_batch_catalogue`. 🚨 B85: `$metab_sort_th` echoed `esc_url( $url )` with $url never imported into the closure and never in scope — an undefined variable, an empty href, four dead headers. NOBODY HAS EVER SORTED THIS TABLE, so "server sort through $metab_sort_th" described an intention. The
Compare disclosure became four real columns (existing / ours / ours-keywords as chips / AI draft) which sort, filter and export; ⚠️ the ♪ marker on a keyword Search Console has actually seen is NOT carried across and is owed to the `list` type\'s per-chip tooltip. Conditional row actions arrived with it: `when` is the declarative form of T0\'s `visible` callable, because a definition is serialisable data. The three cells SQL cannot produce come through the new `aieo_report_rows` filter, which runs on the AJAX path too — a render override would have been page 1 only.', ), 'product_exchanger.overview' => array( 'label' => __( 'Product feeds between sites', 'ai-eshop-optimizer' ), 'page' => 'product_exchanger', 'anchor' => 'product-exchanger-feeds', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20. The Slug/token WIDGET cell is a `control`: a widget cell is a cell the SCREEN owns, not an unfinished migration, so it is declared rather than counted as `html` debt. Four of six columns are on the vocabulary. The five action buttons stay `control` deliberately - each is addressed by its own data-aieo-exch-* attribute from this module\'s JS, and re-expressing them would rewrite the behaviour in the same commit as the conversion. The build status moved from a coloured to a subline PLUS a danger row tone. The run-log table in the same module converted with it and uses `detail_row` for the error payload, retiring a hand-typed colspan="8".', ), 'insights_t3.order_patterns' => array( 'label' => __( 'Order Patterns', 'ai-eshop-optimizer' ), 'page' => 'insights_t3', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 62222, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, ), 'adopted' => true, 'superseded_by' => 'insights_order_patterns', 'note' => 'P3 primary. One purpose-built AIEO table, no Pods and no ERP dependency, and it lands on roosterp where it has never existed. The vendor rendering on ffnew is the regression oracle for the DATA. Not for the filters: wdt-powerful-filters is inactive on ffnew, so per-column and cascade filtering must be validated against production behaviour or you will wrongly conclude they are not needed.', ), 'price_history.omnibus' => array( 'label' => __( 'Price History (Omnibus)', 'ai-eshop-optimizer' ), 'page' => 'price_history', 'anchor' => 'price-history-eu-omnibus', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'query', 'rows_est' => 37902, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'export' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T1 2026-08-27 as `price_history_changes` — the LAST table onto the contract and the last live B26 carrier. All three recorded blockers went with it: the inline query is a definition; the wc_price() HTML baked into the row builder is a `money` column formatted at display (so the value sorts, aggregates and exports as a number); the N+1 wc_get_product() name lookup is a LEFT JOIN on posts with the parent-title fallback for variations. B26 CLOSED: the silent LIMIT 50 is gone entirely rather than merely disclosed — 24,338 products have a recorded change on ffnew and the screen showed fifty as though that were the list. The definition returns array() when wp_aieo_price_history does not exist, so an install that never enabled the feature keeps a valid registry.', ), 'stock_recovery.queue' => array( 'label' => __( 'Stock recovery queue', 'ai-eshop-optimizer' ), 'page' => 'stock_recovery', 'anchor' => 'physical-recovery-recovery-queue-oos-in-eshop-selling-in-store', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 500, 'caps' => array( 'sort' => 'client', 'export' => true, 'bulk' => true, 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 (other-class tail, aieo-pr-table). B1 RULED honour (2026-08-26): the Qty input\'s posted value now reaches apply_restock (empty/0 = engine qty). B2 RULED both-labelled (same day): the predictor\'s est_lost_sales renders as its own "Predictor est. lost online" column beside the in-store-anchored figure, so screen and export finally show the same two metrics under two labels. B3/B4/B5 closed: cap disclosed, export superset disclosed, dismiss now removes the row. B6/B7 closed s25. The screen keeps its OWN sorter reading row_data — the component sorter is off because the sort must re-page. The bulk-apply WHERE question is SETTLED (2026-08-27, measured not argued): the two sets are different questions and both are right — `visible` (the queue: phys_units_recent>0, not dismissed) and `applicable` (what Run now writes to: meets_threshold=1, pending, unblocked) — and the invariant that matters, applicable ⊆ visible, HOLDS on live data (789 visible, 107 applicable, `applicable AND NOT visible` = 0 rows), so the button never writes to a product the operator could not see. The earlier 🚨 inferred a danger from the two WHEREs merely looking different. The REAL defect it was hiding: the 107 was never disclosed before the write — the operator read 789 rows, clicked once, and no number was named anywhere. Both predicates are now single-sourced (where_visible()/where_applicable(), five copied literals collapsed, the nightly auto-apply included) and ajax_apply_count reports eligible+visible so the restock confirms with its count.', ), 'stock_recovery.recent_restocks' => array( 'label' => __( 'Recent restocks', 'ai-eshop-optimizer' ), 'page' => 'stock_recovery', 'anchor' => 'physical-recovery-recent-restocks', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 1000, 'caps' => array( 'sort' => 'client', 'paginate' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, as its own registry row per B8 (closed: the two tables share the component now, not two hand contracts). The screen\'s pager+sorter stay (client-paged at 25 over 1000 rows) reading row_data, bound only to columns that HAVE a sort twin so no header is a dead control (B45). applied_at was stored GMT and printed raw — hours off the shop clock; `datetime` tz=>utc fixed it (B84 family).', ), 'cancellations.returns' => array( 'label' => __( 'Returns', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-returns', 'query' => array( 'cn_section' => 'returns' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'per_page' => 50, 'caps' => array( 'sort' => 'none', 'paginate' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. The screen\'s filter form + numbered pager stay (server-side, carries every filter incl. rl_kind per B21\'s fix). The affected-items cell is the `list` type; the kind pill is a badge with tone_key.', ), 'cancellations.products' => array( 'label' => __( 'Products with cancellations', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-products-with-cancellations-s', 'query' => array( 'cn_section' => 'products' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'per_page' => 50, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Sorting is the form\'s rl_sort select (server), the rate ladder is `bands`, Drill is a declared action.', ), 'cancellations.customers' => array( 'label' => __( 'Customers with returns', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-customers-with-returns-s', 'query' => array( 'cn_section' => 'customers' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'per_page' => 50, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. The risk badge labels resolve through __() now — they were raw English beside Greek headers (B82\'s screen half).', ), 'cancellations.vocabulary' => array( 'label' => __( 'Cancellation reasons', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-cancellation-reasons', 'query' => array( 'cn_section' => 'vocabulary' ), 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'rows', 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, on the `control` pattern — the row IS the reason record, every cell keeps its own input/class/handler; tr data-slug rides on row_data; the disabled-row dimming is the muted row tone.', ), 'cancellations.events' => array( 'label' => __( 'Cancellation & refund history', 'ai-eshop-optimizer' ), 'page' => 'cancellations', 'anchor' => 'cancellations-events-most-recent-first', 'query' => array( 'cn_section' => 'products' ), 'adopted' => true, 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none' ), 'rows_est' => 500, 'note' => 'MIGRATED to T0, session 21 — the per-product drill-down (rl_pid). LIMIT 500 disclosed. B21 (the pager dropping rl_kind) belonged to the RETURNS ledger and its fix is in the code with its own warning comment; this row carried the id as the section\'s defect holder and both halves are closed.', ), 'theme_compat.surfaces' => array( 'label' => __( 'What Rooster One takes over', 'ai-eshop-optimizer' ), 'page' => 'theme_compat', 'anchor' => 'theme-compat-surfaces', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, and the FIRST USER of the `control` cell type. The old ruling - that converting it needed a definition, because the component\'s `editable` posts to the T1 save-cell path and half-converting puts two write paths on one screen - was answering the wrong question: this screen does not want the component\'s write path, it wants its table. `control` hands the cell back, so the select is byte for byte the one that shipped (same class, same data-surface, same feedback span in the same cell, which is what $sel.closest(\'td\') needs) and only the furniture changed hands. sort=none: the value cell is a control, so there is nothing for a DOM sorter to read.', ), 'theme_compat.attribute_types' => array( 'label' => __( 'Attribute types', 'ai-eshop-optimizer' ), 'page' => 'theme_compat', 'anchor' => 'theme-compat-attribute-types', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'client', 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 17, and B24 CLOSED: its own 60-line sorter read td.text(), so the em dash in an empty cell and the ::after arrow on a differing one were part of the sort key. The diff filter keys on the row class the component renders instead of a parallel data-diff attribute, and detection now re-renders through the SERVER rather than rebuilding the four cells a second way in JS.', ), 'workflows.list' => array( 'label' => __( 'Automations', 'ai-eshop-optimizer' ), 'page' => 'workflows', 'anchor' => 'workflows-campaigns-workflows', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, on the tbody_id shape — and B23 CLOSED with it: the headers come from PHP columns through __() and every list string rides in WF_I18N. The stats strip moved to its own container so loadList() cannot destroy the component shell. Also fixed: a failed list request used to render the "no workflows yet" empty state — it is an error message now. (The registry claimed sort:client; no sorter ever existed in the code — corrected to none.)', ), 'offer_effectiveness.breakdown' => array( 'label' => __( 'Offer effectiveness', 'ai-eshop-optimizer' ), 'page' => 'offer_effectiveness', 'anchor' => 'offer-effectiveness-monthly-breakdown', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'none', 'filters' => true, 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21, on the tbody_id shape with ONE extra axis local_pickup does not have: the COLUMN SET is dynamic (nine group_by modes), so the JS retitles the thead from the payload while the component owns shell/classes/wrapper/empty state. The net tone replaced the inline hex; loading/error moved to a status line so a refetch never destroys the table.', ), 'utm_explorer.drilldown' => array( 'label' => __( 'Drill-down by campaign tag', 'ai-eshop-optimizer' ), 'page' => 'utm_explorer', 'anchor' => 'utm-explorer-overview', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none', 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 (renders on the roi_wizard page), tbody_id shape. The hardcoded-English LABELS const became a translated static method feeding the PHP pickers, the AJAX drill_label AND the JS from one source (B81/B23 family); the first header retitles per drill; the totals row is a JS-maintained tfoot wearing the component foot classes; every other JS string rides in UTM_I18N.', ), 'events.participants' => array( 'label' => __( 'Your events', 'ai-eshop-optimizer' ), 'page' => 'events', 'anchor' => 'events-your-events', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'client', 'export' => true ), 'note' => 'Its export sends participant names, emails and phones behind nothing more than edit_posts. Measured on ffnew: 35 non-admin accounts hold that capability, 21 of them shop managers. Whether it jumps the conversion queue is an open decision.', ), 'landing_income.by_page' => array( 'label' => __( 'Sessions and revenue per page', 'ai-eshop-optimizer' ), 'page' => 'landing_income', 'anchor' => 'landing-income-every-entry-page-by-what-it-earned', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 60, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 25, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `landing_income_pages`. It sorted in SQL through psort/pdir over an eight-key whitelist, so a T0 client sorter would have reordered the 60 rows the server had already chosen by revenue (B44). B26 closed: LIMIT 60 and no pagination, silent. ⚠️ Revenue-per-session takes AVG in the footer, never SUM — summing a rate across pages produces a figure that is not a rate of anything, which is B71 in miniature. See the landing_income_zeroes row for the scope-parameter note that applies to both.', ), 'fulfillment.delivery_metrics' => array( 'label' => __( 'Activity counters', 'ai-eshop-optimizer' ), 'page' => 'fulfillment', 'anchor' => 'fulfillment-activity-counters', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18. ⚠️ THIS ROW AND `delivery_stats.fulfillment_delivery_time_metrics_by_postal_area` ARE THE SAME TABLE: it is rendered by AIEO_DMM_Fulfillment but the census sees it on the delivery_stats subtab, which delegates to render_runlog(). Both are marked adopted; ids are frozen, so neither can be deleted. See the delivery_stats row for what the conversion carried. The range cell type (a headline metric over its spread) was derived from this screen.', ), 'seo_search_queries.history' => array( 'label' => __( 'Top Google Search queries', 'ai-eshop-optimizer' ), 'page' => 'seo_search_queries', 'anchor' => 'search-queries-top-queries-last-90-days-google-search-conso', 'query' => array( 'gscq_tab' => 'queries' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 21612, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_gsc_terms` — ONE definition serving BOTH the queries tab and the pages tab, which are the same query with dim_type flipped and were the same fifty lines of markup twice. 🚨 The window is a SCOPE PARAMETER and had to be: it narrows 415,314 daily rows BEFORE they are grouped into 21,612 terms, and the query wrapper WHERE lands outside the derived table. That is the case `is_sql_builder()` was added for. Deleted: a $sort_th closure over five columns, aieo_sem_pager(), and a "Showing N of M" line. The FLOOR disclosure ("Google withholds rare terms") survives as the card footnote — §7e, a constraint about the DATA, not the cap.', ), 'seo_search_queries.by_type' => array( 'label' => __( 'Google traffic by content type', 'ai-eshop-optimizer' ), 'page' => 'seo_search_queries', 'anchor' => 'search-queries-impressions-by-content-type', 'query' => array( 'gscq_tab' => 'content' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 6, 'caps' => array( 'sort' => 'server', 'paginate' => false, 'search' => false, 'filters' => false, 'export' => true ), 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_gsc_by_type`. ⚠️ The Share column was computed in PHP by summing the RENDERED rows, which was right only because nothing was capped — the moment anything caps, a share computed from the page reads as 100% of a slice. It is a SQL fraction of the whole windowed set now.', ), 'seo_search_queries.archive' => array( 'label' => __( 'Full archive — day by day', 'ai-eshop-optimizer' ), 'page' => 'seo_search_queries', 'anchor' => 'search-queries-full-archive-day-by-day', 'query' => array( 'gscq_tab' => 'archive' ), 'kind' => 'bespoke', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 415314, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true ), 'note' => '⛔ DELIBERATELY NOT CONVERTED, session 19, and the reason is worth more than the conversion would have been. 415,314 raw day-rows with NO unique column: the identity is (date, dim_hash). A composite row_key declines to act as a tie-break — correctly, because claiming uniqueness it cannot prove is worse than admitting it has none — so the choice is a CONCAT alias as the row key, which puts a filesort over 415k rows on every page click, or no tie-break at all, which is the non-deterministic LIMIT/OFFSET the contract exists to prevent. Neither improves on what is there. ▶ Revisit if the contract grows a composite tie-break. It already sorts and pages honestly on the server, so it is not lying to anyone meanwhile.', ), 'seo_search_queries.dfs' => array( 'label' => __( 'Striking-distance keywords', 'ai-eshop-optimizer' ), 'page' => 'seo_search_queries', 'anchor' => 'search-queries-dataforseo-striking-distance-keywords-positi-3', 'query' => array( 'gscq_tab' => 'dfs' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 40, 'caps' => array( 'sort' => 'client' ), 'note' => '▶ ORDINARY T0 WORK, found in session 19 and NOT on the 39 because the census has never rendered this tab. Three read-only columns over a PHP array from the DataForSEO cache, array_slice( $dfs, 0, 40 ) with nothing saying so — B26. ⚠️ AND THE TAB IS HALF-DECLARED: render_subtab() adds `dfs` to the strip at runtime when a DFS estate exists, but get_tabs() does NOT list it — so it is a real, clickable tab that is invisible to the capability registry, to the Home search index and to Ctrl+K, all three of which read get_tabs(). section-anchors.php has carried a note about its unreachability since before this migration; the note was right about the DECLARATION and wrong about the strip. Declare it in get_tabs() before converting it, or the conversion adds a table to a place the product still says does not exist.', ), 'role_pricing.current_prices' => array( 'label' => __( 'Current role prices', 'ai-eshop-optimizer' ), 'page' => 'role_pricing', 'anchor' => 'role-pricing-current-role-prices', 'kind' => 'form', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'editable' => true, ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 18 — the FALLBACK grid, which is what renders when wpDataTables is not the viewer. `sortable => false` deliberately: it pages on the SERVER (paginate_links, 25 of 3,415), so a client sorter would reorder the loaded page while looking as though it sorted the set - B44, same call as waitlist. B74: every price printed through number_format(x, 2), the C locale, so 1234.56 on a shop that writes 1.234,56 € everywhere else and no currency at all; `money` is wc_price(). Owns ajax_fetch_rows, one of the two endpoints that independently agreed on a wire shape. Grows one physical column per enabled role, so its column set is computed from data. Its export omits the BOM because emitting one broke its own round-trip import.', ), 'attribution.rows' => array( 'label' => __( 'Recommendation adds & purchases', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-products-added-bought-after-seeing-them-as-r', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'export' => true ), 'note' => '🚨 TWO THINGS WERE WRONG WITH THIS ROW AND 16h FIXED BOTH. Its LABEL and ANCHOR pointed at "Top products via recommendations", a different card on the same page that is now T0 and has its own row; this row has always described ajax_rows, the paginated detail table, so it now carries that card anchor and label. And it is CLIENT-BUILT: render_detail_card() emits an empty
and aieo-attribution-admin.js builds the whole table, toolbar, badge filter, per-page and pager in JS. The harvester therefore never saw it (B73 family) and the T0 recipe does not apply. ▶ It is the best T1 candidate in the product: it ALREADY does server paging, server sort, search and a badge filter against an indexed query, which is the definition contract almost exactly. Its xlsx writer guesses cell types with is_numeric() (B16).', ), 'attribution.traffic_source_channel_breakdown' => array( 'label' => __( 'Cart adds by channel', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-traffic-source-channel-breakdown', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 13, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. TWO tables in this card: the channel breakdown and the top-campaigns drilldown under it, both converted. The channel cell carried an inline 8px dot coloured from a THIRTEEN-entry PHP colour map; it is a `badge` now, and the component emits aieo-t-badge-v-- so those colours have a home in CSS keyed by value instead of a map inside a render loop. The channel name was always in the cell beside the dot, so the dot said nothing the label did not. B26: the campaigns drilldown is LIMIT 15 and never said so.', ), 'attribution.goal_conversions_by_traffic_source' => array( 'label' => __( 'Goal conversions by source', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-goal-conversions-by-traffic-source', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 25, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. Reads the canonical landing/campaign rollup. The early return that printed a bare

for the empty case is gone: the component renders the empty state inside the table, so the card keeps its shape instead of collapsing to a sentence. The hand-written .aieo-dmm-scroll-x went with it (K3).', ), 'attribution.conversion_by_surface' => array( 'label' => __( 'Conversion by surface', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-conversion-by-surface', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 7, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The `⇅` sort affordance was appended to the HEADER TEXT, which is why the census recorded this table columns as "Επιφάνεια ⇅" - a control that had become part of the label. The component owns the indicator. B22: the empty row carried a hand-passed colspan="4".', ), 'attribution.slot_performance' => array( 'label' => __( 'Slot performance', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-slot-performance', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 102, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. The widest table on the page at 102 rows. The Slot column rendered "#3" with a centring inline style and sorted on a separate raw value; it declares sort_key => position so the sort key is the number itself. B22: colspan="7" was hand-passed.', ), 'attribution.top_products_discovered_via_recommendations' => array( 'label' => __( 'Top products via recommendations', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-top-products-discovered-via-recommendations', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. NEW ROW: this card had no registry entry of its own - attribution.rows carried its anchor and label while describing a different table entirely. The product cell was product_link(), which calls get_the_title() and builds an PER ROW; the `object` type does the same job and resolves every title in ONE batch before any cell runs, so twenty queries become one. B26: the top-20 cap was silent.', ), 'attribution.top_anchor_products' => array( 'label' => __( 'Top anchor products', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-top-anchor-products', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 20, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h, same shape and same batched title resolution as the top-products card above. B26.', ), 'attribution.per_branch_attribution_breakdown' => array( 'label' => __( 'Per-branch attribution breakdown', 'ai-eshop-optimizer' ), 'page' => 'attribution', 'anchor' => 'attribution-per-branch-attribution-breakdown', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'rows', 'rows_est' => 0, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 16h. ⚠️ NOT IN THE CENSUS AND NOT VERIFIED ON SCREEN: it renders only where branches are configured AND per-branch attribution rows exist, and ffnew has neither, so the harvester never saw it and this conversion is the one on the page that nobody has looked at. B74 CLOSED 2026-08-27: three money columns were bare decimals under headers hardcoding "(€)". The cells became `money` at conversion; the labels are plain now too, with the Greek ported in the same commit. The unmapped-branch grey was an inline

onto spans inside control cells so its selectors still land; rows keep data-step.', ), 'fulfillment.orphan_invoices' => array( 'label' => __( 'Orphan invoice lines', 'ai-eshop-optimizer' ), 'page' => 'fulfillment', 'query' => array( 'ff_section' => 'settings' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — ONE row for the rollup (totals_at=>foot replaces the hand tfoot) and the top-50 detail inside its
. Renders only when orphan invoice lines exist, so ffnew\'s census never sees it; verified by direct render of the code path\'s shapes.', ), 'seo_linking.related' => array( 'label' => __( 'Related content', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-related', 'query' => array( 'sem_section' => 'related' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'per_page' => 50, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — BOTH variants. Flat: the per-source banding/knee/noise-floor analysis is untouched and the score cell that DRAWS it is a control closure; below-the-cliff dimming is the muted row tone; Pin/Ban/Clear keep their classes + tr data-* via row_data. Grouped: the lazy child list is the component detail_row carrying .aieo-semlink-children + hidden, so the toggle JS is unchanged; sort links ride in header_html.', ), 'seo_linking.clusters' => array( 'label' => __( 'Topic clusters', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-clusters', 'query' => array( 'sem_section' => 'clusters' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'sql', 'per_page' => 50, 'caps' => array( 'sort' => 'server', 'paginate' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. Server sort links in header_html; the open cluster\'s force-graph is the detail_row (class aieo-cluster-graph-row, which the fetch-toggle JS finds); broad-cluster flagging is tone_key + subline.', ), 'seo_linking.gaps' => array( 'label' => __( 'Content gaps', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-gaps', 'query' => array( 'sem_section' => 'gaps' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'query', 'per_page' => 50, 'caps' => array( 'sort' => 'none', 'paginate' => true, 'filters' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. The Google column joins the column set only when GSC is active, exactly as the hand markup branched; the four advisory verdicts are computed in PHP as before.', ), 'seo_linking.aud_terms' => array( 'label' => __( 'Audience categories', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'query' => array( 'sem_section' => 'settings' ), 'kind' => 'form', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'both', 'source' => 'rows', 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — the half-and-half shape: confirmed rows render server-side, the Scan button appends candidates client-side with the component cell contract; the empty-row removal handles both shapes.', ), 'seo_linking.aud_grid' => array( 'label' => __( 'Audience matrix', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'query' => array( 'sem_section' => 'settings' ), 'kind' => 'form', 'phone' => 'grid', 'tier' => 'pro', 'scope' => 'both', 'source' => 'rows', 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21. One control-checkbox column per vocabulary entry; rows keep data-anchor for the collector.', ), 'seo_linking.diagnostics' => array( 'label' => __( 'Diagnostics', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-analyzer', 'query' => array( 'sem_section' => 'diagnostics' ), 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'rows', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 21 — ONE row for the section\'s NINE tables: the analyzer (tbody_id shape, hidden moved to a wrapper div), four label/value panels through one shared head=>false renderer (index health, artifacts, reco signal, search capture), the MWE queue (LIMIT 30 disclosed, decide buttons kept), and the three A/B readouts through one shared column set where `span` covers the no-data rows. 🚨 ALSO FIXED HERE: the translation-pairing scan referenced ajaxUrl/nonce that were never declared in its scope — a ReferenceError on the first click; the feature was dead as written. Item 5 wave (2026-08-26): the pairing RESULT table (D10) joined the contract on the tbody_id shape — hidden shell until a dry-run scan, coverage notes render after it in their own div so the fill never destroys the shell.', ), 'withdrawal.queue' => array( 'label' => __( 'Declarations queue', 'ai-eshop-optimizer' ), 'page' => 'withdrawal', 'anchor' => 'withdrawal-declarations-queue', 'query' => array( 'wd_section' => 'queue' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20. Its row said NEVER migrate and gave four reasons; each is now something the component does. 🚨 THE COLSPAN IS THE HEADLINE: the panel row took a hand-passed 8, with a comment calling it "the ONLY thing tying the expanded return panel to the header\'s width", and it had already been wrong once when a column was removed on 2026-07-31. `detail_row` counts the columns. Its ARRAY form was added for this screen, so the panel keeps its own
caveats that had nowhere to go until `header_tip`. Timing cells are `duration` in hours mode (the stored HOURS are multiplied to seconds on the way in) with the min–max span as a `subline`. Totals were already a real
to the wrapper, because the component owns the table element.', ), 'roi_wizard.results_grid' => array( 'label' => __( 'ROI results grid', 'ai-eshop-optimizer' ), 'page' => 'roi_wizard', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 0, 'caps' => array(), 'adopted' => true, 'note' => 'MIGRATED, item 5 wave (2026-08-26) — B81\'s registered case, closed by a NEW shape: a run renders ONE TABLE PER BLOCK and the block count is the reader\'s type selection, so a single tbody_id could not host them. The component renders two hidden TEMPLATES (the dimension grid and the reco-lift grid) and the JS CLONES one per block, retitles the first header from the block type, sets the dynamic th titles (break-even, ops overhead) and fills the clone\'s tbody; the JS-built tfoot (Total + reconciliation) is appended to the clone. "Channel (origin) / Orders / Revenue / ROAS (gross)" pass through __() at last. The two LEGACY string-built result branches below the unified render were unreachable since it shipped and are DELETED. The ad-spend ledger is an ordinary tbody_id conversion (roi_wizard.ad_spend_ledger, harvested); its date-range-filtered empty state keeps its own "clear From/To" wording beside the component\'s. The long analytic footnotes stay English prose as before — they are not table furniture; if they are ever to translate, that is a B60-scope decision.', ), 'contact_discovery.where_they_came_from' => array( 'label' => __( 'Where they came from', 'ai-eshop-optimizer' ), 'page' => 'contact_discovery', 'anchor' => 'contact-discovery-where-they-came-from', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 7, 'caps' => array(), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. ⚠️ ITS OLD NOTE NAMED THE WRONG DEPENDENCY: it said this needed bulk-select first. It does not - bulk actions are a mechanism for acting ON rows, and these checkboxes are a persistent multi-select that is an INPUT to a scan the screen runs itself. .aieo-cd-pick keeps its class and value so querySelectorAll(\x27.aieo-cd-pick:checked\x27) collects what it always did. 🚨 data-cd-people / data-cd-new MOVED from the because dmm.js reads closest(\'tr\').data(\'id\') for Run now and Delete. The screen used to hide the whole table and print a sentence when empty; it always renders now and uses the component empty state, so the census stops seeing this table appear and disappear.', ), 'signals.what_this_site_measures' => array( 'label' => __( 'Metrics', 'ai-eshop-optimizer' ), 'page' => 'signals', 'anchor' => 'signals-what-this-site-measures', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 119, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. It was ⛔ as "a DIAGNOSTIC PANEL, not a grid", costed at roughly four `html` hatches × 119 rows. The arithmetic was right and the conclusion was not: those cells are not an unfinished migration, they are cells this screen COMPOSES, and `control` says so without inflating a debt counter with things that are not debt. 🚨 sortable=false, deliberately: rows are ordered alarming-first inside each grain and a client sorter would destroy the ranking that is the point of the screen. The alarm highlight was an inline background plus an inset left rule - that IS `aieo-t-row--warning` to the pixel, because the component row tone was built from this screen in the first place.', ), 'swatches.cache_monitoring' => array( 'label' => __( 'Cache monitoring', 'ai-eshop-optimizer' ), 'page' => 'swatches', 'anchor' => 'swatches-cache-monitoring', 'kind' => 'grid', 'phone' => 'grid', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 8, 'caps' => array( 'sort' => 'none' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, as a PANEL: `head => false` + `row_header` + `control`. Every `data-stat` span is the markup that shipped, so refreshStats() keeps finding its cells; the runtime-renderer checkbox keeps its id and its own handler. sort=none: no header, nothing to click.', ), 'navigator.appearance' => array( 'label' => __( 'Navigator colours and sizes', 'ai-eshop-optimizer' ), 'page' => 'navigator', 'anchor' => 'navigator-appearance', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 12, 'caps' => array( 'sort' => 'none', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, and the first user of `bare`. Its old row said the `widefat` should arguably be stripped but that doing so was a visual change to a shipped settings screen - `bare` is how to have both: it joins the contract AND stops reading as data, and the `border:0;background:transparent` plus per-cell padding that were hand-written on all TWENTY-FOUR cells become one CSS rule. Every input keeps its `name`, so the surrounding settings form posts exactly what it posted before.', ), 'invoice_scanner.documents' => array( 'label' => __( 'Scanned documents', 'ai-eshop-optimizer' ), 'page' => 'invoice_scanner', 'anchor' => 'invoice-scanner-documents', 'kind' => 'grid', 'phone' => 'list', 'tier' => 'pro', 'scope' => 'local', 'source' => 'query', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20. It was ⛔ only for WHERE it lives - the Pro plugin has its own build and release train and free+Pro ship as a PAIR - never for anything about the table. 🚨 THREE SELECTORS ARE LOAD-BEARING and all three survive: #aieo-docscan-documents (both row handlers are delegated off it), (Re-scan and Remove read it via closest), and .aieo-docscan-erp-cell (the Send/Retry handler reads its data-document-id) - the last moved from the
to a inside it, because a component cell owns its own attributes; querySelector + textContent behave the same, but do not tidy the span away.', ), 'fulfillment.add_a_manual_carrier' => array( 'label' => __( 'Add a manual carrier', 'ai-eshop-optimizer' ), 'page' => 'fulfillment', 'anchor' => 'fulfillment-add-a-manual-carrier', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 2, 'caps' => array(), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control` - and it RETIRED THE PRIVATE SORTER its old note predicted would go first: ~50 lines with its own asc/desc/unsorted cycle, its own ⇅/▲/▼ icons and its own localeCompare over pre-baked data-sort-* attributes. `sort_key` carries the lowercase pre-bake. ⚠️ ONE COLUMN COULD NOT COME ACROSS: the old sorter read the linked-method dropdown\'s selected option TEXT at click time, because that value changes after an AJAX save with no reload, and no DOM sorter can see that - so that column is declared unsortable rather than sorting on a value that goes stale the moment somebody uses it. The inline editor is untouched: the cells keep .aieo-cc-cell-label / .aieo-cc-cell-tpl and their data-original on a inside the cell.', ), 'user_vitals.professional_signup_gravity_forms' => array( 'label' => __( 'Professional signup and roles', 'ai-eshop-optimizer' ), 'page' => 'user_vitals', 'anchor' => 'user-vitals-professional-signup-gravity-forms', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 8, 'caps' => array(), 'caps' => array( 'sort' => 'client', 'editable' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. The number input keeps .aieo-gf-map and its data-slug, which is all document.querySelectorAll(\'.aieo-gf-map\') needs, so the card\'s single Save button still collects the whole gf_role_map[] array.', ), 'product_orders_analytics.analytics_jobs' => array( 'label' => __( 'Analytics jobs', 'ai-eshop-optimizer' ), 'page' => 'data_prep_scheduler', 'anchor' => 'product-orders-analytics-analytics-jobs', 'kind' => 'form', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 15, 'caps' => array(), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, via `control`. The row IS the schedule record - frequency select, day-of-week select, day-of-month select,
to a inside it, because a component cell owns its own class list. The Accounting column is CONDITIONAL and is APPENDED rather than declared-and-hidden, so a column that is not there cannot go out of step with a header that is. ⚠️ SHIPS WITH THE PRO HALF OF THE PAIR.', ), 'invoice_scanner.performance' => array( 'label' => __( 'Reading performance by supplier', 'ai-eshop-optimizer' ), 'page' => 'invoice_scanner', 'anchor' => 'invoice-scanner-performance', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'pro', 'scope' => 'local', 'source' => 'query', 'rows_est' => 50, 'caps' => array( 'sort' => 'client' ), 'adopted' => true, 'note' => 'MIGRATED to T0, session 20, with invoice_scanner.documents - same file, same ruling, same release train. Eight read-only columns. The two share-percentage cells were an inline class picked by a nested ternary and are `percent` with threshold `bands` now. ⚠️ The two bands are DIFFERENT on purpose: on the expensive-model share HIGH is bad, on the feed-vocabulary share ANY is good.', ), 'seo_linking.suggestions' => array( 'label' => __( 'Link suggestions', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-suggestions', 'kind' => 'grid', 'phone' => 'wide', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'sql', 'rows_est' => 15602, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true, 'bulk' => true, 'editable' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `seo_linking_suggestions` — the FLAT view only. 🚨 THE CENSUS HAS ALWAYS SEEN THIS TABLE AND IT HAD NO REGISTRY ROW: session 16h read the `seo_linking` census entry as one of the three JS-string phantoms (B73) and deleted it, but that screen has BOTH — a grid built in a JS string AND this real one. The builder reuses AIEO_DMM_SEO_Linking::links_filter() rather than restating it, because that method is the ONE filter over wp_aieo_sem_links and every read, total and group-level WRITE goes through it — "approve these 1,306" must never act on a different set from the one the row counted. Five row buttons behind three PHP conditionals became five declared actions with a `when` each. The presentation select autosaves through set_present(), extracted this session so the T1 route and the old endpoint cannot fork on the cache bust. Item 5 (2026-08-26): the `bulk` flag is finally READ — the definition declares Approve/Reject/Restore as bulk_actions (scope filter), the component renders the toolbar + select-all + all-matching toggle, and the write goes through the generic aieo_dmm_report_bulk endpoint into report_bulk() → decide_rows(). The two hand-written "selected" buttons and their 200-id wire are gone.', ), 'seo_linking.groups' => array( 'label' => __( 'Link suggestions, grouped', 'ai-eshop-optimizer' ), 'page' => 'seo_linking', 'anchor' => 'semlink-suggestions', 'query' => array( 'sem_group' => 'anchor' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'local', 'source' => 'query', 'rows_est' => 731, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'bulk' => true ), 'adopted' => true, 'note' => 'MIGRATED to T0 WITH bulk actions, item 5 (2026-08-26) — the row that waited for the mechanism, converted the day the mechanism shipped. It was registered ⛔ with this reasoning: a group row\'s decide button acts on a SET THE ROW COUNTED (the anchor "femme fatale" is ~1,300 suggestions across 615 products and exactly ONE editorial judgement), and converting before bulk actions existed would give a button that says 1,306 and a mechanism that can only name the rows on screen. Now the button posts the generic aieo_dmm_report_bulk with scope=filter and the group AS the filter (params {anchor} or {src_type,src_id} + the view\'s status), resolved through AIEO_Report_Query::row_keys() over report_sql() → links_filter() — the same builder that counted the rows — in committed keyset chunks. The bespoke ajax_group_decide endpoint is deleted; its hand-maintained never-changing-row exclusion is structural now (the cursor only moves forward). Cells stay this screen\'s through `control` renderers (entity-archive prose, per-action exact counts); the expander keeps its identity through `detail_row` (class aieo-semlink-children, hidden), so the group_rows loader and every delegated handler bind unchanged.', ), 'google_reviews.history' => array( 'label' => __( 'Review history', 'ai-eshop-optimizer' ), 'page' => 'google_reviews', 'anchor' => 'google-reviews-history', 'query' => array( 'grv_section' => 'history' ), 'kind' => 'grid', 'phone' => 'list', 'tier' => 'inherit', 'scope' => 'both', 'source' => 'sql', 'rows_est' => 920, 'caps' => array( 'sort' => 'server', 'paginate' => true, 'search' => true, 'filters' => true, 'export' => true ), 'per_page' => 50, 'adopted' => true, 'note' => 'MIGRATED to T1, session 19, as `google_reviews_history`. 🚨 NEVER CENSUSED: it sits behind grv_section=history, so the harvester has never rendered it and `google_reviews.overview` — which session 17 described as this table — is actually the LOCATIONS list on the default tab. B83: the old pager printed twenty numbered links and stopped, so at 50/page anything past 1,000 rows was unreachable, with no next and no last. B84: "Seen at" printed the stored GMT string with " UTC" appended instead of converting to the shop clock. 240 lines deleted: a seven-control filter form, a $sort_link closure over five columns, a hand-written COUNT + LIMIT/OFFSET pair and nine concatenated cells.', ), 'supplier_orders.items' => array( 'label' => __( 'Items to order, by supplier', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'supplier_orders', 'anchor' => 'supplier-orders-items', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 6 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'marketplace_feeds.current_feeds' => array( 'label' => __( 'Current feeds', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'marketplace_feeds', 'harvested' => true, 'adopted' => true, 'rows_est' => 15, 'note' => 'Harvested by rendering. 7 columns, class "widefat striped aieo-t aieo-t--sortable". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'fulfillment.backfill_estimated_costs' => array( 'label' => __( 'Backfill estimated costs', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'fulfillment', 'anchor' => 'fulfillment-backfill-estimated-costs', 'query' => array( 'ff_section' => 'settings' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'fulfillment.backfill_detailed_stats' => array( 'label' => __( 'Backfill checkpoint history', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'fulfillment', 'anchor' => 'fulfillment-backfill-detailed-stats', 'query' => array( 'ff_section' => 'settings' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 16, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'form_insights.table_2' => array( 'label' => 'Ώρες Επικοινωνίας Dropdown 31 respondents', 'phone' => 'grid', 'page' => 'form_insights', 'harvested' => true, 'adopted' => true, 'rows_est' => 4, 'note' => 'Harvested by rendering. 3 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'tools.sku_parent_product_id_lookup' => array( 'label' => 'SKU → parent product', 'phone' => 'grid', 'page' => 'tools', 'anchor' => 'tools-sku-parent-product-id-lookup', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'tools.barcode_parent_product_id_lookup' => array( 'label' => 'Barcode → parent product', 'phone' => 'grid', 'page' => 'tools', 'anchor' => 'tools-barcode-parent-product-id-lookup', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'remote_export.per_table_status_2' => array( 'label' => __( 'Per-table status', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'remote_export', 'anchor' => 'remote-export-per-table-status', 'harvested' => true, 'adopted' => true, 'rows_est' => 9, 'note' => 'Harvested by rendering. 7 columns, class "widefat striped aieo-t aieo-t--sortable". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'shipment_cost_reports.shipping_p_l_by_carrier_zo' => array( 'label' => __( 'Shipping P&L by carrier & zone', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'shipment_cost_reports', 'anchor' => 'shipment-cost-reports-shipping-p-l-by-carrier-zone-monthly', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--sortable". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'customer_profile.describe_a_customer' => array( 'label' => __( 'Customer Lookup', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'customer_profile', 'anchor' => 'customer-profile-describe-a-customer', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--panel aieo-t--bare aieo-t--narrow aieo-cp-table". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'contact_discovery.turn_contacts_into_accounts' => array( 'label' => __( 'Turn contacts into accounts', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'contact_discovery', 'anchor' => 'contact-discovery-turn-contacts-into-accounts', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 3 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'competition_insights.insights_refresh_auto_refresh_offhourlydailyweek' => array( 'label' => 'Insights Refresh Auto-refresh OffHourlyDailyWeeklyMonthly updated 13 hours ago', 'phone' => 'list', 'page' => 'competition_insights', 'harvested' => true, 'adopted' => true, 'rows_est' => 10, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--sortable". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'shipping_rates.table_rate_shipping' => array( 'label' => __( 'Shipping rate rules', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'shipping_rates', 'anchor' => 'shipping-rates-carriers', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'badges.overview' => array( 'label' => __( 'NEW badge validity in days', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'badges', 'anchor' => 'badges-overview', 'harvested' => true, 'adopted' => true, 'rows_est' => 4, 'note' => 'Harvested by rendering. 3 columns, class "widefat striped aieo-t aieo-t--bare aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'web-push.web_push_templates_phase_6_per_trigger_per_segme_2' => array( 'label' => __( 'Web push notification templates', 'ai-eshop-optimizer' ), 'page' => 'web-push', 'anchor' => 'web-push-templates-phase-6-per-trigger-per-segment-we', 'harvested' => true, 'adopted' => true, 'rows_est' => 9, 'note' => 'Harvested by rendering. 7 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--sortable aieo-tpl-table". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'surveys.heuristic_diagnostics_ci_signal_distribu' => array( 'label' => __( 'Heuristic signal distributions', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'surveys', 'anchor' => 'surveys-heuristic-diagnostics-ci-signal-distribution', 'harvested' => true, 'adopted' => true, 'rows_est' => 6, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--panel aieo-t--bare aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'surveys.position_signal_diagnostics_first_last_s' => array( 'label' => __( 'Position-signal diagnostics', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'surveys', 'anchor' => 'surveys-position-signal-diagnostics-first-last-sole', 'harvested' => true, 'adopted' => true, 'rows_est' => 10, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--panel aieo-t--bare aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'surveys.newsletter_engagement_tnp_match_reachabi' => array( 'label' => __( 'Newsletter engagement', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'surveys', 'anchor' => 'surveys-newsletter-engagement-tnp-match-reachability', 'harvested' => true, 'adopted' => true, 'rows_est' => 5, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--panel aieo-t--bare aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'surveys.seven_high_signal_metrics' => array( 'label' => __( 'Seven high-signal metrics', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'surveys', 'anchor' => 'surveys-seven-high-signal-metrics', 'harvested' => true, 'adopted' => true, 'rows_est' => 6, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--compact aieo-t--panel aieo-t--bare aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_import.import_pre_cutover_verify_head_parity' => array( 'label' => __( 'Head parity before cutover', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_import', 'anchor' => 'import-pre-cutover-verify-head-parity', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 5 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_search_queries.search_queries_top_pages_last_90_days_google_sea' => array( 'label' => __( 'Top pages in Google Search', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'seo_search_queries', 'anchor' => 'search-queries-top-pages-last-90-days-google-search-console', 'query' => array( 'gscq_tab' => 'pages' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 7 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_index_health' => array( 'label' => __( 'Index health', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-index-health', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 6, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--panel". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_artifacts' => array( 'label' => __( 'Server artifacts', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-artifacts', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 4, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--panel". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_mwe' => array( 'label' => 'Vocabulary discovery: multi-word terms', 'phone' => 'wide', 'page' => 'seo_linking', 'anchor' => 'semlink-mwe', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 30, 'note' => 'Harvested by rendering. 7 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_reco' => array( 'label' => __( 'Recommendations signal', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-reco', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 4, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--panel". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_experiment' => array( 'label' => __( 'A/B: is the semantic recommendation boost working?', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-experiment', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 4, 'note' => 'Harvested by rendering. 5 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_search_signal' => array( 'label' => __( 'On-site search capture', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-search-signal', 'query' => array( 'sem_section' => 'diagnostics' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 7, 'note' => 'Harvested by rendering. 0 columns, class "widefat striped aieo-t aieo-t--panel". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'seo_linking.semlink_settings' => array( 'label' => __( 'Semantic engine settings', 'ai-eshop-optimizer' ), 'phone' => 'grid', 'page' => 'seo_linking', 'anchor' => 'semlink-settings', 'query' => array( 'sem_section' => 'settings' ), 'harvested' => true, 'adopted' => true, 'rows_est' => 5, 'note' => 'Harvested by rendering. 4 columns, class "widefat striped aieo-t aieo-t--narrow". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'roi_wizard.overview_2' => array( 'label' => __( 'Revenue against ad spend', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'roi_wizard', 'anchor' => 'roi-wizard-overview-2', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 8 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'roi_wizard.ad_spend_ledger' => array( 'label' => __( 'Ad-spend ledger', 'ai-eshop-optimizer' ), 'phone' => 'list', 'page' => 'roi_wizard', 'anchor' => 'roi-wizard-ad-spend-ledger', 'harvested' => true, 'adopted' => true, 'rows_est' => 1, 'note' => 'Harvested by rendering. 6 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_basket_position.insights_basket_position' => array( 'label' => __( 'Basket position', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_basket_position', 'anchor' => 'insights-basket-position', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_cart_products.insights_cart_products' => array( 'label' => __( 'All cart products', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_cart_products', 'anchor' => 'insights-cart-products', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 20 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_customer_order_averages.insights_customer_order_averages' => array( 'label' => __( 'Customer order averages', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_customer_order_averages', 'anchor' => 'insights-customer-order-averages', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 15 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_erp_raw_orders.insights_erp_raw_orders' => array( 'label' => __( 'ERP raw order data', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_erp_raw_orders', 'anchor' => 'insights-erp-raw-orders', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_invoice_match.insights_invoice_match' => array( 'label' => __( 'Woo against ERP', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_invoice_match', 'anchor' => 'insights-invoice-match', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 16 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_order_patterns.insights_order_patterns' => array( 'label' => __( 'Order Patterns', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_order_patterns', 'anchor' => 'insights-order-patterns', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_out_of_stock.insights_out_of_stock' => array( 'label' => __( 'Out of stock products', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_out_of_stock', 'anchor' => 'insights-out-of-stock', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 26 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_product_needs.insights_product_needs' => array( 'label' => __( 'Needs coverage', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_product_needs', 'anchor' => 'insights-product-needs', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 10 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_reorder_summary.insights_reorder_summary' => array( 'label' => __( 'Reorder summary', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_reorder_summary', 'anchor' => 'insights-reorder-summary', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_stock_check.insights_stock_check' => array( 'label' => __( 'Stock check', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_stock_check', 'anchor' => 'insights-stock-check', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_stockout_rate.insights_stockout_rate' => array( 'label' => __( 'Stockout rate by period', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_stockout_rate', 'anchor' => 'insights-stockout-rate', 'harvested' => true, 'adopted' => true, 'rows_est' => 5, 'note' => 'Harvested by rendering. 6 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_survey_results.insights_survey_results' => array( 'label' => __( 'Post-order survey results', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_survey_results', 'anchor' => 'insights-survey-results', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 10 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_unit_sales.insights_unit_sales' => array( 'label' => __( 'Unit sales, online and branches', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_unit_sales', 'anchor' => 'insights-unit-sales', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 13 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_variations_stock.insights_variations_stock' => array( 'label' => __( 'Out of stock variations', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_variations_stock', 'anchor' => 'insights-variations-stock', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t3.insights_t3' => array( 'label' => __( 'Order Patterns', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t3', 'anchor' => 'insights-t3', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t18.insights_t18' => array( 'label' => __( 'Cost of Stock-Outs', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t18', 'anchor' => 'insights-t18', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 26 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t20.insights_t20' => array( 'label' => __( 'Out of Stock Products', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t20', 'anchor' => 'insights-t20', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 26 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t27.insights_t27' => array( 'label' => __( 'Out of Stock Rate by Period', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t27', 'anchor' => 'insights-t27', 'harvested' => true, 'adopted' => true, 'rows_est' => 5, 'note' => 'Harvested by rendering. 6 columns, class "widefat striped aieo-t". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t26.insights_t26' => array( 'label' => __( 'Out of Stock Variations Ratio', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t26', 'anchor' => 'insights-t26', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t14.insights_t14' => array( 'label' => __( 'Stock Fast Finder', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t14', 'anchor' => 'insights-t14', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t15.insights_t15' => array( 'label' => 'Stock Not Set — Fast Finder', 'phone' => 'wide', 'page' => 'insights_t15', 'anchor' => 'insights-t15', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t8.insights_t8' => array( 'label' => __( 'Bought on Its Own', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t8', 'anchor' => 'insights-t8', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t6.insights_t6' => array( 'label' => __( 'First Item in the Basket', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t6', 'anchor' => 'insights-t6', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t7.insights_t7' => array( 'label' => __( 'Last Item Before Checkout', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t7', 'anchor' => 'insights-t7', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t32.insights_t32' => array( 'label' => 'Unit Sales — Online vs Branches', 'phone' => 'wide', 'page' => 'insights_t32', 'anchor' => 'insights-t32', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 13 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t33.insights_t33' => array( 'label' => __( 'ERP Raw Order Data', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t33', 'anchor' => 'insights-t33', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 9 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t5.insights_t5' => array( 'label' => __( 'All Products in Carts', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t5', 'anchor' => 'insights-t5', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 20 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t4.insights_t4' => array( 'label' => __( 'Customer Order Averages', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t4', 'anchor' => 'insights-t4', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 15 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t13.insights_t13' => array( 'label' => __( 'Post-Order Survey Results', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t13', 'anchor' => 'insights-t13', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 10 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t41.insights_t41' => array( 'label' => 'Woo vs ERP Turnover — Current Month', 'phone' => 'wide', 'page' => 'insights_t41', 'anchor' => 'insights-t41', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 16 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t40.insights_t40' => array( 'label' => 'Woo vs ERP Turnover — Previous Month', 'phone' => 'wide', 'page' => 'insights_t40', 'anchor' => 'insights-t40', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 16 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t42.insights_t42' => array( 'label' => 'Woo vs ERP Turnover — Historical', 'phone' => 'wide', 'page' => 'insights_t42', 'anchor' => 'insights-t42', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 16 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t35.insights_t35' => array( 'label' => __( 'Reorder Summary', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t35', 'anchor' => 'insights-t35', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 11 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), 'insights_t28.insights_t28' => array( 'label' => __( 'Needs coverage', 'ai-eshop-optimizer' ), 'phone' => 'wide', 'page' => 'insights_t28', 'anchor' => 'insights-t28', 'harvested' => true, 'adopted' => true, 'rows_est' => 50, 'note' => 'Harvested by rendering. 10 columns, class "widefat striped aieo-t aieo-t--server". kind, tier, scope and defects are NOT known and must be verified before this row is trusted for anything but a count.', ), );