Skip to content

Present Northwind as a business application, and fix its schema.org modelling - #59

Open
namedgraph wants to merge 8 commits into
masterfrom
ft-northwind-traders-power-app
Open

Present Northwind as a business application, and fix its schema.org modelling#59
namedgraph wants to merge 8 commits into
masterfrom
ft-northwind-traders-power-app

Conversation

@namedgraph

@namedgraph namedgraph commented Aug 29, 2026

Copy link
Copy Markdown
Member

Makes the Northwind demo read as a business application rather than an RDF data browser, and puts its data in schema.org terms that actually hold.

No CSV is modified anywhere on this branch. Everything the mappings emit is derived from stock Northwind columns — order status from shipped-vs-required date, line totals from quantity × price × discount, dispatch dates from shippedDate.

Presentation

  • The frontpage no longer advertises LinkedDataHub's feature list or the low-code build story
  • The dashboard leads with the monthly sales trend instead of a two-value table stretched over the 400px chart canvas
  • Record lists come before analytics in each container, so a section opens on its data
  • Suppliers, shippers, regions and territories get the narrative intros the other containers already had
  • dct:title on every ldh:Object block. Without it the block heading renders the fragment identifier, so pages were showing kpi-block and select-suppliers as headings
  • Northwind's own vocabulary in the ontology: Sales rep rather than Broker, Reports to rather than Sponsor, Territory, Region, Company name, Required by
  • .ldh-section for section headers; page-header and lead are Bootstrap 2 leftovers with no rules in the current design system

schema.org modelling

Several properties were carrying values outside their declared domains. Each moves to the term the vocabulary actually provides:

was why it was wrong now
OrderItem schema:price, schema:totalPrice schema:OrderItem has no price properties at all — only orderedItem, orderQuantity, orderItemNumber, orderItemStatus, orderDelivery the line is schema:OrderItem, schema:Offer; Offer.price is the price of the offer as a whole, i.e. the line total
unit price on the line nowhere legal to put it schema:priceSpecificationschema:UnitPriceSpecification with schema:priceType schema:ListPrice
OrderItem schema:discount schema:discount is an Order-level property, and means an amount rather than a rate dropped — list price, quantity and line total pin the rate exactly, and unlike a stored rate they cannot disagree
Product schema:price schema:Product has no price either schema:offersschema:Offer
ParcelDelivery schema:price (freight) schema:ParcelDelivery has no price schema:priceSpecificationschema:DeliveryChargeSpecification from a shipping schema:Offer
ParcelDelivery schema:availableFrom (shipped date) a DeliveryEvent property borrowed onto a delivery, and it means "available for pickup", not "dispatched" schema:deliveryStatusschema:DeliveryEvent with schema:startDate. Orders never dispatched get no event at all
schema:orderedItem domain/range narrowed to OrderProduct, which entailed every OrderItem is a Product left unconstrained; schema.org allows Order|OrderItemOrderItem|Product|Service, which one domain/range pair cannot express

Currency is hardcoded "USD" throughout, since Northwind has no currency column.

Analytics

orderStatus, the materialised line total and the dispatch date make four new charts possible, each with a narrative block ahead of it:

  • Discount rate by month (frontpage) — the gap between catalogue and billed price, 3.6%–11.2% of gross and decorrelated from volume, which a revenue chart cannot show
  • Days to ship (orders) — order to dispatch, averaging 8.5 days
  • Shipped on time (orders) — share of orders beating the promised date, 91.2%–100%
  • Late shipments by carrier (orders) — 5.1% / 4.9% / 3.6%, so carriers are compared on reliability as well as volume

Both rate charts are normalised deliberately. Raw monthly status counts squash the late series (0–4 a month) against a delivered series climbing 21 to 69, and the 21 still-open orders are a dataset-cutoff artifact confined to the last two months — they are filtered out, since not yet dispatched is not the same as delivered late.

Orders by country is dropped to make room: the frontpage already tells the geographic story in revenue rather than order count, the more useful of the two measures.

First multi-series chart

Revenue by carrier on the Orders page plots three carriers over 22 complete months. Each ldh:seriesVarName names its own measure column, so the query pivots with conditional aggregation. The sales-by-region-per-year chart removed earlier passed a label column plus a value column, which is why it never rendered — and region was the wrong dimension anyway, since sales reach a region only through employee → territory and 9 employees hold 49 territory-region pairs, double-counting anyone who serves two.

Correctness

  • Records were being dropped at import on empty postal codes. shipPostalCode sat in the required BGP of orders.rq but is empty for 19 rows, so those orders never entered the graph — 811 → 830 orders, and revenue 1,215,812 → 1,265,792, which is Northwind's canonical Order Subtotals figure. customers.rq had the same bug: HUNGO (Hungry Owl All-Night Grocers, Cork) has no postal code, because Ireland had none until Eircode in 2015, so the whole customer was discarded and its 19 orders were left with a schema:customer link resolving to nothing. Both columns move to OPTIONAL, and dangling customer references go from 19 to 0. An audit of all eleven mappings against their CSVs confirms these were the only two required-but-sometimes-empty columns
  • Revenue charts are net of discount. They had summed quantity × price, overstating by 6.7%. All six now read the materialised line total directly instead of each recomputing it, so they also agree with one another rather than differing by rounding
  • The truncated final month is excluded from the trend. Data stops on 1998-05-06, which rendered as a collapse rather than a partial month. The cutoff derives from MAX(?orderDate) rather than a pinned date
  • No hardcoded counts in prose
  • A Revenue by category chart duplicated in products.ttl is removed; it belongs to categories.ttl

