feat: birthday memories (#30831)

This commit is contained in:
Alex
2026-09-22 04:30:43 -05:00
committed by GitHub
parent f48d4b3321
commit d6d0947383
23 changed files with 1111 additions and 60 deletions
@@ -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,
+1
View File
@@ -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",
@@ -9,6 +9,7 @@ part 'memory.model.freezed.dart';
enum MemoryTypeEnum {
// do not change this order!
onThisDay,
birthday,
}
@Freezed(fromJson: false, toJson: false)
@@ -24,6 +24,7 @@ class MemoryRepository extends DatabaseAccessor<Drift> with $MemoryRepositoryMix
),
])
..where(_db.memoryEntity.ownerId.equals(ownerId))
..where(_db.memoryEntity.type.equalsValue(MemoryTypeEnum.onThisDay))
..where(_db.memoryEntity.deletedAt.isNull());
if (onlyFavorites) {
@@ -952,6 +952,7 @@ extension on AssetOrder {
extension on MemoryType {
MemoryTypeEnum toMemoryType() => switch (this) {
MemoryType.onThisDay => MemoryTypeEnum.onThisDay,
MemoryType.birthday => MemoryTypeEnum.birthday,
};
}
+28 -17
View File
@@ -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": {
+10 -5
View File
@@ -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",
@@ -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')
+24 -10
View File
@@ -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<typeof OnThisDaySchema>;
type MemoryData = z.infer<typeof MemoryDataSchema>;
const MemoryUpdateSchema = nonEmptyPartial({
isSaved: z.boolean().describe('Is memory saved'),
@@ -35,10 +37,9 @@ const MemoryUpdateSchema = nonEmptyPartial({
memoryAt: isoDatetimeToDate.describe('Memory date'),
}).meta({ id: 'MemoryUpdateDto' });
const MemoryCreateSchema = z
.object({
const MemoryCreateBaseSchema = z.object({
type: MemoryTypeSchema,
data: OnThisDaySchema,
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'),
@@ -51,8 +52,21 @@ const MemoryCreateSchema = z
.optional()
.describe('Date when memory should be hidden')
.meta(new HistoryBuilder().added('v2.6.0').stable('v2.6.0').getExtensions()),
})
.meta({ id: 'MemoryCreateDto' });
});
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),
})
+3
View File
@@ -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' });
+76
View File
@@ -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".*,
+46
View File
@@ -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",
+71 -2
View File
@@ -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<DB, 'asset'>, { 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<DB>) {}
@@ -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<number[]> {
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<number>`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();
@@ -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<number>`date_part('year', person."birthDate")::int`.as('birthYear'))
.select(sql<number>`date_part('month', person."birthDate")::int`.as('birthMonth'))
.select(sql<number>`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
+5 -4
View File
@@ -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,
}),
+75 -3
View File
@@ -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,
+2
View File
@@ -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 };
+22 -1
View File
@@ -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();
+7
View File
@@ -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;
@@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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<typeof setup>['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<typeof setup>['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]);
});
});
});
@@ -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();
@@ -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<DB>) => {
SystemMetadataRepository,
UserRepository,
PartnerRepository,
PersonRepository,
],
mock: [LoggingRepository],
});
@@ -43,6 +45,24 @@ const create = async (ctx: ReturnType<typeof setup>['ctx']) => {
return { memory, asset, user };
};
const newPersonAsset = async (
ctx: ReturnType<typeof setup>['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<true>;
const birthday = DateTime.fromObject({ year: 2025, month: 6, day: 13 }, { zone: 'utc' }) as DateTime<true>;
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<true>;
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<true>;
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<true>;
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<true>;
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<true>;
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<true>;
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<true>;
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<true>;
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id, name: 'Alice', birthDate: '1990-06-13' });
const assetIdsByYear: Record<number, string[]> = {};
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<true>;
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<true>;
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<true>;
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();
+4
View File
@@ -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');
};
});