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:
Jason Rasmussen 2023-10-06 08:12:09 -04:00 committed by GitHub
parent 4a8887f37b
commit 35fa6397ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 40 additions and 35 deletions

View File

@ -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) {

View File

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

View File

@ -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> {

View File

@ -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) {

View File

@ -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 || ''}

View File

@ -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 -->

View File

@ -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

View File

@ -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();
}