JavaRush /Courses /Kotlin SELF /Reports set v0: total, byCategory, top, threshold in a un...

Reports set v0: total, byCategory, top, threshold in a unified style

Kotlin SELF
Level 26, Lesson 4
Available

1. Agree on the data: how reports receive expenses

When someone says “make a report”, a beginner often imagines a huge println that somehow calculates everything and draws it nicely. In practice, a report lives longer than inspiration: people come back to it, extend it, and compare it to other reports. So the first grown-up decision is to agree on the shape of the input data and keep it stable at least at this stage of the course (no classes and OOP).

In our practical console app about expenses (a conditional “mini-tracker”), we will assume that one expense is a pair:

  • first: category (String)
  • second: amount (Int) in arbitrary “units”; the main point is an integer

That means the entire expense list is List<Pair<String, Int>>.

Here is a small example of the data we will work with next:

fun main() {
    val expenses: List<Pair<String, Int>> = listOf(
        " food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        "Books" to 90
    )

    println(expenses)
    // [( food , 120), (FOOD, 80), (taxi, 250), (Books, 90)]
}

Yes, the categories are “dirty” here (spaces, different casing). That is not a mistake: it is realistic. A user will always find a way to enter data so your program feels unsure of itself. We will just be smarter.

2. Shared step: category normalization

If you have ever grouped strings “as is”, you have seen the magic in quotes: "Food" and " food " suddenly become different categories, and the report starts to look like a person eats in three parallel universes. To prevent that, normalization is a mandatory shared step for all reports. And yes, normalization is not “pretty output”; it is converting the data to a single internal form before calculations.

Let’s make a small category-normalization function: trim() + lowercase(). It should be predictable, short, and the same everywhere.

fun normalizeCategory(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    println(normalizeCategory("  FOOD ")) // food
    println(normalizeCategory("Books"))   // books
}

Now apply normalization to all expenses. Note: we do not change the original list “in place”; we build a new one (this helps keep computations pure and reduces surprises).

fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
    return expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }
}

fun main() {
    val raw = listOf(" FOOD " to 10, "food" to 5)
    val normalized = normalizeExpenses(raw)

    println(normalized) // [(food, 10), (food, 5)]
}

Almost all our reports later will start with: “take expenses → normalize categories → calculate”. This is the “unified style”.

3. Report skeleton: calculation separate, output separate

It is very tempting to “make it pretty” immediately: print lines while counting, add emoji (but we are in a Kotlin course, not Telegram stickers), align with spaces... and then end up mixing calculation and output so much that changing the format becomes more painful than getting up on Monday.

So we lock in the rule of the day:

  • calculation functions return numbers/structures (Int, Map, List<Pair<...>>)
  • render functions turn the result into a String (or print it, but returning a string is better)
  • main (or a command handler) decides what and when to show

Schematically it looks like this:

flowchart LR
    A[raw expenses] --> B[normalize]
    B --> C[calculate totals]
    C --> D[optional: filter threshold]
    D --> E[optional: sort + top-N]
    E --> F[render string]

In this lecture we will implement 4 reports (total, byCategory, top, threshold) so that they feel like relatives, not random passers-by.

4. total report: sum of all expenses

The total report seems the simplest: “sum all amounts”. But it is exactly in places like this that typical beginner mistakes start: an external counter, forgot to reset it, an empty list “broke the logic”, a sudden println somewhere in the middle, etc. The good news: Kotlin has fold, which is designed for neat accumulation of a result.

Let’s make a function to calculate the total sum. We add up second for each pair.

fun calcTotal(expenses: List<Pair<String, Int>>): Int {
    return expenses.fold(0) { acc, item ->
        acc + item.second
    }
}

fun main() {
    val expenses = listOf("food" to 10, "taxi" to 20)
    println(calcTotal(expenses)) // 30
}

Why fold(0) and not reduce? Because fold can work even with an empty list: it starts from the initial value, so the result is predictable.

Now let’s render total. Let it be a simple string:

fun renderTotal(total: Int): String {
    return "Total: $total"
}

fun main() {
    println(renderTotal(60)) // Total: 60
}

And a small “assembly” example right away: normalization + total.

fun main() {
    val raw = listOf(" FOOD " to 120, "taxi" to 250)
    val expenses = normalizeExpenses(raw)

    val total = calcTotal(expenses)
    println(renderTotal(total)) // Total: 370
}

5. byCategory report: totals by category

