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:
parent
4a8887f37b
commit
35fa6397ea
8 changed files with 40 additions and 35 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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' })
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 || ''}
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue