Cart Totals

A shopping cart at the general store. Any change to a quantity, the delivery or the coupon is followed by every number that depends on it: the line total, the subtotal, the delivery fee and so on. The prices are worked out on the server, written once in Ruby.

Your order

  • Steel Mace

    260 gp each

    260 gp
  • Bronze Dagger

    20 gp each

    40 gp
  • Loaf of Bread

    4 gp each

    24 gp
  • Cabbage

    1 gp each

    8 gp
Subtotal
332 gp
Delivery by cart
30 gp
Tithe, 10% of the goods
33 gp
Total
395 gp

168 gp more and the cart comes free.

# frozen_string_literal: true

# An order at the general store. The states are what the visitor chose: the
# lines, the delivery and the coupon. Every number on the page is derived
# from them through Recipes::Totals::Order, which holds the prices.
class Recipes::Totals::TotalsChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Order = Recipes::Totals::Order
  Line = Recipes::Totals::Line
  VIEWS = "app/recipes/totals"

  def build_graph
    start = Order.start
    @lines = Hibiki::State.new(start.lines)
    @delivery = Hibiki::State.new(start.delivery)
    @coupon = Hibiki::State.new(start.coupon)
    @placed = Hibiki::State.new(false)

    @order = Hibiki::Derived.new do
      Order.new(lines: @lines.value, delivery: @delivery.value, coupon: @coupon.value)
    end
    ids = Hibiki::Derived.new { @lines.value.map(&:id) }

    # The rows hold the quantity fields, so they are sent again only when
    # the set of lines changes: one removed, or the basket filled again.
    # `peek` reads the quantities without subscribing to them, and a field
    # someone is typing in is never replaced.
    Hibiki::Effect.new do
      ids.value
      broadcast_morph target: "totals_lines", partial: "#{VIEWS}/lines", locals: { lines: @lines.peek }
    end

    # A quantity changes two pieces of text in its own row instead. Each is
    # sent only when its text is different from last time.
    start.lines.each do |line|
      transmit_value(:"totals_line_#{line.id}") { line(line.id)&.total&.then { Order.gp(it) } }
      transmit_value(:"totals_note_#{line.id}") { line(line.id)&.note }
    end

    Hibiki::Effect.new do
      broadcast_morph target: "totals_summary", partial: "#{VIEWS}/summary",
                      locals: { order: @order.value, placed: @placed.value }
    end
  end

  def set_qty(data)
    return if data["qty"].blank?

    id = data["id"].to_i
    qty = data["qty"].to_i.clamp(1, Line::MAX_QTY)
    change(@lines, @lines.value.map { it.id == id ? it.with(qty:) : it })
  end

  def remove(data)
    id = data["id"].to_i
    change(@lines, @lines.value.reject { it.id == id })
  end

  def set_delivery(data)
    change(@delivery, data["delivery"]) if Order::DELIVERIES.key?(data["delivery"])
  end

  def set_coupon(data) = change(@coupon, data["coupon"].to_s.strip.upcase.first(20))

  def restock
    change(@lines, Order.start.lines) if @lines.value.empty?
  end

  def place = @placed.value = !@order.value.empty?

  private

  # Any change to the order takes back "placed".
  def change(state, value)
    state.value = value
    @placed.value = false
  end

  def line(id) = @lines.value.find { it.id == id }
end

The cart above holds four things from the general store, each with a field at the right of its row for how many. Type a new number into one of those fields and the rest of the panel follows: the row's total, the subtotal, the delivery fee and so on down to the total. Make it ten and the row shows the bulk price. Keeping all of that in step is tedious in stock Rails, and tempting to do in JavaScript, where the prices are then written twice. Here every price is worked out on the server, in Ruby, in one place. The channel writes down what the visitor chose, and Hibiki sends the parts of the page that were worked out from it.

How it fits together

