The Business Reality8 min read

I Told Her It Was By Design. It Was Data Loss.

I answered a refund request with "one entry per day is by design." Then I opened the code. It wasn't design, it was a bug: the check-in screen always opens blank, so an evening save wrote zeros over what she logged that morning. The streak stayed green the whole time.

#support#data-loss#postgres#reality-check#gotchas#onboarding
Left panel: what I told the customer - one entry per day, by design, saving again replaces it, not a setting I can change, this app isn't right for you. Right panel: what the code did - the form initialises every symptom to zero and upserts over the day's unique row, so a morning hot_flash 3 and sleep 4 became hot_flash 0 and sleep 3, while the streak stayed green
Left: the sentences I sent with confidence. Right: what the code was doing at that same moment.

Earlier today I fixed an onboarding bug that had burned a paying subscriber. That autopsy is its own post. I fixed it, verified it, apologised, replied. I thought the case was closed.

A few hours later she wrote back. Please refund my subscription. Your app does not do what I want.

What she wanted was this: to log things as they happen through the day. A hot flash in the morning, another at 2pm, anxiety at 8pm. That is the entire reason this app exists — write down what happened to your body on the day it happened, and be shown the pattern months later.

Here is what I answered:

Sage records one entry per day. If you save again on the same day it replaces that day's entry rather than adding a second one. Adding things as they happen through the day isn't possible in the current version. The streak and the weekly view are both built around one entry per day, so this isn't a setting I can switch on for you.

If that's how you want to track, then Sage isn't the right app for you, and asking for a refund is a fair call.

Then I carefully walked her through Apple's refund process. Polite, clear, helpful. And wrong.

One question before we continue. When did you last tell a customer "that's by design"? Did you open the code before you sent it? I didn't.

1. I opened the code, and it wasn't design

Something nagged at me after I hit send, so I read the save path. Two files was all it took.

// CheckInViewModel.swift
init(symptoms: [String]) {
    self.symptoms = symptoms
    self.intensities = Dictionary(uniqueKeysWithValues: symptoms.map { ($0, 0) })
}
@Published var sleepQuality: Int = 3
@Published var selectedTags: Set<String> = []

The check-in screen never loads what you already logged today. Whenever you open it, every symptom intensity starts at 0, sleep quality at the default 3, tags as an empty set.

And the save goes out like this:

// SupabaseService.swift
try await client.schema("sage")
    .from("symptom_logs")
    .upsert(SaveSymptomLogBody(
        userId: log.userId.uuidString,
        loggedAt: log.loggedAt,      // "yyyy-MM-dd"
        symptoms: log.symptoms,       // everything currently on screen
        sleepQuality: log.sleepQuality,
        tags: log.tags
    ))
    .execute()

And the table is pinned to one row per day:

CREATE TABLE IF NOT EXISTS sage.symptom_logs (
  ...
  logged_at date NOT NULL DEFAULT current_date,
  symptoms  jsonb NOT NULL DEFAULT '{}',
  UNIQUE(user_id, logged_at)
);

Put the three pieces together:

  • 8am: hot flash 3, sleep 4 saved → row created
  • 8pm: open the same screen and it's a blank form. Tick anxiety 2, save.
  • The upsert hits UNIQUE(user_id, logged_at) and replaces the whole row → hot flash 3 → 0, sleep 4 → 3 (the default), tags → []

So "it replaces that day's entry" was far too gentle a sentence. The app was writing zeros over what she actually experienced that morning. Not deleting it — rewriting it as "that never happened." For a symptom tracker that's close to the worst possible behaviour.

2. And the app reported that as success

This is the part that held me longest. At the exact moment data was being destroyed, every signal on screen was green:

isSaved = true
StreakService.shared.recordCheckIn()
requestReviewIfNeeded()
  • isSaved = true — saved successfully
  • recordCheckIn() — streak preserved. "You logged today too."
  • requestReviewIfNeeded()it asks for an App Store review right after wiping your data.

The dashboard is an accomplice:

