pull/28593/merge
Abhijeet Sanjiv Bonde 2026-06-03 16:43:11 +02:00 committed by GitHub
commit 7f42a38b43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 724 additions and 4 deletions

View File

@ -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?",

View File

@ -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)

View File

@ -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';

View File

@ -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<Response> getMyUploadStatisticsWithHttpInfo({ DateTime? from, DateTime? to, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/users/me/stats/uploads';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (from != null) {
queryParams.addAll(_queryParams('', 'from', from));
}
if (to != null) {
queryParams.addAll(_queryParams('', 'to', to));
}
const contentTypes = <String>[];
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<UserUploadStatsResponseDto?> getMyUploadStatistics({ DateTime? from, DateTime? to, Future<void>? 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.

View File

@ -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':

View File

@ -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<UserUploadStatsResponseDtoSeriesInner> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return UserUploadStatsResponseDto(
from: mapValueOfType<String>(json, r'from')!,
series: UserUploadStatsResponseDtoSeriesInner.listFromJson(json[r'series']),
to: mapValueOfType<String>(json, r'to')!,
totalCount: mapValueOfType<int>(json, r'totalCount')!,
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<UserUploadStatsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserUploadStatsResponseDto>[];
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<String, UserUploadStatsResponseDto> mapFromJson(dynamic json) {
final map = <String, UserUploadStatsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<UserUploadStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UserUploadStatsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'from',
'series',
'to',
'totalCount',
'userId',
};
}

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return UserUploadStatsResponseDtoSeriesInner(
count: mapValueOfType<int>(json, r'count')!,
date: mapValueOfType<String>(json, r'date')!,
);
}
return null;
}
static List<UserUploadStatsResponseDtoSeriesInner> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserUploadStatsResponseDtoSeriesInner>[];
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<String, UserUploadStatsResponseDtoSeriesInner> mapFromJson(dynamic json) {
final map = <String, UserUploadStatsResponseDtoSeriesInner>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<UserUploadStatsResponseDtoSeriesInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UserUploadStatsResponseDtoSeriesInner>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'count',
'date',
};
}

View File

@ -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": {

View File

@ -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
*/

View File

@ -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<UserUploadStatsResponseDto> {
return this.service.getUploadStatistics(auth, dto);
}
@Put('me')
@Authenticated({ permission: Permission.UserUpdate })
@Endpoint({

View File

@ -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'),

View File

@ -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 (

View File

@ -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>`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<number>().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<TimeBucketItem[]> {
return this.db

View File

@ -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<UserUploadStatsResponseDto> {
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<UserAdminResponseDto> {
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);

View File

@ -28,6 +28,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
remove: vitest.fn(),
findLivePhotoMatch: vitest.fn(),
getStatistics: vitest.fn(),
getUploadStatistics: vitest.fn(),
getTimeBucket: vitest.fn(),
getTimeBuckets: vitest.fn(),
getAssetIdByCity: vitest.fn(),

View File

@ -4,10 +4,13 @@
AssetVisibility,
getAlbumStatistics,
getAssetStatistics,
getMyUploadStatistics,
type AlbumStatisticsResponseDto,
type AssetStatsResponseDto,
type UserUploadStatsResponseDto,
} from '@immich/sdk';
import { Heading, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@ -41,16 +44,65 @@
notShared: 0,
});
let uploadStats: UserUploadStatsResponseDto = $state({
userId: '',
from: '',
to: '',
series: [],
totalCount: 0,
});
const today = DateTime.utc().startOf('day');
const uploadActivityTo = today.toISODate();
const uploadActivityFrom = today.minus({ weeks: 52 }).plus({ days: 1 }).toISODate();
const getUsage = async () => {
[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 @@
</TableRow>
</TableBody>
</Table>
<Heading size="tiny" class="mt-8">{$t('upload_activity')}</Heading>
<div class="mt-4 w-full">
<div class="w-full">
<div class="mb-1 ml-7 flex justify-between text-xs text-gray-500 dark:text-gray-400">
{#each getUploadActivityMonths() as month (month)}
<div>{month}</div>
{/each}
</div>
<div class="flex gap-1">
<div class="grid w-6 shrink-0 grid-rows-7 gap-px py-0.5 text-xs text-gray-500 sm:gap-1 dark:text-gray-400">
<div></div>
<div>{$t('upload_activity_day_monday')}</div>
<div></div>
<div>{$t('upload_activity_day_wednesday')}</div>
<div></div>
<div>{$t('upload_activity_day_friday')}</div>
<div></div>
</div>
<div class="grid flex-1 grid-cols-52 gap-px sm:gap-1">
{#each getUploadActivityWeeks() as week (week[0]?.date)}
<div class="grid grid-rows-7 gap-px sm:gap-1">
{#each week as day (day.date)}
<div
class={`aspect-square w-full min-w-0 rounded-sm ${getUploadActivityLevel(day.count)}`}
title={$t('upload_activity_day_count', { values: { date: day.date, count: day.count } })}
aria-label={$t('upload_activity_day_count', { values: { date: day.date, count: day.count } })}
></div>
{/each}
</div>
{/each}
</div>
</div>
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{$t('less')}</span>
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
<span class="size-3 rounded-sm bg-immich-primary/50"></span>
<span class="size-3 rounded-sm bg-immich-primary/70"></span>
<span class="size-3 rounded-sm bg-immich-primary"></span>
<span>{$t('more')}</span>
<span class="ml-4">{$t('upload_activity_total_count', { values: { count: uploadStats.totalCount } })}</span>
</div>
</div>
</div>
</section>