Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions src/Orbit.Api/Controllers/GamificationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Application.Gamification.Commands;
using Orbit.Application.Gamification.Queries;

namespace Orbit.Api.Controllers;
Expand Down Expand Up @@ -51,18 +50,4 @@ public async Task<IActionResult> GetStreakInfo(CancellationToken cancellationTok
? Ok(result.Value)
: BadRequest(new { error = result.Error });
}

[HttpPost("streak/freeze")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ActivateStreakFreeze(CancellationToken cancellationToken)
{
var command = new ActivateStreakFreezeCommand(HttpContext.GetUserId());
var result = await mediator.Send(command, cancellationToken);

return result.IsSuccess
? Ok(result.Value)
: BadRequest(new { error = result.Error });
}
}
2 changes: 1 addition & 1 deletion src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ public static WebApplicationBuilder AddOrbitAiServices(this WebApplicationBuilde
builder.Services.AddScoped<IAiTool, GetUserFactsTool>();
builder.Services.AddScoped<IAiTool, DeleteUserFactsTool>();
builder.Services.AddScoped<IAiTool, GetGamificationOverviewTool>();
builder.Services.AddScoped<IAiTool, ActivateStreakFreezeTool>();
builder.Services.AddScoped<IAiTool, GetReferralOverviewTool>();
builder.Services.AddScoped<IAiTool, GetSubscriptionOverviewTool>();
builder.Services.AddScoped<IAiTool, ManageSubscriptionTool>();
Expand Down Expand Up @@ -345,6 +344,7 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu
builder.Services.AddHostedService<SlipAlertSchedulerService>();
builder.Services.AddHostedService<AccountDeletionService>();
builder.Services.AddHostedService<HabitDueDateAdvancementService>();
builder.Services.AddHostedService<StreakFreezeAutoActivationService>();
builder.Services.AddHostedService<DataEncryptionMigrationService>();
builder.Services.AddHostedService<SyncCleanupService>();
builder.Services.AddHostedService<CalendarAutoSyncService>();
Expand Down
19 changes: 0 additions & 19 deletions src/Orbit.Api/Mcp/Tools/GamificationTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using System.Security.Claims;
using MediatR;
using ModelContextProtocol.Server;
using Orbit.Application.Gamification.Commands;
using Orbit.Application.Gamification.Queries;

namespace Orbit.Api.Mcp.Tools;
Expand Down Expand Up @@ -91,24 +90,6 @@ public async Task<string> GetStreakInfo(
(s.RecentFreezeDates.Count > 0 ? $"Recent freeze dates: {string.Join(", ", s.RecentFreezeDates)}" : "");
}

[McpServerTool(Name = "activate_streak_freeze"), Description("Activate a streak freeze to protect the current streak. Limited to 2 per month.")]
public async Task<string> ActivateStreakFreeze(
ClaimsPrincipal user,
CancellationToken cancellationToken = default)
{
var userId = GetUserId(user);
var command = new ActivateStreakFreezeCommand(userId);
var result = await mediator.Send(command, cancellationToken);

if (result.IsFailure)
return $"Error: {result.Error}";

var r = result.Value;
return $"Streak freeze activated for {r.FrozenDate}\n" +
$"Current streak preserved: {r.CurrentStreak} days\n" +
$"Freezes remaining this month: {r.FreezesRemainingThisMonth}";
}

private static Guid GetUserId(ClaimsPrincipal user)
{
var claim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
key: ai-memory
display_name: AI Memory
related_capabilities: [profile.ai-memory.write, user-facts.read]
related_surfaces: [ai-settings]
version: 1
derived_from:
- src/Orbit.Application/Profile/Commands/SetAiMemoryCommand.cs Handle
- src/Orbit.Application/UserFacts/Commands/CreateUserFactCommand.cs Handle
- src/Orbit.Application/Common/AppConstants.cs MaxUserFacts
---

# AI Memory

AI memory lets the assistant remember compact facts about you across conversations, so you don't have to repeat context every time. **AI memory is a Pro feature** — the toggle to turn it on requires Pro.

## How it works

When memory is on, the assistant can save short facts it learns about you and recall them in later chats. You control this with a single on/off toggle.

## Limits

- Saved facts are capped at **50** (`MaxUserFacts`). Once you reach the cap, you'll need to delete some before new ones can be added.
- **Duplicate facts are rejected** — if a fact with the same text already exists (ignoring case), it won't be saved again.

## Turning it off

Turning memory off stops new facts from being stored. It's the switch that controls whether the assistant is allowed to remember anything new.
26 changes: 26 additions & 0 deletions src/Orbit.Application/Chat/Content/FeatureExplanations/freezes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
key: freezes
display_name: Streak Freezes
related_capabilities: [gamification.read]
related_surfaces: [gamification]
version: 1
derived_from:
- src/Orbit.Domain/Entities/User.cs AwardStreakFreezeIfEligible
- src/Orbit.Domain/Entities/User.cs ApplyStreakFreeze
- src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs ProcessUserAsync
- src/Orbit.Application/Common/AppConstants.cs MaxStreakFreezesAccumulated
---

# Streak Freezes

A streak freeze protects your streak on a day you couldn't complete a habit. **Streak freezes are a Pro feature.**

## Earning freezes

You earn **1 freeze for every 7 streak-days** (`StreakDaysPerFreeze` = 7). You can bank up to **3** freezes at once (`MaxStreakFreezesAccumulated` = 3); once you're at the cap, new milestones don't add more until one is spent.

## How freezes are used

Freezes are **automatic** — there's nothing to tap. When you miss a day on your streak, a banked freeze is spent for you to bridge the gap, so the next completion continues the run instead of starting over. A freeze only **preserves** the streak across a missed day; it does not extend or increase it.
Comment thread
thomasluizon marked this conversation as resolved.

A freeze is spent automatically only when there's a streak worth protecting and you actually missed the day. It won't be used if your current streak is 0, if you already completed a habit that day, or if you've run out of banked freezes. At most **one** freeze is spent per day, and at most **3** are spent per calendar month (`MaxStreakFreezesPerMonth` = 3) — beyond that, a missed day breaks the streak as usual.
Comment thread
thomasluizon marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
key: frequencies
display_name: Habit Frequencies
related_capabilities: [habits.read, habits.write]
related_surfaces: [today]
version: 1
derived_from:
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsHabitDueOnDate
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetWindowStart
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetWindowEnd
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs GetRemainingCompletions
---

# Habit Frequencies

A habit's frequency decides which days it is due. Every recurring habit has a unit (Day, Week, Month, or Year), an interval quantity (how many of those units between occurrences), and an anchor date — the habit's due date — that the schedule aligns to.

## The frequency units

- **Daily** — due every day, or every N days when the interval is greater than 1 (for example, every 2 days). The interval is counted from the anchor date.
- **Weekly** — due on the same weekday as the anchor, every N weeks (for example, every 2 weeks on Monday).
- **Monthly** — due on the same day-of-month as the anchor, every N months.
- **Yearly** — due on the same month and day as the anchor, every N years.

The interval quantity is the "every N" part. With a quantity of 1 the habit is due every period; with a quantity of 2 it is due every other period, and so on. A habit is never due before its anchor date, and never after its end date if one is set.

## Specific weekdays

A habit can also restrict itself to specific weekdays. When weekdays are chosen, the habit is only due on a matching date if that date's weekday is in the list. This layers on top of the unit and interval.

## One-time tasks

A one-time task has no recurring unit. It is due on exactly one date — its due date — and nowhere else. Once completed, it stops appearing.

## Flexible habits

A flexible habit doesn't pin you to specific days. Instead it asks for **N completions per window**, where the window is one Day, one Week, one Month, or one Year. Weekly windows run Monday through Sunday (ISO week). Within a window you can log on any days you like until you hit the target.

Skips make flexible targets more forgiving: each skip in the window reduces the number of completions still required for that window. So if a weekly flexible habit wants 3 completions and you skip once, only 2 completions are needed that week.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
key: gamification
display_name: XP, Levels, and Achievements
related_capabilities: [gamification.read]
related_surfaces: [gamification]
version: 1
derived_from:
- src/Orbit.Application/Gamification/Services/GamificationService.cs ProcessHabitLogged
- src/Orbit.Application/Gamification/Services/GamificationService.cs ProcessGoalCompleted
- src/Orbit.Application/Gamification/LevelDefinitions.cs All
- src/Orbit.Application/Gamification/Services/GamificationService.cs CheckConsistencyAchievements
---

# XP, Levels, and Achievements

Gamification rewards consistency with experience points (XP), levels, and achievements. **All of gamification — XP, levels, and achievements — is a Pro feature.** On the free plan no XP is earned and no achievements unlock.

## Earning XP

- **Logging a habit** earns **10 + your current streak** XP. A habit logged on a 5-day streak gives 15 XP; the longer your streak, the more each completion is worth.
- **Completing a goal** earns **+100** XP.
- Unlocking an achievement also grants that achievement's own XP reward on top.

## Levels

Your total XP places you on a level from 1 to 10. The thresholds are:

| Level | Title | XP required |
|---|---|---|
| 1 | Starter | 0 |
| 2 | Explorer | 100 |
| 3 | Orbiter | 300 |
| 4 | Navigator | 600 |
| 5 | Pilot | 1000 |
| 6 | Captain | 1500 |
| 7 | Commander | 2500 |
| 8 | Admiral | 4000 |
| 9 | Elite | 6000 |
| 10 | Legend | 10000 |

Level 10 (Legend) is the top — there is no XP-to-next once you reach it.

## Achievements

Achievements unlock automatically as you hit milestones:

- **Consistency** — streaks of 7, 14, 30, 90, 100, and 365 days.
- **Volume** — 10, 50, 100, 500, and 1000 total completions.
- **Perfect runs** — Perfect Day (every scheduled habit done in a day), then Perfect Week (7 consecutive perfect days) and Perfect Month (30 consecutive perfect days).
- **Time of day** — Early Bird (complete a habit before 7am, 10 times) and Night Owl (after 10pm, 10 times).
- **Comeback** — return and log after 7+ days of inactivity.
- **Bad Habit Breaker** — resist a bad habit for 30 consecutive days.

There are also first-time achievements for creating your first habit and goal, and goal-completion tiers for completing 1, 5, and 10 goals.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
key: notifications
display_name: Reminders and Notifications
related_capabilities: [notifications.read, notifications.write]
related_surfaces: [notifications]
version: 1
derived_from:
- src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ProcessRelativeReminders
- src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ProcessScheduledReminders
- src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs ShouldSendScheduledReminder
---

# Reminders and Notifications

Reminders are sent by a background job that checks roughly **every minute**. There are two kinds, depending on whether a habit has a due time.

## Relative reminders (habits with a due time)

If a habit has a specific due **time**, you can set "X minutes before" reminders. The job fires each one when the current local time reaches that many minutes before the due time. A habit can have several relative reminders (for example, 30 minutes before and 10 minutes before).

## Scheduled reminders (habits without a due time)

If a habit has no due time, it uses **scheduled** reminders that fire at a time you pick, either:

- **same-day** — on the day the habit is due, or
- **day-before** — the day before it's due.

## When reminders fire

A reminder is only sent for a habit that is:

- not completed and not a general habit,
- has reminders enabled,
- is actually **due** that day, and
- has **not yet been logged** that day.

Each distinct reminder is sent **once** — once a given reminder has fired for a habit on a given day, it won't fire again, so you won't be nudged twice for the same thing.
40 changes: 40 additions & 0 deletions src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
key: paygate
display_name: Free vs Pro
related_capabilities: [subscriptions.read]
related_surfaces: [subscriptions]
version: 1
derived_from:
- src/Orbit.Application/Common/PayGateService.cs CanCreateHabits
- src/Orbit.Application/Common/PayGateService.cs CanSendAiMessage
- src/Orbit.Application/Common/PayGateService.cs CanUseRetrospective
- src/Orbit.Application/Common/AppConstants.cs DefaultFreeMaxHabits
---

# Free vs Pro

Orbit has a free plan and a Pro plan. The free plan is fully usable for daily habit tracking; Pro raises the limits and unlocks the advanced features.

## Limits on the free plan

- **Habits** are capped at **10** top-level habits. Sub-habits and soft-deleted habits don't count toward the cap. Pro removes the cap.
- **AI messages** are capped at **20** per month. Pro raises this to **500** per month.

Both plans can also earn a small bonus of extra AI messages from ad rewards, added on top of the plan limit.

## What Pro unlocks

Upgrading to Pro unlocks:

- Goals
- Sub-habits
- The daily AI summary
- AI memory
- Calendar integration
- Premium color schemes
- Streak freezes
- Gamification: XP, levels, and achievements

## The retrospective is yearly-only

The **retrospective** is the one feature that needs the **yearly** Pro plan specifically. A monthly Pro subscription does not include it; the yearly plan does.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
key: schedule-math
display_name: Schedule and Overdue Math
related_capabilities: [habits.read]
related_surfaces: [today]
version: 1
derived_from:
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsMonthlyMatch
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs IsYearlyMatch
- src/Orbit.Application/Habits/Services/HabitScheduleService.cs HasMissedPastOccurrence
- src/Orbit.Application/Common/AppConstants.cs DefaultOverdueWindowDays
---

# Schedule and Overdue Math

A few scheduling rules surprise people because the calendar isn't uniform. Here is exactly how Orbit handles the tricky cases.

## Monthly habits clamp to the last valid day

A monthly habit fires on the same day-of-month as its anchor (due) date. When a month is too short for that day, it clamps to the **last valid day** of that month instead of drifting. A habit anchored on the 31st fires on March 31 — never March 28 — and on the last day of shorter months. This keeps "the 31st" meaning the end of the month rather than slipping earlier permanently.

## Yearly leap-day habits

A yearly habit anchored on **February 29** fires on **February 28** in non-leap years, then returns to February 29 when a leap year comes around again.

## Intervals align off the anchor date

The "every N" interval (every 2 weeks, every 3 months, and so on) is measured from the anchor date, not from the current date. The anchor is the fixed reference point the whole schedule lines up against.

## Overdue is DueDate-authoritative

A habit is **overdue when its due date has fallen before today**. The due date rests on the oldest unresolved occurrence; logging or skipping advances it past today. This single signal — due date earlier than today — is what marks a recurring habit overdue everywhere in the app.

Bad habits are never overdue (there's no "must do" expectation to miss), and flexible habits use their window instead of an overdue date.

The default overdue lookback window is **7** days (`DefaultOverdueWindowDays`): the day view surfaces unresolved occurrences from up to a week back so a missed day doesn't silently disappear.
39 changes: 39 additions & 0 deletions src/Orbit.Application/Chat/Content/FeatureExplanations/streaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
key: streaks
display_name: Streaks
related_capabilities: [gamification.read]
related_surfaces: [gamification, today]
version: 1
derived_from:
- src/Orbit.Infrastructure/Services/UserStreakService.cs ComputeCurrentStreak
- src/Orbit.Infrastructure/Services/UserStreakService.cs LoadStreakDataAsync
- src/Orbit.Infrastructure/Services/UserStreakService.cs CalendarFallback
- src/Orbit.Application/Common/AppConstants.cs MaxStreakLookbackDays
---

# Streaks

Your streak counts how many consecutive **scheduled days** you stayed active. A scheduled day is a day where one of your recurring habits was due. The day counts toward the streak if either:

- you completed at least one eligible habit that day (a real completion, not a skip), or
- a streak freeze covered that day.

The streak is measured by walking backwards from today. If today has no completion yet, the count starts from yesterday so an unfinished today never breaks the run. Days where nothing was scheduled are simply skipped over — they neither extend nor break the streak. The first scheduled day you missed (no completion and no freeze) is where the streak stops.

## What counts as a completion

A completion is any log with a value greater than zero on a habit that is not deleted and not a bad habit. Skips (a zero value) do not count. Bad habits never **add** scheduled days to your streak, but completing a regular habit on the same day still counts normally — bad habits just don't create the "must do something today" expectation.

## Which habits create scheduled days

Expected (scheduled) days come only from your recurring habits that are not bad habits, not general habits, and not flexible habits. One-time tasks that you've already finished stop contributing expected days going forward. So a missed flexible-habit window or a skipped general habit will not break your streak.

## Brand-new users

If you have no recurring habits at all yet, the streak falls back to simple **calendar-day adjacency**: completing a habit on back-to-back calendar days builds the streak, so you aren't penalized before you've set up any schedule.

## Lookback limit

Streak calculation looks back at most **365** days (`MaxStreakLookbackDays`). Activity older than a year does not extend the current streak.

The longest streak is tracked separately by scanning your full scheduled-day history for the longest unbroken run; it never decreases when the current streak resets.
Loading
Loading