feat: more graphs

This commit is contained in:
Andre Henriques 2025-03-30 09:08:11 +01:00
parent eea2d6c3df
commit 1b6a887648
18 changed files with 2120 additions and 529 deletions

View File

@ -26,13 +26,19 @@ dependencies {
implementation("org.hibernate.orm:hibernate-community-dialects") implementation("org.hibernate.orm:hibernate-community-dialects")
implementation("org.json:json:20250107") implementation("org.json:json:20250107")
implementation("org.bouncycastle:bcprov-jdk18on:1.76") implementation("org.bouncycastle:bcprov-jdk18on:1.76")
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
runtimeOnly("com.h2database:h2") runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.14.0")
}
} }
springBoot { mainClass.set("com.andr3h3nriqu3s.applications.ApplicationsApplicationKt") } springBoot { mainClass.set("com.andr3h3nriqu3s.applications.ApplicationsApplicationKt") }

View File

@ -1,5 +1,7 @@
package com.andr3h3nriqu3s.applications package com.andr3h3nriqu3s.applications
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.trace.SpanKind
import java.sql.ResultSet import java.sql.ResultSet
import java.util.UUID import java.util.UUID
import kotlin.collections.emptyList import kotlin.collections.emptyList
@ -52,6 +54,7 @@ data class Application(
var job_level: String, var job_level: String,
var flairs: List<Flair>, var flairs: List<Flair>,
var events: List<Event>, var events: List<Event>,
var urls: List<String>,
) { ) {
companion object : RowMapper<Application> { companion object : RowMapper<Application> {
override public fun mapRow(rs: ResultSet, rowNum: Int): Application { override public fun mapRow(rs: ResultSet, rowNum: Int): Application {
@ -72,6 +75,7 @@ data class Application(
rs.getString("job_level"), rs.getString("job_level"),
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(),
) )
} }
} }
@ -101,6 +105,7 @@ class ApplicationsController(
val applicationService: ApplicationService, val applicationService: ApplicationService,
val flairService: FlairService, val flairService: FlairService,
val eventService: EventService, val eventService: EventService,
val openTelemetry: OpenTelemetry
) { ) {
@GetMapping(path = ["/cv/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) @GetMapping(path = ["/cv/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
@ -152,6 +157,7 @@ class ApplicationsController(
"", "",
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(),
) )
if (!applicationService.createApplication(user, application)) { if (!applicationService.createApplication(user, application)) {
@ -169,6 +175,8 @@ class ApplicationsController(
@RequestBody info: FlairRequest, @RequestBody info: FlairRequest,
@RequestHeader("token") token: String @RequestHeader("token") token: String
): Int { ): Int {
val trace = openTelemetry.getTracer("applications")
val user = sessionService.verifyTokenThrow(token) val user = sessionService.verifyTokenThrow(token)
val application = applicationService.findApplicationById(user, info.id) val application = applicationService.findApplicationById(user, info.id)
if (application == null) { if (application == null) {
@ -179,6 +187,7 @@ class ApplicationsController(
var count = 0 var count = 0
val span = trace.spanBuilder("parse-doc").setSpanKind(SpanKind.INTERNAL).startSpan()
for (flair: Flair in flairs) { for (flair: Flair in flairs) {
val regex = val regex =
Regex( Regex(
@ -191,6 +200,7 @@ class ApplicationsController(
flairService.linkFlair(application, flair) flairService.linkFlair(application, flair)
} }
} }
span.end()
return count return count
} }
@ -391,7 +401,7 @@ class ApplicationService(
application.flairs = flairService.listFromLinkApplicationId(application.id) application.flairs = flairService.listFromLinkApplicationId(application.id)
application.events = eventService.listFromApplicationId(application.id).toList() application.events = eventService.listFromApplicationId(application.id).toList()
application.urls = this.getUrls(application)
return application return application
} }
@ -503,6 +513,7 @@ class ApplicationService(
"", "",
emptyList(), emptyList(),
emptyList(), emptyList(),
emptyList(),
) )
} }
@ -608,6 +619,16 @@ class ApplicationService(
) )
} }
public fun getUrls(toGet: Application): List<String> {
var urls =
db.query(
"select * from applications_urls where application_id=?",
arrayOf(toGet.id),
ApplicationUrl
)
return urls.map { it.url }
}
public fun delete(application: Application) { public fun delete(application: Application) {
db.update( db.update(
"delete from applications where id=?", "delete from applications where id=?",

View File

@ -1,19 +1,21 @@
package com.andr3h3nriqu3s.applications package com.andr3h3nriqu3s.applications
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonInclude.Include
import java.sql.ResultSet import java.sql.ResultSet
import java.sql.Timestamp import java.sql.Timestamp
import java.util.Date import java.util.Date
import java.util.UUID import java.util.UUID
import org.springframework.http.MediaType
import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.http.MediaType import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
enum class EventType(val value: Int) { enum class EventType(val value: Int) {
Creation(0), Creation(0),
@ -21,6 +23,15 @@ enum class EventType(val value: Int) {
View(2) View(2)
} }
@JsonInclude(Include.NON_NULL)
data class EventStat(var i: String, var a: String, var t: Int, var s: String?, var c: Long)
fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
data class Event( data class Event(
var id: String, var id: String,
var application_id: String, var application_id: String,
@ -39,6 +50,25 @@ data class Event(
) )
} }
} }
fun toSimple(): EventStat {
return EventStat(
id.replace("-", "").substring(0, 10),
application_id,
/*Base64.getEncoder()
.encodeToString(application_id.replace("-", "").decodeHex())
.replace("==", ""),*/
event_type,
new_status_id,
/*if (new_status_id == null) null
else
Base64.getEncoder()
.encodeToString(
(new_status_id as String).replace("-", "").decodeHex()
)
.replace("==", ""),*/
time.time
)
}
} }
@RestController @RestController
@ -60,7 +90,14 @@ class EventController(
throw NotFound() throw NotFound()
} }
return application.events; return application.events
}
@GetMapping(path = ["/"], produces = [MediaType.APPLICATION_JSON_VALUE])
public fun getCV(@RequestHeader("token") token: String): List<EventStat> {
sessionService.verifyTokenThrow(token)
return eventService.findAll().map { it.toSimple() }.toList()
} }
} }
@ -69,7 +106,14 @@ class EventController(
public class EventService(val db: JdbcTemplate) { public class EventService(val db: JdbcTemplate) {
public fun listFromApplicationId(id: String): Iterable<Event> = public fun listFromApplicationId(id: String): Iterable<Event> =
db.query("select * from events where application_id=? order by time asc;", arrayOf(id), Event) db.query(
"select * from events where application_id=? order by time asc;",
arrayOf(id),
Event
)
public fun findAll(): Iterable<Event> =
db.query("select * from events order by time asc;", Event)
public fun getById(id: String): Event? { public fun getById(id: String): Event? {
val items = db.query("select * from events where id=?;", arrayOf(id), Event) val items = db.query("select * from events where id=?;", arrayOf(id), Event)
@ -98,7 +142,13 @@ public class EventService(val db: JdbcTemplate) {
val id = UUID.randomUUID().toString() val id = UUID.randomUUID().toString()
var new_event = var new_event =
Event(id, application_id, event_type.value, new_status_id, Timestamp(Date().getTime())) Event(
id,
application_id,
event_type.value,
new_status_id,
Timestamp(Date().getTime())
)
db.update( db.update(
"insert into events (id, application_id, event_type, new_status_id) values (?, ?, ? ,?)", "insert into events (id, application_id, event_type, new_status_id) values (?, ?, ? ,?)",

View File

@ -1,5 +1,6 @@
package com.andr3h3nriqu3s.applications package com.andr3h3nriqu3s.applications
import io.opentelemetry.api.OpenTelemetry
import java.time.LocalDate import java.time.LocalDate
import kotlin.io.println import kotlin.io.println
import org.json.JSONArray import org.json.JSONArray
@ -26,6 +27,7 @@ class MailController(
val userService: UserService, val userService: UserService,
val applicationService: ApplicationService, val applicationService: ApplicationService,
val sessionService: SessionService, val sessionService: SessionService,
val openTelemetry: OpenTelemetry,
) { ) {
@Value("\${mail.token.api}") private val ApiToken: String? = null @Value("\${mail.token.api}") private val ApiToken: String? = null
@ -46,6 +48,8 @@ class MailController(
if (TargetUserUUID == null || TargetUserUUID == "") { if (TargetUserUUID == null || TargetUserUUID == "") {
error("Target User not defined") error("Target User not defined")
} }
val tracer = openTelemetry.getTracer("applications")
if (user == null) { if (user == null) {
user = userService.findUserById(TargetUserUUID) user = userService.findUserById(TargetUserUUID)
if (user == null) { if (user == null) {
@ -53,6 +57,7 @@ class MailController(
error("User ${TargetUserUUID} not found") error("User ${TargetUserUUID} not found")
} }
} }
if (client == null) { if (client == null) {
val size = 16 * 1024 * 1024 val size = 16 * 1024 * 1024
val strategies = val strategies =
@ -101,6 +106,7 @@ class MailController(
println("mailbox got ${mailbox}") println("mailbox got ${mailbox}")
} }
val listSpan = tracer.spanBuilder("list email").startSpan()
println("\n\nGet Email @ ${LocalDate.now()}\n\n") println("\n\nGet Email @ ${LocalDate.now()}\n\n")
val email_get_obj = val email_get_obj =
JMapQueryBuilder("Email/query", "list_emails", account!!) JMapQueryBuilder("Email/query", "list_emails", account!!)
@ -171,10 +177,15 @@ class MailController(
JSONArray) JSONArray)
.getJSONObject(1) .getJSONObject(1)
if (email_r.getJSONArray("list").length() == 0) { val listSize: Int = email_r.getJSONArray("list").length()
if (listSize == 0) {
println("No emails found") println("No emails found")
listSpan.setAttribute("emailCount", 0)
listSpan.end()
return arrayListOf() return arrayListOf()
} }
listSpan.setAttribute("emailCount", listSize.toLong())
listSpan.end()
val email_html_body = email_r.getJSONArray("list").getJSONObject(0).getJSONArray("htmlBody") val email_html_body = email_r.getJSONArray("list").getJSONObject(0).getJSONArray("htmlBody")
@ -205,6 +216,7 @@ class MailController(
val email_id = email_r.getJSONArray("list").getJSONObject(0).getString("id") val email_id = email_r.getJSONArray("list").getJSONObject(0).getString("id")
val setSpan = tracer.spanBuilder("set emails as read").startSpan()
val update_email = val update_email =
JMapQueryBuilder("Email/set", "set", account!!) JMapQueryBuilder("Email/set", "set", account!!)
.updateNewItem(email_id) .updateNewItem(email_id)
@ -217,6 +229,7 @@ class MailController(
.retrieve() .retrieve()
.bodyToMono<String>() .bodyToMono<String>()
.block()!! .block()!!
setSpan.end();
return applications return applications
} }
@ -341,7 +354,7 @@ class JMapQueryBuilder {
if (this.sorts == null) { if (this.sorts == null) {
this.sorts = JSONArray() this.sorts = JSONArray()
} }
var obj = JSONObject() val obj = JSONObject()
obj.put("property", key) obj.put("property", key)
obj.put("isAscending", isAscending) obj.put("isAscending", isAscending)
this.sorts?.put(obj) this.sorts?.put(obj)

View File

@ -36,6 +36,7 @@ export type Application = {
job_level: string; job_level: string;
flairs: Flair[]; flairs: Flair[];
events: ApplicationEvent[]; events: ApplicationEvent[];
urls: string[];
}; };
function createApplicationStore() { function createApplicationStore() {

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { onDestroy, onMount, type Snippet } from 'svelte';
let {
buttonChildren,
children,
dialogClass = '',
buttonClass = ''
}: {
buttonChildren: Snippet;
children: Snippet;
dialogClass?: string;
buttonClass?: string;
} = $props();
let open = $state(false);
let ref: HTMLDivElement;
function callOutside(e: MouseEvent) {
if (!ref) return;
if (!ref.contains(e.target as any)) return;
open = false;
}
onMount(() => {
window.document.addEventListener('click', callOutside);
});
onDestroy(() => {
window.document.removeEventListener('click', callOutside);
});
</script>
<div class="relative">
<button class={buttonClass} onclick={() => (open = true)}>
{@render buttonChildren()}
</button>
{#if open}
<div
bind:this={ref}
class="absolute top-full left-0 bg-white p-2 rounded-lg shadow-lg {dialogClass}"
>
{@render children()}
</div>
{/if}
</div>

View File

@ -10,7 +10,7 @@
<div class="flex flex-col h-[100vh]"> <div class="flex flex-col h-[100vh]">
<NavBar /> <NavBar />
<div class="w-full px-4 grow h-full gap-3 flex flex-col"> <div class="w-full px-4 grow h-full gap-3 flex flex-col">
<div class="flex gap-3 flex-grow max-h-[75%]"> <div class="flex gap-3 flex-grow">
<ApplicationsList /> <ApplicationsList />
<WorkArea /> <WorkArea />
</div> </div>

View File

@ -66,7 +66,7 @@
}); });
</script> </script>
<div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0"> <div class="w-2/12 card p-3 flex flex-col flex-shrink min-h-0 max-h-[75vh]">
<h1>To Apply</h1> <h1>To Apply</h1>
<div class="flex pb-2 items-center"> <div class="flex pb-2 items-center">
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} /> <input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />

View File

@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import { userStore } from '$lib/UserStore.svelte'; import { userStore } from '$lib/UserStore.svelte';
import { get } from '$lib/utils'; import { get } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import Pill from './pill.svelte'; import Pill from './pill.svelte';
let id: string | undefined | null = $state(undefined); let id: string | undefined | null = $state(undefined);
onMount(() => { onMount(() => {
const url = new URLSearchParams(window.location.search); const url = new URLSearchParams(window.location.search);
id = url.get('id'); id = url.get('id');
loadData(); loadData();
}); });
@ -57,7 +55,7 @@
return b.sort - a.sort; return b.sort - a.sort;
}); });
loadFlairs(); await loadFlairs();
} catch (e) { } catch (e) {
console.log('TODO show this to the user', e); console.log('TODO show this to the user', e);
} }
@ -74,6 +72,88 @@
console.log('TODO inform the user', e); console.log('TODO inform the user', e);
} }
} }
let rootEml: HTMLDivElement;
function getDPI() {
var div = document.createElement('div');
div.style.height = '1in';
div.style.width = '1in';
div.style.top = '-100%';
div.style.left = '-100%';
div.style.position = 'absolute';
document.body.appendChild(div);
var result = div.offsetHeight;
document.body.removeChild(div);
return result;
}
function onPrint() {
const dpi = getDPI();
function cmToPx(cm: number) {
return cm * 0.3937 * dpi;
}
const padding = 20;
// A4 is 29.7
const pageHeight = Math.floor(cmToPx(29.7));
// This will be used for comparansions so we allow the starting padding
const limitedPageHeight = pageHeight - padding;
const children = rootEml.children;
let page = 0;
// As we had padding it will not right away update the dom so we need to account for that
// this will keep track of that
let extraHeight = 0;
for (let i = 0; i < children.length; i++) {
const elm = children[i];
// console.log('here', elm);
const box = elm.getBoundingClientRect();
const useHeight = elm.getAttribute('data-useheight')
? Number(elm.getAttribute('data-useheight'))
: null;
if (useHeight !== null) {
extraHeight += useHeight - box.height;
}
const end = box.top + box.height + extraHeight;
// console.log(page, page * pageHeight + limitedPageHeight, end, extraHeight);
if (page * pageHeight + limitedPageHeight < end) {
// console.log('Goes over recalculating', elm);
// Find the autoPadding
for (let j = i - 1; j > 0; j--) {
const s = children[j];
if (s.getAttribute('data-autopad') !== null) {
// console.log('found autoPadding', s);
const start = s.getBoundingClientRect().top + extraHeight;
const distToEnd = Math.ceil(pageHeight - (start - page * pageHeight));
(s as any).style.setProperty('--auto-size', `${distToEnd + padding}px`);
// console.log('padding with', distToEnd + padding);
extraHeight += distToEnd + padding;
page += 1;
break;
}
}
}
}
}
onMount(() => {
window.addEventListener('beforeprint', onPrint);
});
onDestroy(() => {
window.removeEventListener('beforeprint', onPrint);
});
</script> </script>
<svelte:head> <svelte:head>
@ -86,8 +166,8 @@
{/if} {/if}
</svelte:head> </svelte:head>
<div class="flex items-center w-full flex-col"> <div class="flex items-center w-full flex-col" bind:this={rootEml}>
<div class="py-5 pb-0 w-[190mm] print:py-0 print:pt-4"> <div class="py-5 pb-0 w-[190mm] print:py-0 print:pt-4" data-useheight="228">
<div class="bg-white rounded-lg p-3"> <div class="bg-white rounded-lg p-3">
<div class="w-full flex"> <div class="w-full flex">
<h1 class="text-black text-5xl">Andre Henriques</h1> <h1 class="text-black text-5xl">Andre Henriques</h1>
@ -136,7 +216,7 @@
{#if application} {#if application}
{#if !application.agency} {#if !application.agency}
<h2 class="text-white p-6 print:p-0 text-4xl print:text-3xl"> <h2 class="text-white p-6 print:p-0 text-4xl print:text-3xl" data-useheight="36">
👋 Hello 👋 Hello
{#if application.recruiter} {#if application.recruiter}
<span class="font-bold">{application.recruiter}</span> @ <span class="font-bold">{application.recruiter}</span> @
@ -151,19 +231,124 @@
{#if application.message} {#if application.message}
<div class="p-3 bg-white w-[190mm] rounded-lg"> <div class="p-3 bg-white w-[190mm] rounded-lg">
<h1>A small message from me</h1> <h1>Why your role interests me</h1>
<div class="py-2"> <div class="py-2">
{@html application.message.split('\n').join('<br>')} {@html application.message.split('\n').join('<br>')}
</div> </div>
</div> </div>
<div class="p-1"></div> <div class="p-1"></div>
{/if} {/if}
{:else}
<div class="p-1"></div>
{/if}
<div class="w-[190mm]" data-useheight="32">
<h2
class="p-3 pt-0 print:p-0 text-4xl print:text-2xl font-bold print:font-normal text-white"
>
Work Expericence
</h2>
</div>
<div class="w-[100vw] flex items-center flex-col" data-useheight="340">
<div class="p-3 print:p-0 bg-white w-[190mm] rounded-lg">
<h1>Senior Software Engineer @ Planum Solucoes</h1>
<h2>4 years - May 2020 - Present</h2>
<div class="ml-3">
My role is mainly focused on frontend development, while also having a supporting
role with the API and DevOps teams.
<ul class="pl-5 list-disc">
<li>
Main developer for frontend on the core platform, and the derivative
products, such as video call app for doctors, video call app for vets,
timesheets management, budget management, stock management, gambling
monitoring platform, survey platform, file sharing platform, issue tracking
platform.
</li>
<li class="ml-4">
<b>Stack</b>: Frontend (React, Typescript, Tailwind); Backend (Java, Spring
boot, Kafka); Infrastructure (Docker, AWS, Nginx).
</li>
<li>
Championed and Led a move for the use of CI/CD pipelines for deployment
using Docker on AWS, and started to introduce better testing.
</li>
<li>
Support any other team with any problem that might arise, for example
deploying a Kafka cluster or modifying a python CLI tool.
</li>
</ul>
</div>
</div>
</div>
<div class="p-2 print:p-1" data-useheight="4"></div>
<div class="w-[100vw] flex items-center flex-col" data-useheight="268">
<div class="text-black w-[190mm] bg-white p-4 print:p-0 rounded-lg">
<div>
<div>
<h1>Associate DevOps Engineer @ Sky</h1>
<h2>1 year: July 2022June 2023</h2>
<div class="ml-3">
My time at sky was split into two roles. One role involved maintaining
existing data and file pipelines, using Jenkins, Bash, and Python. My second
role was full-stack development, where I maintained and upgraded existing
applications and developed new ones, mostly with Python and Flask.
<ul class="pl-5 list-disc">
<li>
Maintained and updated and webapp that is used daily to monitor
various systems that the team looks after.
</li>
<li>
Created new backup, and rapid deployment solutions for Jenkins
servers.
</li>
<li>
Fixed, updated, and created new pipelines that were able to erase
terabytes of redundant data, improving performance of other
pipelines and the backup system.
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="p-1"></div>
<div class="autoPadding" data-autopad></div>
<div class="w-[190mm]" data-useheight="28">
<h2 class="p-3 print:p-0 text-4xl print:text-xl font-bold print:font-normal text-white">
Education
</h2>
</div>
<div class="bg-white p-2 text-black rounded-lg w-[190mm] print:text-sm">
<div>
<div>
<h1>BCompSc with First Class Honours @ University of Surrey</h1>
<div class="ml-5">
<h2>July 2020 - June 2024</h2>
</div>
</div>
</div>
</div>
<div class="p-1"></div>
<div class="autoPadding" data-autopad></div>
{#if application}
{#if application.flairs.length > 0} {#if application.flairs.length > 0}
<div class="p-3 bg-white w-[190mm] rounded-lg"> <div class="w-[190mm]">
<h1 class="flex gap-5 items-end"> <h2
class="p-3 print:p-0 text-4xl print:text-xl font-bold print:font-normal text-white flex gap-2 items-center"
>
Skills {#if flairs.length > 0}<input Skills {#if flairs.length > 0}<input
placeholder="Click here to search skills!" placeholder="Click here to search skills!"
class="flex-grow text-blue-500 print:hidden" class="flex-grow text-blue-500 print:hidden px-2 rounded-lg h-[30px] placeholder:text-[11px] text-[12px] py-0"
bind:value={otherSearch} bind:value={otherSearch}
/> />
<span class="hidden print:inline text-slate-600 text-sm" <span class="hidden print:inline text-slate-600 text-sm"
@ -172,7 +357,9 @@
></span ></span
> >
{/if} {/if}
</h1> </h2>
</div>
<div class="p-3 bg-white w-[190mm] rounded-lg">
<div class="flex flex-wrap gap-2 py-2 print:py-0"> <div class="flex flex-wrap gap-2 py-2 print:py-0">
{#if otherSearch === ''} {#if otherSearch === ''}
{#each application.flairs as flair} {#each application.flairs as flair}
@ -246,77 +433,18 @@
{/if} {/if}
{/if} {/if}
<div class="w-[190mm]"> <div class="p-4 print:hidden"></div>
<h2 class="p-3 print:p-0 text-4xl print:text-2xl font-bold print:font-normal text-white">
Work Expericence
</h2>
</div>
<div class="w-[100vw] flex items-center flex-col">
<div class="p-3 print:p-0 bg-white w-[190mm] rounded-lg">
<h1>Senior Software Developer @ Planum Solucoes</h1>
<h2>4 years - May 2020 - Present</h2>
<div class="ml-5">
<h3>Developed various projects:</h3>
<ul class="pl-5 list-disc">
<li>Developing various websites using React and Svelte.</li>
<li>Interacting with a RESTful API</li>
<li>Implemented an ORM system using Java Reflection</li>
<li>Implemented automatic deployment with GitLab CI/CD tools.</li>
<li>Linux Server Administration</li>
</ul>
</div>
</div>
</div>
<div class="p-2 print:p-1"></div>
<div class="w-[100vw] flex items-center flex-col">
<div class="text-black w-[190mm] bg-white p-4 print:p-0 rounded-lg">
<div>
<div>
<h1>Associate Devops Engineer @ Sky UK</h1>
<h2>1 year - July 2022 - June 2023</h2>
<div class="ml-2">
<ul class="pl-5 list-disc">
<li>Developed web-based tools for the DevOps team to use</li>
<li>
Updated Jenkins pipelines that the team uses to manage one of the
most important pipelines that the team manages.
</li>
<li>
Created new scripts that were used to clean up multiple terabytes of
unused data that led to improvements in the performance of the other
scripts running on the same server as well as the performance of the
backup system
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="w-[190mm]">
<h2 class="p-3 print:p-0 text-4xl print:text-xl font-bold print:font-normal text-white">
Education
</h2>
</div>
<div class="bg-white p-2 text-black rounded-lg w-[190mm] print:text-sm">
<div>
<div>
<h1>BCompSc with First Class Honours @ University of Surrey</h1>
<div class="ml-5">
<h2>July 2020 - June 2024</h2>
</div>
</div>
</div>
</div>
<div class="p-3 print:hidden"></div>
<!--div class="p-5"></div> <!--div class="p-5"></div>
<div>TODO: Previous projetcs</div --> <div>TODO: Previous projetcs</div -->
<!-- div class="p-5"></div> <!-- div class="p-5"></div>
<div>TODO: Info form</div --> <div>TODO: Info form</div -->
</div> </div>
<style>
@media print {
.autoPadding {
height: var(--auto-size);
}
}
</style>

View File

@ -1,172 +1,97 @@
<script lang="ts"> <script lang="ts">
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte'; import { applicationStore } from '$lib/ApplicationsStore.svelte';
import HasUser from '$lib/HasUser.svelte'; import HasUser from '$lib/HasUser.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import NavBar from '../NavBar.svelte'; import NavBar from '../NavBar.svelte';
import Pie from './Pie.svelte'; import Pie from './Pie.svelte';
import { statusStore } from '$lib/Types.svelte'; import { statusStore } from '$lib/Types.svelte';
import * as d3 from 'd3';
import { goto } from '$app/navigation';
import { get } from '$lib/utils'; import { get } from '$lib/utils';
import { flairStore } from '$lib/FlairStore.svelte'; import PayRange from './PayRange.svelte';
import LineGraphs, { type LineGraphData } from './LineGraph.svelte';
import * as d3 from 'd3';
import { countReducer } from './utils';
onMount(() => { onMount(() => {
applicationStore.loadAll(); applicationStore.loadAll();
statusStore.load(); 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'); let flairStats: 'loading' | Record<string, number> = $state('loading');
let createGraph: { xs: number[]; ys: number[] } = $state({ xs: [], ys: [] });
let viewGraph: { xs: number[]; ys: number[] } = $state({ xs: [], ys: [] });
let statusGraph: LineGraphData = $state([]);
let width: number = $state(300);
onMount(async () => { onMount(async () => {
const items: any[] = await get('flair/stats'); (async () => {
flairStats = items.reduce( const items: any[] = await get('flair/stats');
(acc, a) => { flairStats = items.reduce(
acc[a.name] = a.count; (acc, a) => {
return acc; acc[a.name] = a.count;
}, return acc;
{} as Record<string, number> },
); {} as Record<string, number>
);
})();
(async () => {
type EventsStat = {
// application_id
a: string;
// created time
c: number;
// id
i: string;
// new_status_id
s?: string;
// Type
t: number;
};
const events: EventsStat[] = await get('events/');
const pre_created_graph = events
.filter((a) => a.t === 0)
.reduce(countReducer, {} as Record<number | string, number>);
const pre_created_graph_xs = Object.keys(pre_created_graph);
createGraph = {
xs: pre_created_graph_xs.map((a) => Number(a)),
ys: pre_created_graph_xs.map((a) => pre_created_graph[a])
};
const pre_views_graph = events
.filter((a) => a.t === 2)
.reduce(countReducer, {} as Record<number | string, number>);
const pre_view_graph_xs = Object.keys(pre_views_graph);
viewGraph = {
xs: pre_view_graph_xs.map((a) => Number(a)),
ys: pre_view_graph_xs.map((a) => pre_views_graph[a])
};
statusGraph = statusStore.nodes
.map((a, i) => {
const pre = events
.filter((e) => e.t === 1 && e.s === a.id)
.reduce(countReducer, {} as Record<number | string, number>);
const pre_xs = Object.keys(pre);
if (pre_xs.length === 0) return undefined;
return {
name: a.name,
color: d3.interpolateRainbow(i / statusStore.nodes.length),
xs: pre_xs.map((a) => Number(a)),
ys: pre_xs.map((a) => pre[a])
};
})
.filter((a) => a) as LineGraphData;
})();
}); });
const seniorities = ['intern', 'entry', 'junior', 'mid', 'senior', 'staff', 'lead'];
</script> </script>
<HasUser redirect="/cv"> <HasUser redirect="/cv">
@ -253,7 +178,7 @@
.replace(/[^\d\-]/g, '') .replace(/[^\d\-]/g, '')
.replace(//g, '-') .replace(//g, '-')
.split('-'); .split('-');
return Number(payrange[0]); return Number(payrange[0]) + Number(payrange[1] ?? payrange[0]);
}) })
.reduce( .reduce(
(acc, a) => { (acc, a) => {
@ -272,6 +197,21 @@
{} as Record<string, number> {} as Record<string, number>
)} )}
/> />
<Pie
title={'Company'}
sensitivity={0.01}
data={applicationStore.all.reduce(
(acc, item) => {
if (acc[item.company]) {
acc[item.company] += 1;
} else {
acc[item.company] = 1;
}
return acc;
},
{} as Record<string, number>
)}
/>
</div> </div>
{#if flairStats !== 'loading'} {#if flairStats !== 'loading'}
<div class="flex gap-5"> <div class="flex gap-5">
@ -301,253 +241,143 @@
/> />
</div> </div>
{/if} {/if}
<h1 class="text-black"> <div bind:clientWidth={width}>
Pay range <LineGraphs
<button data={[
title="Expand/Contract" { name: 'Created Time', color: 'red', ...createGraph },
onclick={() => { { name: 'Views', color: 'blue', ...viewGraph }
expandPayRanges = !expandPayRanges; ]}
}} xIsDates
> width={width - 50}
<span height={300}
class={!expandPayRanges />
? 'bi bi-arrows-angle-expand' </div>
: 'bi bi-arrows-angle-contract'} <div bind:clientWidth={width}>
></span> <h1>Status Graph</h1>
</button> <LineGraphs data={statusGraph} xIsDates width={width - 50} height={300} />
<button </div>
title="Sorting" <h1>Payrange</h1>
onclick={() => { <PayRange />
if (sort === 'asc') { <div>
sort = 'desc'; <h1>Per Seniority</h1>
} else if (sort === 'desc') { {#each seniorities as level}
sort = 'asc'; {@const fapps = applicationStore.all.filter(
} (a) => a.payrange.match(/\d/) && a.job_level === level
}} )}
> <h2 class="font-bold text-lg">
<span class={sort === 'asc' ? 'bi bi-arrow-down' : 'bi bi-arrow-up'}></span> {level} (AVG pay: {(
</button> fapps
<button .map((a) => {
title="Filter mode" const payrange = a.payrange
onclick={() => { .replace(/[kK]/g, '000')
if (searchPayRangeMode === 'limit') { .replace(/[^\d\-]/g, '')
searchPayRangeMode = 'goto'; .replace(//g, '-')
} else if (searchPayRangeMode === 'goto') { .split('-');
searchPayRangeMode = 'limit'; return (
} Number(payrange[0]) + Number(payrange[1] ?? payrange[0])
}} );
> })
<span .reduce((acc, a) => acc + a, 0) /
class={searchPayRangeMode === 'limit' (fapps.length * 2)
? 'bi bi-funnel' ).toLocaleString('en-GB', {
: 'bi bi-sort-alpha-down'} notation: 'compact',
></span> style: 'currency',
</button> currency: 'GBP'
</h1> })})
<div </h2>
class="bg-white {expandPayRanges <div class="flex gap-2">
? '' <Pie
: 'min-h-[500px] max-h-[500px]'} overflow-y-auto" title={'Higher range Pay Range'}
> data={fapps
<div class="sticky top-0 py-2 px-2 bg-white w-full z-50"> .map((a) => {
<input const payrange = a.payrange
class="w-full z-20" .replace(/[kK]/g, '000')
bind:value={searchPayranges} .replace(/[^\d\-]/g, '')
placeholder="search" .replace(//g, '-')
/> .split('-');
</div> return Number(payrange[payrange.length - 1]);
<div bind:this={payRangeDiv}> })
{#if scale && context} .reduce(
{#each searchPayranges && searchPayRangeMode === 'limit' ? payranges.filter( (a) => a[0].match(new RegExp(searchPayranges, 'i')) ) : payranges as v, index} (acc, a) => {
{@const company = v[0]} const f = Math.floor(a / 10000);
{@const values = v[1]} let name = `${f * 10}K-${(f + 1) * 10}K`;
{@const ranges = values.reduce(max_and_min_reducer, [ if (f == 0) {
Number.POSITIVE_INFINITY, name = '<10K';
Number.NEGATIVE_INFINITY }
])} if (acc[name]) {
{@const nameCompany = company === '' ? 'No Company' : company} acc[name] += 1;
{#if open !== company} } else {
<div acc[name] = 1;
class="relative h-[40px] pointer-cursor {gotoIndex === index }
? 'bg-purple-200/50' return acc;
: index % 2 === 0 },
? 'bg-slate-50' {} as Record<string, number>
: ''}" )}
role="button" />
onclick={() => (open = company)} <Pie
onkeydown={() => (open = company)} title={'Lower range Pay Range'}
bind:this={indexPayRanges[index]} data={fapps
tabindex={1} .map((a) => {
> const payrange = a.payrange
<div .replace(/[kK]/g, '000')
class="bg-blue-500 w-[20px] h-[10px] rounded-full absolute" // The first is a - the other is unicode 8211
style="left: {10 + .replace(/[^\d\-]/g, '')
scale(ranges[0]) + .replace(//g, '-')
10}px; top: 50%; transform: translateY(-50%); width: {scale( .split('-');
ranges[1] return Number(payrange[0]);
) - scale(ranges[0])}px;" })
></div> .reduce(
<div (acc, a) => {
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute" const f = Math.floor(a / 10000);
title={`pay: ${ranges[0].toLocaleString('en-GB', { let name = `${f * 10}K-${(f + 1) * 10}K`;
notation: 'compact', if (f == 0) {
currency: 'GBP', name = '<10K';
style: 'currency' }
})}`} if (acc[name]) {
style="left: {10 + acc[name] += 1;
scale( } else {
ranges[0] acc[name] = 1;
)}px; top: 50%; transform: translateY(-50%);" }
></div> return acc;
<div },
class="bg-blue-500 w-[20px] h-[20px] rounded-full absolute" {} as Record<string, number>
title={`pay: ${ranges[1].toLocaleString('en-GB', { )}
notation: 'compact', />
currency: 'GBP', <Pie
style: 'currency' title={'AVG Pay'}
})}`} data={fapps
style="left: {10 + .map((a) => {
scale( const payrange = a.payrange
ranges[1] .replace(/[kK]/g, '000')
)}px; top: 50%; transform: translateY(-50%);" // The first is a - the other is unicode 8211
></div> .replace(/[^\d\-]/g, '')
{#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40} .replace(//g, '-')
<div .split('-');
class="absolute text-center text-white font-bold pb-1" return (
style="left: {10 + (Number(payrange[0]) +
scale(ranges[0]) + Number(payrange[1] ?? payrange[0])) /
10}px; width: {scale(ranges[1]) - 2
scale(ranges[0])}px; );
top: 50%; transform: translateY(-50%); font-size: 10px; " })
> .reduce(
{nameCompany} (acc, a) => {
</div> const f = Math.floor(a / 10000);
{:else} let name = `${f * 10}K-${(f + 1) * 10}K`;
<div if (f == 0) {
class="absolute text-center font-bold pb-1" name = '<10K';
style="left: {10 + }
scale(ranges[1] ?? ranges[0]) + if (acc[name]) {
30}px; acc[name] += 1;
top: 50%; transform: translateY(-50%); font-size: 10px; " } else {
> acc[name] = 1;
{nameCompany} }
</div> return acc;
{/if} },
</div> {} as Record<string, number>
{:else} )}
<div />
class=" p-[10px] inset-2 </div>
{gotoIndex === index {/each}
? '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>
</div> </div>

View File

@ -0,0 +1,422 @@
<script lang="ts">
import * as d3 from 'd3';
import {
type AxisProps,
Axis,
type EnforceSizeType,
enforceSizeHelper,
PaddingManager
} from './utils';
export type LineGraphData = {
ys: number[];
xs: number[];
name: string;
color?: string;
}[];
const {
class: className,
data,
title,
useCompactNotation = 'auto',
width: inWidth = 300,
height: inHeight = 300,
area,
xAxis: inXAxis,
yAxis: inYAxis,
xIsDates = false,
enforceSize
}: {
class?: string;
data: LineGraphData;
title?: string;
useCompactNotation?: 'auto' | boolean;
width?: number;
height?: number;
area?: boolean;
xAxis?: AxisProps;
yAxis?: AxisProps;
xIsDates?: boolean;
enforceSize?: EnforceSizeType;
} = $props();
const notation = 'compact';
const locale = 'en-GB';
let divRef: HTMLDivElement | null = $state(undefined);
let yScaler: d3.ScaleLinear<number, number>;
let optionsHeight = $state(0);
let size: { width: number; height: 0 } = $state({ width: 0, height: 0 });
let mask: Record<string, boolean> = $state({});
const names = $derived(data.map((d) => d.name));
const color = $derived.by(() => {
let colors: readonly string[] = [];
const groups = names;
const len = groups.length;
if (len === 0) {
// Do nothing
} else if (len >= 3 && len < 10) {
colors = d3.schemeBlues[len];
} else {
colors = [...groups].map((_, i) => d3.interpolateBlues(i / len));
}
return d3.scaleOrdinal(groups, colors);
});
const internalEnforceSize = $derived.by(() => {
return (
enforceSizeHelper
.fromEnforceSize(enforceSize)
.h('p-1', '0.5rem')
.h('left', 1, 'left')
.h('p-2', '0.5rem')
//.h('options', optionsHeight)
.enforce()
.toEnfoceSize('left')
);
});
$effect(() => {
if (!data || !divRef || data.length === 0) return;
function gen<T extends number | Date>(xs: T[]) {
// Get all possible values and sort them
const ys = data.reduce((acc, d) => acc.concat(d.ys), [] as number[]);
if (ys.length === 0) return;
ys.sort((a, b) => a - b);
const xAxisTitle = new Axis(inXAxis);
const yAxis = new Axis(inYAxis);
xAxisTitle.titlePos('bot');
yAxis.titlePos('left');
const padding = new PaddingManager({ width: inWidth, height: inHeight });
padding.padAll(20);
padding
.padLeft(
`${ys[ys.length - 1].toLocaleString(locale, { notation, maximumFractionDigits: ys[ys.length] > 100 ? 0 : 2 })}`,
65
)
.padLeft(yAxis);
padding.padBot(50).padTop(xAxisTitle);
padding.enforce(internalEnforceSize);
const div = divRef;
// clear the html inside the div
d3.select(div).html('');
const svg = d3
.select(div)
.append('svg')
.attr('width', padding.paddedWidth)
.attr('height', padding.paddedHeight)
.append('g')
.attr('transform', padding.translateString);
size = {
width: padding.paddedWidth,
height: padding.paddedHeight
};
let x: d3.ScaleTime<number, number, never> | d3.ScaleLinear<number, number, never>;
let xAxis: d3.Selection<SVGGElement, unknown, null, undefined>;
const width = padding.width;
const height = padding.height;
if (xIsDates) {
x = d3
.scaleTime()
.domain([new Date(xs[0]), new Date(xs[xs.length - 1])])
.range([0, width]);
xAxis = svg
.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x).ticks(10));
xAxis
.selectAll('text')
.attr('transform', 'rotate(65)')
.attr('dy', '-0.4em')
.attr('dx', '2.9em');
} else {
x = d3
.scaleLinear()
.domain([xs[0], xs[xs.length - 1]])
.range([0, width]);
xAxis = svg
.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x).ticks(10));
xAxis
.selectAll('text')
.attr('transform', 'rotate(65)')
.attr('dy', '-0.4em')
.attr('dx', '2.9em');
}
const y = d3
.scaleLinear()
.domain([ys[0], ys[ys.length - 1]])
.range([height, 0]);
svg.append('g').call(
d3.axisLeft(y).tickFormat((a) => a.toLocaleString(locale, { notation }))
);
yAxis.apply_title(svg, height, width, padding.left);
xAxisTitle.apply_title(svg, height, width, padding.bot);
yScaler = y;
svg.append('defs')
.append('svg:clipPath')
.attr('id', 'clip')
.append('svg:rect')
.attr('width', width)
.attr('height', height)
.attr('x', 0)
.attr('y', 0);
/*// Create the circle that travels along the curve of chart
const focus = svg
.append("g")
.append("circle")
.style("fill", "none")
.attr("stroke", "black")
.attr("r", 8.5)
.style("opacity", 0);
// Create the text that travels along the curve of chart
const focusText = svg
.append("g")
.append("text")
.style("opacity", 0)
.attr("text-anchor", "left")
.attr("alignment-baseline", "middle");*/
// Add brushing
const brush = d3
.brushX() // Add the brush feature using the d3.brush function
.extent([
[0, 0],
[width, height]
]) // initialise the brush area: start at 0,0 and finishes at width,height: it means I select the whole graph area
.on('end', updateChart);
//
// Create a group that stores all lines
//
const lines = svg.append('g').attr('clip-path', 'url(#clip)');
//
// Create the lines
//
lines
.selectAll('path')
.data(data.map((l, i) => ({ ...l, i })))
.join('path')
.attr('lineId', (_, i) => `l-${i}`)
.attr('transform', (l) => {
if (!mask[l.name]) {
return '';
}
const m = l.ys.reduce((acc, v) => Math.max(acc, v), 0);
return `translate(0, ${height - (y(m) as number)})`;
})
.sort((a, b) => b.ys[0] - a.ys[0])
.attr('fill', (l) => l.color ?? color(l.name))
.attr('fill-opacity', 1)
.attr('stroke', (l) => l.color ?? color(l.name))
.attr('stroke-width', 1.5)
.datum((l) => {
return l.ys.map((a, j) => ({
value: a,
x: l.xs[j]
}));
})
.attr('d', (d) => {
const a = d3
.area<{ x: number; value: number }>()
.x((d) => x(xIsDates ? new Date(d.x).getTime() : d.x))
.curve(d3.curveBumpX);
if (area) {
return a.y0(y(0)).y1((d) => y(d.value))(d as any);
}
return a.y((d) => y(d.value))(d as any);
});
/*lines
.selectAll('circle')
.data(
data.reduce(
(acc, a) => {
for (let i = 0; i < a.xs.length; i++) {
acc.push([a.xs[i], a.ys[i]]);
}
return acc;
},
[] as [number, number][]
)
)
.join('circle')
.attr('cx', (d) => x(xIsDates ? new Date(d[0]).getTime() : d[0]))
.attr('cy', (d) => y(d[1]))
.attr('fill', 'red')
.attr('r', '2px');*/
lines.append('g').attr('class', 'brush').call(brush);
let idleTimeout: number | null;
function idled() {
idleTimeout = null;
}
// A function that update the chart for given boundaries
function updateChart(e: any) {
// What are the selected boundaries?
const extent = e.selection;
// If no selection, back to initial coordinate. Otherwise, update X axis domain
if (!extent) {
if (!idleTimeout) {
idleTimeout = setTimeout(idled, 350) as unknown as number; // This allows to wait a little bit
return;
}
x.domain([xs[0], xs[xs.length - 1]]);
} else {
x.domain([x.invert(extent[0]), x.invert(extent[1])]);
lines.select('.brush').call(brush.move as any, null); // This remove the grey brush area as soon as the selection has been done
}
// Update axis and line position
xAxis
.transition()
.duration(1000)
.call(d3.axisBottom(x).ticks(10))
.selectAll('text')
.attr('transform', 'rotate(65)')
.attr('dy', '-0.4em')
.attr('dx', '2.9em');
lines
.selectAll<d3.BaseType, { value: number; x: number }[]>('path')
.transition()
.duration(1000)
.attr('d', (d) => {
const a = d3
.area<{
value: number;
x: number;
}>()
.x((d) => x(xIsDates ? new Date(d.x).getTime() : d.x))
.curve(d3.curveBumpX);
if (area) {
return a.y0(y(0)).y1((d) => y(d.value))(d as any);
}
return a.y((d) => y(d.value))(d as any);
});
}
svg.on('dblclick', () => {
x.domain([xs[0], xs[xs.length - 1]]);
xAxis
.transition()
.call(d3.axisBottom(x).ticks(10))
.selectAll('text')
.attr('transform', 'rotate(65)')
.attr('dy', '-0.4em')
.attr('dx', '2.9em');
lines
.selectAll('path')
.transition()
.attr('d', (d) => {
const a = d3
.area<{
x: number;
value: number;
}>()
.x((d) => x(xIsDates ? new Date(d.x).getTime() : d.x))
.curve(d3.curveBumpX);
if (area) {
return a.y0(y(0)).y1((d) => y(d.value))(d as any);
}
return a.y((d) => y(d.value))(d as any);
});
});
}
const xs = data.reduce((acc, d) => acc.concat(d.xs), [] as number[]);
if (xIsDates) {
xs.sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
gen(xs.map((a) => new Date(a)));
} else {
xs.sort((a, b) => a - b);
gen(xs);
}
});
</script>
<div>
<div class="p-2">
<div style="width: {size.width}px; height: {size.height}px" bind:this={divRef}></div>
</div>
<div class="flex flex-wrap" bind:clientHeight={optionsHeight}>
{#each data as a, i}
<div class="flex items-center p-2">
<button
type="button"
onclick={() => {
if (mask[a.name]) {
d3.select(divRef)
.selectAll(`path[lineId=l-${i}]`)
.transition()
.duration(1000)
.attr('transform', '');
} else {
const m = a.ys.reduce((acc, v) => Math.max(acc, v), 0);
const scale = yScaler ?? ((i) => i);
d3.select(divRef)
.selectAll(`path[lineId=l-${i}]`)
.transition()
.duration(1500)
.attr(
'transform',
`translate(0, ${inHeight - (scale(m) as number)})`
);
}
mask = { ...mask, [a.name]: !mask[a.name] };
}}
class="border-0 flex bg-transparent"
>
<div
style="background-color: {mask[a.name]
? 'gray'
: a.color ?? color(a.name)}; width: 20px; height: 20px;"
></div>
<div style="textDecoration: {mask[a.name] ? 'line-through' : ''}" class="px-2">
{a.name}
</div>
</button>
</div>
{/each}
</div>
</div>

View File

@ -0,0 +1,438 @@
<script lang="ts">
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
import * as d3 from 'd3';
import { goto } from '$app/navigation';
import type { LineGraphData } from './LineGraph.svelte';
import LineGraph from './LineGraph.svelte';
let payRangeDiv: HTMLDivElement | undefined = $state(undefined);
let sort: 'asc' | 'desc' = $state('desc');
let width = $state(300);
let job_level = $state('all');
const base_payrange = $derived(
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 };
})
);
const payrangeGraphs: LineGraphData = $derived.by(() => {
const pre_overtime = base_payrange.reduce(
(
acc: Record<number, [number, number]>,
a: Omit<Application, 'payrange'> & { payrange: number[] }
) => {
const c = new Date(a.create_time);
c.setHours(0);
c.setMinutes(0);
c.setSeconds(0);
c.setMilliseconds(0);
const ct = c.getTime();
if (acc[ct]) {
acc[ct] = [
acc[ct][0] + 1,
acc[ct][1] + a.payrange[0] + (a.payrange[1] ?? a.payrange[0])
];
} else {
acc[ct] = [1, a.payrange[0] + (a.payrange[1] ?? a.payrange[0])];
}
return acc;
},
{} as Record<number | string, [number, number]>
);
const keys = Object.keys(pre_overtime);
keys.sort((a, b) => Number(a) - Number(b));
return [
{
name: 'Payrange avg',
color: 'green',
xs: keys.map((a) => Number(a)),
ys: keys.map((a) => pre_overtime[a][1] / (pre_overtime[a][0] * 2))
}
];
});
const payranges = $derived.by(() => {
const obj = base_payrange.reduce(
(acc, a) => {
if (job_level !== 'all' && a.job_level !== job_level) {
return acc;
}
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;
});
});
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);
//
// Searching
//
$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;
});
</script>
<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 flex">
<input class="w-full z-20 flex-grow" bind:value={searchPayranges} placeholder="search" />
<select
class="finput flex-shrink"
style="width: unset;"
id="job_level"
bind:value={job_level}
>
<option value="all"> All </option>
<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>
</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 bind:clientWidth={width}>
<LineGraph data={payrangeGraphs} xIsDates width={width - 50} height={300} />
</div>

View File

@ -27,19 +27,27 @@
const sum = valueArray.reduce((acc, d) => acc + d, 0); const sum = valueArray.reduce((acc, d) => acc + d, 0);
let dataf = labelsArray let dataf = labelsArray.map((l) => ({
.map((l) => ({ name: l, value: data[l] })) name: l,
.filter((f) => f.value / sum > sensitivity); value: data[l],
title: `${l}: ${data[l]}`
}));
const otherSum = valueArray.reduce((acc, v) => { const other = dataf.filter((v) => v.value / sum < sensitivity);
if (v / sum > sensitivity) return acc;
return acc + v; dataf = dataf.filter((f) => f.value / sum > sensitivity);
const otherSum = other.reduce((acc, v) => {
return acc + v.value;
}, 0); }, 0);
if (otherSum > 0) { if (otherSum > 0) {
dataf.push({ dataf.push({
value: otherSum, value: otherSum,
name: 'Other' name: 'Other',
title: other
.toSorted((a, b) => b.value - a.value)
.reduce((acc, a) => `${acc}${a.name}: ${a.value}\n`, '')
}); });
} }
@ -54,11 +62,7 @@
const len = groups.length; const len = groups.length;
if (len === 0) { if (len === 0) {
// Do nothing // Do nothing
} else if (len === 1) { } else if (len >= 3 && len < 10) {
colors = ['#FFCCC9'];
} else if (len === 2) {
colors = ['#FFCCC9', '#41EAD4'];
} else if (len < 10) {
colors = d3.schemeBlues[len]; colors = d3.schemeBlues[len];
} else { } else {
colors = [...groups].map((_, i) => d3.interpolateBlues(i / len)); colors = [...groups].map((_, i) => d3.interpolateBlues(i / len));
@ -102,9 +106,7 @@
.attr('fill', (d, i) => color(`${names[d.data as number]}-${i}`)) .attr('fill', (d, i) => color(`${names[d.data as number]}-${i}`))
.attr('d', arc as unknown as number); .attr('d', arc as unknown as number);
svg_arcs svg_arcs.append('title').text((d) => dataf[d.data as number].title);
.append('title')
.text((d) => `${names[d.data as number]}: ${values[d.data as number]}`);
svg.append('g') svg.append('g')
.attr('font-family', 'sans-serif') .attr('font-family', 'sans-serif')

View File

@ -0,0 +1,489 @@
export type Sides = 'left' | 'right' | 'bot' | 'top';
export type AxisProps = {
title?: string;
titleFontSize?: number;
titlePos?: Sides;
};
export class Axis {
private _title?: string;
private _titleFontSize: number;
private _titlePos: Sides;
constructor({ title, titleFontSize, titlePos }: AxisProps = {}) {
this._title = title;
this._titleFontSize = titleFontSize ?? 15;
this._titlePos = titlePos ?? 'left';
}
getPadding() {
if (!this._title) return 0;
return this._titleFontSize + 4;
}
apply_title(
svg: d3.Selection<KnownAny, KnownAny, null, KnownAny>,
height: number,
width: number,
padding: number
) {
if (!this._title) return undefined;
if (this._titlePos === 'left') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(-90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', -height / 2)
.attr('y', -padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'right') {
return (
svg
.append('text')
.attr('text-anchor', 'middle')
.attr('transform', 'rotate(90)')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
// y becomes x
.attr('x', height / 2)
.attr('y', -width - padding + this._titleFontSize + 4)
.text(this._title)
);
}
if (this._titlePos === 'bot') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', height + padding - 4)
.text(this._title);
}
if (this._titlePos === 'top') {
return svg
.append('text')
.attr('text-anchor', 'middle')
.attr('font-size', `${this._titleFontSize}px`)
.attr('font-family', 'Open sans')
.attr('x', width / 2)
.attr('y', -padding + this._titleFontSize)
.text(this._title);
}
console.error('Unknown title pos', this.titlePos);
return undefined;
}
//
// Builder pattern functions
//
title(title?: string) {
this._title = title;
return this;
}
titlePos(pos: Sides) {
this._titlePos = pos;
return this;
}
titleFontSize(size: number) {
this._titleFontSize = size;
return this;
}
enforcePos(pos: 'vert' | 'hoz', defaultPos: Sides) {
if (pos === 'vert') {
if (this._titlePos !== 'top' && this._titlePos !== 'bot') {
return this.titlePos(defaultPos);
}
} else {
if (this._titlePos !== 'left' && this._titlePos !== 'right') {
return this.titlePos(defaultPos);
}
}
return this;
}
}
export type EnforceSizeType = [number, number];
export function enforceSizeHelper<
H extends Record<NamesH, number>,
NamesH extends string = ''
>(width: number, height: number, enforce: boolean): EnforceHelper<NamesH, H> {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
if (!ctx) throw new Error('Failed to get ctx');
const r = {
_width: width,
_height: height,
_enforce: enforce,
_w_p: 0,
_h_indicators: [] as Indicator<NamesH>[]
} as EnforceHelper<NamesH, H>;
r.h = (name, h, type, data, font) => {
const nr = r as EnforceHelper<
NamesH | typeof name,
Record<NamesH | typeof name, number>
>;
// TODO maybe update the values
if (nr[name]) {
return r;
}
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size =
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize
);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
if (size < 0) throw Error('h is negative');
nr._h_indicators.push({
name,
size,
type: type ?? 'fixed',
data,
font
});
(nr as KnownAny)[name] = size;
return nr as KnownAny;
};
r.w_p = (h) => {
let size = 0;
if (typeof h === 'string') {
if (h.endsWith('rem')) {
const rem = Number(h.substring(0, h.length - 3));
if (Number.isNaN(rem)) {
throw new Error('h is not a valid rem value');
}
size =
rem *
Number.parseFloat(
getComputedStyle(document.documentElement).fontSize
);
} else {
const n = Number(h);
if (Number.isNaN(n)) {
throw new Error('h is not a number');
}
size = n;
}
} else {
size = h;
}
r._w_p = size * 2;
return r;
};
r.enforce = (inEnforce) => {
const e = inEnforce ?? r._enforce;
if (!e) return r;
let h_sum = 0;
for (const i of r._h_indicators) {
h_sum += i.size;
}
// TODO handle width
if (h_sum < r._height && !r._h_indicators.some((i) => i.type === 'left'))
return r;
let fSize = r._h_indicators.reduce((acc, i) => {
if (i.type !== 'fixed') return acc;
return acc + i.size;
}, 0);
// you are fucked anyway
if (fSize > r._height) return r;
const h_leftover = h_sum - fSize;
const th_leftover = r._height - fSize;
const pr = r._h_indicators
.filter((i) => i.type === 'dyanmic' || i.type === 'text')
.map((i) => {
return [i, (i.size / h_leftover) * th_leftover] as const;
});
for (const i of pr) {
let s = i[1];
if (i[0].type === 'text') {
s = Math.floor(
getFitSizeForList(
[i[0].data ?? ''],
r._width - r._w_p,
i[1],
0.5,
ctx,
i[0].font
) ?? i[1]
);
}
fSize += s;
(r as KnownAny)[i[0].name] = s;
}
const left = r._h_indicators.filter((i) => i.type === 'left');
const len_left = left.length;
const rest = r._height - fSize;
for (const i of left) {
(r as KnownAny)[i.name] = rest / len_left;
}
return r as KnownAny;
};
r.toEnfoceSize = (name) => {
if (!r._enforce) return;
return [r._width - r._w_p, r[name]];
};
return r;
}
enforceSizeHelper.fromEnforceSize = (enforceSize?: EnforceSizeType) => {
return enforceSizeHelper(
enforceSize?.[0] ?? 0,
enforceSize?.[1] ?? 0,
!!enforceSize
);
};
export type PaddingManagerProps = {
width: number;
height: number;
fontSize?: number;
};
export type Paddable = number | Axis | string[] | string | undefined;
export class PaddingManager {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
width: number;
height: number;
private _paddingLeft = 0;
private _paddingRight = 0;
private _paddingTop = 0;
private _paddingBot = 0;
private _fontSize = 15;
private _enforceSize?: EnforceSizeType;
constructor({ width, height, fontSize }: PaddingManagerProps) {
this.width = width;
this.height = height;
if (fontSize !== undefined) {
this._fontSize = fontSize;
}
// This is used to calculate the size of text
this.canvas = document.createElement('canvas');
const ctx = this.canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create context for the internal canvas');
}
this.ctx = ctx;
this.ctx.font = `${this._fontSize}px Open sans`;
}
get paddedHeight() {
return this.height + this._paddingTop + this._paddingBot;
}
get paddedWidth() {
return this.width + this._paddingLeft + this._paddingRight;
}
set fontSize(size: number) {
this._fontSize = size;
this.ctx.font = `${this._fontSize}px Open sans`;
}
get fontSize() {
return this._fontSize;
}
get left() {
return this._paddingLeft;
}
get right() {
return this._paddingRight;
}
get top() {
return this._paddingTop;
}
get bot() {
return this._paddingBot;
}
get translateString() {
return `translate(${this.left},${this.top})`;
}
//
// Add padding
//
pad(side: Sides, padding: Paddable, angle?: number) {
let pn = 0;
if (padding === undefined) {
return this;
}
if (typeof padding === 'number') {
pn = padding;
} else if (typeof padding === 'string') {
let a: number | undefined = undefined;
if (angle !== undefined) {
a = angle * (Math.PI / 180);
}
pn = this.ctx.measureText(padding).width * Math.sin(a ?? Math.PI / 2);
} else if (Array.isArray(padding)) {
pn = padding.reduce(
(acc, s) => Math.max(this.ctx.measureText(s).width, acc),
0
);
} else {
pn = padding.getPadding();
}
switch (side) {
case 'left':
this._paddingLeft += pn;
return this;
case 'right':
this._paddingRight += pn;
return this;
case 'top':
this._paddingTop += pn;
return this;
case 'bot':
this._paddingBot += pn;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
padHoz(padding: Paddable) {
return this.pad('left', padding).pad('right', padding);
}
padLeft(padding: Paddable, angle?: number) {
return this.pad('left', padding, angle);
}
padRight(padding: Paddable, angle?: number) {
return this.pad('right', padding, angle);
}
padTop(padding: Paddable, angle?: number) {
return this.pad('top', padding, angle);
}
padBot(padding: Paddable, angle?: number) {
return this.pad('bot', padding, angle);
}
resetPadding(side: Sides) {
switch (side) {
case 'left':
this._paddingLeft = 0;
return this;
case 'right':
this._paddingRight = 0;
return this;
case 'top':
this._paddingTop = 0;
return this;
case 'bot':
this._paddingBot = 0;
return this;
default:
throw new Error(`unknown side: ${side}`);
}
}
padAll(n: number) {
this._paddingLeft += n;
this._paddingRight += n;
this._paddingTop += n;
this._paddingBot += n;
return this;
}
enforce(inEnforce?: EnforceSizeType) {
const enforce = this._enforceSize ?? inEnforce;
if (enforce === undefined) return this;
if (this.paddedWidth !== enforce[0]) {
this.width = enforce[0] - this.left - this.right;
}
if (this.paddedHeight !== enforce[1]) {
this.height = enforce[1] - this.top - this.bot;
}
return this;
}
}
export function countReducer(acc: Record<number, number>, a: { c: number | string | Date }) {
const c = new Date(a.c);
c.setHours(0);
c.setMinutes(0);
c.setSeconds(0);
c.setMilliseconds(0);
const ct = c.getTime();
if (acc[ct]) {
acc[ct] += 1;
} else {
acc[ct] = 1;
}
return acc;
}

View File

@ -9,11 +9,15 @@
applicationStore.all[index].company === '' applicationStore.all[index].company === ''
? [] ? []
: [...companies.values()].filter((a) => { : [...companies.values()].filter((a) => {
// TODO improve this a lot I want to make like matching algo try {
return ( // TODO improve this a lot I want to make like matching algo
a.match(applicationStore.all[index].company) && return (
a !== applicationStore.all[index].company a.match(applicationStore.all[index].company) &&
); a !== applicationStore.all[index].company
);
} catch {
return false;
}
}) })
); );
</script> </script>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte'; import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte';
import InplaceDialog from '$lib/InplaceDialog.svelte';
import { statusStore } from '$lib/Types.svelte'; import { statusStore } from '$lib/Types.svelte';
let { let {
@ -11,9 +12,27 @@
} = $props(); } = $props();
let filter = $state(''); let filter = $state('');
let filterStatus: string[] = $state([]);
let advFilters = $state(false);
type ExtraFilterType =
| {
type: 'name';
text: string;
}
| { type: 'company'; text: string }
| { type: 'query'; text: string };
let extraFiltersToDisplay: ExtraFilterType[] = $state([]);
let dialogElement: HTMLDialogElement; let dialogElement: HTMLDialogElement;
$effect(() => {
if (!filter) {
extraFiltersToDisplay = [];
}
});
function docKey(e: KeyboardEvent) { function docKey(e: KeyboardEvent) {
if (e.ctrlKey && e.code === 'KeyK') { if (e.ctrlKey && e.code === 'KeyK') {
dialogElement.showModal(); dialogElement.showModal();
@ -38,36 +57,135 @@
if (application && i.id == application.id) { if (application && i.id == application.id) {
return false; return false;
} }
if (filterStatus.length !== 0 && !filterStatus.includes(i.status_id ?? '')) {
return false;
}
if (!filter) { if (!filter) {
return true; return true;
} }
if (filter.includes('@') && i.company) { if (filter.includes('@') && i.company) {
const splits = filter.split('@'); const splits = filter.split('@');
const f = new RegExp(splits[0].trim(), 'ig');
const c = new RegExp(splits[1].trim(), 'ig'); const newExtraFilters: ExtraFilterType[] = [];
return i.title.match(f) && i.company.match(c); const name = splits[0].trim();
const company = splits[1].trim();
if (name.length !== 0) {
newExtraFilters.push({
type: 'name',
text: name
});
}
if (company.length !== 0) {
newExtraFilters.push({
type: 'company',
text: company
});
}
extraFiltersToDisplay = newExtraFilters;
try {
const f = new RegExp(name, 'ig');
const c = new RegExp(company, 'ig');
return i.title.match(f) && i.company.match(c);
} catch {
return false;
}
} }
const f = new RegExp(filter, 'ig'); extraFiltersToDisplay = [{ type: 'query', text: filter }];
try {
const f = new RegExp(filter, 'ig');
let x = i.title;
let x = i.title; if (i.company) {
x = `${x} @ ${i.company}`;
}
if (i.company) { return x.match(f);
x = `${x} @ ${i.company}`; } catch {
return false;
} }
return x.match(f);
}) })
); );
</script> </script>
<dialog class="card max-w-[50vw]" bind:this={dialogElement}> <dialog class="card max-w-[50vw]" bind:this={dialogElement}>
<div class="flex"> <div class="flex sticky top-0 bg-white z-50 p-2 shadow-lg rounded-lg gap-2 flex-col">
<input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} /> <div class="flex items-center gap-2">
<div> <input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} />
{internal.length} <button
onclick={() => {
advFilters = !advFilters;
}}
>
<span class="bi bi-filter"></span>
</button>
<div>
{internal.length}
</div>
</div> </div>
{#if advFilters}
<div>
<InplaceDialog
buttonClass="border-slate-300 border border-solid color-slate-300 p-1 rounded-md bg-slate-100/50"
>
{#snippet buttonChildren()}
<i class="bi bi-plus"></i> Status
{/snippet}
<div class="flex flex-wrap gap-2">
{#each statusStore.nodes.filter((a) => !filterStatus.includes(a.id)) as node}
<button
class="border-violet-300 border border-solid color-violet-300 bg-violet-100/50 p-1 rounded-md text-violet-800"
onclick={() => {
//filterStatus.push(node.id);
//filterStatus = filterStatus;
filterStatus = [...filterStatus, node.id];
}}
>
{node.name}
</button>
{/each}
</div>
</InplaceDialog>
</div>
<h2>Filters</h2>
<div class="flex gap-2">
{#each statusStore.nodes.filter((a) => filterStatus.includes(a.id)) as node}
<button
class="border-violet-300 border border-solid color-violet-300 bg-violet-100/50 text-violet-800 p-1 rounded-md"
onclick={() => {
filterStatus = filterStatus.filter((a) => a != node.id);
}}
>
{node.name}
</button>
{/each}
{#each extraFiltersToDisplay as filter}
{#if filter.type === 'name'}
<span
class="border-blue-300 border border-solid color-blue-300 bg-blue-100/50 p-1 rounded-md"
>
Name ~ /<span class="text-blue-800 text-bold">{filter.text}</span>/
</span>
{:else if filter.type === 'company'}
<span
class="border-green-300 border border-solid color-green-300 bg-green-100/50 p-1 rounded-md"
>
Company ~ /<span class="text-green-800 text-bold">{filter.text}</span>/
</span>
{:else if filter.type === 'query'}
<span
class="border-orange-300 border border-solid color-orange-300 bg-orange-100/50 p-1 rounded-md"
>
Query ~ /<span class="text-orange-800 text-bold">{filter.text}</span>/
</span>
{/if}
{/each}
</div>
{/if}
</div> </div>
<div class="overflow-y-auto overflow-x-hidden flex-grow p-2"> <div class="overflow-y-auto overflow-x-hidden flex-grow p-2">
<!-- TODO loading screen --> <!-- TODO loading screen -->

View File

@ -112,11 +112,9 @@
<!-- TODO --> <!-- TODO -->
<!-- || !endable.includes(event.new_status) --> <!-- || !endable.includes(event.new_status) -->
{#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable} {#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable}
<div <div class="h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center">
class="min-w-[70px] h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center"
>
{#if event.timeDiff} {#if event.timeDiff}
<div class="-mt-[3px] text-white"> <div class="-mt-[3px] text-white text-nowrap whitespace-nowrap">
<span class="bi bi-clock"></span> <span class="bi bi-clock"></span>
{event.timeDiff} {event.timeDiff}
</div> </div>

View File

@ -125,7 +125,16 @@
if (!lastExtData || activeItem === undefined || !derivedItem) return; if (!lastExtData || activeItem === undefined || !derivedItem) return;
applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&amp;/, '&'); applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&amp;/, '&');
applicationStore.all[activeItem].company = lastExtData.company.replace(/\&amp;/, '&'); applicationStore.all[activeItem].company = lastExtData.company.replace(/\&amp;/, '&');
applicationStore.all[activeItem].payrange = lastExtData.money;
if (
!(
applicationStore.all[activeItem].payrange.match('Glassdoor est.') &&
lastExtData.money.match('Glassdoor est.')
) &&
!(applicationStore.all[activeItem].payrange !== '' && lastExtData.money === '')
) {
applicationStore.all[activeItem].payrange = lastExtData.money;
}
const title: string = lastExtData.jobTitle; const title: string = lastExtData.jobTitle;
if (title.match(/intern|apprenticeship/i)) { if (title.match(/intern|apprenticeship/i)) {
@ -291,6 +300,23 @@
</div> </div>
</fieldset> </fieldset>
{/if} {/if}
{#if showExtraData}
<div>
<div class="flabel">Simple Url</div>
<div class="flex flex-col gap-2">
{#each derivedItem.urls as url}
<div>
<button
class="text-violet-300 text-nowrap whitespace-nowrap overflow-x-hidden"
onclick={() => {
openWindow(url);
}}>{url}</button
>
</div>
{/each}
</div>
</div>
{/if}
<fieldset> <fieldset>
<label class="flabel" for="title">Job Level</label> <label class="flabel" for="title">Job Level</label>
<select <select