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
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ derived_from:

# Streak Freezes

A streak freeze protects your streak on a day you couldn't complete a habit. **Streak freezes are a Pro feature.**
A streak freeze protects your streak on a day you couldn't complete a habit. Streak freezes are part of Orbit Pro. Some free accounts have them too.

## 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.
You earn **1 freeze for every 7 streak-days**. You can bank up to **3** freezes at once; once you're at the cap, new milestones don't add more until one is spent.

## How freezes are used

Orbit automatically spends one banked freeze when an eligible scheduled day is missed. The covered date and remaining bank are reported so the spend is visible. A freeze only **preserves** the streak across the missed day; it does not extend or increase it.

If automatic coverage did not cover a gap, a manual repair can be offered for local yesterday when it was scheduled, remains incomplete and unfrozen, and covering it restores a streak that would otherwise be broken. At most **one** freeze is spent per day, and at most **3** are spent per calendar month (`MaxStreakFreezesPerMonth` = 3). Logging the covered day later does not refund the freeze.
If automatic coverage did not cover a gap, a manual repair can be offered for local yesterday when it was scheduled, remains incomplete and unfrozen, and covering it restores a streak that would otherwise be broken. At most **one** freeze is spent per day, and at most **3** are spent per calendar month. Logging the covered day later does not refund the freeze.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ derived_from:

# XP, Levels, and Achievements

Gamification rewards consistency with experience points (XP), levels, and achievements. Achievements are available on both free and Pro plans.
Gamification rewards consistency with experience points (XP), levels, and achievements. XP, levels, and achievements are part of Orbit Pro. Some free accounts have them too.

## Earning XP

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ Orbit has a free plan and a Pro plan. The free plan is fully usable for daily ha

Upgrading to Pro unlocks:

- AI goal reviews
- Sub-habits
- The daily AI summary
- Calendar integration
- Streak freezes
- Gamification: XP, levels, and achievements
- Retrospectives
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ A yearly habit anchored on **February 29** fires on **February 28** in non-leap

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
## When a habit is overdue

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.
The default overdue lookback window is **7** days: the day view surfaces unresolved occurrences from up to a week back so a missed day doesn't silently disappear.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ If you have no recurring habits at all yet, the streak falls back to simple **ca

## Lookback limit

Streak calculation looks back at most **365** days (`MaxStreakLookbackDays`). Activity older than a year does not extend the current streak.
Streak calculation looks back at most **365** days. 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.
89 changes: 89 additions & 0 deletions tests/Orbit.Application.Tests/Chat/FeatureExplanationDriftTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
using System.Globalization;
using System.Reflection;
using FluentAssertions;
using FluentAssertions.Execution;
using NSubstitute;
using Orbit.Application.Chat.FeatureExplanations;
using Orbit.Application.Common;
using Orbit.Application.Gamification;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;

namespace Orbit.Application.Tests.Chat;

Expand All @@ -23,6 +28,26 @@ private string Body(string key)

private static string N(int value) => value.ToString(CultureInfo.InvariantCulture);

[Fact]
public void AllExplanations_UseProductLanguageWithoutInternalIdentifiers()
{
var internalKeys = new[] { typeof(FeatureFlagKeys), typeof(AppConfigKeys) }
.SelectMany(type => type.GetFields(BindingFlags.Public | BindingFlags.Static))
.Where(field => field.IsLiteral && field.FieldType == typeof(string))
.Select(field => (string)field.GetRawConstantValue()!);

using var scope = new AssertionScope();
foreach (var key in _service.Keys)
{
var body = Body(key);
body.Should().NotMatchRegex(@"`[^`\r\n]+`",
$"{key}.md must explain the product without backticked identifiers");
foreach (var internalKey in internalKeys)
body.Should().NotContain(internalKey,
$"{key}.md must not expose an internal flag or configuration key");
}
}

