Gamification

Everything in Beyou's gamification serves one behavior: showing up daily. This document explains the exact mechanics, formula by formula, including where the numbers come from and what deliberately does not exist.

The check-in formula

One static calculator produces every earned XP amount:

xp = round( 5 × (difficulty + importance) × (1 + min(streak × 1%, 50%)) )

Difficulty and importance are clamped to 1 through 5 (a task may leave either unset; a missing value counts as 1), so the base range is 10 to 50, and the streak bonus (one percent per consecutive day, capped at fifty percent after day 50) stretches the ceiling to 75. The streak used is the one standing before today's check, so a check never feeds its own multiplier. An earlier formula multiplied difficulty by importance and swung 10 to 250; the additive version keeps a hard habit worth five times a trivial one instead of twenty-five.

The amount fans out whole, not split:

flowchart LR
  CHK["✅ Check a habit"] --> CALC["🎮 5 × (diff + imp) × streak bonus"]
  CALC --> U["👤 User XP"]
  CALC --> R["📋 Routine XP"]
  CALC --> H["💪 Habit XP"]
  CALC --> CATS["📂 Every linked category"]
  CHK --> LEDGER["📅 Signed daily ledger<br/>EntityXpDay + EntityCheckDay"]

Tasks play by slightly different rules: a task carries no XP of its own, and a task with no categories earns nothing at all, since there would be nobody to pay besides the user and routine and the design routes task credit through life areas. One-time tasks never build streaks and never write history rows; they are checked once and scheduled for cleanup.

Unchecking removes exactly the stored generated amount rather than recomputing, so tuning the constants later can never desync a removal from its original award.

Levels

The curve is one line: reaching level L costs round(50 × L²) XP, levels 0 through 100. Level 2 costs 200, level 10 costs 5,000, level 100 sits at 500,000. Early levels are hours apart, late ones are seasons apart. Four things level independently on this curve: the user, each category, each habit, and each routine.

New habits and categories can start with a head start: the experience question at creation seeds BEGINNER at level 0, INTERMEDIARY at level 5, ADVANCED at level 8, so someone who already runs marathons does not grind a running habit from zero.

Streaks

Streak logic follows one philosophy: only a real miss breaks a streak. Every day gets an outcome, and the outcomes are not symmetric:

Outcome Effect on a streak
DONE Counts and continues
SKIPPED Continues without counting: an honest "not today" is not a failure
NOT_SCHEDULED / NOT_IN_ROUTINE Continues without counting
MISSED Breaks

Nothing increments a counter. Streaks are re-derived by walking the daily outcome rows backwards from the owner's own today, which is what made the old fragile counters retireable: a derived streak cannot drift.

Two parallel systems share that walk. Each habit, task, and routine has its own streak (current, best, total check-ins) stored as scalars and recomputed on every change, with the best-streak as a ratchet that only rises. The account streak, the "constance" on the dashboard, walks the user's completed days against what was scheduled, and the user chooses what "completed" means: ANY (checking anything today counts) or COMPLETE (every item in the day's routine checked or skipped). Both readings work the same on a list routine as on a sectioned one: a list stores its items in a single internal section, so the walk that counts them never had to learn a second shape.

The day-close job is what turns absence into outcomes. An hourly scheduler, working per timezone, closes yesterday during a grace window in the small hours (a window rather than an exact hour, because daylight-saving jumps once skipped the closing hour entirely and left a day permanently open). It writes one row for each owner that has none, insert-only, so a real check landing during the race can never be overwritten by an absence. Routines are deliberately excluded from day-close: no presence writer exists for them, so every routine row would be a false miss.

