mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(client): keep Immich picker photo order stable and chronological by default
sortProviderPhotos previously passed photos through unchanged whenever there was no valid journey location, leaving order entirely up to Immich's search API (not guaranteed chronological). It now falls back to newest-taken-first. The picker also re-sorted the entire accumulated photo list on every render, so loading another page could reorder tiles already on screen when a location context was set -- newly loaded photos could land above the fold instead of at the bottom, making it look like nothing happened. Each fetched batch (search page or album) is now sorted once at fetch time and appended, so earlier pages never get reshuffled by later ones.
This commit is contained in:
@@ -94,7 +94,7 @@ export function ProviderPicker({
|
||||
setSearchPage(page);
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
const assets = sortProviderPhotos(data.assets || [], contextLocation);
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
} catch {
|
||||
@@ -119,7 +119,8 @@ export function ProviderPicker({
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
try {
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || []);
|
||||
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
|
||||
setPhotos(sortProviderPhotos(assets, contextLocation));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -149,11 +150,6 @@ export function ProviderPicker({
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng]
|
||||
);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -414,9 +410,9 @@ export function ProviderPicker({
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
photos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
@@ -464,7 +460,7 @@ export function ProviderPicker({
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
) : photos.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from
|
||||
@@ -474,7 +470,7 @@ export function ProviderPicker({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
{groupPhotosByDate(photos).map((group) => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
|
||||
@@ -16,7 +16,15 @@ describe('Journey provider photo ranking', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves provider order when the selected entry has no valid location', () => {
|
||||
it('falls back to newest-taken-first when the selected entry has no valid location', () => {
|
||||
const photos = [
|
||||
{ id: 'older', takenAt: '2026-03-14T09:00:00Z' },
|
||||
{ id: 'newer', takenAt: '2026-03-16T09:00:00Z' },
|
||||
];
|
||||
expect(sortProviderPhotos(photos, { lat: 200, lng: 12 }).map((photo) => photo.id)).toEqual(['newer', 'older']);
|
||||
});
|
||||
|
||||
it('keeps original relative order for photos without a valid location and without takenAt', () => {
|
||||
const photos = [{ id: 'first' }, { id: 'second' }];
|
||||
expect(sortProviderPhotos(photos, { lat: 200, lng: 12 }).map((photo) => photo.id)).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
@@ -132,10 +132,24 @@ export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number {
|
||||
|
||||
/**
|
||||
* Keep every asset, but put assets with usable GPS nearest to the selected
|
||||
* Journey location. The original provider order is the stable fallback.
|
||||
* Journey location. Without a usable location, fall back to newest-taken-first
|
||||
* (provider search order isn't guaranteed chronological), with a stable index
|
||||
* tiebreak so photos missing `takenAt` keep a deterministic relative order.
|
||||
*/
|
||||
export function sortProviderPhotos<T extends ProviderPhotoAsset>(photos: T[], location?: GeoPoint | null): T[] {
|
||||
if (!isValidGeoPoint(location)) return photos;
|
||||
if (!isValidGeoPoint(location)) {
|
||||
return photos
|
||||
.map((photo, index) => ({ photo, index }))
|
||||
.sort((a, b) => {
|
||||
const at = a.photo.takenAt;
|
||||
const bt = b.photo.takenAt;
|
||||
if (!at && !bt) return a.index - b.index;
|
||||
if (!at) return 1;
|
||||
if (!bt) return -1;
|
||||
return bt.localeCompare(at) || a.index - b.index;
|
||||
})
|
||||
.map((item) => item.photo);
|
||||
}
|
||||
|
||||
return photos
|
||||
.map((photo, index) => ({
|
||||
|
||||
Reference in New Issue
Block a user