A “by category” report is the first moment when data changes shape: we move from a list of individual operations to aggregated totals. And that is normal: a report exists precisely to turn “raw events” into “understandable totals”. The main thing is not to be scared of the type change and to do it consciously.

Our goal is to get a Map<String, Int>, where the key is a category and the value is the total for that category.

Let’s do it in two clear steps: groupBy → mapValues + fold.

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
    val grouped: Map<String, List<Pair<String, Int>>> =
        expenses.groupBy { it.first }

    return grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }
}

fun main() {
    val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
    println(totalsByCategory(expenses)) // {food=15, taxi=7}
}

It is useful to pause for a second and say it out loud: groupBy creates lists inside the map. This is convenient to understand, but sometimes heavy on memory. In previous days you saw groupingBy { ... }.eachCount() and the general idea that you can group “without explicit lists”. Today we are not chasing optimization; we are chasing readability.

Now the render. An important nuance: with a Map, you should not treat the print order as a “report contract”. If you want deterministic output, you sort it yourself. This is directly related to the topic of collection ordering: sorting by key/value must be explicit, otherwise the order becomes “luck-based”.

Let’s render it as a “table”. For simplicity: sort by category name.

fun renderByCategory(totals: Map<String, Int>): String {
    val rows: List<Pair<String, Int>> = totals.entries
        .map { it.key to it.value }
        .sortedBy { (cat, _) -> cat }

    val sb = StringBuilder()
    for ((cat, sum) in rows) {
        sb.append(cat).append(": ").append(sum).append('\n')
    }
    return sb.toString()
}

fun main() {
    val totals = mapOf("taxi" to 250, "food" to 200)
    print(renderByCategory(totals))
    // food: 200
    // taxi: 250
}

Note: we return a string. Printing it or writing it to a file (someday later) is a decision for the calling code.

6. threshold: threshold as a separate step

A threshold (threshold) often appears in reports as “show only meaningful rows”. For example: “show categories where the total is >= 100”. The trap here is simple: you can apply a threshold at the wrong moment and get weird results. To avoid arguing with your future self, make threshold a separate pure step that works on already aggregated data.

That is, threshold is filtering totals, not filtering individual operations. If you filter operations, the logic becomes different: you will start discarding small purchases even though the category total could be large.

Let’s make a function that takes a Map<String, Int> and returns a list of pairs convenient for further sorting/rendering:

fun applyThreshold(
    totals: Map<String, Int>,
    threshold: Int
): List<Pair<String, Int>> {
    return totals.entries
        .map { it.key to it.value }
        .filter { (_, sum) -> sum >= threshold }
}

fun main() {
    val totals = mapOf("taxi" to 250, "food" to 90, "books" to 110)
    println(applyThreshold(totals, threshold = 100))
    // [(taxi, 250), (books, 110)]
}

Right now it looks like “sure, a filter”. But the point is the architecture: this step can be inserted into different reports the same way, and the code will read like a constructor: “calculated → filtered → sorted → printed”.

7. top report: top-N categories by total

Top-N is a typical “presentation” step: we have already calculated everything, and now we decide what to show first. Sorting and take(n) are logical to do closer to output: they do not change the meaning of the data; they change how we present it to the user.

Let’s make a topCategories function that takes aggregated totals. And we will plug in threshold as an option: first filter by threshold, then sort descending, then take(n).

fun topCategories(
    totals: Map<String, Int>,
    n: Int,
    threshold: Int
): List<Pair<String, Int>> {
    return totals.entries
        .map { it.key to it.value }
        .filter { (_, sum) -> sum >= threshold }
        .sortedByDescending { (_, sum) -> sum }
        .take(n)
}

fun main() {
    val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)
    println(topCategories(totals, n = 2, threshold = 100))
    // [(taxi, 250), (food, 200)]
}

Order is especially important here. If you do take(n) first and then apply the threshold, you can “lose” rows: you took the first N before filtering, and then threw some away, so the report suddenly shows fewer rows than it could have. That is why threshold filtering must happen before take(n).

Now render the top. It is almost the same as rendering byCategory, but the order is already set (we sorted by total). So the render just prints rows in the current order:

fun renderTop(items: List<Pair<String, Int>>): String {
    val sb = StringBuilder()
    for ((cat, sum) in items) {
        sb.append(cat).append(": ").append(sum).append('\n')
    }
    return sb.toString()
}

fun main() {
    val top = listOf("taxi" to 250, "food" to 200)
    print(renderTop(top))
    // taxi: 250
    // food: 200
}