That window used to be the only chance a day got. It comes round once per local day, and if the service was not running when it arrived, nothing ever went back for it: the snapshot returned on the next boot, the outcome rows did not, and the account streak stopped at the day before while the check-ins the user really made sat there uncounted. A thirteen-hour host shutdown in September 2026 cost every account in Europe/Lisbon, Europe/London and UTC exactly one day that way, while Sao Paulo came through untouched because its window happened to fall after the machine was back. Startup now sweeps the same seven days the snapshot backfill walks and closes any of them whose grace hour has passed and which carry no account-level row. It is safe to repeat, because closing a day is a diff against what is already recorded and an insert that does nothing on conflict, and it cannot invent a failure, because every owner is floored at its own creation date. A day whose grace hour has not arrived yet is left alone, since the user may still be checking into it.

Dormancy softens long gaps: a streak with nothing scheduled or completed for 14 days reports as dormant rather than broken, and the UI shows a pause instead of a zero. Checking anything clears it instantly.

Which day a check belongs to

Every number above depends on a question the code has to answer before it can do anything: what day is it, for this person? One resolver answers it, UserDateResolver, and eleven call sites go through it — the check paths, the streak walk, the XP ledger, the snapshot hour, the day-close hour. It reads the timezone stored on the account, never the server's.

That makes the stored zone the single most load-bearing string on a user row. Get it wrong and the day boundary sits in the wrong place, so an evening check-in is filed under tomorrow, the snapshot photographs a day that is still running, and the day-close stamps a miss on a day nobody missed. None of it is recoverable afterwards: entity_check_day and entity_xp_day carry a date and no clock time, so a row written on the wrong day is indistinguishable from one written correctly, and the day-close is insert-only by design.

For a long time nothing set it. The column defaulted to UTC and no signup path overrode it, so every account ran on the UTC calendar wherever its owner was. The obstacle to fixing that was not detection, which is a one-line browser call, but consent: UTC was simultaneously the default and a perfectly valid answer, so nothing could tell an untouched account from someone who had chosen UTC on purpose, and a blind correction would have overwritten the second group.

timezone_source separates the two, and each value carries a different permission:

Value Meaning – What may change it
DEFAULT – nobody has ever answered A client-detected zone adopts over it, silently, once
DETECTED – a client reported the device's zone Nothing automatic. A mismatch surfaces as a suggestion
EXPLICIT – a person picked it Nothing but another explicit pick

DETECTED is deliberately not re-adopted. A laptop opened in another country would otherwise move a travelling user's day boundary under them, and every date that account has ever written is resolved against that boundary. Offering it as a question is the honest form; deciding it is not.

The rule lives on the server, in the one method that writes the column, rather than in the two clients. A buggy client cannot overwrite a real answer that way, and web and mobile cannot drift into disagreeing about when a day starts.

Signup now carries the detected zone on all four paths, so new accounts are right from creation. Accounts that predate that are corrected on their next boot, once, while they are still DEFAULT.

Late check-ins and decay

Past days live in routine snapshots, and checking one still pays, but through a decay chosen by the user:

Strategy Multiplier
GRADUAL 0.8 one day late, then 0.6, 0.4, 0.2 from day four on
FLAT 0.5 regardless of delay
TIME_WINDOW Full XP up to two days late, nothing after

Without somewhere to say so, the decay is something a user only discovers by noticing a smaller number than usual. The Daily Briefing states it before the tap: each open item on the new-day dialog carries what checking it right now would actually pay, computed from the same calculator and the same clock the check path reads, so the advertised figure and the paid one cannot drift. An integration test runs both and compares them.

Late checks never receive a streak bonus (the streak was already broken by the miss), and difficulty and importance come frozen from the snapshot, not from the habit as it is today. What a late check does do is repair history: the missed day flips to DONE and the streak walk reconnects across it, so two five-day runs separated by one repaired miss become an eleven-day streak, a behavior pinned by tests. Unchecking flips the day back and leaves the best-streak record standing. When the original habit or routine has since been deleted, the XP still pays out in shrinking tiers: full distribution, then user plus routine, then user alone.

Goals

