|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { DevListForStack, Tech } from "@/graphql/queries"; |
| 3 | +import { useQueryStore } from "@/stores/QueryStore"; |
| 4 | +import { computed } from "vue"; |
| 5 | +
|
| 6 | +const store = useQueryStore(); |
| 7 | +
|
| 8 | +const props = defineProps<{ |
| 9 | + /** Expects _source property from the ElasticSearch GQL response for this dev */ |
| 10 | + devDetails: DevListForStack; |
| 11 | +}>(); |
| 12 | +
|
| 13 | +/** The name is optional and may be completely absent. Built it out of what we have. */ |
| 14 | +const devName = computed(() => { |
| 15 | + if (!props.devDetails) return "devDetails not initialized yet"; |
| 16 | + if (props.devDetails.name) return props.devDetails.name; |
| 17 | + if (props.devDetails.login) return props.devDetails.login; |
| 18 | +
|
| 19 | + return "Anonymous Software Engineer"; |
| 20 | +}); |
| 21 | +
|
| 22 | +/** DevID can either be a GH login or an internal STM owner ID. */ |
| 23 | +const devId = computed(() => { |
| 24 | + if (!props.devDetails) return "devDetails not initialized yet"; |
| 25 | +
|
| 26 | + if (props.devDetails.login) { |
| 27 | + return `/${props.devDetails.login}`; |
| 28 | + } else { |
| 29 | + return `/?dev=${props.devDetails.ownerId}`; |
| 30 | + } |
| 31 | +}); |
| 32 | +
|
| 33 | +/** Returns TRUE if public contact details are available */ |
| 34 | +const hasPublicContactDetails = computed( |
| 35 | + () => |
| 36 | + props.devDetails && |
| 37 | + props.devDetails.login && |
| 38 | + (props.devDetails.blog || props.devDetails.email) |
| 39 | +); |
| 40 | +
|
| 41 | +/** Only the year component of the current date. */ |
| 42 | +const yearNow = new Date().getFullYear(); |
| 43 | +
|
| 44 | +/** Returns a phrase like `10 projects over 5 years` or an empty string if not enough data */ |
| 45 | +const projectsOverYears = computed(() => { |
| 46 | + // try to get the number of projects - is there enough data? |
| 47 | + if (!props.devDetails?.report?.projectsIncluded) return ""; |
| 48 | + const projectCount = props.devDetails.report.projectsIncluded.length; |
| 49 | + if (projectCount == 0) return ""; |
| 50 | +
|
| 51 | + // get the year of the first commit |
| 52 | + // prefer first_contributor_commit_date_iso, then date_init, then now() |
| 53 | + // check if the data is present and is not in the future |
| 54 | + const firstContributorCommitDateIso = props.devDetails.report |
| 55 | + .firstContributorCommitDateIso |
| 56 | + ? Number.parseInt( |
| 57 | + props.devDetails.report.firstContributorCommitDateIso.substring(0, 4) |
| 58 | + ) |
| 59 | + : 0; |
| 60 | +
|
| 61 | + const dateInit = props.devDetails.report.dateInit |
| 62 | + ? Number.parseInt(props.devDetails.report.dateInit.substring(0, 4)) |
| 63 | + : 0; |
| 64 | +
|
| 65 | + const firstCommitYear = |
| 66 | + firstContributorCommitDateIso > 0 && |
| 67 | + firstContributorCommitDateIso <= yearNow |
| 68 | + ? firstContributorCommitDateIso |
| 69 | + : dateInit > 0 && dateInit <= yearNow |
| 70 | + ? dateInit |
| 71 | + : yearNow; |
| 72 | +
|
| 73 | + // calculate the total number of years of experience |
| 74 | + const years = yearNow - firstCommitYear + 1; |
| 75 | +
|
| 76 | + // get plural or singular form |
| 77 | + const msgYearsPart = years > 1 ? "years" : "year"; |
| 78 | + const msgProjectsPart = years > 1 ? "projects" : "project"; |
| 79 | +
|
| 80 | + // build the final output |
| 81 | + return `${projectCount} ${msgProjectsPart} over ${years} ${msgYearsPart}`; |
| 82 | +}); |
| 83 | +
|
| 84 | +/** Returns a list of languages that are in the dev's stack and the search filter */ |
| 85 | +const matchingLanguages = computed(() => { |
| 86 | + if (!props.devDetails?.report?.tech) return []; |
| 87 | +
|
| 88 | + // get the list of languages from the search filter in an array form |
| 89 | + const listOfFilterLangs = Array.from(store.tech.keys()).map((key) => |
| 90 | + key.toLowerCase() |
| 91 | + ); |
| 92 | +
|
| 93 | + // create an array of techs present in the filter |
| 94 | + const matchingLangs = props.devDetails.report.tech |
| 95 | + .map((tech) => |
| 96 | + listOfFilterLangs.includes(tech.language?.toLowerCase()) ? tech : null |
| 97 | + ) |
| 98 | + .filter((n) => n) as Tech[]; |
| 99 | +
|
| 100 | + // languages with most code lines come first |
| 101 | + matchingLangs.sort((a, b) => (b ? b.codeLines : 0) - (a ? a.codeLines : 0)); |
| 102 | +
|
| 103 | + return matchingLangs; |
| 104 | +}); |
| 105 | +
|
| 106 | +/** Returns a list of languages not in the list of search filter */ |
| 107 | +const otherLanguages = computed(() => { |
| 108 | + if (!props.devDetails?.report?.tech) return []; |
| 109 | +
|
| 110 | + // get the list of languages from the search filter in an array form |
| 111 | + const listOfFilterLangs = Array.from(store.tech.keys()).map((key) => |
| 112 | + key.toLowerCase() |
| 113 | + ); |
| 114 | +
|
| 115 | + // create an array of techs absent from the filter |
| 116 | + const otherLangs = props.devDetails.report.tech |
| 117 | + .map((tech) => |
| 118 | + listOfFilterLangs.includes(tech.language?.toLowerCase()) ? null : tech |
| 119 | + ) |
| 120 | + .filter((n) => n) as Tech[]; |
| 121 | +
|
| 122 | + // languages with most code lines come first |
| 123 | + otherLangs.sort((a, b) => (b ? b.codeLines : 0) - (a ? a.codeLines : 0)); |
| 124 | +
|
| 125 | + return otherLangs; |
| 126 | +}); |
| 127 | +
|
| 128 | +/** Returns a map of package names and their count. */ |
| 129 | +const matchingPkgs = computed(() => { |
| 130 | + // an output collector |
| 131 | + const matchingPkgs = new Map<string, number>(); |
| 132 | +
|
| 133 | + if (!props.devDetails?.report?.tech) return []; |
| 134 | +
|
| 135 | + // get the list of languages from the search filter in an array form |
| 136 | + const listOfFilterPkgs = Array.from(store.pkg).map((value) => |
| 137 | + value.toLowerCase() |
| 138 | + ); |
| 139 | +
|
| 140 | + // collect all dev packages present in the search filter and tot up their counts |
| 141 | + for (let tech of props.devDetails.report.tech) { |
| 142 | + if (tech.refs) { |
| 143 | + // pkgs per tech can be in 2 locations - refs and pkgs |
| 144 | + for (let pkg of tech.refs) { |
| 145 | + const k = pkg.k.toLowerCase(); |
| 146 | + // match with the filter |
| 147 | + for (let kw of listOfFilterPkgs) { |
| 148 | + if (k.includes(kw)) { |
| 149 | + const c = matchingPkgs.get(kw); |
| 150 | + matchingPkgs.set(kw, c ? c + pkg.c : pkg.c); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + } |
| 155 | + if (tech.pkgs) { |
| 156 | + for (let pkg of tech.pkgs) { |
| 157 | + const k = pkg.k.toLowerCase(); |
| 158 | + // match with the filter |
| 159 | + for (let kw of listOfFilterPkgs) { |
| 160 | + if (k.includes(kw)) { |
| 161 | + const c = matchingPkgs.get(kw); |
| 162 | + matchingPkgs.set(kw, c ? c + pkg.c : pkg.c); |
| 163 | + } |
| 164 | + } |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | +
|
| 169 | + return Array.from(matchingPkgs, ([k, c]) => ({ k, c })).sort( |
| 170 | + (a, b) => b.c - a.c |
| 171 | + ); |
| 172 | +}); |
| 173 | +
|
| 174 | +/** Formats number of months into years + months in 5 month increment, depending on how many years. |
| 175 | + * E.g. 0.5y, 2.5y, 3y or 5y. |
| 176 | + */ |
| 177 | +function months_to_years(months?: number) { |
| 178 | + const blankValue = "n/a"; |
| 179 | + if (!months) return blankValue; |
| 180 | +
|
| 181 | + // calculate the remainder of months ahead of time |
| 182 | + const remainder = months % 12 >= 6 ? ".5" : ""; |
| 183 | +
|
| 184 | + // add the remainder to years only if it's less than 3 years |
| 185 | + let years = ""; |
| 186 | + if (months < 12) { |
| 187 | + years = "< 1"; |
| 188 | + } else if (months < 36) { |
| 189 | + years = Math.floor(months / 12).toString() + remainder; |
| 190 | + } else { |
| 191 | + years = Math.floor(months / 12).toString(); |
| 192 | + } |
| 193 | +
|
| 194 | + return years + "y"; |
| 195 | +} |
| 196 | +
|
| 197 | +/** Returns a simplified number, e.g 1,327 -> 1.3K. */ |
| 198 | +function shorten_num(v?: number) { |
| 199 | + if (!v) return "0"; |
| 200 | +
|
| 201 | + let txt = ""; |
| 202 | + if (v < 1000) { |
| 203 | + txt = "< 1K"; |
| 204 | + } else if (v >= 1_000 && v < 10_000.0) { |
| 205 | + txt = `${Math.round(v / 1000).toPrecision(1)}K`; |
| 206 | + } else if (v >= 10_000 && v < 1_000_000) { |
| 207 | + txt = `${v / 1000}K`; |
| 208 | + } else { |
| 209 | + txt = `${Math.round(v / 1_000_000.0).toPrecision(1)}M`; |
| 210 | + } |
| 211 | +
|
| 212 | + return txt; |
| 213 | +} |
| 214 | +
|
| 215 | +/** Formats integer numbers with commas for readability, e.g. 100000 -> 10,000. */ |
| 216 | +function pretty_num(v?: number) { |
| 217 | + if (!v) return ""; |
| 218 | +
|
| 219 | + return Math.round(v) |
| 220 | + .toString() |
| 221 | + .replace(/\B(?=(\d{3})+(?!\d))/g, ","); |
| 222 | +} |
| 223 | +</script> |
| 224 | + |
| 225 | +<template> |
| 226 | + <div class="card mb-4"> |
| 227 | + <div class="card-body"> |
| 228 | + <div |
| 229 | + class="d-flex justify-content-between align-items-center" |
| 230 | + width="100%" |
| 231 | + height="50" |
| 232 | + > |
| 233 | + <h5 class="card-title ma-1"> |
| 234 | + <a :href="devId"> |
| 235 | + {{ devName }} |
| 236 | + </a> |
| 237 | + </h5> |
| 238 | + </div> |
| 239 | + |
| 240 | + <p class="card-subtitle mb-2"> |
| 241 | + <span class="me-2 text-muted"> |
| 242 | + {{ projectsOverYears }} |
| 243 | + </span> |
| 244 | + <a |
| 245 | + v-if="hasPublicContactDetails" |
| 246 | + href="https://github.com/{{dev._source.login}}" |
| 247 | + title="Contact details available on GitHub" |
| 248 | + ><span class="badge bg-success">Contact</span></a |
| 249 | + > |
| 250 | + <span v-if="props.devDetails.location" class="ms-1">{{ |
| 251 | + props.devDetails.location |
| 252 | + }}</span> |
| 253 | + </p> |
| 254 | + |
| 255 | + <ul class="list-inline"> |
| 256 | + <li |
| 257 | + v-for="tech in matchingLanguages" |
| 258 | + :key="tech.language" |
| 259 | + class="list-inline-item bg-light text-dark py-1 px-2 rounded mb-3 me-3 border border-success" |
| 260 | + > |
| 261 | + <h6 class="mb-1">{{ tech.language }}</h6> |
| 262 | + <span class="fw-light smaller-90"> |
| 263 | + <span class="calendar-badge me-3">{{ |
| 264 | + months_to_years(tech.history?.months) |
| 265 | + }}</span> |
| 266 | + <span class="loc-badge">{{ shorten_num(tech?.codeLines) }}</span> |
| 267 | + </span> |
| 268 | + </li> |
| 269 | + |
| 270 | + <li |
| 271 | + v-for="pkg in matchingPkgs" |
| 272 | + :key="pkg.k" |
| 273 | + class="list-inline-item bg-light text-dark py-1 px-2 rounded mb-3 me-3 border border-success" |
| 274 | + > |
| 275 | + <h6 class="mb-1">{{ pkg.k }}</h6> |
| 276 | + <span class="fw-light smaller-90"> |
| 277 | + <span class="libs-badge"> |
| 278 | + {{ pretty_num(pkg.c) }} mention{{ pkg.c > 1 ? "s" : "" }} |
| 279 | + </span> |
| 280 | + </span> |
| 281 | + </li> |
| 282 | + |
| 283 | + <li |
| 284 | + v-for="tech in otherLanguages" |
| 285 | + :key="tech.language" |
| 286 | + class="list-inline-item bg-light text-dark py-1 px-2 rounded mb-3 me-3 border" |
| 287 | + > |
| 288 | + <h6 class="mb-1 fw-light">{{ tech.language }}</h6> |
| 289 | + <span class="fw-light smaller-90"> |
| 290 | + <span class="calendar-badge me-3"> |
| 291 | + {{ months_to_years(tech.history?.months) }} |
| 292 | + </span> |
| 293 | + <span class="loc-badge">{{ shorten_num(tech.codeLines) }}</span> |
| 294 | + </span> |
| 295 | + </li> |
| 296 | + </ul> |
| 297 | + </div> |
| 298 | + </div> |
| 299 | +</template> |
0 commit comments