chore: various updates
This commit is contained in:
118
site/src/app.css
118
site/src/app.css
@@ -3,87 +3,87 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrainsMono';
|
||||
src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf');
|
||||
font-family: 'JetBrainsMono';
|
||||
src: url('/fonts/JetBrainsMono-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.grad-back {
|
||||
@apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700;
|
||||
background-size: 100vw 400vh;
|
||||
animation: grad-back 100s linear infinite;
|
||||
}
|
||||
.grad-back {
|
||||
@apply bg-gradient-to-t from-violet-700 via-blue-400 to-violet-700;
|
||||
background-size: 100vw 400vh;
|
||||
animation: grad-back 100s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes grad-back {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
@keyframes grad-back {
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
|
||||
to {
|
||||
background-position: 0 400vh;
|
||||
}
|
||||
}
|
||||
to {
|
||||
background-position: 0 400vh;
|
||||
}
|
||||
}
|
||||
|
||||
.font-JetBrainsMono {
|
||||
font-family: 'JetBrainsMono';
|
||||
}
|
||||
.font-JetBrainsMono {
|
||||
font-family: 'JetBrainsMono';
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-purple-500 font-bold font-JetBrainsMono text-xl;
|
||||
}
|
||||
h1 {
|
||||
@apply text-purple-500 font-bold font-JetBrainsMono text-xl;
|
||||
}
|
||||
|
||||
dialog {
|
||||
@apply p-3 rounded-md;
|
||||
}
|
||||
dialog {
|
||||
@apply p-3 rounded-md;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
@apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0;
|
||||
}
|
||||
dialog::backdrop {
|
||||
@apply bg-secudanry opacity-75 fixed top-0 right-0 bottom-0 left-0;
|
||||
}
|
||||
|
||||
.flabel {
|
||||
@apply block text-purple-500;
|
||||
}
|
||||
.flabel {
|
||||
@apply block text-purple-500;
|
||||
}
|
||||
|
||||
.finput {
|
||||
@apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1;
|
||||
}
|
||||
.finput {
|
||||
@apply rounded-lg w-full p-2 drop-shadow-lg border-gray-300 border mb-1 bg-white;
|
||||
}
|
||||
|
||||
.finput[type='color'] {
|
||||
@apply p-0;
|
||||
}
|
||||
.finput[type='color'] {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
.btns {
|
||||
@apply flex justify-center py-2 gap-2;
|
||||
}
|
||||
.btns {
|
||||
@apply flex justify-center py-2 gap-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply rounded-lg text-white bg-violet-500 p-2;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply rounded-lg text-white bg-violet-500 p-2;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply rounded-lg bg-danger p-2 text-white;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply rounded-lg bg-danger p-2 text-white;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
@apply rounded-lg bg-blue-400 text-white p-2;
|
||||
}
|
||||
.btn-confirm {
|
||||
@apply rounded-lg bg-blue-400 text-white p-2;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg drop-shadow-lg;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg drop-shadow-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@print {
|
||||
@page :footer {
|
||||
display: none;
|
||||
}
|
||||
@page :footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page :header {
|
||||
display: none;
|
||||
}
|
||||
@page :header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 0;
|
||||
size: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Application = {
|
||||
linked_application: string;
|
||||
create_time: string;
|
||||
status_history: string;
|
||||
job_level: string;
|
||||
flairs: Flair[];
|
||||
events: ApplicationEvent[];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { applicationStore } from '$lib/ApplicationsStore.svelte';
|
||||
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
|
||||
import { get } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let filter = $state('');
|
||||
@@ -28,15 +29,57 @@
|
||||
return x.match(f);
|
||||
})
|
||||
);
|
||||
|
||||
let gettingNext = $state(false);
|
||||
|
||||
async function getNext() {
|
||||
gettingNext = true;
|
||||
try {
|
||||
const r: Application[] = await get('mail/getNext');
|
||||
for (const app of r) {
|
||||
applicationStore.all.push(app);
|
||||
}
|
||||
if (r.length > 0) {
|
||||
applicationStore.loadItem = r[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('TODO inform user', e);
|
||||
} finally {
|
||||
gettingNext = false;
|
||||
}
|
||||
}
|
||||
|
||||
function docKey(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.code === 'KeyJ' && internal.length > 0) {
|
||||
applicationStore.loadItem = internal[0];
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.addEventListener('keydown', docKey, false);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', docKey);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0">
|
||||
<h1>To Apply</h1>
|
||||
<div class="flex pb-2">
|
||||
<div class="flex pb-2 items-center">
|
||||
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
|
||||
<div>
|
||||
{internal.length}
|
||||
</div>
|
||||
{#if !gettingNext}
|
||||
<button class="p-2 text-violet-500" onclick={() => getNext()}>
|
||||
<span class="bi bi-send-arrow-down"></span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="bi bi-arrow-repeat animate-spin"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-auto flex-grow p-2">
|
||||
{#each internal as item}
|
||||
@@ -51,7 +94,10 @@
|
||||
}}
|
||||
role="none"
|
||||
>
|
||||
<div class="max-w-full" class:animate-pulse={applicationStore.dragging?.id === item.id}>
|
||||
<div
|
||||
class="max-w-full"
|
||||
class:animate-pulse={applicationStore.dragging?.id === item.id}
|
||||
>
|
||||
<h2 class="text-lg text-blue-500 flex gap-2 max-w-full overflow-hidden">
|
||||
<div class="flex-grow max-w-[90%]">
|
||||
<div class="whitespace-nowrap overflow-hidden">
|
||||
|
||||
555
site/src/routes/graphs/+page.svelte
Normal file
555
site/src/routes/graphs/+page.svelte
Normal file
@@ -0,0 +1,555 @@
|
||||
<script lang="ts">
|
||||
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
|
||||
import HasUser from '$lib/HasUser.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import NavBar from '../NavBar.svelte';
|
||||
import Pie from './Pie.svelte';
|
||||
import { statusStore } from '$lib/Types.svelte';
|
||||
import * as d3 from 'd3';
|
||||
import { goto } from '$app/navigation';
|
||||
import { get } from '$lib/utils';
|
||||
import { flairStore } from '$lib/FlairStore.svelte';
|
||||
|
||||
onMount(() => {
|
||||
applicationStore.loadAll();
|
||||
statusStore.load();
|
||||
});
|
||||
|
||||
let sort: 'asc' | 'desc' = $state('desc');
|
||||
|
||||
const payranges = $derived.by(() => {
|
||||
const obj = applicationStore.all
|
||||
.filter((a) => a.payrange.match(/\d/))
|
||||
.map((a) => {
|
||||
const payrange = a.payrange
|
||||
.replace(/[kK]/g, '000')
|
||||
// The first is a - the other is unicode 8211
|
||||
.replace(/\.\d+/g, '')
|
||||
.replace(/[^\d\-–]/g, '')
|
||||
.replace(/–/g, '-')
|
||||
.split('-')
|
||||
.map((a) => Number(a));
|
||||
|
||||
if (Number.isNaN(payrange[0])) {
|
||||
payrange[0] = 0;
|
||||
}
|
||||
if (Number.isNaN(payrange[1])) {
|
||||
payrange[1] = 0;
|
||||
}
|
||||
|
||||
return { ...a, payrange };
|
||||
})
|
||||
.reduce(
|
||||
(acc, a) => {
|
||||
if (!acc[a.company]) {
|
||||
acc[a.company] = [a];
|
||||
} else {
|
||||
acc[a.company].push(a);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, (Omit<Application, 'payrange'> & { payrange: number[] })[]>
|
||||
);
|
||||
|
||||
return Object.keys(obj)
|
||||
.reduce(
|
||||
(acc, a) => {
|
||||
acc.push([a, obj[a]]);
|
||||
return acc;
|
||||
},
|
||||
[] as [string, (Omit<Application, 'payrange'> & { payrange: number[] })[]][]
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
const rangesA = a[1].reduce(max_and_min_reducer, [
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY
|
||||
]);
|
||||
const rangesB = b[1].reduce(max_and_min_reducer, [
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY
|
||||
]);
|
||||
|
||||
const va = (rangesA[1] + rangesA[0]) / 2;
|
||||
const vb = (rangesB[1] + rangesB[0]) / 2;
|
||||
|
||||
if (sort === 'asc') {
|
||||
return va - vb;
|
||||
} else if (sort === 'desc') {
|
||||
return vb - va;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
let payRangeDiv: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
function max_and_min_reducer(
|
||||
acc: [number, number],
|
||||
a: Omit<Application, 'payrange'> & { payrange: number[] }
|
||||
): [number, number] {
|
||||
/*if (a.payrange[0] > 1000000 || a.payrange[1] > 1000000) {
|
||||
console.log(a);
|
||||
}*/
|
||||
return [
|
||||
Math.min(acc[0], a.payrange[0]),
|
||||
Math.max(acc[1], a.payrange[1] ?? 0, a.payrange[0])
|
||||
];
|
||||
}
|
||||
|
||||
const scale = $derived.by(() => {
|
||||
if (!payRangeDiv) return;
|
||||
|
||||
const max_and_min = Object.values(payranges).reduce(
|
||||
(acc, a) => {
|
||||
return a[1].reduce(
|
||||
(acc2, e) => max_and_min_reducer(acc2, e),
|
||||
acc as [number, number]
|
||||
);
|
||||
},
|
||||
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] as [number, number]
|
||||
);
|
||||
|
||||
const box = payRangeDiv.getBoundingClientRect();
|
||||
|
||||
const scale = d3
|
||||
.scaleLinear()
|
||||
.domain([max_and_min[0], max_and_min[1]])
|
||||
.range([0, box.width - 40]);
|
||||
|
||||
return scale;
|
||||
});
|
||||
|
||||
const context = (() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
context.font = '12px Open sans';
|
||||
return context;
|
||||
})();
|
||||
|
||||
let open = $state<string | undefined>(undefined);
|
||||
let searchPayranges = $state('');
|
||||
let expandPayRanges = $state(false);
|
||||
let searchPayRangeMode: 'limit' | 'goto' = $state('goto');
|
||||
let indexPayRanges: HTMLDivElement[] = $state([]);
|
||||
let gotoIndex: number | undefined = $state(undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (searchPayRangeMode !== 'goto' || !searchPayranges) {
|
||||
gotoIndex = undefined;
|
||||
return;
|
||||
}
|
||||
let i = 0;
|
||||
for (let [company] of payranges) {
|
||||
if (company.match(new RegExp(searchPayranges, 'i'))) {
|
||||
indexPayRanges[i].scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
block: 'center'
|
||||
});
|
||||
gotoIndex = i;
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
gotoIndex = undefined;
|
||||
});
|
||||
|
||||
let flairStats: 'loading' | Record<string, number> = $state('loading');
|
||||
|
||||
onMount(async () => {
|
||||
const items: any[] = await get('flair/stats');
|
||||
flairStats = items.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.name] = a.count;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<HasUser redirect="/cv">
|
||||
<div class="flex flex-col h-[100vh]">
|
||||
<NavBar />
|
||||
<div class="p-1 px-5">
|
||||
<div class="bg-white p-3 rounded-lg gap-5 flex flex-col">
|
||||
<div class="flex gap-5 flex-wrap">
|
||||
<Pie
|
||||
title={'Origin'}
|
||||
data={applicationStore.all.reduce(
|
||||
(acc, item) => {
|
||||
if (item.url.includes('linkedin')) {
|
||||
acc.linkedin += 1;
|
||||
} else if (item.url.includes('glassdoor')) {
|
||||
acc.glassdoor += 1;
|
||||
} else {
|
||||
acc.other += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
linkedin: 0,
|
||||
glassdoor: 0,
|
||||
other: 0
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<Pie
|
||||
title={'Status'}
|
||||
data={[...statusStore.nodes, 'Created' as const].reduce(
|
||||
(acc, item) => {
|
||||
if (item === 'Created') {
|
||||
acc[item] = applicationStore.all.filter(
|
||||
(a) => a.status_id === null
|
||||
).length;
|
||||
return acc;
|
||||
}
|
||||
acc[item.name] = applicationStore.all.filter(
|
||||
(a) => item.id === a.status_id
|
||||
).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)}
|
||||
/>
|
||||
<Pie
|
||||
title={'Higher range Pay Range'}
|
||||
data={applicationStore.all
|
||||
.filter((a) => a.payrange.match(/\d/))
|
||||
.map((a) => {
|
||||
const payrange = a.payrange
|
||||
.replace(/[kK]/g, '000')
|
||||
.replace(/[^\d\-–]/g, '')
|
||||
.replace(/–/g, '-')
|
||||
.split('-');
|
||||
return Number(payrange[payrange.length - 1]);
|
||||
})
|
||||
.reduce(
|
||||
(acc, a) => {
|
||||
const f = Math.floor(a / 10000);
|
||||
let name = `${f * 10}K-${(f + 1) * 10}K`;
|
||||
if (f == 0) {
|
||||
name = '<10K';
|
||||
}
|
||||
if (acc[name]) {
|
||||
acc[name] += 1;
|
||||
} else {
|
||||
acc[name] = 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)}
|
||||
/>
|
||||
<Pie
|
||||
title={'Lower range Pay Range'}
|
||||
data={applicationStore.all
|
||||
.filter((a) => a.payrange.match(/\d/))
|
||||
.map((a) => {
|
||||
const payrange = a.payrange
|
||||
.replace(/[kK]/g, '000')
|
||||
// The first is a - the other is unicode 8211
|
||||
.replace(/[^\d\-–]/g, '')
|
||||
.replace(/–/g, '-')
|
||||
.split('-');
|
||||
return Number(payrange[0]);
|
||||
})
|
||||
.reduce(
|
||||
(acc, a) => {
|
||||
const f = Math.floor(a / 10000);
|
||||
let name = `${f * 10}K-${(f + 1) * 10}K`;
|
||||
if (f == 0) {
|
||||
name = '<10K';
|
||||
}
|
||||
if (acc[name]) {
|
||||
acc[name] += 1;
|
||||
} else {
|
||||
acc[name] = 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{#if flairStats !== 'loading'}
|
||||
<div class="flex gap-5">
|
||||
<Pie title={'Flair stats'} data={flairStats} sensitivity={0.02} />
|
||||
<div class="max-h-[500px] overflow-auto">
|
||||
<ul>
|
||||
{#each Object.keys(flairStats).toSorted((a, b) => flairStats[b] - flairStats[a]) as flair}
|
||||
<li>{flair}: {flairStats[flair]}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<Pie
|
||||
title={'Job Level'}
|
||||
data={applicationStore.all.reduce(
|
||||
(acc, a) => {
|
||||
const job_level = a.job_level ? a.job_level : 'Unknown';
|
||||
if (acc[job_level]) {
|
||||
acc[job_level] += 1;
|
||||
} else {
|
||||
acc[job_level] = 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)}
|
||||
sensitivity={0.02}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="text-black">
|
||||
Pay range
|
||||
<button
|
||||
title="Expand/Contract"
|
||||
onclick={() => {
|
||||
expandPayRanges = !expandPayRanges;
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class={!expandPayRanges
|
||||
? 'bi bi-arrows-angle-expand'
|
||||
: 'bi bi-arrows-angle-contract'}
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
title="Sorting"
|
||||
onclick={() => {
|
||||
if (sort === 'asc') {
|
||||
sort = 'desc';
|
||||
} else if (sort === 'desc') {
|
||||
sort = 'asc';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class={sort === 'asc' ? 'bi bi-arrow-down' : 'bi bi-arrow-up'}></span>
|
||||
</button>
|
||||
<button
|
||||
title="Filter mode"
|
||||
onclick={() => {
|
||||
if (searchPayRangeMode === 'limit') {
|
||||
searchPayRangeMode = 'goto';
|
||||
} else if (searchPayRangeMode === 'goto') {
|
||||
searchPayRangeMode = 'limit';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class={searchPayRangeMode === 'limit'
|
||||
? 'bi bi-funnel'
|
||||
: 'bi bi-sort-alpha-down'}
|
||||
></span>
|
||||
</button>
|
||||
</h1>
|
||||
<div
|
||||
class="bg-white {expandPayRanges
|
||||
? ''
|
||||
: 'min-h-[500px] max-h-[500px]'} overflow-y-auto"
|
||||
>
|
||||
<div class="sticky top-0 py-2 px-2 bg-white w-full z-50">
|
||||
<input
|
||||
class="w-full z-20"
|
||||
bind:value={searchPayranges}
|
||||
placeholder="search"
|
||||
/>
|
||||
</div>
|
||||
<div bind:this={payRangeDiv}>
|
||||
{#if scale && context}
|
||||
{#each searchPayranges && searchPayRangeMode === 'limit' ? payranges.filter( (a) => a[0].match(new RegExp(searchPayranges, 'i')) ) : payranges as v, index}
|
||||
{@const company = v[0]}
|
||||
{@const values = v[1]}
|
||||
{@const ranges = values.reduce(max_and_min_reducer, [
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY
|
||||
])}
|
||||
{@const nameCompany = company === '' ? 'No Company' : company}
|
||||
{#if open !== company}
|
||||
<div
|
||||
class="relative h-[40px] pointer-cursor {gotoIndex === index
|
||||
? 'bg-purple-200/50'
|
||||
: index % 2 === 0
|
||||
? 'bg-slate-50'
|
||||
: ''}"
|
||||
role="button"
|
||||
onclick={() => (open = company)}
|
||||
onkeydown={() => (open = company)}
|
||||
bind:this={indexPayRanges[index]}
|
||||
tabindex={1}
|
||||
>
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[10px] rounded-full absolute"
|
||||
style="left: {10 +
|
||||
scale(ranges[0]) +
|
||||
10}px; top: 50%; transform: translateY(-50%); width: {scale(
|
||||
ranges[1]
|
||||
) - scale(ranges[0])}px;"
|
||||
></div>
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
|
||||
title={`pay: ${ranges[0].toLocaleString('en-GB', {
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
})}`}
|
||||
style="left: {10 +
|
||||
scale(
|
||||
ranges[0]
|
||||
)}px; top: 50%; transform: translateY(-50%);"
|
||||
></div>
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
|
||||
title={`pay: ${ranges[1].toLocaleString('en-GB', {
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
})}`}
|
||||
style="left: {10 +
|
||||
scale(
|
||||
ranges[1]
|
||||
)}px; top: 50%; transform: translateY(-50%);"
|
||||
></div>
|
||||
{#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40}
|
||||
<div
|
||||
class="absolute text-center text-white font-bold pb-1"
|
||||
style="left: {10 +
|
||||
scale(ranges[0]) +
|
||||
10}px; width: {scale(ranges[1]) -
|
||||
scale(ranges[0])}px;
|
||||
top: 50%; transform: translateY(-50%); font-size: 10px; "
|
||||
>
|
||||
{nameCompany}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute text-center font-bold pb-1"
|
||||
style="left: {10 +
|
||||
scale(ranges[1] ?? ranges[0]) +
|
||||
30}px;
|
||||
top: 50%; transform: translateY(-50%); font-size: 10px; "
|
||||
>
|
||||
{nameCompany}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class=" p-[10px] inset-2
|
||||
{gotoIndex === index
|
||||
? 'bg-purple-200/50'
|
||||
: 'bg-slate-200/50'}
|
||||
|
||||
"
|
||||
bind:this={indexPayRanges[index]}
|
||||
>
|
||||
<h2 class="font-bold">
|
||||
{nameCompany} (Avg: {(
|
||||
(ranges[0] + ranges[1]) /
|
||||
2
|
||||
).toLocaleString('en-GB', {
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
})}; Min: {ranges[0].toLocaleString('en-GB', {
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
})}; Max: {ranges[1].toLocaleString('en-GB', {
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
})})
|
||||
</h2>
|
||||
{#each values as app}
|
||||
<div
|
||||
class="relative -mx-[10px] h-[40px]"
|
||||
role="button"
|
||||
tabindex={1}
|
||||
onclick={() => {
|
||||
applicationStore.loadItem = app as any;
|
||||
goto('/');
|
||||
}}
|
||||
onkeydown={() => {
|
||||
applicationStore.loadItem = app as any;
|
||||
}}
|
||||
>
|
||||
{#if app.payrange[1]}
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[10px] rounded-full absolute"
|
||||
style="left: {10 +
|
||||
scale(app.payrange[0]) +
|
||||
10}px; top: 50%; transform: translateY(-50%); width: {scale(
|
||||
app.payrange[1]
|
||||
) - scale(app.payrange[0])}px;"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
|
||||
title={`pay: ${app.payrange[0].toLocaleString(
|
||||
'en-GB',
|
||||
{
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
}
|
||||
)}`}
|
||||
style="left: {10 +
|
||||
scale(
|
||||
app.payrange[0]
|
||||
)}px; top: 50%; transform: translateY(-50%);"
|
||||
></div>
|
||||
{#if app.payrange[1]}
|
||||
<div
|
||||
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute"
|
||||
title={`pay: ${app.payrange[1].toLocaleString(
|
||||
'en-GB',
|
||||
{
|
||||
notation: 'compact',
|
||||
currency: 'GBP',
|
||||
style: 'currency'
|
||||
}
|
||||
)}`}
|
||||
style="left: {10 +
|
||||
scale(
|
||||
app.payrange[1]
|
||||
)}px; top: 50%; transform: translateY(-50%);"
|
||||
></div>
|
||||
{/if}
|
||||
{#if context.measureText(app.title).width < scale(app.payrange[1]) - scale(app.payrange[0]) - 40}
|
||||
<div
|
||||
class="absolute text-center text-white font-bold pb-1"
|
||||
style="left: {10 +
|
||||
scale(app.payrange[0]) +
|
||||
10}px; width: {scale(app.payrange[1]) -
|
||||
scale(app.payrange[0])}px;
|
||||
top: 50%; transform: translateY(-50%); font-size: 10px; "
|
||||
>
|
||||
{app.title}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute text-center font-bold pb-1"
|
||||
style="left: {10 +
|
||||
scale(
|
||||
app.payrange[1] ?? app.payrange[0]
|
||||
) +
|
||||
30}px;
|
||||
top: 50%; transform: translateY(-50%); font-size: 10px; "
|
||||
>
|
||||
{app.title}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HasUser>
|
||||
172
site/src/routes/graphs/Pie.svelte
Normal file
172
site/src/routes/graphs/Pie.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import * as d3 from 'd3';
|
||||
|
||||
const {
|
||||
title,
|
||||
data,
|
||||
sensitivity = 0.01,
|
||||
width = 500,
|
||||
height = 500,
|
||||
dounout = false
|
||||
}: {
|
||||
title: string;
|
||||
data: Record<string, number>;
|
||||
sensitivity?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dounout?: boolean;
|
||||
} = $props();
|
||||
|
||||
let div: HTMLDivElement;
|
||||
|
||||
$effect(() => {
|
||||
if (!div) return;
|
||||
|
||||
const labelsArray = Object.keys(data); //.toSorted((a, b) => data[a] - data[b]);
|
||||
const valueArray = labelsArray.map((a) => data[a]);
|
||||
|
||||
const sum = valueArray.reduce((acc, d) => acc + d, 0);
|
||||
|
||||
let dataf = labelsArray
|
||||
.map((l) => ({ name: l, value: data[l] }))
|
||||
.filter((f) => f.value / sum > sensitivity);
|
||||
|
||||
const otherSum = valueArray.reduce((acc, v) => {
|
||||
if (v / sum > sensitivity) return acc;
|
||||
return acc + v;
|
||||
}, 0);
|
||||
|
||||
if (otherSum > 0) {
|
||||
dataf.push({
|
||||
value: otherSum,
|
||||
name: 'Other'
|
||||
});
|
||||
}
|
||||
|
||||
dataf = dataf.toSorted((a, b) => a.value - b.value);
|
||||
|
||||
const names = dataf.map((d) => d.name);
|
||||
const values = dataf.map((d) => d.value);
|
||||
const range = d3.range(names.length).filter((i) => !Number.isNaN(values[i]));
|
||||
|
||||
let colors: readonly string[] = [];
|
||||
const groups = names;
|
||||
const len = groups.length;
|
||||
if (len === 0) {
|
||||
// Do nothing
|
||||
} else if (len === 1) {
|
||||
colors = ['#FFCCC9'];
|
||||
} else if (len === 2) {
|
||||
colors = ['#FFCCC9', '#41EAD4'];
|
||||
} else if (len < 10) {
|
||||
colors = d3.schemeBlues[len];
|
||||
} else {
|
||||
colors = [...groups].map((_, i) => d3.interpolateBlues(i / len));
|
||||
}
|
||||
const color = d3.scaleOrdinal(groups, colors);
|
||||
|
||||
const innerRadius = 0; // inner radius of pie, in pixels (non-zero for donut)
|
||||
const outerRadius = Math.min(width, height) / 2; // outer radius of pie, in pixels
|
||||
const labelRadius = innerRadius * 0.2 + outerRadius * 0.8; // center radius of labels
|
||||
const stroke = innerRadius > 0 ? 'none' : 'white'; // stroke separating widths
|
||||
const strokeWidth = 1; // width of stroke separating wedges
|
||||
const strokeLinejoin = 'round'; // line join of stroke separating wedges
|
||||
const padAngle = stroke === 'none' ? 1 / outerRadius : 0; // angular separation between wedges, in radians
|
||||
|
||||
const arcs = d3
|
||||
.pie()
|
||||
.padAngle(padAngle)
|
||||
.sort(null)
|
||||
.value((i) => values[i as number])(range);
|
||||
|
||||
const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);
|
||||
|
||||
const arcLabel = d3.arc().innerRadius(labelRadius).outerRadius(labelRadius);
|
||||
|
||||
const svg = d3
|
||||
.create('svg')
|
||||
.attr('width', width * 2)
|
||||
.attr('height', height * 2)
|
||||
.attr('viewBox', [-width, -height, width * 2, height * 2]);
|
||||
|
||||
const shapes = svg
|
||||
.append('g')
|
||||
.attr('stroke', stroke)
|
||||
.attr('stroke-width', strokeWidth)
|
||||
.attr('stroke-linejoin', strokeLinejoin);
|
||||
|
||||
const svg_arcs = shapes
|
||||
.selectAll('path')
|
||||
.data(arcs)
|
||||
.join('path')
|
||||
.attr('fill', (d, i) => color(`${names[d.data as number]}-${i}`))
|
||||
.attr('d', arc as unknown as number);
|
||||
|
||||
svg_arcs
|
||||
.append('title')
|
||||
.text((d) => `${names[d.data as number]}: ${values[d.data as number]}`);
|
||||
|
||||
svg.append('g')
|
||||
.attr('font-family', 'sans-serif')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 10)
|
||||
.selectAll('text')
|
||||
.data(arcs)
|
||||
.join('text')
|
||||
.attr('transform', (d) => `translate(${arcLabel.centroid(d as unknown as any)})`)
|
||||
.selectAll('tspan')
|
||||
.data((d) => {
|
||||
if (d.endAngle - d.startAngle <= 0.2) {
|
||||
return [];
|
||||
}
|
||||
if (d.endAngle - d.startAngle <= 0.25) {
|
||||
return [{ t: 'title', text: names[d.data as number] }];
|
||||
}
|
||||
|
||||
return [
|
||||
{ t: 'title', text: names[d.data as number] },
|
||||
{
|
||||
t: 'value',
|
||||
text: values[d.data as number].toLocaleString('en-GB', {
|
||||
notation: 'compact'
|
||||
})
|
||||
}
|
||||
];
|
||||
})
|
||||
.join('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('y', (_, i) => `${i * 1.1}em`)
|
||||
.attr('font-weight', (d) => (d.t === 'value' ? null : 'bold'))
|
||||
.text((d) => {
|
||||
return d.text;
|
||||
});
|
||||
|
||||
if (dounout) {
|
||||
const mask = svg.append('mask').attr('id', 'center-mask');
|
||||
|
||||
mask.append('circle')
|
||||
.attr('cx', 0)
|
||||
.attr('cy', 0)
|
||||
.attr('fill', 'white')
|
||||
.attr('r', width);
|
||||
|
||||
mask.append('circle')
|
||||
.attr('cx', 0)
|
||||
.attr('cy', 0)
|
||||
.attr('fill', 'black')
|
||||
.attr('r', width / 4);
|
||||
|
||||
shapes.attr('mask', 'url(#center-mask)');
|
||||
}
|
||||
|
||||
div.innerHTML = '';
|
||||
div.appendChild(svg.node() as unknown as Node);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{title}
|
||||
<div class="relative m-auto overflow-hidden" style="width: {width}px; height: {height}px; ">
|
||||
<div bind:this={div} style="transform: translate(-25%, -25%)" class="m-auto absolute"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +51,9 @@
|
||||
</script>
|
||||
|
||||
{#if applicationStore.dragging && derivedItem}
|
||||
<div class="flex w-full flex-grow rounded-lg p-3 gap-2 absolute bottom-0 left-0 right-0 bg-white">
|
||||
<div
|
||||
class="flex w-full flex-grow rounded-lg p-3 gap-2 absolute bottom-0 left-0 right-0 bg-white"
|
||||
>
|
||||
{#each statusStore.dirLinks[derivedItem.status_id] as node}
|
||||
<DropZone icon={node.icon} ondrop={() => moveStatus(node.id, node.endable)}
|
||||
>{node.name}</DropZone
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { preventDefault } from '$lib/utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { ondrop, icon, children }: { ondrop: () => void, icon: string, children: Snippet } = $props();
|
||||
let { ondrop, icon, children }: { ondrop: () => void; icon: string; children: Snippet } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -13,9 +14,7 @@
|
||||
ondragenter={preventDefault(() => {})}
|
||||
{ondrop}
|
||||
>
|
||||
<span
|
||||
class="bi bi-{icon} text-7xl absolute"
|
||||
class:animate-bounce={applicationStore.dragging}
|
||||
<span class="bi bi-{icon} text-7xl absolute" class:animate-bounce={applicationStore.dragging}
|
||||
></span>
|
||||
<span class="bi bi-{icon} text-7xl opacity-0"></span>
|
||||
<div class="text-xl">{@render children()}</div>
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
onreload: (item: Application) => void;
|
||||
} = $props();
|
||||
|
||||
let filter = $state('');
|
||||
let filter = $state(application.company ? `@ ${application.company}` : '');
|
||||
|
||||
$effect(() => {
|
||||
filter = application.company ? `@ ${application.company}` : '';
|
||||
});
|
||||
|
||||
async function submit(item: Application) {
|
||||
try {
|
||||
@@ -29,6 +33,7 @@
|
||||
|
||||
let internal = $derived(
|
||||
applicationStore.all.filter((i) => {
|
||||
if (i.id === application.id) return false;
|
||||
if (!filter) {
|
||||
return true;
|
||||
}
|
||||
@@ -46,9 +51,9 @@
|
||||
</script>
|
||||
|
||||
<dialog class="card max-w-[50vw]" bind:this={dialog}>
|
||||
<div class="flex">
|
||||
<div class="flex items-center">
|
||||
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
|
||||
<div>
|
||||
<div class="p-2">
|
||||
{internal.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,18 @@
|
||||
<form bind:this={form} onsubmit={preventDefault(submit)}>
|
||||
<fieldset>
|
||||
<label class="flabel" for="text">Url</label>
|
||||
<textarea class="finput min-w-96 min-h-96" id="text" bind:value={data.url}></textarea>
|
||||
<textarea
|
||||
class="finput min-w-96 min-h-96"
|
||||
id="text"
|
||||
bind:value={data.url}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && data.url === '' && hasExtension) {
|
||||
e.preventDefault();
|
||||
askForUrl();
|
||||
return;
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</fieldset>
|
||||
<div class="btns">
|
||||
<button type="submit" class="btn-confirm">Update</button>
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
lastEvent = event;
|
||||
}
|
||||
|
||||
let d2 = new Date().getTime();
|
||||
if (_events.length > 0) {
|
||||
let d1 = new Date(_events[_events.length - 1].time).getTime();
|
||||
_events[_events.length - 1].timeDiff = calcDiff(d2 - d1);
|
||||
}
|
||||
|
||||
// Todo endable
|
||||
/*
|
||||
if (_events.length > 0 && !endable.includes(_events[_events.length - 1].new_status_id)) {
|
||||
@@ -85,10 +91,14 @@
|
||||
class="shadow-sm shadow-violet-500 border rounded-full min-w-[50px] min-h-[50px] grid place-items-center bg-white z-10"
|
||||
>
|
||||
{#if event.event_type == EventType.Creation}
|
||||
<span title={`Created @\n ${new Date(event.time).toLocaleString()}`} class="bi bi-plus"
|
||||
<span
|
||||
title={`Created @\n ${new Date(event.time).toLocaleString()}`}
|
||||
class="bi bi-plus"
|
||||
></span>
|
||||
{:else if event.event_type == EventType.View}
|
||||
<span title={`Viewed @\n ${new Date(event.time).toLocaleString()}`} class="bi bi-eye"
|
||||
<span
|
||||
title={`Viewed @\n ${new Date(event.time).toLocaleString()}`}
|
||||
class="bi bi-eye"
|
||||
></span>
|
||||
{:else}
|
||||
<span
|
||||
@@ -102,7 +112,9 @@
|
||||
<!-- TODO -->
|
||||
<!-- || !endable.includes(event.new_status) -->
|
||||
{#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable}
|
||||
<div class="min-w-[70px] h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center">
|
||||
<div
|
||||
class="min-w-[70px] h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center"
|
||||
>
|
||||
{#if event.timeDiff}
|
||||
<div class="-mt-[3px] text-white">
|
||||
<span class="bi bi-clock"></span>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import CompanyField from './CompanyField.svelte';
|
||||
import AutoDropZone from './AutoDropZone.svelte';
|
||||
import { statusStore } from '$lib/Types.svelte';
|
||||
import { thresholdFreedmanDiaconis } from 'd3';
|
||||
|
||||
// Not this represents the index in the store array
|
||||
let activeItem: number | undefined = $state();
|
||||
@@ -124,9 +123,27 @@
|
||||
|
||||
function setExtData() {
|
||||
if (!lastExtData || activeItem === undefined || !derivedItem) return;
|
||||
applicationStore.all[activeItem].title = lastExtData.jobTitle;
|
||||
applicationStore.all[activeItem].company = lastExtData.company;
|
||||
applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&/, '&');
|
||||
applicationStore.all[activeItem].company = lastExtData.company.replace(/\&/, '&');
|
||||
applicationStore.all[activeItem].payrange = lastExtData.money;
|
||||
|
||||
const title: string = lastExtData.jobTitle;
|
||||
if (title.match(/intern|apprenticeship/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'intern';
|
||||
} else if (title.match(/graduate/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'entry';
|
||||
} else if (title.match(/junior|associate/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'junior';
|
||||
} else if (title.match(/mid/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'mid';
|
||||
} else if (title.match(/senior|III/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'senior';
|
||||
} else if (title.match(/staff/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'staff';
|
||||
} else if (title.match(/lead/i)) {
|
||||
applicationStore.all[activeItem].job_level = 'lead';
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
save().then(async () => {
|
||||
if (activeItem === undefined) return;
|
||||
@@ -274,6 +291,23 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
<fieldset>
|
||||
<label class="flabel" for="title">Job Level</label>
|
||||
<select
|
||||
class="finput"
|
||||
id="job_level"
|
||||
bind:value={applicationStore.all[activeItem].job_level}
|
||||
onchange={save}
|
||||
>
|
||||
<option value="intern"> Intern </option>
|
||||
<option value="entry"> Entry </option>
|
||||
<option value="junior"> Junior </option>
|
||||
<option value="mid"> Mid </option>
|
||||
<option value="senior"> Senior </option>
|
||||
<option value="staff"> Staff </option>
|
||||
<option value="lead"> Lead </option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<div>
|
||||
<div class="flabel">Tags</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
|
||||
Reference in New Issue
Block a user