Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1f2f482
docs: spec for editable album creation date (#520)
Deeds67 Aug 14, 2026
395491a
docs: correct and extend the album creation date spec after review
Deeds67 Aug 14, 2026
f9a41b3
docs: implementation plan for editable album creation date
Deeds67 Aug 14, 2026
422b2b6
docs: fix plan test mechanics after review
Deeds67 Aug 14, 2026
bf735ca
feat(server): allow updating an album's creation date
Deeds67 Aug 14, 2026
c08561a
chore: regenerate api clients for album createdAt
Deeds67 Aug 14, 2026
f3cf0dd
test(server): pin album createdAt propagation through the sync stream
Deeds67 Aug 14, 2026
6f94355
feat(web): add isAlbumEditor helper and pin the DateCreated sort
Deeds67 Aug 14, 2026
94010bd
test(e2e): cover album createdAt permissions and validation
Deeds67 Aug 14, 2026
62b7e86
feat(web): edit an album's creation date
Deeds67 Aug 14, 2026
370a33a
docs: correct AlbumShare permission claim in album date spec and plan
Deeds67 Aug 14, 2026
dd56d63
fix(web): correct isAlbumEditor comment on AlbumShare server permissions
Deeds67 Aug 14, 2026
a4cebc6
feat(web): let album editors open the album edit modal
Deeds67 Aug 14, 2026
d60f3db
feat(mobile): plumb album createdAt through the update path
Deeds67 Aug 14, 2026
099b6cf
feat(mobile): edit an album's creation date from the album menu
Deeds67 Aug 14, 2026
dbd2397
fix(mobile): keep an edited album date across a session, show picks i…
Deeds67 Aug 14, 2026
5c29a23
test(web): rename a stale non-owner test for cmd:album_rename
Deeds67 Aug 14, 2026
74b3012
test(e2e): pin stored createdAt values for the album update grammar t…
Deeds67 Aug 14, 2026
71e73d9
test(e2e): stop asserting a year-1 createdAt round trip
Deeds67 Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,516 changes: 1,516 additions & 0 deletions docs/superpowers/plans/2026-08-14-album-creation-date.md

Large diffs are not rendered by default.

658 changes: 658 additions & 0 deletions docs/superpowers/specs/2026-08-14-album-creation-date-design.md

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions e2e/src/specs/server/api/album.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,168 @@ describe('/albums', () => {
}),
);
});

it('should set the album created date as the owner', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'Backdated' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ createdAt: '1996-06-15T14:30:00.000Z' });

expect(status).toBe(200);
expect(body.createdAt).toBe('1996-06-15T14:30:00.000Z');
expect(body.updatedAt).not.toBe(album.updatedAt);

const after = await getAlbumInfo({ id: album.id }, { headers: asBearerAuth(user1.accessToken) });
expect(after.createdAt).toBe('1996-06-15T14:30:00.000Z');
});

it('should set the album created date as an editor', async () => {
const album = await utils.createAlbum(user1.accessToken, {
albumName: 'Editor may re-date',
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
});

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ createdAt: '1996-06-15T14:30:00.000Z' });

expect(status).toBe(200);
expect(body.createdAt).toBe('1996-06-15T14:30:00.000Z');
});

it('should apply albumName and createdAt together in one request', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'Combined update' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumName: 'Combined update - renamed', createdAt: '1996-06-15T14:30:00.000Z' });

expect(status).toBe(200);
expect(body.albumName).toBe('Combined update - renamed');
expect(body.createdAt).toBe('1996-06-15T14:30:00.000Z');
});

it('should not set the album created date as a viewer', async () => {
const album = await utils.createAlbum(user1.accessToken, {
albumName: 'Viewer may not re-date',
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
});

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ createdAt: '1996-06-15T14:30:00.000Z' });

expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
});

it('should not set the album created date as a non-member', async () => {
const album = await utils.createAlbum(user2.accessToken, { albumName: 'Not yours' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ createdAt: '1996-06-15T14:30:00.000Z' });

expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
});

it('should leave the created date alone when the request omits it', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'Keep my date' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ albumName: 'Renamed' });

expect(status).toBe(200);
expect(body.albumName).toBe('Renamed');
expect(body.createdAt).toBe(album.createdAt);
});

