refactor project page

This commit is contained in:
Nikita Galaiko 2023-03-30 16:34:58 +02:00
parent 5e827d912b
commit 16d3821624
4 changed files with 230 additions and 303 deletions

View File

@ -30,12 +30,15 @@ export default async () => {
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
get: (id: string) => { get: (id: string) => {
const project = derived(store, (store) => store.find((p) => p.id === id)); const project = derived(store, (projects) => {
const project = projects.find((p) => p.id === id);
if (!project) throw new Error(`Project ${id} not found`);
return project;
});
return { return {
subscribe: project.subscribe, subscribe: project.subscribe,
update: (params: { title?: string; api?: Project['api'] }) => { update: (params: { title?: string; api?: Project['api'] }) =>
if (id === undefined) return; update({
return update({
project: { project: {
id, id,
...params ...params
@ -43,8 +46,7 @@ export default async () => {
}).then((project) => { }).then((project) => {
store.update((projects) => projects.map((p) => (p.id === project.id ? project : p))); store.update((projects) => projects.map((p) => (p.id === project.id ? project : p)));
return project; return project;
}); })
}
}; };
}, },
add: (params: { path: string }) => add: (params: { path: string }) =>

View File

@ -7,11 +7,12 @@
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte'; import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CommandPalette from '$lib/components/CommandPalette/CommandPalette.svelte'; import CommandPalette from '$lib/components/CommandPalette/CommandPalette.svelte';
import { readable } from 'svelte/store';
export let data: LayoutData; export let data: LayoutData;
const { user, posthog, projects } = data; const { user, posthog, projects } = data;
$: project = projects.get($page.params.projectId); $: project = $page.params.projectId ? projects.get($page.params.projectId) : readable(undefined);
user.subscribe(posthog.identify); user.subscribe(posthog.identify);
</script> </script>

View File

@ -1,30 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { Session } from '$lib/sessions'; import { format, getTime, isEqual, startOfDay, subDays } from 'date-fns';
import { format, getTime, startOfDay, subDays } from 'date-fns';
import type { Delta } from '$lib/deltas';
import { collapsable } from '$lib/paths'; import { collapsable } from '$lib/paths';
import { list as listDeltas } from '$lib/deltas';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { derived } from 'svelte/store'; import { derived } from 'svelte/store';
import Tooltip from '$lib/components/Tooltip.svelte'; import Tooltip from '$lib/components/Tooltip.svelte';
import { IconGitBranch } from '$lib/components/icons';
import type { Session } from '$lib/sessions';
import { asyncDerived } from '@square/svelte-store';
import { list as listDeltas, type Delta } from '$lib/deltas';
import IconRotateClockwise from '$lib/components/icons/IconRotateClockwise.svelte';
import FileActivity from './FileActivity.svelte';
export let data: PageData; export let data: PageData;
const { activity, project, statuses, sessions, head } = data; const { activity, project, statuses, sessions, head } = data;
let latestDeltasByDateByFile: Record<number, Record<string, Delta[][]>[]> = {};
function playerURL(ms: string, file?: string) {
let date = new Date(parseInt(ms));
let datePass = format(date, 'yyyy-MM-dd');
if ($project) {
if (file) {
return `/projects/${$project.id}/player/${datePass}?file=${encodeURIComponent(file)}`;
}
return `/projects/${$project.id}/player/${datePass}`;
}
}
const recentSessions = derived(sessions, (sessions) => { const recentSessions = derived(sessions, (sessions) => {
const lastFourDaysOfSessions = sessions.filter( const lastFourDaysOfSessions = sessions.filter(
(session) => session.meta.startTimestampMs >= getTime(subDays(new Date(), 4)) (session) => session.meta.startTimestampMs >= getTime(subDays(new Date(), 4))
@ -39,298 +28,194 @@
.sort((a, b) => b.timestampMs - a.timestampMs) .sort((a, b) => b.timestampMs - a.timestampMs)
); );
$: if ($project) { const sessionByDates = derived(recentSessions, (sessions) =>
latestDeltasByDateByFile = {}; sessions.reduce((list: [Session[], Date][], session) => {
const dateSessions: Record<number, Session[]> = {};
$recentSessions.forEach((session) => {
const date = startOfDay(new Date(session.meta.startTimestampMs)); const date = startOfDay(new Date(session.meta.startTimestampMs));
if (dateSessions[date.getTime()]) { if (list.length === 0) {
dateSessions[date.getTime()]?.push(session); list.push([[session], date]);
} else { } else {
dateSessions[date.getTime()] = [session]; const last = list[list.length - 1];
} if (isEqual(last[1], date)) {
}); last[0].push(session);
const latestDateSessions: Record<number, Session[]> = Object.fromEntries(
Object.entries(dateSessions)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0]))
.slice(0, 3)
); // Only show the last 3 days
Object.keys(latestDateSessions).forEach((date: string) => {
Promise.all(
latestDateSessions[parseInt(date)].map(async (session) => {
const sessionDeltas = await listDeltas({
projectId: $project?.id ?? '',
sessionId: session.id
});
const fileDeltas: Record<string, Delta[][]> = {};
Object.keys(sessionDeltas).forEach((filePath) => {
if (sessionDeltas[filePath].length > 0) {
if (fileDeltas[filePath]) {
fileDeltas[filePath]?.push(sessionDeltas[filePath]);
} else {
fileDeltas[filePath] = [sessionDeltas[filePath]];
}
}
});
return fileDeltas;
})
).then((sessionsByFile) => {
latestDeltasByDateByFile[parseInt(date)] = sessionsByFile;
});
});
}
// convert a list of timestamps to a sparkline
function timestampsToSpark(tsArray: number[]) {
let range = tsArray[0] - tsArray[tsArray.length - 1];
let totalBuckets = 18;
let bucketSize = range / totalBuckets;
let buckets: number[][] = [];
for (let i = 0; i <= totalBuckets; i++) {
buckets.push([]);
}
tsArray.forEach((ts) => {
let bucket = Math.floor((tsArray[0] - ts) / bucketSize);
if (bucket && ts) {
buckets[bucket].push(ts);
}
});
let spark = '';
buckets.forEach((entries) => {
let size = entries.length;
if (size < 1) {
spark += '<span class="text-zinc-600"></span>';
} else if (size < 2) {
spark += '<span class="text-blue-200"></span>';
} else if (size < 3) {
spark += '<span class="text-blue-200"></span>';
} else if (size < 4) {
spark += '<span class="text-blue-200"></span>';
} else if (size < 5) {
spark += '<span class="text-blue-200"></span>';
} else if (size < 6) {
spark += '<span class="text-blue-200"></span>';
} else if (size < 7) {
spark += '<span class="text-blue-200"></span>';
} else {
spark += '<span class="text-blue-200"></span>';
}
});
return spark;
}
// reduce a group of sessions to a map of filename to timestamps array
function sessionFileMap(sessions: Record<string, Delta[][]>[]): Record<string, number[]> {
let sessionsByFile: Record<string, number[]> = {};
for (const s of sessions) {
for (const [filename, deltas] of Object.entries(s)) {
let timestamps = deltas.flatMap((d) => d.map((dd) => dd.timestampMs));
if (sessionsByFile[filename]) {
sessionsByFile[filename] = sessionsByFile[filename].concat(timestamps).sort();
} else { } else {
sessionsByFile[filename] = timestamps; list.push([[session], date]);
} }
} }
} return list;
return sessionsByFile; }, [])
} );
// order the sessions and summarize the changes by file const filesActivityByDate = asyncDerived(
function orderedSessions(dateSessions: Record<number, Record<string, Delta[][]>[]>) { [project, sessionByDates],
return Object.entries(dateSessions) async ([project, sessionByDates]) =>
.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) await Promise.all(
.map( sessionByDates.map(async ([sessions, date]) => {
([date, sessions]) => [date, sessionFileMap(sessions)] as [string, Record<string, number[]>] const deltas = await Promise.all(
); sessions.map((session) =>
} listDeltas({
projectId: project.id,
sessionId: session.id
})
)
);
const merged: Record<string, Delta[]> = {};
deltas.forEach((delta) =>
Object.entries(delta).forEach(([filepath, deltas]) => {
if (merged[filepath]) {
merged[filepath].push(...deltas);
} else {
merged[filepath] = deltas;
}
})
);
return [merged, date] as [Record<string, Delta[]>, Date];
})
)
);
</script> </script>
<div class="project-section-component" style="height: calc(-114px + 100vh); overflow: hidden;"> <div id="project-overview" class="flex h-full w-full">
<div class="flex h-full"> <div class="flex w-2/3 flex-col gap-4">
<div <h1 class="flex py-4 px-8 text-xl text-zinc-300">
class="main-column-containercol-span-2 mt-4" <span>{$project?.title}</span>
style="width: calc(100% * 0.66); height: calc(-126px + 100vh)" <span class="ml-2 text-zinc-600">Project</span>
> </h1>
<h1
class="project-title pointer-events-none flex select-none py-4 px-8 text-xl text-zinc-300"
>
{$project?.title} <span class="ml-2 text-zinc-600">Project</span>
</h1>
<div class="mt-4">
<div class="recent-file-changes-container h-full w-full">
<h2 class="pointer-events-none mb-4 select-none px-8 text-lg font-bold text-zinc-300">
Recently changed files
</h2>
{#if latestDeltasByDateByFile === undefined}
<div class="p-8 text-center text-zinc-400">Loading...</div>
{:else}
<div
class="flex flex-col space-y-4 overflow-y-auto px-8 pb-8"
style="height: calc(100vh - 249px);"
>
{#if orderedSessions(latestDeltasByDateByFile).length == 0}
<div class="text-zinc-400">
Waiting for your first file changes. Go edit something and come back.
</div>
{/if}
{#each orderedSessions(latestDeltasByDateByFile) as [dateMilliseconds, fileSessions]} <h2 class="mb-4 px-8 text-lg font-bold text-zinc-300">Recently changed files</h2>
<div class="flex flex-col">
<div class="flex flex-row justify-between space-x-2"> <ul class="flex flex-col space-y-4 overflow-y-auto px-8 pb-8">
<div class="mb-1 text-zinc-300"> {#await filesActivityByDate.load()}
{new Date(parseInt(dateMilliseconds)).toLocaleDateString('en-us', { <li>
weekday: 'long', <IconRotateClockwise class="animate-spin" />
year: 'numeric', </li>
month: 'short', {:then}
day: 'numeric' {#each $filesActivityByDate as [activity, date]}
})} <li class="flex flex-col">
</div> <header class="flex flex-row justify-between gap-2">
<div> <div class="mb-1 text-zinc-300">
<a {date.toLocaleDateString('en-us', {
title="Reply changes (R)" weekday: 'long',
class="text-orange-200" year: 'numeric',
href={playerURL(dateMilliseconds)} month: 'short',
>Replay Changes day: 'numeric'
</a> })}
</div> </div>
</div> <a
<div title="Reply changes (R)"
class="results-card rounded border border-zinc-700 bg-[#2F2F33] p-4 shadow-lg" class="text-orange-200"
href="/projects/{$project.id}/player/{format(date, 'yyyy-MM-dd')}"
>
Replay Changes
</a>
</header>
<ul class="flex flex-col rounded border border-zinc-700 bg-[#2F2F33] p-4">
{#each Object.entries(activity) as [filepath, deltas]}
<li class="flex items-center justify-between gap-4">
<a
class="flex w-full overflow-auto"
href="/projects/{$project.id}/player/{format(
date,
'yyyy-MM-dd'
)}?file={encodeURIComponent(filepath)}"
> >
{#each Object.entries(fileSessions) as filetime} <span
<div class="flex flex-row justify-between"> class="w-full truncate"
<div class="w-96 truncate font-mono text-zinc-300"> use:collapsable={{ value: filepath, separator: '/' }}
<a class="cursor-default" href={playerURL(dateMilliseconds, filetime[0])}> />
<span use:collapsable={{ value: filetime[0], separator: '/' }} /> </a>
</a> <FileActivity {deltas} />
</div> </li>
<div class="font-mono text-zinc-400">
{@html timestampsToSpark(filetime[1])}
</div>
</div>
{/each}
</div>
</div>
{/each} {/each}
</div> </ul>
{/if} </li>
</div>
</div>
</div>
<div
class="secondary-column-container col-span-1 flex flex-col border-l border-l-zinc-700"
style="width: 37%;"
>
<div class="work-in-progress-container border-b border-zinc-700 py-4 px-4 ">
<h2 class="mb-2 text-lg font-bold text-zinc-300">Work in Progress</h2>
<div class="w-100 mb-4 flex items-center justify-between">
<Tooltip label={$head}>
<div
class="button group flex max-w-[200px] select-text justify-between rounded border border-zinc-600 bg-zinc-700 py-2 px-4 text-zinc-300 shadow"
>
<div class="h-4 w-4">
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
class="h-4 w-4 fill-zinc-400"
>
<path
d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"
/>
</svg>
</div>
<div class="truncate pl-2 font-mono text-zinc-300">
{$head}
</div>
<div class="carrot flex hidden items-center pl-3">
<svg width="7" height="5" viewBox="0 0 7 5" fill="none" class="fill-zinc-400">
<path
d="M3.87796 4.56356C3.67858 4.79379 3.32142 4.79379 3.12204 4.56356L0.319371 1.32733C0.0389327 1.00351 0.268959 0.5 0.697336 0.5L6.30267 0.500001C6.73104 0.500001 6.96107 1.00351 6.68063 1.32733L3.87796 4.56356Z"
fill="#A1A1AA"
/>
</svg>
</div>
</div>
</Tooltip>
<div>
<a
href="/projects/{$project?.id}/commit"
title="Commit changes"
class="btn-commit-changes button cursor-default select-none rounded bg-blue-600 py-2 px-3 text-white hover:bg-blue-700"
>Commit changes</a
>
</div>
</div>
{#if $statuses.length == 0}
<div
class="flex rounded border border-green-700 bg-green-900 p-4 align-middle text-green-400"
>
<div class="icon mr-2 h-5 w-5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
fill="#4ADE80"
fill-rule="evenodd"
d="M2 10a8 8 0 1 0 16 0 8 8 0 0 0-16 0Zm12.16-1.44a.8.8 0 0 0-1.12-1.12L9.2 11.28 7.36 9.44a.8.8 0 0 0-1.12 1.12l2.4 2.4c.32.32.8.32 1.12 0l4.4-4.4Z"
/>
</svg>
</div>
Everything is committed
</div>
{:else} {:else}
<ul class="rounded border border-yellow-400 bg-yellow-500 p-4 font-mono text-yellow-900"> <li class="text-zinc-400">
{#each $statuses as activity} Waiting for your first file changes. Go edit something and come back.
<li class="flex w-full gap-2"> </li>
<span
class:text-left={activity.staged}
class:text-right={!activity.staged}
class="w-[3ch] font-semibold">{activity.status.slice(0, 1).toUpperCase()}</span
>
<span class="truncate" use:collapsable={{ value: activity.path, separator: '/' }} />
</li>
{/each}
</ul>
{/if}
</div>
<div
class="recent-activity-container p-4"
style="height: calc(100vh - 110px); overflow-y: auto;"
>
<h2 class="text-lg font-bold text-zinc-300">Recent Activity</h2>
{#if $recentActivity.length == 0}
<div class="text-zinc-400">No activity yet.</div>
{/if}
{#each $recentActivity as activity}
<div
class="recent-activity-card mt-4 mb-1 rounded border border-zinc-700 text-zinc-400 shadow-lg"
>
<div class="flex select-text flex-col rounded bg-[#2F2F33] p-3">
<div class="flex flex-row justify-between pb-2 text-zinc-500">
<div class="">
{new Date(activity.timestampMs).toLocaleDateString('en-us', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
<div class="text-right font-mono ">{activity.type}</div>
</div>
<div class="rounded-b bg-[#2F2F33] text-zinc-100">{activity.message}</div>
</div>
</div>
{/each} {/each}
{/await}
</ul>
</div>
<div class="flex w-1/3 flex-col gap-4 border-l border-l-zinc-700 p-2">
<h2 class="text-lg font-bold text-zinc-300">Work in Progress</h2>
<div class="flex items-center justify-between gap-2">
<Tooltip label={$head}>
<div
class="flex items-center gap-2 rounded border border-zinc-600 bg-zinc-700 py-2 px-4 text-zinc-300"
>
<IconGitBranch class="h-4 w-4 fill-zinc-400 stroke-none" />
<span title={$head} class="truncate font-mono text-zinc-300">
{$head}
</span>
</div>
</Tooltip>
<a
href="/projects/{$project?.id}/commit"
title="Commit changes"
class="button whitespace-nowrap rounded bg-blue-600 p-2 text-white hover:bg-blue-700"
>
Commit changes
</a>
</div>
{#if $statuses.length === 0}
<div
class="flex rounded border border-green-700 bg-green-900 p-2 align-middle text-green-400"
>
<div class="icon mr-2 h-5 w-5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path
fill="#4ADE80"
fill-rule="evenodd"
d="M2 10a8 8 0 1 0 16 0 8 8 0 0 0-16 0Zm12.16-1.44a.8.8 0 0 0-1.12-1.12L9.2 11.28 7.36 9.44a.8.8 0 0 0-1.12 1.12l2.4 2.4c.32.32.8.32 1.12 0l4.4-4.4Z"
/>
</svg>
</div>
Everything is committed
</div> </div>
{:else}
<ul class="rounded border border-yellow-400 bg-yellow-500 p-2 font-mono text-yellow-900">
{#each $statuses as activity}
<li class="flex w-full gap-2">
<span
class:text-left={activity.staged}
class:text-right={!activity.staged}
class="w-[3ch] font-semibold">{activity.status.slice(0, 1).toUpperCase()}</span
>
<span class="truncate" use:collapsable={{ value: activity.path, separator: '/' }} />
</li>
{/each}
</ul>
{/if}
<div class="flex flex-auto flex-col gap-4 overflow-auto">
<h2 class="text-lg font-bold text-zinc-300">Recent Activity</h2>
<ul class="flex flex-auto flex-col gap-2 overflow-auto">
{#each $recentActivity as activity}
<li
class="flex flex-col gap-2 rounded rounded border border-zinc-700 bg-[#2F2F33] p-3 text-zinc-400"
>
<div class="flex flex-row justify-between text-zinc-500">
<span>
{new Date(activity.timestampMs).toLocaleDateString('en-us', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
<div class="text-right font-mono">{activity.type}</div>
</div>
<div class="rounded-b bg-[#2F2F33] text-zinc-100">{activity.message}</div>
</li>
{:else}
<li class="text-zinc-400">No activity yet.</li>
{/each}
</ul>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,39 @@
<script lang="ts">
import type { Delta } from '$lib/deltas';
export let deltas: Delta[];
const timestamps = deltas.map((delta) => delta.timestampMs).sort((a, b) => a - b);
const totalBuckets = 18;
const range = timestamps[timestamps.length - 1] - timestamps[0];
const bucketSize = range / totalBuckets;
const buckets: number[] = Array.from({ length: totalBuckets }, () => 0);
timestamps.forEach((timestamp) => {
const bucketIndex = Math.floor((timestamp - timestamps[0]) / bucketSize);
buckets[bucketIndex] += 1;
});
</script>
<div class="font-mono text-zinc-400">
{#each buckets as bucket}
{#if bucket < 1}
<span class="text-zinc-600"></span>
{:else if bucket < 2}
<span class="text-blue-200"></span>
{:else if bucket < 3}
<span class="text-blue-200"></span>
{:else if bucket < 4}
<span class="text-blue-200"></span>
{:else if bucket < 5}
<span class="text-blue-200"></span>
{:else if bucket < 6}
<span class="text-blue-200"></span>
{:else if bucket < 6}
<span class="text-blue-200"></span>
{:else}
<span class="text-blue-200"></span>
{/if}
{/each}
</div>