Deep dive

ROAS target case study: the long version

The full reasoning behind the case study, kept in one scrolling page. The screens here are the first redesign (v1); the refined v2 screens, with a before-and-after UX audit, are on the audit page.

Context

Affinity runs campaigns across multiple ad products for three different roles — Brands, Publishers and Finance — each of whom needs to define or check what a campaign is trying to achieve and how it is funded, at either the Business Unit level or the Affinity level.

Six recorded rounds of iteration — Lo-Fi through Lo-Fi v5, then two Hi-Fi passes — show the team wrestling with the same core question the whole way: does each combination get its own screen, or does one flow carry all of them?

Constraints. Three roles with different jobs had to work against one shared model — a model that was itself still being defined across those six iterations. And two things a target depends on, the Masterbrand hierarchy behind a Deal-ID and the Hubspot deal Finance invoices against, belong to other teams on other clocks, so the flow could not assume either was ready.

The reframe

The question the team kept asking: Which screen does each combination get?
The question that unlocked it: How many choices does one person actually make?

Thirty-two valid combinations looks like thirty-two screens only if you count what the system supports instead of what a user decides. The redesign splits selection into two questions — what are you optimising for, then how is it funded — turning sixteen upfront combinations into two choices of four, with fields revealed only as they become relevant.

Principles that settled the trade-offs

1. One model, many roles. Brand, Publisher and Finance work against one shared configuration surface with a role switcher — a single source of truth on what a target and its budget mean — rather than three bespoke dashboards.

2. Never block on a dependency the user can’t fix. The Masterbrand hierarchy and the Hubspot deal belong to other teams. The flow offers likely matches, or saves in a named, visible state, instead of halting.

3. State the consequence, not just the status. The uncovered tag count leads the counter row; an unmapped deal reads “Not invoiceable” — in Finance’s language, with an owner and an action.

Setting a target

Decision 1. One configuration flow with conditional fields, instead of a screen per target/budget combination

The tension
Each of the 4 target types needs different inputs — a ROAS type needs an ad-spend ceiling, a CPA type needs an event definition and CAC, a sales type needs a date range — but a screen-per-combination approach means 16+ near-identical flows to build, maintain, and re-teach the user every time they pick a different type.
What I rejected, and why
A dedicated wizard per target-type/budget-type pairing — the default most enterprise ad platforms fall into — was rejected because it multiplies engineering and QA surface area 16× for a config object that is structurally the same shape underneath.
What I did instead
A single Create Top Level Target flow where Target Type and Budget Type selection at the top conditionally reveals only the relevant fields below. In the redesign the selection step itself is split in two — what are you optimising for , then how is it funded — which turns 16 upfront combinations into two choices of four, and the confusable pair is renamed so the labels carry the distinction the definitions were doing alone.
What it moved
Thirty-two valid combinations ship as one create/manage flow — one configuration, not one screen — in place of 16+ near-identical wizards to build, test and re-teach.
Target type step: four options, each naming the fields it will ask for.
Redesigned target-type step, kept in the shipped screen's own idiom — same outlined fields with notched labels, same dropdown listbox, same four option names in the same order. Four changes inside that pattern: each option gains a short distinguishing tag, so Max CPA/CAC and Maximum Conversions are no longer told apart by reading two paragraphs; each definition names the fields that choice will ask for, making progressive disclosure visible instead of a surprise; the listbox gains the question it is answering as a header; and the two unlabelled pale-blue blocks become named, summarised sections with the "+" given a verb. First redesign (v1), illustrative data.

Decision 2. A live tag inventory instead of a flat tag list

