Add crate releases heatmap to user page
Diff
chartered-frontend/package-lock.json | 11 +++++++++++
chartered-frontend/package.json | 3 ++-
chartered-db/src/users.rs | 43 +++++++++++++++++++++++++++++++++++++++++++
chartered-frontend/src/app.pcss | 4 ++++
chartered-frontend/src/components/Heatmap.svelte | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/users/heatmap.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++
chartered-web/src/endpoints/web_api/users/mod.rs | 2 ++
chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
8 files changed, 212 insertions(+), 25 deletions(-)
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"lodash": "^4.17.21",
"svelte-heatmap": "^1.0.2",
"svelte-markdown": "^0.2.3"
},
"devDependencies": {
@@ -3129,6 +3130,11 @@
"svelte": "^3.24.0"
}
},
"node_modules/svelte-heatmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/svelte-heatmap/-/svelte-heatmap-1.0.2.tgz",
"integrity": "sha512-qVsJj/Y/FQCjYJ2A7QU/v87FjGFjWqrTIb+qPumrudzH5w6D5WoubHF7Q2g38naJry4ADO1I0K29UuC86t5lJw=="
},
"node_modules/svelte-hmr": {
"version": "0.14.12",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.14.12.tgz",
@@ -5593,6 +5599,11 @@
"svelte-preprocess": "^4.0.0",
"typescript": "*"
}
},
"svelte-heatmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/svelte-heatmap/-/svelte-heatmap-1.0.2.tgz",
"integrity": "sha512-qVsJj/Y/FQCjYJ2A7QU/v87FjGFjWqrTIb+qPumrudzH5w6D5WoubHF7Q2g38naJry4ADO1I0K29UuC86t5lJw=="
},
"svelte-hmr": {
"version": "0.14.12",
@@ -14,8 +14,8 @@
},
"devDependencies": {
"@playwright/test": "^1.25.0",
"@sveltejs/kit": "next",
"@sveltejs/adapter-static": "^1.0.0-next.42",
"@sveltejs/kit": "next",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.7",
"@types/feather-icons": "^4.7.0",
@@ -43,6 +43,7 @@
"type": "module",
"dependencies": {
"lodash": "^4.17.21",
"svelte-heatmap": "^1.0.2",
"svelte-markdown": "^0.2.3"
}
}
@@ -5,9 +5,13 @@
uuid::SqlUuid,
ConnectionPool, Error, Result,
};
use diesel::result::DatabaseErrorKind;
use chrono::{Duration, NaiveDateTime, Utc};
use diesel::{
insert_into, prelude::*, result::Error as DieselError, Associations, Identifiable, Queryable,
dsl::sql,
insert_into,
prelude::*,
result::{DatabaseErrorKind, Error as DieselError},
Associations, Identifiable, Queryable,
};
use rand::{thread_rng, Rng};
use std::sync::Arc;
@@ -313,6 +317,41 @@
.as_ref()
.or(self.name.as_ref())
.unwrap_or(&self.username)
}
pub async fn published_versions_by_date(
&self,
conn: ConnectionPool,
) -> Result<Vec<(NaiveDateTime, i64)>> {
let id = self.id;
tokio::task::spawn_blocking(move || {
use crate::schema::crate_versions::dsl::{created_at, user_id};
let conn = conn.get()?;
let date_statement = if cfg!(feature = "sqlite") {
"strftime('%Y-%m-%d 00:00:00', created_at)"
} else {
"DATE_TRUNC('day', created_at)"
};
let selected = crate::schema::crate_versions::table
.group_by(sql::<diesel::sql_types::Timestamp>(date_statement))
.select((
sql::<diesel::sql_types::Timestamp>(date_statement),
sql::<diesel::sql_types::BigInt>("count(*)"),
))
.filter(
user_id
.eq(id)
.and(created_at.gt((Utc::now() - Duration::days(365)).naive_utc())),
)
.load(&conn)?;
Ok(selected)
})
.await?
}
}
@@ -6,6 +6,10 @@
@apply bg-blue-100 dark:bg-slate-800 text-black dark:text-slate-400;
}
.heatmap svg {
@apply overflow-visible mt-2;
}
@layer components {
.card {
@apply block p-6 bg-white rounded-lg border border-gray-200 dark:border-gray-700 shadow-md dark:bg-transparent;
@@ -1,0 +1,61 @@
<script type="typescript">
// @ts-expect-error missing typedefs
import SvelteHeatmap from 'svelte-heatmap';
import { DateTime } from 'luxon';
import { onDestroy } from 'svelte';
export let data: Promise<{ [date: string]: number }>;
let heatmapData: { date: Date; value: number }[] = [];
$: data.then((v) => {
heatmapData = Object.entries(v).map(([k, v]) => ({ date: new Date(k), value: v }));
});
// we want to apply conditional styles to SvelteHeatmap which doesn't allow for css classes to be
// applied
let prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
let isDarkMode = prefersDarkMode.matches;
const onDarkModeChange = (v: MediaQueryListEvent) => (isDarkMode = v.matches);
prefersDarkMode.addEventListener('change', onDarkModeChange);
onDestroy(() => prefersDarkMode.removeEventListener('change', onDarkModeChange));
</script>
<div class="grid">
<div class="overlay heatmap">
<SvelteHeatmap
allowOverflow={false}
cellGap={5}
fontFamily="inherit"
fontColor={isDarkMode ? '#94a3b8' : 'black'}
cellRadius={'50%'}
data={[]}
emptyColor={isDarkMode ? '#475569' : '#e2e8f0'}
startDate={DateTime.now().minus({ year: 1 }).toJSDate()}
endDate={DateTime.now().toJSDate()}
/>
</div>
<div
class:!opacity-100={heatmapData.length !== 0}
class="overlay heatmap opacity-0 transition-all ease-out duration-100"
>
<SvelteHeatmap
allowOverflow={false}
cellGap={5}
fontFamily="inherit"
fontColor={isDarkMode ? '#94a3b8' : 'black'}
cellRadius={'50%'}
data={heatmapData}
emptyColor={isDarkMode ? '#475569' : '#e2e8f0'}
startDate={DateTime.now().minus({ year: 1 }).toJSDate()}
endDate={DateTime.now().toJSDate()}
/>
</div>
</div>
<style lang="postcss">
.overlay {
grid-area: 1 / 1;
}
</style>
@@ -1,0 +1,47 @@
use axum::{extract, Json};
use chartered_db::users::User;
use chartered_db::ConnectionPool;
use chrono::NaiveDate;
use std::collections::HashMap;
use thiserror::Error;
pub async fn handle(
extract::Path((_session_key, uuid)): extract::Path<(String, chartered_db::uuid::Uuid)>,
extract::Extension(db): extract::Extension<ConnectionPool>,
) -> Result<Json<Response>, Error> {
let user = User::find_by_uuid(db.clone(), uuid)
.await?
.ok_or(Error::NotFound)?;
let res = user
.published_versions_by_date(db)
.await?
.into_iter()
.map(|(k, v)| (k.date(), v))
.collect();
Ok(Json(res))
}
pub type Response = HashMap<NaiveDate, i64>;
#[derive(Error, Debug)]
pub enum Error {
#[error("{0}")]
Database(#[from] chartered_db::Error),
#[error("User doesn't exist")]
NotFound,
}
impl Error {
pub fn status_code(&self) -> axum::http::StatusCode {
use axum::http::StatusCode;
match self {
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::NotFound => StatusCode::NOT_FOUND,
}
}
}
define_error_response!(Error);
@@ -1,3 +1,4 @@
mod heatmap;
mod info;
mod search;
@@ -7,4 +8,5 @@
Router::new()
.route("/search", get(search::handle))
.route("/info/:uuid", get(info::handle))
.route("/info/:uuid/heatmap", get(heatmap::handle))
}
@@ -1,31 +1,41 @@
<script type="typescript">
import { page } from '$app/stores';
import { request } from '../../../../stores/auth';
import Spinner from '../../../../components/Spinner.svelte';
import Icon from '../../../../components/Icon.svelte';
import type { User } from '../../../../types/user';
import Heatmap from '../../../../components/Heatmap.svelte';
// grab the requested user from the backend and determine a `displayName` for them to show on their
// this is used for conditionally aligning the spinner to the center of the header for aesthetics
let isLoaded = false;
// grabs the requested user from the backend and determine a `displayName` for them to show on their
// profile.
let userPromise: Promise<User & { displayName?: string }>;
$: userPromise = request<User>(`/web/v1/users/info/${$page.params.uuid}`).then(
(user: User & { displayName?: string }) => {
user.displayName = user.nick || user.name || user.username;
return user;
},
);
$: {
isLoaded = false;
userPromise = request<User>(`/web/v1/users/info/${$page.params.uuid}`).then(
(user: User & { displayName?: string }) => {
isLoaded = true;
user.displayName = user.nick || user.name || user.username;
return user;
},
);
}
// grab data for the user's heatmap from the API
let heatmapPromise: Promise<{ [date: string]: number }>;
$: heatmapPromise = request(`/web/v1/users/info/${$page.params.uuid}/heatmap`);
</script>
{#await userPromise}
<header>
<div class="relative h-[20rem]">
<Spinner />
</div>
</header>
{:then user}
<header>
<div class="container flex flex-col md:flex-row items-center md:items-start mx-auto p-10 mb-3">
<div class="flex-grow text-center md:text-left">
<header>
<div class:md:items-start={isLoaded} class="container flex flex-col md:flex-row items-center mx-auto p-10 mb-3">
<div class="flex-grow text-center md:text-left">
{#await userPromise}
<div class="relative justify-center w-12 h-12">
<Spinner />
</div>
{:then user}
<h1 class="text-5xl text-highlight font-bold tracking-tight">
{user.displayName}
</h1>
@@ -55,9 +65,15 @@
<span>Email</span>
</a>
{/if}
</div>
{/await}
</div>
<div class="order-first md:order-last">
<div class="order-first md:order-last">
{#await userPromise}
<div class="h-[8rem] w-[8rem] rounded-[50%] text-gray-300 bg-gray-100 dark:bg-gray-800 overflow-hidden">
<Icon height="8rem" width="8rem" name="user" />
</div>
{:then user}
{#if user.picture_url}
<img alt={user.displayName} src={user.picture_url} class="rounded-[50%] h-[8rem] inline" />
{:else}
@@ -67,7 +83,13 @@
<Icon height="8rem" width="8rem" name="user" />
</div>
{/if}
</div>
{/await}
</div>
</header>
{/await}
</div>
</header>
<main class="container h-fit mx-auto p-10 pt-0">
<div class="card">
<Heatmap data={heatmapPromise} />
</div>
</main>