[Fact]
public void Freezes_MatchesStreakFreezeConstants()
{
Expand Down Expand Up @@ -63,6 +88,70 @@ public void Paygate_MatchesPlanLimitConstants()
body.Should().Contain(N(AppConstants.DefaultProAiMessages));
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Paygate_QuotaClaimMatchesEnforcedDailyWindow(bool pro)
{
var user = User.Create("Quota reader", "quota@example.com").Value;
user.StartTrial(DateTime.UtcNow.AddDays(-1));
if (pro)
user.GrantLifetimePro();
var today = new DateOnly(2026, 8, 5);
var users = Substitute.For<IGenericRepository<User>>();
users.GetByIdAsync(user.Id, Arg.Any<CancellationToken>()).Returns(user);
var dates = Substitute.For<IUserDateService>();
dates.GetUserTodayAsync(user.Id, Arg.Any<CancellationToken>()).Returns(today);
var config = Substitute.For<IAppConfigService>();
config.GetAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call => call.ArgAt<int>(1));
var gate = new PayGateService(Substitute.For<IGenericRepository<Habit>>(), users, config, dates);
var limit = pro ? AppConstants.DefaultProAiMessages : AppConstants.DefaultFreeAiMessages;

for (var count = 0; count < limit; count++)
{
(await gate.CanSendAiMessage(user.Id)).IsSuccess.Should().BeTrue();
user.IncrementAiMessageCount(today);
}

(await gate.CanSendAiMessage(user.Id)).IsFailure.Should().BeTrue();
dates.GetUserTodayAsync(user.Id, Arg.Any<CancellationToken>()).Returns(today.AddDays(1));
(await gate.CanSendAiMessage(user.Id)).IsSuccess.Should().BeTrue();
user.IncrementAiMessageCount(today.AddDays(1));
user.AiMessagesUsedToday.Should().Be(1);
Body("paygate").Should().Contain(
$"**AI messages** are capped at **{N(AppConstants.DefaultFreeAiMessages)}** per day. Pro raises this to **{N(AppConstants.DefaultProAiMessages)}** per day.",
"paygate.md must describe the enforced daily quota and its plan limits");
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Paygate_ProUnlocksMatchEnforcedGates(bool pro)
{
var user = User.Create("Plan reader", "plan@example.com").Value;
user.StartTrial(DateTime.UtcNow.AddDays(-1));
if (pro)
user.GrantLifetimePro();
var users = Substitute.For<IGenericRepository<User>>();
users.GetByIdAsync(user.Id, Arg.Any<CancellationToken>()).Returns(user);
var config = Substitute.For<IAppConfigService>();
config.GetAsync(Arg.Any<string>(), true, Arg.Any<CancellationToken>()).Returns(true);
var gate = new PayGateService(Substitute.For<IGenericRepository<Habit>>(), users, config,
Substitute.For<IUserDateService>());

(await gate.CanCreateSubHabits(user.Id)).IsSuccess.Should().Be(pro);
(await gate.CanUseDailySummary(user.Id)).IsSuccess.Should().Be(pro);
(await gate.CanAccessCalendar(user.Id)).IsSuccess.Should().Be(pro);
(await gate.CanUseRetrospective(user.Id)).IsSuccess.Should().Be(pro);

var unlocks = Body("paygate").Split("## What Pro unlocks")[1]
.Split('\n').Where(line => line.StartsWith("- ", StringComparison.Ordinal));
unlocks.Should().BeEquivalentTo(
["- Sub-habits", "- The daily AI summary", "- Calendar integration", "- Retrospectives"],
"paygate.md must list the features these default plan gates enforce");
}

[Fact]
public void ScheduleMath_MatchesOverdueWindowConstant()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using Orbit.Application.Chat.FeatureExplanations;
using Orbit.Application.Common;
using Orbit.Application.Goals.Queries;
using Orbit.Application.Goals.Services;
Expand Down Expand Up @@ -210,6 +211,9 @@ public async Task Handle_FreeUser_GeneratesReview()

result.IsSuccess.Should().BeTrue();
result.Value.Review.Should().Be("Review content");
await _payGate.Received(1).TryConsumeAiMessage(UserId, _unitOfWork, Arg.Any<CancellationToken>());
new FeatureExplanationService().Get("paygate")!.Body.Split("## What Pro unlocks")[1].Should()
.NotContain("AI goal reviews", "paygate.md must reflect goal reviews being available within the AI quota");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using NSubstitute.ExceptionExtensions;
using Npgsql;
using Orbit.Application.Common;
using Orbit.Application.Chat.FeatureExplanations;
using Orbit.Application.Tests.Notifications;
using Orbit.Application.Gamification;
using Orbit.Application.Gamification.Models;
Expand Down Expand Up @@ -1341,6 +1342,14 @@ await _achievementRepo.Received(1).AddAsync(
Arg.Is<UserAchievement>(a => a.AchievementId == AchievementDefinitions.Liftoff),
Arg.Any<CancellationToken>());
await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());

