From b3d49045dea24c73be1512fcfab152e839b720c0 Mon Sep 17 00:00:00 2001 From: Abhijeet Sanjiv Bonde Date: Thu, 4 Jun 2026 15:36:09 -0400 Subject: [PATCH] feat: user upload heatmap (#28593) * Feat - Heatmap * Implemented Comments to prettify and code cleanup * fixing code to pass cases. * fixing errors for OpenAPI Clients * Improving the code. * Fix code * Rerun generated client check * Rerun generated client * feat: command for user pages (#28554) * fix(web): timeline stuttering with many assets in 1 day (#28509) * fix(web): timeline stuttering with many assets in 1 day * cache isInOrNearViewport per day * skip inOrNearViewport check on first run * chore(ml): allow insightface 1.x (#28595) * chore(ml): allow insightface 1.x The new insightface 1.0 release appears to have no breaking code changes nor relevant license changes ([before](https://github.com/deepinsight/insightface/blob/2a78baec428354883e0cda39c54b555a5ed8358a/README.md), [after](https://github.com/deepinsight/insightface/blob/70f3269ea628d0658c5723976944c9de414e96f8/README.md), c.f. https://github.com/immich-app/immich/blob/fd7ddfef54cdf2b6256c4fc08bc5ff3f86176775/machine-learning/README.md), and it works on my machine. * Update uv.lock * please excuse my incompetence * Triggering the actions. * bad merge * Fix code * Code clear * Resolve conflict * Resolve conflict * Resolve conflict * Resolve errors * Resolve errors * Resolve errors more * chore: clean up --------- Co-authored-by: Alex Co-authored-by: Ben Beckford Co-authored-by: Aaron Liu Co-authored-by: Jason Rasmussen --- i18n/en.json | 5 + mobile/openapi/README.md | 5 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api/users_admin_api.dart | 84 +++++++ mobile/openapi/lib/api/users_api.dart | 79 ++++++ mobile/openapi/lib/api_client.dart | 6 + mobile/openapi/lib/api_helper.dart | 3 + .../model/calendar_heatmap_response_dto.dart | 129 ++++++++++ ...dar_heatmap_response_dto_series_inner.dart | 112 +++++++++ .../lib/model/calendar_heatmap_type.dart | 85 +++++++ mobile/test/openapi_patches_coverage.dart | 6 + open-api/immich-openapi-specs.json | 228 ++++++++++++++++++ packages/sdk/src/fetch-client.ts | 57 +++++ .../src/controllers/user-admin.controller.ts | 16 ++ server/src/controllers/user.controller.ts | 13 + server/src/dtos/calendar-heatmap.dto.ts | 36 +++ server/src/enum.ts | 5 + server/src/queries/asset.repository.sql | 16 ++ server/src/repositories/asset.repository.ts | 36 ++- server/src/services/shared/user-methods.ts | 36 +++ server/src/services/user-admin.service.ts | 7 + server/src/services/user.service.ts | 6 + server/src/utils/database.ts | 4 +- .../repositories/asset.repository.mock.ts | 1 + web/src/lib/components/AdminCard.svelte | 5 +- web/src/lib/components/CalendarHeatmap.svelte | 105 ++++++++ web/src/lib/elements/Skeleton.svelte | 7 +- web/src/lib/index.ts | 8 + web/src/lib/utils/date-time.ts | 9 + .../user-settings/UserUsageStatistic.svelte | 31 +++ .../routes/admin/users/[id]/+layout.svelte | 24 +- 31 files changed, 1159 insertions(+), 8 deletions(-) create mode 100644 mobile/openapi/lib/model/calendar_heatmap_response_dto.dart create mode 100644 mobile/openapi/lib/model/calendar_heatmap_response_dto_series_inner.dart create mode 100644 mobile/openapi/lib/model/calendar_heatmap_type.dart create mode 100644 server/src/dtos/calendar-heatmap.dto.ts create mode 100644 server/src/services/shared/user-methods.ts create mode 100644 web/src/lib/components/CalendarHeatmap.svelte diff --git a/i18n/en.json b/i18n/en.json index adf73aac01..2e6b7ba492 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -570,6 +570,7 @@ "asset_added_to_album": "Added to album", "asset_adding_to_album": "Adding to album…", "asset_created": "Asset created", + "asset_day_count": "{date}: {count, plural, one {# asset} other {# assets}}", "asset_description_updated": "Asset description has been updated", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", @@ -1400,6 +1401,7 @@ "leave": "Leave", "leave_album": "Leave album", "lens_model": "Lens model", + "less": "Less", "let_others_respond": "Let others respond", "level": "Level", "library": "Library", @@ -2412,6 +2414,7 @@ "updated_password": "Updated password", "upload": "Upload", "upload_concurrency": "Upload concurrency", + "upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", @@ -2427,6 +2430,8 @@ "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", "uploading_media": "Uploading media", + "uploads": "Uploads", + "uploads_count": "{count, plural, one {# upload} other {# uploads}}", "url": "URL", "usage": "Usage", "use_biometric": "Use biometric", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index af40910b4d..55db46740f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -293,6 +293,7 @@ Class | Method | HTTP request | Description *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | Delete user profile image *UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key *UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | Delete user onboarding +*UsersApi* | [**getMyCalendarHeatmap**](doc//UsersApi.md#getmycalendarheatmap) | **GET** /users/me/calendar-heatmap | Retrieve calendar heatmap activity *UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences *UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | Get current user *UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | Retrieve user profile image @@ -307,6 +308,7 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | Create a user *UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | Delete a user *UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | Retrieve a user +*UsersAdminApi* | [**getUserCalendarHeatmapAdmin**](doc//UsersAdminApi.md#getusercalendarheatmapadmin) | **GET** /admin/users/{id}/calendar-heatmap | Retrieve calendar heatmap activity *UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | Retrieve user preferences *UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | Retrieve user sessions *UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | Retrieve user statistics @@ -398,6 +400,9 @@ Class | Method | HTTP request | Description - [BulkIdsDto](doc//BulkIdsDto.md) - [CLIPConfig](doc//CLIPConfig.md) - [CQMode](doc//CQMode.md) + - [CalendarHeatmapResponseDto](doc//CalendarHeatmapResponseDto.md) + - [CalendarHeatmapResponseDtoSeriesInner](doc//CalendarHeatmapResponseDtoSeriesInner.md) + - [CalendarHeatmapType](doc//CalendarHeatmapType.md) - [CastResponse](doc//CastResponse.md) - [CastUpdate](doc//CastUpdate.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1dbe28cc0f..43c3fb320a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -140,6 +140,9 @@ part 'model/bulk_id_response_dto.dart'; part 'model/bulk_ids_dto.dart'; part 'model/clip_config.dart'; part 'model/cq_mode.dart'; +part 'model/calendar_heatmap_response_dto.dart'; +part 'model/calendar_heatmap_response_dto_series_inner.dart'; +part 'model/calendar_heatmap_type.dart'; part 'model/cast_response.dart'; part 'model/cast_update.dart'; part 'model/change_password_dto.dart'; diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index fd6b43d9ce..ef695e2f33 100644 --- a/mobile/openapi/lib/api/users_admin_api.dart +++ b/mobile/openapi/lib/api/users_admin_api.dart @@ -193,6 +193,90 @@ class UsersAdminApi { return null; } + /// Retrieve calendar heatmap activity + /// + /// Retrieve activity counts for a specified period, in a calendar heatmap format. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + /// + /// * [CalendarHeatmapType] type: + Future getUserCalendarHeatmapAdminWithHttpInfo(String id, { DateTime? from, DateTime? to, CalendarHeatmapType? type, Future? abortTrigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/users/{id}/calendar-heatmap' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (from != null) { + queryParams.addAll(_queryParams('', 'from', from)); + } + if (to != null) { + queryParams.addAll(_queryParams('', 'to', to)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + abortTrigger: abortTrigger, + ); + } + + /// Retrieve calendar heatmap activity + /// + /// Retrieve activity counts for a specified period, in a calendar heatmap format. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + /// + /// * [CalendarHeatmapType] type: + Future getUserCalendarHeatmapAdmin(String id, { DateTime? from, DateTime? to, CalendarHeatmapType? type, Future? abortTrigger, }) async { + final response = await getUserCalendarHeatmapAdminWithHttpInfo(id, from: from, to: to, type: type, abortTrigger: abortTrigger,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CalendarHeatmapResponseDto',) as CalendarHeatmapResponseDto; + + } + return null; + } + /// Retrieve user preferences /// /// Retrieve the preferences of a specific user. diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index a7fac3ea66..f768e7c92b 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -208,6 +208,85 @@ class UsersApi { } } + /// Retrieve calendar heatmap activity + /// + /// Retrieve activity counts for a specified period, in a calendar heatmap format. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + /// + /// * [CalendarHeatmapType] type: + Future getMyCalendarHeatmapWithHttpInfo({ DateTime? from, DateTime? to, CalendarHeatmapType? type, Future? abortTrigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/calendar-heatmap'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (from != null) { + queryParams.addAll(_queryParams('', 'from', from)); + } + if (to != null) { + queryParams.addAll(_queryParams('', 'to', to)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + abortTrigger: abortTrigger, + ); + } + + /// Retrieve calendar heatmap activity + /// + /// Retrieve activity counts for a specified period, in a calendar heatmap format. + /// + /// Parameters: + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + /// + /// * [CalendarHeatmapType] type: + Future getMyCalendarHeatmap({ DateTime? from, DateTime? to, CalendarHeatmapType? type, Future? abortTrigger, }) async { + final response = await getMyCalendarHeatmapWithHttpInfo(from: from, to: to, type: type, abortTrigger: abortTrigger,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CalendarHeatmapResponseDto',) as CalendarHeatmapResponseDto; + + } + return null; + } + /// Get my preferences /// /// Retrieve the preferences for the current user. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6efa46571e..9240b30c0b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -325,6 +325,12 @@ class ApiClient { return CLIPConfig.fromJson(value); case 'CQMode': return CQModeTypeTransformer().decode(value); + case 'CalendarHeatmapResponseDto': + return CalendarHeatmapResponseDto.fromJson(value); + case 'CalendarHeatmapResponseDtoSeriesInner': + return CalendarHeatmapResponseDtoSeriesInner.fromJson(value); + case 'CalendarHeatmapType': + return CalendarHeatmapTypeTypeTransformer().decode(value); case 'CastResponse': return CastResponse.fromJson(value); case 'CastUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 6c824d4a86..e9040a6f7a 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,6 +100,9 @@ String parameterToString(dynamic value) { if (value is CQMode) { return CQModeTypeTransformer().encode(value).toString(); } + if (value is CalendarHeatmapType) { + return CalendarHeatmapTypeTypeTransformer().encode(value).toString(); + } if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/calendar_heatmap_response_dto.dart b/mobile/openapi/lib/model/calendar_heatmap_response_dto.dart new file mode 100644 index 0000000000..2da9c411d4 --- /dev/null +++ b/mobile/openapi/lib/model/calendar_heatmap_response_dto.dart @@ -0,0 +1,129 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CalendarHeatmapResponseDto { + /// Returns a new [CalendarHeatmapResponseDto] instance. + CalendarHeatmapResponseDto({ + required this.from, + this.series = const [], + required this.to, + required this.totalCount, + }); + + /// Start date in UTC + String from; + + List series; + + /// End date in UTC + String to; + + /// Total activity count over the period + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int totalCount; + + @override + bool operator ==(Object other) => identical(this, other) || other is CalendarHeatmapResponseDto && + other.from == from && + _deepEquality.equals(other.series, series) && + other.to == to && + other.totalCount == totalCount; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (from.hashCode) + + (series.hashCode) + + (to.hashCode) + + (totalCount.hashCode); + + @override + String toString() => 'CalendarHeatmapResponseDto[from=$from, series=$series, to=$to, totalCount=$totalCount]'; + + Map toJson() { + final json = {}; + json[r'from'] = this.from; + json[r'series'] = this.series; + json[r'to'] = this.to; + json[r'totalCount'] = this.totalCount; + return json; + } + + /// Returns a new [CalendarHeatmapResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CalendarHeatmapResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CalendarHeatmapResponseDto"); + if (value is Map) { + final json = value.cast(); + + return CalendarHeatmapResponseDto( + from: mapValueOfType(json, r'from')!, + series: CalendarHeatmapResponseDtoSeriesInner.listFromJson(json[r'series']), + to: mapValueOfType(json, r'to')!, + totalCount: mapValueOfType(json, r'totalCount')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CalendarHeatmapResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CalendarHeatmapResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CalendarHeatmapResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CalendarHeatmapResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'from', + 'series', + 'to', + 'totalCount', + }; +} + diff --git a/mobile/openapi/lib/model/calendar_heatmap_response_dto_series_inner.dart b/mobile/openapi/lib/model/calendar_heatmap_response_dto_series_inner.dart new file mode 100644 index 0000000000..d1bdd467d4 --- /dev/null +++ b/mobile/openapi/lib/model/calendar_heatmap_response_dto_series_inner.dart @@ -0,0 +1,112 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CalendarHeatmapResponseDtoSeriesInner { + /// Returns a new [CalendarHeatmapResponseDtoSeriesInner] instance. + CalendarHeatmapResponseDtoSeriesInner({ + required this.count, + required this.date, + }); + + /// Activity count + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int count; + + /// Date in UTC + String date; + + @override + bool operator ==(Object other) => identical(this, other) || other is CalendarHeatmapResponseDtoSeriesInner && + other.count == count && + other.date == date; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode) + + (date.hashCode); + + @override + String toString() => 'CalendarHeatmapResponseDtoSeriesInner[count=$count, date=$date]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + json[r'date'] = this.date; + return json; + } + + /// Returns a new [CalendarHeatmapResponseDtoSeriesInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CalendarHeatmapResponseDtoSeriesInner? fromJson(dynamic value) { + upgradeDto(value, "CalendarHeatmapResponseDtoSeriesInner"); + if (value is Map) { + final json = value.cast(); + + return CalendarHeatmapResponseDtoSeriesInner( + count: mapValueOfType(json, r'count')!, + date: mapValueOfType(json, r'date')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CalendarHeatmapResponseDtoSeriesInner.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CalendarHeatmapResponseDtoSeriesInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CalendarHeatmapResponseDtoSeriesInner-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CalendarHeatmapResponseDtoSeriesInner.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + 'date', + }; +} + diff --git a/mobile/openapi/lib/model/calendar_heatmap_type.dart b/mobile/openapi/lib/model/calendar_heatmap_type.dart new file mode 100644 index 0000000000..8fdc0b1727 --- /dev/null +++ b/mobile/openapi/lib/model/calendar_heatmap_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +/// Type of calendar heatmap +class CalendarHeatmapType { + /// Instantiate a new enum with the provided [value]. + const CalendarHeatmapType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const upload = CalendarHeatmapType._(r'Upload'); + static const taken = CalendarHeatmapType._(r'Taken'); + + /// List of all possible values in this [enum][CalendarHeatmapType]. + static const values = [ + upload, + taken, + ]; + + static CalendarHeatmapType? fromJson(dynamic value) => CalendarHeatmapTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CalendarHeatmapType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [CalendarHeatmapType] to String, +/// and [decode] dynamic data back to [CalendarHeatmapType]. +class CalendarHeatmapTypeTypeTransformer { + factory CalendarHeatmapTypeTypeTransformer() => _instance ??= const CalendarHeatmapTypeTypeTransformer._(); + + const CalendarHeatmapTypeTypeTransformer._(); + + String encode(CalendarHeatmapType data) => data.value; + + /// Decodes a [dynamic value][data] to a CalendarHeatmapType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + CalendarHeatmapType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'Upload': return CalendarHeatmapType.upload; + case r'Taken': return CalendarHeatmapType.taken; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [CalendarHeatmapTypeTypeTransformer] instance. + static CalendarHeatmapTypeTypeTransformer? _instance; +} + diff --git a/mobile/test/openapi_patches_coverage.dart b/mobile/test/openapi_patches_coverage.dart index c4225d82c6..956f6793d3 100644 --- a/mobile/test/openapi_patches_coverage.dart +++ b/mobile/test/openapi_patches_coverage.dart @@ -31,6 +31,12 @@ void main() { if (!deserialized.contains(entry.key)) { continue; } + + // Skip new DTOs + if (!baseRequired.containsKey(entry.key)) { + continue; + } + final have = patched[entry.key] ?? const {}; final newlyRequired = entry.value.difference( baseRequired[entry.key] ?? const {}, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 434079e6a1..5cfc6ead0a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1264,6 +1264,96 @@ "x-immich-state": "Stable" } }, + "/admin/users/{id}/calendar-heatmap": { + "get": { + "description": "Retrieve activity counts for a specified period, in a calendar heatmap format.", + "operationId": "getUserCalendarHeatmapAdmin", + "parameters": [ + { + "name": "from", + "required": false, + "in": "query", + "description": "Start date in UTC", + "schema": { + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", + "example": "2024-01-01", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "description": "End date in UTC", + "schema": { + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", + "example": "2024-01-01", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "default": "Upload", + "$ref": "#/components/schemas/CalendarHeatmapType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarHeatmapResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve calendar heatmap activity", + "tags": [ + "Users (admin)" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + }, + { + "version": "v3", + "state": "Stable" + } + ], + "x-immich-permission": "user.read", + "x-immich-state": "Stable" + } + }, "/admin/users/{id}/preferences": { "get": { "description": "Retrieve the preferences of a specific user.", @@ -14485,6 +14575,86 @@ "x-immich-state": "Stable" } }, + "/users/me/calendar-heatmap": { + "get": { + "description": "Retrieve activity counts for a specified period, in a calendar heatmap format.", + "operationId": "getMyCalendarHeatmap", + "parameters": [ + { + "name": "from", + "required": false, + "in": "query", + "description": "Start date in UTC", + "schema": { + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", + "example": "2024-01-01", + "type": "string" + } + }, + { + "name": "to", + "required": false, + "in": "query", + "description": "End date in UTC", + "schema": { + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", + "example": "2024-01-01", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "default": "Upload", + "$ref": "#/components/schemas/CalendarHeatmapType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalendarHeatmapResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve calendar heatmap activity", + "tags": [ + "Users" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + }, + { + "version": "v3", + "state": "Stable" + } + ], + "x-immich-permission": "user.read", + "x-immich-state": "Stable" + } + }, "/users/me/license": { "delete": { "description": "Delete the registered product key for the current user.", @@ -17645,6 +17815,64 @@ ], "type": "string" }, + "CalendarHeatmapResponseDto": { + "properties": { + "from": { + "description": "Start date in UTC", + "example": "2024-01-01", + "type": "string" + }, + "series": { + "items": { + "properties": { + "count": { + "description": "Activity count", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "date": { + "description": "Date in UTC", + "example": "2024-01-01", + "type": "string" + } + }, + "required": [ + "date", + "count" + ], + "type": "object" + }, + "type": "array" + }, + "to": { + "description": "End date in UTC", + "example": "2024-12-31", + "type": "string" + }, + "totalCount": { + "description": "Total activity count over the period", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "from", + "series", + "to", + "totalCount" + ], + "type": "object" + }, + "CalendarHeatmapType": { + "description": "Type of calendar heatmap", + "enum": [ + "Upload", + "Taken" + ], + "type": "string" + }, "CastResponse": { "properties": { "gCastEnabled": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3e76872b6f..0791c15d44 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -262,6 +262,20 @@ export type UserAdminUpdateDto = { /** Storage label */ storageLabel?: string | null; }; +export type CalendarHeatmapResponseDto = { + /** Start date in UTC */ + "from": string; + series: { + /** Activity count */ + count: number; + /** Date in UTC */ + date: string; + }[]; + /** End date in UTC */ + to: string; + /** Total activity count over the period */ + totalCount: number; +}; export type AlbumsResponse = { defaultAssetOrder: AssetOrder; }; @@ -3544,6 +3558,26 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: { body: userAdminUpdateDto }))); } +/** + * Retrieve calendar heatmap activity + */ +export function getUserCalendarHeatmapAdmin({ $from, id, to, $type }: { + $from?: string; + id: string; + to?: string; + $type?: CalendarHeatmapType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: CalendarHeatmapResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/calendar-heatmap${QS.query(QS.explode({ + "from": $from, + to, + "type": $type + }))}`, { + ...opts + })); +} /** * Retrieve user preferences */ @@ -6578,6 +6612,25 @@ export function updateMyUser({ userUpdateMeDto }: { body: userUpdateMeDto }))); } +/** + * Retrieve calendar heatmap activity + */ +export function getMyCalendarHeatmap({ $from, to, $type }: { + $from?: string; + to?: string; + $type?: CalendarHeatmapType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: CalendarHeatmapResponseDto; + }>(`/users/me/calendar-heatmap${QS.query(QS.explode({ + "from": $from, + to, + "type": $type + }))}`, { + ...opts + })); +} /** * Delete user product key */ @@ -6905,6 +6958,10 @@ export enum UserStatus { Removing = "removing", Deleted = "deleted" } +export enum CalendarHeatmapType { + Upload = "Upload", + Taken = "Taken" +} export enum AssetOrder { Asc = "asc", Desc = "desc" diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 6dd919e193..36bc4baee5 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto'; import { SessionResponseDto } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { @@ -85,6 +86,21 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/calendar-heatmap') + @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Retrieve calendar heatmap activity', + description: 'Retrieve activity counts for a specified period, in a calendar heatmap format.', + history: new HistoryBuilder().added('v3').stable('v3'), + }) + getUserCalendarHeatmapAdmin( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: CalendarHeatmapDto, + ): Promise { + return this.service.getCalendarHeatmap(auth, id, dto); + } + @Get(':id/sessions') @Authenticated({ permission: Permission.AdminSessionRead, admin: true }) @Endpoint({ diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 2db0ca182b..c0ae4e32fc 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -9,6 +9,7 @@ import { Param, Post, Put, + Query, Res, UploadedFile, UseInterceptors, @@ -17,6 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; @@ -60,6 +62,17 @@ export class UserController { return this.service.getMe(auth); } + @Get('me/calendar-heatmap') + @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Retrieve calendar heatmap activity', + description: 'Retrieve activity counts for a specified period, in a calendar heatmap format.', + history: new HistoryBuilder().added('v3').stable('v3'), + }) + getMyCalendarHeatmap(@Auth() auth: AuthDto, @Query() dto: CalendarHeatmapDto): Promise { + return this.service.getCalendarHeatmap(auth, dto); + } + @Put('me') @Authenticated({ permission: Permission.UserUpdate }) @Endpoint({ diff --git a/server/src/dtos/calendar-heatmap.dto.ts b/server/src/dtos/calendar-heatmap.dto.ts new file mode 100644 index 0000000000..b52d6e27bb --- /dev/null +++ b/server/src/dtos/calendar-heatmap.dto.ts @@ -0,0 +1,36 @@ +import { createZodDto } from 'nestjs-zod'; +import { CalendarHeatmapType } from 'src/enum'; +import { isoDateToDate } from 'src/validation'; +import z from 'zod'; + +const CalendarHeatmapTypeSchema = z + .enum(CalendarHeatmapType) + .describe('Type of calendar heatmap') + .meta({ id: 'CalendarHeatmapType' }); + +const CalendarHeatmapSchema = z + .object({ + from: isoDateToDate.optional().describe('Start date in UTC'), + to: isoDateToDate.optional().describe('End date in UTC'), + type: CalendarHeatmapTypeSchema.optional().default(CalendarHeatmapType.Upload), + }) + .refine((dto) => !dto.from || !dto.to || dto.from <= dto.to, { message: 'from must be before to', path: ['from'] }) + .meta({ id: 'CalendarHeatmapDto' }); + +export class CalendarHeatmapDto extends createZodDto(CalendarHeatmapSchema) {} + +const CalendarHeatmapResponseSchema = z + .object({ + from: z.string().describe('Start date in UTC').meta({ example: '2024-01-01' }), + to: z.string().describe('End date in UTC').meta({ example: '2024-12-31' }), + series: z.array( + z.object({ + date: z.string().describe('Date in UTC').meta({ example: '2024-01-01' }), + count: z.int().nonnegative().describe('Activity count'), + }), + ), + totalCount: z.int().nonnegative().describe('Total activity count over the period'), + }) + .meta({ id: 'CalendarHeatmapResponseDto' }); + +export class CalendarHeatmapResponseDto extends createZodDto(CalendarHeatmapResponseSchema) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 9dee1db313..04c4898779 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1175,3 +1175,8 @@ export enum WorkflowType { } export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' }); + +export enum CalendarHeatmapType { + Upload = 'Upload', + Taken = 'Taken', +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 819ec4f838..cc694dd63a 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -339,6 +339,22 @@ where limit $3 +-- AssetRepository.getCalendarHeatmap +select + date_trunc('DAY', "asset"."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' as "date", + count(*) as "count" +from + "asset" +where + "ownerId" = $1::uuid + and "createdAt" >= $2 + and "createdAt" < $3 + and "deletedAt" is null +group by + date_trunc('DAY', "asset"."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' +order by + "date" asc + -- AssetRepository.getTimeBuckets with "asset" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5488f747ad..ca00245c27 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -17,7 +17,15 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { + AssetFileType, + AssetOrder, + AssetOrderBy, + AssetStatus, + AssetType, + AssetVisibility, + CalendarHeatmapType, +} from 'src/enum'; import { DB } from 'src/schema'; import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -706,6 +714,32 @@ export class AssetRepository { .executeTakeFirstOrThrow(); } + @GenerateSql({ + params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE, type: CalendarHeatmapType.Upload }], + }) + getCalendarHeatmap(ownerId: string, dto: { from: Date; to: Date; type: CalendarHeatmapType }) { + const dateColumns: Record = { + [CalendarHeatmapType.Upload]: { order: AssetOrderBy.CreatedAt, column: 'createdAt' }, + [CalendarHeatmapType.Taken]: { order: AssetOrderBy.TakenAt, column: 'localDateTime' }, + } as const; + + const { order, column } = dateColumns[dto.type]; + + const date = truncatedDate(order, 'DAY'); + + return this.db + .selectFrom('asset') + .select(date.as('date')) + .select((eb) => eb.fn.countAll().as('count')) + .where('ownerId', '=', asUuid(ownerId)) + .where(column, '>=', dto.from) + .where(column, '<', dto.to) + .where('deletedAt', 'is', null) + .groupBy(date) + .orderBy('date', 'asc') + .execute(); + } + @GenerateSql({ params: [{}] }) async getTimeBuckets(options: TimeBucketOptions): Promise { return this.db diff --git a/server/src/services/shared/user-methods.ts b/server/src/services/shared/user-methods.ts new file mode 100644 index 0000000000..96e3a30756 --- /dev/null +++ b/server/src/services/shared/user-methods.ts @@ -0,0 +1,36 @@ +import { DateTime } from 'luxon'; +import { CalendarHeatmapDto } from 'src/dtos/calendar-heatmap.dto'; +import { CalendarHeatmapType } from 'src/enum'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { asDateString } from 'src/utils/date'; + +export const getCalendarHeatmap = async ( + userId: string, + dto: CalendarHeatmapDto, + repos: { asset: AssetRepository }, +) => { + const toDate = DateTime.fromJSDate(dto.to ?? new Date(), { zone: 'utc' }).startOf('day'); + const fromDate = ( + dto.from ? DateTime.fromJSDate(dto.from, { zone: 'utc' }) : toDate.minus({ weeks: 52 }).plus({ days: 1 }) + ).startOf('day'); + + const counts = await repos.asset.getCalendarHeatmap(userId, { + from: fromDate.toJSDate(), + to: toDate.plus({ days: 1 }).toJSDate(), + type: dto.type ?? CalendarHeatmapType.Upload, + }); + const countsMap = new Map(counts.map((item) => [asDateString(item.date)!, item.count])); + + const series: Array<{ date: string; count: number }> = []; + for (let date = fromDate; date <= toDate; date = date.plus({ days: 1 })) { + const key = date.toISODate()!; + series.push({ date: key, count: countsMap.get(key) ?? 0 }); + } + + return { + from: fromDate.toISODate()!, + to: toDate.toISODate()!, + series, + totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0), + }; +}; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 2a57fdd299..15ea8c0598 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com import { SALT_ROUNDS } from 'src/constants'; import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -15,6 +16,7 @@ import { import { JobName, UserMetadataKey, UserStatus } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; +import { getCalendarHeatmap } from 'src/services/shared/user-methods'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() @@ -122,6 +124,11 @@ export class UserAdminService extends BaseService { return mapUserAdmin(user); } + async getCalendarHeatmap(auth: AuthDto, id: string, dto: CalendarHeatmapDto): Promise { + await this.findOrFail(id, { withDeleted: false }); + return getCalendarHeatmap(id, dto, { asset: this.assetRepository }); + } + async getSessions(auth: AuthDto, id: string): Promise { const sessions = await this.sessionRepository.getByUserId(id); return sessions.map((session) => mapSession(session)); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 27084eb3b4..0ec4ab4a7b 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -5,6 +5,7 @@ import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; @@ -15,6 +16,7 @@ import { ArgOf } from 'src/repositories/event.repository'; import { UserFindOptions } from 'src/repositories/user.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { BaseService } from 'src/services/base.service'; +import { getCalendarHeatmap } from 'src/services/shared/user-methods'; import { JobOf, UserMetadataItem } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -46,6 +48,10 @@ export class UserService extends BaseService { return mapUserAdmin(user); } + getCalendarHeatmap(auth: AuthDto, dto: CalendarHeatmapDto): Promise { + return getCalendarHeatmap(auth.user.id, dto, { asset: this.assetRepository }); + } + async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index cb942b5366..a187c25913 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -301,8 +301,8 @@ export function withTags(eb: ExpressionBuilder) { ).as('tags'); } -export function truncatedDate(order: AssetOrderBy = AssetOrderBy.TakenAt) { - return sql`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; +export function truncatedDate(order: AssetOrderBy = AssetOrderBy.TakenAt, size?: 'DAY' | 'MONTH') { + return sql`date_trunc(${sql.lit(size ?? 'MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; } export function withTagId(qb: SelectQueryBuilder, tagId: string) { diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 0540128908..a56f7cea15 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -28,6 +28,7 @@ export const newAssetRepositoryMock = (): Mocked - +
diff --git a/web/src/lib/components/CalendarHeatmap.svelte b/web/src/lib/components/CalendarHeatmap.svelte new file mode 100644 index 0000000000..4aca9bb3e8 --- /dev/null +++ b/web/src/lib/components/CalendarHeatmap.svelte @@ -0,0 +1,105 @@ + + +
+
+ + + + + +
+ {#each rows as row, dayIndex (dayIndex)} +
+ {#each row as day (day.date)} +
+ {/each} +
+ {/each} +
+ +
+ {$t('less')} + + + + + + {$t('more')} + {totalLabel(data.totalCount)} +
+
+
diff --git a/web/src/lib/elements/Skeleton.svelte b/web/src/lib/elements/Skeleton.svelte index 8030a26279..900228412b 100644 --- a/web/src/lib/elements/Skeleton.svelte +++ b/web/src/lib/elements/Skeleton.svelte @@ -1,14 +1,17 @@ -
+
{#if title}
{ @@ -16,3 +17,10 @@ export const cleanClass = (...classNames: unknown[]) => { }; export const isDefined = (value: T): value is NonNullable => value !== null && value !== undefined; + +export const getHeatmapRange = () => { + const to = DateTime.utc().startOf('day'); + const from = to.minus({ weeks: 51 }).plus({ days: 1 }); + const fromSunday = from.minus({ days: from.weekday % 7 }); + return { $from: fromSunday.toISODate()!, to: to.toISODate()! }; +}; diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index d06f92376b..17704aff22 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -78,3 +78,12 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); + +const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + +type DayOfWeek = (typeof days)[number]; +export const dayOfWeek = (day: DayOfWeek, options?: { locale?: string; style?: 'long' | 'short' | 'narrow' }) => { + const fmt = new Intl.DateTimeFormat(options?.locale, { weekday: options?.style ?? 'long', timeZone: 'UTC' }); + // 2021-08-01 is a Sunday + return fmt.format(new Date(Date.UTC(2021, 7, 1 + days.indexOf(day)))); +}; diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index 817c8933a6..b6f7439cbe 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -1,9 +1,14 @@