Verified by running all eleven import queries over the real CSVs and all 31 view and chart queries against the resulting graph: 830 orders, 2155 line items, freight totalling 64,943, and top product / employee / customer / category all matching canonical Northwind.

Known issues, not addressed here

  • Re-importing duplicates line items. order_details.rq writes many rows into one order graph; add() PUTs with If-None-Match: * and POSTs on 412, so a second run appends to the existing graph, and STRUUID() identities mean it never converges. Northwind must be purged before each import. This looks like a LinkedDataHub bug affecting any mapping that writes several rows to one graph
  • The line-items table lost its unit price and discount columns. Table columns come from the resource's own predicates, and the unit price now lives on a nested UnitPriceSpecification, so it is one click away rather than in the table. This is the direct cost of schema.org having no per-line unit-price slot
  • A View cannot show an aggregate. ldh:View wraps its SELECT as DESCRIBE * WHERE { … }, so an aggregate binds to a literal and vanishes before rendering. The order list therefore has no order-total column
  • Property labels only resolve client-side. SSR passes object-metadata but never property-metadata, so a hard load shows OrderDate where in-app navigation shows Order date

namedgraph and others added 8 commits August 28, 2026 17:22
- Line items table on order pages via forward ldh:view (detail gallery)
- Order status (OrderDelivered/OrderProcessing/OrderProblem) derived
  from shipped vs required date; shipped date and freight re-emitted
- Line item extended prices (schema:totalPrice) computed from the
  existing quantity/unitPrice/discount columns
- Orders gallery first on the container page, newest-first, with
  customer/broker/date/status columns; narrative blocks rewritten;
  buggy sales-by-region chart (duplicate ldh:seriesVarName) removed
- Inverse views upgraded to TableMode with richer columns; new
  "Orders shipped by this shipper" panel; showWhenEmpty false
- KPI row (orders/revenue/avg order value) on the root dashboard
- Fix unbound ?deliveryLocation triple in orders.rq

The source CSVs stay stock Northwind - no orderTotal or productName
columns are added. An order total is the sum of its line items and a
product name lives in products.csv, so neither belongs in the source
data; the row-at-a-time importer cannot aggregate or join, but that is
a constraint to design around rather than to denormalise away.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg
Page structure and copy:
- Drop the frontpage block advertising LinkedDataHub's features and the
  low-code build story; an app does not explain its own toolchain
- Lead the dashboard with the monthly sales trend instead of a two-value
  table stretched over the 400px chart canvas
- Record lists come before analytics in each container, so a section opens
  on its data; narrative blocks run between sections throughout
- Give the four reference containers (suppliers, shippers, regions,
  territories) the intros the others already had
- Drop the revenue-by-category chart duplicated in products.ttl; it belongs
  to categories.ttl

Labelling:
- dct:title on every ldh:Object block. Without it the block heading renders
  the fragment identifier, so pages showed "kpi-block", "select-suppliers"
- Northwind's own vocabulary in the ontology: Sales rep (not Broker),
  Reports to (not Sponsor), Territory, Region, Company name, Required by
- Use .ldh-section for section headers; page-header/lead are Bootstrap 2
  leftovers with no rules in the current design system

Correctness:
- Revenue charts are now net of schema:discount. They summed quantity x price
  and overstated by 6.7% (1,297,141 gross against 1,215,813 net), so no chart
  reconciled with the order totals
- No hardcoded counts in prose. "all 830 orders" was also wrong: 19 rows are
  dropped at import because shipPostalCode is empty and the mapping requires it
- Exclude the final, incomplete month from the trend. The data stops on
  1998-05-06, which rendered as a collapse rather than a truncated month; the
  cutoff is derived from MAX(?orderDate), not pinned to a date
- Order-scoped schema:totalPrice is no longer required by any query, following
  its removal from the import mapping

Charts:
- Add revenue by carrier to Orders, the first chart here with multiple series.
  Each ldh:seriesVarName names its own measure column, so the query pivots with
  conditional aggregation; the removed sales-by-region chart passed a label
  column plus a value column, which is why it never rendered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg
The checker maps every .ttl to <path minus .ttl>/, which is right for
containers but wrong for root.ttl: install.sh PUTs it at $base itself, not
at <dir>/root/. So a relative link in a root.ttl resolved one level too
deep, and demo/northwind-traders/root.ttl's href="orders/" was reported as
demo/northwind-traders/root/orders.ttl.