it('should accept an empty body without changing anything', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'Untouched' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({});

expect(status).toBe(200);
expect(body.albumName).toBe('Untouched');
expect(body.createdAt).toBe(album.createdAt);
expect(body.updatedAt).not.toBe(album.updatedAt);
});

it.each([
['1996-06-15T14:30:00.000Z', 'UTC with milliseconds', 200, '1996-06-15T14:30:00.000Z'],
['1996-06-15T14:30:00+02:00', 'a numeric offset', 200, '1996-06-15T12:30:00.000Z'],
['1996-06-15T14:30Z', 'omitted seconds', 200, '1996-06-15T14:30:00.000Z'],
['1996-02-29T00:00:00.000Z', 'a real leap day', 200, '1996-02-29T00:00:00.000Z'],
// Status only, deliberately. This row exists to pin the *grammar* boundary — the
// schema's `\d{4}` year accepts `0001` — not storage fidelity, and year 1 does not
// survive the Postgres round trip: it comes back as `2001-01-01T00:00:00.000Z`.
// Asserting the returned value here would enshrine that as intended behaviour.
// Nobody backdates an album to year 1, so it is not worth a validation floor; the
// real lower-bound users reach is covered by the 1996 rows.
['0001-01-01T00:00:00.000Z', 'the earliest four-digit year', 200, undefined],
['1996-06-15T14:30:00', 'no timezone designator', 400, undefined],
['1996-06-15', 'a date with no time', 400, undefined],
['not-a-date', 'a non-date string', 400, undefined],
['', 'an empty string', 400, undefined],
[null, 'null', 400, undefined],
['12345-06-15T14:30:00Z', 'a five-digit year', 400, undefined],
['1996-06-31T00:00:00.000Z', 'the 31st of a 30-day month', 400, undefined],
['1997-02-29T00:00:00.000Z', 'a leap day in a non-leap year', 400, undefined],
['1996-06-15T24:00:00.000Z', 'hour 24', 400, undefined],
['1996-06-15t14:30:00z', 'lowercase t and z', 400, undefined],
] as [createdAt: unknown, label: string, expectedStatus: number, expectedStored: string | undefined][])(
'createdAt %s (%s) should answer %i',
async (createdAt, label, expectedStatus, expectedStored) => {
const album = await utils.createAlbum(user1.accessToken, { albumName: `Grammar: ${label}` });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ createdAt });

expect(status).toBe(expectedStatus);
if (expectedStored !== undefined) {
expect(body.createdAt).toBe(expectedStored);
}
},
);

it('should accept a future created date', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'From the future' });
const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ createdAt: future });

expect(status).toBe(200);
expect(body.createdAt).toBe(future);
});

