mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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>
This commit is contained in:
		@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -84,7 +84,7 @@ export class AssetEntity {
 | 
			
		||||
  @Column({ type: 'timestamptz' })
 | 
			
		||||
  fileCreatedAt!: Date;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'timestamp' })
 | 
			
		||||
  @Column({ type: 'timestamptz' })
 | 
			
		||||
  localDateTime!: Date;
 | 
			
		||||
 | 
			
		||||
  @Column({ type: 'timestamptz' })
 | 
			
		||||
 
 | 
			
		||||
@@ -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> {
 | 
			
		||||
 
 | 
			
		||||
@@ -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')
 | 
			
		||||
    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();
 | 
			
		||||
        .getMany()
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private getBuilder(options: TimeBucketOptions) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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 || ''}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 -->
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user