The basket lives in a channel: the server side of an Action Cable connection, an object that stays alive while the page is open. A state holds a value and remembers who read it. Here three states hold what the visitor chose: @lines, @delivery and @coupon. The channel's public methods are its actions, the methods the page may call, and each one writes one of the three.

A derived is a value worked out from other values, and worked out again when one of them changes. Here @order is the one derived. It reads the three states and builds an Order from them: a plain Ruby value that holds every pricing rule and knows nothing about the channel.

An effect is a block of code that runs once, and runs again whenever a value it read has changed. One effect reads @order, renders the summary partial and sends it to the page, where it is morphed into place. This is what happens when you pick Swift rider in the delivery select:

  1. The select calls set_delivery, which writes @delivery and returns.
  2. @order read @delivery, so it is built again with the rider's fee and mark.
  3. The summary effect read @order, so it renders the summary again and sends it. The fee, the total and the bar come with it.

The channel never says which effect depends on which state. Whatever a block read on its last run is what it depends on. The figures inside a row take a shorter road: transmit_value is an effect that sends a piece of text instead of HTML, and sends it only when the text differs from last time.

The prices are written once

A shop's rules lean on one another. The coupon takes a share of the subtotal, delivery is free once the goods come to a mark, and the tithe is a share of the goods. In Order each amount is a method that reads the methods before it:

# app/models/recipes/totals/order.rb
def subtotal = lines.sum(&:total)

def discount = coupon? ? COUPONS[coupon].call(subtotal) : 0

def goods = subtotal - discount

def delivery_fee = empty? || free_delivery? ? 0 : delivery_method.fee

def tithe = (goods * TITHE_PERCENT).fdiv(100).round

def total = goods + delivery_fee + tithe

goods is the subtotal with the discount taken off, and both delivery_fee and tithe read it. Now follow a coupon through the chain. Type CABBAGE into the coupon field on an order that has only just reached free delivery. The discount lowers the goods, the goods fall back under the mark, the fee returns, and the tithe shrinks with the goods. We didn't write the case where a coupon costs you free delivery: it follows from the fee and the tithe both reading goods.

The page's first render calls the same methods: _totals builds an Order.start and renders the rows and the summary from it. You may be tempted to build the chain out of deriveds in the channel instead. However, that chain would be a second copy of the pricing, and the first render, which has no channel yet, could not use it. Keeping the rules in a plain value and wrapping it in one derived gives the first render and every update the same answer.

The field being typed in is never replaced

Each row holds a quantity field. If the rows were sent by an effect that read @lines, the whole list would arrive again for a one-number change, with the field you are typing in inside it. So the rows are sent again only when a line comes or goes, and a quantity changes two pieces of text beside the field instead:

ids = Hibiki::Derived.new { @lines.value.map(&:id) }

Hibiki::Effect.new do
  ids.value                                                # subscribes: a line gone, new rows
  broadcast_morph target: "totals_lines", partial: "#{VIEWS}/lines",
                  locals: { lines: @lines.peek }           # does not: a quantity sends nothing here
end

# what a quantity does send: the text of its own row
transmit_value(:"totals_line_#{line.id}") { line(line.id)&.total&.then { Order.gp(it) } }

ids is a derived list of the line ids. The effect reads ids.value, which subscribes it to the list, and renders the rows from @lines.peek, which reads the lines without subscribing to them. Type a new quantity and @lines becomes a new list, but ids comes to the same ids as before, so the effect stays quiet and the row's transmit_value sends the new total on its own. Removing a line instead changes ids to a shorter list: the effect runs, the morph leaves the remaining fields as they are, and the removed row slides out.

Changing a rule

Say the shopkeeper raises the mark for free delivery by cart, or starts offering gift wrap. The first is one number in Order. The second is a method there, a row in the summary, and a state and an action in the channel for the visitor's choice:

# Free delivery by cart from 750 gp: one number.
"cart" => Delivery.new(name: "Cart", fee: 30, free_from: 750),

