Refactor: Better PostMetric API and Rendering

This commit is contained in:
Donatas Kirda 2024-05-15 18:07:40 +03:00
parent 63ab10318f
commit 499fd6fa5b
Signed by: bloodwiing
GPG Key ID: 63020D8D3F4A164F
13 changed files with 185 additions and 154 deletions

View File

@ -1,5 +1,6 @@
<script> <script>
import Rating from "./rating.svelte"; import Iconvalue from "./iconvalue.svelte";
import Rating from "./rating.svelte";
/** /**
* @type {import("$types/base").Post | import("$types/base").Comment} * @type {import("$types/base").Post | import("$types/base").Comment}
@ -14,9 +15,11 @@
width: 100%; width: 100%;
flex: 1; flex: 1;
position: relative; position: relative;
gap: 24px;
} }
</style> </style>
<div class="actionBar"> <div class="actionBar">
<Rating rating={message.rating}></Rating> <Rating rating={message.rating} style="gap: 24px;"></Rating>
<slot />
</div> </div>

32
src/comp/iconvalue.svelte Normal file
View File

@ -0,0 +1,32 @@
<script>
import Tablericon from "./tablericon.svelte";
/**
* @type {string | undefined}
*/
export let name = undefined;
/**
* @type {string}
*/
export let iconName;
/**
* @type {number}
*/
export let iconSize = 16;
</script>
<style type="scss">
.pair {
display: flex;
flex-flow: row nowrap;
gap: 8px;
align-items: center;
}
</style>
<div class="pair" title={name}>
<Tablericon name={iconName} size={iconSize} color="white"></Tablericon>
<span><slot /></span>
</div>

View File

