diff --git a/api/build.gradle.kts b/api/build.gradle.kts index dbf1fcc..e4a916a 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -26,13 +26,19 @@ dependencies { implementation("org.hibernate.orm:hibernate-community-dialects") implementation("org.json:json:20250107") implementation("org.bouncycastle:bcprov-jdk18on:1.76") + implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") 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") } diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt index ac59d5f..559f5b9 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/ApplicationsController.kt @@ -1,5 +1,7 @@ package com.andr3h3nriqu3s.applications +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.trace.SpanKind import java.sql.ResultSet import java.util.UUID import kotlin.collections.emptyList @@ -52,6 +54,7 @@ data class Application( var job_level: String, var flairs: List<Flair>, var events: List<Event>, + var urls: List<String>, ) { companion object : RowMapper<Application> { override public fun mapRow(rs: ResultSet, rowNum: Int): Application { @@ -72,6 +75,7 @@ data class Application( rs.getString("job_level"), emptyList(), emptyList(), + emptyList(), ) } } @@ -101,6 +105,7 @@ class ApplicationsController( val applicationService: ApplicationService, val flairService: FlairService, val eventService: EventService, + val openTelemetry: OpenTelemetry ) { @GetMapping(path = ["/cv/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) @@ -152,6 +157,7 @@ class ApplicationsController( "", emptyList(), emptyList(), + emptyList(), ) if (!applicationService.createApplication(user, application)) { @@ -169,6 +175,8 @@ class ApplicationsController( @RequestBody info: FlairRequest, @RequestHeader("token") token: String ): Int { + val trace = openTelemetry.getTracer("applications") + val user = sessionService.verifyTokenThrow(token) val application = applicationService.findApplicationById(user, info.id) if (application == null) { @@ -179,6 +187,7 @@ class ApplicationsController( var count = 0 + val span = trace.spanBuilder("parse-doc").setSpanKind(SpanKind.INTERNAL).startSpan() for (flair: Flair in flairs) { val regex = Regex( @@ -191,6 +200,7 @@ class ApplicationsController( flairService.linkFlair(application, flair) } } + span.end() return count } @@ -391,7 +401,7 @@ class ApplicationService( application.flairs = flairService.listFromLinkApplicationId(application.id) application.events = eventService.listFromApplicationId(application.id).toList() - + application.urls = this.getUrls(application) return application } @@ -503,6 +513,7 @@ class ApplicationService( "", 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) { db.update( "delete from applications where id=?", diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Events.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Events.kt index 4e0c704..172cbba 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Events.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Events.kt @@ -1,19 +1,21 @@ package com.andr3h3nriqu3s.applications +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include import java.sql.ResultSet import java.sql.Timestamp import java.util.Date import java.util.UUID +import org.springframework.http.MediaType import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.RowMapper 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.RequestMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable 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) { Creation(0), @@ -21,6 +23,15 @@ enum class EventType(val value: Int) { 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( var 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 @@ -60,7 +90,14 @@ class EventController( 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 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? { 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() 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( "insert into events (id, application_id, event_type, new_status_id) values (?, ?, ? ,?)", diff --git a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt index edd4e2d..1032a05 100644 --- a/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt +++ b/api/src/main/kotlin/com/andr3h3nriqu3s/applications/Mail.kt @@ -1,5 +1,6 @@ package com.andr3h3nriqu3s.applications +import io.opentelemetry.api.OpenTelemetry import java.time.LocalDate import kotlin.io.println import org.json.JSONArray @@ -26,6 +27,7 @@ class MailController( val userService: UserService, val applicationService: ApplicationService, val sessionService: SessionService, + val openTelemetry: OpenTelemetry, ) { @Value("\${mail.token.api}") private val ApiToken: String? = null @@ -46,6 +48,8 @@ class MailController( if (TargetUserUUID == null || TargetUserUUID == "") { error("Target User not defined") } + val tracer = openTelemetry.getTracer("applications") + if (user == null) { user = userService.findUserById(TargetUserUUID) if (user == null) { @@ -53,6 +57,7 @@ class MailController( error("User ${TargetUserUUID} not found") } } + if (client == null) { val size = 16 * 1024 * 1024 val strategies = @@ -101,6 +106,7 @@ class MailController( println("mailbox got ${mailbox}") } + val listSpan = tracer.spanBuilder("list email").startSpan() println("\n\nGet Email @ ${LocalDate.now()}\n\n") val email_get_obj = JMapQueryBuilder("Email/query", "list_emails", account!!) @@ -171,10 +177,15 @@ class MailController( JSONArray) .getJSONObject(1) - if (email_r.getJSONArray("list").length() == 0) { + val listSize: Int = email_r.getJSONArray("list").length() + if (listSize == 0) { println("No emails found") + listSpan.setAttribute("emailCount", 0) + listSpan.end() return arrayListOf() } + listSpan.setAttribute("emailCount", listSize.toLong()) + listSpan.end() 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 setSpan = tracer.spanBuilder("set emails as read").startSpan() val update_email = JMapQueryBuilder("Email/set", "set", account!!) .updateNewItem(email_id) @@ -217,6 +229,7 @@ class MailController( .retrieve() .bodyToMono<String>() .block()!! + setSpan.end(); return applications } @@ -341,7 +354,7 @@ class JMapQueryBuilder { if (this.sorts == null) { this.sorts = JSONArray() } - var obj = JSONObject() + val obj = JSONObject() obj.put("property", key) obj.put("isAscending", isAscending) this.sorts?.put(obj) diff --git a/site/src/lib/ApplicationsStore.svelte.ts b/site/src/lib/ApplicationsStore.svelte.ts index d17f3fa..4a43f2b 100644 --- a/site/src/lib/ApplicationsStore.svelte.ts +++ b/site/src/lib/ApplicationsStore.svelte.ts @@ -36,6 +36,7 @@ export type Application = { job_level: string; flairs: Flair[]; events: ApplicationEvent[]; + urls: string[]; }; function createApplicationStore() { diff --git a/site/src/lib/InplaceDialog.svelte b/site/src/lib/InplaceDialog.svelte new file mode 100644 index 0000000..b76c280 --- /dev/null +++ b/site/src/lib/InplaceDialog.svelte @@ -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> diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 1e9d1ae..380b0a9 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -10,7 +10,7 @@ <div class="flex flex-col h-[100vh]"> <NavBar /> <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 /> <WorkArea /> </div> diff --git a/site/src/routes/ApplicationsList.svelte b/site/src/routes/ApplicationsList.svelte index a5278ad..5270818 100644 --- a/site/src/routes/ApplicationsList.svelte +++ b/site/src/routes/ApplicationsList.svelte @@ -66,7 +66,7 @@ }); </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> <div class="flex pb-2 items-center"> <input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} /> diff --git a/site/src/routes/cv/+page.svelte b/site/src/routes/cv/+page.svelte index 8385e0c..57ce131 100644 --- a/site/src/routes/cv/+page.svelte +++ b/site/src/routes/cv/+page.svelte @@ -1,16 +1,14 @@ <script lang="ts"> import { userStore } from '$lib/UserStore.svelte'; import { get } from '$lib/utils'; - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import Pill from './pill.svelte'; let id: string | undefined | null = $state(undefined); onMount(() => { const url = new URLSearchParams(window.location.search); - id = url.get('id'); - loadData(); }); @@ -57,7 +55,7 @@ return b.sort - a.sort; }); - loadFlairs(); + await loadFlairs(); } catch (e) { console.log('TODO show this to the user', e); } @@ -74,6 +72,88 @@ 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> <svelte:head> @@ -86,8 +166,8 @@ {/if} </svelte:head> -<div class="flex items-center w-full flex-col"> - <div class="py-5 pb-0 w-[190mm] print:py-0 print:pt-4"> +<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" data-useheight="228"> <div class="bg-white rounded-lg p-3"> <div class="w-full flex"> <h1 class="text-black text-5xl">Andre Henriques</h1> @@ -136,7 +216,7 @@ {#if application} {#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 {#if application.recruiter} <span class="font-bold">{application.recruiter}</span> @ @@ -151,19 +231,124 @@ {#if application.message} <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"> {@html application.message.split('\n').join('<br>')} </div> </div> <div class="p-1"></div> {/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 2022βJune 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} - <div class="p-3 bg-white w-[190mm] rounded-lg"> - <h1 class="flex gap-5 items-end"> + <div class="w-[190mm]"> + <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 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} /> <span class="hidden print:inline text-slate-600 text-sm" @@ -172,7 +357,9 @@ ></span > {/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"> {#if otherSearch === ''} {#each application.flairs as flair} @@ -246,77 +433,18 @@ {/if} {/if} - <div class="w-[190mm]"> - <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-4 print:hidden"></div> <!--div class="p-5"></div> <div>TODO: Previous projetcs</div --> <!-- div class="p-5"></div> <div>TODO: Info form</div --> </div> + +<style> + @media print { + .autoPadding { + height: var(--auto-size); + } + } +</style> diff --git a/site/src/routes/graphs/+page.svelte b/site/src/routes/graphs/+page.svelte index 50be056..4b81ee5 100644 --- a/site/src/routes/graphs/+page.svelte +++ b/site/src/routes/graphs/+page.svelte @@ -1,172 +1,97 @@ <script lang="ts"> - import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte'; + import { applicationStore } 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'; + import PayRange from './PayRange.svelte'; + import LineGraphs, { type LineGraphData } from './LineGraph.svelte'; + import * as d3 from 'd3'; + import { countReducer } from './utils'; 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'); + 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 () => { - const items: any[] = await get('flair/stats'); - flairStats = items.reduce( - (acc, a) => { - acc[a.name] = a.count; - return acc; - }, - {} as Record<string, number> - ); + (async () => { + const items: any[] = await get('flair/stats'); + flairStats = items.reduce( + (acc, a) => { + acc[a.name] = a.count; + return acc; + }, + {} 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> <HasUser redirect="/cv"> @@ -253,7 +178,7 @@ .replace(/[^\d\-β]/g, '') .replace(/β/g, '-') .split('-'); - return Number(payrange[0]); + return Number(payrange[0]) + Number(payrange[1] ?? payrange[0]); }) .reduce( (acc, a) => { @@ -272,6 +197,21 @@ {} 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> {#if flairStats !== 'loading'} <div class="flex gap-5"> @@ -301,253 +241,143 @@ /> </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 bind:clientWidth={width}> + <LineGraphs + data={[ + { name: 'Created Time', color: 'red', ...createGraph }, + { name: 'Views', color: 'blue', ...viewGraph } + ]} + xIsDates + width={width - 50} + height={300} + /> + </div> + <div bind:clientWidth={width}> + <h1>Status Graph</h1> + <LineGraphs data={statusGraph} xIsDates width={width - 50} height={300} /> + </div> + <h1>Payrange</h1> + <PayRange /> + <div> + <h1>Per Seniority</h1> + {#each seniorities as level} + {@const fapps = applicationStore.all.filter( + (a) => a.payrange.match(/\d/) && a.job_level === level + )} + <h2 class="font-bold text-lg"> + {level} (AVG pay: {( + fapps + .map((a) => { + const payrange = a.payrange + .replace(/[kK]/g, '000') + .replace(/[^\d\-β]/g, '') + .replace(/β/g, '-') + .split('-'); + return ( + Number(payrange[0]) + Number(payrange[1] ?? payrange[0]) + ); + }) + .reduce((acc, a) => acc + a, 0) / + (fapps.length * 2) + ).toLocaleString('en-GB', { + notation: 'compact', + style: 'currency', + currency: 'GBP' + })}) + </h2> + <div class="flex gap-2"> + <Pie + title={'Higher range Pay Range'} + data={fapps + .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={fapps + .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> + )} + /> + <Pie + title={'AVG Pay'} + data={fapps + .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]) + + Number(payrange[1] ?? payrange[0])) / + 2 + ); + }) + .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> + {/each} </div> </div> </div> diff --git a/site/src/routes/graphs/LineGraph.svelte b/site/src/routes/graphs/LineGraph.svelte new file mode 100644 index 0000000..2f66778 --- /dev/null +++ b/site/src/routes/graphs/LineGraph.svelte @@ -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> diff --git a/site/src/routes/graphs/PayRange.svelte b/site/src/routes/graphs/PayRange.svelte new file mode 100644 index 0000000..721e7c7 --- /dev/null +++ b/site/src/routes/graphs/PayRange.svelte @@ -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> diff --git a/site/src/routes/graphs/Pie.svelte b/site/src/routes/graphs/Pie.svelte index 85754f3..69e76a6 100644 --- a/site/src/routes/graphs/Pie.svelte +++ b/site/src/routes/graphs/Pie.svelte @@ -27,19 +27,27 @@ 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); + let dataf = labelsArray.map((l) => ({ + name: l, + value: data[l], + title: `${l}: ${data[l]}` + })); - const otherSum = valueArray.reduce((acc, v) => { - if (v / sum > sensitivity) return acc; - return acc + v; + const other = dataf.filter((v) => v.value / sum < sensitivity); + + dataf = dataf.filter((f) => f.value / sum > sensitivity); + + const otherSum = other.reduce((acc, v) => { + return acc + v.value; }, 0); if (otherSum > 0) { dataf.push({ 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; if (len === 0) { // Do nothing - } else if (len === 1) { - colors = ['#FFCCC9']; - } else if (len === 2) { - colors = ['#FFCCC9', '#41EAD4']; - } else if (len < 10) { + } else if (len >= 3 && len < 10) { colors = d3.schemeBlues[len]; } else { colors = [...groups].map((_, i) => d3.interpolateBlues(i / len)); @@ -102,9 +106,7 @@ .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_arcs.append('title').text((d) => dataf[d.data as number].title); svg.append('g') .attr('font-family', 'sans-serif') diff --git a/site/src/routes/graphs/utils.ts b/site/src/routes/graphs/utils.ts new file mode 100644 index 0000000..3aa3667 --- /dev/null +++ b/site/src/routes/graphs/utils.ts @@ -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; +} + diff --git a/site/src/routes/work-area/CompanyField.svelte b/site/src/routes/work-area/CompanyField.svelte index 51b6cb2..20ccab9 100644 --- a/site/src/routes/work-area/CompanyField.svelte +++ b/site/src/routes/work-area/CompanyField.svelte @@ -9,11 +9,15 @@ applicationStore.all[index].company === '' ? [] : [...companies.values()].filter((a) => { - // TODO improve this a lot I want to make like matching algo - return ( - a.match(applicationStore.all[index].company) && - a !== applicationStore.all[index].company - ); + try { + // TODO improve this a lot I want to make like matching algo + return ( + a.match(applicationStore.all[index].company) && + a !== applicationStore.all[index].company + ); + } catch { + return false; + } }) ); </script> diff --git a/site/src/routes/work-area/SearchApplication.svelte b/site/src/routes/work-area/SearchApplication.svelte index 4a165e1..b848c4b 100644 --- a/site/src/routes/work-area/SearchApplication.svelte +++ b/site/src/routes/work-area/SearchApplication.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { applicationStore, type Application } from '$lib/ApplicationsStore.svelte'; + import InplaceDialog from '$lib/InplaceDialog.svelte'; import { statusStore } from '$lib/Types.svelte'; let { @@ -11,9 +12,27 @@ } = $props(); 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; + $effect(() => { + if (!filter) { + extraFiltersToDisplay = []; + } + }); + function docKey(e: KeyboardEvent) { if (e.ctrlKey && e.code === 'KeyK') { dialogElement.showModal(); @@ -38,36 +57,135 @@ if (application && i.id == application.id) { return false; } + + if (filterStatus.length !== 0 && !filterStatus.includes(i.status_id ?? '')) { + return false; + } + if (!filter) { return true; } if (filter.includes('@') && i.company) { const splits = filter.split('@'); - const f = new RegExp(splits[0].trim(), 'ig'); - const c = new RegExp(splits[1].trim(), 'ig'); - return i.title.match(f) && i.company.match(c); + + const newExtraFilters: ExtraFilterType[] = []; + 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) { - x = `${x} @ ${i.company}`; + return x.match(f); + } catch { + return false; } - - return x.match(f); }) ); </script> <dialog class="card max-w-[50vw]" bind:this={dialogElement}> - <div class="flex"> - <input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} /> - <div> - {internal.length} + <div class="flex sticky top-0 bg-white z-50 p-2 shadow-lg rounded-lg gap-2 flex-col"> + <div class="flex items-center gap-2"> + <input placeholder="Filter" class="p-2 flex-grow" bind:value={filter} /> + <button + onclick={() => { + advFilters = !advFilters; + }} + > + <span class="bi bi-filter"></span> + </button> + <div> + {internal.length} + </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 class="overflow-y-auto overflow-x-hidden flex-grow p-2"> <!-- TODO loading screen --> diff --git a/site/src/routes/work-area/Timeline.svelte b/site/src/routes/work-area/Timeline.svelte index 2aa9346..713cde9 100644 --- a/site/src/routes/work-area/Timeline.svelte +++ b/site/src/routes/work-area/Timeline.svelte @@ -112,11 +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="h-[18px] bg-blue-500 -mx-[10px] px-[20px] flex-grow text-center"> {#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> {event.timeDiff} </div> diff --git a/site/src/routes/work-area/WorkArea.svelte b/site/src/routes/work-area/WorkArea.svelte index d418eda..a8ae0ad 100644 --- a/site/src/routes/work-area/WorkArea.svelte +++ b/site/src/routes/work-area/WorkArea.svelte @@ -125,7 +125,16 @@ if (!lastExtData || activeItem === undefined || !derivedItem) return; applicationStore.all[activeItem].title = lastExtData.jobTitle.replace(/\&/, '&'); applicationStore.all[activeItem].company = lastExtData.company.replace(/\&/, '&'); - 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; if (title.match(/intern|apprenticeship/i)) { @@ -291,6 +300,23 @@ </div> </fieldset> {/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> <label class="flabel" for="title">Job Level</label> <select