From 86842b538a34f2821f2ee3aeb3730acebbf1c860 Mon Sep 17 00:00:00 2001 From: Mac Date: Wed, 10 Jun 2026 10:57:20 -0600 Subject: [PATCH] feat(shorebird_code_push_protocol): add active-hours response types (#3812) --- cspell.config.yaml | 1 + .../lib/shorebird_code_push_protocol.dart | 2 + .../get_active_hours_response.dart | 110 ++++++++++++++++++ .../lib/src/models/active_hour_entry.dart | 67 +++++++++++ .../get_active_hours_response_test.dart | 34 ++++++ .../models/active_hour_entry_test.dart | 25 ++++ 6 files changed, 239 insertions(+) create mode 100644 packages/shorebird_code_push_protocol/lib/src/messages/get_active_hours/get_active_hours_response.dart create mode 100644 packages/shorebird_code_push_protocol/lib/src/models/active_hour_entry.dart create mode 100644 packages/shorebird_code_push_protocol/test/generated/messages/get_active_hours/get_active_hours_response_test.dart create mode 100644 packages/shorebird_code_push_protocol/test/generated/models/active_hour_entry_test.dart diff --git a/cspell.config.yaml b/cspell.config.yaml index f6da2e9a..d76e4faa 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -74,6 +74,7 @@ words: - LOCALAPPDATA - logcat - longpaths + - lookback # active-hours "best time to release" lookback window - lproj - madd # From ./packages/redis_client - mbps diff --git a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart index 791722bf..3f20f644 100644 --- a/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart +++ b/packages/shorebird_code_push_protocol/lib/shorebird_code_push_protocol.dart @@ -23,6 +23,7 @@ export 'package:shorebird_code_push_protocol/src/messages/create_release_artifac export 'package:shorebird_code_push_protocol/src/messages/create_release_artifact/create_release_artifact_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/create_user/create_user_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/error_response.dart'; +export 'package:shorebird_code_push_protocol/src/messages/get_active_hours/get_active_hours_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_apps/get_apps_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_gcp_download_speed_test_url/get_gcp_download_speed_test_url200_response.dart'; export 'package:shorebird_code_push_protocol/src/messages/get_gcp_upload_speed_test_url/get_gcp_upload_speed_test_url200_response.dart'; @@ -41,6 +42,7 @@ export 'package:shorebird_code_push_protocol/src/messages/promote_patch/promote_ export 'package:shorebird_code_push_protocol/src/messages/update_app_collaborator/update_app_collaborator_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/update_patch/update_patch_request.dart'; export 'package:shorebird_code_push_protocol/src/messages/update_release/update_release_request.dart'; +export 'package:shorebird_code_push_protocol/src/models/active_hour_entry.dart'; export 'package:shorebird_code_push_protocol/src/models/app.dart'; export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.dart'; export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart'; diff --git a/packages/shorebird_code_push_protocol/lib/src/messages/get_active_hours/get_active_hours_response.dart b/packages/shorebird_code_push_protocol/lib/src/messages/get_active_hours/get_active_hours_response.dart new file mode 100644 index 00000000..29944ada --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/messages/get_active_hours/get_active_hours_response.dart @@ -0,0 +1,110 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; +import 'package:shorebird_code_push_protocol/src/models/active_hour_entry.dart'; + +/// {@template get_active_hours_response} +/// The response body for GET /apps/{appId}/metrics/active-hours. Powers the +/// "best time to release" recommendation: the consecutive low-activity UTC +/// window when the app's users are least active. +/// {@endtemplate} +@immutable +class GetActiveHoursResponse { + /// {@macro get_active_hours_response} + const GetActiveHoursResponse({ + required this.hourly, + required this.recommendedWindowStartUtc, + required this.recommendedWindowLengthHours, + required this.busiestHourUtc, + required this.lookbackDays, + required this.asOf, + }); + + /// Converts a `Map` to a [GetActiveHoursResponse]. + factory GetActiveHoursResponse.fromJson(Map json) { + return parseFromJson( + 'GetActiveHoursResponse', + json, + () => GetActiveHoursResponse( + hourly: (json['hourly'] as List) + .map( + (e) => ActiveHourEntry.fromJson(e as Map), + ) + .toList(), + recommendedWindowStartUtc: + checkedKey(json, 'recommended_window_start_utc') as int?, + recommendedWindowLengthHours: + json['recommended_window_length_hours'] as int, + busiestHourUtc: checkedKey(json, 'busiest_hour_utc') as int?, + lookbackDays: json['lookback_days'] as int, + asOf: DateTime.parse(json['as_of'] as String), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static GetActiveHoursResponse? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return GetActiveHoursResponse.fromJson(json); + } + + /// One entry per UTC hour: 24 entries, hour_utc 0–23, zero-filled, + /// ordered by hour_utc ascending. + final List hourly; + + /// Start hour (UTC, 0–23) of the lowest-activity consecutive window — + /// the recommended time to release. The window wraps past midnight. + /// Null when there is insufficient data (fewer than 7 days). + final int? recommendedWindowStartUtc; + + /// Length of the recommended window in hours. Fixed at 2 in v1. + final int recommendedWindowLengthHours; + + /// UTC hour (0–23) with the highest average active devices, for + /// contrast in the recommendation copy. Null when insufficient data. + final int? busiestHourUtc; + + /// Number of days of history the profile is computed over. + final int lookbackDays; + + /// Server's UTC timestamp at the moment the response was constructed. + /// Not a freshness indicator for the underlying data, which is + /// refreshed by an hourly scheduled query and may lag by up to ~1 hour. + final DateTime asOf; + + /// Converts a [GetActiveHoursResponse] to a `Map`. + Map toJson() { + return { + 'hourly': hourly.map((e) => e.toJson()).toList(), + 'recommended_window_start_utc': recommendedWindowStartUtc, + 'recommended_window_length_hours': recommendedWindowLengthHours, + 'busiest_hour_utc': busiestHourUtc, + 'lookback_days': lookbackDays, + 'as_of': asOf.toIso8601String(), + }; + } + + @override + int get hashCode => Object.hashAll([ + listHash(hourly), + recommendedWindowStartUtc, + recommendedWindowLengthHours, + busiestHourUtc, + lookbackDays, + asOf, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GetActiveHoursResponse && + listsEqual(hourly, other.hourly) && + recommendedWindowStartUtc == other.recommendedWindowStartUtc && + recommendedWindowLengthHours == other.recommendedWindowLengthHours && + busiestHourUtc == other.busiestHourUtc && + lookbackDays == other.lookbackDays && + asOf == other.asOf; + } +} diff --git a/packages/shorebird_code_push_protocol/lib/src/models/active_hour_entry.dart b/packages/shorebird_code_push_protocol/lib/src/models/active_hour_entry.dart new file mode 100644 index 00000000..078db3ed --- /dev/null +++ b/packages/shorebird_code_push_protocol/lib/src/models/active_hour_entry.dart @@ -0,0 +1,67 @@ +import 'package:meta/meta.dart'; +import 'package:shorebird_code_push_protocol/model_helpers.dart'; + +/// {@template active_hour_entry} +/// Average number of distinct active devices during one UTC hour-of-day, +/// averaged across all days in the lookback window (days with no activity +/// in that hour count as zero). +/// {@endtemplate} +@immutable +class ActiveHourEntry { + /// {@macro active_hour_entry} + const ActiveHourEntry({ + required this.hourUtc, + required this.averageActiveDevices, + }); + + /// Converts a `Map` to an [ActiveHourEntry]. + factory ActiveHourEntry.fromJson(Map json) { + return parseFromJson( + 'ActiveHourEntry', + json, + () => ActiveHourEntry( + hourUtc: json['hour_utc'] as int, + averageActiveDevices: (json['average_active_devices'] as num) + .toDouble(), + ), + ); + } + + /// Convenience to create a nullable type from a nullable json object. + /// Useful when parsing optional fields. + static ActiveHourEntry? maybeFromJson(Map? json) { + if (json == null) { + return null; + } + return ActiveHourEntry.fromJson(json); + } + + /// Hour of day in UTC, 0–23. + final int hourUtc; + + /// Mean distinct active devices seen during this UTC hour, averaged + /// over the lookback window with implicit zeros included. + final double averageActiveDevices; + + /// Converts an [ActiveHourEntry] to a `Map`. + Map toJson() { + return { + 'hour_utc': hourUtc, + 'average_active_devices': averageActiveDevices, + }; + } + + @override + int get hashCode => Object.hashAll([ + hourUtc, + averageActiveDevices, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ActiveHourEntry && + hourUtc == other.hourUtc && + averageActiveDevices == other.averageActiveDevices; + } +} diff --git a/packages/shorebird_code_push_protocol/test/generated/messages/get_active_hours/get_active_hours_response_test.dart b/packages/shorebird_code_push_protocol/test/generated/messages/get_active_hours/get_active_hours_response_test.dart new file mode 100644 index 00000000..22541c80 --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/messages/get_active_hours/get_active_hours_response_test.dart @@ -0,0 +1,34 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('GetActiveHoursResponse', () { + test('round-trips via maybeFromJson/toJson', () { + final instance = GetActiveHoursResponse( + hourly: const [ + ActiveHourEntry(hourUtc: 0, averageActiveDevices: 0), + ], + recommendedWindowStartUtc: 0, + recommendedWindowLengthHours: 0, + busiestHourUtc: 0, + lookbackDays: 0, + asOf: DateTime.utc(2024), + ); + final parsed = GetActiveHoursResponse.maybeFromJson(instance.toJson())!; + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(GetActiveHoursResponse.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => GetActiveHoursResponse.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/shorebird_code_push_protocol/test/generated/models/active_hour_entry_test.dart b/packages/shorebird_code_push_protocol/test/generated/models/active_hour_entry_test.dart new file mode 100644 index 00000000..c9898fdc --- /dev/null +++ b/packages/shorebird_code_push_protocol/test/generated/models/active_hour_entry_test.dart @@ -0,0 +1,25 @@ +// GENERATED — do not hand-edit. +import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + group('ActiveHourEntry', () { + test('round-trips via maybeFromJson/toJson', () { + const instance = ActiveHourEntry(hourUtc: 0, averageActiveDevices: 0); + final parsed = ActiveHourEntry.maybeFromJson(instance.toJson())!; + expect(parsed, equals(instance)); + expect(parsed.hashCode, equals(instance.hashCode)); + }); + + test('maybeFromJson returns null on null input', () { + expect(ActiveHourEntry.maybeFromJson(null), isNull); + }); + + test('maybeFromJson throws FormatException on invalid input', () { + expect( + () => ActiveHourEntry.maybeFromJson({}), + throwsFormatException, + ); + }); + }); +}