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.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')
|
||||
.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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue