🏡 index : ~doyle/chartered.git

author Jordan Doyle <jordan@doyle.la> 2022-09-10 2:11:48.0 +01:00:00
committer Jordan Doyle <jordan@doyle.la> 2022-09-10 2:11:48.0 +01:00:00
commit
e7f4cf0c0f1647c3c8e3e9f469ba8cd1324c26bc [patch]
tree
dd236c6c453940146828dc2fad9f58f0736183db
parent
9ebf55ebc7d3a264b5b5ab7f2c820cbac5ac5ff6
download
e7f4cf0c0f1647c3c8e3e9f469ba8cd1324c26bc.tar.gz

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(-)

diff --git a/chartered-frontend/package-lock.json b/chartered-frontend/package-lock.json
index 3effc35..70cc92b 100644
--- a/chartered-frontend/package-lock.json
+++ a/chartered-frontend/package-lock.json
@@ -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",
diff --git a/chartered-frontend/package.json b/chartered-frontend/package.json
index b178879..79fec28 100644
--- a/chartered-frontend/package.json
+++ a/chartered-frontend/package.json
@@ -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"
    }

}

diff --git a/chartered-db/src/users.rs b/chartered-db/src/users.rs
index 994e3ed..9ec1969 100644
--- a/chartered-db/src/users.rs
+++ a/chartered-db/src/users.rs
@@ -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?
    }
}

diff --git a/chartered-frontend/src/app.pcss b/chartered-frontend/src/app.pcss
index 7e4634c..590601d 100644
--- a/chartered-frontend/src/app.pcss
+++ a/chartered-frontend/src/app.pcss
@@ -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;
diff --git a/chartered-frontend/src/components/Heatmap.svelte b/chartered-frontend/src/components/Heatmap.svelte
new file mode 100644
index 0000000..0b6a45c 100644
--- /dev/null
+++ a/chartered-frontend/src/components/Heatmap.svelte
@@ -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>

    <!-- hack to fade in colours over the empty heatmap -->
    <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>
diff --git a/chartered-web/src/endpoints/web_api/users/heatmap.rs b/chartered-web/src/endpoints/web_api/users/heatmap.rs
new file mode 100644
index 0000000..c97ac4a 100644
--- /dev/null
+++ a/chartered-web/src/endpoints/web_api/users/heatmap.rs
@@ -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);
diff --git a/chartered-web/src/endpoints/web_api/users/mod.rs b/chartered-web/src/endpoints/web_api/users/mod.rs
index 271d873..eca9e4b 100644
--- a/chartered-web/src/endpoints/web_api/users/mod.rs
+++ a/chartered-web/src/endpoints/web_api/users/mod.rs
@@ -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))
}
diff --git a/chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte b/chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte
index d0e39ea..6f374c2 100644
--- a/chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte
+++ a/chartered-frontend/src/routes/(authed)/users/[uuid]/+page.svelte
@@ -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>