diff --git a/i18n/en.json b/i18n/en.json index f4ad3001c2..91c7031a8e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1399,6 +1399,7 @@ "leave": "Leave", "leave_album": "Leave album", "lens_model": "Lens model", + "less": "Less", "let_others_respond": "Let others respond", "level": "Level", "library": "Library", @@ -2409,6 +2410,12 @@ "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", + "upload_activity": "Upload activity", + "upload_activity_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}", + "upload_activity_day_friday": "Fri", + "upload_activity_day_monday": "Mon", + "upload_activity_day_wednesday": "Wed", + "upload_activity_total_count": "{count, plural, one {# upload} other {# uploads}}", "upload_concurrency": "Upload concurrency", "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index af40910b4d..b71f6f7243 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -294,6 +294,7 @@ Class | Method | HTTP request | Description *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* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences +*UsersApi* | [**getMyUploadStatistics**](doc//UsersApi.md#getmyuploadstatistics) | **GET** /users/me/stats/uploads | Get current user upload statistics *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 *UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | Retrieve a user @@ -667,6 +668,8 @@ Class | Method | HTTP request | Description - [UserResponseDto](doc//UserResponseDto.md) - [UserStatus](doc//UserStatus.md) - [UserUpdateMeDto](doc//UserUpdateMeDto.md) + - [UserUploadStatsResponseDto](doc//UserUploadStatsResponseDto.md) + - [UserUploadStatsResponseDtoSeriesInner](doc//UserUploadStatsResponseDtoSeriesInner.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [ValidateLibraryDto](doc//ValidateLibraryDto.md) - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1dbe28cc0f..1b3104450d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -409,6 +409,8 @@ part 'model/user_preferences_update_dto.dart'; part 'model/user_response_dto.dart'; part 'model/user_status.dart'; part 'model/user_update_me_dto.dart'; +part 'model/user_upload_stats_response_dto.dart'; +part 'model/user_upload_stats_response_dto_series_inner.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/validate_library_dto.dart'; part 'model/validate_library_import_path_response_dto.dart'; diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index a7fac3ea66..27b7650f5f 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -257,6 +257,78 @@ class UsersApi { return null; } + /// Get current user upload statistics + /// + /// Retrieve daily upload counts and totals for the current user. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + Future getMyUploadStatisticsWithHttpInfo({ DateTime? from, DateTime? to, Future? abortTrigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/stats/uploads'; + + // 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)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + abortTrigger: abortTrigger, + ); + } + + /// Get current user upload statistics + /// + /// Retrieve daily upload counts and totals for the current user. + /// + /// Parameters: + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + Future getMyUploadStatistics({ DateTime? from, DateTime? to, Future? abortTrigger, }) async { + final response = await getMyUploadStatisticsWithHttpInfo(from: from, to: to, 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), 'UserUploadStatsResponseDto',) as UserUploadStatsResponseDto; + + } + return null; + } + /// Get current user /// /// Retrieve information about the user making the API request. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6efa46571e..34b2f03641 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -863,6 +863,10 @@ class ApiClient { return UserStatusTypeTransformer().decode(value); case 'UserUpdateMeDto': return UserUpdateMeDto.fromJson(value); + case 'UserUploadStatsResponseDto': + return UserUploadStatsResponseDto.fromJson(value); + case 'UserUploadStatsResponseDtoSeriesInner': + return UserUploadStatsResponseDtoSeriesInner.fromJson(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); case 'ValidateLibraryDto': diff --git a/mobile/openapi/lib/model/user_upload_stats_response_dto.dart b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart new file mode 100644 index 0000000000..0816bb65ac --- /dev/null +++ b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart @@ -0,0 +1,138 @@ +// +// 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 UserUploadStatsResponseDto { + /// Returns a new [UserUploadStatsResponseDto] instance. + UserUploadStatsResponseDto({ + required this.from, + this.series = const [], + required this.to, + required this.totalCount, + required this.userId, + }); + + /// Start date in UTC + String from; + + List series; + + /// End date in UTC + String to; + + /// Total number of uploads + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int totalCount; + + /// User ID + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDto && + other.from == from && + _deepEquality.equals(other.series, series) && + other.to == to && + other.totalCount == totalCount && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (from.hashCode) + + (series.hashCode) + + (to.hashCode) + + (totalCount.hashCode) + + (userId.hashCode); + + @override + String toString() => 'UserUploadStatsResponseDto[from=$from, series=$series, to=$to, totalCount=$totalCount, userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'from'] = this.from; + json[r'series'] = this.series; + json[r'to'] = this.to; + json[r'totalCount'] = this.totalCount; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [UserUploadStatsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserUploadStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserUploadStatsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return UserUploadStatsResponseDto( + from: mapValueOfType(json, r'from')!, + series: UserUploadStatsResponseDtoSeriesInner.listFromJson(json[r'series']), + to: mapValueOfType(json, r'to')!, + totalCount: mapValueOfType(json, r'totalCount')!, + userId: mapValueOfType(json, r'userId')!, + ); + } + 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 = UserUploadStatsResponseDto.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 = UserUploadStatsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserUploadStatsResponseDto-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] = UserUploadStatsResponseDto.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', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/user_upload_stats_response_dto_series_inner.dart b/mobile/openapi/lib/model/user_upload_stats_response_dto_series_inner.dart new file mode 100644 index 0000000000..c0eec1bfcb --- /dev/null +++ b/mobile/openapi/lib/model/user_upload_stats_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 UserUploadStatsResponseDtoSeriesInner { + /// Returns a new [UserUploadStatsResponseDtoSeriesInner] instance. + UserUploadStatsResponseDtoSeriesInner({ + required this.count, + required this.date, + }); + + /// Number of uploads + /// + /// Minimum value: 0 + /// Maximum value: 9007199254740991 + int count; + + /// Date in UTC + String date; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDtoSeriesInner && + other.count == count && + other.date == date; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode) + + (date.hashCode); + + @override + String toString() => 'UserUploadStatsResponseDtoSeriesInner[count=$count, date=$date]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + json[r'date'] = this.date; + return json; + } + + /// Returns a new [UserUploadStatsResponseDtoSeriesInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserUploadStatsResponseDtoSeriesInner? fromJson(dynamic value) { + upgradeDto(value, "UserUploadStatsResponseDtoSeriesInner"); + if (value is Map) { + final json = value.cast(); + + return UserUploadStatsResponseDtoSeriesInner( + 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 = UserUploadStatsResponseDtoSeriesInner.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 = UserUploadStatsResponseDtoSeriesInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserUploadStatsResponseDtoSeriesInner-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] = UserUploadStatsResponseDtoSeriesInner.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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 33eaf13fc2..9c88d9e348 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14886,6 +14886,77 @@ "x-immich-state": "Stable" } }, + "/users/me/stats/uploads": { + "get": { + "description": "Retrieve daily upload counts and totals for the current user.", + "operationId": "getMyUploadStatistics", + "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" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUploadStatsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get current user upload statistics", + "tags": [ + "Users" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + }, + { + "version": "v3", + "state": "Stable" + } + ], + "x-immich-permission": "user.read", + "x-immich-state": "Stable" + } + }, "/users/profile-image": { "delete": { "description": "Delete the profile image of the current user.", @@ -26539,6 +26610,63 @@ }, "type": "object" }, + "UserUploadStatsResponseDto": { + "properties": { + "from": { + "description": "Start date in UTC", + "example": "2024-01-01", + "type": "string" + }, + "series": { + "items": { + "properties": { + "count": { + "description": "Number of uploads", + "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 number of uploads", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "userId": { + "description": "User ID", + "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" + } + }, + "required": [ + "from", + "series", + "to", + "totalCount", + "userId" + ], + "type": "object" + }, "ValidateAccessTokenResponseDto": { "properties": { "authStatus": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 89d0e513d8..a05a1f3d65 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2695,6 +2695,22 @@ export type OnboardingDto = { /** Is user onboarded */ isOnboarded: boolean; }; +export type UserUploadStatsResponseDto = { + /** Start date in UTC */ + "from": string; + series: { + /** Number of uploads */ + count: number; + /** Date in UTC */ + date: string; + }[]; + /** End date in UTC */ + to: string; + /** Total number of uploads */ + totalCount: number; + /** User ID */ + userId: string; +}; export type CreateProfileImageDto = { /** Profile image file */ file: Blob; @@ -6676,6 +6692,23 @@ export function updateMyPreferences({ userPreferencesUpdateDto }: { body: userPreferencesUpdateDto }))); } +/** + * Get current user upload statistics + */ +export function getMyUploadStatistics({ $from, to }: { + $from?: string; + to?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserUploadStatsResponseDto; + }>(`/users/me/stats/uploads${QS.query(QS.explode({ + "from": $from, + to + }))}`, { + ...opts + })); +} /** * Delete user profile image */ diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 2db0ca182b..015878d7d5 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, @@ -21,7 +22,13 @@ 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'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; -import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; +import { + UserAdminResponseDto, + UserResponseDto, + UserUpdateMeDto, + UserUploadStatsDto, + UserUploadStatsResponseDto, +} from 'src/dtos/user.dto'; import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; @@ -60,6 +67,17 @@ export class UserController { return this.service.getMe(auth); } + @Get('me/stats/uploads') + @Authenticated({ permission: Permission.UserRead }) + @Endpoint({ + summary: 'Get current user upload statistics', + description: 'Retrieve daily upload counts and totals for the current user.', + history: new HistoryBuilder().added('v3').stable('v3'), + }) + getMyUploadStatistics(@Auth() auth: AuthDto, @Query() dto: UserUploadStatsDto): Promise { + return this.service.getUploadStatistics(auth, dto); + } + @Put('me') @Authenticated({ permission: Permission.UserUpdate }) @Endpoint({ diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 75256b9e1a..6454dd89c7 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -4,7 +4,14 @@ import { pinCodeRegex } from 'src/dtos/auth.dto'; import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum'; import { MaybeDehydrated, UserMetadataItem } from 'src/types'; import { asDateString } from 'src/utils/date'; -import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation'; +import { + emptyStringToNull, + isoDateToDate, + isoDatetimeToDate, + sanitizeFilename, + stringToBool, + toEmail, +} from 'src/validation'; import z from 'zod'; export const UserUpdateMeSchema = z @@ -22,6 +29,33 @@ export const UserUpdateMeSchema = z export class UserUpdateMeDto extends createZodDto(UserUpdateMeSchema) {} +const UserUploadStatsSchema = z + .object({ + from: isoDateToDate.optional().describe('Start date in UTC'), + to: isoDateToDate.optional().describe('End date in UTC'), + }) + .refine((dto) => !dto.from || !dto.to || dto.from <= dto.to, { message: 'from must be before to', path: ['from'] }) + .meta({ id: 'UserUploadStatsDto' }); + +export class UserUploadStatsDto extends createZodDto(UserUploadStatsSchema) {} + +const UserUploadStatsResponseSchema = z + .object({ + userId: z.uuidv4().describe('User ID'), + 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('Number of uploads'), + }), + ), + totalCount: z.int().nonnegative().describe('Total number of uploads'), + }) + .meta({ id: 'UserUploadStatsResponseDto' }); + +export class UserUploadStatsResponseDto extends createZodDto(UserUploadStatsResponseSchema) {} + export const UserResponseSchema = z .object({ id: z.uuidv4().describe('User ID'), diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 819ec4f838..7948abc7df 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -339,6 +339,22 @@ where limit $3 +-- AssetRepository.getUploadStatistics +select + date_trunc('day', "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', "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..59f620554f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -706,6 +706,23 @@ export class AssetRepository { .executeTakeFirstOrThrow(); } + @GenerateSql({ params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] }) + getUploadStatistics(ownerId: string, options: { from: Date; to: Date }) { + const uploadDate = sql`date_trunc('day', "createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; + + return this.db + .selectFrom('asset') + .select(uploadDate.as('date')) + .select((eb) => eb.fn.countAll().as('count')) + .where('ownerId', '=', asUuid(ownerId)) + .where('createdAt', '>=', options.from) + .where('createdAt', '<', options.to) + .where('deletedAt', 'is', null) + .groupBy(uploadDate) + .orderBy('date', 'asc') + .execute(); + } + @GenerateSql({ params: [{}] }) async getTimeBuckets(options: TimeBucketOptions): Promise { return this.db diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 27084eb3b4..351086dea5 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -9,7 +9,15 @@ 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'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; -import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; +import { + UserAdminResponseDto, + UserResponseDto, + UserUpdateMeDto, + UserUploadStatsDto, + UserUploadStatsResponseDto, + mapUser, + mapUserAdmin, +} from 'src/dtos/user.dto'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { UserFindOptions } from 'src/repositories/user.repository'; @@ -46,6 +54,33 @@ export class UserService extends BaseService { return mapUserAdmin(user); } + async getUploadStatistics(auth: AuthDto, dto: UserUploadStatsDto): Promise { + const formatUploadDate = (date: Date) => date.toISOString().slice(0, 10); + 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 uploadCounts = await this.assetRepository.getUploadStatistics(auth.user.id, { + from: fromDate.toJSDate(), + to: toDate.plus({ days: 1 }).toJSDate(), + }); + const countsByDate = new Map(uploadCounts.map((item) => [formatUploadDate(item.date), item.count])); + const series: UserUploadStatsResponseDto['series'] = []; + + for (let date = fromDate; date <= toDate; date = date.plus({ days: 1 })) { + const dateKey = date.toISODate()!; + series.push({ date: dateKey, count: countsByDate.get(dateKey) ?? 0 }); + } + + return { + userId: auth.user.id, + from: fromDate.toISODate()!, + to: toDate.toISODate()!, + series, + totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0), + }; + } + async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise { if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 0540128908..b4f828613b 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 { - [timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([ + [timelineStats, favoriteStats, archiveStats, trashStats, albumStats, uploadStats] = await Promise.all([ getAssetStatistics({ visibility: AssetVisibility.Timeline }), getAssetStatistics({ isFavorite: true }), getAssetStatistics({ visibility: AssetVisibility.Archive }), getAssetStatistics({ isTrashed: true }), getAlbumStatistics(), + getMyUploadStatistics({ $from: uploadActivityFrom, to: uploadActivityTo }), ]); }; + const getUploadActivityWeeks = () => { + return Array.from({ length: Math.ceil(uploadStats.series.length / 7) }, (_, index) => + uploadStats.series.slice(index * 7, index * 7 + 7), + ); + }; + + const getUploadActivityLevel = (count: number) => { + const maxCount = Math.max(...uploadStats.series.map((item) => item.count), 0); + + if (count === 0 || maxCount === 0) { + return 'bg-gray-200 dark:bg-gray-700'; + } + + if (count <= Math.ceil(maxCount * 0.25)) { + return 'bg-immich-primary/30'; + } + + if (count <= Math.ceil(maxCount * 0.5)) { + return 'bg-immich-primary/50'; + } + + if (count <= Math.ceil(maxCount * 0.75)) { + return 'bg-immich-primary/70'; + } + + return 'bg-immich-primary'; + }; + + const getUploadActivityMonths = () => { + const endDate = uploadStats.to ? DateTime.fromISO(uploadStats.to, { zone: 'utc' }) : today; + return Array.from({ length: 12 }, (_, index) => { + const monthDate = endDate.minus({ months: 11 - index }); + return monthDate.toLocaleString({ month: 'short' }, { locale: $locale }); + }); + }; + onMount(async () => { await getUsage(); }); @@ -95,4 +147,52 @@ + + {$t('upload_activity')} +
+
+
+ {#each getUploadActivityMonths() as month (month)} +
{month}
+ {/each} +
+ +
+
+
+
{$t('upload_activity_day_monday')}
+
+
{$t('upload_activity_day_wednesday')}
+
+
{$t('upload_activity_day_friday')}
+
+
+ +
+ {#each getUploadActivityWeeks() as week (week[0]?.date)} +
+ {#each week as day (day.date)} +
+ {/each} +
+ {/each} +
+
+ +
+ {$t('less')} + + + + + + {$t('more')} + {$t('upload_activity_total_count', { values: { count: uploadStats.totalCount } })} +
+
+