func intensityForDate(_ dateStr: String, symptom: String) -> Int {
    recentLogs.first { $0.loggedAt == dateStr }?.symptoms[symptom] ?? 0
}

Missing means 0. So a day that got zeroed out and a day with genuinely no symptoms look identical on screen. The heatmap, the streak and the weekly view all report nothing wrong. The same file also has this:

do {
    recentLogs = try await SupabaseService.shared.fetchRecentLogs(...)
    latestInsight = try await SupabaseService.shared.fetchLatestInsight(...)
} catch {}

catch {}. If the fetch fails, the screen renders with an empty list. "No data" and "fetch failed" are indistinguishable. I'd already learned that green proves nothing in CI, and here I was doing it inside the app.

3. So I was wrong to the customer in three places

To be precise:

  1. "It's by design." Not design — a missing form prefill plus a whole-row upsert. A bug.
  2. "It replaces that day's entry." In reality it writes zeros and defaults over values she entered. Not replacement — destruction.
  3. "Sage isn't the right app for you." I used a bug as grounds to advise a paying customer to leave. This is the worst of the three.

The third is worst because the first two are just failing to read the code, while the third steered someone's decision using that false premise. She trusted my explanation and went for the refund. The explanation she trusted was wrong.

4. Which would you fix first?

The fix splits two ways:

  1. Keep one row per day, merge into it. Load today's row on open and prefill; saving adds to what's there. Schema untouched, app-only ship. Out the same day.
  2. Change the row structure. Split each log into its own row carrying the time it happened (logged_time), and make the daily and weekly views sum them. The real model, where "2pm hot flash" and "8pm anxiety" both survive. But it changes the streak, the weekly patterns and the AI insights.

I shipped option 1 first. It's with review now, and it'll arrive as an ordinary App Store update. Option 2 is the model she actually wanted, but it takes days — and every evening in between, data keeps getting erased. Stopping the partially-right thing now beats shipping the fully-right thing next week. Data destruction is not a roadmap item.

5. The real lesson is in the reply, not the code

If one thing survives this post, it isn't the bug. It's that a support reply I sent with total confidence was itself a bug report.

I built this app, so I "knew" it was a one-row-per-day structure — from memory. That memory matched the schema (UNIQUE(user_id, logged_at)) and did not match the actual behaviour (blank form plus whole-row upsert). Knowing the schema is not knowing the code path. That gap turned into one polite, wrong email.

Support replies are published artefacts. When I write dashboard numbers I check that the claim's inputs exist in the code — but I wasn't applying that discipline to customer email. Now there's a rule: before writing "by design," I have to be able to open the lines that implement that design and paste them. If I can't paste them, it isn't design. It's a guess.

6. The honest part

The refund is hers. I told her to go ahead exactly as described and didn't ask her to reconsider. I added one thing: if the refund goes through and it takes her access away, one email and I'll restore it from my side, free. The entitlement flag is mine to set, so that's a promise I can actually keep. Getting your money back and keeping the app don't have to be either/or.

And this ending is still open. There's been no reply to my last email. As I write this I don't know the outcome — whether she'll wait for the update or just walk. Either is fair.

One thing is clear. Both real fixes this app got today exist because one person wrote twice instead of deleting quietly. The dashboard was green both times. The streak kept climbing. Metrics told me how many users I had; only one angry customer told me what the app was actually doing.

Self-check — just three

  1. Is there a screen that can be saved twice in one day? Does it load existing values when it opens? If it doesn't, and it upserts or PUTs the whole record, you are erasing data right now.
  2. Can you tell "no value" from "0" on screen? Render with ?? 0, || 0, .get(k, 0) and destroyed data looks healthy.
  3. Open your last three support replies and attach a code line to every confident sentence. Any sentence you can't back is a guess you sent to a customer as fact.

Conclusion

The app bug took an hour to fix. What took longer was the habit of trusting that I knew what I knew.

Do exactly one thing now: search your support replies from this week for "by design," "intended behaviour," "that's how it works." Then verify one of those sentences against the code. I skipped that step, and told my only paying customer to leave. I'd like to hear how it went — especially if you checked and you were wrong.

Related