No root.ttl carried a relative link until now, which is why this went
unnoticed. The link is correct at runtime; the checker's base was not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C17WzuUXBuyAxZ3nQASCNg
Several schema.org properties were carrying values outside their declared
domains. Each is moved to the term the vocabulary actually provides:

- Order lines are now schema:OrderItem *and* schema:Offer. schema:OrderItem
  has no price properties at all, so schema:price/schema:priceCurrency were
  off-domain; typing the line as the offer that was accepted for it makes
  them legal without splitting the line across two nodes.
- schema:Offer's own schema:price is the price of the offer as a whole, so it
  replaces schema:totalPrice (a Reservation/Ticket property) as the line
  total. The historical unit price moves to a schema:UnitPriceSpecification
  with schema:priceType schema:ListPrice.
- schema:discount is dropped. It is an Order-level property in schema.org,
  and the list price, quantity and line total already pin the per-line rate
  exactly - unlike a stored rate, they cannot disagree with each other.
- Product prices move from schema:Product to a schema:Offer reached through
  schema:offers. A product is not an offer, so this one needs its own node.
- Freight moves off schema:ParcelDelivery, which has no price property, onto
  a schema:DeliveryChargeSpecification reached via schema:priceSpecification
  from a shipping schema:Offer.
- The dispatch date moves off schema:availableFrom (a DeliveryEvent property
  borrowed onto a ParcelDelivery, and meaning "available for pickup" rather
  than "dispatched") onto a real schema:DeliveryEvent hanging off
  schema:deliveryStatus. Orders never dispatched get no event at all.
- schema:orderedItem loses its rdfs:domain/rdfs:range. schema.org allows
  Order|OrderItem -> OrderItem|Product|Service, which a single domain/range
  pair cannot express; the narrowed pair entailed every OrderItem is a
  Product.

Also fixes a pre-existing import bug: shipPostalCode sat in the required BGP
but 19 orders have none, so those orders never entered the graph at all.
Making it OPTIONAL restores them, taking the import from 811 to 830 orders
and revenue from 1,215,812 to 1,265,792 - the canonical Northwind figure.

Consuming queries follow the new structure. The six revenue queries drop
their ?quantity * ?price * (1 - ?discount) recomputation for the materialised
line total, which also settles a 0.1 discrepancy between the carrier chart
and the rest. SelectProductsFromSupplier traverses schema:offers/schema:price.

Verified by running all eleven import queries over the real CSVs and all 28
view and chart queries against the resulting graph: 830 orders, 2155 lines,
freight totalling 64,943, and top product/employee/customer/category all
matching canonical Northwind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc
…and orders pages

The schema.org remodelling added facts the content blocks were not using:
per-order status, a materialised line total, the catalogue price each line
was struck against, and a DeliveryEvent carrying the dispatch date. Four new
charts read them, each with a narrative block ahead of it.

Front page gains a discounting section. Revenue is what was billed; the
ListPrice specification on each line is what was listed, so the gap is what
was given away to win the deal. As a share of gross it runs 3.6%-11.2% and is
decorrelated from volume, which a revenue chart cannot show.

Orders page gains a fulfilment section - average days from order to dispatch,
and the share of orders that beat the date the customer was promised - plus a
late-shipments-by-carrier bar alongside the existing revenue-by-carrier line,
so carriers are compared on reliability as well as volume.

"Orders by country" is dropped to make room. The front page already tells the
geographic story in revenue rather than order count, which is the more useful
of the two measures.

Both rate charts are normalised on purpose. Raw monthly status counts squash
the late series (0-4 a month) against a delivered series climbing 21 to 69,
and the 21 still-open orders are a dataset-cutoff artifact confined to the
last two months - they are filtered out of the on-time and late rates, since
not yet dispatched is not the same as delivered late.

Also corrects the orders intro, which still promised unit prices in the
line-items table; those moved to a nested UnitPriceSpecification.

Verified against the full graph built from the real CSVs: 31 view and chart
queries, none failing to parse, none returning no rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc
customers.rq required <#postalCode> in its main BGP, but HUNGO — Hungry Owl
All-Night Grocers, Cork — has none. Ireland had no national postcode system
until Eircode in 2015, so a 1996-98 Irish address legitimately has no postal
code; this is correct data, not dirty data.

The row processor emits no triple for an empty cell, so the BGP failed to
match and the entire customer was silently discarded - company, contact,
address, phone and coordinates alike. Its 19 orders kept their
schema:customer link, which then resolved to nothing.

Moving the column to an OPTIONAL, alongside region, fax, lat and long in that
same query, restores the customer and takes dangling customer references from
19 to 0. HUNGO imports with its city and no postal code, rather than a
fabricated placeholder.

Same failure mode as the 19 orders dropped on an empty shipPostalCode. An
audit of all eleven mappings against their CSVs confirms these were the only
two required-but-sometimes-empty columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYifJzEkGsTcJsuB4fGRAc
@namedgraph namedgraph changed the title Present Northwind as a business application, and take the invented columns back out Present Northwind as a business application, and fix its schema.org modelling Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant