fix player sessions list

This commit is contained in:
Nikita Galaiko 2023-05-23 13:51:56 +02:00
parent 9731a41139
commit ef49fcbcdc
7 changed files with 247 additions and 189 deletions

View File

@ -67,11 +67,11 @@
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"prettier-plugin-tailwindcss": "^0.3.0",
"svelte": "^3.55.1",
"svelte": "~3.55.1",
"svelte-check": "^3.0.1",
"svelte-floating-ui": "^1.5.2",
"svelte-french-toast": "^1.0.3",
"svelte-loadable-store": "^1.0.1",
"svelte-loadable-store": "^1.0.7",
"svelte-outclick": "^3.5.0",
"svelte-resize-observer": "^2.0.0",
"tailwindcss": "^3.1.5",

View File

@ -155,7 +155,7 @@ devDependencies:
specifier: ^0.3.0
version: 0.3.0(prettier-plugin-svelte@2.9.0)(prettier@2.8.4)
svelte:
specifier: ^3.55.1
specifier: ~3.55.1
version: 3.55.1
svelte-check:
specifier: ^3.0.1
@ -167,8 +167,8 @@ devDependencies:
specifier: ^1.0.3
version: 1.0.3(svelte@3.55.1)
svelte-loadable-store:
specifier: ^1.0.1
version: 1.0.1
specifier: ^1.0.7
version: 1.0.7(svelte@3.55.1)
svelte-outclick:
specifier: ^3.5.0
version: 3.5.0(svelte@3.55.1)
@ -3655,10 +3655,12 @@ packages:
svelte: 3.55.1
dev: true
/svelte-loadable-store@1.0.1:
resolution: {integrity: sha512-ZPRBNqaDF6PW4nruHWh73b9WWdmt5SMAZ6he9KsZo4XDvl1h4jECBKPVdnHCc4zEQMCig01BofH3bwu67G44SQ==}
/svelte-loadable-store@1.0.7(svelte@3.55.1):
resolution: {integrity: sha512-FLahOiMtD9lxVNS9bzbdxHC8+D0y7qxZjaaeybCyxc9uzS3/9EMTSDpv/FMTLwncQ4syOtwz5oQBO+9iAqm7hQ==}
peerDependencies:
svelte: ^3.0.0
dependencies:
svelte: 3.59.1
svelte: 3.55.1
dev: true
/svelte-outclick@3.5.0(svelte@3.55.1):
@ -3738,11 +3740,6 @@ packages:
engines: {node: '>= 8'}
dev: true
/svelte@3.59.1:
resolution: {integrity: sha512-pKj8fEBmqf6mq3/NfrB9SLtcJcUvjYSWyePlfCqN9gujLB25RitWK8PvFzlwim6hD/We35KbPlRteuA6rnPGcQ==}
engines: {node: '>= 8'}
dev: true
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: true

View File

@ -16,7 +16,9 @@ export default (params: { projectId: string; sessionId: string }) => {
} else {
set({
...oldValue.value,
[filePath]: [...oldValue.value[filePath], ...newDeltas]
[filePath]: oldValue.value[filePath]
? [...oldValue.value[filePath], ...newDeltas]
: newDeltas
});
}
})

View File

@ -1,110 +1,69 @@
<script lang="ts">
import type { LayoutData } from './$types';
import { IconChevronLeft, IconChevronRight } from '$lib/icons';
import { page } from '$app/stores';
import { asyncDerived, derived } from '@square/svelte-store';
import { derived } from 'svelte-loadable-store';
import { format } from 'date-fns';
import { onMount } from 'svelte';
import { api, events, hotkeys, toasts } from '$lib';
import { api, events, hotkeys, stores, toasts } from '$lib';
import BookmarkModal from './BookmarkModal.svelte';
import tinykeys from 'tinykeys';
import { goto } from '$app/navigation';
import { unsubscribe } from '$lib/utils';
import SessionCard from './SessionCard.svelte';
import SessionsList from './SessionsList.svelte';
import SessionNavigations from './SessionNavigations.svelte';
export let data: LayoutData;
const { currentFilepath, currentTimestamp } = data;
const dateSessions = derived([data.sessions, page], ([sessions, page]) =>
sessions?.filter(
(session) => format(session.meta.startTimestampMs, 'yyyy-MM-dd') === page.params.date
)
let sessions = stores.sessions({ projectId: $page.params.projectId });
let dateSessions = derived([sessions, page], ([sessions, page]) =>
sessions
.filter((session) => format(session.meta.startTimestampMs, 'yyyy-MM-dd') === page.params.date)
.sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs)
);
const fileFilter = derived(page, (page) => page.url.searchParams.get('file'));
const projectId = derived(page, (page) => page.params.projectId);
const richSessions = asyncDerived(
[dateSessions, fileFilter, projectId],
async ([sessions, fileFilter, projectId]) => {
return sessions
.map((session) => ({
...session,
deltas: derived(api.deltas.Deltas({ projectId, sessionId: session.id }), (deltas) => {
if (!fileFilter) return deltas;
return Object.fromEntries(
Object.entries(deltas).filter(([path]) => fileFilter.includes(path))
);
})
}))
.sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs);
}
let richSessions = derived([dateSessions, page], ([sessions, page]) =>
sessions.map((session) => ({
...session,
deltas: derived(
stores.deltas({ projectId: page.params.projectId, sessionId: session.id }),
(deltas) =>
Object.fromEntries(
Object.entries(deltas).filter(([path]) => {
const filter = page.url.searchParams.get('file');
return filter ? path === filter : true;
})
)
),
files: derived(
stores.files({ projectId: page.params.projectId, sessionId: session.id }),
(files) =>
Object.fromEntries(
Object.entries(files).filter(([path]) => {
const filter = page.url.searchParams.get('file');
return filter ? path === filter : true;
})
)
)
}))
);
const scrollToSession = () => {
const sessionEl = document.getElementById('current-session');
if (sessionEl) {
sessionEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
const currentSession = derived(
let currentSession = derived(
[page, richSessions, data.currentSessionId],
([page, sessions, currentSessionId]) =>
sessions?.find((s) => s.id === currentSessionId) ??
sessions?.find((s) => s.id === page.params.sessionId)
);
currentSession.subscribe(scrollToSession);
const nextSessionId = derived([page, richSessions], ([page, sessions]) => {
if (sessions) {
const currentIndex = sessions.findIndex((s) => s.id === page.params.sessionId);
if (currentIndex === -1) return undefined;
if (currentIndex < sessions.length - 1) return sessions[currentIndex + 1].id;
return undefined;
}
});
const prevSessionId = derived([page, richSessions], ([page, sessions]) => {
if (sessions) {
const currentIndex = sessions.findIndex((s) => s.id === page.params.sessionId);
if (currentIndex === -1) return undefined;
if (currentIndex > 0) return sessions[currentIndex - 1].id;
return undefined;
}
});
const removeFromSearchParams = (params: URLSearchParams, key: string) => {
params.delete(key);
return params;
};
const getSessionURI = (sessionId: string) =>
`/projects/${$page.params.projectId}/player/${
$page.params.date
}/${sessionId}?${removeFromSearchParams($page.url.searchParams, 'delta').toString()}`;
let bookmarkModal: BookmarkModal;
onMount(() =>
unsubscribe(
tinykeys(window, {
'Shift+ArrowRight': () =>
nextSessionId.load().then((sessionId) => {
if (sessionId) goto(getSessionURI(sessionId));
}),
'Shift+ArrowLeft': () =>
prevSessionId.load().then((sessionId) => {
if (sessionId) goto(getSessionURI(sessionId));
})
}),
events.on('openBookmarkModal', () => bookmarkModal?.show($currentTimestamp)),
hotkeys.on('Meta+Shift+D', () => bookmarkModal?.show($currentTimestamp)),
hotkeys.on('D', () =>
api.bookmarks
.upsert({
projectId: $projectId,
projectId: $page.params.projectId,
note: '',
timestampMs: $currentTimestamp,
deleted: false
@ -116,82 +75,34 @@
</script>
<article id="activities" class="card my-2 flex w-80 flex-shrink-0 flex-grow-0 flex-col xl:w-96">
{#await richSessions.load()}
{#if $richSessions.isLoading || $currentSession.isLoading}
<div class="flex h-full flex-col items-center justify-center">
<div
class="loader border-gray-200 mb-4 h-12 w-12 rounded-full border-4 border-t-4 ease-linear"
/>
<h2 class="text-center text-2xl font-medium text-gray-500">Loading...</h2>
</div>
{:then}
<header
class="card-header flex flex-row justify-between rounded-t border-b-[1px] border-b-divider bg-card-active px-3 py-2 leading-[21px]"
>
<div class="relative flex gap-2">
<div class="relative bottom-[1px] h-4 w-4 text-sm">🧰</div>
<div>Working History</div>
<div class="text-zinc-400">
{$richSessions.length}
</div>
</div>
</header>
<ul
class="mr-1 flex h-full flex-col gap-2 overflow-auto rounded-b bg-card-default pt-2 pb-2 pl-2 pr-1"
>
{#each $richSessions as session}
{@const isCurrent = session.id === $currentSession?.id}
<SessionCard
{isCurrent}
{session}
deltas={session.deltas}
currentFilepath={$currentFilepath}
/>
{:else}
<div class="mt-4 text-center text-zinc-300">No activities found</div>
{/each}
</ul>
{/await}
{:else}
<SessionsList
sessions={$richSessions.value}
currentSession={$currentSession.value}
currentFilepath={$currentFilepath}
/>
{/if}
</article>
<div id="player" class="card relative my-2 flex flex-auto flex-col overflow-auto">
<header class="flex items-center gap-3 bg-card-active px-3 py-2">
{#await Promise.all([currentSession.load(), nextSessionId.load(), prevSessionId.load()])}
{#if $currentSession.isLoading || $richSessions.isLoading}
<span>Loading...</span>
{:then}
{#if !$currentSession}
<span>No session found</span>
{:else}
<span class="min-w-[200px]">
{format($currentSession.meta.startTimestampMs, 'EEEE, LLL d, HH:mm')}
-
{format($currentSession.meta.lastTimestampMs, 'HH:mm')}
</span>
<div class="flex items-center gap-1">
<a
href={$prevSessionId && getSessionURI($prevSessionId)}
class="rounded border border-zinc-500 bg-zinc-600 p-0.5"
class:hover:bg-zinc-500={!!$prevSessionId}
class:pointer-events-none={!$prevSessionId}
class:text-zinc-500={!$prevSessionId}
>
<IconChevronLeft class="h-4 w-4" />
</a>
<a
href={$nextSessionId && getSessionURI($nextSessionId)}
class="rounded border border-zinc-500 bg-zinc-600 p-0.5"
class:hover:bg-zinc-500={!!$nextSessionId}
class:pointer-events-none={!$nextSessionId}
class:text-zinc-500={!$nextSessionId}
>
<IconChevronRight class="h-4 w-4" />
</a>
</div>
{/if}
{/await}
{:else if !$currentSession.value}
<span>No session found</span>
{:else}
<SessionNavigations currentSession={$currentSession.value} sessions={$richSessions.value} />
{/if}
</header>
<slot />
</div>
<BookmarkModal bind:this={bookmarkModal} projectId={$projectId} />
<BookmarkModal bind:this={bookmarkModal} projectId={$page.params.projectId} />

View File

@ -2,34 +2,31 @@
import type { Delta, Session } from '$lib/api';
import { page } from '$app/stores';
import { collapse } from '$lib/paths';
import { derived, type Loadable } from '@square/svelte-store';
import { derived } from '@square/svelte-store';
import { stores } from '$lib';
import { IconBookmarkFilled } from '$lib/icons';
export let isCurrent: boolean;
export let session: Session;
export let currentFilepath: string;
export let deltas: Loadable<Record<string, Delta[]>>;
export let deltas: Record<string, Delta[]>;
$: bookmarks = derived(
[stores.bookmarks({ projectId: session.projectId }), deltas],
([bookmarks, deltas]) => {
if (bookmarks.isLoading) return [];
const timestamps = Object.values(deltas ?? {}).flatMap((deltas) =>
deltas.map((d) => d.timestampMs)
);
const start = Math.min(...timestamps);
const end = Math.max(...timestamps);
return bookmarks.value
.filter((bookmark) => !bookmark.deleted)
.filter((bookmark) => bookmark.timestampMs >= start && bookmark.timestampMs < end);
}
);
$: bookmarks = derived(stores.bookmarks({ projectId: session.projectId }), (bookmarks) => {
if (bookmarks.isLoading) return [];
const timestamps = Object.values(deltas ?? {}).flatMap((deltas) =>
deltas.map((d) => d.timestampMs)
);
const start = Math.min(...timestamps);
const end = Math.max(...timestamps);
return bookmarks.value
.filter((bookmark) => !bookmark.deleted)
.filter((bookmark) => bookmark.timestampMs >= start && bookmark.timestampMs < end);
});
const unique = (value: any, index: number, self: any[]) => self.indexOf(value) === index;
const lexically = (a: string, b: string) => a.localeCompare(b);
const changedFiles = derived(deltas, (deltas) => Object.keys(deltas ?? {}).filter(unique));
const changedFiles = Object.keys(deltas ?? {}).filter(unique);
const sessionDuration = (session: Session) =>
`${Math.round((session.meta.lastTimestampMs - session.meta.startTimestampMs) / 1000 / 60)} min`;
@ -59,9 +56,15 @@
`/projects/${$page.params.projectId}/player/${
$page.params.date
}/${sessionId}?${removeFromSearchParams($page.url.searchParams, 'delta').toString()}`;
let card: HTMLLIElement;
$: if (isCurrent) {
card?.scrollIntoView({ behavior: 'smooth' });
}
</script>
<li
bind:this={card}
id={isCurrent ? 'current-session' : ''}
class:bg-card-active={isCurrent}
class="session-card relative rounded border-[0.5px] border-gb-700 text-zinc-300 shadow-md transition-colors duration-200 ease-in-out hover:bg-card-active"
@ -81,27 +84,25 @@
</div>
<span class="flex flex-row justify-between px-3 pb-3">
{$changedFiles.length}
{$changedFiles.length !== 1 ? 'files' : 'file'}
{changedFiles.length}
{changedFiles.length !== 1 ? 'files' : 'file'}
</span>
{#if isCurrent}
{#await changedFiles.load() then}
<ul
class="list-disk list-none overflow-hidden rounded-bl rounded-br bg-zinc-800 py-1 pl-0 pr-2"
style:list-style="disc"
>
{#each $changedFiles.sort(lexically) as filename}
<li
class:text-zinc-100={currentFilepath === filename}
class:bg-[#3356C2]={currentFilepath === filename}
class="mx-5 ml-1 w-full list-none rounded p-1 text-zinc-500"
>
{collapse(filename)}
</li>
{/each}
</ul>
{/await}
<ul
class="list-disk list-none overflow-hidden rounded-bl rounded-br bg-zinc-800 py-1 pl-0 pr-2"
style:list-style="disc"
>
{#each changedFiles.sort(lexically) as filename}
<li
class:text-zinc-100={currentFilepath === filename}
class:bg-[#3356C2]={currentFilepath === filename}
class="mx-5 ml-1 w-full list-none rounded p-1 text-zinc-500"
>
{collapse(filename)}
</li>
{/each}
</ul>
{/if}
</a>
</li>

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { IconChevronLeft, IconChevronRight } from '$lib/icons';
import { page } from '$app/stores';
import { hotkeys } from '$lib';
import type { Delta, Session } from '$lib/api';
import { unsubscribe } from '$lib/utils';
import type { Readable } from '@square/svelte-store';
import { onMount } from 'svelte';
import type { Loadable } from 'svelte-loadable-store';
import { derived } from 'svelte-loadable-store';
import { format } from 'date-fns';
export let sessions: (Session & {
deltas: Readable<Loadable<Record<string, Delta[]>>>;
})[];
export let currentSession: Session;
$: sessionDeltas = derived(
sessions.map(({ deltas }) => deltas),
(deltas) => deltas
);
$: nextSessionId = derived([page, sessionDeltas], ([page, sessionDeltas]) => {
if (sessions) {
const currentIndex = sessions.findIndex((s) => s.id === page.params.sessionId);
if (currentIndex === -1) return undefined;
for (let i = currentIndex + 1; i < sessions.length; i++) {
if (Object.keys(sessionDeltas[i]).length > 0) return sessions[i].id;
}
return undefined;
}
});
$: prevSessionId = derived([page, sessionDeltas], ([page, sessionDeltas]) => {
if (sessions) {
const currentIndex = sessions.findIndex((s) => s.id === page.params.sessionId);
if (currentIndex === -1) return undefined;
for (let i = currentIndex - 1; i >= 0; i--) {
if (Object.keys(sessionDeltas[i]).length > 0) return sessions[i].id;
}
return undefined;
}
});
const removeFromSearchParams = (params: URLSearchParams, key: string) => {
params.delete(key);
return params;
};
const getSessionURI = (sessionId: string) =>
`/projects/${$page.params.projectId}/player/${
$page.params.date
}/${sessionId}?${removeFromSearchParams($page.url.searchParams, 'delta').toString()}`;
onMount(() =>
unsubscribe(
hotkeys.on('Shift+ArrowRight', () => {
if (!$nextSessionId.isLoading && $nextSessionId.value)
goto(getSessionURI($nextSessionId.value));
}),
hotkeys.on('Shift+ArrowLeft', () => {
if (!$prevSessionId.isLoading && $prevSessionId.value)
goto(getSessionURI($prevSessionId.value));
})
)
);
</script>
<span class="min-w-[200px]">
{format(currentSession.meta.startTimestampMs, 'EEEE, LLL d, HH:mm')}
-
{format(currentSession.meta.lastTimestampMs, 'HH:mm')}
</span>
<div class="flex items-center gap-1">
{#if !$prevSessionId.isLoading && !$nextSessionId.isLoading}
<a
href={$prevSessionId.value && getSessionURI($prevSessionId.value)}
class="rounded border border-zinc-500 bg-zinc-600 p-0.5"
class:hover:bg-zinc-500={!!$prevSessionId.value}
class:pointer-events-none={!$prevSessionId.value}
class:text-zinc-500={!$prevSessionId.value}
>
<IconChevronLeft class="h-4 w-4" />
</a>
<a
href={$nextSessionId.value && getSessionURI($nextSessionId.value)}
class="rounded border border-zinc-500 bg-zinc-600 p-0.5"
class:hover:bg-zinc-500={!!$nextSessionId.value}
class:pointer-events-none={!$nextSessionId.value}
class:text-zinc-500={!$nextSessionId.value}
>
<IconChevronRight class="h-4 w-4" />
</a>
{/if}
</div>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import type { Delta, Session } from '$lib/api';
import type { Readable } from '@square/svelte-store';
import type { Loadable } from 'svelte-loadable-store';
import { derived } from 'svelte-loadable-store';
import SessionCard from './SessionCard.svelte';
export let sessions: (Session & {
deltas: Readable<Loadable<Record<string, Delta[]>>>;
files: Readable<Loadable<Record<string, string>>>;
})[];
export let currentSession: Session | undefined;
export let currentFilepath: string;
$: visibleDeltas = derived(
sessions.map(({ deltas }) => deltas),
(deltas) => deltas.map((delta) => Object.fromEntries(Object.entries(delta ?? {})))
);
$: visibleSessions = sessions?.map((session, i) => ({
...session,
visible: !$visibleDeltas.isLoading && Object.keys($visibleDeltas.value[i]).length > 0
}));
</script>
<header
class="card-header flex flex-row justify-between rounded-t border-b-[1px] border-b-divider bg-card-active px-3 py-2 leading-[21px]"
>
<div class="relative flex gap-2">
<div class="relative bottom-[1px] h-4 w-4 text-sm">🧰</div>
<div>Working History</div>
<div class="text-zinc-400">
{visibleSessions.filter(({ visible }) => visible).length}
</div>
</div>
</header>
<ul
class="mr-1 flex h-full flex-col gap-2 overflow-auto rounded-b bg-card-default pt-2 pb-2 pl-2 pr-1"
>
{#each visibleSessions as session, i}
{@const isCurrent = session.id === currentSession?.id}
{#if session.visible && !$visibleDeltas.isLoading}
<SessionCard {isCurrent} {session} deltas={$visibleDeltas.value[i]} {currentFilepath} />
{/if}
{:else}
<div class="mt-4 text-center text-zinc-300">No activities found</div>
{/each}
</ul>