Compare commits

20 Commits

Author SHA1 Message Date
cesnimda 81954cbc76 style: prettier formatting for case-study content rewrite
CI / quality (push) Failing after 2m8s
CI / e2e (push) Failing after 2m7s
CI / lighthouse (push) Failing after 2m7s
CI / relay (push) Failing after 2m8s
CI / images (push) Has been skipped
2026-07-13 15:21:34 +02:00
cesnimda f070c726de content: refresh JobTrack/InboxIntel meta descriptions to match rewritten case studies 2026-07-12 20:13:24 +02:00
cesnimda 0d45b32bd5 content: rewrite JobTrack and InboxIntel case studies from current codebases
Re-audited both apps (full codebase + docs) and rewrote both case studies to a
professional-SaaS structure: Solution and Feature Showcase are new sections
(schema + FeatureList component), Engineering Highlights/Challenges reframe the
existing architecture/decisions content around real, verified engineering work
(deterministic CV match, multi-provider email abstraction, SSRF-hardened
fetcher, hybrid RRF search, preview-then-confirm cleanup), and Status & roadmap
now separates Implemented from Planned explicitly. InboxIntel copy is careful
to state it's Gmail-only today with multi-provider fully designed but not built.
2026-07-12 20:12:32 +02:00
cesnimda 29ecfc0e79 fix: case-study TOC sidebar wasn't sticking (grid stretch made it as tall as content) 2026-07-12 19:53:30 +02:00
cesnimda 97f23f7a02 deploy: drop Gitea Actions deploy job, cron autodeploy is sole path
CI / quality (push) Successful in 43s
CI / e2e (push) Successful in 1m33s
CI / lighthouse (push) Failing after 25s
CI / relay (push) Successful in 34s
CI / images (push) Failing after 12s
2026-07-12 15:27:20 +02:00
cesnimda 4cd7b3d88a fix: e2e/lighthouse CI jobs missing pnpm lockfile cache path
CI / quality (push) Successful in 49s
CI / e2e (push) Successful in 1m45s
CI / lighthouse (push) Failing after 32s
CI / relay (push) Successful in 33s
Deploy / deploy (push) Failing after 6s
CI / images (push) Failing after 2m9s
2026-07-12 15:08:19 +02:00
cesnimda dabcc68b21 fix: externalize hoisted scripts so CSP script-src 'self' allows them
CI / quality (push) Successful in 2m3s
CI / e2e (push) Failing after 10s
CI / lighthouse (push) Failing after 10s
CI / relay (push) Successful in 49s
Deploy / deploy (push) Failing after 6s
CI / images (push) Has been skipped
Astro was inlining the theme/nav/observer bundle as <script type="module">.
The deployed nginx CSP blocks inline scripts, so the reveal observer never
ran and every [data-reveal] section stayed at opacity:0 (space reserved,
nothing visible). Force JS external via assetsInlineLimit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:17:24 +02:00
cesnimda 451c5f6f43 deploy: load deploy/.env explicitly in autodeploy
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:05:11 +02:00
cesnimda 2952c957ca deploy: self-locating autodeploy (path-independent)
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:03:59 +02:00
cesnimda ec7ae1c70f deploy: move checkout to /opt/resumesite
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:02:41 +02:00
cesnimda 0cd516a424 docs: mark site live on :1337 with cron auto-deploy; update owner tasks
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:59:59 +02:00
cesnimda 07327b906f deploy: poll-based auto-deploy script (cron fallback for the runner)
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:58:29 +02:00
cesnimda c877c61e3e deploy: publish site on host port 1337; nginx proxies /api/contact; auto-deploy
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
CI / images (push) Has been cancelled
- site nginx listens on 1337; proxies /api/contact to the relay over the internal
  network (single public port; TLS terminates upstream at Cloudflare)
- trailing-slash rewrite moved inside location / so /api/contact isn't redirected
- compose: publish ${SITE_PORT:-1337}, internal bridge network, no Traefik labels
  (non-destructive — does not touch the WordPress apex router)