it('should truncate sub-millisecond precision', async () => {
const album = await utils.createAlbum(user1.accessToken, { albumName: 'Microseconds' });

const { status, body } = await request(app)
.patch(`/albums/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ createdAt: '1996-06-15T14:30:00.123456Z' });

expect(status).toBe(200);
expect(body.createdAt).toBe('1996-06-15T14:30:00.123Z');
});
});

describe('DELETE /albums/:id/assets', () => {
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/domain/services/remote_album.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ class RemoteAlbumService {
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
DateTime? createdAt,
}) async {
final owner = await _repository.getOwner(albumId);
final updatedAlbum = await _albumApiRepository.updateAlbum(
Expand All @@ -151,6 +152,7 @@ class RemoteAlbumService {
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
createdAt: createdAt,
);

// Update the local database
Expand Down
56 changes: 46 additions & 10 deletions mobile/lib/presentation/pages/drift_remote_album.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import 'package:immich_mobile/providers/infrastructure/space_album_actions.dart'
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
import 'package:intl/intl.dart';

@RoutePage()
class RemoteAlbumPage extends ConsumerStatefulWidget {
Expand Down Expand Up @@ -151,7 +153,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
}

Future<void> showEditTitleAndDescription(BuildContext context) async {
Future<void> showEditAlbum(BuildContext context) async {
final result = await showDialog<_EditAlbumData?>(
context: context,
barrierDismissible: true,
Expand All @@ -160,7 +162,7 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {

if (result != null && context.mounted) {
setState(() {
_album = _album.copyWith(name: result.name, description: result.description ?? '');
_album = _album.copyWith(name: result.name, description: result.description ?? '', createdAt: result.createdAt);
});
unawaited(HapticFeedback.mediumImpact());
}
Expand Down Expand Up @@ -218,12 +220,12 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onEditAlbum: () => showEditAlbum(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
onLinkToSpace: () => unawaited(linkToSpace(context)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onEditTitle: isOwner ? () => showEditAlbum(context) : null,
onActivity: () => showActivity(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
Expand All @@ -235,8 +237,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
class _EditAlbumData {
final String name;
final String? description;
final DateTime createdAt;

const _EditAlbumData({required this.name, this.description});
const _EditAlbumData({required this.name, this.description, required this.createdAt});
}

class _EditAlbumDialog extends ConsumerStatefulWidget {
Expand All @@ -252,6 +255,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
late final TextEditingController titleController;
late final TextEditingController descriptionController;
final formKey = GlobalKey<FormState>();
late DateTime createdAt;

@override
void initState() {
Expand All @@ -260,6 +264,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
descriptionController = TextEditingController(
text: widget.album.description.isEmpty ? '' : widget.album.description,
);
createdAt = widget.album.createdAt;
}

@override
Expand All @@ -269,6 +274,16 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
super.dispose();
}

Future<void> _pickCreatedAt() async {
// Returns an ISO string with a +HH:MM offset, or null when dismissed —
// same contract action.service.dart:202-219 consumes for asset dates.
final picked = await showDateTimePicker(context: context, initialDateTime: createdAt);
if (picked == null) {
return;
}
setState(() => createdAt = DateTime.parse(picked).toLocal());
}

Future<void> _handleSave() async {
if (formKey.currentState?.validate() != true) {
return;
Expand All @@ -280,12 +295,16 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {

await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
.updateAlbum(widget.album.id, name: newTitle, description: newDescription, createdAt: createdAt);

if (mounted) {
Navigator.of(
context,
).pop(_EditAlbumData(name: newTitle, description: newDescription.isEmpty ? null : newDescription));
Navigator.of(context).pop(
_EditAlbumData(
name: newTitle,
description: newDescription.isEmpty ? null : newDescription,
createdAt: createdAt,
),
);
}
} catch (e) {
if (mounted) {
Expand Down Expand Up @@ -363,6 +382,22 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
fillColor: context.colorScheme.surface,
),
),
const SizedBox(height: 18),

// Created date
Text(
'date_created'.t(context: context).toUpperCase(),
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
ListTile(
key: const Key('album-edit-created-at'),
tileColor: context.colorScheme.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
title: Text(DateFormat.yMMMd().format(createdAt), style: context.textTheme.bodyMedium),
trailing: Icon(Icons.edit_outlined, size: 18, color: context.colorScheme.primary),
onTap: _pickCreatedAt,
),
const SizedBox(height: 24),

// Action Buttons
Expand All @@ -375,6 +410,7 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
),
const SizedBox(width: 12),
FilledButton(
key: const Key('album-edit-save'),
onPressed: _handleSave,
child: Text('save'.t(context: context)),
),
Expand Down Expand Up @@ -456,7 +492,7 @@ class _AlbumKebabMenu extends ConsumerWidget {
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onEditAlbum: isOwner || canAddPhotos ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
// L15: gated to owned albums (mirrors web's isOwned gate on the same affordance).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
DateTime? createdAt,
}) async {
try {
final updatedAlbum = await _remoteAlbumService.updateAlbum(
Expand All @@ -167,6 +168,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
createdAt: createdAt,
);

final updatedAlbums = state.albums.map((album) {
Expand Down
2 changes: 2 additions & 0 deletions mobile/lib/repositories/drift_album_api_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
DateTime? createdAt,
}) async {
AssetOrder? apiOrder;
if (order != null) {
Expand All @@ -88,6 +89,7 @@ class DriftAlbumApiRepository extends ApiRepository {
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
description: description == null ? const Optional.absent() : Optional.present(description),
createdAt: createdAt == null ? const Optional.absent() : Optional.present(createdAt),
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
Expand Down
Loading
Loading