user.HasProAccess.Should().BeFalse();
var features = new FeatureExplanationService();
features.Get("gamification")!.Body.Should().Contain(
"XP, levels, and achievements are part of Orbit Pro. Some free accounts have them too.",
"gamification.md must describe the access condition that allowed this free user to earn XP and an achievement");
features.Get("paygate")!.Body.Split("## What Pro unlocks")[1].Should()
.NotContain("Gamification", "paygate.md must not sell this free user's XP, levels, or achievements as Pro-only");
}

[Fact]
Expand Down
33 changes: 33 additions & 0 deletions tests/Orbit.Infrastructure.Tests/Mcp/FeatureToolsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Security.Claims;
using System.Text.Json;
using FluentAssertions;
using Orbit.Api.Mcp.Tools;
using Orbit.Application.Chat.FeatureExplanations;
using Orbit.Application.Chat.Tools.Implementations;

namespace Orbit.Infrastructure.Tests.Mcp;

public class FeatureToolsTests
{
[Theory]
[InlineData("freezes", "Streak freezes are part of Orbit Pro. Some free accounts have them too.")]
[InlineData("gamification", "XP, levels, and achievements are part of Orbit Pro. Some free accounts have them too.")]
[InlineData("paygate", "**AI messages** are capped at **5** per day. Pro raises this to **50** per day.")]
public async Task DescribeFeature_ChatAndMcpServeTheSameEmbeddedBody(string key, string expectedClaim)
{
IFeatureExplanationService features = new FeatureExplanationService();

Check warning on line 18 in tests/Orbit.Infrastructure.Tests/Mcp/FeatureToolsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change type of variable 'features' from 'Orbit.Application.Chat.FeatureExplanations.IFeatureExplanationService' to 'Orbit.Application.Chat.FeatureExplanations.FeatureExplanationService' for improved performance

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AaFWEUVZtW_ns2OlD81P&open=AaFWEUVZtW_ns2OlD81P&pullRequest=797
var explanation = features.Get(key);
explanation.Should().NotBeNull();
var args = JsonSerializer.SerializeToElement(new { feature_key = key });

var chat = await new DescribeFeatureTool(features).ExecuteAsync(args, Guid.NewGuid(), CancellationToken.None);
var mcp = new FeatureTools(features).DescribeFeature(new ClaimsPrincipal(), key);

chat.Success.Should().BeTrue();
var chatBody = JsonSerializer.SerializeToElement(chat.Payload).GetProperty("markdown").GetString();
chatBody.Should().Be(explanation!.Body);
chatBody.Should().Contain(expectedClaim);
mcp.Should().Be($"# {explanation.DisplayName}\n\n{chatBody}");
mcp.Should().Contain(expectedClaim);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Orbit.Application.Chat.FeatureExplanations;
using Orbit.Application.Notifications;
using Orbit.Domain.Entities;
using Orbit.Domain.Enums;
Expand Down Expand Up @@ -48,6 +49,14 @@ public async Task ActivateMissedDayFreezes_FreeTierFlagOnlyCoversTargetedAccount

(await dbContext.StreakFreezes.SingleAsync()).UserId.Should().Be(targeted.Id);
other.StreakFreezesAccumulated.Should().Be(1);
targeted.HasProAccess.Should().BeFalse();
var features = new FeatureExplanationService();
features.Get("freezes")!.Body.Should().Contain(
"Streak freezes are part of Orbit Pro. Some free accounts have them too.",
"freezes.md must describe the access condition that allowed this free user's automatic freeze");
features.Get("freezes")!.Body.Should().NotContain("Streak freezes are a Pro feature");
features.Get("paygate")!.Body.Split("## What Pro unlocks")[1].Should()
.NotContain("Streak freezes", "paygate.md must not list this free user's freeze as a Pro unlock");
}

[Fact]
Expand Down
Loading