diff --git a/.env.example b/.env.example
index 2b7750c..9e36833 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,11 @@ PORT=8082
# How often to sync with Immich (in milliseconds, default: 5 minutes)
SYNC_INTERVAL_MS=300000
+# "Nearby in time" suggestions: how many hours before/after a photo to search for
+# geolocated neighbors (default: 6). Widen it if your photos are geotagged sparsely;
+# narrow it to avoid suggesting locations from a different outing the same day.
+# SUGGESTIONS_NEIGHBOR_WINDOW_HOURS=6
+
# Set to true when running behind a reverse proxy that terminates TLS (marks session cookies as Secure)
TRUST_PROXY_TLS=true
diff --git a/README.md b/README.md
index 6b375ef..a8cca51 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ The app has two parts:
- Drag-and-drop a photo onto the map to assign coordinates
- Geocoding search with autocomplete and history (Nominatim by default, optional HERE and Google Maps providers)
- Favorite places: star locations from search results for quick access
-- Smart suggestions: locations from same-day, nearby-day, weekly patterns, frequent spots, and album context
+- Smart suggestions: locations from nearby-in-time photos, same-day, nearby-day, weekly patterns, frequent spots, and album context
- GPX import: upload one or multiple GPX tracks to batch-assign coordinates to photos by timestamp
**Photo Browsing**
@@ -138,6 +138,7 @@ The following Immich permissions are required:
- `FRONTEND_PORT` (default `3032`): Frontend port exposed to the host.
- `REGISTRATION_ENABLED` (default `true`): Set to `false` to disable new users.
- `SYNC_INTERVAL_MS` (default `300000`): Background sync frequency in milliseconds.
+- `SUGGESTIONS_NEIGHBOR_WINDOW_HOURS` (default `6`): For "Nearby in time" suggestions, how many hours before/after a photo to search for geolocated neighbors. Widen it if your library is geotagged sparsely; narrow it to avoid suggesting a location from a different outing on the same day.
- `DATA_DIR` (default `/data`): Backend DB path inside container.
- `PORT` (default `8082`): Backend listen port inside container.
- `BACKEND_URL` (frontend): Backend service URL used by the Next.js rewrite, default is `http://backend:8082`.
diff --git a/backend/config.go b/backend/config.go
index d131f78..fa6e7f6 100644
--- a/backend/config.go
+++ b/backend/config.go
@@ -26,6 +26,7 @@ type Config struct {
HereAPIKey string `env:"HERE_API_KEY"`
GoogleAPIKey string `env:"GOOGLE_API_KEY"`
GeocodeTimeoutSecs int `env:"GEOCODE_TIMEOUT" envDefault:"10"`
+ NeighborWindowHours int `env:"SUGGESTIONS_NEIGHBOR_WINDOW_HOURS" envDefault:"6"`
Debug bool `env:"DEBUG" envDefault:"false"`
defaultTimezoneLocation *time.Location
@@ -51,6 +52,10 @@ func loadConfig() (*Config, error) {
return nil, fmt.Errorf("GEOCODE_TIMEOUT must be > 0, got %d", cfg.GeocodeTimeoutSecs)
}
+ if cfg.NeighborWindowHours <= 0 {
+ return nil, fmt.Errorf("SUGGESTIONS_NEIGHBOR_WINDOW_HOURS must be > 0, got %d", cfg.NeighborWindowHours)
+ }
+
if !cfg.TrustProxyTLS && !cfg.AllowInsecure {
return nil, fmt.Errorf("TRUST_PROXY_TLS is false and no TLS is configured; set ALLOW_INSECURE=true to run without TLS")
}
diff --git a/backend/config_test.go b/backend/config_test.go
index 917d82b..96467f5 100644
--- a/backend/config_test.go
+++ b/backend/config_test.go
@@ -121,3 +121,18 @@ func TestLoadConfigRegistrationDisabled(t *testing.T) {
t.Error("expected RegistrationEnabled to be false")
}
}
+
+func TestLoadConfigNeighborWindowOverride(t *testing.T) {
+ withCleanWorkDir(t)
+ t.Setenv("IMMICH_URL", "http://test:2283")
+ t.Setenv("ENCRYPTION_KEY", "test-secret")
+ t.Setenv("SUGGESTIONS_NEIGHBOR_WINDOW_HOURS", "24")
+
+ cfg, err := loadConfig()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cfg.NeighborWindowHours != 24 {
+ t.Errorf("expected NeighborWindowHours 24, got %d", cfg.NeighborWindowHours)
+ }
+}
diff --git a/backend/database.go b/backend/database.go
index dfe25e9..0caacfe 100644
--- a/backend/database.go
+++ b/backend/database.go
@@ -5,8 +5,10 @@ import (
"database/sql"
"fmt"
"log"
+ "math"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
@@ -470,6 +472,58 @@ func (d *Database) getSameDayAssets(ctx context.Context, userID, dateTimeOrigina
return scanAssetRows(rows)
}
+func (d *Database) getNeighborAssets(ctx context.Context, userID, excludeAssetID, dateTimeOriginal string, windowHours, limit int) ([]AssetRow, error) {
+ refTime, err := parseTimestamp(dateTimeOriginal)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse dateTimeOriginal: %w", err)
+ }
+ rangeStart := refTime.Add(-time.Duration(windowHours) * time.Hour).Format(time.RFC3339)
+ rangeEnd := refTime.Add(time.Duration(windowHours) * time.Hour).Format(time.RFC3339)
+
+ rows, err := d.db.QueryContext(ctx,
+ `SELECT `+assetColumns+`
+ FROM assets
+ WHERE userID = ? AND immichID != ? AND latitude IS NOT NULL AND longitude IS NOT NULL
+ AND dateTimeOriginal IS NOT NULL
+ AND stackPrimaryAssetID IS NULL
+ AND dateTimeOriginal BETWEEN ? AND ?`+hiddenLibraryFilter,
+ userID, excludeAssetID, rangeStart, rangeEnd,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ assets, err := scanAssetRows(rows)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(assets, func(i, j int) bool {
+ return absTimeDiff(refTime, assets[i].DateTimeOriginal) < absTimeDiff(refTime, assets[j].DateTimeOriginal)
+ })
+
+ if limit >= 0 && len(assets) > limit {
+ assets = assets[:limit]
+ }
+ return assets, nil
+}
+
+func absTimeDiff(refTime time.Time, ts *string) time.Duration {
+ if ts == nil {
+ return time.Duration(math.MaxInt64)
+ }
+ t, err := parseTimestamp(*ts)
+ if err != nil {
+ return time.Duration(math.MaxInt64)
+ }
+ diff := refTime.Sub(t)
+ if diff < 0 {
+ diff = -diff
+ }
+ return diff
+}
+
func (d *Database) getFavoritePlaces(ctx context.Context, userID string) ([]FavoritePlaceRow, error) {
rows, err := d.db.QueryContext(ctx,
"SELECT ID, latitude, longitude, displayName, createdAt FROM favoritePlaces WHERE userID = ? ORDER BY createdAt DESC LIMIT 30",
diff --git a/backend/database_test.go b/backend/database_test.go
index f8faf55..94763ac 100644
--- a/backend/database_test.go
+++ b/backend/database_test.go
@@ -467,6 +467,75 @@ func TestGetSameDayAssetsRange(t *testing.T) {
}
}
+func TestGetNeighborAssetsOrderingAndWindow(t *testing.T) {
+ db := newTestDB(t)
+ ctx := context.Background()
+
+ ref := "2024-06-15T12:00:00Z"
+ self := ref
+ after5 := time.Date(2024, 6, 15, 12, 5, 0, 0, time.UTC).Format(time.RFC3339)
+ before2 := time.Date(2024, 6, 15, 11, 58, 0, 0, time.UTC).Format(time.RFC3339)
+ after3h := time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC).Format(time.RFC3339)
+ outside := time.Date(2024, 6, 14, 12, 0, 0, 0, time.UTC).Format(time.RFC3339) // 24h before
+
+ db.upsertAssets(ctx, testUserID, []AssetRow{
+ {
+ ImmichID: "self", Type: "IMAGE", OriginalFileName: "self.jpg",
+ FileCreatedAt: self, Latitude: ptr(48.86), Longitude: ptr(2.36),
+ DateTimeOriginal: &self,
+ },
+ {
+ ImmichID: "after5", Type: "IMAGE", OriginalFileName: "after5.jpg",
+ FileCreatedAt: after5, Latitude: ptr(48.86), Longitude: ptr(2.36),
+ DateTimeOriginal: &after5,
+ },
+ {
+ ImmichID: "before2", Type: "IMAGE", OriginalFileName: "before2.jpg",
+ FileCreatedAt: before2, Latitude: ptr(48.85), Longitude: ptr(2.35),
+ DateTimeOriginal: &before2,
+ },
+ {
+ ImmichID: "after3h", Type: "IMAGE", OriginalFileName: "after3h.jpg",
+ FileCreatedAt: after3h, Latitude: ptr(48.87), Longitude: ptr(2.37),
+ DateTimeOriginal: &after3h,
+ },
+ {
+ ImmichID: "outside", Type: "IMAGE", OriginalFileName: "outside.jpg",
+ FileCreatedAt: outside, Latitude: ptr(40.71), Longitude: ptr(-74.0),
+ DateTimeOriginal: &outside,
+ },
+ })
+
+ // 6h window keeps the three in-window photos, excludes the 24h-away one
+ // and the reference asset itself
+ assets, err := db.getNeighborAssets(ctx, testUserID, "self", ref, 6, 6)
+ if err != nil {
+ t.Fatalf("getNeighborAssets: %v", err)
+ }
+ if len(assets) != 3 {
+ t.Fatalf("expected 3 neighbors within 6h window, got %d", len(assets))
+ }
+ // Closest-first: before2 (2m), after5 (5m), after3h (3h).
+ wantOrder := []string{"before2", "after5", "after3h"}
+ for i, want := range wantOrder {
+ if assets[i].ImmichID != want {
+ t.Errorf("position %d: expected %q, got %q", i, want, assets[i].ImmichID)
+ }
+ }
+
+ // limit truncates to the closest N.
+ assets, err = db.getNeighborAssets(ctx, testUserID, "self", ref, 6, 2)
+ if err != nil {
+ t.Fatalf("getNeighborAssets limit=2: %v", err)
+ }
+ if len(assets) != 2 {
+ t.Fatalf("expected limit to truncate to 2, got %d", len(assets))
+ }
+ if assets[0].ImmichID != "before2" || assets[1].ImmichID != "after5" {
+ t.Errorf("expected closest two [before2 after5], got [%s %s]", assets[0].ImmichID, assets[1].ImmichID)
+ }
+}
+
func TestAlbumDiffReplaceAssets(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
diff --git a/backend/handlers.go b/backend/handlers.go
index 27a2290..53aa644 100644
--- a/backend/handlers.go
+++ b/backend/handlers.go
@@ -23,6 +23,7 @@ const (
maxPageInfoPageSize = 500
defaultPageInfoPageSize = 90
frequentLocationsLimit = 5
+ neighborLimit = 6
maxClusterResults = 5
defaultMapMarkersLimit = maxMapMarkers
syncVersion = 1
diff --git a/backend/handlers_test.go b/backend/handlers_test.go
index 4af4160..288b93a 100644
--- a/backend/handlers_test.go
+++ b/backend/handlers_test.go
@@ -32,7 +32,7 @@ func newTestHandlers(t *testing.T) (*Handlers, *http.ServeMux) {
}
syncService := newSyncService(db, factory, newNominatimClient(10 * time.Second))
syncService.shutdownCtx = context.Background()
- suggestions := newSuggestionService(db)
+ suggestions := newSuggestionService(db, testNeighborWindow)
handlers := newHandlers(db, factory, "http://external:2283", syncService, suggestions, nil, newNominatimClient(10*time.Second))
mux := http.NewServeMux()
@@ -380,7 +380,7 @@ func newTestHandlersWithMockImmich(t *testing.T, immichHandler http.HandlerFunc)
}
syncService := newSyncService(db, factory, newNominatimClient(10 * time.Second))
syncService.shutdownCtx = context.Background()
- suggestions := newSuggestionService(db)
+ suggestions := newSuggestionService(db, testNeighborWindow)
handlers := newHandlers(db, factory, "http://external:2283", syncService, suggestions, nil, newNominatimClient(10*time.Second))
mux := http.NewServeMux()
@@ -732,6 +732,9 @@ func TestHandleGetSuggestionsSuccess(t *testing.T) {
if resp.SameDayClusters == nil {
t.Error("expected non-nil sameDayClusters")
}
+ if resp.NeighborClusters == nil {
+ t.Error("expected non-nil neighborClusters")
+ }
}
func TestHandleGetAssetPageInfoSuccess(t *testing.T) {
diff --git a/backend/interfaces.go b/backend/interfaces.go
index 98bc89b..8ff3ea3 100644
--- a/backend/interfaces.go
+++ b/backend/interfaces.go
@@ -58,6 +58,7 @@ type HandlerStore interface {
type SuggestionStore interface {
getAssetByID(ctx context.Context, userID, immichID string) (*AssetRow, error)
getSameDayAssets(ctx context.Context, userID, dateTimeOriginal string, hoursRange int) ([]AssetRow, error)
+ getNeighborAssets(ctx context.Context, userID, excludeAssetID, dateTimeOriginal string, windowHours, limit int) ([]AssetRow, error)
getFrequentLocations(ctx context.Context, userID string, limit int) ([]FrequentLocationRow, error)
getAlbumUpdatedAt(ctx context.Context, userID, albumID string) (string, error)
getGeolocatedAssetsByAlbum(ctx context.Context, userID, albumID string) ([]AssetRow, error)
diff --git a/backend/main.go b/backend/main.go
index 11fe342..106bd06 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -46,7 +46,7 @@ func main() {
}, geocodeTimeout)
log.Printf("[Geocode] Provider: %s (timeout: %v)", describeProvider(geocoder), geocodeTimeout)
syncService := newSyncService(db, immichFactory, geocoder)
- suggestions := newSuggestionService(db)
+ suggestions := newSuggestionService(db, cfg.NeighborWindowHours)
handlers := newHandlers(db, immichFactory, cfg.ImmichExternalURL, syncService, suggestions, cfg.defaultTimezoneLocation, geocoder)
libraryHandlers := newLibraryHandlers(db, immichFactory, syncService)
authHandlers := newAuthHandlers(db, immichFactory, syncService, cfg.RegistrationEnabled, !cfg.AllowInsecure)
diff --git a/backend/suggestionService.go b/backend/suggestionService.go
index 3e18c7e..3e82eb7 100644
--- a/backend/suggestionService.go
+++ b/backend/suggestionService.go
@@ -20,13 +20,15 @@ type albumClusterCache struct {
type SuggestionService struct {
db SuggestionStore
+ neighborWindow int
albumClustersMu sync.Mutex
albumClustersMap map[string]albumClusterCache
}
-func newSuggestionService(db SuggestionStore) *SuggestionService {
+func newSuggestionService(db SuggestionStore, neighborWindowHours int) *SuggestionService {
return &SuggestionService{
db: db,
+ neighborWindow: neighborWindowHours,
albumClustersMap: make(map[string]albumClusterCache),
}
}
@@ -51,6 +53,7 @@ func (s *SuggestionService) getSuggestions(ctx context.Context, userID, assetID
WeeklyClusters: []LocationCluster{},
FrequentLocations: []LocationCluster{},
AlbumClusters: []LocationCluster{},
+ NeighborClusters: []LocationCluster{},
}
if albumID != "" {
@@ -75,6 +78,12 @@ func (s *SuggestionService) getSuggestions(ctx context.Context, userID, assetID
response.WeeklyClusters = clusterAssets(weeklyAssets)
}
+ if neighborAssets, err := s.db.getNeighborAssets(ctx, userID, assetID, *dateRef, s.neighborWindow, neighborLimit); err != nil {
+ log.Printf("[Suggest] Failed to get neighbor assets: %v", err)
+ } else if parseErr == nil {
+ response.NeighborClusters = buildNeighborPoints(neighborAssets, refTime)
+ }
+
freqLocs, err := s.db.getFrequentLocations(ctx, userID, frequentLocationsLimit)
if err != nil {
log.Printf("[Suggest] Failed to get frequent locations: %v", err)
@@ -143,6 +152,32 @@ func filterByHourRange(assets []AssetRow, refTime time.Time, hoursRange int) []A
return filtered
}
+func buildNeighborPoints(assets []AssetRow, refTime time.Time) []LocationCluster {
+ points := make([]LocationCluster, 0, len(assets))
+ for _, a := range assets {
+ if a.Latitude == nil || a.Longitude == nil || a.DateTimeOriginal == nil {
+ continue
+ }
+ t, err := parseTimestamp(*a.DateTimeOriginal)
+ if err != nil {
+ continue
+ }
+ offset := int64(t.Sub(refTime).Seconds())
+ label := buildMetadataLabel(a.City, a.State, a.Country)
+ if label == "" {
+ label = formatCoords(*a.Latitude, *a.Longitude)
+ }
+ points = append(points, LocationCluster{
+ Latitude: *a.Latitude,
+ Longitude: *a.Longitude,
+ Label: label,
+ Count: 1,
+ SecondsFromRef: &offset,
+ })
+ }
+ return points
+}
+
func clusterAssets(assets []AssetRow) []LocationCluster {
type clusterData struct {
latSum float64
diff --git a/backend/suggestionService_test.go b/backend/suggestionService_test.go
index f3fb2f2..03f087b 100644
--- a/backend/suggestionService_test.go
+++ b/backend/suggestionService_test.go
@@ -5,10 +5,12 @@ import (
"testing"
)
+const testNeighborWindow = 6
+
func TestGetSuggestionsWithSameDayAssets(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
dateTime := "2024-06-15T12:00:00Z"
nearbyTime := "2024-06-15T14:00:00Z"
@@ -17,19 +19,19 @@ func TestGetSuggestionsWithSameDayAssets(t *testing.T) {
db.upsertAssets(ctx, testUserID, []AssetRow{
{
ImmichID: "target", Type: "IMAGE", OriginalFileName: "target.jpg",
- FileCreatedAt: "2024-06-15T12:00:00Z",
+ FileCreatedAt: "2024-06-15T12:00:00Z",
DateTimeOriginal: &dateTime,
},
{
ImmichID: "nearby", Type: "IMAGE", OriginalFileName: "nearby.jpg",
FileCreatedAt: nearbyTime,
- Latitude: ptr(48.85), Longitude: ptr(2.35),
+ Latitude: ptr(48.85), Longitude: ptr(2.35),
DateTimeOriginal: &nearbyTime,
},
{
ImmichID: "faraway", Type: "IMAGE", OriginalFileName: "far.jpg",
FileCreatedAt: farTime,
- Latitude: ptr(40.71), Longitude: ptr(-74.0),
+ Latitude: ptr(40.71), Longitude: ptr(-74.0),
DateTimeOriginal: &farTime,
},
})
@@ -44,9 +46,97 @@ func TestGetSuggestionsWithSameDayAssets(t *testing.T) {
}
}
+func TestGetSuggestionsWithNeighborClusters(t *testing.T) {
+ db := newTestDB(t)
+ ctx := context.Background()
+ svc := newSuggestionService(db, testNeighborWindow)
+
+ refTime := "2024-06-15T12:00:00Z"
+ before2min := "2024-06-15T11:58:00Z"
+ after5min := "2024-06-15T12:05:00Z"
+ outsideWindow := "2024-06-14T12:00:00Z" // 24h before -> beyond the 6h cap
+
+ db.upsertAssets(ctx, testUserID, []AssetRow{
+ {
+ ImmichID: "target", Type: "IMAGE", OriginalFileName: "target.jpg",
+ FileCreatedAt: refTime, DateTimeOriginal: &refTime,
+ },
+ {
+ ImmichID: "after", Type: "IMAGE", OriginalFileName: "after.jpg",
+ FileCreatedAt: after5min, Latitude: ptr(48.86), Longitude: ptr(2.36),
+ DateTimeOriginal: &after5min,
+ },
+ {
+ ImmichID: "before", Type: "IMAGE", OriginalFileName: "before.jpg",
+ FileCreatedAt: before2min, Latitude: ptr(48.85), Longitude: ptr(2.35),
+ DateTimeOriginal: &before2min,
+ },
+ {
+ ImmichID: "outside", Type: "IMAGE", OriginalFileName: "outside.jpg",
+ FileCreatedAt: outsideWindow, Latitude: ptr(40.71), Longitude: ptr(-74.0),
+ DateTimeOriginal: &outsideWindow,
+ },
+ })
+
+ resp, err := svc.getSuggestions(ctx, testUserID, "target", "")
+ if err != nil {
+ t.Fatalf("getSuggestions: %v", err)
+ }
+
+ if len(resp.NeighborClusters) != 2 {
+ t.Fatalf("expected 2 neighbor points (within window), got %d", len(resp.NeighborClusters))
+ }
+
+ // Closest-first ordering: the 2-min-before photo must precede the 5-min-after one.
+ first := resp.NeighborClusters[0]
+ second := resp.NeighborClusters[1]
+ if first.SecondsFromRef == nil || second.SecondsFromRef == nil {
+ t.Fatalf("expected SecondsFromRef to be set on neighbor points")
+ }
+ if *first.SecondsFromRef != -120 {
+ t.Errorf("expected first neighbor 2 min before, got %d", *first.SecondsFromRef)
+ }
+ if *second.SecondsFromRef != 300 {
+ t.Errorf("expected second neighbor 5 min after, got %d", *second.SecondsFromRef)
+ }
+ if first.Count != 1 || second.Count != 1 {
+ t.Errorf("expected neighbor points to have count 1, got %d and %d", first.Count, second.Count)
+ }
+}
+
+func TestGetSuggestionsNeighborClustersEmptyWhenNoneInWindow(t *testing.T) {
+ db := newTestDB(t)
+ ctx := context.Background()
+ svc := newSuggestionService(db, testNeighborWindow)
+
+ refTime := "2024-06-15T12:00:00Z"
+ farTime := "2024-01-01T12:00:00Z"
+
+ db.upsertAssets(ctx, testUserID, []AssetRow{
+ {
+ ImmichID: "target", Type: "IMAGE", OriginalFileName: "target.jpg",
+ FileCreatedAt: refTime, DateTimeOriginal: &refTime,
+ },
+ {
+ ImmichID: "far", Type: "IMAGE", OriginalFileName: "far.jpg",
+ FileCreatedAt: farTime, Latitude: ptr(40.71), Longitude: ptr(-74.0),
+ DateTimeOriginal: &farTime,
+ },
+ })
+
+ resp, err := svc.getSuggestions(ctx, testUserID, "target", "")
+ if err != nil {
+ t.Fatalf("getSuggestions: %v", err)
+ }
+
+ if len(resp.NeighborClusters) != 0 {
+ t.Errorf("expected no neighbor points outside the window, got %d", len(resp.NeighborClusters))
+ }
+}
+
func TestGetSuggestionsAssetNotFound(t *testing.T) {
db := newTestDB(t)
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
_, err := svc.getSuggestions(context.Background(), testUserID, "nonexistent", "")
if err == nil {
@@ -57,7 +147,7 @@ func TestGetSuggestionsAssetNotFound(t *testing.T) {
func TestGetSuggestionsWithAlbumClusters(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
dateTime := "2024-06-15T12:00:00Z"
db.upsertAssets(ctx, testUserID, []AssetRow{
@@ -68,17 +158,17 @@ func TestGetSuggestionsWithAlbumClusters(t *testing.T) {
{
ImmichID: "albumAsset1", Type: "IMAGE", OriginalFileName: "a1.jpg",
FileCreatedAt: dateTime,
- Latitude: ptr(48.85), Longitude: ptr(2.35),
+ Latitude: ptr(48.85), Longitude: ptr(2.35),
},
{
ImmichID: "albumAsset2", Type: "IMAGE", OriginalFileName: "a2.jpg",
FileCreatedAt: dateTime,
- Latitude: ptr(48.86), Longitude: ptr(2.36),
+ Latitude: ptr(48.86), Longitude: ptr(2.36),
},
})
db.upsertAlbum(ctx, testUserID, "testAlbum", "Test Album", nil, 2, dateTime, nil)
- db.replaceAlbumAssets(ctx, testUserID,"testAlbum", []string{"albumAsset1", "albumAsset2"})
+ db.replaceAlbumAssets(ctx, testUserID, "testAlbum", []string{"albumAsset1", "albumAsset2"})
resp, err := svc.getSuggestions(ctx, testUserID, "target", "testAlbum")
if err != nil {
@@ -136,7 +226,7 @@ func TestBuildMetadataLabel(t *testing.T) {
func TestAlbumClusterCacheHit(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
dateTime := "2024-06-15T12:00:00Z"
db.upsertAssets(ctx, testUserID, []AssetRow{{
@@ -144,7 +234,7 @@ func TestAlbumClusterCacheHit(t *testing.T) {
FileCreatedAt: dateTime, Latitude: ptr(48.85), Longitude: ptr(2.35),
}})
db.upsertAlbum(ctx, testUserID, "testAlbum", "Test", nil, 1, dateTime, nil)
- db.replaceAlbumAssets(ctx, testUserID,"testAlbum", []string{"a1"})
+ db.replaceAlbumAssets(ctx, testUserID, "testAlbum", []string{"a1"})
clusters1 := svc.clusterAlbumAssets(ctx, testUserID, "testAlbum")
clusters2 := svc.clusterAlbumAssets(ctx, testUserID, "testAlbum")
@@ -157,7 +247,7 @@ func TestAlbumClusterCacheHit(t *testing.T) {
func TestGetSuggestionsNoDateTimeOriginal(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
db.upsertAssets(ctx, testUserID, []AssetRow{{
ImmichID: "target", Type: "IMAGE", OriginalFileName: "target.jpg",
@@ -176,7 +266,7 @@ func TestGetSuggestionsNoDateTimeOriginal(t *testing.T) {
func TestGetSuggestionsWithFrequentLocations(t *testing.T) {
db := newTestDB(t)
ctx := context.Background()
- svc := newSuggestionService(db)
+ svc := newSuggestionService(db, testNeighborWindow)
db.replaceFrequentLocations(ctx, testUserID, []FrequentLocationRow{
{Latitude: 48.85, Longitude: 2.35, Label: "Paris, France", AssetCount: 100},
diff --git a/backend/types.go b/backend/types.go
index ccf6d82..20cb698 100644
--- a/backend/types.go
+++ b/backend/types.go
@@ -41,10 +41,11 @@ type MapMarker struct {
}
type LocationCluster struct {
- Latitude float64 `json:"latitude"`
- Longitude float64 `json:"longitude"`
- Label string `json:"label"`
- Count int `json:"count"`
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ Label string `json:"label"`
+ Count int `json:"count"`
+ SecondsFromRef *int64 `json:"secondsFromRef,omitempty"`
}
type SuggestionsResponse struct {
@@ -53,6 +54,7 @@ type SuggestionsResponse struct {
WeeklyClusters []LocationCluster `json:"weeklyClusters"`
FrequentLocations []LocationCluster `json:"frequentLocations"`
AlbumClusters []LocationCluster `json:"albumClusters"`
+ NeighborClusters []LocationCluster `json:"neighborClusters"`
}
type HealthResponse struct {
@@ -175,13 +177,13 @@ type TViewportBounds struct {
}
type UserRow struct {
- ID string `json:"ID"`
- Email string `json:"email"`
- PasswordHash string `json:"-"`
- ImmichAPIKey *string `json:"-"`
- DawarichAPIKey *string `json:"-"`
- CreatedAt string `json:"createdAt"`
- UpdatedAt string `json:"updatedAt"`
+ ID string `json:"ID"`
+ Email string `json:"email"`
+ PasswordHash string `json:"-"`
+ ImmichAPIKey *string `json:"-"`
+ DawarichAPIKey *string `json:"-"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
}
type RegisterRequest struct {
@@ -278,4 +280,3 @@ type GPXPreviewResponse struct {
Matches []GPXMatchResult `json:"matches"`
DetectedTimezone string `json:"detectedTimezone"`
}
-
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 5ff317a..bb3f2b5 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -29,6 +29,7 @@ services:
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GEOCODE_API_KEY=${GEOCODE_API_KEY:-}
- GEOCODE_TIMEOUT=${GEOCODE_TIMEOUT:-10}
+ - SUGGESTIONS_NEIGHBOR_WINDOW_HOURS=${SUGGESTIONS_NEIGHBOR_WINDOW_HOURS:-6}
- DAWARICH_URL=${DAWARICH_URL:-}
- DAWARICH_SYNC_INTERVAL_MS=${DAWARICH_SYNC_INTERVAL_MS:-86400000}
- DEBUG=${DEBUG:-false}
diff --git a/docker-compose.yml b/docker-compose.yml
index 838bd20..689f5c0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -29,6 +29,7 @@ services:
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GEOCODE_API_KEY=${GEOCODE_API_KEY:-}
- GEOCODE_TIMEOUT=${GEOCODE_TIMEOUT:-10}
+ - SUGGESTIONS_NEIGHBOR_WINDOW_HOURS=${SUGGESTIONS_NEIGHBOR_WINDOW_HOURS:-6}
- DAWARICH_URL=${DAWARICH_URL:-}
- DAWARICH_SYNC_INTERVAL_MS=${DAWARICH_SYNC_INTERVAL_MS:-86400000}
- DEBUG=${DEBUG:-false}
diff --git a/src/features/suggestions/Readme.md b/src/features/suggestions/Readme.md
index 19a33e4..108cea1 100644
--- a/src/features/suggestions/Readme.md
+++ b/src/features/suggestions/Readme.md
@@ -14,7 +14,7 @@ The `suggestions` feature provides location suggestions and recently used locati
- `useSuggestions.ts`
- Fetches suggestion clusters from backend.
- Scores and merges suggestion sources.
- - Builds categorized suggestion groups (suggested, album, same-day, two-day, weekly, frequent).
+ - Builds categorized suggestion groups (suggested, neighbor, album, same-day, two-day, weekly, frequent).
- `useSuggestionState.ts`
- UI state derivation for suggestion tabs, counts, and frequent-load behavior.
- Includes visual constants and stable cluster/category helpers.
@@ -38,5 +38,6 @@ The `suggestions` feature provides location suggestions and recently used locati
## Notes
+- The "Nearby in time" (neighbor) category lists the geolocated photos closest in time to the selected one, as individual points ordered by proximity and labeled with their time distance ("2 min before").
- Frequent-location clusters can be loaded on-demand when the suggestion panel opens.
- Recent locations are read from local storage-backed history and trimmed/validated before rendering.
diff --git a/src/features/suggestions/SuggestionsPill.tsx b/src/features/suggestions/SuggestionsPill.tsx
index 39409fe..9225c1b 100644
--- a/src/features/suggestions/SuggestionsPill.tsx
+++ b/src/features/suggestions/SuggestionsPill.tsx
@@ -15,6 +15,7 @@ import {
} from '@/features/suggestions/useSuggestionState';
import {useCatalog, useSelection} from '@/shared/context/AppContext';
import {MAP_LOCATION_SOURCE_SUGGESTION} from '@/utils/map';
+import {SUGGESTION_CATEGORY_KEY, formatNeighborOffset} from '@/utils/suggestions';
import type {ReactElement} from 'react';
@@ -109,7 +110,11 @@ export function SuggestionsPill(): ReactElement {
/>
{cluster.label}
- {cluster.count}
+
+ {activeCategory.key === SUGGESTION_CATEGORY_KEY.neighbor
+ ? formatNeighborOffset(cluster.secondsFromRef)
+ : cluster.count}
+
))}
diff --git a/src/features/suggestions/useSuggestionState.ts b/src/features/suggestions/useSuggestionState.ts
index 2bc3f07..0faabe8 100644
--- a/src/features/suggestions/useSuggestionState.ts
+++ b/src/features/suggestions/useSuggestionState.ts
@@ -8,7 +8,8 @@ import {
SUGGESTION_CATEGORY_KEY,
SUGGESTION_CATEGORY_LABEL,
SUGGESTION_PANEL_FREQUENT_MAX_ITEMS,
- SUGGESTION_PANEL_MAX_ITEMS
+ SUGGESTION_PANEL_MAX_ITEMS,
+ SUGGESTION_PANEL_NEIGHBOR_MAX_ITEMS
} from '@/utils/suggestions';
import type {TLocationCluster, TSuggestionCategory, TSuggestionCategoryKey} from '@/shared/types/suggestion';
@@ -19,6 +20,7 @@ import type {TLocationCluster, TSuggestionCategory, TSuggestionCategoryKey} from
const categoryColors: Record = {
suggested: '#2563eb',
album: '#0d9488',
+ neighbor: '#db2777',
sameDay: '#d97706',
twoDay: '#ea580c',
weekly: '#7c3aed',
@@ -62,7 +64,7 @@ export function categoryColor(value: string): string {
* @returns Stable key string.
*/
export function clusterStableKey(cluster: TLocationCluster): string {
- return `${cluster.latitude}:${cluster.longitude}:${cluster.label}:${cluster.count}`;
+ return `${cluster.latitude}:${cluster.longitude}:${cluster.label}:${cluster.count}:${cluster.secondsFromRef ?? ''}`;
}
type TFrequentSuggestionsState = {
@@ -199,5 +201,8 @@ export function resolveMaxItemsForCategory(categoryKey: string): number {
if (categoryKey === SUGGESTION_CATEGORY_KEY.frequent) {
return SUGGESTION_PANEL_FREQUENT_MAX_ITEMS;
}
+ if (categoryKey === SUGGESTION_CATEGORY_KEY.neighbor) {
+ return SUGGESTION_PANEL_NEIGHBOR_MAX_ITEMS;
+ }
return SUGGESTION_PANEL_MAX_ITEMS;
}
diff --git a/src/features/suggestions/useSuggestions.ts b/src/features/suggestions/useSuggestions.ts
index b949e85..8e3c52b 100644
--- a/src/features/suggestions/useSuggestions.ts
+++ b/src/features/suggestions/useSuggestions.ts
@@ -136,6 +136,14 @@ function buildCategories(response: TSuggestionsResponse, excludeFrequentLocation
});
}
+ if (response.neighborClusters?.length) {
+ cats.push({
+ key: SUGGESTION_CATEGORY_KEY.neighbor,
+ label: SUGGESTION_CATEGORY_LABEL[SUGGESTION_CATEGORY_KEY.neighbor],
+ clusters: response.neighborClusters
+ });
+ }
+
if (response.albumClusters?.length) {
cats.push({
key: SUGGESTION_CATEGORY_KEY.album,
diff --git a/src/shared/services/backendApi.guards.ts b/src/shared/services/backendApi.guards.ts
index 0cdcca1..ecf0ad5 100644
--- a/src/shared/services/backendApi.guards.ts
+++ b/src/shared/services/backendApi.guards.ts
@@ -67,7 +67,8 @@ export function isTLocationCluster(value: unknown): value is TLocationCluster {
isFiniteNumber(value.latitude) &&
isFiniteNumber(value.longitude) &&
isString(value.label) &&
- isFiniteNumber(value.count)
+ isFiniteNumber(value.count) &&
+ (value.secondsFromRef === undefined || isFiniteNumber(value.secondsFromRef))
);
}
@@ -91,7 +92,9 @@ export function isTSuggestionsResponse(value: unknown): value is TSuggestionsRes
Array.isArray(value.frequentLocations) &&
value.frequentLocations.every(isTLocationCluster) &&
Array.isArray(value.albumClusters) &&
- value.albumClusters.every(isTLocationCluster)
+ value.albumClusters.every(isTLocationCluster) &&
+ Array.isArray(value.neighborClusters) &&
+ value.neighborClusters.every(isTLocationCluster)
);
}
@@ -114,7 +117,8 @@ export function isTRawSuggestionsResponse(value: unknown): value is TRawSuggesti
isNullableLocationClusterArray(value.twoDayClusters) &&
isNullableLocationClusterArray(value.weeklyClusters) &&
isNullableLocationClusterArray(value.frequentLocations) &&
- isNullableLocationClusterArray(value.albumClusters)
+ isNullableLocationClusterArray(value.albumClusters) &&
+ isNullableLocationClusterArray(value.neighborClusters)
);
}
diff --git a/src/shared/services/backendApi.ts b/src/shared/services/backendApi.ts
index 497cf31..a371c20 100644
--- a/src/shared/services/backendApi.ts
+++ b/src/shared/services/backendApi.ts
@@ -270,7 +270,8 @@ export async function fetchSuggestions(
twoDayClusters: payload.twoDayClusters ?? [],
weeklyClusters: payload.weeklyClusters ?? [],
frequentLocations: payload.frequentLocations ?? [],
- albumClusters: payload.albumClusters ?? []
+ albumClusters: payload.albumClusters ?? [],
+ neighborClusters: payload.neighborClusters ?? []
};
if (!isTSuggestionsResponse(normalized)) {
diff --git a/src/shared/types/suggestion.ts b/src/shared/types/suggestion.ts
index 5b98584..0615c59 100644
--- a/src/shared/types/suggestion.ts
+++ b/src/shared/types/suggestion.ts
@@ -1,10 +1,11 @@
-export type TSuggestionCategoryKey = 'suggested' | 'album' | 'sameDay' | 'twoDay' | 'weekly' | 'frequent';
+export type TSuggestionCategoryKey = 'suggested' | 'album' | 'sameDay' | 'twoDay' | 'weekly' | 'frequent' | 'neighbor';
export type TLocationCluster = {
latitude: number;
longitude: number;
label: string;
count: number;
+ secondsFromRef?: number;
};
export type TSuggestionsResponse = {
@@ -13,6 +14,7 @@ export type TSuggestionsResponse = {
weeklyClusters: TLocationCluster[];
frequentLocations: TLocationCluster[];
albumClusters: TLocationCluster[];
+ neighborClusters: TLocationCluster[];
};
export type TRawSuggestionsResponse = {
@@ -21,6 +23,7 @@ export type TRawSuggestionsResponse = {
weeklyClusters: TLocationCluster[] | null;
frequentLocations: TLocationCluster[] | null;
albumClusters: TLocationCluster[] | null;
+ neighborClusters: TLocationCluster[] | null;
};
export type TSuggestionCategory = {
diff --git a/src/utils/suggestions.ts b/src/utils/suggestions.ts
index a5dee14..bef8ce6 100644
--- a/src/utils/suggestions.ts
+++ b/src/utils/suggestions.ts
@@ -6,6 +6,7 @@ import type {TSuggestionCategoryKey} from '@/shared/types/suggestion';
export const SUGGESTION_CATEGORY_KEY = {
suggested: 'suggested',
album: 'album',
+ neighbor: 'neighbor',
sameDay: 'sameDay',
twoDay: 'twoDay',
weekly: 'weekly',
@@ -18,6 +19,7 @@ export const SUGGESTION_CATEGORY_KEY = {
export const SUGGESTION_CATEGORY_LABEL: Record = {
suggested: 'Suggestions',
album: 'Same Album',
+ neighbor: 'Nearby in time',
sameDay: 'Same Day',
twoDay: 'Same Week',
weekly: 'Same Month',
@@ -28,3 +30,29 @@ export const SUGGESTION_CATEGORY_LABEL: Record =
export const SUGGESTION_PANEL_MAX_ITEMS = 3;
/** Maximum number of frequent-location suggestion cards rendered. */
export const SUGGESTION_PANEL_FREQUENT_MAX_ITEMS = 5;
+/** Maximum number of temporal-neighbor suggestion cards rendered. */
+export const SUGGESTION_PANEL_NEIGHBOR_MAX_ITEMS = 6;
+
+/**
+ * Format a signed time offset as a short human-readable distance
+ * 59 = just after
+ * -120 = 2 min before
+ * 3600 = 1 h after
+ */
+export function formatNeighborOffset(secondsFromRef: number | undefined): string {
+ if (secondsFromRef === undefined || !Number.isFinite(secondsFromRef)) {
+ return '';
+ }
+ const direction = secondsFromRef < 0 ? 'before' : 'after';
+ const absSeconds = Math.abs(secondsFromRef);
+
+ if (absSeconds < 60) {
+ return `just ${direction}`;
+ }
+ const minutes = Math.round(absSeconds / 60);
+ if (minutes < 60) {
+ return `${minutes} min ${direction}`;
+ }
+ const hours = Math.round(absSeconds / 3600);
+ return `${hours} h ${direction}`;
+}