@ -3,7 +3,11 @@
import Ago from "$comp/ago.svelte"; import Ago from "$comp/ago.svelte";
import Avatar from "$comp/avatar.svelte"; import Avatar from "$comp/avatar.svelte";
import CommentList from "$comp/commentlist.svelte"; import CommentList from "$comp/commentlist.svelte";
import Iconvalue from "$comp/iconvalue.svelte";
import Mention from "$comp/mention.svelte"; import Mention from "$comp/mention.svelte";
import Tablericon from "$comp/tablericon.svelte";
import { round10 } from "expected-round";
import moment from "moment";
/** /**
* @type {import("$types/base").Post} * @type {import("$types/base").Post}
@ -15,7 +19,17 @@
*/ */
export let commentTree = null; export let commentTree = null;
$: isPreview = commentTree == null; /**
* @type {boolean}
*/
export let showMetrics = false;
let showStats = false;
function togglePostStats(/** @type {MouseEvent | TouchEvent} */ e) {
e.stopPropagation();
showStats = !showStats;
}
</script> </script>
<style lang="scss"> <style lang="scss">
@ -69,6 +83,40 @@
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
} }
.title {
text-align: left;
padding: 0.3em 0;
line-height: 1.4;
}
.metricsSpacer {
flex: 1;
}
.stats {
color: var(--white-dim);
& > table {
text-align: left;
font-size: 0.8em;
& td {
padding: 0;
&:first-child {
padding-right: 1em;
}
}
}
}
.statsTitle {
color: var(--white-dim);
font-size: 0.8em;
text-align: left;
padding-left: 0.1em;
}
</style> </style>
<div class="body"> <div class="body">
@ -80,7 +128,7 @@
<Mention user={post.author}></Mention> <Mention user={post.author}></Mention>
<Ago date={post.postDate}></Ago> <Ago date={post.postDate}></Ago>
</div> </div>
<h1 class="typeTitle">{post.name}</h1> <h1 class="typeTitle title">{post.name}</h1>
</div> </div>
</div> </div>
@ -88,7 +136,37 @@
{post.content} {post.content}
</p> </p>
<Actionbar message={post}></Actionbar> <Actionbar message={post}>
{#if showMetrics && post.metrics}
<Iconvalue name="Comment Count" iconName="message">{post.metrics.commentCount}</Iconvalue>
<Iconvalue name="User Count" iconName="user">{post.metrics.userCount}</Iconvalue>
<Iconvalue name="Relevancy" iconName="chart-bar">{round10(post.metrics.relevancy, -2)}</Iconvalue>
{/if}
<div class="metricsSpacer"></div>
<button class="button transparent" on:click={togglePostStats}>
<Iconvalue name="More detailed metrics" iconName="database">stats</Iconvalue>
</button>
</Actionbar>
{#if showStats}
<div class="stats">
<h6 class="statsTitle">Debug stats</h6>
<table>
<tr><td>ID</td><td>{post.id}</td></tr>
<tr><td>Author</td><td>{post.author?.id}</td></tr>
<tr><td>Created Date</td><td>{post.postDate}</td></tr>
<tr><td>Score</td><td>{post.rating.likes - post.rating.dislikes}</td></tr>
{#if post.metrics}
<tr><td>Comment Count</td><td>{post.metrics.commentCount}</td></tr>
<tr><td>User Count</td><td>{post.metrics.userCount}</td></tr>
<tr><td>Latest Activity</td><td>{post.metrics.latestActivity}</td></tr>
<tr><td>Engagement</td><td>{post.metrics.engagement}</td></tr>
<tr><td>Age</td><td>{post.metrics.age}</td></tr>
<tr><td>Relevancy</td><td>{round10(post.metrics.relevancy, -5)}</td></tr>
{/if}
</table>
</div>
{/if}
</article> </article>
{#if commentTree} {#if commentTree}

View File

@ -9,8 +9,10 @@
*/ */
export let posts = []; export let posts = [];
/** @type {import("$types/base").Post | null} */ /**
$: currentPreview = null; * @type {import('$types/base').Post | null}
*/
export let glancePost = null;
/** @type {boolean[]} */ /** @type {boolean[]} */
$: hidden = Array(posts.length).fill(false); $: hidden = Array(posts.length).fill(false);
@ -19,33 +21,33 @@
/** @type {import("$types/base").Post} */ /** @type {import("$types/base").Post} */
const post = e.detail.post; const post = e.detail.post;
currentPreview = post;
posts.forEach((p, index) => { posts.forEach((p, index) => {
hidden[index] = p == post; hidden[index] = p == post;
}); });
goto(`/posts?glance=${currentPreview.id}`, { goto(`/posts?glance=${post.id}`, {
replaceState: true, replaceState: true,
noScroll: true noScroll: true
}); });
} }
function dismiss() { function dismiss(/** @type {MouseEvent | TouchEvent} */ e) {
currentPreview = null; e.stopPropagation();
hidden = Array(posts.length).fill(false); hidden = Array(posts.length).fill(false);
// goto(`/posts`, { goto(`/posts`, {
// replaceState: true, replaceState: true,
// noScroll: true noScroll: true
// }); });
} }
function expand() { function expand(/** @type {MouseEvent | TouchEvent} */ e) {
if (currentPreview == null) return; if (glancePost == null) return;
e.stopPropagation();
goto(`/posts/${getNamedId(currentPreview.id, currentPreview.name)}`, { goto(`/posts/${getNamedId(glancePost.id, glancePost.name)}`, {
replaceState: false replaceState: false
}); });
} }
@ -109,10 +111,10 @@
{/each} {/each}
</div> </div>
<button class="postPreview" data-visible={currentPreview != null || null} on:click={dismiss}> <button class="postPreview" data-visible={glancePost != null || null} on:click={dismiss}>
<button class="preview" on:click={expand}> <button class="preview" on:click={expand}>
{#if currentPreview} {#if glancePost}
<Post post={currentPreview} commentTree={null}></Post> <Post post={glancePost} commentTree={null} showMetrics={true}></Post>
{/if} {/if}
</button> </button>
</button> </button>

View File

@ -1,4 +1,6 @@
<script> <script>
import Iconvalue from "./iconvalue.svelte";
/** /**
* @type {import("$types/base").Rating} * @type {import("$types/base").Rating}
*/ */
@ -10,28 +12,13 @@
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
gap: 10px; gap: 24px;
align-items: center; align-items: center;
} }
svg.icon {
height: 16px;
width: 16px;
& > use {
color: white;
}
}
</style> </style>
<div class="rating"> <div class="rating" {...$$restProps}>
<svg class="icon" viewBox="0 0 24 24"> <Iconvalue name="Likes" iconName="heart">{rating.likes}</Iconvalue>
<use href="/icon/heart.svg#icon"></use> <Iconvalue name="Dislikes" iconName="thumb-down">{rating.dislikes}</Iconvalue>
</svg>
<span>{rating.likes}</span>
<svg class="icon" viewBox="0 0 24 24">
<use href="/icon/thumb-down.svg#icon"></use>
</svg>
<span>{rating.dislikes}</span>
</div> </div>

View File

@ -46,15 +46,24 @@ function parsePostFromRow(author, category, row, withMetrics = false) {
/** /**
* @param {import('postgres').Sql} sql * @param {import('postgres').Sql} sql
* @param {import('$types/base').Category | undefined} category * @param {{
* @param {number} limit * category?: import('$types/base').Category | undefined,
* @param {number} offset * limit?: number,
* @param {boolean} withMetrics * offset?: number,
* withMetrics?: boolean
* }} opts
* @returns {Promise<Post[]>} * @returns {Promise<Post[]>}
*/ */
export async function getPosts(sql, category = undefined, limit = 10, offset = 0, withMetrics = false) { export async function getPosts(sql, opts = {}) {
const filter = category === undefined ? sql`` : sql`WHERE category_id = ${ category.id }`; const {
const metrics = !withMetrics ? sql`` : sql`, comment_count, user_count, latest_activity, engagement, age, relevancy`; category = undefined,
limit = 10,
offset = 0,
withMetrics = false,
} = opts;
const filter = category ? sql`WHERE category_id = ${ category.id }` : sql``;
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
const query = sql` const query = sql`
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics } SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics }
@ -83,11 +92,20 @@ export async function getPosts(sql, category = undefined, limit = 10, offset = 0
* *
* @param {import('postgres').Sql} sql * @param {import('postgres').Sql} sql
* @param {number} post_id * @param {number} post_id
* @param {{
* withMetrics?: boolean
* }} opts
* @returns {Promise<Post | import('$types/error').Error>} * @returns {Promise<Post | import('$types/error').Error>}
*/ */
export async function getPost(sql, post_id) { export async function getPost(sql, post_id, opts = {}) {
const {
withMetrics = false
} = opts;
const metrics = withMetrics ? sql`, comment_count, user_count, latest_activity, engagement, age, relevancy` : sql``;
const query = sql` const query = sql`
SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes SELECT id, author_id, name, category_id, latest_content, created_date, likes, dislikes ${ metrics }
FROM doki8902.message_post FROM doki8902.message_post
WHERE id = ${ post_id };`; WHERE id = ${ post_id };`;
@ -130,5 +148,5 @@ export async function getPost(sql, post_id) {
/** /**
* @type {Post} * @type {Post}
*/ */
return parsePostFromRow(author, category, post); return parsePostFromRow(author, category, post, withMetrics);
} }

View File

@ -1,89 +0,0 @@
// /**
// * @typedef {import('$types/base').PostMetrics} PostMetrics
// */
// import { sql } from '$lib/db.server';
// /**
// * @param {import('$types/base').Post} post
// * @param {import('postgres').Row} row
// * @returns {PostMetrics}
// */
// function parsePostMetricsFromRowAndPost(post, row) {
// return {
// postId: post.id,
// commentCount: BigInt(row['comment_count']),
// userCount: BigInt(row['user_count']),
// latestActivity: row['latest_activity'],
// rating: post.rating,
// score: BigInt(row['score']),
// engagement: BigInt(row['engagement']),
// age: row['age'],
// relevancy: row['relevancy']
// };
// }
// /**
// * @param {import('postgres').Row} row
// * @returns {PostMetrics}
// */
// function parsePostMetricsFromRow(row) {
// return {
// postId: row['post_id'],
// commentCount: BigInt(row['comment_count']),
// userCount: BigInt(row['user_count']),
// latestActivity: row['latest_activity'],
// rating: {
// likes: row['likes'],
// dislikes: row['dislikes']
// },
// score: BigInt(row['score']),
// engagement: BigInt(row['engagement']),
// age: row['age'],
// relevancy: row['relevancy']
// };
// }
// /**
// * @param {import('$types/base').Post} post
// * @returns {Promise<PostMetrics | import('$types/error').Error>}
// */
// export async function getPostMetricsForPost(post) {
// const query = sql`
// SELECT comment_count, user_count, latest_activity, score, engagement, age, relevancy
// FROM doki8902.post_metrics
// WHERE post_id = ${ post.id }`;
// const result = await query;
// if (result.length == 0) {
// return {
// error: true,
// msg: `Could not find PostMetrics for Post ID ${post.id}`
// };
// }
// return parsePostMetricsFromRowAndPost(post, result[0]);
// }
// /**
// * @param {number} post_id
// * @returns {Promise<PostMetrics | import('$types/error').Error>}
// */
// export async function getPostMetricsForPostId(post_id) {
// const query = sql`
// SELECT post_id, comment_count, user_count, latest_activity, likes, dislikes, score, engagement, age, relevancy
// FROM doki8902.post_metrics
// WHERE post_id = ${ post_id }`;
// const result = await query;
// if (result.length == 0) {
// return {
// error: true,
// msg: `Could not find PostMetrics for Post ID ${post_id}`
// };
// }
// return parsePostMetricsFromRow(result[0]);
// }

View File

@ -1,22 +1,17 @@
import { getPosts } from '$lib/server/db/post'; import { getPost, getPosts } from '$lib/server/db/post';
import { getPostMetricsForPostId } from '$lib/server/db/postmertics';
/** @type {import('./$types').PageServerLoad} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals, url }) { export async function load({ locals, url }) {
const result = await getPosts(locals.sql, undefined, 10, 0, true); const result = await getPosts(locals.sql);
// const glance = url.searchParams.get('glance'); const glance = url.searchParams.get('glance');
// const glanceID = glance ? parseInt(glance) : null const glanceID = glance ? parseInt(glance) : null
// let postMetrics = null; const glancePost = glanceID ? await getPost(locals.sql, glanceID, { withMetrics: true }) : null;
// if (glanceID) {
// postMetrics = await getPostMetricsForPostId(glanceID);
// }
console.log(result);
return { return {
posts: result posts: result,
glancePost: glancePost
}; };
} }

View File

@ -3,11 +3,12 @@
/** /**
* @type {{ * @type {{
* posts: import("$types/base").Post[] * posts: import("$types/base").Post[],
* glancePost: import("$types/base").Post | null
* }} * }}
*/ */
export let data; export let data;
</script> </script>
<h1>Posts</h1> <h1>Posts</h1>
<Postlist posts={data.posts}></Postlist> <Postlist posts={data.posts} glancePost={data.glancePost}></Postlist>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chart-bar"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M9 8m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M15 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M4 20l14 0" /></svg>

After

Width:  |  Height:  |  Size: 621 B

1
static/icon/database.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-database"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 6m-8 0a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" /><path d="M4 6v6a8 3 0 0 0 16 0v-6" /><path d="M4 12v6a8 3 0 0 0 16 0v-6" /></svg>

After

Width:  |  Height:  |  Size: 455 B

1
static/icon/message.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-message"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" /></svg>

After

Width:  |  Height:  |  Size: 465 B

1
static/icon/user.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /></svg>

After

Width:  |  Height:  |  Size: 422 B