From d6d094738366aaf4d2fcefdfe2702f50c72ba5b0 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 22 Sep 2026 04:30:43 -0500 Subject: [PATCH] feat: birthday memories (#30831) --- e2e/src/ui/generators/memory/model-objects.ts | 4 +- i18n/en.json | 1 + mobile/lib/domain/models/memory.model.dart | 1 + .../repositories/memory.repository.dart | 1 + .../repositories/sync_stream.repository.dart | 1 + open-api/immich-openapi-specs.json | 45 ++- packages/sdk/src/fetch-client.ts | 15 +- .../src/controllers/memory.controller.spec.ts | 30 ++ server/src/dtos/memory.dto.ts | 60 ++-- server/src/enum.ts | 3 + server/src/queries/memory.repository.sql | 76 ++++ server/src/queries/person.repository.sql | 46 +++ server/src/repositories/memory.repository.ts | 73 +++- server/src/repositories/person.repository.ts | 38 ++ server/src/services/memory.service.spec.ts | 9 +- server/src/services/memory.service.ts | 78 ++++- server/src/types.ts | 2 + server/src/utils/date.spec.ts | 23 +- server/src/utils/date.ts | 7 + .../repositories/memory.repository.spec.ts | 235 +++++++++++++ .../repositories/person.repository.spec.ts | 90 +++++ .../specs/services/memory.service.spec.ts | 329 +++++++++++++++++- web/src/lib/utils.ts | 4 + 23 files changed, 1111 insertions(+), 60 deletions(-) create mode 100644 server/test/medium/specs/repositories/memory.repository.spec.ts diff --git a/e2e/src/ui/generators/memory/model-objects.ts b/e2e/src/ui/generators/memory/model-objects.ts index 02a3909547..d270a187e6 100644 --- a/e2e/src/ui/generators/memory/model-objects.ts +++ b/e2e/src/ui/generators/memory/model-objects.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { MemoryType, type MemoryResponseDto, type OnThisDayDto } from '@immich/sdk'; +import { MemoryType, type MemoryResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { toAssetResponseDto } from 'src/ui/generators/timeline/rest-response.js'; import type { MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js'; @@ -25,7 +25,7 @@ export function generateMemory(config: MemoryConfig, assets: MockTimelineAsset[] return { id: memoryId, assets: assets.map((asset) => toAssetResponseDto(asset)), - data: { year: config.year } as OnThisDayDto, + data: { year: config.year }, memoryAt: config.memoryAt, createdAt: now, updatedAt: now, diff --git a/i18n/en.json b/i18n/en.json index 87e89e577a..cfccc3d42d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -663,6 +663,7 @@ "biometric_no_options": "No biometric options available", "biometric_not_available": "Biometric authentication is not available on this device", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", + "birthday_memory_title": "{name}'s birthday", "blurred_background": "Blurred background", "browse_templates": "Browse templates", "bugs_and_feature_requests": "Bugs & Feature Requests", diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart index 35fc98ad63..465b2ef6c3 100644 --- a/mobile/lib/domain/models/memory.model.dart +++ b/mobile/lib/domain/models/memory.model.dart @@ -9,6 +9,7 @@ part 'memory.model.freezed.dart'; enum MemoryTypeEnum { // do not change this order! onThisDay, + birthday, } @Freezed(fromJson: false, toJson: false) diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index f9755e7f29..613e0e247e 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -24,6 +24,7 @@ class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMix ), ]) ..where(_db.memoryEntity.ownerId.equals(ownerId)) + ..where(_db.memoryEntity.type.equalsValue(MemoryTypeEnum.onThisDay)) ..where(_db.memoryEntity.deletedAt.isNull()); if (onlyFavorites) { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index e418ab6feb..54188741e1 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -952,6 +952,7 @@ extension on AssetOrder { extension on MemoryType { MemoryTypeEnum toMemoryType() => switch (this) { MemoryType.onThisDay => MemoryTypeEnum.onThisDay, + MemoryType.birthday => MemoryTypeEnum.birthday, }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c333dd7be2..0e20c69f79 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -23056,7 +23056,7 @@ "type": "array" }, "data": { - "$ref": "#/components/schemas/OnThisDayDto" + "$ref": "#/components/schemas/MemoryDataDto" }, "hideAt": { "description": "Date when memory should be hidden", @@ -23123,6 +23123,30 @@ ], "type": "object" }, + "MemoryDataDto": { + "properties": { + "personId": { + "description": "Person ID (birthday memories)", + "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" + }, + "personName": { + "description": "Name of the person when the memory was created (birthday memories)", + "type": "string" + }, + "year": { + "description": "Year of the memory", + "maximum": 9999, + "minimum": 1000, + "type": "integer" + } + }, + "required": [ + "year" + ], + "type": "object" + }, "MemoryResponseDto": { "properties": { "assets": { @@ -23139,7 +23163,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/OnThisDayDto" + "$ref": "#/components/schemas/MemoryDataDto" }, "deletedAt": { "description": "Deletion date", @@ -23242,7 +23266,8 @@ "MemoryType": { "description": "Memory type", "enum": [ - "on_this_day" + "on_this_day", + "birthday" ], "type": "string" }, @@ -24521,20 +24546,6 @@ ], "type": "string" }, - "OnThisDayDto": { - "properties": { - "year": { - "description": "Year for on this day memory", - "maximum": 9999, - "minimum": 1000, - "type": "integer" - } - }, - "required": [ - "year" - ], - "type": "object" - }, "OnboardingDto": { "properties": { "isOnboarded": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 4e87ba7c21..0b5a8b38c2 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1842,15 +1842,19 @@ export type MapReverseGeocodeResponseDto = { /** State/Province name */ state: string | null; }; -export type OnThisDayDto = { - /** Year for on this day memory */ +export type MemoryDataDto = { + /** Person ID (birthday memories) */ + personId?: string; + /** Name of the person when the memory was created (birthday memories) */ + personName?: string; + /** Year of the memory */ year: number; }; export type MemoryResponseDto = { assets: AssetResponseDto[]; /** Creation date */ createdAt: string; - data: OnThisDayDto; + data: MemoryDataDto; /** Deletion date */ deletedAt?: string; /** Date when memory should be hidden */ @@ -1874,7 +1878,7 @@ export type MemoryResponseDto = { export type MemoryCreateDto = { /** Asset IDs to associate with memory */ assetIds?: string[]; - data: OnThisDayDto; + data: MemoryDataDto; /** Date when memory should be hidden */ hideAt?: string; /** Is memory saved */ @@ -8218,7 +8222,8 @@ export enum MemorySearchOrder { Random = "random" } export enum MemoryType { - OnThisDay = "on_this_day" + OnThisDay = "on_this_day", + Birthday = "birthday" } export enum PartnerDirection { SharedBy = "shared-by", diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index a42b539fdb..39ba2fb60d 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -44,6 +44,36 @@ describe(MemoryController.name, () => { ); }); + it('should validate data when type is birthday', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/memories') + .send({ + type: 'birthday', + data: { year: 1990 }, + memoryAt: new Date(2021).toISOString(), + }); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { path: ['data', 'personId'], message: 'Required for birthday memories' }, + { path: ['data', 'personName'], message: 'Required for birthday memories' }, + ]), + ); + }); + + it('should accept a birthday memory', async () => { + const { status } = await request(ctx.getHttpServer()) + .post('/memories') + .send({ + type: 'birthday', + data: { personId: factory.uuid(), personName: 'Alice', year: 1990 }, + memoryAt: new Date(2021).toISOString(), + }); + + expect(status).toBe(201); + }); + it('should accept showAt and hideAt', async () => { const { status } = await request(ctx.getHttpServer()) .post('/memories') diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 544afd3e52..4b3977ba72 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -21,13 +21,15 @@ const MemorySearchSchema = z }) .meta({ id: 'MemorySearchDto' }); -const OnThisDaySchema = z +const MemoryDataSchema = z .object({ - year: z.int().min(1000).max(9999).describe('Year for on this day memory'), + year: z.int().min(1000).max(9999).describe('Year of the memory'), + personId: z.uuidv4().optional().describe('Person ID (birthday memories)'), + personName: z.string().optional().describe('Name of the person when the memory was created (birthday memories)'), }) - .meta({ id: 'OnThisDayDto' }); + .meta({ id: 'MemoryDataDto' }); -type MemoryData = z.infer; +type MemoryData = z.infer; const MemoryUpdateSchema = nonEmptyPartial({ isSaved: z.boolean().describe('Is memory saved'), @@ -35,24 +37,36 @@ const MemoryUpdateSchema = nonEmptyPartial({ memoryAt: isoDatetimeToDate.describe('Memory date'), }).meta({ id: 'MemoryUpdateDto' }); -const MemoryCreateSchema = z - .object({ - type: MemoryTypeSchema, - data: OnThisDaySchema, - memoryAt: isoDatetimeToDate.describe('Memory date'), - assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs to associate with memory'), - isSaved: z.boolean().optional().describe('Is memory saved'), - seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'), - showAt: isoDatetimeToDate - .optional() - .describe('Date when memory should be shown') - .meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()), - hideAt: isoDatetimeToDate - .optional() - .describe('Date when memory should be hidden') - .meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()), - }) - .meta({ id: 'MemoryCreateDto' }); +const MemoryCreateBaseSchema = z.object({ + type: MemoryTypeSchema, + data: MemoryDataSchema, + memoryAt: isoDatetimeToDate.describe('Memory date'), + assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs to associate with memory'), + isSaved: z.boolean().optional().describe('Is memory saved'), + seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'), + showAt: isoDatetimeToDate + .optional() + .describe('Date when memory should be shown') + .meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()), + hideAt: isoDatetimeToDate + .optional() + .describe('Date when memory should be hidden') + .meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()), +}); + +const MemoryCreateSchema = MemoryCreateBaseSchema.superRefine((dto, ctx) => { + if (dto.type === MemoryType.Birthday) { + for (const key of ['personId', 'personName'] as const) { + if (dto.data[key] === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['data', key], + message: `Required for ${MemoryType.Birthday} memories`, + }); + } + } + } +}).meta({ id: 'MemoryCreateDto' }); const MemoryStatisticsResponseSchema = z .object({ @@ -72,7 +86,7 @@ const MemoryResponseSchema = z hideAt: isoDatetimeToDate.optional().describe('Date when memory should be hidden'), ownerId: z.uuidv4().describe('Owner user ID'), type: MemoryTypeSchema, - data: OnThisDaySchema, + data: MemoryDataSchema, isSaved: z.boolean().describe('Is memory saved'), assets: z.array(AssetResponseSchema), }) diff --git a/server/src/enum.ts b/server/src/enum.ts index 75f6f0fc09..622014cd1f 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -89,6 +89,9 @@ export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting p export enum MemoryType { /** pictures taken on this day X years ago */ OnThisDay = 'on_this_day', + + /** pictures of a person, shown leading up to their birthday */ + Birthday = 'birthday', } export const MemoryTypeSchema = z.enum(MemoryType).describe('Memory type').meta({ id: 'MemoryType' }); diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 5386c2fedc..7c4f177a49 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -203,6 +203,82 @@ order by "showAt" desc nulls last, "memoryAt" desc +-- MemoryRepository.getPersonBirthdayYears +select distinct + date_part( + 'year', + (asset."localDateTime" at time zone 'UTC')::date + )::int as "year" +from + "asset" +where + "asset"."ownerId" = $1 + and "asset"."visibility" = $2 + and "asset"."deletedAt" is null + and exists ( + select + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."personGroupId" = $3 + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true + ) + and exists ( + select + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $4 + ) + and date_part( + 'month', + (asset."localDateTime" at time zone 'UTC')::date + )::int = $5 + and date_part( + 'day', + (asset."localDateTime" at time zone 'UTC')::date + )::int = $6 + and (asset."localDateTime" at time zone 'UTC')::date >= make_date($7::int, $8::int, $9::int) + and (asset."localDateTime" at time zone 'UTC')::date < make_date($10::int, $11::int, $12::int) +order by + year desc + +-- MemoryRepository.getPersonAssetsByDate +select + "asset"."id" +from + "asset" +where + "asset"."ownerId" = $1 + and "asset"."visibility" = $2 + and "asset"."deletedAt" is null + and exists ( + select + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."personGroupId" = $3 + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true + ) + and exists ( + select + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $4 + ) + and (asset."localDateTime" at time zone 'UTC')::date = make_date($5::int, $6::int, $7::int) +order by + "asset"."localDateTime" desc +limit + $8 + -- MemoryRepository.get select "memory".*, diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index e50446aa5b..8a85c742e8 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -75,6 +75,52 @@ where and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true +-- PersonRepository.forBirthdayMemories +select + "person"."personGroupId", + "person"."name", + date_part('year', person."birthDate")::int as "birthYear", + date_part('month', person."birthDate")::int as "birthMonth", + date_part('day', person."birthDate")::int as "birthDay" +from + "person" +where + "person"."ownerId" = $1 + and "person"."isHidden" = $2 + and "person"."name" != $3 + and "person"."birthDate" is not null + and ( + date_part('month', person."birthDate")::int = $4 + and date_part('day', person."birthDate")::int = $5 + ) + and date_part('year', person."birthDate")::int < $6 + +-- PersonRepository.forBirthdayMemories (leap day fallback) +select + "person"."personGroupId", + "person"."name", + date_part('year', person."birthDate")::int as "birthYear", + date_part('month', person."birthDate")::int as "birthMonth", + date_part('day', person."birthDate")::int as "birthDay" +from + "person" +where + "person"."ownerId" = $1 + and "person"."isHidden" = $2 + and "person"."name" != $3 + and "person"."birthDate" is not null + and ( + ( + date_part('month', person."birthDate")::int = $4 + and date_part('day', person."birthDate")::int = $5 + ) + or ( + date_part('month', person."birthDate")::int = $6 + and date_part('day', person."birthDate")::int = $7 + ) + ) + and date_part('year', person."birthDate")::int < $8 + -- PersonRepository.getFileSamples select "ownerId", diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 0a81434e1e..cffd8559a7 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -1,15 +1,26 @@ import { Injectable } from '@nestjs/common'; -import { type Insertable, type Kysely, type OrderByDirection, type Updateable, sql } from 'kysely'; +import { + type ExpressionBuilder, + type Insertable, + type Kysely, + type OrderByDirection, + type Updateable, + sql, +} from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import type { IBulkAsset } from 'src/types.js'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators.js'; import { MemorySearchDto } from 'src/dtos/memory.dto.js'; -import { AssetOrderWithRandom, AssetVisibility } from 'src/enum.js'; +import { AssetFileType, AssetOrderWithRandom, AssetVisibility } from 'src/enum.js'; +import { type YearMonthDay } from 'src/repositories/asset.repository.js'; import { DB } from 'src/schema/index.js'; import { MemoryTable } from 'src/schema/tables/memory.table.js'; +const asMakeDate = (eb: ExpressionBuilder, { year, month, day }: YearMonthDay) => + eb.fn('make_date', [sql`${year}::int`, sql`${month}::int`, sql`${day}::int`]); + @Injectable() export class MemoryRepository implements IBulkAsset { constructor(@InjectKysely() private db: Kysely) {} @@ -112,6 +123,64 @@ export class MemoryRepository implements IBulkAsset { .execute(); } + private personAssets(ownerId: string, personGroupId: string) { + return this.db + .selectFrom('asset') + .where('asset.ownerId', '=', ownerId) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.deletedAt', 'is', null) + .where((eb) => + eb.exists((qb) => + qb + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.personGroupId', '=', personGroupId) + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true), + ), + ) + .where((eb) => + eb.exists((qb) => + qb + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.Preview), + ), + ); + } + + @GenerateSql({ + params: [DummyValue.UUID, DummyValue.UUID, { year: 2000, month: 1, day: 1 }, { year: 2025, month: 1, day: 1 }], + }) + async getPersonBirthdayYears( + ownerId: string, + personGroupId: string, + birthDate: YearMonthDay, + until: YearMonthDay, + ): Promise { + const rows = await this.personAssets(ownerId, personGroupId) + .where(sql`date_part('month', (asset."localDateTime" at time zone 'UTC')::date)::int`, '=', birthDate.month) + .where(sql`date_part('day', (asset."localDateTime" at time zone 'UTC')::date)::int`, '=', birthDate.day) + .where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '>=', asMakeDate(eb, birthDate))) + .where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '<', asMakeDate(eb, until))) + .select(sql`date_part('year', (asset."localDateTime" at time zone 'UTC')::date)::int`.as('year')) + .distinct() + .orderBy(sql`year`, 'desc') + .execute(); + + return rows.map(({ year }) => year); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, { year: 2000, month: 1, day: 1 }, 5] }) + getPersonAssetsByDate(ownerId: string, personGroupId: string, date: YearMonthDay, limit: number) { + return this.personAssets(ownerId, personGroupId) + .select(['asset.id']) + .where((eb) => eb(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', asMakeDate(eb, date))) + .orderBy('asset.localDateTime', 'desc') + .limit(limit) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) get(id: string) { return this.getByIdBuilder(id).executeTakeFirst(); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 01448646af..ad4aa1534d 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -5,12 +5,14 @@ import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database.js'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js'; import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum.js'; +import { type YearMonthDay } from 'src/repositories/asset.repository.js'; import { DB } from 'src/schema/index.js'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table.js'; import { FaceSearchTable } from 'src/schema/tables/face-search.table.js'; import { PersonGroupTable } from 'src/schema/tables/person-group.table.js'; import { PersonTable } from 'src/schema/tables/person.table.js'; import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database.js'; +import { isLeapDayObserved } from 'src/utils/date.js'; import { type PaginationOptions, paginationHelper } from 'src/utils/pagination.js'; export interface PersonSearchOptions { @@ -219,6 +221,42 @@ export class PersonRepository { .stream(); } + @GenerateSql( + { params: [DummyValue.UUID, { year: 2025, month: 1, day: 1 }] }, + { name: 'leap day fallback', params: [DummyValue.UUID, { year: 2025, month: 2, day: 28 }] }, + ) + async forBirthdayMemories(ownerId: string, { year, month, day }: YearMonthDay) { + const isLeapDayBirthday = isLeapDayObserved({ year, month, day }); + + const people = await this.db + .selectFrom('person') + .select(['person.personGroupId', 'person.name']) + .select(sql`date_part('year', person."birthDate")::int`.as('birthYear')) + .select(sql`date_part('month', person."birthDate")::int`.as('birthMonth')) + .select(sql`date_part('day', person."birthDate")::int`.as('birthDay')) + .where('person.ownerId', '=', ownerId) + .where('person.isHidden', '=', false) + .where('person.name', '!=', '') + .where('person.birthDate', 'is not', null) + .where((eb) => { + const bornOn = (month: number, day: number) => + eb.and([ + eb(sql`date_part('month', person."birthDate")::int`, '=', month), + eb(sql`date_part('day', person."birthDate")::int`, '=', day), + ]); + + return isLeapDayBirthday ? eb.or([bornOn(month, day), bornOn(2, 29)]) : bornOn(month, day); + }) + .where(sql`date_part('year', person."birthDate")::int`, '<', year) + .execute(); + + return people.map(({ personGroupId, name, birthYear, birthMonth, birthDay }) => ({ + personGroupId, + name, + birthDate: { year: birthYear, month: birthMonth, day: birthDay }, + })); + } + @GenerateSql() getFileSamples() { return this.db diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 3ffa0b3868..bc0aefbab1 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; -import type { OnThisDayData } from 'src/types.js'; +import { MemoryType } from 'src/enum.js'; import { MemoryService } from 'src/services/memory.service.js'; +import { OnThisDayData } from 'src/types.js'; import { AssetFactory } from 'test/factories/asset.factory.js'; import { MemoryFactory } from 'test/factories/memory.factory.js'; import { getForMemory } from 'test/mappers.js'; @@ -94,7 +95,7 @@ describe(MemoryService.name, () => { await expect( sut.create(factory.auth({ user: { id: userId } }), { - type: memory.type, + type: MemoryType.OnThisDay, data: memory.data as OnThisDayData, memoryAt: memory.memoryAt, isSaved: memory.isSaved, @@ -124,7 +125,7 @@ describe(MemoryService.name, () => { await expect( sut.create(factory.auth({ user: { id: userId } }), { - type: memory.type, + type: MemoryType.OnThisDay, data: memory.data as OnThisDayData, assetIds: memory.assets.map((asset) => asset.id), memoryAt: memory.memoryAt, @@ -144,7 +145,7 @@ describe(MemoryService.name, () => { await expect( sut.create(factory.auth(), { - type: memory.type, + type: MemoryType.OnThisDay, data: memory.data as OnThisDayData, memoryAt: memory.memoryAt, }), diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index e1c5b9c192..18d81f6863 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { shuffle } from 'lodash-es'; import { DateTime } from 'luxon'; import { Memory } from 'src/database.js'; import { OnJob } from 'src/decorators.js'; @@ -12,11 +13,15 @@ import { mapMemory, } from 'src/dtos/memory.dto.js'; import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum.js'; +import { type YearMonthDay } from 'src/repositories/asset.repository.js'; import { BaseService } from 'src/services/base.service.js'; import { addAssets, removeAssets } from 'src/utils/asset.util.js'; import { findOrFail } from 'src/utils/misc.js'; const DAYS = 3; +const DAYS_UNTIL_BIRTHDAY = 3; +const BIRTHDAY_MEMORY_ASSETS_PER_YEAR = 5; +const MEMORY_ASSET_LIMIT = 25; @Injectable() export class MemoryService extends BaseService { @@ -38,7 +43,14 @@ export class MemoryService extends BaseService { this.logger.log(`Creating memories for ${target.toISO()}`); try { - await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target))); + await Promise.all( + users.map((owner) => + Promise.all([ + this.createOnThisDayMemories(owner.id, target), + this.createBirthdayMemories(owner.id, target), + ]), + ), + ); } catch (error) { this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`); } @@ -72,6 +84,68 @@ export class MemoryService extends BaseService { ); } + private async createBirthdayMemories(ownerId: string, target: DateTime) { + const people = await this.personRepository.forBirthdayMemories(ownerId, target); + if (people.length === 0) { + return; + } + + const showAt = target.minus({ days: DAYS_UNTIL_BIRTHDAY }).startOf('day').toISO(); + const hideAt = target.endOf('day').toISO(); + + await Promise.all( + people.map(async ({ personGroupId, name: personName, birthDate }) => { + const assets = await this.getBirthdayAssets(ownerId, personGroupId, birthDate, target); + if (assets.length === 0) { + return; + } + + await this.memoryRepository.create( + { + ownerId, + type: MemoryType.Birthday, + data: { personId: personGroupId, personName, year: birthDate.year }, + memoryAt: target.startOf('day').toISO()!, + showAt, + hideAt, + }, + new Set(assets.map(({ id }) => id)), + ); + }), + ); + } + + private async getBirthdayAssets( + ownerId: string, + personGroupId: string, + birthDate: YearMonthDay, + until: YearMonthDay, + ) { + const years = await this.memoryRepository.getPersonBirthdayYears(ownerId, personGroupId, birthDate, until); + if (years.length === 0) { + return []; + } + + let birthdayYears = years; + let assetsPerYear = Math.min(BIRTHDAY_MEMORY_ASSETS_PER_YEAR, Math.floor(MEMORY_ASSET_LIMIT / years.length)); + + // Select random birthdays if there are more than 25 birthdays with assets + if (years.length > MEMORY_ASSET_LIMIT) { + birthdayYears = shuffle(years) + .slice(0, MEMORY_ASSET_LIMIT) + .sort((a, b) => b - a); + assetsPerYear = 1; + } + + const assets = await Promise.all( + birthdayYears.map((year) => + this.memoryRepository.getPersonAssetsByDate(ownerId, personGroupId, { ...birthDate, year }, assetsPerYear), + ), + ); + + return assets.flat(); + } + @OnJob({ name: JobName.MemoryCleanup, queue: QueueName.BackgroundTask }) async onMemoriesCleanup() { await this.memoryRepository.cleanup(); @@ -95,8 +169,6 @@ export class MemoryService extends BaseService { } async create(auth: AuthDto, dto: MemoryCreateDto) { - // TODO validate type/data combination - const assetIds = dto.assetIds || []; const allowedAssetIds = await this.checkAccess({ auth, diff --git a/server/src/types.ts b/server/src/types.ts index de98e2317f..197e978105 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -521,9 +521,11 @@ export type StorageAsset = { }; export type OnThisDayData = { year: number }; +export type BirthdayData = { personId: string; personName: string; year: number }; export interface MemoryData { [MemoryType.OnThisDay]: OnThisDayData; + [MemoryType.Birthday]: BirthdayData; } export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; diff --git a/server/src/utils/date.spec.ts b/server/src/utils/date.spec.ts index 0abdee7637..d24e24dcbc 100644 --- a/server/src/utils/date.spec.ts +++ b/server/src/utils/date.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { asDateString, asDateTimeString } from 'src/utils/date.js'; +import { asDateString, asDateTimeString, isLeapDayObserved } from 'src/utils/date.js'; describe('asDateString', () => { it('should return null for null input', () => { @@ -20,6 +20,27 @@ describe('asDateString', () => { }); }); +describe('isLeapDayObserved', () => { + it('should return true on february 28th in a non-leap year', () => { + expect(isLeapDayObserved({ year: 2025, month: 2, day: 28 })).toBe(true); + }); + + it('should return false on february 28th in a leap year', () => { + expect(isLeapDayObserved({ year: 2024, month: 2, day: 28 })).toBe(false); + expect(isLeapDayObserved({ year: 2000, month: 2, day: 28 })).toBe(false); + }); + + it('should return true on february 28th in a century that is not a leap year', () => { + expect(isLeapDayObserved({ year: 1900, month: 2, day: 28 })).toBe(true); + expect(isLeapDayObserved({ year: 2100, month: 2, day: 28 })).toBe(true); + }); + + it('should return false on other days', () => { + expect(isLeapDayObserved({ year: 2025, month: 2, day: 27 })).toBe(false); + expect(isLeapDayObserved({ year: 2025, month: 3, day: 28 })).toBe(false); + }); +}); + describe('asDateTimeString', () => { it('should return null for null input', () => { expect(asDateTimeString(null)).toBeNull(); diff --git a/server/src/utils/date.ts b/server/src/utils/date.ts index 6154129ef4..523074ae66 100644 --- a/server/src/utils/date.ts +++ b/server/src/utils/date.ts @@ -15,6 +15,13 @@ export const asDateString = (x: Date | string | null): string | null => { return x instanceof Date ? isoDateToDate.encode(x) : x; }; +/** + * People born on February 29th are celebrated on February 28th in non-leap years. + */ +export const isLeapDayObserved = ({ year, month, day }: { year: number; month: number; day: number }) => { + return month === 2 && day === 28 && !DateTime.local(year).isInLeapYear; +}; + export const extractTimeZone = (dateTimeOriginal?: string | null) => { const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; return extractedTimeZone?.type === 'fixed' ? extractedTimeZone : undefined; diff --git a/server/test/medium/specs/repositories/memory.repository.spec.ts b/server/test/medium/specs/repositories/memory.repository.spec.ts new file mode 100644 index 0000000000..1f5cfb28d4 --- /dev/null +++ b/server/test/medium/specs/repositories/memory.repository.spec.ts @@ -0,0 +1,235 @@ +import { Kysely } from 'kysely'; +import { AssetFileType, AssetVisibility } from 'src/enum.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { MemoryRepository } from 'src/repositories/memory.repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(MemoryRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +const newPersonAsset = async ( + ctx: ReturnType['ctx'], + { ownerId, personGroupId, localDateTime }: { ownerId: string; personGroupId: string; localDateTime: string }, +) => { + const { asset } = await ctx.newAsset({ ownerId, localDateTime }); + await Promise.all([ + ctx.newAssetFace({ assetId: asset.id, personGroupId }), + ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` }), + ]); + return asset; +}; + +const newBirthdayPerson = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + return { user, person }; +}; + +describe(MemoryRepository.name, () => { + const birthDate = { year: 1990, month: 6, day: 13 }; + const until = { year: 2025, month: 6, day: 13 }; + + describe('getPersonBirthdayYears', () => { + it('should return the distinct years with assets on the birthday, newest first', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const args = { ownerId: user.id, personGroupId: person.personGroupId }; + await newPersonAsset(ctx, { ...args, localDateTime: '2020-06-13T10:00:00.000Z' }); + await newPersonAsset(ctx, { ...args, localDateTime: '2022-06-13T08:00:00.000Z' }); + await newPersonAsset(ctx, { ...args, localDateTime: '2022-06-13T09:00:00.000Z' }); + await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T12:00:00.000Z' }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([2024, 2022, 2020]); + }); + + it('should ignore assets taken on other days', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-06-14T12:00:00.000Z', + }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets of other people', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const { person: otherPerson } = await ctx.newPerson({ ownerId: user.id, name: 'Bob' }); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: otherPerson.personGroupId, + localDateTime: '2024-06-13T12:00:00.000Z', + }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets without a preview file', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore trashed assets', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const asset = await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-06-13T12:00:00.000Z', + }); + await ctx.softDeleteAsset(asset.id); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets that are not timeline-visible', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const { asset } = await ctx.newAsset({ + ownerId: user.id, + localDateTime: '2024-06-13T12:00:00.000Z', + visibility: AssetVisibility.Archive, + }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets with a soft-deleted face', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId, deletedAt: new Date() }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets with an invisible face', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: '2024-06-13T12:00:00.000Z' }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId, isVisible: false }); + await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: `/preview/${asset.id}.jpg` }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should ignore assets taken before the person was born', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '1985-06-13T12:00:00.000Z', + }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([]); + }); + + it('should include assets taken on the day of birth', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '1990-06-13T12:00:00.000Z', + }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([1990]); + }); + + it('should ignore assets taken on or after the until date', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const args = { ownerId: user.id, personGroupId: person.personGroupId }; + await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T12:00:00.000Z' }); + await newPersonAsset(ctx, { ...args, localDateTime: '2025-06-13T12:00:00.000Z' }); + + const years = await sut.getPersonBirthdayYears(user.id, person.personGroupId, birthDate, until); + + expect(years).toEqual([2024]); + }); + }); + + describe('getPersonAssetsByDate', () => { + it('should return the newest assets of the given date, newest first, up to the limit', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const args = { ownerId: user.id, personGroupId: person.personGroupId }; + await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T01:00:00.000Z' }); + const nextNewest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T02:00:00.000Z' }); + const newest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T03:00:00.000Z' }); + await newPersonAsset(ctx, { ...args, localDateTime: '2023-06-13T01:00:00.000Z' }); + + const assets = await sut.getPersonAssetsByDate( + user.id, + person.personGroupId, + { year: 2024, month: 6, day: 13 }, + 2, + ); + + expect(assets.map(({ id }) => id)).toEqual([newest2024.id, nextNewest2024.id]); + }); + + it('should return the single newest asset when the limit is 1', async () => { + const { ctx, sut } = setup(); + const { user, person } = await newBirthdayPerson(ctx); + const args = { ownerId: user.id, personGroupId: person.personGroupId }; + await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T01:00:00.000Z' }); + const newest2024 = await newPersonAsset(ctx, { ...args, localDateTime: '2024-06-13T02:00:00.000Z' }); + + const assets = await sut.getPersonAssetsByDate( + user.id, + person.personGroupId, + { year: 2024, month: 6, day: 13 }, + 1, + ); + + expect(assets.map(({ id }) => id)).toEqual([newest2024.id]); + }); + }); +}); diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person.repository.spec.ts index fc0c9245d3..c17fdc9a50 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person.repository.spec.ts @@ -229,6 +229,96 @@ describe(PersonRepository.name, () => { }); }); + describe('forBirthdayMemories', () => { + const target = { year: 2025, month: 6, day: 13 }; + + it('should return people with a birthday on the given day', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + await ctx.newPerson({ ownerId: user.id, name: 'Bob', birthDate: '1990-06-14' }); + + const people = await sut.forBirthdayMemories(user.id, target); + + expect(people).toEqual([ + { personGroupId: person.personGroupId, name: 'Alice', birthDate: { year: 1990, month: 6, day: 13 } }, + ]); + }); + + it('should not return hidden people, unnamed people, or people without a birth date', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13', isHidden: true }); + await ctx.newPerson({ ownerId: user.id, name: '', birthDate: '1990-06-13' }); + await ctx.newPerson({ ownerId: user.id, name: 'Carol', birthDate: null }); + + const people = await sut.forBirthdayMemories(user.id, target); + + expect(people).toEqual([]); + }); + + it('should not return people belonging to another user', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + await ctx.newPerson({ ownerId: otherUser.id, name: 'Alice', birthDate: '1990-06-13' }); + + const people = await sut.forBirthdayMemories(user.id, target); + + expect(people).toEqual([]); + }); + + it('should not return people born in the target year', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '2025-06-13' }); + + const people = await sut.forBirthdayMemories(user.id, target); + + expect(people).toEqual([]); + }); + + it('should include leap-day birthdays on february 28th of non-leap years', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' }); + + const people = await sut.forBirthdayMemories(user.id, { year: 2025, month: 2, day: 28 }); + + expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]); + }); + + it('should include people born on february 28th on february 28th of non-leap years', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1991-02-28' }); + + const people = await sut.forBirthdayMemories(user.id, { year: 2025, month: 2, day: 28 }); + + expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]); + }); + + it('should not include leap-day birthdays on february 28th of leap years', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' }); + + const people = await sut.forBirthdayMemories(user.id, { year: 2024, month: 2, day: 28 }); + + expect(people).toEqual([]); + }); + + it('should include leap-day birthdays on february 29th of leap years', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' }); + + const people = await sut.forBirthdayMemories(user.id, { year: 2024, month: 2, day: 29 }); + + expect(people.map(({ personGroupId }) => personGroupId)).toEqual([person.personGroupId]); + }); + }); + describe('getForFeatureFaceUpdate', () => { it('should ignore soft deleted faces', async () => { const { ctx, sut } = setup(); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 0a406b33ab..75fbff0c00 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -8,6 +8,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { MemoryRepository } from 'src/repositories/memory.repository.js'; import { PartnerRepository } from 'src/repositories/partner.repository.js'; +import { PersonRepository } from 'src/repositories/person.repository.js'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; import { DB } from 'src/schema/index.js'; @@ -30,6 +31,7 @@ const setup = (db?: Kysely) => { SystemMetadataRepository, UserRepository, PartnerRepository, + PersonRepository, ], mock: [LoggingRepository], }); @@ -43,6 +45,24 @@ const create = async (ctx: ReturnType['ctx']) => { return { memory, asset, user }; }; +const newPersonAsset = async ( + ctx: ReturnType['ctx'], + { ownerId, personGroupId, localDateTime }: { ownerId: string; personGroupId: string; localDateTime?: string }, +) => { + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId, localDateTime: localDateTime ?? '2024-06-13T12:00:00.000Z' }); + await Promise.all([ + ctx.newExif({ assetId: asset.id, make: 'Canon' }), + ctx.newJobStatus({ assetId: asset.id }), + ctx.newAssetFace({ assetId: asset.id, personGroupId }), + assetRepo.upsertFiles([ + { assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' }, + { assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' }, + ]), + ]); + return asset; +}; + describe(MemoryService.name, () => { describe('get', () => { it('should return the memory', async () => { @@ -183,13 +203,17 @@ describe(MemoryService.name, () => { defaultDatabase = await getKyselyDB(); }); + afterEach(async () => { + await defaultDatabase.destroy(); + }); + describe('create', () => { it('should create a new memory', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const dto = { - type: MemoryType.OnThisDay, + type: MemoryType.OnThisDay as const, data: { year: 2021 }, memoryAt: new Date(2021), }; @@ -214,7 +238,7 @@ describe(MemoryService.name, () => { const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); const auth = factory.auth({ user }); const dto = { - type: MemoryType.OnThisDay, + type: MemoryType.OnThisDay as const, data: { year: 2021 }, memoryAt: new Date(2021), assetIds: [asset1.id, asset2.id], @@ -228,6 +252,26 @@ describe(MemoryService.name, () => { ); }); + it('should create a new birthday memory', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice' }); + const auth = factory.auth({ user }); + const dto = { + type: MemoryType.Birthday as const, + data: { personId: person.personGroupId, personName: 'Alice', year: 1990 }, + memoryAt: new Date(2025, 5, 13), + }; + + await expect(sut.create(auth, dto)).resolves.toEqual( + expect.objectContaining({ + id: expect.any(String), + type: MemoryType.Birthday, + data: dto.data, + }), + ); + }); + it('should create a new memory and ignore assets the user does not have access to', async () => { const { sut, ctx } = setup(); const { user: user1 } = await ctx.newUser(); @@ -236,7 +280,7 @@ describe(MemoryService.name, () => { const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id }); const auth = factory.auth({ user: user1 }); const dto = { - type: MemoryType.OnThisDay, + type: MemoryType.OnThisDay as const, data: { year: 2021 }, memoryAt: new Date(2021), assetIds: [asset1.id, asset2.id], @@ -418,6 +462,285 @@ describe(MemoryService.name, () => { }); }); + describe('onMemoryCreate (birthday)', () => { + it('should create a memory for the 3 days leading up to a birthday', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const birthday = DateTime.fromObject({ year: 2025, month: 6, day: 13 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + const asset = await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + ownerId: user.id, + type: 'birthday', + data: { personId: person.personGroupId, personName: 'Alice', year: 1990 }, + memoryAt: birthday.startOf('day').toJSDate(), + showAt: birthday.minus({ days: 3 }).startOf('day').toJSDate(), + hideAt: birthday.endOf('day').toJSDate(), + assets: [expect.objectContaining({ id: asset.id })], + }), + ); + }); + + it('should create a memory on the birthday itself', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 13 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( + expect.objectContaining({ + showAt: now.minus({ days: 3 }).startOf('day').toJSDate(), + hideAt: now.endOf('day').toJSDate(), + }), + ); + }); + + it('should not create a birthday memory twice for the same birthday', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + }); + + it('should not create a birthday memory for a hidden person', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ + ownerId: user.id, + name: 'Alice', + birthDate: '1990-06-13', + isHidden: true, + }); + await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(0); + }); + + it('should not create a birthday memory for an unnamed person', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: '', birthDate: '1990-06-13' }); + await newPersonAsset(ctx, { ownerId: user.id, personGroupId: person.personGroupId }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(0); + }); + + it('should only include assets taken on the birthday itself', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + const onBirthday = await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-06-13T12:00:00.000Z', + }); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-06-12T12:00:00.000Z', + }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + expect(memories[0].assets.map(({ id }) => id)).toEqual([onBirthday.id]); + }); + + it('should not create a birthday memory for a person without any assets', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(0); + }); + + it('should include at most 5 assets from a single year, newest first', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + + const assetIds: string[] = []; + for (let hour = 1; hour <= 7; hour++) { + const asset = await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: `2024-06-13T${hour.toString().padStart(2, '0')}:00:00.000Z`, + }); + assetIds.push(asset.id); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + const memoryAssetIds = memories[0].assets.map(({ id }) => id).sort(); + expect(memoryAssetIds).toEqual(assetIds.slice(-5).sort()); + }); + + it('should split the asset budget evenly across years, keeping the newest assets of each year', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + + const assetIdsByYear: Record = {}; + for (let year = 2019; year <= 2024; year++) { + assetIdsByYear[year] = []; + for (let hour = 1; hour <= 5; hour++) { + const asset = await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: `${year}-06-13T${hour.toString().padStart(2, '0')}:00:00.000Z`, + }); + assetIdsByYear[year].push(asset.id); + } + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + // 6 birthdays share the budget of 25 assets, so each birthday includes its 4 newest assets + expect(memories[0].assets.length).toBe(24); + const memoryAssetIds = new Set(memories[0].assets.map(({ id }) => id)); + for (let year = 2019; year <= 2024; year++) { + const [oldest, ...newest] = assetIdsByYear[year]; + for (const assetId of newest) { + expect(memoryAssetIds.has(assetId)).toBe(true); + } + expect(memoryAssetIds.has(oldest)).toBe(false); + } + }); + + it('should include one asset from each of 25 random birthdays when there are more than 25', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' }); + + for (let year = 1997; year <= 2024; year++) { + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: `${year}-06-13T12:00:00.000Z`, + }); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: `${year}-06-13T08:00:00.000Z`, + }); + } + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + // 28 birthdays exist, so 25 of them are sampled with one asset each + expect(memories[0].assets.length).toBe(25); + const years = new Set(memories[0].assets.map(({ localDateTime }) => new Date(localDateTime).getUTCFullYear())); + expect(years.size).toBe(25); + }); + + it('should celebrate a leap-day birthday on February 28th in non-leap years', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1992-02-29' }); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-02-29T12:00:00.000Z', + }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( + expect.objectContaining({ + data: { personId: person.personGroupId, personName: 'Alice', year: 1992 }, + memoryAt: DateTime.fromObject({ year: 2025, month: 2, day: 28 }, { zone: 'utc' }).toJSDate(), + }), + ); + }); + + it('should not create a birthday memory when the birthday is more than 3 days away', async () => { + const { sut, ctx } = setup(); + const memoryRepo = ctx.get(MemoryRepository); + const now = DateTime.fromObject({ year: 2025, month: 6, day: 10 }, { zone: 'utc' }) as DateTime; + const { user } = await ctx.newUser(); + const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-20' }); + await newPersonAsset(ctx, { + ownerId: user.id, + personGroupId: person.personGroupId, + localDateTime: '2024-06-20T12:00:00.000Z', + }); + + vi.setSystemTime(now.toJSDate()); + await sut.onMemoriesCreate(); + + const memories = await memoryRepo.search(user.id, { type: MemoryType.Birthday }); + expect(memories.length).toBe(0); + }); + }); + describe('onMemoriesCleanup', () => { it('should run without error', async () => { const { sut } = setup(); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5302e68f9e..d89a7ca4b6 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -397,6 +397,10 @@ export const memoryLaneTitle = derived(t, ($t) => { : memoryDate.toLocaleString(DateTime.DATE_MED, { locale: get(locale) }); } + if (memory.type === MemoryType.Birthday && 'personName' in memory.data) { + return $t('birthday_memory_title', { values: { name: memory.data.personName } }); + } + return $t('unknown'); }; });