From 1b6a887648f4db4364e3cba3ddb191d89dc9cbc5 Mon Sep 17 00:00:00 2001 From: Andre Henriques Date: Sun, 30 Mar 2025 09:08:11 +0100 Subject: [PATCH] feat: more graphs --- api/build.gradle.kts | 6 + .../applications/ApplicationsController.kt | 23 +- .../com/andr3h3nriqu3s/applications/Events.kt | 62 +- .../com/andr3h3nriqu3s/applications/Mail.kt | 17 +- site/src/lib/ApplicationsStore.svelte.ts | 1 + site/src/lib/InplaceDialog.svelte | 45 ++ site/src/routes/+page.svelte | 2 +- site/src/routes/ApplicationsList.svelte | 2 +- site/src/routes/cv/+page.svelte | 288 +++++--- site/src/routes/graphs/+page.svelte | 630 +++++++----------- site/src/routes/graphs/LineGraph.svelte | 422 ++++++++++++ site/src/routes/graphs/PayRange.svelte | 438 ++++++++++++ site/src/routes/graphs/Pie.svelte | 32 +- site/src/routes/graphs/utils.ts | 489 ++++++++++++++ site/src/routes/work-area/CompanyField.svelte | 14 +- .../routes/work-area/SearchApplication.svelte | 144 +++- site/src/routes/work-area/Timeline.svelte | 6 +- site/src/routes/work-area/WorkArea.svelte | 28 +- 18 files changed, 2120 insertions(+), 529 deletions(-) create mode 100644 site/src/lib/InplaceDialog.svelte create mode 100644 site/src/routes/graphs/LineGraph.svelte create mode 100644 site/src/routes/graphs/PayRange.svelte create mode 100644 site/src/routes/graphs/utils.ts 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, var events: List, + var urls: List, ) { companion object : RowMapper { 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 { + 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 { + 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 = - 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 = + 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() .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 @@ + + +
+ + {#if open} +
+ {@render children()} +
+ {/if} +
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 @@
-
+
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 @@ }); -
+

To Apply

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 @@ @@ -86,8 +166,8 @@ {/if} -
-
+
+

Andre Henriques

@@ -136,7 +216,7 @@ {#if application} {#if !application.agency} -

+

πŸ‘‹ Hello {#if application.recruiter} {application.recruiter} @ @@ -151,19 +231,124 @@ {#if application.message}
-

A small message from me

+

Why your role interests me

{@html application.message.split('\n').join('
')}
{/if} + {:else} +
+ {/if} + +
+

+ Work Expericence +

+
+ +
+
+

Senior Software Engineer @ Planum Solucoes

+

4 years - May 2020 - Present

+
+ My role is mainly focused on frontend development, while also having a supporting + role with the API and DevOps teams. +
    +
  • + 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. +
  • +
  • + Stack: Frontend (React, Typescript, Tailwind); Backend (Java, Spring + boot, Kafka); Infrastructure (Docker, AWS, Nginx). +
  • +
  • + Championed and Led a move for the use of CI/CD pipelines for deployment + using Docker on AWS, and started to introduce better testing. +
  • +
  • + Support any other team with any problem that might arise, for example + deploying a Kafka cluster or modifying a python CLI tool. +
  • +
+
+
+
+ +
+ +
+
+
+
+

Associate DevOps Engineer @ Sky

+

1 year: July 2022–June 2023

+
+ 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. +
    +
  • + Maintained and updated and webapp that is used daily to monitor + various systems that the team looks after. +
  • +
  • + Created new backup, and rapid deployment solutions for Jenkins + servers. +
  • +
  • + Fixed, updated, and created new pipelines that were able to erase + terabytes of redundant data, improving performance of other + pipelines and the backup system. +
  • +
+
+
+
+
+
+ +
+ +
+ +
+

+ Education +

+
+
+
+
+

BCompSc with First Class Honours @ University of Surrey

+
+

July 2020 - June 2024

+
+
+
+
+ +
+ +
+ + {#if application} {#if application.flairs.length > 0} -
-

+
+

Skills {#if flairs.length > 0} {/if} -

+

+
+
{#if otherSearch === ''} {#each application.flairs as flair} @@ -246,77 +433,18 @@ {/if} {/if} -
-

- Work Expericence -

-
- -
-
-

Senior Software Developer @ Planum Solucoes

-

4 years - May 2020 - Present

-
-

Developed various projects:

-
    -
  • Developing various websites using React and Svelte.
  • -
  • Interacting with a RESTful API
  • -
  • Implemented an ORM system using Java Reflection
  • -
  • Implemented automatic deployment with GitLab CI/CD tools.
  • -
  • Linux Server Administration
  • -
-
-
-
- -
- -
-
-
-
-

Associate Devops Engineer @ Sky UK

-

1 year - July 2022 - June 2023

-
-
    -
  • Developed web-based tools for the DevOps team to use
  • -
  • - Updated Jenkins pipelines that the team uses to manage one of the - most important pipelines that the team manages. -
  • -
  • - 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 -
  • -
-
-
-
-
-
- -
-

- Education -

-
-
-
-
-

BCompSc with First Class Honours @ University of Surrey

-
-

July 2020 - June 2024

-
-
-
-
- -
+
+ + 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 @@ @@ -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 )} /> + { + if (acc[item.company]) { + acc[item.company] += 1; + } else { + acc[item.company] = 1; + } + return acc; + }, + {} as Record + )} + />
{#if flairStats !== 'loading'}
@@ -301,253 +241,143 @@ />
{/if} -

- Pay range - - - -

-
-
- -
-
- {#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} -
(open = company)} - onkeydown={() => (open = company)} - bind:this={indexPayRanges[index]} - tabindex={1} - > -
-
-
- {#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40} -
- {nameCompany} -
- {:else} -
- {nameCompany} -
- {/if} -
- {:else} -
-

- {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' - })}) -

- {#each values as app} -
{ - applicationStore.loadItem = app as any; - goto('/'); - }} - onkeydown={() => { - applicationStore.loadItem = app as any; - }} - > - {#if app.payrange[1]} -
- {/if} -
- {#if app.payrange[1]} -
- {/if} - {#if context.measureText(app.title).width < scale(app.payrange[1]) - scale(app.payrange[0]) - 40} -
- {app.title} -
- {:else} -
- {app.title} -
- {/if} -
- {/each} -
- {/if} - {/each} - {/if} -
+
+ +
+
+

Status Graph

+ +
+

Payrange

+ +
+

Per Seniority

+ {#each seniorities as level} + {@const fapps = applicationStore.all.filter( + (a) => a.payrange.match(/\d/) && a.job_level === level + )} +

+ {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' + })}) +

+
+ { + 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 + )} + /> + { + 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 + )} + /> + { + 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 + )} + /> +
+ {/each}
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 @@ + + +
+
+
+
+
+ {#each data as a, i} +
+ +
+ {/each} +
+
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 @@ + + +

+ Pay range + + + +

+
+
+ + +
+
+ {#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} +
(open = company)} + onkeydown={() => (open = company)} + bind:this={indexPayRanges[index]} + tabindex={1} + > +
+
+
+ {#if context.measureText(nameCompany).width < scale(ranges[1]) - scale(ranges[0]) - 40} +
+ {nameCompany} +
+ {:else} +
+ {nameCompany} +
+ {/if} +
+ {:else} +
+

+ {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' + })}) +

+ {#each values as app} +
{ + applicationStore.loadItem = app as any; + goto('/'); + }} + onkeydown={() => { + applicationStore.loadItem = app as any; + }} + > + {#if app.payrange[1]} +
+ {/if} +
+ {#if app.payrange[1]} +
+ {/if} + {#if context.measureText(app.title).width < scale(app.payrange[1]) - scale(app.payrange[0]) - 40} +
+ {app.title} +
+ {:else} +
+ {app.title} +
+ {/if} +
+ {/each} +
+ {/if} + {/each} + {/if} +
+
+
+ +
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, + 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 extends string = '' +>(width: number, height: number, enforce: boolean): EnforceHelper { + 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[] + } as EnforceHelper; + + r.h = (name, h, type, data, font) => { + const nr = r as EnforceHelper< + NamesH | typeof name, + Record + >; + + // 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, 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; + } }) ); 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 @@ -
- -
- {internal.length} +
+
+ + +
+ {internal.length} +
+ {#if advFilters} +
+ + {#snippet buttonChildren()} + Status + {/snippet} +
+ {#each statusStore.nodes.filter((a) => !filterStatus.includes(a.id)) as node} + + {/each} +
+
+
+

Filters

+
+ {#each statusStore.nodes.filter((a) => filterStatus.includes(a.id)) as node} + + {/each} + {#each extraFiltersToDisplay as filter} + {#if filter.type === 'name'} + + Name ~ /{filter.text}/ + + {:else if filter.type === 'company'} + + Company ~ /{filter.text}/ + + {:else if filter.type === 'query'} + + Query ~ /{filter.text}/ + + {/if} + {/each} +
+ {/if}
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 @@ {#if i != events.length - 1 || !statusStore.nodesR[event.new_status_id].endable} -
+
{#if event.timeDiff} -
+
{event.timeDiff}
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 @@
{/if} + {#if showExtraData} +
+
Simple Url
+
+ {#each derivedItem.urls as url} +
+ +
+ {/each} +
+
+ {/if}