Sfoglia il codice sorgente

fix: time buckets (#4358)

* fix: time buckets

* chore: update entity metadata

* fix: set correct localDateTime

* fix: display without timezone shifting

* fix: handle non-utc databases

* fix: scrollbar

* docs: comment how buckets are sorted

* chore: remove test/log

* chore: lint

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
Jason Rasmussen 1 anno fa
parent
commit
35fa6397ea

+ 2 - 1
server/src/domain/metadata/metadata.service.ts

@@ -157,9 +157,10 @@ export class MetadataService {
     await this.applyMotionPhotos(asset, tags);
     await this.applyReverseGeocoding(asset, exifData);
     await this.assetRepository.upsertExif(exifData);
-    let localDateTime = exifData.dateTimeOriginal ?? undefined;
 
     const dateTimeOriginal = exifDate(firstDateTime(tags as Tags)) ?? exifData.dateTimeOriginal;
+    let localDateTime = dateTimeOriginal ?? undefined;
+
     const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
 
     if (dateTimeOriginal && timeZoneOffset) {

+ 1 - 1
server/src/infra/entities/asset.entity.ts

@@ -84,7 +84,7 @@ export class AssetEntity {
   @Column({ type: 'timestamptz' })
   fileCreatedAt!: Date;
 
-  @Column({ type: 'timestamp' })
+  @Column({ type: 'timestamptz' })
   localDateTime!: Date;
 
   @Column({ type: 'timestamptz' })

+ 8 - 15
server/src/infra/migrations/1694525143117-AddLocalDateTime.ts

@@ -4,22 +4,15 @@ export class AddLocalDateTime1694525143117 implements MigrationInterface {
   name = 'AddLocalDateTime1694525143117';
 
   public async up(queryRunner: QueryRunner): Promise<void> {
-    await queryRunner.query(`ALTER TABLE "assets" ADD "localDateTime" TIMESTAMP`);
-    await queryRunner.query(`
-      update "assets"
-        set "localDateTime" = "fileCreatedAt"`);
-
-    await queryRunner.query(`
-      update "assets"
-        set "localDateTime" = "fileCreatedAt" at TIME ZONE "exif"."timeZone"
-        from "exif"
-      where
-        "exif"."assetId" = "assets"."id" and
-        "exif"."timeZone" is not null`);
-
+    await queryRunner.query(`ALTER TABLE "assets" ADD "localDateTime" TIMESTAMP WITH TIME ZONE`);
+    await queryRunner.query(`UPDATE "assets" SET "localDateTime" = "fileCreatedAt"`);
     await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" SET NOT NULL`);
-    await queryRunner.query(`CREATE INDEX "IDX_day_of_month" ON assets (EXTRACT(DAY FROM "localDateTime"))`);
-    await queryRunner.query(`CREATE INDEX "IDX_month" ON assets (EXTRACT(MONTH FROM "localDateTime"))`);
+    await queryRunner.query(
+      `CREATE INDEX "IDX_day_of_month" ON assets (EXTRACT(DAY FROM "localDateTime" AT TIME ZONE 'UTC'))`,
+    );
+    await queryRunner.query(
+      `CREATE INDEX "IDX_month" ON assets (EXTRACT(MONTH FROM "localDateTime" AT TIME ZONE 'UTC'))`,
+    );
   }
 
   public async down(queryRunner: QueryRunner): Promise<void> {

+ 18 - 10
server/src/infra/repositories/asset.repository.ts

@@ -29,6 +29,8 @@ const truncateMap: Record<TimeBucketSize, string> = {
   [TimeBucketSize.MONTH]: 'month',
 };
 
+const TIME_BUCKET_COLUMN = 'localDateTime';
+
 @Injectable()
 export class AssetRepository implements IAssetRepository {
   constructor(
@@ -86,8 +88,8 @@ export class AssetRepository implements IAssetRepository {
       AND entity.isVisible = true
       AND entity.isArchived = false
       AND entity.resizePath IS NOT NULL
-      AND EXTRACT(DAY FROM entity.localDateTime) = :day
-      AND EXTRACT(MONTH FROM entity.localDateTime) = :month`,
+      AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day
+      AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`,
         {
           ownerId,
           day,
@@ -480,19 +482,25 @@ export class AssetRepository implements IAssetRepository {
 
     return this.getBuilder(options)
       .select(`COUNT(asset.id)::int`, 'count')
-      .addSelect(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'timeBucket')
-      .groupBy(`date_trunc('${truncateValue}', "fileCreatedAt")`)
-      .orderBy(`date_trunc('${truncateValue}', "fileCreatedAt")`, 'DESC')
+      .addSelect(`date_trunc('${truncateValue}', "${TIME_BUCKET_COLUMN}" at time zone 'UTC')`, 'timeBucket')
+      .groupBy(`date_trunc('${truncateValue}', "${TIME_BUCKET_COLUMN}" at time zone 'UTC')`)
+      .orderBy(`date_trunc('${truncateValue}', "${TIME_BUCKET_COLUMN}" at time zone 'UTC')`, 'DESC')
       .getRawMany();
   }
 
   getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
     const truncateValue = truncateMap[options.size];
-    return this.getBuilder(options)
-      .andWhere(`date_trunc('${truncateValue}', "fileCreatedAt") = :timeBucket`, { timeBucket })
-      .orderBy(`date_trunc('day', "localDateTime")`, 'DESC')
-      .addOrderBy('asset.fileCreatedAt', 'DESC')
-      .getMany();
+    return (
+      this.getBuilder(options)
+        .andWhere(`date_trunc('${truncateValue}', "${TIME_BUCKET_COLUMN}" at time zone 'UTC') = :timeBucket`, {
+          timeBucket,
+        })
+        // First sort by the day in localtime (put it in the right bucket)
+        .orderBy(`date_trunc('day', "${TIME_BUCKET_COLUMN}" at time zone 'UTC')`, 'DESC')
+        // and then sort by the actual time
+        .addOrderBy('asset.fileCreatedAt', 'DESC')
+        .getMany()
+    );
   }
 
   private getBuilder(options: TimeBucketOptions) {

+ 2 - 1
web/src/lib/components/memory-page/memory-viewer.svelte

@@ -5,6 +5,7 @@
   import { api } from '@api';
   import { goto } from '$app/navigation';
   import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
+  import { fromLocalDateTime } from '$lib/utils/timeline-util';
   import Play from 'svelte-material-icons/Play.svelte';
   import Pause from 'svelte-material-icons/Pause.svelte';
   import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
@@ -214,7 +215,7 @@
 
             <div class="absolute left-8 top-4 text-sm font-medium text-white">
               <p>
-                {DateTime.fromISO(currentMemory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)}
+                {fromLocalDateTime(currentMemory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)}
               </p>
               <p>
                 {currentAsset.exifInfo?.city || ''}

+ 2 - 3
web/src/lib/components/photos-page/asset-date-group.svelte

@@ -1,10 +1,9 @@
 <script lang="ts">
   import { locale } from '$lib/stores/preferences.store';
   import { getAssetRatio } from '$lib/utils/asset-utils';
-  import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
+  import { formatGroupTitle, fromLocalDateTime, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
   import type { AssetResponseDto } from '@api';
   import justifiedLayout from 'justified-layout';
-  import { DateTime } from 'luxon';
   import { createEventDispatcher } from 'svelte';
   import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
   import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
@@ -127,7 +126,7 @@
 <section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
   {#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
     {@const asset = groupAssets[0]}
-    {@const groupTitle = formatGroupTitle(DateTime.fromISO(asset.localDateTime).startOf('day'))}
+    {@const groupTitle = formatGroupTitle(fromLocalDateTime(asset.localDateTime).startOf('day'))}
     <!-- Asset Group By Date -->
 
     <!-- svelte-ignore a11y-no-static-element-interactions -->

+ 4 - 3
web/src/lib/components/shared-components/scrollbar/scrollbar.svelte

@@ -1,5 +1,6 @@
 <script lang="ts">
   import type { AssetStore } from '$lib/stores/assets.store';
+  import { fromLocalDateTime } from '$lib/utils/timeline-util';
   import { createEventDispatcher } from 'svelte';
 
   export let timelineY = 0;
@@ -92,9 +93,9 @@
     {/if}
     <!-- Time Segment -->
     {#each segments as segment, index (segment.timeGroup)}
-      {@const date = new Date(segment.timeGroup)}
-      {@const year = date.getFullYear()}
-      {@const label = `${date.toLocaleString('default', { month: 'short' })} ${year}`}
+      {@const date = fromLocalDateTime(segment.timeGroup)}
+      {@const year = date.year}
+      {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`}
 
       <!-- svelte-ignore a11y-no-static-element-interactions -->
       <div

+ 3 - 1
web/src/lib/utils/timeline-util.ts

@@ -2,6 +2,8 @@ import type { AssetResponseDto } from '@api';
 import lodash from 'lodash-es';
 import { DateTime, Interval } from 'luxon';
 
+export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC' });
+
 export const groupDateFormat: Intl.DateTimeFormatOptions = {
   weekday: 'short',
   month: 'short',
@@ -45,7 +47,7 @@ export function splitBucketIntoDateGroups(
 ): AssetResponseDto[][] {
   return lodash
     .chain(assets)
-    .groupBy((asset) => new Date(asset.localDateTime).toLocaleDateString(locale, groupDateFormat))
+    .groupBy((asset) => fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }))
     .sortBy((group) => assets.indexOf(group[0]))
     .value();
 }