Goals pay once, through the explicit complete endpoint, calculated from target size, difficulty, urgency, and finishing before the deadline (the full factor table lives in the domain model topic). The reward goes to the user and the goal's categories; goals themselves have no level. Completing is a toggle: un-completing takes the XP back and returns the goal to in-progress. Progress updates pay nothing, which is what makes a fake pre-completed goal worthless. Nested goals keep the same rule at every level: a sub-goal pays when it is completed, its parent pays when it is completed, and there is no bonus for finishing a whole tree, for the same reason the pomodoro pays nothing on top of the check it wraps. The only thing a sub-goal does to its parent is start it: the first increment on a child moves a NOT_STARTED parent to IN_PROGRESS.

Calendar tasks

A calendar task pays what a task check pays at the lowest priority rung, with no streak bonus: 5 × (1 + 1), so 10 XP. That is what an unrated one-time routine task earns too, since anything the app calls a task should pay the same way. The XP goes to the user only. A calendar task has no category, habit or routine to share it with, and it lands in the daily ledger like every other payment.

Completing is a toggle, and the amount paid is stored on the task. Un-completing takes back exactly that, so a change to the formula between the two can never leave XP behind or take back more than was given. Deleting a completed task keeps what it paid: the task was done, and removing it afterwards is tidying up. Calendar tasks write no outcome rows, build no streak of their own and do not count toward the account streak. They happen once, on one date, and a streak needs something that repeats.

The study notebook

The notebook pays three fixed amounts, all held in NotebookRewards, and none of them depends on difficulty, importance or a streak:

Event XP Paid once by
A page reaches DONE for the first time 15 done_xp_at on the page, set once and never cleared
A quiz passed for the first time (70% or better) 20 passed_at on the quiz
A reviewed flashcard 1, at most 30 a day xp_paid on the review row, collected when the session ends

The XP goes to the user and to the category on the page's topic, and a topic with no category pays the user only. It lands in the same ledger as every other payment. These are one-way. Marking a page undone takes nothing back, and marking it done again pays nothing, because the column that guards the payment is never cleared. A status that paid on every transition would be an XP button. Finishing a page's last node finishes the page holding the board, and each page pays its own 15 in that one call, which is the only place the notebook pays for a whole tree.

Reviews are counted per day in the user's timezone. Past the cap of 30, reviewing still moves each card's schedule and pays nothing. A day that ends without the session being collected stays unpaid, and there is no back pay. The review streak the notebook shows counts days with a review; it is its own number, feeds no multiplier, and pays nothing.

The ledger

Every XP movement, in both directions, writes a signed delta into a per-owner per-day ledger, upserted with in-database addition so concurrent check-ins queue instead of overwriting. Summing an owner's rows reproduces its total exactly; unchecking makes the day give the XP back rather than remembering a high-water mark. The ledger feeds the XP history endpoint, which returns dense series (a value for every day of the window, zeros included) for the dashboard's charts. The parallel outcome table does the same for checks. Both tables use owner references without foreign keys on purpose: history must outlive whatever produced it.

The celebration layer

The frontends detect moments by comparison, not by backend flags: a check response carries fresh totals, the client compares against the previous level and streak, and a crossing pushes a celebration into a queue. Level-ups celebrate any rise; streak milestones celebrate crossing 7, 14, 21, 30, 60, 90, or 100 days, lowest crossed first. The "+N XP" float over a checked item shows the real generated amount, decay and all.

What deliberately does not exist

No daily completion bonus, no routine-completion payout, no weekly recap XP. The five XP paths with reversals (habit check, task check, snapshot check, goal completion, calendar task completion) and the notebook's three pay-once rewards are the whole economy. Skips cannot be placed in the future, since an unbounded forward skip would make a streak unbreakable.

Recording a mood earns nothing either, and that one is a product decision rather than an omission. Paying for a feeling turns a five-point scale into something to farm, and a farmed scale stops describing anything, which would cost the diary the only thing it is for. The days-in-a-row count the diary shows is derived in the client from the entries themselves. It is not a streak in this system, it feeds no multiplier, and nothing pays it out.

A note on the Ideas board pays nothing as well. It is an inbox entry, and the board returns no RefreshUiDTO. Turning a note into a task pays nothing at that moment either; the task earns XP the usual way, when somebody checks it in a routine.