pull/24025/head
Abhijeet Sanjiv Bonde 2025-11-20 05:56:05 -05:00
parent edbdc14178
commit f04cb7d2d3
7 changed files with 440 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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