refactor
parent
edbdc14178
commit
f04cb7d2d3
|
|
@ -13349,6 +13349,80 @@
|
||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/users/me/stats/uploads": {
|
||||||
|
"get": {
|
||||||
|
"description": "Return daily upload counts between the given dates for the current user (used by the Account Usage heatmap). Query params: from=YYYY-MM-DD (optional), to=YYYY-MM-DD (exclusive, optional), tz=IANA timezone like \"Europe/Berlin\" (optional, default UTC).",
|
||||||
|
"operationId": "getMyUploadStatsUploads",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "from",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Inclusive start date (YYYY-MM-DD). Defaults to 52 weeks before `to`/now.",
|
||||||
|
"schema": {
|
||||||
|
"pattern": "/^\\d{4}-\\d{2}-\\d{2}$/",
|
||||||
|
"example": "2024-11-03",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "to",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Exclusive end date (YYYY-MM-DD). Defaults to today.",
|
||||||
|
"schema": {
|
||||||
|
"pattern": "/^\\d{4}-\\d{2}-\\d{2}$/",
|
||||||
|
"example": "2025-11-03",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tz",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "IANA timezone for grouping days (default: UTC).",
|
||||||
|
"schema": {
|
||||||
|
"example": "UTC",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UserUploadsStatsResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Get my upload activity (daily)",
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "user.read"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/users/profile-image": {
|
"/users/profile-image": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Delete the profile image of the current user.",
|
"description": "Delete the profile image of the current user.",
|
||||||
|
|
@ -15920,6 +15994,25 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"DayCountDto": {
|
||||||
|
"properties": {
|
||||||
|
"count": {
|
||||||
|
"description": "Number of uploads on that date",
|
||||||
|
"example": 3,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"description": "Date (YYYY-MM-DD)",
|
||||||
|
"example": "2025-01-15",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"count",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"DownloadArchiveInfo": {
|
"DownloadArchiveInfo": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetIds": {
|
"assetIds": {
|
||||||
|
|
@ -22150,6 +22243,19 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"UploadsSummaryDto": {
|
||||||
|
"properties": {
|
||||||
|
"totalCount": {
|
||||||
|
"description": "Total uploads across the series",
|
||||||
|
"example": 42,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"totalCount"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"UsageByUserDto": {
|
"UsageByUserDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"photos": {
|
"photos": {
|
||||||
|
|
@ -22593,6 +22699,42 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"UserUploadsStatsResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"description": "First date in the filled series (YYYY-MM-DD)",
|
||||||
|
"example": "2024-11-03",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"description": "One item per calendar day in range",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DayCountDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$ref": "#/components/schemas/UploadsSummaryDto"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"description": "Last date in the filled series (YYYY-MM-DD)",
|
||||||
|
"example": "2025-11-02",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"description": "User id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"from",
|
||||||
|
"series",
|
||||||
|
"summary",
|
||||||
|
"to",
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ValidateAccessTokenResponseDto": {
|
"ValidateAccessTokenResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"authStatus": {
|
"authStatus": {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
Res,
|
Res,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
|
@ -21,6 +22,10 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
|
import {
|
||||||
|
UserUploadsStatsQueryDto,
|
||||||
|
UserUploadsStatsResponseDto,
|
||||||
|
} from 'src/dtos/user-stats.dto';
|
||||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
|
|
@ -30,6 +35,7 @@ import { UserService } from 'src/services/user.service';
|
||||||
import { sendFile } from 'src/utils/file';
|
import { sendFile } from 'src/utils/file';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
|
|
||||||
@ApiTags(ApiTag.Users)
|
@ApiTags(ApiTag.Users)
|
||||||
@Controller(RouteKey.User)
|
@Controller(RouteKey.User)
|
||||||
export class UserController {
|
export class UserController {
|
||||||
|
|
@ -164,6 +170,23 @@ export class UserController {
|
||||||
await this.service.deleteOnboarding(auth);
|
await this.service.deleteOnboarding(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Get('me/stats/uploads')
|
||||||
|
@Authenticated({ permission: Permission.UserRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get my upload activity (daily)',
|
||||||
|
description:
|
||||||
|
'Return daily upload counts between the given dates for the current user (used by the Account Usage heatmap). ' +
|
||||||
|
'Query params: from=YYYY-MM-DD (optional), to=YYYY-MM-DD (exclusive, optional), tz=IANA timezone like "Europe/Berlin" (optional, default UTC).',
|
||||||
|
history: new HistoryBuilder().added('v2'),
|
||||||
|
})
|
||||||
|
getMyUploadStatsUploads(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Query() query: UserUploadsStatsQueryDto,
|
||||||
|
): Promise<UserUploadsStatsResponseDto> {
|
||||||
|
return this.service.getMyUploadStatsUploads(auth.user.id, query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Authenticated({ permission: Permission.UserRead })
|
@Authenticated({ permission: Permission.UserRead })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
|
|
@ -215,4 +238,4 @@ export class UserController {
|
||||||
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
|
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
|
||||||
await sendFile(res, next, () => this.service.getProfileImage(id), this.logger);
|
await sendFile(res, next, () => this.service.getProfileImage(id), this.logger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class UserUploadsStatsQueryDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Inclusive start date (YYYY-MM-DD). Defaults to 52 weeks before `to`/now.',
|
||||||
|
example: '2024-11-03',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'from must be YYYY-MM-DD' })
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Exclusive end date (YYYY-MM-DD). Defaults to today.',
|
||||||
|
example: '2025-11-03',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'to must be YYYY-MM-DD' })
|
||||||
|
to?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'IANA timezone for grouping days (default: UTC).',
|
||||||
|
example: 'UTC',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tz?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DayCountDto {
|
||||||
|
@ApiProperty({ description: 'Date (YYYY-MM-DD)', example: '2025-01-15' })
|
||||||
|
date!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Number of uploads on that date', example: 3 })
|
||||||
|
count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadsSummaryDto {
|
||||||
|
@ApiProperty({ description: 'Total uploads across the series', example: 42 })
|
||||||
|
totalCount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserUploadsStatsResponseDto {
|
||||||
|
@ApiProperty({ description: 'User id' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First date in the filled series (YYYY-MM-DD)', example: '2024-11-03' })
|
||||||
|
from!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last date in the filled series (YYYY-MM-DD)', example: '2025-11-02' })
|
||||||
|
to!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [DayCountDto], description: 'One item per calendar day in range' })
|
||||||
|
series!: DayCountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: UploadsSummaryDto })
|
||||||
|
summary!: UploadsSummaryDto;
|
||||||
|
}
|
||||||
|
|
@ -464,8 +464,6 @@ from
|
||||||
"asset" as "stacked"
|
"asset" as "stacked"
|
||||||
where
|
where
|
||||||
"stacked"."stackId" = "stack"."id"
|
"stacked"."stackId" = "stack"."id"
|
||||||
group by
|
|
||||||
"stack"."id"
|
|
||||||
) as "stacked_assets" on "stack"."id" is not null
|
) as "stacked_assets" on "stack"."id" is not null
|
||||||
where
|
where
|
||||||
"asset"."ownerId" = any ($1::uuid[])
|
"asset"."ownerId" = any ($1::uuid[])
|
||||||
|
|
@ -504,3 +502,23 @@ where
|
||||||
and "libraryId" = $2::uuid
|
and "libraryId" = $2::uuid
|
||||||
and "isExternal" = $3
|
and "isExternal" = $3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
select
|
||||||
|
"date",
|
||||||
|
count(*) as "count"
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
to_char(
|
||||||
|
date_trunc('day', ("a"."createdAt" AT TIME ZONE $3)),
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
) as "date"
|
||||||
|
from "asset" as "a"
|
||||||
|
where
|
||||||
|
"a"."ownerId" = $1
|
||||||
|
and "a"."deletedAt" is null
|
||||||
|
and "a"."createdAt" >= $2
|
||||||
|
and "a"."createdAt" < $4
|
||||||
|
) s
|
||||||
|
group by "date"
|
||||||
|
order by "date" asc
|
||||||
|
|
|
||||||
|
|
@ -912,4 +912,37 @@ export class AssetRepository {
|
||||||
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserDailyUploads(
|
||||||
|
userId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
tz: string = 'UTC',
|
||||||
|
): Promise<Array<{ date: string; count: number }>> {
|
||||||
|
const dayText = sql<string>`
|
||||||
|
to_char(
|
||||||
|
date_trunc('day', (a."createdAt" AT TIME ZONE ${tz})),
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const daySub = this.db
|
||||||
|
.selectFrom('asset as a')
|
||||||
|
.select(dayText.as('day'))
|
||||||
|
.where('a.ownerId', '=', asUuid(userId))
|
||||||
|
.where('a.deletedAt', 'is', null)
|
||||||
|
.where('a.createdAt', '>=', from)
|
||||||
|
.where('a.createdAt', '<', to) // end-exclusive
|
||||||
|
.as('d');
|
||||||
|
|
||||||
|
const rows = await this.db
|
||||||
|
.selectFrom(daySub)
|
||||||
|
.select('day')
|
||||||
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
|
.groupBy('day')
|
||||||
|
.orderBy('day', 'asc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return rows.map((r) => ({ date: String(r.day), count: Number(r.count) }));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
|
import { UserUploadsStatsQueryDto, UserUploadsStatsResponseDto } from 'src/dtos/user-stats.dto';
|
||||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||||
|
|
@ -34,6 +35,59 @@ export class UserService extends BaseService {
|
||||||
return users.map((user) => mapUser(user));
|
return users.map((user) => mapUser(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyUploadStatsUploads(
|
||||||
|
userId: string,
|
||||||
|
{ from, to, tz: timeZone = 'UTC' }: UserUploadsStatsQueryDto,
|
||||||
|
): Promise<UserUploadsStatsResponseDto> {
|
||||||
|
const now = new Date();
|
||||||
|
const endExclusive = to ? new Date(to) : now;
|
||||||
|
const startInclusive = from ? new Date(from) : new Date(endExclusive);
|
||||||
|
|
||||||
|
if (!from) {
|
||||||
|
startInclusive.setUTCDate(startInclusive.getUTCDate() - 7 * 52);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSeries = await this.assetRepository.getUserDailyUploads(
|
||||||
|
userId,
|
||||||
|
startInclusive,
|
||||||
|
endExclusive,
|
||||||
|
timeZone,
|
||||||
|
);
|
||||||
|
|
||||||
|
const countByDate = new Map(rawSeries.map((point) => [point.date, point.count]));
|
||||||
|
|
||||||
|
const cursor = new Date(Date.UTC(
|
||||||
|
startInclusive.getUTCFullYear(),
|
||||||
|
startInclusive.getUTCMonth(),
|
||||||
|
startInclusive.getUTCDate(),
|
||||||
|
));
|
||||||
|
const endCursor = new Date(Date.UTC(
|
||||||
|
endExclusive.getUTCFullYear(),
|
||||||
|
endExclusive.getUTCMonth(),
|
||||||
|
endExclusive.getUTCDate(),
|
||||||
|
));
|
||||||
|
|
||||||
|
const series: Array<{ date: string; count: number }> = [];
|
||||||
|
while (cursor < endCursor) {
|
||||||
|
const year = cursor.getUTCFullYear();
|
||||||
|
const month = String(cursor.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(cursor.getUTCDate()).padStart(2, '0');
|
||||||
|
const dateKey = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
series.push({ date: dateKey, count: countByDate.get(dateKey) ?? 0 });
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
from: series[0]?.date ?? startInclusive.toISOString().slice(0, 10),
|
||||||
|
to: series.at(-1)?.date ?? endExclusive.toISOString().slice(0, 10),
|
||||||
|
series,
|
||||||
|
summary: { totalCount: series.reduce((sum, point) => sum + point.count, 0) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
|
async getMe(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||||
const user = await this.userRepository.get(auth.user.id, {});
|
const user = await this.userRepository.get(auth.user.id, {});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -278,4 +332,4 @@ export class UserService extends BaseService {
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +50,85 @@
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Day = { date: string; count: number };
|
||||||
|
|
||||||
|
let days = $state([] as Day[]);
|
||||||
|
let maxCount = $state(1);
|
||||||
|
let weeks = $state([] as Day[][]);
|
||||||
|
|
||||||
|
// Default range: last 52 weeks
|
||||||
|
const today = new Date();
|
||||||
|
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1));
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setUTCDate(start.getUTCDate() - 7 * 52);
|
||||||
|
|
||||||
|
while (start.getUTCDay() !== 0) {
|
||||||
|
start.setUTCDate(start.getUTCDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toISODate(d: Date) {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucket(count: number) {
|
||||||
|
if (count === 0) return 0;
|
||||||
|
const q = maxCount / 4;
|
||||||
|
if (count <= q) return 1;
|
||||||
|
if (count <= 2 * q) return 2;
|
||||||
|
if (count <= 3 * q) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heatmapColors = [
|
||||||
|
'bg-[var(--gray-3,#e9ecef)]', // b0
|
||||||
|
'bg-[#cdeac0]', // b1
|
||||||
|
'bg-[#9bd18f]', // b2
|
||||||
|
'bg-[#5fbf5b]', // b3
|
||||||
|
'bg-[#2f9445]' // b4
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadActivity() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
from: toISODate(start),
|
||||||
|
to: toISODate(end),
|
||||||
|
tz: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/users/me/stats/uploads?${params.toString()}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const data: Day[] = json.series ?? [];
|
||||||
|
|
||||||
|
days = data;
|
||||||
|
maxCount = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (days.length) {
|
||||||
|
const byDate = new Map(days.map((d) => [d.date, d.count]));
|
||||||
|
const grid: Day[][] = [];
|
||||||
|
const cursor = new Date(start);
|
||||||
|
|
||||||
|
while (cursor < end) {
|
||||||
|
const col: Day[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const key = toISODate(cursor);
|
||||||
|
col.push({ date: key, count: byDate.get(key) ?? 0 });
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
grid.push(col);
|
||||||
|
}
|
||||||
|
weeks = grid;
|
||||||
|
} else {
|
||||||
|
weeks = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await getUsage();
|
await getUsage();
|
||||||
|
await loadActivity();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -109,3 +186,34 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="my-6">
|
||||||
|
<h3 class="text-sm font-medium text-primary dark:text-white mb-2">Upload Activity (last 52 weeks)</h3>
|
||||||
|
|
||||||
|
{#if weeks && weeks.length}
|
||||||
|
<div class="w-full rounded-md border dark:border-immich-dark-gray bg-subtle/20 p-4 overflow-x-auto">
|
||||||
|
<div class="flex flex-row justify-between w-full gap-[1px]" aria-label="Upload activity heatmap">
|
||||||
|
{#each weeks as column}
|
||||||
|
<div class="flex flex-col gap-[1px]">
|
||||||
|
{#each column as day}
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-[2px] {heatmapColors[bucket(day.count)]}"
|
||||||
|
title="{day.date}: {day.count} upload(s)"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 mt-3 text-xs opacity-80">
|
||||||
|
<span class="w-3 h-3 rounded-[2px] inline-block {heatmapColors[0]}" title="0"></span>
|
||||||
|
<span class="w-3 h-3 rounded-[2px] inline-block {heatmapColors[1]}" title="low"></span>
|
||||||
|
<span class="w-3 h-3 rounded-[2px] inline-block {heatmapColors[2]}" title="medium"></span>
|
||||||
|
<span class="w-3 h-3 rounded-[2px] inline-block {heatmapColors[3]}" title="high"></span>
|
||||||
|
<span class="w-3 h-3 rounded-[2px] inline-block {heatmapColors[4]}" title="very high"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-subtle">No uploads yet.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
Loading…
Reference in New Issue