- .gitea/workflows/deploy.yml: on push to main, runner rebuilds + restarts the stack

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:00:33 +02:00
cesnimda 8132c202ba 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>
2026-07-10 09:54:30 +02:00
cesnimda d9dd4f432e chore: gitignore .design-sync artifact
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
CI / images (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:24:01 +02:00
cesnimda 239d3dec0e docs: Master Career Profile — single source of truth for career materials
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
CI / images (push) Has been cancelled
Aggregates identity, positioning, summaries (3 lengths, EN/NO), skills taxonomy, experience,
projects (incl. verified homelab detail), achievements bank, ready-made LinkedIn/bio/cover-letter
snippets, and a voice/usage guide for generating CVs, cover letters, LinkedIn and bios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:23:09 +02:00
cesnimda bfa8cfd357 feat: homelab case study from live infra; Traefik-based deploy
- rewrote homelab (content + topology diagram + stack) from a live inspection of the host:
  Ubuntu 24.04, ~30 Docker services behind Traefik (Cloudflare-fronted, TLS, HTTP/3),
  Authentik SSO forward-auth, CrowdSec, Pi-hole, self-hosted Gitea + CI runner, socket-proxy,
  per-app network isolation; WordPress framed as being decommissioned (not future arch)
- deploy: docker-compose now uses Traefik labels + traefik_proxy network (was assumed nginx
  edge); .env.example adds SITE_HOST/TRAEFIK_ENTRYPOINT; colophon + ARCHITECTURE/DOCKER/
  DEPLOYMENT specs corrected nginx-edge -> Traefik (site container still serves via nginx)
- PROJECT_STATUS: pre-launch checklist updated; infra section added

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:23:09 +02:00
cesnimda a10418ab93 fix: CV split-button height + LinkedIn redirect; tooling tidy
- CV split button: make the chevron <details> flex and the summary full-height so the
  toggle matches the primary button height (verified 39.1px == 39.1px)
- nginx: /Linkedin now 301s to the real profile (linkedin.com/in/connor-babbington)
- prettier-ignore .pnpm-store; gitignore .claude/settings.local.json; e2e port 4321->4399;
  dev preview autoPort

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:22:48 +02:00
cesnimda b4b293bdb4 feat: real headshot + JobTrack seed-data screenshots
- portrait: warm outdoor headshot, cropped 4:5 and optimised to WebP (800x1000, 116kB),
  used in hero + about
- JobTrack case study: three real seed-data captures (applications, dashboard, reminders)
  optimised to WebP (~40kB each) replacing the placeholder skeletons
- alts/captions updated to match the real screens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:22:48 +02:00
cesnimda 18c556bd54 feat: ATS-safe CV rework (EN + NO), replacing scrambled two-column PDFs
CI / quality (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / lighthouse (push) Has been cancelled
CI / relay (push) Has been cancelled
CI / images (push) Has been cancelled
- single-column, real-text PDFs: standard headings (Summary, Skills, Experience,
  Projects, Education), consistent dates, no letter-spacing
- 'Systems Developer, eight years experience' (drops 'mid-level'); apprentice->developer
  progression shown; Projects section added; Norwegian written natively (errors fixed)
- one page each, ~8 kB (was 132/172 kB); verified clean ATS text extraction + reading order
- generator committed to tools/cv (content.js + pdfkit/docx builders) for reproducibility
- editable .docx kept alongside; site file-size labels updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:22:57 +02:00
52 changed files with 1462 additions and 305 deletions
+3 -2
View File
@@ -4,8 +4,9 @@
{
"name": "site",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["-C", "site", "dev", "--host", "--port", "4321"],
"port": 4321
"runtimeArgs": ["-C", "site", "dev", "--host"],
"port": 4321,
"autoPort": true
}
]
}
+1
View File
@@ -2,6 +2,7 @@
* text=auto eol=lf
# Binary assets — never touch.
*.docx binary
*.png binary
*.jpg binary
*.jpeg binary
+3 -2
View File
@@ -30,7 +30,8 @@ jobs:
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
with:
{ node-version: 22, cache: pnpm, cache-dependency-path: '**/pnpm-lock.yaml' }
- run: pnpm -C site install --frozen-lockfile
- run: pnpm -C tests install --frozen-lockfile
- run: pnpm -C tests exec playwright install --with-deps chromium
@@ -43,7 +44,7 @@ jobs:
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
with: { node-version: 22, cache: pnpm, cache-dependency-path: site/pnpm-lock.yaml }
- run: pnpm -C site install --frozen-lockfile
- run: pnpm -C site build
- run: pnpm dlx @lhci/cli@0.14.x autorun --config=site/lighthouserc.json
+2
View File
@@ -26,6 +26,8 @@ relay/appsettings.*.local.json
Thumbs.db
.vscode/
.idea/
.claude/settings.local.json
.design-sync/
# Mission prompt scratch files (kept locally, not part of the product)
step1.txt
+22 -13
View File
@@ -59,19 +59,28 @@
6. **Theme-init is an external script** (`/theme-init.js`) so the CSP can use `script-src
'self'` with no inline hash.
## Pre-launch checklist (carried from Phase 1/2 — owner action)
## Deployed — live
- [ ] **Replace the `/Linkedin` redirect target** in `site/nginx.conf` with the real LinkedIn
profile URL before cutover (currently a placeholder slug).
- [ ] Screenshot capture pass on **seeded demo data** for JobTrack + InboxIntel; drop real
captures into the `placeholder:` media refs (`src/data/projects/*`).
- [ ] Drop the real **outdoor headshot** into the portrait ref (replaces `placeholder:portrait`).
- [ ] **ATS-safe CV rework** (EN/NO) — replace the PDFs at `site/public/cv/` (same URLs).
- [ ] **Native-Norwegian review** of all NO content (launch gate).
- [ ] **Legacy WordPress URL inventory** → confirm the 301/410 map in `nginx.conf`.
- [ ] Configure CI secrets (`REGISTRY_USER`, `REGISTRY_TOKEN`) + the relay `.env` on the server.
- **Live on the `mediaserver` host at `http://<host>:1337`.** Stack: `deploy-site-1`
(nginx static, publishes 1337) + `deploy-relay-1` (contact relay, internal). Verified: all
routes + `/no/`, case studies, CV PDFs, images, `/Linkedin` 301, `/api/contact` proxy.
- **Auto-deploy:** a 1-minute cron on the host (`deploy/autodeploy.sh`) pulls `main` and
`docker compose up -d --build` on change. Sole deploy path — the Gitea Actions `deploy.yml`
job was removed (its runner's job containers have no Docker socket, and mounting one in would
give any CI job root-equivalent host access).
- Repo cloned at `/home/pi/resumesite` (remote carries the deploy token — rotate/scrub if desired).
## Open questions / blocked-on-user
## Owner action remaining
- Are the JobTrack / InboxIntel repos public? If so, add `repo` links in `src/data/projects/*`
(currently omitted to avoid dead links).
- [ ] **Point Cloudflare / your TLS proxy at host:1337** to serve it on `cesnimda.co.uk`
(retire the WordPress apex router as part of the cutover).
- [ ] **Relay SMTP** — add creds to `/home/pi/resumesite/deploy/.env` (`SMTP_HOST/USER/PASSWORD`)
so the contact form sends; until then it degrades to the visible e-mail (POST returns 502).
- [ ] **Native-Norwegian review** of all NO content, incl. the NO CV.
- [ ] **InboxIntel screenshots** — mockups are in; swap for live captures if/when available.
- [ ] **Legacy WordPress URL inventory** → confirm the 410 map in `nginx.conf`.
## Resolved
- JobTrack / InboxIntel repos are **private** → repo links intentionally omitted; case studies
describe the architecture without linking code.
+2 -1
View File
@@ -15,4 +15,5 @@ RELAY_WINDOW_SECONDS=600
PUBLIC_SITE_URL=https://cesnimda.co.uk
# --- Infra ---
PROXY_NETWORK=web # name of the existing external reverse-proxy docker network
SITE_PORT=1337 # host port the public-facing site is published on
# (put Cloudflare / your TLS proxy in front of it)
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Poll-based auto-deploy: pull main and rebuild the stack when it changes.
# ponytail: 1-minute cron poll, not a webhook — swap for the Gitea runner
# (.gitea/workflows/deploy.yml) once the runner's job image has the Docker CLI + socket.
set -e
REPO=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) # repo root, wherever this lives
exec 9>/tmp/resumesite-deploy.lock
flock -n 9 || exit 0 # a build is already running
cd "$REPO"
git fetch -q origin main
[ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] && exit 0
git reset -q --hard origin/main
ENVFILE=""
[ -f deploy/.env ] && ENVFILE="--env-file deploy/.env"
docker compose $ENVFILE -f deploy/docker-compose.yml up -d --build --remove-orphans
+12 -9
View File
@@ -1,6 +1,6 @@
# Production stack (DOCKER_SPEC §2). Publishes no host ports — the existing host
# reverse proxy routes cesnimda.co.uk -> site:8080 and /api/contact -> relay:8081
# over the shared external proxy network.
# Production stack. The public-facing site is published on host port 1337 (put your
# TLS terminator / Cloudflare in front of it). nginx inside the site container proxies
# /api/contact to the relay over the internal network, so only one port is exposed.
services:
site:
@@ -14,6 +14,10 @@ services:
- /tmp
- /var/cache/nginx
- /var/run
ports:
- '${SITE_PORT:-1337}:1337'
depends_on:
- relay
networks: [web]
logging:
driver: json-file
@@ -27,12 +31,12 @@ services:
restart: unless-stopped
read_only: true
environment:
- Smtp__Host=${SMTP_HOST}
- Smtp__Host=${SMTP_HOST:-}
- Smtp__Port=${SMTP_PORT:-587}
- Smtp__User=${SMTP_USER}
- Smtp__Password=${SMTP_PASSWORD}
- Smtp__User=${SMTP_USER:-}
- Smtp__Password=${SMTP_PASSWORD:-}
- Relay__FromAddress=${RELAY_FROM:-}
- Relay__ToAddress=${RELAY_TO}
- Relay__ToAddress=${RELAY_TO:-connor.babbington@cesnimda.co.uk}
- Relay__AllowedOrigin=${RELAY_ALLOWED_ORIGIN:-https://cesnimda.co.uk}
- Relay__RateLimitPerWindow=${RELAY_RATE_LIMIT:-5}
- Relay__WindowSeconds=${RELAY_WINDOW_SECONDS:-600}
@@ -43,5 +47,4 @@ services:
networks:
web:
external: true
name: ${PROXY_NETWORK:-web}
driver: bridge
+277
View File
@@ -0,0 +1,277 @@
# Master Career Profile — Connor Babbington
**Single source of truth** for generating CVs, cover letters, LinkedIn content, bios and
other career materials. Everything here is factual and verified against the CV, the
portfolio site content, the project repositories, and a live inspection of the
infrastructure (July 2026). When generating any artifact, pull from this document and
follow the **Voice & usage guide** at the end.
Related sources: [`design/02-ux/CANONICAL_CONTENT.md`](design/02-ux/CANONICAL_CONTENT.md)
(original CV facts) · site content in `site/src/data/` · CV generator in `tools/cv/`.
---
## 1. Identity & contact
| Field | Value |
|---|---|
| Name | Connor Babbington |
| Title (self-label) | **Systems Developer** (NO: *Systemutvikler*) |
| Location | Tønsberg, Norway |
| Work eligibility | Valid Norwegian residence permit |
| E-mail | connor.babbington@cesnimda.co.uk |
| Phone | +47 41 33 44 70 |
| Website | https://cesnimda.co.uk |
| LinkedIn | https://www.linkedin.com/in/connor-babbington |
| Git (self-hosted) | https://git.cesnimda.uk/cesnimda |
| Languages | English (native) · Norwegian (B1, actively developing) |
| Availability | Open to remote, hybrid or on-site developer roles |
## 2. Positioning
**Core positioning:** a systems developer with **eight years' experience** building and
maintaining production software for UK local government, now building and self-hosting
full-stack products in Norway. Backend-leaning full-stack, with genuine DevOps and
operations depth.
- **Never** self-label "senior" or "mid-level". Lead with "eight years' experience" and
let the evidence (production systems, security decisions, operations) carry seniority.
- Emphasis differs by audience: for Norwegian public/SME employers, foreground the
**public-sector systems background** and **operations/security**; for product companies,
foreground **full-stack delivery** and the **case studies**.
**Headline options (LinkedIn / CV subtitle):**
- `Systems Developer · .NET, full-stack & infrastructure · Tønsberg, Norway`
- `Systems Developer — 8 years building & running production software`
- `Backend-leaning full-stack developer (.NET, React, Docker) · self-hosting enthusiast`
- NO: `Systemutvikler · .NET, fullstack og infrastruktur · Tønsberg`
## 3. Professional summary (three lengths)
**One line:**
> Systems developer with eight years' experience building, shipping and running
> production software.
**Short (23 sentences):**
> Systems developer with eight years' experience delivering internal software for UK local
> government. Backend-leaning full-stack across C#, .NET, Python, JavaScript/TypeScript and
> SQL, with hands-on DevOps in Docker, Linux and CI/CD. Now building and self-hosting
> full-stack products in Norway.
**Paragraph:**
> Systems developer with eight years' experience building and maintaining production
> software for UK local government. A backend-leaning full-stack developer across C#, .NET,
> Python, JavaScript/TypeScript and SQL, with hands-on DevOps in Docker, Linux, CI/CD, Azure
> DevOps and GitHub. I turn stakeholder requirements into reliable, well-tested systems and
> support them in production — and I care about the parts users never see: tests,
> deployments and keeping things running. Alongside professional work I design, build and
> self-host my own products and a ~30-service home infrastructure lab, which keeps my skills
> current and hands-on. Based in Tønsberg with a valid residence permit; native English
> speaker with Norwegian at B1 and actively developing.
**Norwegian (paragraph):**
> Systemutvikler med åtte års erfaring med å bygge og vedlikeholde produksjonssystemer i
> britisk offentlig sektor. Fullstack-utvikler med tyngde på backend i C#, .NET, Python,
> JavaScript/TypeScript og SQL, med praktisk DevOps i Docker, Linux, CI/CD, Azure DevOps og
> GitHub. Jeg omsetter behov fra brukere og interessenter til pålitelige, godt testede
> systemer og drifter dem i produksjon. Ved siden av jobb bygger og drifter jeg egne
> produkter og en hjemmelab med rundt tretti tjenester. Bosatt i Tønsberg med gyldig
> oppholdstillatelse; engelsk morsmål, norsk på B1-nivå og i aktiv utvikling.
## 4. Skills taxonomy
Grouped for CV/LinkedIn. Every item is defensible in an interview — don't pad.
**Languages:** C#, Python, JavaScript, TypeScript, SQL, Ruby (Ruby on Rails, from the
council role). *(HTML/CSS assumed.)*
**Frameworks & platforms:** .NET / ASP.NET Core, React, FastAPI, Ruby on Rails, Astro,
Entity Framework Core, Node.js.
**Data:** SQL databases, PostgreSQL, SQLite, MariaDB, Redis, EF Core.
**DevOps & infrastructure:** Docker & Docker Compose, Linux (Ubuntu), CI/CD, Azure DevOps,
GitHub, Gitea + Actions runners, Traefik, nginx, reverse proxies, Cloudflare, self-hosting,
container orchestration.
**Security & identity:** OAuth2 integrations, encrypted secrets at rest (Data Protection
API), Authentik (SSO / forward-auth), CrowdSec (intrusion prevention), Pi-hole (DNS),
security hardening, preview-then-confirm destructive operations.
**AI / integrations:** local LLMs via Ollama, AI-assisted summarisation, Gmail API (OAuth2).
**Architecture & practices:** Clean Architecture, full-stack design, REST APIs, background
workers, testing (unit + integration), production support & troubleshooting, stakeholder
communication, cross-functional collaboration, WCAG accessibility, SEO.
**Tools:** Git, Portainer, Serilog, Polly, QuestPDF, Chart.js, Playwright, Vitest.
## 5. Experience
### System Developer — Warwickshire County Council, UK · 20152023
*(First two years completed as an apprenticeship; progressed to full developer.)*
- Worked on the **county-wide highways and streetlight fault-reporting system** used across
Warwickshire.
- Designed, built and maintained **full-stack applications** in C#, Python, Ruby on Rails,
SQL and JavaScript across several projects.
- Delivered reliable, well-tested software; resolved stability and performance issues on
live systems.
- Owned **deployments and production troubleshooting**; strengthened permissions and
reliability.
- Produced clean, maintainable, documented code; **guided colleagues** on best practices for
code and process.
- Context for framing: UK **local government / public sector** — maps directly to Norwegian
*kommune/fylkeskommune* systems work.
### Independent Development & Norwegian Study — Tønsberg, Norway · 2023Present
- Building and self-hosting full-stack products (JobTrack, InboxIntel) and operating a
~30-service home infrastructure lab.
- Developing Norwegian language skills (norskkurs) toward professional fluency.
### Earlier roles (part-time, alongside the above; customer-facing)
- Sales Representative — Royal Vapes, UK · 20172021
- Bartender — The Hodcarrier, UK · 20162018
- Receptionist — Nuffield Health, UK · 20142015
*Framing:* held alongside the council role; evidence of communication skills and work ethic.
De-emphasise on technical CVs; a single line usually suffices.
## 6. Projects (portfolio)
### JobTrack — full-stack job-application tracker · *Active*
- **What:** a workspace for the whole job-application journey — pipeline, correspondence,
attachments, AI summaries, reminders and analytics.
- **Stack:** React + TypeScript SPA · ASP.NET Core (.NET 9) API · SQLite (EF Core) · FastAPI +
Ollama local AI service · Docker Compose · installable PWA with a mobile share-target.
- **Key decisions:** local AI (Ollama) over a cloud API for privacy and zero per-call cost;
**no offline PWA cache by design** (frequent deploys → stale builds are worse than offline
gaps); SQLite over Postgres for a single-user app.
- **Security/production:** optional Google sign-in (ID tokens), per-owner record scoping,
validated file uploads with ownership checks, advisory-only AI, scheduled JSON/CSV exports.
- **Talking points:** product thinking end-to-end; a deliberate anti-feature with reasoning;
integration depth (Gmail OAuth2).
### InboxIntel — Gmail analytics & safe bulk cleanup · *In development*
- **What:** Gmail analytics, cleanup and unsubscribe management with a draggable dashboard.
- **Stack:** .NET 8 · Clean Architecture (Domain → Application → Infrastructure → Api) ·
PostgreSQL (EF Core + Npgsql) · React 18 + Vite + Chart.js · hosted background sync worker ·
Serilog · FluentValidation · Polly · Gmail API.
- **Key decisions:** four-project Clean Architecture with an enforced dependency rule;
**OAuth refresh tokens encrypted at rest** (ASP.NET Data Protection API / AES, never
logged); **every destructive action is preview-then-confirm**; advisory-only AI.
- **Security/production:** Gmail read/modify scopes only (no send); Polly retry/backoff;
unit tests (query parser, unsubscribe extraction) + integration tests asserting authz.
- **Talking points:** architecture discipline; safe-by-design destructive operations.
### Self-hosted infrastructure lab · *Active*
- **What:** an Ubuntu 24.04 server running **~30 Docker services** behind a single reverse
proxy — the deploy target for the portfolio site itself.
- **Platform:** **Traefik** (TLS, HTTP/3, Cloudflare-fronted, separate internal/external
entrypoints) · **Authentik** SSO as forward-auth in front of internal tools · **CrowdSec**
intrusion prevention · **Pi-hole** DNS filtering.
- **CI/CD & data:** self-hosted **Gitea + Actions runner** (git.cesnimda.uk) building and
deploying this site; PostgreSQL, MariaDB, Redis; Portainer + Dozzle for management/logs.
- **Security posture:** Docker socket exposed only via a hardened socket-proxy; each app on
its own isolated Docker network.
- **Also runs:** application containers (JobTrack) and a media stack (Jellyfin + *arr).
- **Talking points:** real operations and security experience, not just deployment; network
segmentation; SSO/IPS/DNS in a home setting; runs its own CI/CD.
### The portfolio site itself (cesnimda.co.uk)
- Astro 5 static, bilingual (EN/NO), Tailwind 4 over design tokens, CSS-first motion, a
slug-map i18n contract, a small .NET 9 contact relay, WCAG AA accessibility, Lighthouse
budgets, and full CI — self-hosted in Docker behind Traefik. A work sample in its own right.
## 7. Education
- **Extended Diploma — NVQ Level 3 in ICT**, Warwickshire College, UK · 20122015.
Professional education in IT: programming, systems administration and IT support.
## 8. Achievement / evidence library (bullet bank)
Reusable, outcome-oriented bullets for CVs and cover letters:
- Delivered and maintained internal software used across a UK county council for eight years.
- Contributed to a **county-wide highways/streetlight fault-reporting system**.
- Replaced spreadsheet-heavy and manual workflows with reliable internal systems.
- Built full-stack applications in five languages (C#, Python, Ruby on Rails, SQL, JS),
adapting across frameworks as projects changed.
- Owned deployments and production troubleshooting on live systems.
- Designed and shipped two full-stack products end-to-end (product, backend, frontend, ops,
security).
- Made and documented real engineering trade-offs (local vs cloud AI; deliberate anti-feature;
Clean Architecture; encrypted secrets at rest).
- Operate a ~30-service self-hosted infrastructure with SSO, intrusion prevention and CI/CD.
- Built an accessible (WCAG AA), performant, bilingual site with automated testing and CI.
## 9. Working style & soft skills
Problem solving · troubleshooting · system improvement · stakeholder communication ·
production support · cross-functional collaboration · mentoring/guiding colleagues ·
comfortable with both structured and open-ended tasks · effective independently and in a
team · takes initiative · dependable and detail-oriented.
## 10. Interests
PC and board games (strategic thinking, problem solving), cooking, and continually learning
new skills. Keep to one line on formal documents.
## 11. Ready-made assets
**Elevator pitch (spoken, ~20s):**
> I'm a systems developer with eight years building and running production software for UK
> local government — backend-leaning full-stack in .NET, plus real DevOps. I moved to Norway
> and I've been shipping my own full-stack products and running a fairly serious home
> infrastructure lab. I'm looking for a developer role where I can build things and help keep
> them running.
**LinkedIn headline:**
> Systems Developer · .NET, full-stack & infrastructure · 8 years · Tønsberg, Norway
**LinkedIn "About":**
> Systems developer with eight years' experience building and maintaining production software
> for UK local government. Backend-leaning full-stack across C#, .NET, Python, JavaScript/
> TypeScript and SQL, with hands-on DevOps in Docker, Linux and CI/CD. I like turning real
> requirements into reliable, well-tested systems — and I care about the parts users never
> see: tests, deployments and keeping things running.
>
> Outside work I design, build and self-host my own products (a full-stack job-application
> tracker and a Gmail analytics tool) and run a ~30-service home infrastructure lab with
> Traefik, single sign-on, intrusion prevention and self-hosted CI/CD. Based in Tønsberg with
> a valid residence permit; native English, Norwegian at B1 and improving. Open to remote,
> hybrid or on-site developer roles.
**Short bio (third person, ~40 words):**
> Connor Babbington is a systems developer with eight years' experience building production
> software for UK local government. Backend-leaning full-stack in .NET with real DevOps
> depth, he now builds and self-hosts his own products from Tønsberg, Norway.
**Cover-letter opener (template):**
> I'm a systems developer with eight years' experience delivering and running production
> software, and I'm writing about the [ROLE] position at [COMPANY]. [ONE SENTENCE connecting a
> specific project/skill to their need.] I turn requirements into reliable, well-tested
> systems and I'm comfortable owning them in production.
## 12. Voice & usage guide
**Tone:** plain-spoken, evidence-led, understated. Calibrated for the Norwegian market —
avoid grandiosity (no "rockstar/ninja/guru", no "senior" unless a specific role warrants it).
Let concrete facts carry weight.
**When generating an artifact:**
- **CV:** use §3 short summary + §4 skills + §5 experience + §6 projects (2 lines each) + §7
education. Keep to one page; ATS-safe single column; the generator in `tools/cv/` already
encodes this. Positioning = "eight years", never "mid-level".
- **Cover letter:** open with §11 template; pick 23 bullets from §8 that match the job;
close on availability + permit + location fit.
- **LinkedIn:** headline + About from §11; Experience from §5; Skills from §4.
- **Bio:** pick the length from §11 (or §3).
- **Norwegian materials:** use the native NO summary in §3; have a fluent speaker review
before sending (the NO here is solid but not yet native-reviewed).
**Facts to never overstate:** don't claim "senior"; don't invent user numbers (say
"county-wide"); InboxIntel is "in development"; project repos are **private** (describe, don't
link); Norwegian is **B1**.
**Maintenance:** update this document when facts change (new role, project, certification,
language level), then regenerate downstream materials from it. Keep it the single source of
truth — edit here first, propagate outward.
+1 -1
View File
@@ -7,7 +7,7 @@ System architecture for the bilingual portfolio. Companion detail specs: ROUTING
```
┌──────────────────────────── Connor's server ───────────────────────────┐
Browser ── HTTPS ──► │ nginx (existing reverse proxy, TLS)
Browser ── HTTPS ──► │ Traefik (existing host reverse proxy, TLS; Cloudflare-fronted)
│ ├── cesnimda.co.uk/ → [site] nginx container (static) │
│ ├── cesnimda.co.uk/api/contact → [contact-relay] .NET 9 container │
│ └── git.cesnimda.uk → Gitea (existing) │
+1 -1
View File
@@ -33,7 +33,7 @@ push / PR to main
1. Pre-launch: staging fully verified (both locales, CV downloads, form → real mailbox, redirects incl. `/Linkedin`, 410 map, OG previews via LinkedIn/Slack debuggers, Search Console fetch).
2. URL inventory + redirect/410 map finalised (SEO_SPEC §6).
3. Cutover = repoint host nginx server-block from WP to the `site` container (single reload; WP left running dark for 2 weeks as instant rollback).
3. Cutover = move the `cesnimda.co.uk` Traefik Host router from the WordPress container to the `site` container (a label change + `compose up -d`; WP left running dark for 2 weeks as instant rollback).
4. Post-launch checklist: submit sitemaps, watch Search Console coverage + relay logs for form spam patterns, then decommission WP + its PHP/MySQL surface (attack-surface win worth noting on the colophon).
## 4. Operational guardrails
+1 -1
View File
@@ -31,7 +31,7 @@ deploy/
.env.example # documented variables, no real values
```
- Prod compose joins the server's existing reverse-proxy network; the host nginx (existing, TLS-terminating) routes `cesnimda.co.uk``site:8080` and `cesnimda.co.uk/api/contact``contact-relay:8081`. The stack publishes **no host ports** itself.
- Prod compose joins the server's existing Traefik network (`traefik_proxy`); the host Traefik (existing, TLS-terminating, Cloudflare-fronted) discovers the containers via labels and routes `cesnimda.co.uk``site:8080` and `cesnimda.co.uk/api/contact``relay:8081` (the relay's `PathPrefix` rule outranks the site's `Host` rule). The stack publishes **no host ports** itself.
- `restart: unless-stopped`, log rotation via compose logging options, images referenced by registry tag + digest.
- Optional third service slot (`analytics`, self-hosted Umami/Plausible) reserved in the compose file as a commented profile — decision deferred.
+1
View File
@@ -1,6 +1,7 @@
dist/
.astro/
node_modules/
.pnpm-store/
pnpm-lock.yaml
public/
*.pdf
+1 -1
View File
@@ -14,5 +14,5 @@ RUN pnpm build
FROM nginxinc/nginx-unprivileged:1.27-alpine AS final
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html
EXPOSE 8080
EXPOSE 1337
# runs as uid 101 (nginx) by default in this image
+4
View File
@@ -26,6 +26,10 @@ export default defineConfig({
compressHTML: true,
vite: {
plugins: [tailwindcss()],
// Never inline JS: the deployed CSP is `script-src 'self'`, which blocks inline
// <script> modules. Keep hoisted scripts as external /_astro/*.js so CSP allows them.
// (Images etc. fall back to the default inline threshold.)
build: { assetsInlineLimit: (file) => (file.endsWith('.js') ? false : undefined) },
resolve: {
alias: {
'@': r('./src'),
+14 -7
View File
@@ -3,7 +3,7 @@
# trailing slashes, the preserved /Linkedin redirect, and legacy-WP 410s.
server {
listen 8080;
listen 1337;
server_name _;
root /usr/share/nginx/html;
index index.html;
@@ -27,9 +27,17 @@ server {
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml application/xml+rss;
# --- Preserved LinkedIn redirect (printed on the CVs). Replace slug before cutover. ---
# --- Preserved LinkedIn redirect (printed on the CVs). ---
location = /Linkedin {
return 301 https://www.linkedin.com/in/REPLACE-WITH-REAL-SLUG/;
return 301 https://www.linkedin.com/in/connor-babbington;
}
# --- Contact relay (same-origin; single public port) ---
location = /api/contact {
proxy_pass http://relay:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# --- Legacy WordPress URLs -> 410 Gone (crawler cleanup) ---
@@ -59,11 +67,10 @@ server {
add_header Cache-Control "public, must-revalidate" always;
}
# --- Canonical trailing slash for extensionless paths (SEO) ---
rewrite ^([^.]*[^/])$ $1/ permanent;
# --- HTML documents: revalidate so deploys are instant (inherits headers above) ---
# --- HTML documents: canonical trailing slash + revalidate. Kept inside the
# catch-all so exact routes (e.g. /api/contact) are never slash-redirected. ---
location / {
rewrite ^([^.]*[^/])$ $1/ permanent;
try_files $uri $uri/ =404;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,28 @@
---
interface Feature {
title: string;
value: string;
technical: string;
}
interface Props {
features: Feature[];
}
const { features } = Astro.props;
---
<ol class="flex flex-col gap-5">
{
features.map((f, i) => (
<li class="border-line bg-surface-1 rounded-md border p-5">
<p class="flex gap-3">
<span class="text-body text-accent font-mono font-semibold">
{String(i + 1).padStart(2, '0')}
</span>
<span class="text-ink font-semibold">{f.title}</span>
</p>
<p class="text-body text-ink-muted mt-2 pl-8">{f.value}</p>
<p class="text-small text-ink-faint border-line mt-2 ml-8 border-l pl-3">{f.technical}</p>
</li>
))
}
</ol>
+1 -1
View File
@@ -10,7 +10,7 @@ const { locale, sections } = Astro.props;
const d = useDict(locale);
---
<nav class="sticky top-24 hidden xl:block" aria-label={d.project.onThisPage}>
<nav class="sticky top-24 hidden self-start xl:block" aria-label={d.project.onThisPage}>
<p class="mono-label mb-3">{d.project.onThisPage}</p>
<ul class="border-line flex flex-col gap-2 border-l">
{
@@ -2,6 +2,7 @@
import type { Locale } from '@i18n/locales';
import ArchDiagram from '@components/case-study/ArchDiagram.astro';
import DecisionList from '@components/case-study/DecisionList.astro';
import FeatureList from '@components/case-study/FeatureList.astro';
import Gallery from '@components/case-study/Gallery.astro';
interface Block {
@@ -15,6 +16,7 @@ interface Section {
anchorId: string;
blocks?: Block[];
decisions?: { n: number; choice: string; alternative: string; rationale: string }[];
features?: { title: string; value: string; technical: string }[];
}
interface Media {
src: string;
@@ -91,6 +93,14 @@ const galleryMedia = media.slice(1); // media[0] is the hero shown above the art
)
}
{
section.kind === 'features' && section.features && (
<div class="mt-6">
<FeatureList features={section.features} />
</div>
)
}
{
section.kind === 'screenshots' &&
(galleryMedia.length > 0 ? (
-5
View File
@@ -58,11 +58,6 @@ const nav: { id: PageId; label: string }[] = [
>LinkedIn ↗</a
>
</li>
<li>
<a class="hover:text-ink transition-colors" href={p.links.gitea} rel="me noopener"
>git.cesnimda.uk ↗</a
>
</li>
<li>
<a class="hover:text-ink transition-colors" href={pathFor('cv', locale)}
>{d.cv.download}</a
+1 -1
View File
@@ -26,7 +26,7 @@ const isCurrent = (id: PageId) =>
<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
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"
-1
View File
@@ -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">
<a href={`mailto:${p.links.email}`}>{p.links.email}</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>
+2 -2
View File
@@ -34,7 +34,7 @@ const copy = {
],
[
'Self-hosted',
'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.',
'Built and deployed from my own Gitea instance to Docker behind Traefik. The contact form is a small stateless .NET service — even that is part of my own stack.',
],
[
'No tracking',
@@ -59,7 +59,7 @@ const copy = {
],
[
'Egendriftet',
'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.',
'Bygget og rullet ut fra min egen Gitea-instans til Docker bak Traefik. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.',
],
[
'Ingen sporing',
@@ -122,13 +122,6 @@ const fieldClass =
class="text-body text-ink hover:text-accent">LinkedIn ↗</a
>
</li>
<li>
<a
href={profile.links.gitea}
rel="me noopener"
class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a
>
</li>
<li>
<a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent"
>{profile.links.phone}</a
+2 -2
View File
@@ -30,9 +30,9 @@ const currentLabel = locale === 'en' ? d.cv.english : d.cv.norsk;
{d.cv.download}
{!compact && <span class="text-mono-label ml-2 font-mono opacity-70">EN/NO</span>}
</a>
<details class="cv-menu">
<details class="cv-menu flex">
<summary
class="border-accent-ink/20 bg-accent text-accent-ink flex cursor-pointer list-none items-center rounded-r-sm border-l px-2 transition-[filter] duration-[--dur-quick] hover:brightness-105"
class="border-accent-ink/20 bg-accent text-accent-ink flex h-full cursor-pointer list-none items-center rounded-r-sm border-l px-2 transition-[filter] duration-[--dur-quick] hover:brightness-105"
aria-label={d.cv.download}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+1 -1
View File
@@ -10,7 +10,7 @@ const d = useDict(locale);
---
<button
id="theme-toggle"
data-theme-toggle
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]"
aria-label={d.theme.toLight}
+1 -1
View File
@@ -11,7 +11,7 @@ const { statusLine, class: cls } = Astro.props;
---
<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
class="trace-line"
d="M2 12 H150 l10 10 H194"
+6 -6
View File
@@ -30,36 +30,36 @@ export const meta: Meta = {
en: {
title: 'JobTrack — case study · Connor Babbington',
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: Next.js, ASP.NET Core, a Kanban pipeline, deterministic CV match and multi-provider recruiter threads.',
},
no: {
title: 'JobTrack — prosjektgjennomgang · Connor Babbington',
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: Next.js, ASP.NET Core, Kanban-pipeline, deterministisk CV-match og korrespondanse fra flere kanaler.',
},
},
inboxintel: {
en: {
title: 'InboxIntel — case study · Connor Babbington',
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 tool: hybrid semantic + full-text search (pgvector + Ollama), AI summaries and inbox-health analytics, built as Clean Architecture.',
},
no: {
title: 'InboxIntel — prosjektgjennomgang · Connor Babbington',
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 innboksverktøy: hybrid semantisk + fulltekst-søk (pgvector + Ollama), AI-sammendrag og innboks-helse, bygget som Clean Architecture.',
},
},
homelab: {
en: {
title: 'Self-hosted infrastructure lab — Connor Babbington',
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: {
title: 'Egendriftet hjemmelab — Connor Babbington',
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: {
+5 -5
View File
@@ -15,11 +15,11 @@ export const profile: Profile = {
gitea: 'https://git.cesnimda.uk/cesnimda',
},
cv: {
en: { path: '/cv/connor-babbington-cv-en.pdf', sizeKb: 132, updated: '2026-07' },
no: { path: '/cv/connor-babbington-cv-no.pdf', sizeKb: 172, 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: 296, updated: '2026-07' },
},
photo: {
src: 'placeholder:portrait',
src: '/assets/portrait.webp',
alt: {
en: 'Connor Babbington, smiling, outdoors',
no: 'Connor Babbington, smilende, utendørs',
@@ -71,7 +71,7 @@ export const profile: Profile = {
aboutHeading: 'About',
aboutParagraphs: [
'Im 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 its 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 its where I get to make the architecture decisions end to end.',
'Im 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 Im into PC and board games, cooking, and generally learning new things.',
],
interestsLine: 'PC & board games · cooking · always learning something new',
@@ -88,7 +88,7 @@ export const profile: Profile = {
aboutHeading: 'Om meg',
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.',
'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.',
],
interestsLine: 'PC- og brettspill · matlaging · lærer alltid noe nytt',
+77 -59
View File
@@ -1,6 +1,10 @@
import type { Project } from '@lib/schema';
/* Homelab — capability page (lighter template): no TL;DR/decisions/gallery rendered. */
/*
Homelab — capability page (lighter template): no TL;DR/decisions/gallery rendered.
Content reflects the real setup inspected on the host (Ubuntu 24.04, ~30 Docker
services behind Traefik with Authentik SSO, CrowdSec, Pi-hole, Gitea CI).
*/
export const homelab: Project = {
id: 'homelab',
name: 'Self-hosted infrastructure lab',
@@ -10,88 +14,100 @@ export const homelab: Project = {
stack: [
{ name: 'Ubuntu' },
{ name: 'Docker' },
{ name: 'nginx' },
{ name: 'Gitea' },
{ name: 'Monitoring' },
{ name: 'Backups' },
{ name: 'Traefik' },
{ name: 'Authentik' },
{ name: 'CrowdSec' },
{ name: 'Gitea CI' },
],
links: [],
diagram: {
viewBox: '0 0 1120 300',
viewBox: '0 0 1120 310',
title: { en: 'Homelab topology', no: 'Hjemmelab-topologi' },
desc: {
en: 'Inbound traffic reaches an nginx reverse proxy terminating TLS, which routes to self-hosted services: Gitea, application containers and a monitoring stack.',
no: 'Innkommende trafikk når en nginx reverse proxy som terminerer TLS, og rutes videre til egendriftede tjenester: Gitea, applikasjonscontainere og en overvåkingsstack.',
en: 'Traffic arrives through Cloudflare to a Traefik reverse proxy that terminates TLS and applies Authentik single sign-on, then routes to self-hosted services: Gitea and its CI runner, application containers, and a media stack.',
no: 'Trafikk kommer via Cloudflare til en Traefik reverse proxy som terminerer TLS og bruker Authentik single sign-on, og rutes videre til egendriftede tjenester: Gitea med CI-runner, applikasjonscontainere og en mediastack.',
},
nodes: [
{
id: 'internet',
id: 'cloudflare',
x: 40,
y: 116,
w: 160,
h: 72,
y: 124,
w: 170,
h: 68,
kind: 'external',
label: 'Internet',
sub: { en: 'HTTPS', no: 'HTTPS' },
label: 'Cloudflare',
sub: { en: 'DNS · edge · WAF', no: 'DNS · edge · WAF' },
},
{
id: 'proxy',
x: 280,
y: 116,
w: 190,
h: 72,
id: 'traefik',
x: 290,
y: 124,
w: 180,
h: 68,
kind: 'primary',
label: 'nginx',
label: 'Traefik',
sub: { en: 'reverse proxy · TLS', no: 'reverse proxy · TLS' },
},
{
id: 'authentik',
x: 290,
y: 232,
w: 180,
h: 56,
kind: 'internal',
label: 'Authentik',
sub: { en: 'SSO · forward-auth', no: 'SSO · forward-auth' },
},
{
id: 'gitea',
x: 560,
y: 40,
w: 190,
y: 44,
w: 210,
h: 60,
kind: 'internal',
label: 'Gitea',
sub: { en: 'git.cesnimda.uk', no: 'git.cesnimda.uk' },
label: 'Gitea + runner',
sub: { en: 'self-hosted git · CI/CD', no: 'egendriftet git · CI/CD' },
},
{
id: 'apps',
x: 560,
y: 120,
w: 190,
y: 128,
w: 210,
h: 60,
kind: 'internal',
label: 'App services',
sub: { en: 'Docker containers', no: 'Docker-containere' },
label: 'App containers',
sub: { en: 'JobTrack · this site', no: 'JobTrack · denne siden' },
},
{
id: 'monitor',
id: 'media',
x: 560,
y: 200,
w: 190,
y: 212,
w: 210,
h: 60,
kind: 'internal',
label: 'Monitoring',
sub: { en: 'uptime · logs', no: 'oppetid · logger' },
label: 'Media stack',
sub: { en: 'Jellyfin · *arr', no: 'Jellyfin · *arr' },
},
],
edges: [
{ d: 'M200 152 H280', kind: 'flow' },
{ d: 'M470 140 L560 70', kind: 'flow' },
{ d: 'M470 152 H560', kind: 'flow' },
{ d: 'M470 164 L560 230', kind: 'flow' },
{ d: 'M210 158 H290', kind: 'flow' },
{ d: 'M380 192 V232', kind: 'flow', label: 'auth', labelX: 400, labelY: 218 },
{ d: 'M470 150 L560 74', kind: 'flow' },
{ d: 'M470 158 H560', kind: 'flow' },
{ d: 'M470 166 L560 242', kind: 'flow' },
],
},
media: [],
content: {
en: {
valueProp: 'Where I run, break and fix real infrastructure — not just deploy it.',
valueProp:
'Where I run, break and fix real infrastructure — around thirty containerised services behind a single reverse proxy.',
cardTeaser:
'Ubuntu · Docker services · reverse proxy · auth · monitoring · Gitea (this sites own git remote).',
'Ubuntu + Docker · Traefik · Authentik SSO · CrowdSec · Gitea CI — ~30 self-hosted services, and the deploy target for this site.',
tldr: {
what: 'A self-hosted home lab running production-style services.',
why: 'To keep operations skills hands-on.',
stack: 'Ubuntu · Docker · nginx · Gitea · monitoring.',
what: 'A self-hosted home server running ~30 production-style services.',
why: 'To keep operations and security skills hands-on.',
stack: 'Ubuntu · Docker · Traefik · Authentik · CrowdSec · Gitea.',
role: 'Operator.',
},
sections: [
@@ -102,7 +118,7 @@ export const homelab: Project = {
blocks: [
{
type: 'p',
text: 'A home lab running Ubuntu and Docker-based services behind an nginx reverse proxy, with authentication, monitoring and backups. Its where I get ongoing, hands-on practice with deployment, troubleshooting and real-world operations — including the self-hosted Gitea instance that this very site is built and deployed from.',
text: 'An Ubuntu 24.04 server running around thirty Docker services behind a Traefik reverse proxy, with single sign-on, an intrusion-prevention layer, DNS filtering and a full CI/CD setup. Its where I get ongoing, hands-on practice with deployment, security and real-world operations — including the self-hosted Gitea instance and runner that build and deploy this very portfolio.',
},
],
},
@@ -114,10 +130,11 @@ export const homelab: Project = {
{
type: 'ul',
items: [
'nginx reverse proxy terminating TLS in front of every service.',
'Docker Compose stacks for applications, each isolated on its own network.',
'Authentication in front of internal tools; monitoring for uptime and logs.',
'Self-hosted Gitea with CI runners — the deployment target for this portfolio.',
'Traefik terminates TLS and routes every service, fronted by Cloudflare, with separate internal and external entrypoints and HTTP/3.',
'Authentik provides single sign-on as a Traefik forward-auth middleware in front of internal tools; CrowdSec adds intrusion prevention and Pi-hole handles DNS filtering.',
'Gitea and a Gitea Actions runner give me self-hosted git and CI/CD — the build-and-deploy target for this site.',
'The Docker socket is exposed to Traefik only through a hardened socket-proxy, and each application runs on its own isolated Docker network.',
'Portainer and Dozzle handle container management and logs; alongside these run application, media and utility stacks.',
],
},
],
@@ -129,7 +146,7 @@ export const homelab: Project = {
blocks: [
{
type: 'p',
text: 'Tightening backup verification and adding more automated health checks and alerting across the stack.',
text: 'Retiring the legacy WordPress site now that this portfolio replaces it, then tightening backup verification and expanding automated health checks and alerting across the stack.',
},
],
},
@@ -137,13 +154,13 @@ export const homelab: Project = {
},
no: {
valueProp:
'Der jeg drifter, ødelegger og fikser ekte infrastruktur — ikke bare ruller den ut.',
'Der jeg drifter, ødelegger og fikser ekte infrastruktur — rundt tretti containeriserte tjenester bak én reverse proxy.',
cardTeaser:
'Ubuntu · Docker-tjenester · reverse proxy · auth · overvåking · Gitea (git-remoten til denne siden).',
'Ubuntu + Docker · Traefik · Authentik SSO · CrowdSec · Gitea CI — ~30 egendriftede tjenester, og utrullingsmålet for denne siden.',
tldr: {
what: 'En egendriftet hjemmelab som kjører produksjonslignende tjenester.',
why: 'For å holde driftsferdighetene praktiske.',
stack: 'Ubuntu · Docker · nginx · Gitea · overvåking.',
what: 'En egendriftet hjemmeserver med ~30 produksjonslignende tjenester.',
why: 'For å holde drifts- og sikkerhetsferdighetene praktiske.',
stack: 'Ubuntu · Docker · Traefik · Authentik · CrowdSec · Gitea.',
role: 'Operatør.',
},
sections: [
@@ -154,7 +171,7 @@ export const homelab: Project = {
blocks: [
{
type: 'p',
text: 'En hjemmelab som kjører Ubuntu og Docker-baserte tjenester bak en nginx reverse proxy, med autentisering, overvåking og sikkerhetskopier. Det er her jeg får løpende, praktisk øving på utrulling, feilsøking og ekte drift — inkludert den egendriftede Gitea-instansen som denne siden bygges og rulles ut fra.',
text: 'En Ubuntu 24.04-server som kjører rundt tretti Docker-tjenester bak en Traefik reverse proxy, med single sign-on, et lag for inntrengingsvern, DNS-filtrering og full CI/CD. Det er her jeg får løpende, praktisk øving på utrulling, sikkerhet og ekte drift — inkludert den egendriftede Gitea-instansen og runneren som bygger og ruller ut denne porteføljen.',
},
],
},
@@ -166,10 +183,11 @@ export const homelab: Project = {
{
type: 'ul',
items: [
'nginx reverse proxy som terminerer TLS foran hver tjeneste.',
'Docker Compose-stacker for applikasjoner, hver isolert på eget nettverk.',
'Autentisering foran interne verktøy; overvåking av oppetid og logger.',
'Egendriftet Gitea med CI-runnere — utrullingsmålet for denne porteføljen.',
'Traefik terminerer TLS og ruter alle tjenester, med Cloudflare foran, egne interne og eksterne entrypoints og HTTP/3.',
'Authentik gir single sign-on som en Traefik forward-auth-middleware foran interne verktøy; CrowdSec legger til inntrengingsvern og Pi-hole håndterer DNS-filtrering.',
'Gitea og en Gitea Actions-runner gir egendriftet git og CI/CD — bygge- og utrullingsmålet for denne siden.',
'Docker-socketen eksponeres kun til Traefik gjennom en herdet socket-proxy, og hver applikasjon kjører på sitt eget isolerte Docker-nettverk.',
'Portainer og Dozzle håndterer containere og logger; ved siden av kjører applikasjons-, media- og verktøystacker.',
],
},
],
@@ -181,7 +199,7 @@ export const homelab: Project = {
blocks: [
{
type: 'p',
text: 'Strammere verifisering av sikkerhetskopier og flere automatiske helsesjekker og varsling på tvers av stacken.',
text: 'Legge ned den gamle WordPress-siden nå som denne porteføljen erstatter den, og deretter stramme inn verifisering av sikkerhetskopier og utvide automatiske helsesjekker og varsling på tvers av stacken.',
},
],
},
+254 -74
View File
@@ -1,6 +1,6 @@
import type { Project } from '@lib/schema';
/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel (README + docs). */
/* InboxIntel case study. Source: F:\Documents\InboxIntel\InboxIntel (full codebase + docs audit, 2026-07-12). */
export const inboxintel: Project = {
id: 'inboxintel',
name: 'InboxIntel',
@@ -8,11 +8,11 @@ export const inboxintel: Project = {
status: 'in-development',
template: 'case-study',
stack: [
{ name: '.NET 8', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } },
{ name: 'PostgreSQL', context: { en: 'EF Core + Npgsql', no: 'EF Core + Npgsql' } },
{ name: 'React 18', context: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' } },
{ name: 'Gmail API', context: { en: 'OAuth2 · Polly', no: 'OAuth2 · Polly' } },
{ name: 'Serilog' },
{ name: '.NET 10', context: { en: 'ASP.NET Core', no: 'ASP.NET Core' } },
{ name: 'React', context: { en: 'Vite · TypeScript', no: 'Vite · TypeScript' } },
{ name: 'PostgreSQL', context: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' } },
{ name: 'Ollama', context: { en: 'embeddings · summaries', no: 'embeddings · sammendrag' } },
{ name: 'Clean Architecture' },
{ name: 'Docker' },
],
links: [],
@@ -20,8 +20,8 @@ export const inboxintel: Project = {
viewBox: '0 0 1120 300',
title: { en: 'InboxIntel architecture', no: 'InboxIntel-arkitektur' },
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.',
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.',
en: 'A React SPA calls an ASP.NET Core API layered as Clean Architecture. A hosted background worker syncs the Gmail API into PostgreSQL with pgvector, and a local Ollama model produces the embeddings and summaries that power hybrid search.',
no: 'En React-app kaller et ASP.NET Core-API bygget som Clean Architecture. En bakgrunnstjeneste synkroniserer Gmail-API-et inn i PostgreSQL med pgvector, og en lokal Ollama-modell lager embeddings og sammendrag som driver hybrid søk.',
},
nodes: [
{
@@ -32,7 +32,7 @@ export const inboxintel: Project = {
h: 72,
kind: 'internal',
label: 'React SPA',
sub: { en: 'Vite · Chart.js', no: 'Vite · Chart.js' },
sub: { en: 'split-view · search', no: 'delt visning · søk' },
},
{
id: 'api',
@@ -44,6 +44,16 @@ export const inboxintel: Project = {
label: 'ASP.NET Core API',
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',
x: 580,
@@ -52,7 +62,7 @@ export const inboxintel: Project = {
h: 64,
kind: 'internal',
label: 'PostgreSQL',
sub: { en: 'EF Core · Npgsql', no: 'EF Core · Npgsql' },
sub: { en: 'EF Core · pgvector', no: 'EF Core · pgvector' },
},
{
id: 'worker',
@@ -77,6 +87,7 @@ export const inboxintel: Project = {
],
edges: [
{ 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 170 L580 192', kind: 'flow' },
{ d: 'M670 160 V112', kind: 'flow' },
@@ -85,42 +96,55 @@ export const inboxintel: Project = {
},
media: [
{
src: 'placeholder:inboxintel-dashboard',
src: '/assets/projects/inboxintel/inbox.webp',
width: 1600,
height: 1000,
alt: {
en: 'InboxIntel draggable analytics dashboard with charts',
no: 'InboxIntel dashbord med flyttbare analyse-widgets',
en: 'InboxIntel split view: a persistent message list beside a resizable reading pane with an AI summary',
no: 'InboxIntel delt visning: en fast meldingsliste ved siden av en justerbar leserute med AI-sammendrag',
},
caption: {
en: 'Draggable analytics dashboard (react-grid-layout)',
no: 'Dashbord med flyttbare widgets (react-grid-layout)',
en: 'Split view — read and triage without ever opening a new tab',
no: 'Delt visning — les og sorter uten å åpne en ny fane',
},
},
{
src: 'placeholder:inboxintel-cleanup',
src: '/assets/projects/inboxintel/dashboard.webp',
width: 1600,
height: 1000,
alt: {
en: 'Bulk cleanup preview screen requiring explicit confirmation',
no: 'Forhåndsvisning av masseopprydding som krever bekreftelse',
en: 'InboxIntel analytics dashboard: inbox-health grade, emails by category, 90-day volume and top senders',
no: 'InboxIntel dashbord: innboks-helse, e-post per kategori, 90-dagers volum og toppavsendere',
},
caption: {
en: 'Cleanup preview — nothing is deleted without a confirmed flag',
no: 'Oppryddings-forhåndsvisning — ingenting slettes uten bekreftelse',
en: 'Analytics — an inbox-health grade, categories, volume trend and top senders',
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: {
en: {
valueProp:
'Gmail analytics and safe bulk cleanup, built as a disciplined Clean Architecture.',
'A self-hosted inbox intelligence and cleanup tool — hybrid search, AI summaries and inbox-health analytics over your mail.',
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: {
what: 'Gmail analytics, cleanup and unsubscribe management with a background sync worker.',
why: 'To practise Clean Architecture properly and make destructive operations safe by design.',
stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.',
what: 'Inbox intelligence: split-view reading, hybrid semantic search, AI summaries, inbox-health analytics and safe cleanup.',
why: 'To practise Clean Architecture properly, and make search useful and destructive operations safe by design.',
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
role: 'Sole architect and developer, front to back.',
},
sections: [
@@ -131,55 +155,108 @@ export const inboxintel: Project = {
blocks: [
{
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 cant 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 — self-hosted, with a clean, testable backend built to production discipline rather than side-project shortcuts.",
},
],
},
{
kind: 'solution',
heading: 'Solution',
anchorId: 'solution',
blocks: [
{
type: 'p',
text: 'InboxIntel connects to your mailbox via OAuth, syncs it into a local PostgreSQL database, and layers three things on top: a split-view reading pane for fast triage, hybrid search that finds mail by meaning as well as keywords, and an analytics dashboard that shows inbox health at a glance. Every destructive action — bulk trash, unsubscribe — previews before it executes.',
},
{
type: 'ul',
items: [
'Reading: a persistent list beside a resizable pane with an AI summary — triage without opening a new tab.',
'Search: hybrid semantic (pgvector) + full-text, with a fuzzy fallback for typos and a relevance score.',
'Analytics: an inbox-health grade, emails by category, a 90-day volume trend and top senders.',
'Cleanup: preview-then-confirm on every destructive action; the AI is advisory only.',
],
},
{
type: 'p',
text: 'Today it connects to Gmail only. A full multi-provider architecture — a provider abstraction, unified email model, and an Outlook/IMAP rollout plan — is designed in detail (a dozen internal design documents covering auth, data model, admin platform and migration path); building it out is the next major milestone rather than a shipped feature.',
},
],
},
{
kind: 'features',
heading: 'Feature showcase',
anchorId: 'features',
features: [
{
title: 'Hybrid search that understands meaning, not just words',
value:
'Find "that gym receipt" even if you never typed the word "gym" in the email — search stops being a literal keyword match.',
technical:
'Reciprocal Rank Fusion combines Postgres full-text search (tsvector/GIN, websearch_to_tsquery) with pgvector cosine-similarity search over local Ollama embeddings, fusing the top 50 results from each. A pg_trgm fuzzy fallback with a hand-tuned 0.3 similarity threshold (the default 0.6 misses real typos like "recieved") catches what neither ranked list does. Any AI-path failure — Ollama down, embeddings not yet backfilled — degrades silently to lexical-only search.',
},
{
title: 'Cleanup you can trust',
value:
'Preview exactly what a bulk-delete or unsubscribe action will do before anything happens — no accidental data loss from an over-broad filter.',
technical:
'Every destructive action requires a server-side Preview call before Execute, with an explicit Confirmed flag checked twice — once by FluentValidation, once inside the service itself as defence in depth. Unsubscribe targets are validated by an SSRF egress guard against private/metadata IP ranges before any outbound request is made, and mailto: links are surfaced to the user rather than auto-sent.',
},
{
title: 'Inbox health at a glance',
value:
'A draggable dashboard shows volume trends, top senders and category breakdowns without digging through folders manually.',
technical:
'A react-grid-layout dashboard with per-user persisted widget positions, backed by dedicated analytics endpoints (health grade, category heatmap, 90-day volume, top senders, attachment stats) computed server-side rather than client-aggregated.',
},
],
},
{
kind: 'architecture',
heading: 'Architecture',
heading: 'Engineering highlights',
anchorId: 'architecture',
blocks: [
{
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: 'The backend is Clean Architecture with the dependency rule enforced end to end: Api → Infrastructure → Application → Domain, controllers hold no business logic, and the Domain project has zero external dependencies bar the one type needed for full-text search. AI is fully swappable at the DI boundary — Null, Ollama and OpenAI implementations of the same IAiProvider interface — so the rest of the app never branches on whether AI is enabled.',
},
{
type: 'ul',
items: [
'Domain: entities and enums, no external dependencies.',
'Application: service interfaces, DTOs, validators, the Gmail query parser.',
'Infrastructure: EF Core, the Gmail client, the sync worker, AI and export.',
'Api: ASP.NET Core controllers, auth, DI and Serilog — thin by design.',
"Sync engine: checkpoints its page token and message count after every page, so a killed sync resumes rather than restarting; incremental syncs use Gmail's historyId watermark instead of re-scanning; all calls run through Polly retry/backoff.",
"Pagination: keyset (cursor) pagination on large result sets — O(page size) instead of OFFSET's O(page × page size) — alongside plain offset pagination for the general browse path.",
'AI is advisory only by contract: every AiService method carries the explicit guarantee that it never archives, deletes, labels or unsubscribes on its own.',
'Security is self-audited on a paper trail: a dated, severity-coded internal audit (plaintext-storage risk, missing rate limits, unencrypted keys) with each finding resolved via its own tracked PR and new tests, not just prose claims.',
],
},
],
},
{
kind: 'decisions',
heading: 'Key decisions & trade-offs',
heading: 'Challenges',
anchorId: 'decisions',
decisions: [
{
n: 1,
choice: 'Full Clean Architecture across four projects.',
alternative: 'A single project would have shipped faster.',
choice:
'Fuse lexical and semantic search with Reciprocal Rank Fusion, degrading silently on failure.',
alternative: 'Run semantic search as a separate mode the user has to switch to.',
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.',
"Keyword search alone misses paraphrased or vaguely-remembered mail; semantic search alone drops exact-match precision. RRF blends the top 50 of each into one ranked list automatically, and if Ollama is down or an email hasn't been embedded yet, the query still returns lexical results instead of erroring. Outcome: one search box that works whether or not the AI layer is healthy.",
},
{
n: 2,
choice: 'Encrypt OAuth refresh tokens at rest and never log them.',
alternative: 'Store them as plain columns.',
alternative: 'Store them as plain columns — simpler, faster to ship.',
rationale:
'Refresh tokens are long-lived keys to someones mailbox. Theyre encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — the single most important security decision in the app.',
"Refresh tokens are long-lived keys to someone's mailbox. They're encrypted with the ASP.NET Core Data Protection API (AES), with keys persisted to a mounted volume — flagged in the internal audit as the single most important security decision in the app, and verified clean on re-review.",
},
{
n: 3,
choice: 'Every destructive action is preview-then-confirm.',
alternative: 'Delete immediately on request.',
choice: 'Require a mandatory preview before any destructive action, enforced twice.',
alternative: 'Trust client-side confirmation and execute on request.',
rationale:
'All cleanup and unsubscribe actions require a server-side preview and an explicit Confirmed flag. The AI layer is advisory only and can never trigger a deletion.',
'An audit finding showed rate limiting and validation gaps around bulk actions early on. The fix — preview-then-confirm checked at both the validator and the service layer, plus an SSRF guard on every outbound unsubscribe request — closed the finding and shipped with regression tests proving the 400 on invalid input.',
},
],
},
@@ -191,10 +268,10 @@ export const inboxintel: Project = {
{
type: 'ul',
items: [
'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.',
'FluentValidation on inputs; 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.',
'Google-OAuth gated; Gmail scopes are read/modify only — no send scope is ever requested.',
"Embeddings and summaries are generated locally (Ollama) by default; an optional cloud fallback exists but mail never leaves the box unless that's explicitly enabled.",
'Polly retry/backoff against Gmail rate limits; Serilog structured logging that never records tokens; OpenTelemetry instrumentation wired for production observability.',
'Clean Architecture keeps the layers testable; 39+ unit and integration tests, including live-database tests for the full-text/fuzzy/pgvector paths.',
],
},
],
@@ -206,25 +283,50 @@ export const inboxintel: Project = {
},
{
kind: 'next',
heading: 'Status & whats next',
heading: 'Status & roadmap',
anchorId: 'next',
blocks: [
{
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: 'Implemented today:',
},
{
type: 'ul',
items: [
'Gmail OAuth sync into PostgreSQL, with resumable, checkpointed background sync',
'Hybrid semantic + full-text + fuzzy search with keyset pagination',
'Draggable analytics dashboard: inbox health, categories, volume, top senders',
'Safe bulk cleanup and unsubscribe (preview-then-confirm, SSRF-guarded)',
'Optional local AI (Ollama) or cloud fallback for classification, summaries and natural-language search',
'A self-audited, remediated security posture with tracked findings and fixes',
],
},
{
type: 'p',
text: 'Designed but not yet built — the next major milestones:',
},
{
type: 'ul',
items: [
'Multi-provider support (Outlook, generic IMAP) — architecture and migration plan already written',
'Rules engine for automated cleanup, sender policy (block/whitelist/screener) and an activity log with undo',
'A privacy monitor (breach-check against known leak databases)',
'Tuned AI prompts and richer HTML-body/attachment parsing beyond the common cases',
],
},
],
},
],
},
no: {
valueProp: 'Gmail-analyse og trygg masseopprydding, bygget som en ryddig Clean Architecture.',
valueProp:
'Et egendriftet verktøy for innboks-intelligens og opprydding — hybrid søk, AI-sammendrag og innboks-helse over e-posten din.',
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: {
what: 'Gmail-analyse, opprydding og avmeldingshåndtering med en bakgrunns-synk.',
why: 'For å øve på Clean Architecture skikkelig og gjøre destruktive operasjoner trygge fra bunnen.',
stack: '.NET 8 · PostgreSQL · React 18 · Gmail API · Serilog · Polly · Docker.',
what: 'Innboks-intelligens: delt visning, hybrid semantisk søk, AI-sammendrag, innboks-helse og trygg opprydding.',
why: 'For å øve på Clean Architecture skikkelig, gjøre søk nyttig og destruktive operasjoner trygge fra bunnen.',
stack: '.NET 10 · PostgreSQL + pgvector · React · Ollama · Docker · Clean Architecture.',
role: 'Eneste arkitekt og utvikler, fra ende til ende.',
},
sections: [
@@ -235,55 +337,109 @@ export const inboxintel: Project = {
blocks: [
{
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 — egendriftet, med en ryddig og testbar backend bygget til produksjonsstandard, ikke fritidsprosjekt-snarveier.',
},
],
},
{
kind: 'solution',
heading: 'Løsning',
anchorId: 'losning',
blocks: [
{
type: 'p',
text: 'InboxIntel kobler seg til postkassen din via OAuth, synkroniserer den inn i en lokal PostgreSQL-database, og legger tre ting på toppen: en delt lesevisning for rask sortering, hybrid søk som finner e-post på mening i tillegg til nøkkelord, og et analysedashbord som viser innboks-helse med ett blikk. Hver destruktive handling — massesletting, avmelding — forhåndsvises før den utføres.',
},
{
type: 'ul',
items: [
'Lesing: en fast liste ved siden av en justerbar rute med AI-sammendrag — sorter uten å åpne en ny fane.',
'Søk: hybrid semantisk (pgvector) + fulltekst, med fuzzy-fallback for skrivefeil og relevans-score.',
'Analyse: en innboks-helsekarakter, e-post per kategori, 90-dagers volumtrend og toppavsendere.',
'Opprydding: forhåndsvis-så-bekreft på hver destruktive handling; AI-en er kun rådgivende.',
],
},
{
type: 'p',
text: 'I dag kobles den kun til Gmail. En full arkitektur for flere leverandører — en leverandørabstraksjon, en samlet e-postmodell og en utrullingsplan for Outlook/IMAP — er designet i detalj (et dusin interne designdokumenter om autentisering, datamodell, admin-plattform og migreringsvei); å bygge det ut er neste store milepæl, ikke en levert funksjon.',
},
],
},
{
kind: 'features',
heading: 'Utvalgte funksjoner',
anchorId: 'funksjoner',
features: [
{
title: 'Hybrid søk som forstår mening, ikke bare ord',
value:
'Finn «den kvittering fra treningssenteret» selv om du aldri skrev ordet «trening» i e-posten — søk slutter å være et bokstavelig nøkkelordtreff.',
technical:
'Reciprocal Rank Fusion kombinerer Postgres fulltekstsøk (tsvector/GIN, websearch_to_tsquery) med pgvector kosinuslikhet over lokale Ollama-embeddings, og fusjonerer de 50 beste resultatene fra hver. En pg_trgm fuzzy-fallback med en håndjustert similaritetsterskel på 0,3 (standarden 0,6 bommer på ekte skrivefeil som «mottat»→«mottatt») fanger opp det ingen av de rangerte listene gjør. Enhver feil i AI-stien — Ollama nede, embeddings ikke bakfylt ennå — degraderer stille til kun leksikalsk søk.',
},
{
title: 'Opprydding du kan stole på',
value:
'Forhåndsvis nøyaktig hva en massesletting eller avmelding vil gjøre før noe skjer — ingen utilsiktet datatap fra et for bredt filter.',
technical:
'Hver destruktive handling krever et server-side Preview-kall før Execute, med et eksplisitt Confirmed-flagg sjekket to ganger — én gang av FluentValidation, én gang inne i selve tjenesten som ekstra sikkerhetslag. Avmeldingsmål valideres av en SSRF-utgangsvakt mot private/metadata-IP-områder før noen utgående forespørsel gjøres, og mailto-lenker vises til brukeren i stedet for å sendes automatisk.',
},
{
title: 'Innboks-helse med ett blikk',
value:
'Et flyttbart dashbord viser volumtrender, toppavsendere og kategorifordeling uten å måtte grave gjennom mapper manuelt.',
technical:
'Et react-grid-layout-dashbord med per-bruker lagret widget-plassering, støttet av dedikerte analyse-endepunkter (helsekarakter, kategori-varmekart, 90-dagers volum, toppavsendere, vedleggsstatistikk) beregnet server-side i stedet for aggregert på klienten.',
},
],
},
{
kind: 'architecture',
heading: 'Arkitektur',
heading: 'Tekniske høydepunkter',
anchorId: 'arkitektur',
blocks: [
{
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: 'Backend er Clean Architecture med avhengighetsregelen håndhevet fra ende til ende: Api → Infrastructure → Application → Domain, kontrollerne har ingen forretningslogikk, og Domain-prosjektet har ingen eksterne avhengigheter bortsett fra én type som trengs for fulltekstsøk. AI er fullt utskiftbart ved DI-grensen — Null-, Ollama- og OpenAI-implementasjoner av samme IAiProvider-grensesnitt — så resten av appen forgrener seg aldri på om AI er skrudd på.',
},
{
type: 'ul',
items: [
'Domain: entiteter og enums, uten eksterne avhengigheter.',
'Application: tjenestegrensesnitt, DTO-er, validatorer og Gmail-søkeparser.',
'Infrastructure: EF Core, Gmail-klient, synk-tjeneste, AI og eksport.',
'Api: ASP.NET Core-kontrollere, auth, DI og Serilog — tynt med hensikt.',
'Synk-motor: sjekkpunkter side-token og meldingstall etter hver side, så en avbrutt synk fortsetter i stedet for å starte på nytt; inkrementelle synker bruker Gmails historyId-vannmerke i stedet for å skanne på nytt; alle kall går gjennom Polly retry/backoff.',
'Paginering: keyset (markør)-paginering på store resultatsett — O(sidestørrelse) i stedet for OFFSETs O(side × sidestørrelse) — ved siden av vanlig offset-paginering for generell nettlesing.',
'AI er kun rådgivende ved kontrakt: hver AiService-metode bærer den eksplisitte garantien at den aldri arkiverer, sletter, merker eller melder av på egen hånd.',
'Sikkerhet er egen-revidert med papirspor: en datert, alvorlighetskodet intern revisjon (risiko for klartekst-lagring, manglende ratebegrensning, ukrypterte nøkler) hvor hvert funn er løst via egen sporet PR og nye tester, ikke bare prosapåstander.',
],
},
],
},
{
kind: 'decisions',
heading: 'Viktige valg og avveininger',
anchorId: 'beslutninger',
heading: 'Utfordringer',
anchorId: 'utfordringer',
decisions: [
{
n: 1,
choice: 'Full Clean Architecture over fire prosjekter.',
alternative: 'Ett prosjekt hadde vært raskere å levere.',
choice:
'Fusjonere leksikalsk og semantisk søk med Reciprocal Rank Fusion, med stille degradering ved feil.',
alternative: 'Kjøre semantisk søk som en egen modus brukeren må bytte til.',
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.',
'Nøkkelordsøk alene bommer på omskrevet eller vagt husket e-post; semantisk søk alene mister presisjon på eksakte treff. RRF blander de 50 beste fra hver til én rangert liste automatisk, og hvis Ollama er nede eller en e-post ikke er bakfylt ennå, returnerer spørringen fortsatt leksikalske resultater i stedet for å feile. Resultat: én søkeboks som fungerer uansett om AI-laget er friskt.',
},
{
n: 2,
choice: 'Kryptere OAuth-refresh-tokens i ro og aldri logge dem.',
alternative: 'Lagre dem som vanlige kolonner.',
alternative: 'Lagre dem som vanlige kolonner — enklere, raskere å levere.',
rationale:
'Refresh-tokens er langlevde nøkler til noens innboks. De krypteres med ASP.NET Core Data Protection API (AES), med nøkler lagret på et montert volum — det viktigste sikkerhetsvalget i appen.',
'Refresh-tokens er langlevde nøkler til noens innboks. De krypteres med ASP.NET Core Data Protection API (AES), med nøkler lagret på et montert volum — flagget i den interne revisjonen som det viktigste sikkerhetsvalget i appen, og verifisert rent ved ny gjennomgang.',
},
{
n: 3,
choice: 'Alle destruktive handlinger er forhåndsvis-så-bekreft.',
alternative: 'Slette umiddelbart ved forespørsel.',
choice:
'Kreve obligatorisk forhåndsvisning før enhver destruktiv handling, håndhevet to ganger.',
alternative: 'Stole på klientsidebekreftelse og utføre ved forespørsel.',
rationale:
'All opprydding og avmelding krever en server-side forhåndsvisning og et eksplisitt Confirmed-flagg. AI-laget er kun rådgivende og kan aldri utløse en sletting.',
'Et revisjonsfunn viste ratebegrensnings- og valideringshull rundt massehandlinger tidlig. Løsningen — forhåndsvis-så-bekreft sjekket både på validator- og tjenestenivå, pluss en SSRF-vakt på hver utgående avmeldingsforespørsel — lukket funnet og ble levert med regresjonstester som beviser 400 ved ugyldig input.',
},
],
},
@@ -295,10 +451,10 @@ export const inboxintel: Project = {
{
type: 'ul',
items: [
'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.',
'FluentValidation på input; strukturert Serilog-logging som aldri lagrer tokens.',
'Enhetstester dekker søkeparser og avmeldings-uttrekk; integrasjonstester starter API-verten og sjekker at autorisasjon håndheves.',
'Google-OAuth-beskyttet; Gmail-scopes er kun lese/endre — send-scope blir aldri etterspurt.',
'Embeddings og sammendrag lages lokalt (Ollama) som standard; en valgfri sky-fallback finnes, men e-post forlater aldri maskinen med mindre det er eksplisitt skrudd på.',
'Polly retry/backoff mot Gmails rategrenser; strukturert Serilog-logging som aldri lagrer tokens; OpenTelemetry-instrumentering klar for produksjonsobservabilitet.',
'Clean Architecture holder lagene testbare; 39+ enhets- og integrasjonstester, inkludert live-database-tester for fulltekst-/fuzzy-/pgvector-stiene.',
],
},
],
@@ -315,7 +471,31 @@ export const inboxintel: Project = {
blocks: [
{
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: 'Implementert i dag:',
},
{
type: 'ul',
items: [
'Gmail OAuth-synk inn i PostgreSQL, med gjenopptakbar, sjekkpunktet bakgrunnssynk',
'Hybrid semantisk + fulltekst + fuzzy søk med keyset-paginering',
'Flyttbart analysedashbord: innboks-helse, kategorier, volum, toppavsendere',
'Trygg massesletting og avmelding (forhåndsvis-så-bekreft, SSRF-sikret)',
'Valgfri lokal AI (Ollama) eller sky-fallback for klassifisering, sammendrag og naturlig-språk-søk',
'En egen-revidert, utbedret sikkerhetsstatus med sporede funn og fikser',
],
},
{
type: 'p',
text: 'Designet, men ikke bygget ennå — de neste store milepælene:',
},
{
type: 'ul',
items: [
'Støtte for flere leverandører (Outlook, generisk IMAP) — arkitektur og migreringsplan allerede skrevet',
'Regelmotor for automatisert opprydding, avsenderpolicy (blokker/hvitliste/screener) og en aktivitetslogg med angre',
'En personvernvakt (lekkasjesjekk mot kjente bruddatabaser)',
'Justerte AI-prompter og rikere HTML-body-/vedleggsparsing utover de vanlige tilfellene',
],
},
],
},
+236 -86
View File
@@ -1,19 +1,21 @@
import type { Project } from '@lib/schema';
/* JobTrack case study. Source: D:\Job tracker (README + website_details.md). */
/* JobTrack case study. Source: D:\Job tracker (full codebase + docs audit, 2026-07-12). */
export const jobtrack: Project = {
id: 'jobtrack',
name: 'JobTrack',
order: 1,
status: 'active',
status: 'in-development',
template: 'case-study',
stack: [
{ name: '.NET 9', context: { en: 'ASP.NET Core API', no: 'ASP.NET Core-API' } },
{ name: 'React', context: { en: 'SPA frontend', no: 'SPA-frontend' } },
{ name: 'TypeScript' },
{ name: 'SQLite', context: { en: 'EF Core', no: 'EF Core' } },
{ name: 'FastAPI', context: { en: 'Python AI service', no: 'Python AI-tjeneste' } },
{ name: 'Ollama', context: { en: 'local LLM', no: 'lokal LLM' } },
{ name: 'ASP.NET Core', context: { en: '.NET 9 API', no: '.NET 9-API' } },
{ name: 'Next.js', context: { en: 'React 19 · TypeScript', no: 'React 19 · TypeScript' } },
{ name: 'EF Core', context: { en: 'SQLite · MySQL/MariaDB', no: 'SQLite · MySQL/MariaDB' } },
{ name: 'FastAPI + Ollama', context: { en: 'local AI service', no: 'lokal AI-tjeneste' } },
{
name: 'Multi-provider email',
context: { en: 'Gmail · Microsoft · IMAP', no: 'Gmail · Microsoft · IMAP' },
},
{ name: 'Docker' },
],
links: [],
@@ -21,8 +23,8 @@ export const jobtrack: Project = {
viewBox: '0 0 1120 300',
title: { en: 'JobTrack architecture', no: 'JobTrack-arkitektur' },
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.',
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.',
en: 'A Next.js 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 syncs correspondence from Gmail, Microsoft Graph and IMAP through one provider abstraction.',
no: 'En Next.js-app snakker med et ASP.NET Core-API som lagrer via EF Core med vedlegg på disk, kaller en FastAPI/Ollama AI-tjeneste, og synkroniserer korrespondanse fra Gmail, Microsoft Graph og IMAP gjennom én felles abstraksjon.',
},
nodes: [
{
@@ -32,8 +34,8 @@ export const jobtrack: Project = {
w: 180,
h: 72,
kind: 'internal',
label: 'React SPA',
sub: { en: 'nginx · PWA', no: 'nginx · PWA' },
label: 'Next.js SPA',
sub: { en: 'nginx · CSR', no: 'nginx · CSR' },
},
{
id: 'api',
@@ -43,7 +45,7 @@ export const jobtrack: Project = {
h: 72,
kind: 'primary',
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',
@@ -52,8 +54,8 @@ export const jobtrack: Project = {
w: 180,
h: 64,
kind: 'internal',
label: 'SQLite + files',
sub: { en: 'EF Core · attachments', no: 'EF Core · vedlegg' },
label: 'EF Core + files',
sub: { en: 'data · attachments', no: 'data · vedlegg' },
},
{
id: 'ai',
@@ -72,68 +74,69 @@ export const jobtrack: Project = {
w: 180,
h: 72,
kind: 'external',
label: 'Gmail API',
sub: { en: 'external · OAuth2', no: 'eksternt · OAuth2' },
label: 'Email providers',
sub: { en: 'Gmail · MS Graph · IMAP', no: 'Gmail · MS Graph · IMAP' },
},
],
edges: [
{ d: 'M220 148 H300', kind: 'flow', label: '/api', labelX: 248, labelY: 140 },
{ d: 'M500 130 L600 88', kind: 'flow' },
{ d: 'M500 166 L600 200', kind: 'flow' },
{ d: 'M500 148 H900', kind: 'external', label: 'import', labelX: 690, labelY: 140 },
{ d: 'M500 148 H900', kind: 'external', label: 'sync', labelX: 690, labelY: 140 },
],
},
media: [
{
src: 'placeholder:jobtrack-table',
src: '/assets/projects/jobtrack/workspace.webp',
width: 1600,
height: 1000,
alt: {
en: 'JobTrack applications table with status filters and search',
no: 'JobTrack-søknadstabell med statusfiltre og søk',
en: 'JobTrack job workspace: one application with its details, CV match and recruiter thread',
no: 'JobTrack jobb-arbeidsrom: én søknad med detaljer, CV-match og rekruttør-tråd',
},
caption: {
en: 'Applications table — pipeline at a glance',
no: 'Søknadstabell — hele pipelinen samlet',
en: 'Job workspace — everything about one application in one place',
no: 'Jobb-arbeidsrom — alt om én søknad på ett sted',
},
},
{
src: 'placeholder:jobtrack-details',
src: '/assets/projects/jobtrack/pipeline.webp',
width: 1600,
height: 1000,
alt: {
en: 'Job details dialog combining summary, notes, correspondence and attachments',
no: 'Jobbdetaljer med sammendrag, notater, korrespondanse og vedlegg',
en: 'JobTrack Kanban pipeline with applications across Applied, Waiting, Interview and Offer',
no: 'JobTrack Kanban-pipeline med søknader fordelt på stadiene',
},
caption: {
en: 'Job details — everything about one application in one place',
no: 'Jobbdetaljer — alt om én søknad på ett sted',
en: 'Pipeline — drag applications between stages, from Applied to Offer',
no: 'Pipeline — dra søknader mellom stadier, fra Søkt til Tilbud',
},
},
{
src: 'placeholder:jobtrack-gmail',
src: '/assets/projects/jobtrack/dashboard.webp',
width: 1600,
height: 1000,
alt: {
en: 'Gmail import view with smart query filters and thread import',
no: 'Gmail-import med smarte søkefiltre og trådimport',
en: 'JobTrack dashboard with response rate, funnel and time-in-stage',
no: 'JobTrack-dashbord med svarrate, trakt og tid-per-stadium',
},
caption: {
en: 'Gmail import — pull correspondence straight onto the application',
no: 'Gmail-importhent korrespondanse rett inn på søknaden',
en: 'Dashboard — response rates, funnel and skill demand across the search',
no: 'Dashbordsvarrater, trakt og etterspurte ferdigheter',
},
},
],
content: {
en: {
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:
'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: {
what: 'Full-stack job-application tracker: pipeline, correspondence, attachments, analytics.',
what: 'Full-stack job-search workspace: pipeline, deterministic CV match, multi-provider recruiter threads, AI drafting.',
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:
'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama local AI · Gmail/Microsoft/IMAP · Docker.',
role: 'Everything: product, backend, frontend, ops and security.',
},
sections: [
@@ -144,55 +147,104 @@ export const jobtrack: Project = {
blocks: [
{
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, and every CV-tailoring tool either charges a subscription or hands back an opaque "match score" you can\'t interrogate. 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.',
},
],
},
{
kind: 'solution',
heading: 'Solution',
anchorId: 'solution',
blocks: [
{
type: 'p',
text: 'JobTrack is a self-hosted workspace that carries one job application through its whole life: import the posting, see how your CV stacks up against it, track it through a pipeline, and keep every recruiter message attached to the right job — with an assistive AI layer that drafts but never sends anything on your behalf.',
},
{
type: 'ul',
items: [
'Pipeline: a Kanban board — Applied, Waiting, Interview, Offer, Rejected, Ghosted — drag to update, with rule-based auto-transition to Ghosted after inactivity.',
'CV match: deterministic keyword coverage (matched vs missing), not a black-box score.',
'Correspondence: one unified thread view across Gmail, Microsoft/Outlook and generic IMAP, auto-linked to the right job.',
'AI is assistive, never autonomous: it drafts CVs, cover letters and follow-ups — you always review and send.',
],
},
],
},
{
kind: 'features',
heading: 'Feature showcase',
anchorId: 'features',
features: [
{
title: 'Deterministic CV↔job match score',
value:
'See exactly which keywords from a posting your CV covers and which are missing — a stable, reproducible number instead of a $50/month "AI fit score" you can\'t argue with.',
technical:
'A from-scratch weighted-keyword algorithm (JobCvMatchService): curated skill tags at weight 3 plus a title bonus, frequency-ranked stop-word-filtered posting terms at weight 1, matched with a hand-rolled word-boundary check so "go" never matches "goal". No AI call — same inputs always produce the same score, with an honest "not enough signal" flag instead of false confidence.',
},
{
title: 'One thread, any provider',
value:
'Recruiter emails land in the right job automatically, whether they come through Gmail, Outlook or any other IMAP mailbox — no more digging through three inboxes to find what a recruiter said.',
technical:
'Correspondence import runs through a single IEmailProvider interface (SearchAsync / ListThreadMessagesAsync / GetMessageAsync) behind a provider registry, with a Provider discriminator column on each stored message. Gmail shipped first; Microsoft Graph and IMAP were added later as separate implementations of the same contract — no controller logic was duplicated per provider.',
},
{
title: 'AI-assisted drafting, human-gated sending',
value:
'Get a tailored CV, cover letter or follow-up draft in seconds — but nothing is ever sent or auto-applied without you reading and approving it first.',
technical:
'CVs are OCR/text-extracted (FastAPI + Tesseract/PyMuPDF), classified into structured blocks by a local Ollama model (qwen2.5:7b), then rendered to PDF via a templated Playwright export. Prompts wrap untrusted CV text in explicit delimiters with an instruction to treat it as inert data, not commands — closing off prompt injection from a CV someone else wrote.',
},
],
},
{
kind: 'architecture',
heading: 'Architecture',
heading: 'Engineering highlights',
anchorId: 'architecture',
blocks: [
{
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: 'The backend is a modular monolith on ASP.NET Core (.NET 9) with six hosted background services — rules engine, follow-up reminders, scheduled exports, job enrichment, CV processing and an AI-readiness probe — running off the request path. EF Core targets SQLite by default and switches to MySQL/MariaDB via the Pomelo provider for larger deployments. Every tenant table is scoped by a global EF Core query filter (deny-on-null by design), so one database safely serves multiple users.',
},
{
type: 'ul',
items: [
'Frontend: React + TypeScript SPA, served by nginx, installable as a PWA with a mobile share-target for quick capture.',
'API: ASP.NET Core, EF Core, rule-driven follow-up reminders, per-application history trail.',
'AI: FastAPI wrapper over Ollama for short/full description summaries — advisory only.',
'Integration: Gmail OAuth2 with smart query filters and single-message or full-thread import.',
'Auth: a custom policy scheme routes Google-issued, Microsoft-issued and local JWTs to the right handler; CSRF double-submit on cookie sessions; rate-limited login and email-sending endpoints.',
'CI/CD: Gitea Actions builds and tests the backend, builds the frontend, then SSHes into the production host and redeploys via Docker Compose on every push to main — no staging environment, by deliberate choice.',
'i18n: full English + Norwegian Bokmål UI, a genuine differentiator against the mostly US-centric competitors (Teal, Huntr, Simplify).',
],
},
],
},
{
kind: 'decisions',
heading: 'Key decisions & trade-offs',
heading: 'Challenges',
anchorId: 'decisions',
decisions: [
{
n: 1,
choice: 'Run the AI locally with Ollama instead of a cloud API.',
alternative: 'A hosted LLM API would have been faster to wire up.',
choice:
'Generalize Gmail-only import into a provider-neutral IEmailProvider abstraction, incrementally.',
alternative:
'Rewrite the correspondence layer once, up front, for every provider we might ever need.',
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.',
'Import started Gmail-only; when Microsoft and IMAP support were requested later, I extracted the interface across several small PRs instead of a big-bang rewrite. Outcome: three working providers behind one contract, and no controller ever had to be duplicated per provider.',
},
{
n: 2,
choice: 'Ship the PWA with no offline service-worker cache.',
alternative: 'A cache would enable full offline use.',
choice: 'SSRF-harden the job-import URL fetcher before shipping it.',
alternative: 'Trust the pasted URL and fetch it directly — it is only a job posting.',
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.',
'Letting users paste an arbitrary URL for server-side scraping is a classic path into internal infrastructure. The fetcher resolves the hostname via DNS rather than trusting the literal host, then rejects the resolved IP against the full private/reserved-range table (RFC 1918, CGNAT, link-local, IPv6 unique-local) for both IPv4 and IPv6. Outcome: independently re-verified as fixed in a follow-up security pass.',
},
{
n: 3,
choice: 'SQLite rather than PostgreSQL.',
alternative: 'Postgres is the default reach for a web backend.',
choice: 'Keep CV matching deterministic instead of an AI-generated score.',
alternative: 'Let the local model rate the fit directly.',
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.',
'A number a candidate can\'t interrogate is useless, and it\'s the exact "AI slop" complaint competitor research turned up against existing tools. Deterministic keyword coverage shows precisely which terms matched and which are missing, so the advice is honest and actionable — and reproducible.',
},
],
},
@@ -204,10 +256,10 @@ export const jobtrack: Project = {
{
type: 'ul',
items: [
'Optional Google sign-in (Google ID tokens) protects the API; every record is scoped to its owner.',
'Google and Microsoft sign-in (issuer-routed policy scheme) protect the API; every record is scoped to its owner via EF Core global query filters.',
'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.',
'Runs as a reproducible Docker Compose stack with a documented .env; scheduled JSON/CSV exports for data portability.',
'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; JSON/CSV exports and automated database backups for data portability.',
],
},
],
@@ -219,25 +271,50 @@ export const jobtrack: Project = {
},
{
kind: 'next',
heading: 'What Id improve next',
heading: 'Status & roadmap',
anchorId: 'next',
blocks: [
{
type: 'p',
text: 'The rules engine for follow-ups is simple date logic; Id make it configurable per pipeline stage. Id 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: 'Implemented today:',
},
{
type: 'ul',
items: [
'Kanban pipeline with rule-based stage automation',
'Deterministic CV↔job match scoring',
'Multi-provider correspondence (Gmail, Microsoft Graph, IMAP)',
'AI-assisted CV, cover-letter and follow-up drafting (local Ollama, optional cloud fallback)',
'Attachments, reminders, JSON/CSV exports and automated backups',
],
},
{
type: 'p',
text: 'Planned next:',
},
{
type: 'ul',
items: [
'An interview hub and lightweight recruiter/contacts CRM',
'A second analytics pass and a proper PWA offline mode',
'Wider integration-test coverage around correspondence-import edge cases',
'Configurable per-stage follow-up rules (currently simple date logic)',
],
},
],
},
],
},
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:
'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: {
what: 'Fullstack jobbsøknads-tracker: pipeline, korrespondanse, vedlegg og analyse.',
what: 'Fullstack arbeidsrom for jobbsøking: pipeline, deterministisk CV-match, korrespondanse fra flere kanaler, AI-utkast.',
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:
'Next.js + ASP.NET Core (EF Core) · FastAPI/Ollama lokal AI · Gmail/Microsoft/IMAP · Docker.',
role: 'Alt: produkt, backend, frontend, drift og sikkerhet.',
},
sections: [
@@ -248,55 +325,105 @@ export const jobtrack: Project = {
blocks: [
{
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 — og alle CV-tilpasningsverktøy krever enten abonnement eller gir deg en uigjennomsiktig "match-score" du ikke kan etterprøve. Jeg ville ha ett samlet arbeidsrom — fra import av en stilling til endelig tilbud — bygget til samme standard som jeg leverer på jobb.',
},
],
},
{
kind: 'solution',
heading: 'Løsning',
anchorId: 'losning',
blocks: [
{
type: 'p',
text: 'JobTrack er et egendriftet arbeidsrom som følger én søknad gjennom hele livsløpet: importer stillingen, se hvordan CV-en din står seg mot den, følg den gjennom en pipeline, og hold hver rekruttør-melding koblet til riktig jobb — med et assisterende AI-lag som skriver utkast, men aldri sender noe på dine vegne.',
},
{
type: 'ul',
items: [
'Pipeline: et Kanban-brett — Søkt, Venter, Intervju, Tilbud, Avslått, Ghostet — dra for å oppdatere, med regelstyrt auto-overgang til Ghostet ved inaktivitet.',
'CV-match: deterministisk nøkkelorddekning (treff vs. mangler), ikke en svart boks.',
'Korrespondanse: én samlet tråd-visning på tvers av Gmail, Microsoft/Outlook og generisk IMAP, automatisk koblet til riktig jobb.',
'AI-en er assisterende, aldri autonom: den skriver utkast til CV, søknadsbrev og oppfølging — du gjennomgår og sender alltid selv.',
],
},
],
},
{
kind: 'features',
heading: 'Utvalgte funksjoner',
anchorId: 'funksjoner',
features: [
{
title: 'Deterministisk CV↔jobb-match',
value:
'Se nøyaktig hvilke nøkkelord fra stillingsannonsen CV-en din dekker, og hvilke som mangler — et stabilt, reproduserbart tall i stedet for en uigjennomsiktig "AI-score" til 50 dollar i måneden.',
technical:
'En egenutviklet, vektet nøkkelordalgoritme (JobCvMatchService): kuraterte kompetanse-tagger med vekt 3 pluss tittelbonus, frekvensrangerte, stoppord-filtrerte annonsetermer med vekt 1, matchet med en håndbygget ordgrense-sjekk så «go» aldri treffer «goal». Ingen AI-kall — samme input gir alltid samme score, med et ærlig «for lite signal»-flagg i stedet for falsk trygghet.',
},
{
title: 'Én tråd, uansett leverandør',
value:
'Rekruttør-eposter havner automatisk på riktig jobb, uansett om de kommer via Gmail, Outlook eller en annen IMAP-postkasse — ikke flere tre-innbokser å lete gjennom for å finne hva en rekrutterer sa.',
technical:
'Import av korrespondanse går gjennom ett felles IEmailProvider-grensesnitt (SearchAsync / ListThreadMessagesAsync / GetMessageAsync) bak et leverandørregister, med en Provider-kolonne på hver lagrede melding. Gmail kom først; Microsoft Graph og IMAP ble lagt til senere som separate implementasjoner av samme kontrakt — ingen kontrollerlogikk ble duplisert per leverandør.',
},
{
title: 'AI-assistert utkast, menneske-styrt sending',
value:
'Få et tilpasset CV-, søknadsbrev- eller oppfølgingsutkast på sekunder — men ingenting sendes eller søkes automatisk uten at du har lest og godkjent det først.',
technical:
'CV-er tekstutrekkes med OCR (FastAPI + Tesseract/PyMuPDF), klassifiseres i strukturerte blokker av en lokal Ollama-modell (qwen2.5:7b), og rendres til PDF via en malbasert Playwright-eksport. Prompter pakker inn utrygg CV-tekst i eksplisitte skilletegn med instruks om å behandle den som data, ikke kommandoer — som stenger av prompt-injeksjon fra en CV noen andre har skrevet.',
},
],
},
{
kind: 'architecture',
heading: 'Arkitektur',
heading: 'Tekniske høydepunkter',
anchorId: 'arkitektur',
blocks: [
{
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: 'Backend er en modulær monolitt på ASP.NET Core (.NET 9) med seks bakgrunnstjenester — regelmotor, oppfølgingspåminnelser, planlagte eksporter, jobb-berikelse, CV-prosessering og en AI-klarhetssjekk — som kjører utenfor forespørselsløpet. EF Core bruker SQLite som standard og bytter til MySQL/MariaDB via Pomelo-driveren for større driftssettinger. Hver leietaker-tabell er avgrenset av et globalt EF Core-spørringsfilter (nekt-ved-null by design), så én database trygt kan betjene flere brukere.',
},
{
type: 'ul',
items: [
'Frontend: React + TypeScript, servert av nginx, installerbar som PWA med share-target for rask registrering.',
'API: ASP.NET Core, EF Core, regelstyrte oppfølgingspåminnelser og historikk per søknad.',
'AI: FastAPI over Ollama for korte og fulle sammendrag — kun rådgivende.',
'Integrasjon: Gmail OAuth2 med smarte søkefiltre og import av enkeltmeldinger eller hele tråder.',
'Autentisering: et tilpasset policy-skjema ruter Google-utstedte, Microsoft-utstedte og lokale JWT-er til riktig håndterer; CSRF-dobbeltinnsending på cookie-økter; ratebegrenset innlogging og e-postutsending.',
'CI/CD: Gitea Actions bygger og tester backend, bygger frontend, og SSH-er så inn på produksjonsserveren og ruller ut på nytt via Docker Compose ved hver push til main — ingen staging-miljø, et bevisst valg.',
'i18n: fullt engelsk + norsk bokmål-grensesnitt, en reell differensiator mot de mest USA-sentrerte konkurrentene (Teal, Huntr, Simplify).',
],
},
],
},
{
kind: 'decisions',
heading: 'Viktige valg og avveininger',
anchorId: 'beslutninger',
heading: 'Utfordringer',
anchorId: 'utfordringer',
decisions: [
{
n: 1,
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.',
choice:
'Generalisere Gmail-bare import til en leverandørnøytral IEmailProvider-abstraksjon, trinnvis.',
alternative:
'Skrive om korrespondanselaget én gang, på forhånd, for hver leverandør vi kanskje trenger.',
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.',
'Import startet Gmail-bare; da Microsoft- og IMAP-støtte ble etterspurt senere, trakk jeg ut grensesnittet over flere små PR-er i stedet for en stor omskriving. Resultat: tre fungerende leverandører bak én kontrakt, og ingen kontroller måtte dupliseres per leverandør.',
},
{
n: 2,
choice: 'Levere PWA-en uten offline-cache i service-workeren.',
alternative: 'En cache ville gitt full offline-bruk.',
choice: 'SSRF-sikre jobb-import-URL-henteren før den ble sluppet.',
alternative:
'Stole på den limte inn URL-en og hente den direkte — det er jo bare en stillingsannonse.',
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.',
'Å la brukere lime inn en vilkårlig URL for server-side skraping er en klassisk vei inn i intern infrastruktur. Henteren slår opp vertsnavnet via DNS i stedet for å stole på den bokstavelige verten, og avviser så den oppløste IP-en mot hele det private/reserverte adresseområdet (RFC 1918, CGNAT, link-lokal, IPv6 unique-local) for både IPv4 og IPv6. Resultat: uavhengig verifisert som fikset i en påfølgende sikkerhetsgjennomgang.',
},
{
n: 3,
choice: 'SQLite framfor PostgreSQL.',
alternative: 'Postgres er standardvalget for en web-backend.',
choice: 'Holde CV-matchen deterministisk i stedet for en AI-generert score.',
alternative: 'La den lokale modellen vurdere treffet direkte.',
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.',
'Et tall en kandidat ikke kan etterprøve er ubrukelig, og det er nøyaktig «AI-slop»-klagen konkurrentanalysen fant mot eksisterende verktøy. Deterministisk nøkkelorddekning viser nøyaktig hvilke ord som traff og hvilke som mangler, så rådet er ærlig, handlingsrettet — og reproduserbart.',
},
],
},
@@ -308,10 +435,10 @@ export const jobtrack: Project = {
{
type: 'ul',
items: [
'Valgfri Google-innlogging (Google ID-tokens) beskytter API-et; hver post er knyttet til sin eier.',
'Google- og Microsoft-innlogging (utsteder-rutet policy-skjema) beskytter API-et; hver post er knyttet til sin eier via globale EF Core-spørringsfilter.',
'Filopplastinger valideres og lagres per søknad, med eierskapssjekk ved hvert tilgangspunkt.',
'AI-laget er kun rådgivende — det utfører aldri destruktive handlinger.',
'Kjører som en reproduserbar Docker Compose-stack med dokumentert .env; planlagte JSON/CSV-eksporter for dataportabilitet.',
'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; JSON/CSV-eksport og automatiske databasebackupper for dataportabilitet.',
],
},
],
@@ -323,12 +450,35 @@ export const jobtrack: Project = {
},
{
kind: 'next',
heading: 'Hva jeg ville forbedret',
heading: 'Status og veien videre',
anchorId: 'videre',
blocks: [
{
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: 'Implementert i dag:',
},
{
type: 'ul',
items: [
'Kanban-pipeline med regelstyrt stadie-automasjon',
'Deterministisk CV↔jobb-match',
'Korrespondanse fra flere kanaler (Gmail, Microsoft Graph, IMAP)',
'AI-assistert utkast til CV, søknadsbrev og oppfølging (lokal Ollama, valgfri sky-fallback)',
'Vedlegg, påminnelser, JSON/CSV-eksport og automatiske backupper',
],
},
{
type: 'p',
text: 'Planlagt videre:',
},
{
type: 'ul',
items: [
'Et intervjuknutepunkt og en enkel rekrutterer/kontakt-CRM',
'En ny analyserunde og et skikkelig offline-modus for PWA-en',
'Bredere integrasjonstestdekning rundt kanttilfeller i korrespondanse-import',
'Konfigurerbare oppfølgingsregler per stadium (i dag enkel datologikk)',
],
},
],
},
+9
View File
@@ -72,8 +72,16 @@ export const decision = z.object({
rationale: z.string(),
});
export const feature = z.object({
title: z.string(),
value: z.string(),
technical: z.string(),
});
export const SECTION_KINDS = [
'problem',
'solution',
'features',
'architecture',
'decisions',
'security',
@@ -87,6 +95,7 @@ export const section = z.object({
anchorId: z.string(),
blocks: z.array(block).optional(),
decisions: z.array(decision).optional(),
features: z.array(feature).optional(),
});
export const projectLocaleContent = z.object({
+1 -1
View File
@@ -49,7 +49,7 @@ export function buildJsonLd(
addressLocality: 'Tønsberg',
addressCountry: 'NO',
},
sameAs: [p.links.linkedin, p.links.gitea],
sameAs: [p.links.linkedin],
knowsLanguage: ['en', 'nb'],
knowsAbout: ['C#', '.NET', 'React', 'TypeScript', 'Python', 'SQL', 'Docker', 'Linux'],
};
+14 -12
View File
@@ -1,19 +1,21 @@
/*
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
describing the *next* action. Fail-silent if the button is absent.
paint; this wires every toggle (header + mobile nav), persists the choice, and keeps
each aria-label describing the *next* action. Fail-silent if none are present.
*/
const html = document.documentElement;
const btn = document.getElementById('theme-toggle');
const toggles = document.querySelectorAll<HTMLElement>('[data-theme-toggle]');
function labelFor(theme: string): string {
if (!btn) return '';
return theme === 'dark' ? (btn.dataset.toLight ?? '') : (btn.dataset.toDark ?? '');
function label(el: HTMLElement, theme: string): string {
return theme === 'dark' ? (el.dataset.toLight ?? '') : (el.dataset.toDark ?? '');
}
function syncLabels(theme: string): void {
toggles.forEach((el) => el.setAttribute('aria-label', label(el, theme)));
}
if (btn) {
btn.setAttribute('aria-label', labelFor(html.dataset.theme ?? 'dark'));
btn.addEventListener('click', () => {
syncLabels(html.dataset.theme ?? 'dark');
toggles.forEach((el) =>
el.addEventListener('click', () => {
const next = html.dataset.theme === 'light' ? 'dark' : 'light';
html.dataset.theme = next;
try {
@@ -21,6 +23,6 @@ if (btn) {
} catch {
/* private mode — ignore */
}
btn.setAttribute('aria-label', labelFor(next));
});
}
syncLabels(next);
}),
);
+3 -3
View File
@@ -11,13 +11,13 @@ export default defineConfig({
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:4321',
baseURL: 'http://localhost:4399',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm -C ../site build && pnpm -C ../site preview --port 4321 --host',
url: 'http://localhost:4321/',
command: 'pnpm -C ../site build && pnpm -C ../site preview --port 4399 --host',
url: 'http://localhost:4399/',
timeout: 180_000,
reuseExistingServer: !process.env.CI,
},
Binary file not shown.
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
# CV generator
Produces Connor Babbington's CV in both languages as **ATS-safe PDFs** (single column,
real text, standard headings, no letter-spacing tricks) and as editable **.docx**.
Content lives in [`content.js`](content.js) — the single source, mirroring
`docs/design/02-ux/CANONICAL_CONTENT.md`. Positioning is "Systems Developer, eight
years' experience" (not "mid-level"); the council role shows the apprentice→developer
progression; a Projects section is included; Norwegian is written natively.
## Regenerate
```bash
cd tools/cv
npm install
npm run pdf # -> connor-babbington-cv-en.pdf / -no.pdf (deploy these to site/public/cv/)
npm run docx # -> Connor-Babbington-CV-EN.docx / -NO.docx (editable source)
```
Then copy the PDFs into `site/public/cv/` (same filenames) — the site links to those
stable URLs, so sharing links always fetch the newest CV.
## Notes
- The download default on the site is the **ATS-safe PDF** (these files).
- The current PDFs replaced the original two-column, letter-spaced CVs, which parsed
as scrambled text in applicant-tracking systems.
- `.docx` files are kept for easy manual editing; re-export or regenerate after edits.
+216
View File
@@ -0,0 +1,216 @@
const fs = require('fs');
const {
Document, Packer, Paragraph, TextRun, ExternalHyperlink,
AlignmentType, LevelFormat, TabStopType, BorderStyle, HeadingLevel,
} = require('docx');
// A4, 1 inch margins. Content width = 11906 - 2*1440 = 9026 DXA.
const CONTENT_W = 9026;
function styles() {
return {
default: { document: { run: { font: 'Arial', size: 21 } } }, // 10.5pt
paragraphStyles: [
{ id: 'Heading2', name: 'Heading 2', basedOn: 'Normal', next: 'Normal', quickFormat: true,
run: { size: 24, bold: true, font: 'Arial', color: '1A1A1A' },
paragraph: {
spacing: { before: 280, after: 120 }, outlineLevel: 1,
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'B0B0B0', space: 4 } },
} },
],
};
}
const numbering = {
config: [{
reference: 'b',
levels: [{ level: 0, format: LevelFormat.BULLET, text: '•', alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 360, hanging: 220 } } } }],
}],
};
const bullet = (text) =>
new Paragraph({ numbering: { reference: 'b', level: 0 }, spacing: { after: 40 },
children: [new TextRun(text)] });
const heading = (text) => new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun(text)] });
// Role line: "Role — Org" left, dates right (tab stop).
const roleLine = (left, dates) =>
new Paragraph({
spacing: { before: 120, after: 0 },
tabStops: [{ type: TabStopType.RIGHT, position: CONTENT_W }],
children: [new TextRun({ text: left, bold: true }), new TextRun({ text: `\t${dates}`, color: '555555' })],
});
const note = (text) =>
new Paragraph({ spacing: { after: 40 }, children: [new TextRun({ text, italics: true, color: '555555' })] });
const skillLine = (group, items) =>
new Paragraph({ spacing: { after: 40 },
children: [new TextRun({ text: `${group}: `, bold: true }), new TextRun(items)] });
const projectLine = (name, desc) =>
new Paragraph({ numbering: { reference: 'b', level: 0 }, spacing: { after: 40 },
children: [new TextRun({ text: `${name}`, bold: true }), new TextRun(desc)] });
function buildCv(c) {
const children = [];
// Name + title
children.push(new Paragraph({ spacing: { after: 0 },
children: [new TextRun({ text: c.name, bold: true, size: 40 })] }));
children.push(new Paragraph({ spacing: { after: 60 },
children: [new TextRun({ text: c.title, size: 24, color: '333333' })] }));
// Contact (with hyperlinks), rule beneath
children.push(new Paragraph({
spacing: { after: 20 },
children: [
new TextRun(`${c.location} · `),
new ExternalHyperlink({ link: `mailto:${c.email}`, children: [new TextRun({ text: c.email, style: 'Hyperlink' })] }),
new TextRun(` · ${c.phone}`),
],
}));
children.push(new Paragraph({
spacing: { after: 40 },
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'B0B0B0', space: 6 } },
children: [
new ExternalHyperlink({ link: 'https://cesnimda.co.uk', children: [new TextRun({ text: 'cesnimda.co.uk', style: 'Hyperlink' })] }),
new TextRun(' · '),
new ExternalHyperlink({ link: 'https://cesnimda.co.uk/Linkedin', children: [new TextRun({ text: 'LinkedIn', style: 'Hyperlink' })] }),
],
}));
// Summary
children.push(heading(c.t.summary));
children.push(new Paragraph({ spacing: { after: 40 }, children: [new TextRun(c.summary)] }));
// Skills
children.push(heading(c.t.skills));
for (const s of c.skills) children.push(skillLine(s.group, s.items));
// Experience
children.push(heading(c.t.experience));
for (const e of c.experience) {
children.push(roleLine(`${e.role}${e.org}`, e.dates));
if (e.note) children.push(note(e.note));
for (const b of e.bullets) children.push(bullet(b));
}
// Earlier roles
children.push(new Paragraph({ spacing: { before: 100, after: 20 },
children: [new TextRun({ text: c.t.earlier, bold: true })] }));
for (const r of c.earlier) children.push(bullet(r));
// Projects
children.push(heading(c.t.projects));
for (const p of c.projects) children.push(projectLine(p.name, p.desc));
// Education
children.push(heading(c.t.education));
children.push(roleLine(c.education.qual, c.education.dates));
children.push(new Paragraph({ children: [new TextRun(c.education.org)] }));
return new Document({
styles: styles(),
numbering,
sections: [{
properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } },
children,
}],
});
}
const EN = {
name: 'Connor Babbington',
title: 'Systems Developer',
location: 'Tønsberg, Norway',
email: 'connor.babbington@cesnimda.co.uk',
phone: '+47 41 33 44 70',
t: { summary: 'Professional Summary', skills: 'Core Skills', experience: 'Experience',
earlier: 'Earlier roles (part-time, alongside the above)', projects: 'Projects', education: 'Education' },
summary:
"Systems developer with eight years' experience building and maintaining production software for UK local government. A backend-leaning full-stack developer across C#, .NET, Python, JavaScript/TypeScript and SQL, with hands-on DevOps in Docker, Linux, CI/CD, Azure DevOps and GitHub. I turn stakeholder requirements into reliable, well-tested systems and support them in production. Based in Tønsberg with a valid residence permit. Native English speaker; Norwegian at B1 and actively developing. Open to remote, hybrid or on-site roles.",
skills: [
{ group: 'Development', items: 'C#, .NET, Python, JavaScript, TypeScript, React, SQL' },
{ group: 'DevOps & Infrastructure', items: 'Docker, Linux, CI/CD, Azure DevOps, GitHub, nginx / reverse proxies, monitoring, self-hosting' },
{ group: 'Practices', items: 'Testing, security hardening, OAuth2 integrations, production support, stakeholder communication' },
],
experience: [
{ role: 'System Developer', org: 'Warwickshire County Council, UK', dates: '20152023',
note: 'The first two years were completed as an apprenticeship.',
bullets: [
'Worked on the county-wide highways and streetlight fault-reporting system used across Warwickshire.',
'Designed, built and maintained full-stack applications in C#, Python, Ruby on Rails, SQL and JavaScript.',
'Delivered reliable, well-tested software and resolved stability and performance issues on live systems.',
'Owned deployments and production troubleshooting, and strengthened permissions and reliability.',
'Guided colleagues on best practices for code and process.',
] },
{ role: 'Independent Development & Norwegian Study', org: 'Tønsberg, Norway', dates: '2023Present',
bullets: [
'Building and self-hosting full-stack products (see Projects) while developing Norwegian language skills.',
] },
],
earlier: [
'Sales Representative — Royal Vapes, UK (20172021)',
'Bartender — The Hodcarrier, UK (20162018)',
'Receptionist — Nuffield Health, UK (20142015)',
],
projects: [
{ name: 'JobTrack', desc: 'Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker) with a local AI summariser and Gmail integration.' },
{ name: 'InboxIntel', desc: 'Gmail analytics and safe bulk-cleanup tool built as Clean Architecture in .NET 8 with PostgreSQL and encrypted OAuth tokens.' },
{ name: 'Self-hosted infrastructure lab', desc: 'Ubuntu, Docker, nginx reverse proxy, authentication, monitoring and a self-hosted Gitea instance.' },
],
education: { qual: 'Extended Diploma — NVQ Level 3 in ICT', org: 'Warwickshire College, UK', dates: '20122015' },
};
const NO = {
name: 'Connor Babbington',
title: 'Systemutvikler',
location: 'Tønsberg',
email: 'connor.babbington@cesnimda.co.uk',
phone: '+47 41 33 44 70',
t: { summary: 'Sammendrag', skills: 'Kjernekompetanse', experience: 'Erfaring',
earlier: 'Tidligere roller (deltid, ved siden av)', projects: 'Prosjekter', education: 'Utdanning' },
summary:
'Systemutvikler med åtte års erfaring med å bygge og vedlikeholde produksjonssystemer i britisk offentlig sektor. Fullstack-utvikler med tyngde på backend i C#, .NET, Python, JavaScript/TypeScript og SQL, med praktisk DevOps i Docker, Linux, CI/CD, Azure DevOps og GitHub. Jeg omsetter behov fra brukere og interessenter til pålitelige, godt testede systemer og drifter dem i produksjon. Bosatt i Tønsberg med gyldig oppholdstillatelse. Engelsk morsmål; norsk på B1-nivå og i aktiv utvikling. Åpen for remote, hybrid eller stedbaserte roller.',
skills: [
{ group: 'Utvikling', items: 'C#, .NET, Python, JavaScript, TypeScript, React, SQL' },
{ group: 'DevOps og infrastruktur', items: 'Docker, Linux, CI/CD, Azure DevOps, GitHub, nginx / reverse proxy, overvåking, egendrift' },
{ group: 'Arbeidsmåte', items: 'Testing, sikkerhet, OAuth2-integrasjoner, driftsstøtte, kommunikasjon med interessenter' },
],
experience: [
{ role: 'Systemutvikler', org: 'Warwickshire County Council, UK', dates: '20152023',
note: 'De to første årene var en lærlingperiode.',
bullets: [
'Jobbet på det fylkesdekkende systemet for feilmelding av veilys og veier, brukt i hele Warwickshire.',
'Designet, bygde og vedlikeholdt fullstack-applikasjoner i C#, Python, Ruby on Rails, SQL og JavaScript.',
'Leverte pålitelig, godt testet programvare og løste stabilitets- og ytelsesproblemer i produksjon.',
'Hadde ansvar for utrulling og feilsøking i drift, og styrket tilganger og pålitelighet.',
'Veiledet kollegaer i beste praksis for kode og prosess.',
] },
{ role: 'Egen produktutvikling og norskkurs', org: 'Tønsberg', dates: '2023nå',
bullets: [
'Bygger og drifter egne fullstack-produkter (se Prosjekter) samtidig som jeg utvikler norskkunnskapene.',
] },
],
earlier: [
'Salgsrepresentant — Royal Vapes, UK (20172021)',
'Bartender — The Hodcarrier, UK (20162018)',
'Resepsjonist — Nuffield Health, UK (20142015)',
],
projects: [
{ name: 'JobTrack', desc: 'Fullstack jobbsøknads-tracker (React, ASP.NET Core, SQLite, Docker) med lokal AI-oppsummering og Gmail-integrasjon.' },
{ name: 'InboxIntel', desc: 'Verktøy for Gmail-analyse og trygg masseopprydding, bygget som Clean Architecture i .NET 8 med PostgreSQL og krypterte OAuth-tokens.' },
{ name: 'Egendriftet hjemmelab', desc: 'Ubuntu, Docker, nginx reverse proxy, autentisering, overvåking og egen Gitea-instans.' },
],
education: { qual: 'Extended Diploma — NVQ nivå 3 i IKT', org: 'Warwickshire College, UK', dates: '20122015' },
};
async function main() {
await Packer.toBuffer(buildCv(EN)).then((b) => fs.writeFileSync('Connor-Babbington-CV-EN.docx', b));
await Packer.toBuffer(buildCv(NO)).then((b) => fs.writeFileSync('Connor-Babbington-CV-NO.docx', b));
console.log('wrote EN + NO docx');
}
main();
+107
View File
@@ -0,0 +1,107 @@
const fs = require('fs');
const PDFDocument = require('pdfkit');
// Shared content (mirrors build-cv.js).
const { EN, NO } = require('./content.js');
const PAGE_W = 595.28;
const MARGIN = 54;
const CW = PAGE_W - MARGIN * 2; // content width
const INK = '#1a1a1a';
const GRAY = '#555555';
const RULE = '#b0b0b0';
function build(c, outfile) {
const doc = new PDFDocument({ size: 'A4', margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN } });
doc.pipe(fs.createWriteStream(outfile));
const rule = (gap = 4) => {
const y = doc.y + gap;
doc.moveTo(MARGIN, y).lineTo(MARGIN + CW, y).lineWidth(0.75).strokeColor(RULE).stroke();
doc.y = y + gap + 2;
};
const heading = (text) => {
doc.moveDown(0.5);
doc.fillColor(INK).font('Helvetica-Bold').fontSize(12).text(text, MARGIN, doc.y);
rule(2);
};
const bullet = (text, prefix) => {
const y = doc.y;
doc.fillColor(INK).font('Helvetica').fontSize(10).text('•', MARGIN + 2, y, { width: 10, lineBreak: false });
const tx = MARGIN + 16;
const tw = CW - 16;
if (prefix) {
doc.font('Helvetica-Bold').text(prefix, tx, y, { width: tw, continued: true });
doc.font('Helvetica').text(text, { width: tw });
} else {
doc.font('Helvetica').text(text, tx, y, { width: tw });
}
doc.moveDown(0.15);
};
const roleLine = (left, dates) => {
doc.moveDown(0.3);
const y = doc.y;
doc.font('Helvetica').fontSize(9.5).fillColor(GRAY).text(dates, MARGIN, y, { width: CW, align: 'right', lineBreak: false });
doc.font('Helvetica-Bold').fontSize(10.5).fillColor(INK).text(left, MARGIN, y, { width: CW - 90, lineBreak: false });
doc.y = y + 14;
};
// Header
doc.fillColor(INK).font('Helvetica-Bold').fontSize(20).text(c.name, MARGIN, MARGIN);
doc.font('Helvetica').fontSize(12).fillColor('#333').text(c.title, { paragraphGap: 2 });
doc.font('Helvetica').fontSize(9.5).fillColor(GRAY)
.text(`${c.location} · ${c.email} · ${c.phone}`);
doc.text('cesnimda.co.uk · cesnimda.co.uk/Linkedin');
rule(4);
// Summary
heading(c.t.summary);
doc.font('Helvetica').fontSize(10).fillColor(INK).text(c.summary, MARGIN, doc.y, { width: CW, align: 'justify' });
// Skills
heading(c.t.skills);
for (const s of c.skills) {
const y = doc.y;
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(`${s.group}: `, MARGIN, y, { width: CW, continued: true });
doc.font('Helvetica').text(s.items, { width: CW });
doc.moveDown(0.15);
}
// Experience
heading(c.t.experience);
for (const e of c.experience) {
roleLine(`${e.role}${e.org}`, e.dates);
if (e.note) {
doc.font('Helvetica-Oblique').fontSize(9.5).fillColor(GRAY).text(e.note, MARGIN, doc.y, { width: CW });
doc.moveDown(0.1);
}
for (const b of e.bullets) bullet(b);
}
// Earlier roles
doc.moveDown(0.4);
doc.font('Helvetica-Bold').fontSize(10).fillColor(INK).text(c.t.earlier, MARGIN, doc.y, { width: CW });
doc.moveDown(0.1);
for (const r of c.earlier) bullet(r);
// Projects
heading(c.t.projects);
for (const p of c.projects) bullet(p.desc, `${p.name}`);
// Education
heading(c.t.education);
roleLine(c.education.qual, c.education.dates);
doc.font('Helvetica').fontSize(10).fillColor(INK).text(c.education.org, MARGIN, doc.y, { width: CW });
doc.end();
return new Promise((res) => doc.on('end', res));
}
(async () => {
await build(EN, 'connor-babbington-cv-en.pdf');
await build(NO, 'connor-babbington-cv-no.pdf');
console.log('wrote EN + NO pdf');
})();
+87
View File
@@ -0,0 +1,87 @@
const EN = {
name: 'Connor Babbington',
title: 'Systems Developer',
location: 'Tønsberg, Norway',
email: 'connor.babbington@cesnimda.co.uk',
phone: '+47 41 33 44 70',
t: { summary: 'Professional Summary', skills: 'Core Skills', experience: 'Experience',
earlier: 'Earlier roles (part-time, alongside the above)', projects: 'Projects', education: 'Education' },
summary:
"Systems developer with eight years experience building and maintaining production software for UK local government. A backend-leaning full-stack developer across C#, .NET, Python, JavaScript/TypeScript and SQL, with hands-on DevOps in Docker, Linux, CI/CD, Azure DevOps and GitHub. I turn stakeholder requirements into reliable, well-tested systems and support them in production. Based in Tønsberg with a valid residence permit. Native English speaker; Norwegian at B1 and actively developing. Open to remote, hybrid or on-site roles.",
skills: [
{ group: 'Development', items: 'C#, .NET, Python, JavaScript, TypeScript, React, SQL' },
{ group: 'DevOps & Infrastructure', items: 'Docker, Linux, CI/CD, Azure DevOps, GitHub, nginx / reverse proxies, monitoring, self-hosting' },
{ group: 'Practices', items: 'Testing, security hardening, OAuth2 integrations, production support, stakeholder communication' },
],
experience: [
{ role: 'System Developer', org: 'Warwickshire County Council, UK', dates: '20152023',
note: 'The first two years were completed as an apprenticeship.',
bullets: [
'Worked on the county-wide highways and streetlight fault-reporting system used across Warwickshire.',
'Designed, built and maintained full-stack applications in C#, Python, Ruby on Rails, SQL and JavaScript.',
'Delivered reliable, well-tested software and resolved stability and performance issues on live systems.',
'Owned deployments and production troubleshooting, and strengthened permissions and reliability.',
'Guided colleagues on best practices for code and process.',
] },
{ role: 'Independent Development & Norwegian Study', org: 'Tønsberg, Norway', dates: '2023Present',
bullets: [
'Building and self-hosting full-stack products (see Projects) while developing Norwegian language skills.',
] },
],
earlier: [
'Sales Representative — Royal Vapes, UK (20172021)',
'Bartender — The Hodcarrier, UK (20162018)',
'Receptionist — Nuffield Health, UK (20142015)',
],
projects: [
{ name: 'JobTrack', desc: 'Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker) with a local AI summariser and Gmail integration.' },
{ name: 'InboxIntel', desc: 'Gmail analytics and safe bulk-cleanup tool built as Clean Architecture in .NET 8 with PostgreSQL and encrypted OAuth tokens.' },
{ name: 'Self-hosted infrastructure lab', desc: 'Ubuntu, Docker, nginx reverse proxy, authentication, monitoring and a self-hosted Gitea instance.' },
],
education: { qual: 'Extended Diploma — NVQ Level 3 in ICT', org: 'Warwickshire College, UK', dates: '20122015' },
};
const NO = {
name: 'Connor Babbington',
title: 'Systemutvikler',
location: 'Tønsberg',
email: 'connor.babbington@cesnimda.co.uk',
phone: '+47 41 33 44 70',
t: { summary: 'Sammendrag', skills: 'Kjernekompetanse', experience: 'Erfaring',
earlier: 'Tidligere roller (deltid, ved siden av)', projects: 'Prosjekter', education: 'Utdanning' },
summary:
'Systemutvikler med åtte års erfaring med å bygge og vedlikeholde produksjonssystemer i britisk offentlig sektor. Fullstack-utvikler med tyngde på backend i C#, .NET, Python, JavaScript/TypeScript og SQL, med praktisk DevOps i Docker, Linux, CI/CD, Azure DevOps og GitHub. Jeg omsetter behov fra brukere og interessenter til pålitelige, godt testede systemer og drifter dem i produksjon. Bosatt i Tønsberg med gyldig oppholdstillatelse. Engelsk morsmål; norsk på B1-nivå og i aktiv utvikling. Åpen for remote, hybrid eller stedbaserte roller.',
skills: [
{ group: 'Utvikling', items: 'C#, .NET, Python, JavaScript, TypeScript, React, SQL' },
{ group: 'DevOps og infrastruktur', items: 'Docker, Linux, CI/CD, Azure DevOps, GitHub, nginx / reverse proxy, overvåking, egendrift' },
{ group: 'Arbeidsmåte', items: 'Testing, sikkerhet, OAuth2-integrasjoner, driftsstøtte, kommunikasjon med interessenter' },
],
experience: [
{ role: 'Systemutvikler', org: 'Warwickshire County Council, UK', dates: '20152023',
note: 'De to første årene var en lærlingperiode.',
bullets: [
'Jobbet på det fylkesdekkende systemet for feilmelding av veilys og veier, brukt i hele Warwickshire.',
'Designet, bygde og vedlikeholdt fullstack-applikasjoner i C#, Python, Ruby on Rails, SQL og JavaScript.',
'Leverte pålitelig, godt testet programvare og løste stabilitets- og ytelsesproblemer i produksjon.',
'Hadde ansvar for utrulling og feilsøking i drift, og styrket tilganger og pålitelighet.',
'Veiledet kollegaer i beste praksis for kode og prosess.',
] },
{ role: 'Egen produktutvikling og norskkurs', org: 'Tønsberg', dates: '2023nå',
bullets: [
'Bygger og drifter egne fullstack-produkter (se Prosjekter) samtidig som jeg utvikler norskkunnskapene.',
] },
],
earlier: [
'Salgsrepresentant — Royal Vapes, UK (20172021)',
'Bartender — The Hodcarrier, UK (20162018)',
'Resepsjonist — Nuffield Health, UK (20142015)',
],
projects: [
{ name: 'JobTrack', desc: 'Fullstack jobbsøknads-tracker (React, ASP.NET Core, SQLite, Docker) med lokal AI-oppsummering og Gmail-integrasjon.' },
{ name: 'InboxIntel', desc: 'Verktøy for Gmail-analyse og trygg masseopprydding, bygget som Clean Architecture i .NET 8 med PostgreSQL og krypterte OAuth-tokens.' },
{ name: 'Egendriftet hjemmelab', desc: 'Ubuntu, Docker, nginx reverse proxy, autentisering, overvåking og egen Gitea-instans.' },
],
education: { qual: 'Extended Diploma — NVQ nivå 3 i IKT', org: 'Warwickshire College, UK', dates: '20122015' },
};
module.exports = { EN, NO };
+13
View File
@@ -0,0 +1,13 @@
{
"name": "resumesite-cv",
"private": true,
"description": "Generates the ATS-safe CV PDFs (and editable .docx) from canonical content",
"scripts": {
"pdf": "node build-pdf.js",
"docx": "node build-cv.js"
},
"dependencies": {
"docx": "^9.0.0",
"pdfkit": "^0.15.0"
}
}