refactor
parent
edbdc14178
commit
f04cb7d2d3
|
|
@ -13349,6 +13349,80 @@
|
|||
"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": {
|
||||
"delete": {
|
||||
"description": "Delete the profile image of the current user.",
|
||||
|
|
@ -15920,6 +15994,25 @@
|
|||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
|
|
@ -22150,6 +22243,19 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UploadsSummaryDto": {
|
||||
"properties": {
|
||||
"totalCount": {
|
||||
"description": "Total uploads across the series",
|
||||
"example": 42,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UsageByUserDto": {
|
||||
"properties": {
|
||||
"photos": {
|
||||
|
|
@ -22593,6 +22699,42 @@
|
|||
},
|
||||
"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": {
|
||||
"properties": {
|
||||
"authStatus": {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
|
|
@ -21,6 +22,10 @@ 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 {
|
||||
UserUploadsStatsQueryDto,
|
||||
UserUploadsStatsResponseDto,
|
||||
} from 'src/dtos/user-stats.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
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 { UUIDParamDto } from 'src/validation';
|
||||
|
||||
|
||||
@ApiTags(ApiTag.Users)
|
||||
@Controller(RouteKey.User)
|
||||
export class UserController {
|
||||
|
|
@ -164,6 +170,23 @@ export class UserController {
|
|||
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')
|
||||
@Authenticated({ permission: Permission.UserRead })
|
||||
@Endpoint({
|
||||
|
|
@ -215,4 +238,4 @@ export class UserController {
|
|||
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
|
||||
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"
|
||||
where
|
||||
"stacked"."stackId" = "stack"."id"
|
||||
group by
|
||||
"stack"."id"
|
||||
) as "stacked_assets" on "stack"."id" is not null
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
|
|
@ -504,3 +502,23 @@ where
|
|||
and "libraryId" = $2::uuid
|
||||
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;
|
||||
}
|
||||
|
||||
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 { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.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 { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
|
|
@ -34,6 +35,59 @@ export class UserService extends BaseService {
|
|||
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> {
|
||||
const user = await this.userRepository.get(auth.user.id, {});
|
||||
if (!user) {
|
||||
|
|
@ -278,4 +332,4 @@ export class UserService extends BaseService {
|
|||
}
|
||||
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 () => {
|
||||
await getUsage();
|
||||
await loadActivity();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -109,3 +186,34 @@
|
|||
</table>
|
||||
</div>
|
||||
</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