# Gift wrap at 2 gp a line: a state and an action in the channel,
# a field and a method in the order, and a row in the summary partial.
@wrap = Hibiki::State.new(false)
def set_wrap(data) = change(@wrap, data["wrap"] == true)

def wrap_fee = wrap ? lines.size * 2 : 0
def total = goods + delivery_fee + wrap_fee + tithe

Neither change touches an effect. The summary effect reads the order as it did before, so the bar follows the new mark and the gift wrap row arrives with the rest of the summary.

The same feature in stock Rails

The usual stock Rails build that keeps the prices in Ruby posts the whole form on every change and answers with Turbo Streams. Here is a sketch of it. It is not run here, and it leaves out Order, Line and the summary partial, which would be the same in both versions.

# A sketch of the stock Rails version. It is not part of this app.
class BasketsController < ApplicationController
  before_action :set_order

  def show
  end

  # Every keystroke that settles posts the whole form: each quantity, the
  # delivery and the coupon. Nothing is remembered between requests, so the
  # order is rebuilt from all of it and saved back.
  def update
    @before = @order
    @order = @order.with(
      lines: @order.lines.map { it.with(qty: quantity(it.id) || it.qty) },
      delivery: params[:delivery].presence_in(Order::DELIVERIES.keys) || @order.delivery,
      coupon: params[:coupon].to_s.strip.upcase.first(20)
    )
    session[:basket] = @order.to_session

    # The template has to know what changed, so that it does not replace
    # more than it needs to. Here that is every line whose total moved.
    @changed = @order.lines.reject { |line| @before.lines.include?(line) }

    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to basket_path }
    end
  end

  private

  def set_order = @order = Order.from_session(session[:basket])

  def quantity(id) = params.dig(:qty, id.to_s)&.to_i&.clamp(1, Line::MAX_QTY)
end

# Removing a line is its own controller, action and template.
class Baskets::LinesController < ApplicationController
  def destroy
    @order = Order.from_session(session[:basket])
    @line_id = params[:id].to_i
    @order = @order.with(lines: @order.lines.reject { it.id == @line_id })
    session[:basket] = @order.to_session

    respond_to do |format|
      format.turbo_stream   # destroy.turbo_stream.erb: remove the row, then the same summary line
      format.html { redirect_to basket_path }
    end
  end
end

The pricing is as short as before. What grows is everything around it. The server remembers nothing between requests, so the form posts every field each time and the controller rebuilds the order from all of it, then compares it with the order before to learn which rows moved, since the stream template has to name each place a number appears. Removing a line needs its own route, controller and template. The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
A line total follows its quantity An id on the total, and a Turbo Stream line for each row that changed One transmit_value for the row
Subtotal, tithe, delivery and total follow A Turbo Stream line for the summary, repeated in every template that can change it One effect and its partial
Knowing what changed The controller compares the order before and after Nothing to write; each effect runs when what it read has changed
What a change sends to the server Every field of the form The one value that changed
Not replacing the field in use Keep fields out of every replaced fragment, by hand, in each template The rows subscribe to which lines exist and peek at the quantities
Removing a line A route, a controller, an action and a second stream template A one-line action
Waiting for typing to settle A Stimulus controller with a timer on(:set_qty, event: :input), which waits 250 ms by default
Two quick changes in a row Replies can arrive out of order; guarding against it is more JavaScript Actions run one at a time, in the order sent
A new rule, such as gift wrap The method and the summary row, plus the field in the form, the param in the controller and the session The method and the summary row, plus a state and an action

Stock Rails has a second way with no round trip: work the totals out in a Stimulus controller. It is quick to the eye. However, the bulk price, the coupons and the delivery tiers are then written twice, in Ruby for the order that is charged and in JavaScript for the one that is shown, and the day one copy changes without the other, the page shows a total the shop does not charge. Here the prices stay on the server, and Hibiki carries them to the page.