From 0a1a65a275655727e08ca9a6730433f2d5f9f517 Mon Sep 17 00:00:00 2001 From: Dell Date: Sun, 24 May 2026 13:50:30 -0400 Subject: [PATCH 01/14] Feat - Heatmap --- server/src/controllers/user.controller.ts | 23 +++- server/src/dtos/user.dto.ts | 31 ++++- server/src/repositories/asset.repository.ts | 27 +++++ server/src/services/user.service.ts | 36 +++++- .../repositories/asset.repository.mock.ts | 1 + .../user-settings/UserUsageStatistic.svelte | 114 +++++++++++++++++- 6 files changed, 228 insertions(+), 4 deletions(-) diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 2db0ca182b..1613c0d860 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,20 @@ 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 for the current user.', + history: new HistoryBuilder().added('v2'), + }) + 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..08e8b1ec2d 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -4,7 +4,7 @@ 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 +22,35 @@ 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().describe('Number of uploads'), + }), + ), + summary: z.object({ + totalCount: z.int().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/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b144666773..28caec4512 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -97,6 +97,16 @@ export interface TimeBucketItem { count: number; } +export interface UploadStatisticsOptions { + from: Date; + to: Date; +} + +export interface UploadStatisticsItem { + date: string; + count: number; +} + export interface YearMonthDay { day: number; month: number; @@ -706,6 +716,23 @@ export class AssetRepository { .executeTakeFirstOrThrow(); } + @GenerateSql({ params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] }) + getUploadStatistics(ownerId: string, options: UploadStatisticsOptions): Promise { + const uploadDate = sql`date_trunc('day', "createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; + + return this.db + .selectFrom('asset') + .select(sql`(${uploadDate} AT TIME ZONE 'UTC')::date::text`.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 82ab90a590..2700f470ae 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 { UserFindOptions } from 'src/repositories/user.repository'; import { UserTable } from 'src/schema/tables/user.table'; @@ -45,6 +53,32 @@ export class UserService extends BaseService { return mapUserAdmin(user); } + async getUploadStatistics(auth: AuthDto, dto: UserUploadStatsDto): Promise { + const toDate = DateTime.fromJSDate(dto.to ?? new Date(), { zone: 'utc' }).startOf('day'); + const fromDate = DateTime.fromJSDate(dto.from ?? toDate.minus({ weeks: 52 }).plus({ days: 1 }).toJSDate(), { + zone: 'utc', + }).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) => [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, + summary: { 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; + summary: { totalCount: number }; + }; + let timelineStats: AssetStatsResponseDto = $state({ videos: 0, images: 0, @@ -41,16 +49,72 @@ notShared: 0, }); + let uploadStats: UserUploadStatsResponseDto = $state({ + userId: '', + from: '', + to: '', + series: [], + summary: { totalCount: 0 }, + }); + + const formatDate = (date: Date) => date.toISOString().slice(0, 10); + const today = new Date(); + const uploadActivityTo = formatDate(today); + const uploadActivityFromDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + uploadActivityFromDate.setUTCDate(uploadActivityFromDate.getUTCDate() - 52 * 7 + 1); + const uploadActivityFrom = formatDate(uploadActivityFromDate); + + const getMyUploadStatistics = async (from: string, to: string) => { + const response = await fetch(`/api/users/me/stats/uploads?${new URLSearchParams({ from, to })}`); + return (await response.json()) as UserUploadStatsResponseDto; + }; + 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(uploadActivityFrom, uploadActivityTo), ]); }; + const getUploadActivityWeeks = () => { + const days = uploadStats.series.map((item) => ({ ...item, dateValue: new Date(`${item.date}T00:00:00.000Z`) })); + return Array.from({ length: Math.ceil(days.length / 7) }, (_, index) => days.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 ? new Date(`${uploadStats.to}T00:00:00.000Z`) : today; + return Array.from({ length: 12 }, (_, index) => { + const monthDate = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - 11 + index, 1)); + return monthDate.toLocaleString($locale, { month: 'short', timeZone: 'UTC' }); + }); + }; + onMount(async () => { await getUsage(); }); @@ -95,4 +159,52 @@ + + Upload activity +
+
+
+ {#each getUploadActivityMonths() as month} +
{month}
+ {/each} +
+ +
+
+
+
Mon
+
+
Wed
+
+
Fri
+
+
+ +
+ {#each getUploadActivityWeeks() as week} +
+ {#each week as day} +
+ {/each} +
+ {/each} +
+
+ +
+ Less + + + + + + More + {uploadStats.summary.totalCount.toLocaleString($locale)} uploads +
+
+
From 07173efc501c44f227407e7feff53bfd14f0c9f4 Mon Sep 17 00:00:00 2001 From: Dell Date: Tue, 26 May 2026 13:01:58 -0400 Subject: [PATCH 02/14] Implemented Comments to prettify and code cleanup --- i18n/en.json | 7 + open-api/immich-openapi-specs.json | 136 ++++++++++++++++++ server/src/controllers/user.controller.ts | 7 +- server/src/queries/asset.repository.sql | 16 +++ server/src/repositories/asset.repository.ts | 14 +- server/src/services/user.service.ts | 9 +- .../user-settings/UserUsageStatistic.svelte | 20 +-- 7 files changed, 179 insertions(+), 30 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 97f4575567..76110d074a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1386,6 +1386,7 @@ "leave": "Leave", "leave_album": "Leave album", "lens_model": "Lens model", + "less": "Less", "let_others_respond": "Let others respond", "level": "Level", "library": "Library", @@ -2392,6 +2393,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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index badf9ce25d..4e8b366890 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14477,6 +14477,77 @@ "x-immich-state": "Stable" } }, + "/users/me/stats/uploads": { + "get": { + "description": "Retrieve daily upload counts 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.", @@ -25949,6 +26020,71 @@ }, "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": -9007199254740991, + "type": "integer" + }, + "date": { + "description": "Date in UTC", + "example": "2024-01-01", + "type": "string" + } + }, + "required": [ + "date", + "count" + ], + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "totalCount": { + "description": "Total number of uploads", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "totalCount" + ], + "type": "object" + }, + "to": { + "description": "End date in UTC", + "example": "2024-12-31", + "type": "string" + }, + "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", + "summary", + "to", + "userId" + ], + "type": "object" + }, "ValidateAccessTokenResponseDto": { "properties": { "authStatus": { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 1613c0d860..2961f28802 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -72,12 +72,9 @@ export class UserController { @Endpoint({ summary: 'Get current user upload statistics', description: 'Retrieve daily upload counts for the current user.', - history: new HistoryBuilder().added('v2'), + history: new HistoryBuilder().added('v3').stable('v3'), }) - getMyUploadStatistics( - @Auth() auth: AuthDto, - @Query() dto: UserUploadStatsDto, - ): Promise { + getMyUploadStatistics(@Auth() auth: AuthDto, @Query() dto: UserUploadStatsDto): Promise { return this.service.getUploadStatistics(auth, dto); } diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4d90bbf0d5..618501885b 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 28caec4512..799325c0ae 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -97,16 +97,6 @@ export interface TimeBucketItem { count: number; } -export interface UploadStatisticsOptions { - from: Date; - to: Date; -} - -export interface UploadStatisticsItem { - date: string; - count: number; -} - export interface YearMonthDay { day: number; month: number; @@ -717,12 +707,12 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] }) - getUploadStatistics(ownerId: string, options: UploadStatisticsOptions): Promise { + 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(sql`(${uploadDate} AT TIME ZONE 'UTC')::date::text`.as('date')) + .select(uploadDate.as('date')) .select((eb) => eb.fn.countAll().as('count')) .where('ownerId', '=', asUuid(ownerId)) .where('createdAt', '>=', options.from) diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 2700f470ae..f08b6dca20 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -54,15 +54,16 @@ export class UserService extends BaseService { } 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 = DateTime.fromJSDate(dto.from ?? toDate.minus({ weeks: 52 }).plus({ days: 1 }).toJSDate(), { - 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) => [item.date, item.count])); + 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 })) { diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index de878e1d27..f74ab0df51 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -160,7 +160,7 @@ - Upload activity + {$t('upload_activity')}
@@ -172,11 +172,11 @@
-
Mon
+
{$t('upload_activity_day_monday')}
-
Wed
+
{$t('upload_activity_day_wednesday')}
-
Fri
+
{$t('upload_activity_day_friday')}
@@ -186,8 +186,8 @@ {#each week as day}
{/each}
@@ -196,14 +196,16 @@
- Less + {$t('less')} - More - {uploadStats.summary.totalCount.toLocaleString($locale)} uploads + {$t('more')} + {$t('upload_activity_total_count', { values: { count: uploadStats.summary.totalCount } })}
From 78ec57250860070465fc965338b09f44ee22837b Mon Sep 17 00:00:00 2001 From: Dell Date: Thu, 28 May 2026 10:34:44 -0400 Subject: [PATCH 03/14] fixing code to pass cases. --- server/src/dtos/user.dto.ts | 9 ++++++++- .../user-settings/UserUsageStatistic.svelte | 20 +++++++++---------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 08e8b1ec2d..3dfecf3f48 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, isoDateToDate, 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 diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index f74ab0df51..5abbd84fdf 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -8,6 +8,7 @@ type AssetStatsResponseDto, } 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'; @@ -57,12 +58,9 @@ summary: { totalCount: 0 }, }); - const formatDate = (date: Date) => date.toISOString().slice(0, 10); - const today = new Date(); - const uploadActivityTo = formatDate(today); - const uploadActivityFromDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); - uploadActivityFromDate.setUTCDate(uploadActivityFromDate.getUTCDate() - 52 * 7 + 1); - const uploadActivityFrom = formatDate(uploadActivityFromDate); + const today = DateTime.utc().startOf('day'); + const uploadActivityTo = today.toISODate(); + const uploadActivityFrom = today.minus({ weeks: 52 }).plus({ days: 1 }).toISODate(); const getMyUploadStatistics = async (from: string, to: string) => { const response = await fetch(`/api/users/me/stats/uploads?${new URLSearchParams({ from, to })}`); @@ -108,7 +106,7 @@ }; const getUploadActivityMonths = () => { - const endDate = uploadStats.to ? new Date(`${uploadStats.to}T00:00:00.000Z`) : today; + const endDate = uploadStats.to ? new Date(`${uploadStats.to}T00:00:00.000Z`) : today.toJSDate(); return Array.from({ length: 12 }, (_, index) => { const monthDate = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - 11 + index, 1)); return monthDate.toLocaleString($locale, { month: 'short', timeZone: 'UTC' }); @@ -164,7 +162,7 @@
- {#each getUploadActivityMonths() as month} + {#each getUploadActivityMonths() as month (month)}
{month}
{/each}
@@ -180,10 +178,10 @@
-
- {#each getUploadActivityWeeks() as week} +
+ {#each getUploadActivityWeeks() as week (week[0]?.date)}
- {#each week as day} + {#each week as day (day.date)}
Date: Thu, 28 May 2026 14:45:07 -0400 Subject: [PATCH 04/14] fixing errors for OpenAPI Clients --- mobile/openapi/README.md | 4 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api/users_api.dart | 71 ++++++++++ mobile/openapi/lib/api_client.dart | 6 + .../model/user_upload_stats_response_dto.dart | 134 ++++++++++++++++++ ...pload_stats_response_dto_series_inner.dart | 112 +++++++++++++++ ...ser_upload_stats_response_dto_summary.dart | 103 ++++++++++++++ packages/sdk/src/fetch-client.ts | 35 +++++ 8 files changed, 468 insertions(+) create mode 100644 mobile/openapi/lib/model/user_upload_stats_response_dto.dart create mode 100644 mobile/openapi/lib/model/user_upload_stats_response_dto_series_inner.dart create mode 100644 mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ecc75dd945..ea56c388a3 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -289,6 +289,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 @@ -656,6 +657,9 @@ 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) + - [UserUploadStatsResponseDtoSummary](doc//UserUploadStatsResponseDtoSummary.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 1769c8af75..b8c845c178 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -402,6 +402,9 @@ 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/user_upload_stats_response_dto_summary.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 401cf4e94b..f169c380df 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -252,6 +252,77 @@ class UsersApi { return null; } + /// Get current user upload statistics + /// + /// Retrieve daily upload counts 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, }) 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, + ); + } + + /// Get current user upload statistics + /// + /// Retrieve daily upload counts for the current user. + /// + /// Parameters: + /// + /// * [DateTime] from: + /// Start date in UTC + /// + /// * [DateTime] to: + /// End date in UTC + Future getMyUploadStatistics({ DateTime? from, DateTime? to, }) async { + final response = await getMyUploadStatisticsWithHttpInfo( from: from, to: to, ); + 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 103a5db5f4..3de62e23ea 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -850,6 +850,12 @@ 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 'UserUploadStatsResponseDtoSummary': + return UserUploadStatsResponseDtoSummary.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..2ae1b23e40 --- /dev/null +++ b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart @@ -0,0 +1,134 @@ +// +// 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.summary, + required this.to, + required this.userId, + }); + + /// Start date in UTC + String from; + + List series; + + UserUploadStatsResponseDtoSummary summary; + + /// End date in UTC + String to; + + /// User ID + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDto && + other.from == from && + _deepEquality.equals(other.series, series) && + other.summary == summary && + other.to == to && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (from.hashCode) + + (series.hashCode) + + (summary.hashCode) + + (to.hashCode) + + (userId.hashCode); + + @override + String toString() => 'UserUploadStatsResponseDto[from=$from, series=$series, summary=$summary, to=$to, userId=$userId]'; + + Map toJson() { + final json = {}; + json[r'from'] = this.from; + json[r'series'] = this.series; + json[r'summary'] = this.summary; + json[r'to'] = this.to; + 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']), + summary: UserUploadStatsResponseDtoSummary.fromJson(json[r'summary'])!, + to: mapValueOfType(json, r'to')!, + 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', + 'summary', + 'to', + '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..9ba615601b --- /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: -9007199254740991 + /// 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/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart b/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart new file mode 100644 index 0000000000..11abb45a0c --- /dev/null +++ b/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart @@ -0,0 +1,103 @@ +// +// 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 UserUploadStatsResponseDtoSummary { + /// Returns a new [UserUploadStatsResponseDtoSummary] instance. + UserUploadStatsResponseDtoSummary({ + required this.totalCount, + }); + + /// Total number of uploads + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int totalCount; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDtoSummary && + other.totalCount == totalCount; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (totalCount.hashCode); + + @override + String toString() => 'UserUploadStatsResponseDtoSummary[totalCount=$totalCount]'; + + Map toJson() { + final json = {}; + json[r'totalCount'] = this.totalCount; + return json; + } + + /// Returns a new [UserUploadStatsResponseDtoSummary] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserUploadStatsResponseDtoSummary? fromJson(dynamic value) { + upgradeDto(value, "UserUploadStatsResponseDtoSummary"); + if (value is Map) { + final json = value.cast(); + + return UserUploadStatsResponseDtoSummary( + 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 = UserUploadStatsResponseDtoSummary.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 = UserUploadStatsResponseDtoSummary.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserUploadStatsResponseDtoSummary-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] = UserUploadStatsResponseDtoSummary.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'totalCount', + }; +} + diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index e82074d02c..44e368f3f6 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2655,6 +2655,24 @@ 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; + }[]; + summary: { + /** Total number of uploads */ + totalCount: number; + }; + /** End date in UTC */ + to: string; + /** User ID */ + userId: string; +}; export type CreateProfileImageDto = { /** Profile image file */ file: Blob; @@ -6535,6 +6553,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 */ From 23a6f58a8a9959d17ad726ce7eab684ca3a5a1ea Mon Sep 17 00:00:00 2001 From: Dell Date: Sat, 30 May 2026 17:49:02 -0400 Subject: [PATCH 05/14] Improving the code. --- mobile/openapi/README.md | 1 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api_client.dart | 2 - .../model/user_upload_stats_response_dto.dart | 22 ++-- ...ser_upload_stats_response_dto_summary.dart | 103 ------------------ open-api/immich-openapi-specs.json | 22 ++-- packages/sdk/src/fetch-client.ts | 6 +- server/src/dtos/user.dto.ts | 4 +- server/src/services/user.service.ts | 2 +- .../user-settings/UserUsageStatistic.svelte | 8 +- 10 files changed, 27 insertions(+), 144 deletions(-) delete mode 100644 mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ea56c388a3..f9ec2e60e9 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -659,7 +659,6 @@ Class | Method | HTTP request | Description - [UserUpdateMeDto](doc//UserUpdateMeDto.md) - [UserUploadStatsResponseDto](doc//UserUploadStatsResponseDto.md) - [UserUploadStatsResponseDtoSeriesInner](doc//UserUploadStatsResponseDtoSeriesInner.md) - - [UserUploadStatsResponseDtoSummary](doc//UserUploadStatsResponseDtoSummary.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 b8c845c178..e33e780717 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -404,7 +404,6 @@ 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/user_upload_stats_response_dto_summary.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_client.dart b/mobile/openapi/lib/api_client.dart index 3de62e23ea..bc588f0132 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -854,8 +854,6 @@ class ApiClient { return UserUploadStatsResponseDto.fromJson(value); case 'UserUploadStatsResponseDtoSeriesInner': return UserUploadStatsResponseDtoSeriesInner.fromJson(value); - case 'UserUploadStatsResponseDtoSummary': - return UserUploadStatsResponseDtoSummary.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 index 2ae1b23e40..e09e2436ab 100644 --- a/mobile/openapi/lib/model/user_upload_stats_response_dto.dart +++ b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart @@ -15,8 +15,8 @@ class UserUploadStatsResponseDto { UserUploadStatsResponseDto({ required this.from, this.series = const [], - required this.summary, required this.to, + required this.totalCount, required this.userId, }); @@ -25,11 +25,15 @@ class UserUploadStatsResponseDto { List series; - UserUploadStatsResponseDtoSummary summary; - /// End date in UTC String to; + /// Total number of uploads + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int totalCount; + /// User ID String userId; @@ -37,8 +41,8 @@ class UserUploadStatsResponseDto { bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDto && other.from == from && _deepEquality.equals(other.series, series) && - other.summary == summary && other.to == to && + other.totalCount == totalCount && other.userId == userId; @override @@ -46,19 +50,19 @@ class UserUploadStatsResponseDto { // ignore: unnecessary_parenthesis (from.hashCode) + (series.hashCode) + - (summary.hashCode) + (to.hashCode) + + (totalCount.hashCode) + (userId.hashCode); @override - String toString() => 'UserUploadStatsResponseDto[from=$from, series=$series, summary=$summary, to=$to, userId=$userId]'; + 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'summary'] = this.summary; json[r'to'] = this.to; + json[r'totalCount'] = this.totalCount; json[r'userId'] = this.userId; return json; } @@ -74,8 +78,8 @@ class UserUploadStatsResponseDto { return UserUploadStatsResponseDto( from: mapValueOfType(json, r'from')!, series: UserUploadStatsResponseDtoSeriesInner.listFromJson(json[r'series']), - summary: UserUploadStatsResponseDtoSummary.fromJson(json[r'summary'])!, to: mapValueOfType(json, r'to')!, + totalCount: mapValueOfType(json, r'totalCount')!, userId: mapValueOfType(json, r'userId')!, ); } @@ -126,8 +130,8 @@ class UserUploadStatsResponseDto { static const requiredKeys = { 'from', 'series', - 'summary', 'to', + 'totalCount', 'userId', }; } diff --git a/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart b/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart deleted file mode 100644 index 11abb45a0c..0000000000 --- a/mobile/openapi/lib/model/user_upload_stats_response_dto_summary.dart +++ /dev/null @@ -1,103 +0,0 @@ -// -// 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 UserUploadStatsResponseDtoSummary { - /// Returns a new [UserUploadStatsResponseDtoSummary] instance. - UserUploadStatsResponseDtoSummary({ - required this.totalCount, - }); - - /// Total number of uploads - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int totalCount; - - @override - bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDtoSummary && - other.totalCount == totalCount; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (totalCount.hashCode); - - @override - String toString() => 'UserUploadStatsResponseDtoSummary[totalCount=$totalCount]'; - - Map toJson() { - final json = {}; - json[r'totalCount'] = this.totalCount; - return json; - } - - /// Returns a new [UserUploadStatsResponseDtoSummary] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static UserUploadStatsResponseDtoSummary? fromJson(dynamic value) { - upgradeDto(value, "UserUploadStatsResponseDtoSummary"); - if (value is Map) { - final json = value.cast(); - - return UserUploadStatsResponseDtoSummary( - 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 = UserUploadStatsResponseDtoSummary.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 = UserUploadStatsResponseDtoSummary.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of UserUploadStatsResponseDtoSummary-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] = UserUploadStatsResponseDtoSummary.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'totalCount', - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4e8b366890..cbf27c5b3c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -26050,25 +26050,17 @@ }, "type": "array" }, - "summary": { - "properties": { - "totalCount": { - "description": "Total number of uploads", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - } - }, - "required": [ - "totalCount" - ], - "type": "object" - }, "to": { "description": "End date in UTC", "example": "2024-12-31", "type": "string" }, + "totalCount": { + "description": "Total number of uploads", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, "userId": { "description": "User ID", "format": "uuid", @@ -26079,8 +26071,8 @@ "required": [ "from", "series", - "summary", "to", + "totalCount", "userId" ], "type": "object" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 44e368f3f6..1ede8980f4 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2664,12 +2664,10 @@ export type UserUploadStatsResponseDto = { /** Date in UTC */ date: string; }[]; - summary: { - /** Total number of uploads */ - totalCount: number; - }; /** End date in UTC */ to: string; + /** Total number of uploads */ + totalCount: number; /** User ID */ userId: string; }; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 3dfecf3f48..cf8995f24e 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -50,9 +50,7 @@ const UserUploadStatsResponseSchema = z count: z.int().describe('Number of uploads'), }), ), - summary: z.object({ - totalCount: z.int().describe('Total number of uploads'), - }), + totalCount: z.int().describe('Total number of uploads'), }) .meta({ id: 'UserUploadStatsResponseDto' }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index f08b6dca20..353174b73f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -76,7 +76,7 @@ export class UserService extends BaseService { from: fromDate.toISODate()!, to: toDate.toISODate()!, series, - summary: { totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0) }, + totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0), }; } diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index 5abbd84fdf..5027ac0e7c 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -17,7 +17,7 @@ from: string; to: string; series: Array<{ date: string; count: number }>; - summary: { totalCount: number }; + totalCount: number; }; let timelineStats: AssetStatsResponseDto = $state({ @@ -55,7 +55,7 @@ from: '', to: '', series: [], - summary: { totalCount: 0 }, + totalCount: 0, }); const today = DateTime.utc().startOf('day'); @@ -201,9 +201,7 @@ {$t('more')} - {$t('upload_activity_total_count', { values: { count: uploadStats.summary.totalCount } })} + {$t('upload_activity_total_count', { values: { count: uploadStats.totalCount } })}
From 3d6a63d3b21f0edb39013d738e3478e4e690a0c8 Mon Sep 17 00:00:00 2001 From: Dell Date: Sat, 30 May 2026 19:24:38 -0400 Subject: [PATCH 06/14] Fix code --- .../user-settings/UserUsageStatistic.svelte | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index 5027ac0e7c..8394b7ac0d 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -4,22 +4,16 @@ 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'; - type UserUploadStatsResponseDto = { - userId: string; - from: string; - to: string; - series: Array<{ date: string; count: number }>; - totalCount: number; - }; - let timelineStats: AssetStatsResponseDto = $state({ videos: 0, images: 0, @@ -62,11 +56,6 @@ const uploadActivityTo = today.toISODate(); const uploadActivityFrom = today.minus({ weeks: 52 }).plus({ days: 1 }).toISODate(); - const getMyUploadStatistics = async (from: string, to: string) => { - const response = await fetch(`/api/users/me/stats/uploads?${new URLSearchParams({ from, to })}`); - return (await response.json()) as UserUploadStatsResponseDto; - }; - const getUsage = async () => { [timelineStats, favoriteStats, archiveStats, trashStats, albumStats, uploadStats] = await Promise.all([ getAssetStatistics({ visibility: AssetVisibility.Timeline }), @@ -74,7 +63,7 @@ getAssetStatistics({ visibility: AssetVisibility.Archive }), getAssetStatistics({ isTrashed: true }), getAlbumStatistics(), - getMyUploadStatistics(uploadActivityFrom, uploadActivityTo), + getMyUploadStatistics({ $from: uploadActivityFrom, to: uploadActivityTo }), ]); }; From a4235e96512d7425f2ac3ecb2cf3a8258edccf07 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 24 May 2026 16:03:12 -0500 Subject: [PATCH 09/14] feat: command for user pages (#28554) --- web/src/lib/commands.ts | 146 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 59c25179f2..ddc90a4a42 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,17 +1,35 @@ import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui'; import { mdiAccountMultipleOutline, + mdiAccountOutline, + mdiArchiveArrowDownOutline, mdiBookshelf, mdiCog, + mdiContentDuplicate, + mdiCrosshairsGps, + mdiFolderOutline, + mdiHeartOutline, + mdiImageAlbum, + mdiImageMultipleOutline, + mdiImageSizeSelectLarge, mdiKeyboard, + mdiLink, + mdiLockOutline, + mdiMagnify, + mdiMapOutline, mdiServer, + mdiStateMachine, mdiSync, + mdiTagMultipleOutline, mdiThemeLightDark, + mdiToolboxOutline, + mdiTrashCanOutline, } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { goto } from '$app/navigation'; import { page } from '$app/state'; import { authManager } from '$lib/managers/auth-manager.svelte'; +import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; import { copyToClipboard } from '$lib/utils'; @@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => { }, ].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin })); - return defaultProvider({ name: $t('page'), actions: adminPages }); + const userPages: ActionItem[] = [ + { + title: $t('photos'), + icon: mdiImageMultipleOutline, + onAction: () => goto(Route.photos()), + }, + { + title: $t('explore'), + icon: mdiMagnify, + onAction: () => goto(Route.explore()), + $if: () => authManager.authenticated && featureFlagsManager.value.search, + }, + + { + title: $t('map'), + icon: mdiMapOutline, + onAction: () => goto(Route.map()), + $if: () => authManager.authenticated && featureFlagsManager.value.map, + }, + { + title: $t('people'), + description: $t('people_feature_description'), + icon: mdiAccountOutline, + onAction: () => goto(Route.people()), + $if: () => authManager.authenticated && authManager.preferences.people.enabled, + }, + { + title: $t('shared_links'), + icon: mdiLink, + onAction: () => goto(Route.sharedLinks()), + $if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled, + }, + { + title: $t('recently_added'), + icon: mdiMagnify, + onAction: () => goto(Route.recentlyAdded()), + $if: () => authManager.authenticated, + }, + { + title: $t('sharing'), + icon: mdiAccountMultipleOutline, + onAction: () => goto(Route.sharing()), + $if: () => authManager.authenticated, + }, + { + title: $t('favorites'), + icon: mdiHeartOutline, + onAction: () => goto(Route.favorites()), + $if: () => authManager.authenticated, + }, + { + title: $t('albums'), + description: $t('albums_feature_description'), + icon: mdiImageAlbum, + onAction: () => goto(Route.albums()), + $if: () => authManager.authenticated, + }, + { + title: $t('tags'), + description: $t('tag_feature_description'), + icon: mdiTagMultipleOutline, + onAction: () => goto(Route.tags()), + $if: () => authManager.authenticated && authManager.preferences.tags.enabled, + }, + { + title: $t('folders'), + description: $t('folders_feature_description'), + icon: mdiFolderOutline, + onAction: () => goto(Route.folders()), + $if: () => authManager.authenticated && authManager.preferences.folders.enabled, + }, + { + title: $t('utilities'), + icon: mdiToolboxOutline, + onAction: () => goto(Route.utilities()), + $if: () => authManager.authenticated, + }, + { + title: $t('archive'), + icon: mdiArchiveArrowDownOutline, + onAction: () => goto(Route.archive()), + $if: () => authManager.authenticated, + }, + { + title: $t('locked_folder'), + icon: mdiLockOutline, + onAction: () => goto(Route.locked()), + $if: () => authManager.authenticated, + }, + { + title: $t('trash'), + icon: mdiTrashCanOutline, + onAction: () => goto(Route.trash()), + $if: () => authManager.authenticated && featureFlagsManager.value.trash, + }, + { + title: $t('admin.user_settings'), + icon: mdiCog, + onAction: () => goto(Route.userSettings()), + $if: () => authManager.authenticated, + }, + ].map((route) => ({ $if: () => authManager.authenticated, ...route })); + + const utilityPages: ActionItem[] = [ + { + title: $t('review_duplicates'), + icon: mdiContentDuplicate, + onAction: () => goto(Route.duplicatesUtility()), + }, + { + title: $t('review_large_files'), + icon: mdiImageSizeSelectLarge, + onAction: () => goto(Route.largeFileUtility()), + }, + { + title: $t('manage_geolocation'), + icon: mdiCrosshairsGps, + onAction: () => goto(Route.geolocationUtility()), + }, + { + title: $t('workflows'), + icon: mdiStateMachine, + onAction: () => goto(Route.workflows()), + }, + ].map((route) => ({ ...route, $if: () => authManager.authenticated })); + + return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] }); }; const getMyImmichLink = () => { From 106989b5f990d1d220fba665ee3f5a075102918b Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Sun, 24 May 2026 14:03:46 -0700 Subject: [PATCH 10/14] 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 --- web/src/lib/components/timeline/AssetLayout.svelte | 6 ++++-- .../managers/timeline-manager/timeline-day.svelte.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 4ecf71f517..88f03ef46a 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -1,6 +1,5 @@
- {#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)} + {#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)} {@const position = viewerAsset.position!} {@const asset = viewerAsset.asset!} diff --git a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts index 73b2c259ae..c3d53be67a 100644 --- a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts @@ -17,13 +17,13 @@ export class TimelineDay { height = $state(0); width = $state(0); - isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport)); #top: number = $state(0); #start: number = $state(0); #row = $state(0); #col = $state(0); #deferredLayout = false; + #lastInOrNearViewport = -1; constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { this.index = index; @@ -154,4 +154,13 @@ export class TimelineDay { get absoluteTimelineDayTop() { return this.timelineMonth.top + this.#top; } + + get isInOrNearViewport() { + if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) { + return true; + } + + this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport); + return this.#lastInOrNearViewport !== -1; + } } From d57622ad192f166926bcf8c7173b95269e755972 Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Mon, 25 May 2026 12:32:50 -0400 Subject: [PATCH 11/14] 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 --- machine-learning/pyproject.toml | 2 +- machine-learning/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index f706a1f125..4a74c6405e 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "fastapi>=0.95.2,<1.0", "gunicorn>=21.1.0", "huggingface-hub>=1.0,<2.0", - "insightface>=0.7.3,<1.0", + "insightface>=0.7.3,<2.0", "numpy>=2.4.0,<3.0", "opencv-python-headless>=4.7.0.72,<5.0", "orjson>=3.9.5", diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 5623c553df..145ee5f9e5 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -1004,7 +1004,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.95.2,<1.0" }, { name = "gunicorn", specifier = ">=21.1.0" }, { name = "huggingface-hub", specifier = ">=1.0,<2.0" }, - { name = "insightface", specifier = ">=0.7.3,<1.0" }, + { name = "insightface", specifier = ">=0.7.3,<2.0" }, { name = "numpy", specifier = ">=2.4.0,<3.0" }, { name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" }, From ea33d736c5b8486ef8df22d2d94ffd71682a8c5f Mon Sep 17 00:00:00 2001 From: Dell Date: Sat, 30 May 2026 21:16:32 -0400 Subject: [PATCH 12/14] Triggering the actions. --- mobile/openapi/lib/api/users_api.dart | 4 ++-- open-api/immich-openapi-specs.json | 2 +- server/src/controllers/user.controller.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index f169c380df..568df49978 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -254,7 +254,7 @@ class UsersApi { /// Get current user upload statistics /// - /// Retrieve daily upload counts for the current user. + /// Retrieve daily upload counts and totals for the current user. /// /// Note: This method returns the HTTP [Response]. /// @@ -299,7 +299,7 @@ class UsersApi { /// Get current user upload statistics /// - /// Retrieve daily upload counts for the current user. + /// Retrieve daily upload counts and totals for the current user. /// /// Parameters: /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cbf27c5b3c..7cd7d4f525 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14479,7 +14479,7 @@ }, "/users/me/stats/uploads": { "get": { - "description": "Retrieve daily upload counts for the current user.", + "description": "Retrieve daily upload counts and totals for the current user.", "operationId": "getMyUploadStatistics", "parameters": [ { diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 2961f28802..015878d7d5 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -71,7 +71,7 @@ export class UserController { @Authenticated({ permission: Permission.UserRead }) @Endpoint({ summary: 'Get current user upload statistics', - description: 'Retrieve daily upload counts for the current user.', + 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 { From 3d386ec86e636f411624c05bd6cc8c8f41cb1e99 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Sat, 30 May 2026 20:23:33 -0500 Subject: [PATCH 13/14] bad merge --- web/src/lib/components/timeline/AssetLayout.svelte | 3 --- .../managers/timeline-manager/timeline-day.svelte.ts | 10 ---------- 2 files changed, 13 deletions(-) diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 94b01d65ac..59b8499d79 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -30,9 +30,6 @@ const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); - - const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport)); - const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport)); diff --git a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts index 2eb9750086..2515acc385 100644 --- a/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-day.svelte.ts @@ -46,7 +46,6 @@ export class TimelineDay { #row = $state(0); #col = $state(0); #deferredLayout = false; - #lastInOrNearViewport = -1; constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { this.index = index; @@ -200,13 +199,4 @@ export class TimelineDay { get absoluteTimelineDayTop() { return this.timelineMonth.top + this.#top; } - - get isInOrNearViewport() { - if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) { - return true; - } - - this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport); - return this.#lastInOrNearViewport !== -1; - } } From 963f822606a0ccd8d5c6089f140586249930b012 Mon Sep 17 00:00:00 2001 From: Dell Date: Sat, 30 May 2026 22:30:22 -0400 Subject: [PATCH 14/14] Fix code --- .../lib/model/user_upload_stats_response_dto.dart | 2 +- .../user_upload_stats_response_dto_series_inner.dart | 2 +- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/user.dto.ts | 4 ++-- .../(user)/user-settings/UserUsageStatistic.svelte | 11 ++++++----- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/mobile/openapi/lib/model/user_upload_stats_response_dto.dart b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart index e09e2436ab..0816bb65ac 100644 --- a/mobile/openapi/lib/model/user_upload_stats_response_dto.dart +++ b/mobile/openapi/lib/model/user_upload_stats_response_dto.dart @@ -30,7 +30,7 @@ class UserUploadStatsResponseDto { /// Total number of uploads /// - /// Minimum value: -9007199254740991 + /// Minimum value: 0 /// Maximum value: 9007199254740991 int totalCount; 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 index 9ba615601b..c0eec1bfcb 100644 --- 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 @@ -19,7 +19,7 @@ class UserUploadStatsResponseDtoSeriesInner { /// Number of uploads /// - /// Minimum value: -9007199254740991 + /// Minimum value: 0 /// Maximum value: 9007199254740991 int count; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1b72aa58bb..53fbcc7477 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -26168,7 +26168,7 @@ "count": { "description": "Number of uploads", "maximum": 9007199254740991, - "minimum": -9007199254740991, + "minimum": 0, "type": "integer" }, "date": { @@ -26193,7 +26193,7 @@ "totalCount": { "description": "Total number of uploads", "maximum": 9007199254740991, - "minimum": -9007199254740991, + "minimum": 0, "type": "integer" }, "userId": { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index cf8995f24e..6454dd89c7 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -47,10 +47,10 @@ const UserUploadStatsResponseSchema = z series: z.array( z.object({ date: z.string().describe('Date in UTC').meta({ example: '2024-01-01' }), - count: z.int().describe('Number of uploads'), + count: z.int().nonnegative().describe('Number of uploads'), }), ), - totalCount: z.int().describe('Total number of uploads'), + totalCount: z.int().nonnegative().describe('Total number of uploads'), }) .meta({ id: 'UserUploadStatsResponseDto' }); diff --git a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte index 8394b7ac0d..eb432b0ac1 100644 --- a/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte +++ b/web/src/routes/(user)/user-settings/UserUsageStatistic.svelte @@ -68,8 +68,9 @@ }; const getUploadActivityWeeks = () => { - const days = uploadStats.series.map((item) => ({ ...item, dateValue: new Date(`${item.date}T00:00:00.000Z`) })); - return Array.from({ length: Math.ceil(days.length / 7) }, (_, index) => days.slice(index * 7, index * 7 + 7)); + return Array.from({ length: Math.ceil(uploadStats.series.length / 7) }, (_, index) => + uploadStats.series.slice(index * 7, index * 7 + 7), + ); }; const getUploadActivityLevel = (count: number) => { @@ -95,10 +96,10 @@ }; const getUploadActivityMonths = () => { - const endDate = uploadStats.to ? new Date(`${uploadStats.to}T00:00:00.000Z`) : today.toJSDate(); + const endDate = uploadStats.to ? DateTime.fromISO(uploadStats.to, { zone: 'utc' }) : today; return Array.from({ length: 12 }, (_, index) => { - const monthDate = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth() - 11 + index, 1)); - return monthDate.toLocaleString($locale, { month: 'short', timeZone: 'UTC' }); + const monthDate = endDate.minus({ months: 11 - index }); + return monthDate.toLocaleString({ month: 'short' }, { locale: $locale }); }); };