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.applyMotionPhotos(asset, tags);
await this.applyReverseGeocoding(asset, exifData); await this.applyReverseGeocoding(asset, exifData);
await this.assetRepository.upsertExif(exifData); await this.assetRepository.upsertExif(exifData);
let localDateTime = exifData.dateTimeOriginal ?? undefined;
const dateTimeOriginal = exifDate(firstDateTime(tags as Tags)) ?? exifData.dateTimeOriginal; const dateTimeOriginal = exifDate(firstDateTime(tags as Tags)) ?? exifData.dateTimeOriginal;
let localDateTime = dateTimeOriginal ?? undefined;
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0; const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) { if (dateTimeOriginal && timeZoneOffset) {

View file

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

View file

@ -4,22 +4,15 @@ export class AddLocalDateTime1694525143117 implements MigrationInterface {
name = 'AddLocalDateTime1694525143117'; name = 'AddLocalDateTime1694525143117';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "localDateTime" TIMESTAMP`); await queryRunner.query(`ALTER TABLE "assets" ADD "localDateTime" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(` await queryRunner.query(`UPDATE "assets" SET "localDateTime" = "fileCreatedAt"`);
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" ALTER COLUMN "localDateTime" SET NOT NULL`); 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(
await queryRunner.query(`CREATE INDEX "IDX_month" ON assets (EXTRACT(MONTH FROM "localDateTime"))`); `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> { public async down(queryRunner: QueryRunner): Promise<void> {

View file

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

View file

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

View file

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

View file

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

View file

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