The tension
Targets get mapped to campaign tags for tracking, but once dozens of tags exist across many targets, a flat list gives no way to see which tags are actually covered by a target and which are silently orphaned.
What I rejected, and why
A simple free-text or flat-list tag field — the lightest possible implementation — was rejected because it hides exactly the coverage gap this system exists to prevent.
What I did instead
Tags are tracked as persistent, always-visible counters — total available, running, allocated, with target, and without target — so a coverage gap is visible before a campaign launches, not discovered after spend has already gone out untracked. The redesign moves those counters above the table instead of below it, states the uncovered count as the headline number rather than one figure among five, and gives the empty table an actual empty state.
What it moved
A coverage gap shows up before a campaign launches: the count of tags without a target is the headline number, not one of five, so orphaned tags are caught before spend goes out untracked.
Tag inventory with an empty state and a counter row that leads with tags without a target.
Redesigned tag inventory, keeping the shipped structure — toolbar strip, target table, nested tag sub-table, the counter row with its circular badges and View here, centred footer buttons. Four fixes inside it: the truncated headers "(Achieved/Prop◄" and "Active / Inactiv" now wrap and read in full; the nested table gets the empty state it never had; the counter row states the consequence of 45 without a target rather than listing it as one number among equals; ₹10,00,000 allocated is reconciled against the ₹2,00,000 actually split; and the two unlabelled footer buttons become Cancel and Save Split. First redesign (v1), illustrative data.

Getting it billed

Decision 3. Guided in-place recovery instead of a blocking validation error

The tension
A Fixed Cost budget has to map to a Deal-ID, but Deal-IDs live under a Masterbrand hierarchy that a brand-new brand will not have set up yet — and the person entering the budget usually is not the person who owns that hierarchy.
What I rejected, and why
A hard validation error that stops budget entry until someone else fixes the Masterbrand mapping elsewhere in the org was rejected — it turns one person's task into a cross-team blocker with no visible next step.
What I did instead
Inline copy that names the exact gap and gives an immediate path forward: "As there is no Masterbrand mapped to {this brand}, following are probable Deal-IDs to add the above fixed cost budget. Kindly create or map this {Brand} to a Masterbrand." The system suggests likely Deal-IDs rather than leaving the user to solve the hierarchy problem cold. The redesign makes good on that — the suggestions are actual selectable rows with the evidence behind each match, and the unmapped-Hubspot state carries an owner and an action instead of standing as a bare label.
What it moved
Someone entering a budget for a brand with no Masterbrand mapped still has likely Deal-IDs in front of them, and the note says plainly that it does not block — so one person’s task never becomes a cross-team blocker.
Deal-ID step with a does-not-block notice and three likely matches.
Deal-ID recovery, rebuilt in the same component vocabulary as the Hubspot Demand Pipeline step — the notched outlined fields, the confirmed-state rows, the 14px table-and-form type scale and the centred footer are the ones from the source file, so the two steps read as one flow. The fixes are inside: the three boxes that rendered empty in the shipped build now hold the probable Deal-IDs the copy promises, each showing which of the three match signals hit and which missed, so the user can judge rather than guess. The Masterbrand note says plainly that it does not block. "Hubspot Deal Not Mapped" gains a consequence, an owner and an action. The duplicated Add/Map link is gone, and the destructive red commit is a standard primary — saving a budget is not a destructive act. First redesign (v1), illustrative data.

Decision 4. Bring the Hubspot deals into the form, and make an unmapped target a tracked state rather than a blocked one