8. Assemble the v0 reports into a unified style

When there are more than two functions, the brain starts valuing rituals. Good style is not “pretty”; it is “predictable”: you open the file a week later and immediately understand where to look for what. So let’s assemble everything into a small example similar to how the code will live in our app (for now, still in a single file).

Pay attention to the intermediate val names: they describe the pipeline steps.

fun main() {
    val rawExpenses = listOf(
        " food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        "Books" to 90,
        "books " to 30
    )

    val expenses = normalizeExpenses(rawExpenses)

    val total = calcTotal(expenses)
    println(renderTotal(total)) // Total: 570

    val totalsByCat = totalsByCategory(expenses)
    print(renderByCategory(totalsByCat))
    // books: 120
    // food: 200
    // taxi: 250

    val top = topCategories(totalsByCat, n = 2, threshold = 100)
    print(renderTop(top))
    // taxi: 250
    // food: 200
}

This already looks like a set of reports rather than one “divine println”. Yes, everything is still simple. But this style survives added complexity: you add a report, and it fits into the same scheme.

Report contracts: what we accept and what we return

When you write many functions, it is useful to keep the contracts in sight. This reduces the chance of “cramming the uncrammable” and starting to pass a Map where a List is expected. Below is a compact table of our v0 functions.

Function Takes Returns Meaning
normalizeExpenses(...)
List<Pair<String, Int>>
List<Pair<String, Int>>
Convert categories to a unified form
calcTotal(...)
List<Pair<String, Int>>
Int
Total of all expenses
totalsByCategory(...)
List<Pair<String, Int>>
Map<String, Int>
Totals by category
applyThreshold(...)
Map<String, Int>
,
threshold
List<Pair<String, Int>>
Select categories by threshold
topCategories(...)
Map<String, Int>
,
n
,
threshold
List<Pair<String, Int>>
Top categories by total
render...(...)
numbers/structures
String
Prepare report text

This table is not “documentation for the sake of documentation”; it is a way not to get lost when there are more reports.

9. Typical mistakes

Mistake #1: calculating top from “raw” operations instead of aggregated totals.
If you take the original expense list and simply sort it by amount, you will get “the largest individual expenses”, not “the most expensive categories”. Sometimes that is even a useful report, but it is a different report. For top to be by category, first do totalsByCategory, and only then sort the totals.

Mistake #2: applying threshold after take(n) and being surprised why there are fewer rows than expected.
The logic “first take top-2, then filter by threshold” leads to the fact that you might throw away one row and end up with top-1, even though the data had candidates that would pass the threshold and could take a spot. Threshold is a candidate filter, and take(n) is the final “trimmer”, so threshold goes first.

Mistake #3: printing inside calculation functions.
As soon as you do a println inside totalsByCategory or calcTotal, you break reusability. Today you print to the console; tomorrow you will want to build one big string; the day after tomorrow, save to a file. If the calculation returns a value and output is done separately, you do not corner yourself.

Mistake #4: expecting a Map to “by itself” print in the needed order.
Even if “it happened to match for you right now”, that is not a contract. A report must be deterministic: either sort by key, or by value, or explicitly document the order. In our examples, before output we turn entries into a list of pairs and sort it—this makes the result stable and readable.

Mistake #5: forgetting category normalization and ending up with “FOOD”, “food”, and “ food ” as three different worlds.
This is a classic: you group by a string, and the string is “dirty”. In reports it hurts especially, because a person looks at the totals and draws conclusions. So normalization is a shared step for all reports, and it is better to keep it in a single function so the rules do not drift apart.

Mistake #6: using reduce where the list can be empty, and catching an exception.
reduce requires at least one element; otherwise it has “nothing to start from”. For reports, an empty list is a normal situation (the user has not added anything yet), so for sums and accumulations choose fold with a starting value. This makes the behavior not only safe but also logical: the sum of an empty set is zero, and nobody cries.

1
Task
Kotlin SELF, level 26, lesson 4
Locked
Receipt total
Receipt total
1
Task
Kotlin SELF, level 26, lesson 4
Locked
Total using fold
Total using fold
1
Task
Kotlin SELF, level 26, lesson 4
Locked
Category summary
Category summary
1
Task
Kotlin SELF, level 26, lesson 4
Locked
Report threshold
Report threshold
1
Survey/quiz
Reports and transformation pipelines, 26 level, 4 lesson
Unavailable
Reports and transformation pipelines
Reports and transformation pipelines
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION