diff --git a/CLAUDE.md b/CLAUDE.md index e3bd5d3..c94f485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -593,7 +593,9 @@ option via `optionSinceAppVersion`, e.g. `"1.2.0"`): tags when a filter or parameter shipped, purely so `WhatsNewService` can draw a "NEW" badge next to it — persisting across every launch of the current app version, and only advancing on the next real update, not clearing after one -run. **Stamp it in the same change that adds or modifies the property, using +run. A patch-only update (1.5.0 → 1.5.1) is not one: the baseline moves only +when major or minor changes, so a bugfix release keeps the feature release's +badges. **Stamp it in the same change that adds or modifies the property, using the version that change will actually ship in** (a minor bump on the latest public release, or whatever's already in `app/pubspec.yaml` if that's ahead) — not retroactively at release time, which is exactly how properties get diff --git a/app/lib/services/whats_new_service.dart b/app/lib/services/whats_new_service.dart index 9ce4193..90a9214 100644 --- a/app/lib/services/whats_new_service.dart +++ b/app/lib/services/whats_new_service.dart @@ -20,6 +20,12 @@ import '../models/filter_schema.dart'; /// is otherwise left untouched — which is what makes badges survive many /// launches of the same version instead of clearing after one. /// +/// A patch release (1.5.0 -> 1.5.1) is not an update for this purpose: the +/// baseline only moves when the major or minor version changes. A bugfix +/// release shipped days after 1.5.0 would otherwise clear 1.5.0's badges +/// before most users had seen them; instead 1.5.1 keeps showing everything +/// since the version before 1.5.0, plus anything stamped 1.5.1. +/// /// A fresh install (or an upgrade from a build that predates this tracking, /// which looks the same — no `lastRunAppVersion` stored yet) flags nothing as /// new: both markers are seeded to the current version, so there's no earlier @@ -54,6 +60,12 @@ class WhatsNewService { // Nothing to diff against yet — seed both markers to now. _lastSeenVersion = currentVersion; await prefs.setString(_baselineKey, currentVersion); + } else if (lastRunVersion != currentVersion && + _isPatchOnly(lastRunVersion, currentVersion)) { + // A bugfix release: keep the baseline the last feature release set, + // so its badges outlive the fix that followed it. + _lastSeenVersion = storedBaseline ?? lastRunVersion; + await prefs.setString(_baselineKey, _lastSeenVersion!); } else if (lastRunVersion != currentVersion) { // The version actually changed since the last launch: this is the // one moment the baseline moves, to whatever was running just @@ -149,6 +161,18 @@ class WhatsNewService { } } +/// Whether [from] -> [to] changes only the patch component (same major and +/// minor), the kind of update that leaves the badge baseline alone. +bool _isPatchOnly(String from, String to) { + List majorMinor(String v) { + final parts = v.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList(); + return [parts.isNotEmpty ? parts[0] : 0, parts.length > 1 ? parts[1] : 0]; + } + + final a = majorMinor(from), b = majorMinor(to); + return a[0] == b[0] && a[1] == b[1]; +} + /// Compare two dotted version strings numerically ("1.10.0" > "1.9.0"). /// Returns positive if [a] > [b], negative if [a] < [b], zero if equal. /// Non-numeric or differing-length components degrade to a component-wise diff --git a/app/test/whats_new_service_test.dart b/app/test/whats_new_service_test.dart index 5496966..6fbcbd7 100644 --- a/app/test/whats_new_service_test.dart +++ b/app/test/whats_new_service_test.dart @@ -108,6 +108,81 @@ void main() { expect(prefs.getString('lastRunAppVersion'), '1.3.0'); }); + test('a patch release keeps the previous feature release\'s badges', + () async { + // 1.4.2 -> 1.5.0 set the baseline to 1.4.2; now 1.5.1 ships a fix. + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.5.0', + 'lastSeenAppVersion': '1.4.2', + }); + setCurrentVersion('1.5.1'); + await service.initialize(); + + expect(service.isNew('1.5.0'), true, + reason: 'a bugfix release must not clear what 1.5.0 added'); + expect(service.isNew('1.5.1'), true, reason: 'anything the fix itself added'); + expect(service.isNew('1.4.2'), false); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('lastSeenAppVersion'), '1.4.2'); + expect(prefs.getString('lastRunAppVersion'), '1.5.1'); + }); + + test('several patch releases in a row keep the same baseline', () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.5.0', + 'lastSeenAppVersion': '1.4.2', + }); + for (final version in ['1.5.1', '1.5.2', '1.5.10']) { + service.resetForTesting(); + setCurrentVersion(version); + await service.initialize(); + expect(service.isNew('1.5.0'), true, reason: 'still new on $version'); + } + }); + + test('the next feature release after patches advances past all of them', + () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.5.2', + 'lastSeenAppVersion': '1.4.2', + }); + setCurrentVersion('1.6.0'); + await service.initialize(); + + expect(service.isNew('1.5.0'), false); + expect(service.isNew('1.5.2'), false); + expect(service.isNew('1.6.0'), true); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('lastSeenAppVersion'), '1.5.2'); + }); + + test('a major release with the same minor number still advances', () async { + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.5.1', + 'lastSeenAppVersion': '1.4.0', + }); + setCurrentVersion('2.5.0'); + await service.initialize(); + + expect(service.isNew('1.5.0'), false); + expect(service.isNew('2.5.0'), true); + }); + + test('jumping straight to a patch of a new feature release still badges it', + () async { + // Skipped 1.5.0 entirely: 1.4.2 -> 1.5.1 crosses a minor boundary. + SharedPreferences.setMockInitialValues({ + 'lastRunAppVersion': '1.4.2', + 'lastSeenAppVersion': '1.3.0', + }); + setCurrentVersion('1.5.1'); + await service.initialize(); + + expect(service.isNew('1.5.0'), true); + expect(service.isNew('1.4.2'), false); + }); + test('untagged (null) is never new', () async { SharedPreferences.setMockInitialValues({ 'lastRunAppVersion': '1.1.0', diff --git a/docs/FILTER_SCHEMA.md b/docs/FILTER_SCHEMA.md index de227fb..2bfcb9d 100644 --- a/docs/FILTER_SCHEMA.md +++ b/docs/FILTER_SCHEMA.md @@ -286,6 +286,11 @@ schemas; extend it when stamping a release's additions. just happened), then holds steady through as many future launches as the user stays on that version. There's no "mark as seen" action to wire up, and no badge that silently disappears after someone glances at the app once. +- **A patch release doesn't count as an update.** The baseline moves only + when the major or minor version changes, so 1.5.0 → 1.5.1 keeps every badge + 1.5.0 showed (and adds anything stamped `1.5.1`); the next feature release, + 1.6.0, then clears them all. A bugfix shipped days after a feature release + would otherwise wipe its badges before most users saw them. - **A fresh install sees no badges at all.** There's no "since you last updated" for someone who has never had a previous version, so a first launch would otherwise badge the entire app.