The tension
A target is configured by media and ad-ops, but Finance cannot invoice against it until it is linked to a Hubspot deal — and the consumption recorded here becomes the amount invoiced there. Mapping the two means holding both records side by side: the target’s business unit, brand, geo, budget type and date window against a CRM record’s brand, geo, stage, owner and amount. The systems are owned by different teams on different clocks, so at the moment a target is created the deal is often either hard to identify among near-identical ones, or does not exist yet.
What I rejected, and why
Three things. A deal-ID text field with a link out to Hubspot is cheapest to build and moves the whole matching problem onto the user — open a second tab, search a CRM they may not have access to, copy an ID back — and it produces the failure with no signature: a valid ID pointing at the wrong deal, which nothing downstream can catch. Making the deal a hard required field would hold a live campaign hostage to another team’s CRM hygiene. And letting the target save behind a quiet internal flag, which is what the shipped "Hubspot Deal Not Mapped" label amounted to, pushes the gap to month-end invoicing, after the spend has gone out.
What I did instead
The form pre-filters Hubspot on the selections already made above and presents the result as a table, so the choice is a single-select among candidates rather than free recall. The filter states its own criteria in the header — VEVE × Zomato × India × Fixed Cost + CPC — so a user seeing no matches knows which selection to widen instead of assuming the integration is broken. Each row carries the fields that actually discriminate between similar deals: amount, close date, stage and owner. Search stays available and is scoped explicitly to Deals, with Company as the documented fallback. Below it, the Demand Success Pipeline table shows what the mapping will produce month by month, so the consequence is legible when the decision is made rather than at the first invoice. And when no deal exists yet, the target saves anyway: the unmapped state becomes a first-class condition that names its consequence in Finance’s language and persists onto the target row in the list as Not invoiceable , visible to every role on the screen they already open.
What it moved
An unmapped target saves anyway and carries “Not invoiceable” onto its row — visible to Brand, Publisher and Finance on the screen they already open — instead of surfacing at month-end invoicing.
Hubspot Demand Pipeline inside the create-target flow, with the match criteria printed above the table.
Hubspot Demand Pipeline inside the create-target flow. The match criteria are printed above the table, so an empty result is diagnosable rather than mysterious; single-select radios make the mapping one decision instead of a copied ID; and the four fields that separate near-identical deals — amount, close date, stage, owner — sit in the row. The Demand Success Pipeline table below shows the months this target will generate deals for. Map & Proceed stays disabled until a deal is selected, so the step cannot be passed through silently — and when no deal exists yet, the target still saves and carries Not invoiceable onto its row in the list. First redesign (v1), illustrative data.

Target list: status column filled in

This is the screen all three roles land on, and in the shipped build it was the weakest: four visually identical rows, a blank Status column, a blank Actions column, unlabelled buttons, reversed date ranges, and the only differentiator rendered as a wordless coloured pill. None of that needed a new feature to fix — it needed the data the table already had to be shown.

Target list with a role switcher and a Status column that carries real state.
Redesigned target list, same shell and same nine columns as the shipped screen. Status now carries real state — Live, Under target, 45 tags unmapped, Not invoiceable — and Actions carries the verb that state implies. The last row is the case this whole system exists to catch: the campaign ended, ₹4,90,000 of spend went out, and it still cannot be billed because no Hubspot deal was ever linked. BU Details keeps its colour dot but gains the name beside it, so four identical Zomato rows are finally distinguishable. Date ranges read start-to-end rather than end-over-start, currency is prefixed, the two unlabelled buttons become Create Target and Export, active filters show as removable chips, and the aggregate totals sit inline on the toolbar. First redesign (v1), illustrative data.

Strategic considerations: what I deliberately did not build

We considered giving each of the three roles — Brands, Publishers, Finance — a fully separate, purpose-built dashboard tailored to exactly what that role needs to see. We ruled it out for this release: all three work against one shared configuration surface with a role switcher, trading per-role customization for a single, consistent source of truth on what a target and its budget actually mean, while the underlying model itself was still being defined across six iterations.

Supporting evidence

Design system discipline

A dedicated internal canvas documents type scale, colour tokens (Blue, Mauve, Neutral ramps) and a full icon set. The platform's shell — Mini Side Bar, Profile, Menu drop down — appears identically nested under dozens of otherwise-unrelated screens across every product line, confirming the consistency was not accidental.

Visible iteration — test-and-learn cycles

Six named rounds — Lo-Fi through Lo-Fi v5, then Hi-Fi v1 and a later Hi-Fi changes pass — each restructuring the target/budget flow, are preserved rather than discarded. That progression is itself the evidence for how the 4×4×2 model got simplified into one flow rather than being designed that way from the start.

Domain-correct language

Inline definitions for all four target types are written in the exact vocabulary brand and media teams use — ROAS, CAC, CPA, top-funnel versus bottom-funnel — rather than generic platform-speak. This is what "match between system and the real world" looks like for an enterprise ad-tech audience.

← Back to the case study