fix: mobile theme toggle + UI polish; refresh both case studies
- fix: theme toggle did nothing on mobile — duplicate id=theme-toggle bound only the desktop button; bind all [data-theme-toggle] instead (root cause) - header: 2px gap top/bottom; TraceMotif svg 24->28 so the status dot isn't clipped - remove private git.cesnimda.uk links (footer, contact, mobile nav, JSON-LD sameAs); soften homelab diagram label - both projects now 'in development'; about wording: Gmail analytics -> inbox cleanup - JobTrack: refreshed to current app (Kanban pipeline, deterministic CV match, assistive AI, EF Core) + new mockups; InboxIntel: .NET 10, pgvector + Ollama semantic search, inbox-health analytics, split-view + new mockups; meta descriptions updated - new ATS CV PDFs (from the new docx, via Word) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -58,11 +58,6 @@ const nav: { id: PageId; label: string }[] = [
|
|||||||
>LinkedIn ↗</a
|
>LinkedIn ↗</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a class="hover:text-ink transition-colors" href={p.links.gitea} rel="me noopener"
|
|
||||||
>git.cesnimda.uk ↗</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a class="hover:text-ink transition-colors" href={pathFor('cv', locale)}
|
<a class="hover:text-ink transition-colors" href={pathFor('cv', locale)}
|
||||||
>{d.cv.download}</a
|
>{d.cv.download}</a
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const isCurrent = (id: PageId) =>
|
|||||||
|
|
||||||
<header
|
<header
|
||||||
id="site-header"
|
id="site-header"
|
||||||
class="bg-surface-0/80 sticky top-0 z-40 border-b border-transparent backdrop-blur-md"
|
class="bg-surface-0/80 sticky top-0.5 z-40 mt-0.5 mb-0.5 border-b border-transparent backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
class="mx-auto flex h-[--header-h] max-w-[--content-max] items-center justify-between px-5 transition-[height] duration-[--dur-standard] ease-[--ease-out] sm:px-6"
|
||||||
|
|||||||
@@ -95,6 +95,5 @@ const navItems: { id: PageId; label: string }[] = [
|
|||||||
<div class="text-mono-meta text-ink-muted mt-auto flex flex-col gap-2 pt-8 font-mono">
|
<div class="text-mono-meta text-ink-muted mt-auto flex flex-col gap-2 pt-8 font-mono">
|
||||||
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
<a href={`mailto:${p.links.email}`}>{p.links.email}</a>
|
||||||
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
<a href={p.links.linkedin} rel="me noopener">LinkedIn ↗</a>
|
||||||
<a href={p.links.gitea} rel="me noopener">git.cesnimda.uk ↗</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,13 +122,6 @@ const fieldClass =
|
|||||||
class="text-body text-ink hover:text-accent">LinkedIn ↗</a
|
class="text-body text-ink hover:text-accent">LinkedIn ↗</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href={profile.links.gitea}
|
|
||||||
rel="me noopener"
|
|
||||||
class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent"
|
<a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent"
|
||||||
>{profile.links.phone}</a
|
>{profile.links.phone}</a
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const d = useDict(locale);
|
|||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
data-theme-toggle
|
||||||
type="button"
|
type="button"
|
||||||
class="border-line text-ink-muted hover:text-ink inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-[--dur-quick]"
|
class="border-line text-ink-muted hover:text-ink inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors duration-[--dur-quick]"
|
||||||
aria-label={d.theme.toLight}
|
aria-label={d.theme.toLight}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const { statusLine, class: cls } = Astro.props;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div class:list={['flex items-center gap-3', cls]}>
|
<div class:list={['flex items-center gap-3', cls]}>
|
||||||
<svg width="200" height="24" viewBox="0 0 200 24" fill="none" aria-hidden="true">
|
<svg width="200" height="28" viewBox="0 0 200 28" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
class="trace-line"
|
class="trace-line"
|
||||||
d="M2 12 H150 l10 10 H194"
|
d="M2 12 H150 l10 10 H194"
|
||||||
|
|||||||
@@ -30,36 +30,36 @@ export const meta: Meta = {
|
|||||||
en: {
|
en: {
|
||||||
title: 'JobTrack — case study · Connor Babbington',
|
title: 'JobTrack — case study · Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'A full-stack job-application workspace: React, ASP.NET Core, SQLite, a local Ollama AI service and Docker. Architecture, decisions and security.',
|
'AI-assisted job-search workspace: React, ASP.NET Core, a Kanban pipeline, deterministic CV match and Gmail threads — architecture, decisions, security.',
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
title: 'JobTrack — prosjektgjennomgang · Connor Babbington',
|
title: 'JobTrack — prosjektgjennomgang · Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'Et fullstack arbeidsrom for jobbsøking: React, ASP.NET Core, SQLite, lokal Ollama-AI og Docker. Arkitektur, valg og sikkerhet.',
|
'AI-assistert arbeidsrom for jobbsøking: React, ASP.NET Core, Kanban-pipeline, deterministisk CV-match og Gmail-tråder — arkitektur, valg og sikkerhet.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
inboxintel: {
|
inboxintel: {
|
||||||
en: {
|
en: {
|
||||||
title: 'InboxIntel — case study · Connor Babbington',
|
title: 'InboxIntel — case study · Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'Gmail analytics and safe bulk cleanup, built as Clean Architecture in .NET 8 with encrypted OAuth tokens and preview-before-delete.',
|
'A self-hosted inbox intelligence tool: semantic search (pgvector + Ollama), AI summaries and inbox-health analytics, built as Clean Architecture in .NET 10.',
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
title: 'InboxIntel — prosjektgjennomgang · Connor Babbington',
|
title: 'InboxIntel — prosjektgjennomgang · Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'Gmail-analyse og trygg masseopprydding, bygget som Clean Architecture i .NET 8 med krypterte OAuth-tokens og forhåndsvisning før sletting.',
|
'Et egendriftet verktøy for innboks-intelligens: semantisk søk (pgvector + Ollama), AI-sammendrag og innboks-helse, bygget som Clean Architecture i .NET 10.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
homelab: {
|
homelab: {
|
||||||
en: {
|
en: {
|
||||||
title: 'Self-hosted infrastructure lab — Connor Babbington',
|
title: 'Self-hosted infrastructure lab — Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'A home lab running Ubuntu, Docker, nginx, Gitea and monitoring — hands-on deployment and operations, and the deploy target for this site.',
|
'A ~30-service home lab: Ubuntu, Docker, Traefik, Authentik SSO, CrowdSec and self-hosted Gitea CI — hands-on ops, and the deploy target for this site.',
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
title: 'Egendriftet hjemmelab — Connor Babbington',
|
title: 'Egendriftet hjemmelab — Connor Babbington',
|
||||||
description:
|
description:
|
||||||
'En hjemmelab med Ubuntu, Docker, nginx, Gitea og overvåking — praktisk drift og utrulling, og utrullingsmålet for denne siden.',
|
'En hjemmelab med ~30 tjenester: Ubuntu, Docker, Traefik, Authentik SSO, CrowdSec og egendriftet Gitea-CI — praktisk drift, og utrullingsmålet for denne siden.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
experience: {
|
experience: {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export const profile: Profile = {
|
|||||||
gitea: 'https://git.cesnimda.uk/cesnimda',
|
gitea: 'https://git.cesnimda.uk/cesnimda',
|
||||||
},
|
},
|
||||||
cv: {
|
cv: {
|
||||||
en: { path: '/cv/connor-babbington-cv-en.pdf', sizeKb: 8, updated: '2026-07' },
|
en: { path: '/cv/connor-babbington-cv-en.pdf', sizeKb: 290, updated: '2026-07' },
|
||||||
no: { path: '/cv/connor-babbington-cv-no.pdf', sizeKb: 8, updated: '2026-07' },
|
no: { path: '/cv/connor-babbington-cv-no.pdf', sizeKb: 296, updated: '2026-07' },
|
||||||
},
|
},
|
||||||
photo: {
|
photo: {
|
||||||
src: '/assets/portrait.webp',
|
src: '/assets/portrait.webp',
|
||||||
@@ -71,7 +71,7 @@ export const profile: Profile = {
|
|||||||
aboutHeading: 'About',
|
aboutHeading: 'About',
|
||||||
aboutParagraphs: [
|
aboutParagraphs: [
|
||||||
'I’m a systems developer with a backend-leaning full-stack background: business systems, troubleshooting and production support. I like turning stakeholder requirements into practical, reliable software, and I care about the parts users never see — tests, deployments and keeping things running.',
|
'I’m a systems developer with a backend-leaning full-stack background: business systems, troubleshooting and production support. I like turning stakeholder requirements into practical, reliable software, and I care about the parts users never see — tests, deployments and keeping things running.',
|
||||||
'Alongside professional work I build and self-host my own projects — a job-application tracker, a Gmail analytics tool, and a home infrastructure lab. It keeps my skills current and hands-on, and it’s where I get to make the architecture decisions end to end.',
|
'Alongside professional work I build and self-host my own projects — a job-application tracker, a self-hosted inbox cleanup tool, and a home infrastructure lab. It keeps my skills current and hands-on, and it’s where I get to make the architecture decisions end to end.',
|
||||||
'I’m based in Tønsberg, Norway, with a valid residence permit. English is my native language and my Norwegian is at B1 and actively improving. Outside work I’m into PC and board games, cooking, and generally learning new things.',
|
'I’m based in Tønsberg, Norway, with a valid residence permit. English is my native language and my Norwegian is at B1 and actively improving. Outside work I’m into PC and board games, cooking, and generally learning new things.',
|
||||||
],
|
],
|
||||||
interestsLine: 'PC & board games · cooking · always learning something new',
|
interestsLine: 'PC & board games · cooking · always learning something new',
|
||||||
@@ -88,7 +88,7 @@ export const profile: Profile = {
|
|||||||
aboutHeading: 'Om meg',
|
aboutHeading: 'Om meg',
|
||||||
aboutParagraphs: [
|
aboutParagraphs: [
|
||||||
'Jeg er systemutvikler med bakgrunn i fullstack med tyngde på backend: fagsystemer, feilsøking og driftsstøtte. Jeg liker å gjøre om behov fra brukere og interessenter til praktisk og pålitelig programvare, og jeg bryr meg om det brukerne aldri ser — tester, utrulling og stabil drift.',
|
'Jeg er systemutvikler med bakgrunn i fullstack med tyngde på backend: fagsystemer, feilsøking og driftsstøtte. Jeg liker å gjøre om behov fra brukere og interessenter til praktisk og pålitelig programvare, og jeg bryr meg om det brukerne aldri ser — tester, utrulling og stabil drift.',
|
||||||
'Ved siden av jobb bygger og drifter jeg egne prosjekter — en jobbsøknads-tracker, et verktøy for Gmail-analyse og en hjemmelab for infrastruktur. Det holder ferdighetene mine oppdaterte, og der tar jeg alle arkitekturvalgene selv, fra ende til ende.',
|
'Ved siden av jobb bygger og drifter jeg egne prosjekter — en jobbsøknads-tracker, et verktøy for innboks-opprydding og en hjemmelab for infrastruktur. Det holder ferdighetene mine oppdaterte, og der tar jeg alle arkitekturvalgene selv, fra ende til ende.',
|
||||||
'Jeg er bosatt i Tønsberg og har gyldig oppholdstillatelse. Engelsk er morsmålet mitt, og norsken min er på B1 og i aktiv utvikling. På fritiden liker jeg PC- og brettspill, matlaging og å lære nye ting.',
|
'Jeg er bosatt i Tønsberg og har gyldig oppholdstillatelse. Engelsk er morsmålet mitt, og norsken min er på B1 og i aktiv utvikling. På fritiden liker jeg PC- og brettspill, matlaging og å lære nye ting.',
|
||||||
],
|
],
|
||||||
interestsLine: 'PC- og brettspill · matlaging · lærer alltid noe nytt',
|
interestsLine: 'PC- og brettspill · matlaging · lærer alltid noe nytt',
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const homelab: Project = {
|
|||||||
h: 60,
|
h: 60,
|
||||||
kind: 'internal',
|
kind: 'internal',
|
||||||
label: 'Gitea + runner',
|
label: 'Gitea + runner',
|
||||||
sub: { en: 'git.cesnimda.uk · CI/CD', no: 'git.cesnimda.uk · CI/CD' },
|
sub: { en: 'self-hosted git · CI/CD', no: 'egendriftet git · CI/CD' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'apps',
|
id: 'apps',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Project } from '@lib/schema';
|
import type { Project } from '@lib/schema';
|
||||||
|
|
||||||
/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel (README + docs). */
|
/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel + current app mockups. */
|
||||||
export const inboxintel: Project = {
|
export const inboxintel: Project = {
|
||||||
id: 'inboxintel',
|
id: 'inboxintel',
|
||||||
name: 'InboxIntel',
|
name: 'InboxIntel',
|
||||||
@@ -8,11 +8,11 @@ export const inboxintel: Project = {
|
|||||||
status: 'in-development',
|
status: 'in-development',
|
||||||
template: 'case-study',
|
template: 'case-study',
|
||||||
stack: [
|
stack: [
|
||||||
{ name: '.NET 8', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } },
|
{ name: '.NET 10', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } },
|
||||||
{ name: 'PostgreSQL', context: { en: 'EF Core + Npgsql', no: 'EF Core + Npgsql' } },
|
{ name: 'React', context: { en: 'TypeScript SPA', no: 'TypeScript-SPA' } },
|
||||||
{ name: 'React 18', context: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' } },
|
{ name: 'PostgreSQL', context: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' } },
|
||||||
{ name: 'Gmail API', context: { en: 'OAuth2 · Polly', no: 'OAuth2 · Polly' } },
|
{ name: 'Ollama', context: { en: 'embeddings · summaries', no: 'embeddings · sammendrag' } },
|
||||||
{ name: 'Serilog' },
|
{ name: 'Clean Architecture' },
|
||||||
{ name: 'Docker' },
|
{ name: 'Docker' },
|
||||||
],
|
],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -20,8 +20,8 @@ export const inboxintel: Project = {
|
|||||||
viewBox: '0 0 1120 300',
|
viewBox: '0 0 1120 300',
|
||||||
title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' },
|
title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' },
|
||||||
desc: {
|
desc: {
|
||||||
en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture (Api → Infrastructure → Application → Domain). A hosted background worker syncs the external Gmail API into PostgreSQL with Polly retry/backoff.',
|
en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture. A hosted background worker syncs the external Gmail API into PostgreSQL with pgvector, and a local Ollama model produces the embeddings and summaries that power semantic search.',
|
||||||
no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture (Api → Infrastructure → Application → Domain). En bakgrunnstjeneste synkroniserer det eksterne Gmail-API-et inn i PostgreSQL med Polly retry/backoff.',
|
no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture. En bakgrunnstjeneste synkroniserer det eksterne Gmail-API-et inn i PostgreSQL med pgvector, og en lokal Ollama-modell lager embeddings og sammendrag som driver semantisk søk.',
|
||||||
},
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ export const inboxintel: Project = {
|
|||||||
h: 72,
|
h: 72,
|
||||||
kind: 'internal',
|
kind: 'internal',
|
||||||
label: 'React SPA',
|
label: 'React SPA',
|
||||||
sub: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' },
|
sub: { en: 'split-view · search', no: 'delt visning · søk' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'api',
|
id: 'api',
|
||||||
@@ -44,6 +44,16 @@ export const inboxintel: Project = {
|
|||||||
label: 'ASP.NET Core API',
|
label: 'ASP.NET Core API',
|
||||||
sub: { en: 'Clean Architecture', no: 'Clean Architecture' },
|
sub: { en: 'Clean Architecture', no: 'Clean Architecture' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'ollama',
|
||||||
|
x: 290,
|
||||||
|
y: 232,
|
||||||
|
w: 200,
|
||||||
|
h: 56,
|
||||||
|
kind: 'internal',
|
||||||
|
label: 'Ollama',
|
||||||
|
sub: { en: 'embeddings · summaries', no: 'embeddings · sammendrag' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'pg',
|
id: 'pg',
|
||||||
x: 580,
|
x: 580,
|
||||||
@@ -52,7 +62,7 @@ export const inboxintel: Project = {
|
|||||||
h: 64,
|
h: 64,
|
||||||
kind: 'internal',
|
kind: 'internal',
|
||||||
label: 'PostgreSQL',
|
label: 'PostgreSQL',
|
||||||
sub: { en: 'EF Core · Npgsql', no: 'EF Core · Npgsql' },
|
sub: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'worker',
|
id: 'worker',
|
||||||
@@ -77,6 +87,7 @@ export const inboxintel: Project = {
|
|||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ d: 'M210 152 H290', kind: 'flow', label: '/api', labelX: 240, labelY: 144 },
|
{ d: 'M210 152 H290', kind: 'flow', label: '/api', labelX: 240, labelY: 144 },
|
||||||
|
{ d: 'M390 188 V232', kind: 'flow', label: 'AI', labelX: 408, labelY: 216 },
|
||||||
{ d: 'M490 134 L580 92', kind: 'flow' },
|
{ d: 'M490 134 L580 92', kind: 'flow' },
|
||||||
{ d: 'M490 170 L580 192', kind: 'flow' },
|
{ d: 'M490 170 L580 192', kind: 'flow' },
|
||||||
{ d: 'M670 160 V112', kind: 'flow' },
|
{ d: 'M670 160 V112', kind: 'flow' },
|
||||||
@@ -85,42 +96,55 @@ export const inboxintel: Project = {
|
|||||||
},
|
},
|
||||||
media: [
|
media: [
|
||||||
{
|
{
|
||||||
src: 'placeholder:inboxintel-dashboard',
|
src: '/assets/projects/inboxintel/inbox.webp',
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
alt: {
|
alt: {
|
||||||
en: 'InboxIntel draggable analytics dashboard with charts',
|
en: 'InboxIntel split view: a persistent message list beside a resizable reading pane with an AI summary',
|
||||||
no: 'InboxIntel dashbord med flyttbare analyse-widgets',
|
no: 'InboxIntel delt visning: en fast meldingsliste ved siden av en justerbar leserute med AI-sammendrag',
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
en: 'Draggable analytics dashboard (react-grid-layout)',
|
en: 'Split view — read and triage without ever opening a new tab',
|
||||||
no: 'Dashbord med flyttbare widgets (react-grid-layout)',
|
no: 'Delt visning — les og sorter uten å åpne en ny fane',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'placeholder:inboxintel-cleanup',
|
src: '/assets/projects/inboxintel/dashboard.webp',
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
alt: {
|
alt: {
|
||||||
en: 'Bulk cleanup preview screen requiring explicit confirmation',
|
en: 'InboxIntel analytics dashboard: inbox-health grade, emails by category, 90-day volume and top senders',
|
||||||
no: 'Forhåndsvisning av masseopprydding som krever bekreftelse',
|
no: 'InboxIntel dashbord: innboks-helse, e-post per kategori, 90-dagers volum og toppavsendere',
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
en: 'Cleanup preview — nothing is deleted without a confirmed flag',
|
en: 'Analytics — an inbox-health grade, categories, volume trend and top senders',
|
||||||
no: 'Oppryddings-forhåndsvisning — ingenting slettes uten bekreftelse',
|
no: 'Analyse — innboks-helse, kategorier, volumtrend og toppavsendere',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/assets/projects/inboxintel/search.webp',
|
||||||
|
width: 1600,
|
||||||
|
height: 1000,
|
||||||
|
alt: {
|
||||||
|
en: 'InboxIntel search: hybrid semantic and full-text results with why-matched highlights and relevance scores',
|
||||||
|
no: 'InboxIntel søk: hybrid semantisk og fulltekst med treff-forklaring og relevans-score',
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
en: 'Search — hybrid semantic + full-text, with why-matched highlights and relevance',
|
||||||
|
no: 'Søk — hybrid semantisk + fulltekst, med treff-forklaring og relevans',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
valueProp:
|
valueProp:
|
||||||
'Gmail analytics and safe bulk cleanup, built as a disciplined Clean Architecture.',
|
'A self-hosted inbox intelligence and cleanup tool — semantic search, AI summaries and inbox-health analytics over your mail.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'Gmail analytics and safe bulk cleanup — Clean Architecture, encrypted OAuth tokens, preview-before-delete by design.',
|
'Self-hosted inbox intelligence — hybrid semantic + full-text search, AI summaries and inbox-health analytics, with safe cleanup by design.',
|
||||||
tldr: {
|
tldr: {
|
||||||
what: 'Gmail analytics, cleanup and unsubscribe management with a background sync worker.',
|
what: 'Inbox intelligence: split-view reading, semantic search, AI summaries, inbox-health analytics and safe cleanup.',
|
||||||
why: 'To practise Clean Architecture properly and make destructive operations safe by design.',
|
why: 'To practise Clean Architecture properly, and make search useful and destructive operations safe by design.',
|
||||||
stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.',
|
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
|
||||||
role: 'Sole architect and developer, front to back.',
|
role: 'Sole architect and developer, front to back.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
@@ -131,7 +155,7 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'A busy inbox needs analytics and cleanup, but bulk operations on real mail are dangerous — one wrong filter can delete things you can’t get back. I built InboxIntel to explore two things at once: a clean, testable backend architecture, and destructive operations that are safe by construction.',
|
text: 'A busy inbox is hard to search and dangerous to clean up — keyword search misses what you meant, and one wrong bulk filter deletes things you can’t get back. I built InboxIntel to make an inbox actually searchable (by meaning, not just words), understandable at a glance, and safe to tidy — all self-hosted, with a clean, testable backend.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -142,15 +166,15 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'A four-project Clean Architecture solution behind a React SPA. The dependency rule points inward: Api → Infrastructure → Application → Domain. Controllers hold no business logic — they delegate to Application-layer service interfaces resolved through DI. A hosted background worker syncs Gmail into PostgreSQL with Polly retry and backoff.',
|
text: 'A React SPA over an ASP.NET Core API built as Clean Architecture — the dependency rule points inward (Api → Infrastructure → Application → Domain), and controllers hold no business logic. A hosted background worker syncs Gmail into PostgreSQL; a local Ollama model generates embeddings (stored with pgvector) and message summaries, which power hybrid semantic + full-text search.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Domain: entities and enums, no external dependencies.',
|
'Reading: a persistent list beside a resizable pane with an AI summary — triage without opening a new tab.',
|
||||||
'Application: service interfaces, DTOs, validators, the Gmail query parser.',
|
'Search: semantic (pgvector) + full-text, with why-matched highlights and a relevance score.',
|
||||||
'Infrastructure: EF Core, the Gmail client, the sync worker, AI and export.',
|
'Analytics: an inbox-health grade, emails by category, a 90-day volume trend and top senders.',
|
||||||
'Api: ASP.NET Core controllers, auth, DI and Serilog — thin by design.',
|
'Cleanup: preview-then-confirm on every destructive action; the AI is advisory only.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -162,10 +186,10 @@ export const inboxintel: Project = {
|
|||||||
decisions: [
|
decisions: [
|
||||||
{
|
{
|
||||||
n: 1,
|
n: 1,
|
||||||
choice: 'Full Clean Architecture across four projects.',
|
choice: 'Local Ollama embeddings + pgvector for semantic search.',
|
||||||
alternative: 'A single project would have shipped faster.',
|
alternative: 'A hosted embeddings API and a vector database.',
|
||||||
rationale:
|
rationale:
|
||||||
'I wanted the dependency rule to be enforced by the project structure, not by discipline alone. It costs more ceremony up front and pays back in testability and clear boundaries.',
|
'Mail is private, so embeddings stay on my own hardware and live right next to the data in Postgres via pgvector — one datastore, no third party, no per-call cost. The trade-off is running the model myself.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: 2,
|
n: 2,
|
||||||
@@ -191,10 +215,10 @@ export const inboxintel: Project = {
|
|||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Gmail scopes are read/modify only — no send scope is ever requested.',
|
'Google-OAuth gated; Gmail scopes are read/modify only — no send scope is ever requested.',
|
||||||
'Polly provides retry and backoff against Gmail API rate limits and transient failures.',
|
'Embeddings and summaries are generated locally (Ollama); mail never leaves the box for a third-party model.',
|
||||||
'FluentValidation on inputs; Serilog structured logging that never records tokens.',
|
'Polly retry/backoff against Gmail rate limits; Serilog structured logging that never records tokens.',
|
||||||
'Unit tests cover the query parser and unsubscribe extraction; integration tests boot the API host and assert authorization is enforced.',
|
'Clean Architecture keeps the layers testable; integration tests boot the API host and assert authorization.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -211,20 +235,21 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'InboxIntel is in active development: the architecture is complete and the layers compile and are wired end to end, with a few integration points (Gmail parsing edge cases, AI prompt tuning) left as clearly-marked extension points. Next up is hardening those edges and expanding the integration-test suite.',
|
text: 'In active development. Search, summaries and analytics work end to end; next up is tuning the semantic-recall thresholds, expanding the integration-test suite, and working toward connectors beyond Gmail so it becomes a genuinely universal inbox tool.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
valueProp: 'Gmail-analyse og trygg masseopprydding, bygget som en ryddig Clean Architecture.',
|
valueProp:
|
||||||
|
'Et egendriftet verktøy for innboks-intelligens og opprydding — semantisk søk, AI-sammendrag og innboks-helse over e-posten din.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'Gmail-analyse og trygg masseopprydding — Clean Architecture, krypterte OAuth-tokens, forhåndsvisning før sletting.',
|
'Egendriftet innboks-intelligens — hybrid semantisk + fulltekst-søk, AI-sammendrag og innboks-helse, med trygg opprydding fra bunnen.',
|
||||||
tldr: {
|
tldr: {
|
||||||
what: 'Gmail-analyse, opprydding og avmeldingshåndtering med en bakgrunns-synk.',
|
what: 'Innboks-intelligens: delt visning, semantisk søk, AI-sammendrag, innboks-helse og trygg opprydding.',
|
||||||
why: 'For å øve på Clean Architecture skikkelig og gjøre destruktive operasjoner trygge fra bunnen.',
|
why: 'For å øve på Clean Architecture skikkelig, gjøre søk nyttig og destruktive operasjoner trygge fra bunnen.',
|
||||||
stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.',
|
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
|
||||||
role: 'Eneste arkitekt og utvikler, fra ende til ende.',
|
role: 'Eneste arkitekt og utvikler, fra ende til ende.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
@@ -235,7 +260,7 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'En travel innboks trenger analyse og opprydding, men masseoperasjoner på ekte e-post er farlige — ett feil filter kan slette ting du ikke får tilbake. Jeg bygde InboxIntel for å utforske to ting samtidig: en ryddig, testbar backend-arkitektur, og destruktive operasjoner som er trygge fra bunnen.',
|
text: 'En travel innboks er vanskelig å søke i og farlig å rydde i — nøkkelordsøk bommer på det du mente, og ett feil massefilter sletter ting du ikke får tilbake. Jeg bygde InboxIntel for å gjøre en innboks faktisk søkbar (på mening, ikke bare ord), forståelig med ett blikk, og trygg å rydde i — alt egendriftet, med en ryddig og testbar backend.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -246,15 +271,15 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'En løsning med fire prosjekter i Clean Architecture bak en React-app. Avhengighetsregelen peker innover: Api → Infrastructure → Application → Domain. Kontrollerne har ingen forretningslogikk — de delegerer til tjenestegrensesnitt i Application-laget via DI. En bakgrunnstjeneste synkroniserer Gmail inn i PostgreSQL med Polly retry og backoff.',
|
text: 'En React-app over et ASP.NET Core-API bygget som Clean Architecture — avhengighetsregelen peker innover (Api → Infrastructure → Application → Domain), og kontrollerne har ingen forretningslogikk. En bakgrunnstjeneste synkroniserer Gmail inn i PostgreSQL; en lokal Ollama-modell lager embeddings (lagret med pgvector) og sammendrag, som driver hybrid semantisk + fulltekst-søk.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Domain: entiteter og enums, uten eksterne avhengigheter.',
|
'Lesing: en fast liste ved siden av en justerbar rute med AI-sammendrag — sorter uten å åpne en ny fane.',
|
||||||
'Application: tjenestegrensesnitt, DTO-er, validatorer og Gmail-søkeparser.',
|
'Søk: semantisk (pgvector) + fulltekst, med treff-forklaring og relevans-score.',
|
||||||
'Infrastructure: EF Core, Gmail-klient, synk-tjeneste, AI og eksport.',
|
'Analyse: en innboks-helsekarakter, e-post per kategori, 90-dagers volumtrend og toppavsendere.',
|
||||||
'Api: ASP.NET Core-kontrollere, auth, DI og Serilog — tynt med hensikt.',
|
'Opprydding: forhåndsvis-så-bekreft på hver destruktive handling; AI-en er kun rådgivende.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -266,10 +291,10 @@ export const inboxintel: Project = {
|
|||||||
decisions: [
|
decisions: [
|
||||||
{
|
{
|
||||||
n: 1,
|
n: 1,
|
||||||
choice: 'Full Clean Architecture over fire prosjekter.',
|
choice: 'Lokale Ollama-embeddings + pgvector for semantisk søk.',
|
||||||
alternative: 'Ett prosjekt hadde vært raskere å levere.',
|
alternative: 'Et hostet embeddings-API og en egen vektordatabase.',
|
||||||
rationale:
|
rationale:
|
||||||
'Jeg ville at avhengighetsregelen skulle håndheves av prosjektstrukturen, ikke bare av disiplin. Det koster mer seremoni i starten og betaler seg i testbarhet og tydelige grenser.',
|
'E-post er privat, så embeddings blir på min egen maskin og ligger rett ved siden av dataene i Postgres via pgvector — én datalagring, ingen tredjepart, ingen kostnad per kall. Avveiningen er å drifte modellen selv.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: 2,
|
n: 2,
|
||||||
@@ -295,10 +320,10 @@ export const inboxintel: Project = {
|
|||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.',
|
'Google-OAuth-beskyttet; Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.',
|
||||||
'Polly gir retry og backoff mot Gmail-API-ets rategrenser og forbigående feil.',
|
'Embeddings og sammendrag lages lokalt (Ollama); e-post forlater aldri maskinen til en tredjepartsmodell.',
|
||||||
'FluentValidation på input; strukturert Serilog-logging som aldri lagrer tokens.',
|
'Polly retry/backoff mot Gmails rategrenser; strukturert Serilog-logging som aldri lagrer tokens.',
|
||||||
'Enhetstester dekker søkeparser og avmeldings-uttrekk; integrasjonstester starter API-verten og sjekker at autorisasjon håndheves.',
|
'Clean Architecture holder lagene testbare; integrasjonstester starter API-verten og sjekker autorisasjon.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -315,7 +340,7 @@ export const inboxintel: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'InboxIntel er under aktiv utvikling: arkitekturen er komplett, lagene kompilerer og er koblet ende til ende, med noen integrasjonspunkter (kanttilfeller i Gmail-parsing, tuning av AI-prompter) igjen som tydelig markerte utvidelsespunkter. Neste steg er å herde disse kantene og utvide integrasjonstestene.',
|
text: 'Under aktiv utvikling. Søk, sammendrag og analyse fungerer ende til ende; neste steg er å justere tersklene for semantisk gjenfinning, utvide integrasjonstestene, og jobbe mot koblinger utover Gmail slik at det blir et virkelig universelt innboks-verktøy.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import type { Project } from '@lib/schema';
|
import type { Project } from '@lib/schema';
|
||||||
|
|
||||||
/* JobTrack case study. Source: D:\Job tracker (README + website_details.md). */
|
/* JobTrack case study. Source: D:\Job tracker + current app mockups. */
|
||||||
export const jobtrack: Project = {
|
export const jobtrack: Project = {
|
||||||
id: 'jobtrack',
|
id: 'jobtrack',
|
||||||
name: 'JobTrack',
|
name: 'JobTrack',
|
||||||
order: 1,
|
order: 1,
|
||||||
status: 'active',
|
status: 'in-development',
|
||||||
template: 'case-study',
|
template: 'case-study',
|
||||||
stack: [
|
stack: [
|
||||||
{ name: '.NET 9', context: { en: 'ASP.NET Core API', no: 'ASP.NET Core-API' } },
|
{ name: 'ASP.NET Core', context: { en: '.NET API', no: '.NET-API' } },
|
||||||
{ name: 'React', context: { en: 'SPA frontend', no: 'SPA-frontend' } },
|
{ name: 'React', context: { en: 'TypeScript SPA', no: 'TypeScript-SPA' } },
|
||||||
{ name: 'TypeScript' },
|
{ name: 'EF Core' },
|
||||||
{ name: 'SQLite', context: { en: 'EF Core', no: 'EF Core' } },
|
{ name: 'FastAPI', context: { en: 'local AI service', no: 'lokal AI-tjeneste' } },
|
||||||
{ name: 'FastAPI', context: { en: 'Python AI service', no: 'Python AI-tjeneste' } },
|
{ name: 'Gmail OAuth' },
|
||||||
{ name: 'Ollama', context: { en: 'local LLM', no: 'lokal LLM' } },
|
|
||||||
{ name: 'Docker' },
|
{ name: 'Docker' },
|
||||||
],
|
],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -21,8 +20,8 @@ export const jobtrack: Project = {
|
|||||||
viewBox: '0 0 1120 300',
|
viewBox: '0 0 1120 300',
|
||||||
title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' },
|
title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' },
|
||||||
desc: {
|
desc: {
|
||||||
en: 'A React single-page app talks to an ASP.NET Core API, which persists to SQLite and disk, calls a FastAPI/Ollama AI service, and imports from the external Gmail API over OAuth2.',
|
en: 'A React single-page app talks to an ASP.NET Core API, which persists via EF Core with attachments on disk, calls a FastAPI/Ollama AI service, and imports from the external Gmail API over OAuth2.',
|
||||||
no: 'En React-app snakker med et ASP.NET Core-API som lagrer til SQLite og disk, kaller en FastAPI/Ollama AI-tjeneste, og importerer fra det eksterne Gmail-API-et via OAuth2.',
|
no: 'En React-app snakker med et ASP.NET Core-API som lagrer via EF Core med vedlegg på disk, kaller en FastAPI/Ollama AI-tjeneste, og importerer fra det eksterne Gmail-API-et via OAuth2.',
|
||||||
},
|
},
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
@@ -43,7 +42,7 @@ export const jobtrack: Project = {
|
|||||||
h: 72,
|
h: 72,
|
||||||
kind: 'primary',
|
kind: 'primary',
|
||||||
label: 'ASP.NET Core API',
|
label: 'ASP.NET Core API',
|
||||||
sub: { en: '.NET 9 · auth · rules', no: '.NET 9 · auth · regler' },
|
sub: { en: 'auth · rules · CV match', no: 'auth · regler · CV-match' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'data',
|
id: 'data',
|
||||||
@@ -52,8 +51,8 @@ export const jobtrack: Project = {
|
|||||||
w: 180,
|
w: 180,
|
||||||
h: 64,
|
h: 64,
|
||||||
kind: 'internal',
|
kind: 'internal',
|
||||||
label: 'SQLite + files',
|
label: 'EF Core + files',
|
||||||
sub: { en: 'EF Core · attachments', no: 'EF Core · vedlegg' },
|
sub: { en: 'data · attachments', no: 'data · vedlegg' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ai',
|
id: 'ai',
|
||||||
@@ -85,55 +84,55 @@ export const jobtrack: Project = {
|
|||||||
},
|
},
|
||||||
media: [
|
media: [
|
||||||
{
|
{
|
||||||
src: '/assets/projects/jobtrack/applications.webp',
|
src: '/assets/projects/jobtrack/workspace.webp',
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 768,
|
height: 1000,
|
||||||
alt: {
|
alt: {
|
||||||
en: 'JobTrack applications table showing companies, roles, statuses and follow-up flags',
|
en: 'JobTrack job workspace: one application with its details, CV match and recruiter thread',
|
||||||
no: 'JobTrack-søknadstabell med selskaper, roller, statuser og oppfølgingsmarkører',
|
no: 'JobTrack jobb-arbeidsrom: én søknad med detaljer, CV-match og rekruttør-tråd',
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
en: 'Applications table — the whole pipeline at a glance, with per-row next actions',
|
en: 'Job workspace — everything about one application in one place',
|
||||||
no: 'Søknadstabell — hele pipelinen samlet, med neste handling per rad',
|
no: 'Jobb-arbeidsrom — alt om én søknad på ett sted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/assets/projects/jobtrack/pipeline.webp',
|
||||||
|
width: 1600,
|
||||||
|
height: 1000,
|
||||||
|
alt: {
|
||||||
|
en: 'JobTrack Kanban pipeline with applications across Applied, Waiting, Interview and Offer',
|
||||||
|
no: 'JobTrack Kanban-pipeline med søknader fordelt på stadiene',
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
en: 'Pipeline — drag applications between stages, from Applied to Offer',
|
||||||
|
no: 'Pipeline — dra søknader mellom stadier, fra Søkt til Tilbud',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: '/assets/projects/jobtrack/dashboard.webp',
|
src: '/assets/projects/jobtrack/dashboard.webp',
|
||||||
width: 1600,
|
width: 1600,
|
||||||
height: 759,
|
height: 1000,
|
||||||
alt: {
|
alt: {
|
||||||
en: 'JobTrack dashboard with application trends, response rates and skill tags',
|
en: 'JobTrack dashboard with response rate, funnel and time-in-stage',
|
||||||
no: 'JobTrack-dashbord med søknadstrender, svarrater og ferdighetstagger',
|
no: 'JobTrack-dashbord med svarrate, trakt og tid-per-stadium',
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
en: 'Dashboard — trends, response rates and pipeline health',
|
en: 'Dashboard — response rates, funnel and skill demand across the search',
|
||||||
no: 'Dashbord — trender, svarrater og pipeline-helse',
|
no: 'Dashbord — svarrater, trakt og etterspurte ferdigheter',
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: '/assets/projects/jobtrack/reminders.webp',
|
|
||||||
width: 1600,
|
|
||||||
height: 758,
|
|
||||||
alt: {
|
|
||||||
en: 'JobTrack reminders view surfacing applications that need follow-up',
|
|
||||||
no: 'JobTrack-påminnelser som løfter fram søknader som trenger oppfølging',
|
|
||||||
},
|
|
||||||
caption: {
|
|
||||||
en: 'Reminders — applications that need attention, driven by follow-up rules',
|
|
||||||
no: 'Påminnelser — søknader som trenger oppfølging, styrt av regler',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
content: {
|
content: {
|
||||||
en: {
|
en: {
|
||||||
valueProp:
|
valueProp:
|
||||||
'A job-application workspace covering the full journey from application to follow-up.',
|
'An AI-assisted job-search workspace — import a role, tailor your CV, and track every application from applied to offer.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'One workspace for the whole job-application journey — pipeline, correspondence, attachments, AI summaries.',
|
'Import a role, tailor your CV, and track every application — recruiter threads and a Kanban pipeline in one workspace.',
|
||||||
tldr: {
|
tldr: {
|
||||||
what: 'Full-stack job-application tracker: pipeline, correspondence, attachments, analytics.',
|
what: 'Full-stack job-search workspace: pipeline, CV match, Gmail threads, attachments, analytics.',
|
||||||
why: 'A real problem — my own job search needed production-grade tooling, not a spreadsheet.',
|
why: 'A real problem — my own job search needed production-grade tooling, not a spreadsheet.',
|
||||||
stack: 'React + ASP.NET Core + SQLite · FastAPI/Ollama AI service · Docker Compose.',
|
stack: 'React + ASP.NET Core (EF Core) · FastAPI/Ollama local AI · Gmail OAuth · Docker.',
|
||||||
role: 'Everything: product, backend, frontend, ops and security.',
|
role: 'Everything: product, backend, frontend, ops and security.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
@@ -144,7 +143,7 @@ export const jobtrack: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'A serious job search spreads across spreadsheets, email threads, notes apps and scattered documents. Nothing shows you, at a glance, which applications need attention or what was said last. I wanted one focused workspace — and I wanted to build it to the standard I would ship at work, not as a throwaway.',
|
text: 'A serious job search spreads across spreadsheets, email threads, notes apps and scattered documents. Nothing shows you, at a glance, which applications need attention or what was said last. I wanted one focused workspace — from importing a role to the final offer — built to the standard I would ship at work, not as a throwaway.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -155,15 +154,15 @@ export const jobtrack: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'Four services behind one Docker Compose file. A React SPA talks to an ASP.NET Core (.NET 9) API that owns the domain logic and follow-up rules, persisting to SQLite via EF Core with attachments on disk. A small FastAPI service backed by a local Ollama model produces summaries and skill extraction, and the API imports correspondence from the Gmail API over OAuth2.',
|
text: 'A React + TypeScript SPA talks to an ASP.NET Core API that owns the domain logic — pipeline, follow-up rules, CV keyword matching — persisting via EF Core with attachments on disk. A small FastAPI service backed by a local Ollama model drafts CVs, cover letters and follow-ups, and the API imports correspondence from the Gmail API over OAuth2. The whole thing runs behind one Docker Compose file.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Frontend: React + TypeScript SPA, served by nginx, installable as a PWA with a mobile share-target for quick capture.',
|
'Pipeline: a Kanban board — Applied, Waiting, Interview, Offer, Rejected, Ghosted — drag to update.',
|
||||||
'API: ASP.NET Core, EF Core, rule-driven follow-up reminders, per-application history trail.',
|
'CV match: deterministic keyword coverage (matched vs missing), not a black-box score.',
|
||||||
'AI: FastAPI wrapper over Ollama for short/full description summaries — advisory only.',
|
'Gmail: import full threads over OAuth2; linked threads auto-refresh onto the right job.',
|
||||||
'Integration: Gmail OAuth2 with smart query filters and single-message or full-thread import.',
|
'AI is assistive, never autonomous: it drafts, you always review and send — no auto-apply.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -178,22 +177,22 @@ export const jobtrack: Project = {
|
|||||||
choice: 'Run the AI locally with Ollama instead of a cloud API.',
|
choice: 'Run the AI locally with Ollama instead of a cloud API.',
|
||||||
alternative: 'A hosted LLM API would have been faster to wire up.',
|
alternative: 'A hosted LLM API would have been faster to wire up.',
|
||||||
rationale:
|
rationale:
|
||||||
'Job-search data is sensitive and I wanted zero per-call cost and no third party in the loop. The trade-off is more setup and heavier local resources — acceptable for a self-hosted, single-user tool.',
|
'Job-search data is sensitive and I wanted zero per-call cost and no third party in the loop. The trade-off is more setup and heavier local resources — acceptable for a self-hosted tool.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: 2,
|
n: 2,
|
||||||
|
choice: 'Make CV matching deterministic, not an AI score.',
|
||||||
|
alternative: 'Let the model rate the fit.',
|
||||||
|
rationale:
|
||||||
|
'A number a candidate can’t interrogate is useless. Deterministic keyword coverage shows exactly which terms matched and which are missing, so the advice is honest and actionable.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3,
|
||||||
choice: 'Ship the PWA with no offline service-worker cache.',
|
choice: 'Ship the PWA with no offline service-worker cache.',
|
||||||
alternative: 'A cache would enable full offline use.',
|
alternative: 'A cache would enable full offline use.',
|
||||||
rationale:
|
rationale:
|
||||||
'I deploy frequently, so an aggressive cache risks serving stale builds — a worse failure than a brief offline gap. The manifest still provides installability and share-to-capture. A deliberate anti-feature.',
|
'I deploy frequently, so an aggressive cache risks serving stale builds — a worse failure than a brief offline gap. The manifest still provides installability and share-to-capture. A deliberate anti-feature.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
n: 3,
|
|
||||||
choice: 'SQLite rather than PostgreSQL.',
|
|
||||||
alternative: 'Postgres is the default reach for a web backend.',
|
|
||||||
rationale:
|
|
||||||
'This is a single-user app; SQLite removes an entire service with no practical downside here. If it ever went multi-tenant, moving to Postgres via EF Core is a contained change.',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -206,8 +205,8 @@ export const jobtrack: Project = {
|
|||||||
items: [
|
items: [
|
||||||
'Optional Google sign-in (Google ID tokens) protects the API; every record is scoped to its owner.',
|
'Optional Google sign-in (Google ID tokens) protects the API; every record is scoped to its owner.',
|
||||||
'File uploads are validated and stored per-application with ownership checks on every access.',
|
'File uploads are validated and stored per-application with ownership checks on every access.',
|
||||||
'The AI layer is advisory only — it never performs destructive actions.',
|
'The AI layer is advisory only — it drafts, it never sends or auto-applies.',
|
||||||
'Runs as a reproducible Docker Compose stack with a documented .env; scheduled JSON/CSV exports for data portability.',
|
'Runs as a reproducible Docker Compose stack with a documented .env; JSON/CSV exports for data portability.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -219,25 +218,26 @@ export const jobtrack: Project = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'next',
|
kind: 'next',
|
||||||
heading: 'What I’d improve next',
|
heading: 'Status & what’s next',
|
||||||
anchorId: 'next',
|
anchorId: 'next',
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'The rules engine for follow-ups is simple date logic; I’d make it configurable per pipeline stage. I’d also add a proper integration-test pass around the Gmail import edge cases, and tighten the summariser prompts now that I have real usage to learn from.',
|
text: 'In active development. The follow-up rules are simple date logic I’d like to make configurable per stage; next up is a proper integration-test pass around the Gmail import edge cases and tighter grounding on the AI drafts now that I have real usage to learn from.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
valueProp: 'Et arbeidsrom for jobbsøking som dekker hele reisen fra søknad til oppfølging.',
|
valueProp:
|
||||||
|
'Et AI-assistert arbeidsrom for jobbsøking — importer en stilling, tilpass CV-en, og følg hver søknad fra søkt til tilbud.',
|
||||||
cardTeaser:
|
cardTeaser:
|
||||||
'Ett arbeidsrom for hele jobbsøkerreisen — pipeline, korrespondanse, vedlegg og AI-sammendrag.',
|
'Importer en stilling, tilpass CV-en, og følg hver søknad — rekruttør-tråder og en Kanban-pipeline i ett arbeidsrom.',
|
||||||
tldr: {
|
tldr: {
|
||||||
what: 'Fullstack jobbsøknads-tracker: pipeline, korrespondanse, vedlegg og analyse.',
|
what: 'Fullstack arbeidsrom for jobbsøking: pipeline, CV-match, Gmail-tråder, vedlegg og analyse.',
|
||||||
why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.',
|
why: 'Et ekte problem — min egen jobbsøking trengte skikkelig verktøy, ikke et regneark.',
|
||||||
stack: 'React + ASP.NET Core + SQLite · FastAPI/Ollama AI-tjeneste · Docker Compose.',
|
stack: 'React + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail OAuth · Docker.',
|
||||||
role: 'Alt: produkt, backend, frontend, drift og sikkerhet.',
|
role: 'Alt: produkt, backend, frontend, drift og sikkerhet.',
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
@@ -248,7 +248,7 @@ export const jobtrack: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'En seriøs jobbsøking sprer seg over regneark, e-posttråder, notatapper og løse dokumenter. Ingenting viser deg med ett blikk hvilke søknader som trenger oppfølging, eller hva som sist ble sagt. Jeg ville ha ett samlet arbeidsrom — og bygge det til samme standard som jeg leverer på jobb, ikke som noe engangs.',
|
text: 'En seriøs jobbsøking sprer seg over regneark, e-posttråder, notatapper og løse dokumenter. Ingenting viser deg med ett blikk hvilke søknader som trenger oppfølging, eller hva som sist ble sagt. Jeg ville ha ett samlet arbeidsrom — fra import av en stilling til endelig tilbud — bygget til samme standard som jeg leverer på jobb.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -259,15 +259,15 @@ export const jobtrack: Project = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'Fire tjenester bak én Docker Compose-fil. En React-app snakker med et ASP.NET Core-API (.NET 9) som eier domenelogikken og oppfølgingsreglene, og lagrer til SQLite via EF Core med vedlegg på disk. En liten FastAPI-tjeneste med en lokal Ollama-modell lager sammendrag og trekker ut ferdigheter, og API-et importerer korrespondanse fra Gmail-API-et via OAuth2.',
|
text: 'En React + TypeScript-SPA snakker med et ASP.NET Core-API som eier domenelogikken — pipeline, oppfølgingsregler, CV-nøkkelordmatch — og lagrer via EF Core med vedlegg på disk. En liten FastAPI-tjeneste med en lokal Ollama-modell skriver utkast til CV, søknadsbrev og oppfølging, og API-et importerer korrespondanse fra Gmail-API-et via OAuth2. Alt kjører bak én Docker Compose-fil.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'ul',
|
type: 'ul',
|
||||||
items: [
|
items: [
|
||||||
'Frontend: React + TypeScript, servert av nginx, installerbar som PWA med share-target for rask registrering.',
|
'Pipeline: et Kanban-brett — Søkt, Venter, Intervju, Tilbud, Avslått, Ghostet — dra for å oppdatere.',
|
||||||
'API: ASP.NET Core, EF Core, regelstyrte oppfølgingspåminnelser og historikk per søknad.',
|
'CV-match: deterministisk nøkkelorddekning (treff vs. mangler), ikke en svart boks.',
|
||||||
'AI: FastAPI over Ollama for korte og fulle sammendrag — kun rådgivende.',
|
'Gmail: importer hele tråder via OAuth2; koblede tråder oppdateres automatisk på riktig jobb.',
|
||||||
'Integrasjon: Gmail OAuth2 med smarte søkefiltre og import av enkeltmeldinger eller hele tråder.',
|
'AI-en er assisterende, aldri autonom: den skriver utkast, du gjennomgår og sender — ingen auto-søking.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -282,22 +282,22 @@ export const jobtrack: Project = {
|
|||||||
choice: 'Kjøre AI-en lokalt med Ollama i stedet for et sky-API.',
|
choice: 'Kjøre AI-en lokalt med Ollama i stedet for et sky-API.',
|
||||||
alternative: 'Et hostet LLM-API hadde vært raskere å koble til.',
|
alternative: 'Et hostet LLM-API hadde vært raskere å koble til.',
|
||||||
rationale:
|
rationale:
|
||||||
'Jobbsøkerdata er sensitivt, og jeg ville ha null kostnad per kall og ingen tredjepart i loopen. Avveiningen er mer oppsett og tyngre lokale ressurser — greit for et egendriftet verktøy med én bruker.',
|
'Jobbsøkerdata er sensitivt, og jeg ville ha null kostnad per kall og ingen tredjepart i loopen. Avveiningen er mer oppsett og tyngre lokale ressurser — greit for et egendriftet verktøy.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
n: 2,
|
n: 2,
|
||||||
|
choice: 'Gjøre CV-matchen deterministisk, ikke en AI-score.',
|
||||||
|
alternative: 'La modellen vurdere treffet.',
|
||||||
|
rationale:
|
||||||
|
'Et tall du ikke kan etterprøve er ubrukelig. Deterministisk nøkkelorddekning viser nøyaktig hvilke ord som traff og hvilke som mangler, så rådet er ærlig og handlingsrettet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3,
|
||||||
choice: 'Levere PWA-en uten offline-cache i service-workeren.',
|
choice: 'Levere PWA-en uten offline-cache i service-workeren.',
|
||||||
alternative: 'En cache ville gitt full offline-bruk.',
|
alternative: 'En cache ville gitt full offline-bruk.',
|
||||||
rationale:
|
rationale:
|
||||||
'Jeg ruller ut ofte, så en aggressiv cache risikerer å servere utdaterte bygg — en verre feil enn et kort offline-hull. Manifestet gir fortsatt installerbarhet og share-to-capture. En bevisst anti-funksjon.',
|
'Jeg ruller ut ofte, så en aggressiv cache risikerer å servere utdaterte bygg — en verre feil enn et kort offline-hull. Manifestet gir fortsatt installerbarhet og share-to-capture. En bevisst anti-funksjon.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
n: 3,
|
|
||||||
choice: 'SQLite framfor PostgreSQL.',
|
|
||||||
alternative: 'Postgres er standardvalget for en web-backend.',
|
|
||||||
rationale:
|
|
||||||
'Dette er en app for én bruker; SQLite fjerner en hel tjeneste uten praktisk ulempe her. Skulle den bli fler-bruker, er overgang til Postgres via EF Core en avgrenset endring.',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -310,8 +310,8 @@ export const jobtrack: Project = {
|
|||||||
items: [
|
items: [
|
||||||
'Valgfri Google-innlogging (Google ID-tokens) beskytter API-et; hver post er knyttet til sin eier.',
|
'Valgfri Google-innlogging (Google ID-tokens) beskytter API-et; hver post er knyttet til sin eier.',
|
||||||
'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.',
|
'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.',
|
||||||
'AI-laget er kun rådgivende — det utfører aldri destruktive handlinger.',
|
'AI-laget er kun rådgivende — det skriver utkast, det sender aldri og søker aldri automatisk.',
|
||||||
'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; planlagte JSON/CSV-eksporter for dataportabilitet.',
|
'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; JSON/CSV-eksport for dataportabilitet.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -323,12 +323,12 @@ export const jobtrack: Project = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'next',
|
kind: 'next',
|
||||||
heading: 'Hva jeg ville forbedret',
|
heading: 'Status og veien videre',
|
||||||
anchorId: 'videre',
|
anchorId: 'videre',
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'p',
|
type: 'p',
|
||||||
text: 'Regelmotoren for oppfølging er enkel datologikk; jeg ville gjort den konfigurerbar per pipeline-steg. Jeg ville også lagt til skikkelige integrasjonstester rundt kanttilfellene i Gmail-importen, og strammet inn sammendrags-promptene nå som jeg har ekte bruk å lære av.',
|
text: 'Under aktiv utvikling. Oppfølgingsreglene er enkel datologikk jeg vil gjøre konfigurerbar per stadium; neste steg er skikkelige integrasjonstester rundt kanttilfellene i Gmail-importen og strammere forankring av AI-utkastene nå som jeg har ekte bruk å lære av.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function buildJsonLd(
|
|||||||
addressLocality: 'Tønsberg',
|
addressLocality: 'Tønsberg',
|
||||||
addressCountry: 'NO',
|
addressCountry: 'NO',
|
||||||
},
|
},
|
||||||
sameAs: [p.links.linkedin, p.links.gitea],
|
sameAs: [p.links.linkedin],
|
||||||
knowsLanguage: ['en', 'nb'],
|
knowsLanguage: ['en', 'nb'],
|
||||||
knowsAbout: ['C#', '.NET', 'React', 'TypeScript', 'Python', 'SQL', 'Docker', 'Linux'],
|
knowsAbout: ['C#', '.NET', 'React', 'TypeScript', 'Python', 'SQL', 'Docker', 'Linux'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
/*
|
/*
|
||||||
Theme toggle (A6). The no-flash init in ThemeScript already set data-theme before
|
Theme toggle (A6). The no-flash init in ThemeScript already set data-theme before
|
||||||
paint; this only wires the button, persists the choice, and keeps the aria-label
|
paint; this wires every toggle (header + mobile nav), persists the choice, and keeps
|
||||||
describing the *next* action. Fail-silent if the button is absent.
|
each aria-label describing the *next* action. Fail-silent if none are present.
|
||||||
*/
|
*/
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
const btn = document.getElementById('theme-toggle');
|
const toggles = document.querySelectorAll<HTMLElement>('[data-theme-toggle]');
|
||||||
|
|
||||||
function labelFor(theme: string): string {
|
function label(el: HTMLElement, theme: string): string {
|
||||||
if (!btn) return '';
|
return theme === 'dark' ? (el.dataset.toLight ?? '') : (el.dataset.toDark ?? '');
|
||||||
return theme === 'dark' ? (btn.dataset.toLight ?? '') : (btn.dataset.toDark ?? '');
|
}
|
||||||
|
function syncLabels(theme: string): void {
|
||||||
|
toggles.forEach((el) => el.setAttribute('aria-label', label(el, theme)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn) {
|
syncLabels(html.dataset.theme ?? 'dark');
|
||||||
btn.setAttribute('aria-label', labelFor(html.dataset.theme ?? 'dark'));
|
toggles.forEach((el) =>
|
||||||
btn.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const next = html.dataset.theme === 'light' ? 'dark' : 'light';
|
const next = html.dataset.theme === 'light' ? 'dark' : 'light';
|
||||||
html.dataset.theme = next;
|
html.dataset.theme = next;
|
||||||
try {
|
try {
|
||||||
@@ -21,6 +23,6 @@ if (btn) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* private mode — ignore */
|
/* private mode — ignore */
|
||||||
}
|
}
|
||||||
btn.setAttribute('aria-label', labelFor(next));
|
syncLabels(next);
|
||||||
});
|
}),
|
||||||
}
|
);
|
||||||
|
|||||||