feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import type { CareerStation } from "@/src/components/illustrations/CareerLine";
|
||||
|
||||
/** The career in four stations. Facts from the owner's CV, deliberately summarised. */
|
||||
export const CAREER: readonly CareerStation[] = [
|
||||
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
|
||||
{
|
||||
years: "2014 – 2020",
|
||||
role: "Agencies",
|
||||
place: "Frontend developer",
|
||||
},
|
||||
{
|
||||
years: "2020 – 2023",
|
||||
role: "Product companies",
|
||||
place: "Senior software developer",
|
||||
},
|
||||
{
|
||||
years: "Today",
|
||||
role: "Independent",
|
||||
place: "Working directly with clients",
|
||||
},
|
||||
];
|
||||
|
||||
/** Which projects I am a good fit for, and which not. */
|
||||
export const FIT = [
|
||||
{
|
||||
title: "A good fit",
|
||||
items: [
|
||||
"Web apps: dashboards, portals, internal tools",
|
||||
"Interfaces where design and usability matter",
|
||||
"Company websites, also in several languages",
|
||||
"Web3 frontends: wallets, live data, dashboards",
|
||||
"Automation: connecting systems and their APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Also possible",
|
||||
items: [
|
||||
"The backend an app needs: APIs, login, integrations",
|
||||
"Command-line tools",
|
||||
"Taking over and tidying up existing code",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Not my field",
|
||||
items: [
|
||||
"Mobile apps for the app stores",
|
||||
"Windows software",
|
||||
"Running servers, specialised database work",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Technologies I work with. Only what I have actually used in projects. */
|
||||
export const TECH = [
|
||||
{
|
||||
area: "Interface",
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
area: "Behind it",
|
||||
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
|
||||
},
|
||||
{
|
||||
area: "Web3 and systems",
|
||||
items: ["Solidity", "Rust"],
|
||||
},
|
||||
{
|
||||
area: "Delivery",
|
||||
items: ["Automated tests", "Docker", "CI/CD"],
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Example workflows for the automation section. They are illustrations of typical
|
||||
* cases, not descriptions of specific client projects.
|
||||
*/
|
||||
|
||||
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
|
||||
|
||||
export type FlowNode = {
|
||||
readonly id: string;
|
||||
readonly kind: NodeKind;
|
||||
/** Position of the node centre on the wide 920 x 400 canvas. */
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
/** Position of the node centre on the tall 400 x 540 canvas for phones. */
|
||||
readonly nx: number;
|
||||
readonly ny: number;
|
||||
readonly system: string;
|
||||
readonly label: string;
|
||||
/** What appears in the log when a run reaches this node. */
|
||||
readonly log: string;
|
||||
};
|
||||
|
||||
/** One step of a run: the connections travelled at the same time. */
|
||||
export type FlowStep = readonly (readonly [from: string, to: string])[];
|
||||
|
||||
export type Workflow = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly summary: string;
|
||||
readonly byHand: string;
|
||||
readonly automated: string;
|
||||
readonly nodes: readonly FlowNode[];
|
||||
/** Every connection that exists, for drawing. */
|
||||
readonly edges: readonly (readonly [from: string, to: string])[];
|
||||
/** The runs that play in turn. Each run is a list of steps. */
|
||||
readonly runs: readonly (readonly FlowStep[])[];
|
||||
};
|
||||
|
||||
export const WORKFLOWS: readonly Workflow[] = [
|
||||
{
|
||||
id: "order",
|
||||
name: "Order to invoice",
|
||||
summary:
|
||||
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
|
||||
byHand:
|
||||
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
|
||||
automated:
|
||||
"The order triggers everything. People only see the finished result.",
|
||||
nodes: [
|
||||
{
|
||||
id: "shop",
|
||||
kind: "trigger",
|
||||
x: 110,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Shop",
|
||||
label: "New order",
|
||||
log: "order received",
|
||||
},
|
||||
{
|
||||
id: "stock",
|
||||
kind: "rule",
|
||||
x: 340,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "Rule",
|
||||
label: "In stock?",
|
||||
log: "stock checked: available",
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
kind: "action",
|
||||
x: 580,
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 330,
|
||||
system: "Accounting",
|
||||
label: "Create invoice",
|
||||
log: "invoice created",
|
||||
},
|
||||
{
|
||||
id: "crm",
|
||||
kind: "action",
|
||||
x: 580,
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 330,
|
||||
system: "CRM",
|
||||
label: "Update customer",
|
||||
log: "customer record updated",
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
kind: "action",
|
||||
x: 810,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 470,
|
||||
system: "Email",
|
||||
label: "Send confirmation",
|
||||
log: "confirmation sent",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
["shop", "stock"],
|
||||
["stock", "invoice"],
|
||||
["stock", "crm"],
|
||||
["invoice", "mail"],
|
||||
["crm", "mail"],
|
||||
],
|
||||
runs: [
|
||||
[
|
||||
[["shop", "stock"]],
|
||||
[
|
||||
["stock", "invoice"],
|
||||
["stock", "crm"],
|
||||
],
|
||||
[
|
||||
["invoice", "mail"],
|
||||
["crm", "mail"],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
name: "Incoming documents",
|
||||
summary:
|
||||
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
|
||||
byHand:
|
||||
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
|
||||
automated:
|
||||
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
|
||||
nodes: [
|
||||
{
|
||||
id: "inbox",
|
||||
kind: "trigger",
|
||||
x: 110,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Inbox",
|
||||
label: "Email with PDF",
|
||||
log: "document received",
|
||||
},
|
||||
{
|
||||
id: "read",
|
||||
kind: "ai",
|
||||
x: 340,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "AI step",
|
||||
label: "Read the document",
|
||||
log: "supplier, amount and date extracted",
|
||||
},
|
||||
{
|
||||
id: "check",
|
||||
kind: "rule",
|
||||
x: 570,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 320,
|
||||
system: "Rule",
|
||||
label: "Plausible?",
|
||||
log: "values checked",
|
||||
},
|
||||
{
|
||||
id: "book",
|
||||
kind: "action",
|
||||
x: 810,
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 460,
|
||||
system: "Accounting",
|
||||
label: "Book it",
|
||||
log: "booked automatically",
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
kind: "human",
|
||||
x: 810,
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 460,
|
||||
system: "A person",
|
||||
label: "Review and decide",
|
||||
log: "unclear: sent to a person for review",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
["inbox", "read"],
|
||||
["read", "check"],
|
||||
["check", "book"],
|
||||
["check", "ask"],
|
||||
],
|
||||
runs: [
|
||||
[[["inbox", "read"]], [["read", "check"]], [["check", "book"]]],
|
||||
[[["inbox", "read"]], [["read", "check"]], [["check", "book"]]],
|
||||
[[["inbox", "read"]], [["read", "check"]], [["check", "ask"]]],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lead",
|
||||
name: "Enquiry to follow-up",
|
||||
summary:
|
||||
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
|
||||
byHand:
|
||||
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
|
||||
automated: "The enquiry is filed, assigned and followed up on schedule.",
|
||||
nodes: [
|
||||
{
|
||||
id: "form",
|
||||
kind: "trigger",
|
||||
x: 110,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Website",
|
||||
label: "Contact form",
|
||||
log: "enquiry received",
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
kind: "action",
|
||||
x: 340,
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "CRM",
|
||||
label: "Create contact",
|
||||
log: "contact created",
|
||||
},
|
||||
{
|
||||
id: "notify",
|
||||
kind: "action",
|
||||
x: 580,
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 330,
|
||||
system: "Team chat",
|
||||
label: "Notify sales",
|
||||
log: "sales notified",
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
kind: "rule",
|
||||
x: 580,
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 330,
|
||||
system: "Rule",
|
||||
label: "No reply in 3 days?",
|
||||
log: "follow-up scheduled",
|
||||
},
|
||||
{
|
||||
id: "remind",
|
||||
kind: "action",
|
||||
x: 810,
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 470,
|
||||
system: "Tasks",
|
||||
label: "Create reminder",
|
||||
log: "reminder created",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
["form", "contact"],
|
||||
["contact", "notify"],
|
||||
["contact", "wait"],
|
||||
["wait", "remind"],
|
||||
],
|
||||
runs: [
|
||||
[
|
||||
[["form", "contact"]],
|
||||
[
|
||||
["contact", "notify"],
|
||||
["contact", "wait"],
|
||||
],
|
||||
[["wait", "remind"]],
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const KIND_LABEL: Readonly<Record<NodeKind, string>> = {
|
||||
trigger: "Trigger: something happens",
|
||||
rule: "Rule: a fixed check",
|
||||
ai: "AI step: reads or sorts",
|
||||
action: "Action: a system does something",
|
||||
human: "Person: decides the unclear cases",
|
||||
};
|
||||
|
||||
export const AUTOMATION_NOTES = [
|
||||
{
|
||||
title: "Worth automating when",
|
||||
items: [
|
||||
"The same steps repeat every day or every week",
|
||||
"Data is typed from one tool into another",
|
||||
"Mistakes happen because the work is monotonous",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What stays with people",
|
||||
items: [
|
||||
"Decisions and exceptions",
|
||||
"Anything a customer should hear from a person",
|
||||
"The final say when the automation is unsure",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What I need from you",
|
||||
items: [
|
||||
"Access to the tools and their interfaces (APIs)",
|
||||
"Someone who knows the process as it is done today",
|
||||
"Real examples to test with",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -1,121 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { CoreShellDiagram } from "@/src/components/notes/CoreShellDiagram";
|
||||
import {
|
||||
NoteFigure,
|
||||
NoteList,
|
||||
NoteParagraph,
|
||||
NoteSection,
|
||||
} from "@/src/components/notes/NoteProse";
|
||||
import type { Note } from "./types";
|
||||
|
||||
const Body: React.FC = () => (
|
||||
<>
|
||||
<NoteParagraph>
|
||||
I am building a liquidation system that runs across 7 chains. It is
|
||||
written in Rust, with Solidity executors on chain. It is built to run
|
||||
unattended, which changes how much you can rely on manual checking: almost
|
||||
all of the confidence has to come from tests that run before the code
|
||||
does. The architecture that makes this workable is a functional core with
|
||||
imperative shells.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>The shape</NoteSection>
|
||||
<NoteParagraph>
|
||||
A detector per chain indexes borrower positions and computes health
|
||||
factors. That work is split in two. The math lives in a pure domain crate:
|
||||
health factor, fixed-point arithmetic, and a closed-form optimum that is
|
||||
checked against an exact swap engine. The crate has no network, no clock,
|
||||
no randomness and no global state. Everything arrives as arguments and
|
||||
leaves as return values.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
Around that core sit thin shells. A shell reads state from a chain,
|
||||
converts it into plain data, calls the core, and acts on the answer. It
|
||||
contains almost no decisions, so there is little in it that can be wrong
|
||||
in an interesting way.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteFigure caption="Data flows down; only the shells touch the network.">
|
||||
<CoreShellDiagram />
|
||||
</NoteFigure>
|
||||
|
||||
<NoteSection>The on-chain side</NoteSection>
|
||||
<NoteParagraph>
|
||||
The executor contract does one job in one transaction. It takes a flash
|
||||
loan, liquidates the position, swaps the collateral, repays the loan, and
|
||||
reverts if the trade would not be profitable. Because the transaction is
|
||||
atomic, a failed attempt leaves nothing behind except the gas for the
|
||||
attempt. No own capital is at risk per attempt, which is a property of the
|
||||
design rather than of careful operation.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
The same idea applies on chain as off chain: the contract is a small,
|
||||
closed piece of logic with explicit inputs. The off-chain core decides
|
||||
whether to try; the contract has the final say.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>Testing the edge, not the middle</NoteSection>
|
||||
<NoteParagraph>
|
||||
Fixed-point arithmetic has cliffs. Past a certain input size a
|
||||
multiplication no longer fits in the integer type. If the code wraps
|
||||
around, a hugely healthy position can suddenly look unhealthy, or the
|
||||
other way round, and nothing crashes to tell you.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
One spec in the domain crate tests the exact overflow cliff of the health
|
||||
factor. It pins the behavior one step below the cliff and at the cliff,
|
||||
and requires the result to saturate instead of wrapping. This kind of case
|
||||
is where an optimizing refactor or a generated change breaks things
|
||||
silently, and it is trivial to write because the core is a pure function:
|
||||
call it with the boundary values and compare the result.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>Why the split pays off</NoteSection>
|
||||
<NoteList>
|
||||
<li>
|
||||
<strong>The core is testable without a network.</strong> Specs run fast,
|
||||
with no node, no fork and no flaky timing. That makes spec-first
|
||||
practical for the part with the most arithmetic.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mutation testing can prove the specs.</strong> Pure functions
|
||||
give mutation tools clean targets. If a mutant of the health factor
|
||||
survives, a spec is missing, and the gate says so. That is a stronger
|
||||
statement than a coverage percentage.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Shells are proven by end-to-end specs.</strong> They are not
|
||||
pure, so they are not tested like the core. They are exercised as a
|
||||
whole against the behavior they are meant to produce, and because they
|
||||
hold so little logic, those specs stay small.
|
||||
</li>
|
||||
</NoteList>
|
||||
|
||||
<NoteSection>Where it hurts</NoteSection>
|
||||
<NoteParagraph>
|
||||
The discipline has a cost. You have to keep deciding where a line of code
|
||||
belongs, and the temptation to put a small calculation in the shell
|
||||
because it is right there is constant. Every such shortcut moves logic
|
||||
into the part that is hardest to test. The gates described in the note on
|
||||
mechanical spec-first enforcement help here: a gate that forbids methods,
|
||||
mutable references and unwrap pushes the code toward the style the core
|
||||
depends on.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
The pattern is not specific to this project. Any system where money,
|
||||
permissions or irreversible actions sit behind a thin layer of I/O gains
|
||||
from keeping the decision logic pure and the I/O boring.
|
||||
</NoteParagraph>
|
||||
</>
|
||||
);
|
||||
|
||||
export const functionalCoreImperativeShell: Note = {
|
||||
slug: "functional-core-imperative-shell-multi-chain",
|
||||
title: "Functional core, imperative shell in a multi-chain system",
|
||||
summary:
|
||||
"How a pure Rust core, thin shells and an atomic on-chain executor make a 7-chain liquidation system testable.",
|
||||
publishedOn: "2026-10-02",
|
||||
readingMinutes: 4,
|
||||
tags: ["architecture", "rust", "web3"],
|
||||
Body,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { sortNotesNewestFirst, validateNoteRegistry } from "@/src/domain/notes";
|
||||
import { functionalCoreImperativeShell } from "./functional-core-imperative-shell";
|
||||
import { specFirstGates } from "./spec-first-gates";
|
||||
import type { Note } from "./types";
|
||||
|
||||
const ALL_NOTES: readonly Note[] = [
|
||||
specFirstGates,
|
||||
functionalCoreImperativeShell,
|
||||
];
|
||||
|
||||
const validated = validateNoteRegistry(ALL_NOTES);
|
||||
if (!validated.ok) {
|
||||
throw new Error(`Invalid notes: ${validated.error.join("; ")}`);
|
||||
}
|
||||
|
||||
export const NOTES: readonly Note[] = sortNotesNewestFirst(validated.value);
|
||||
@@ -1,142 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { GatePipelineDiagram } from "@/src/components/notes/GatePipelineDiagram";
|
||||
import {
|
||||
NoteCode,
|
||||
NoteFigure,
|
||||
NoteList,
|
||||
NoteParagraph,
|
||||
NoteSection,
|
||||
} from "@/src/components/notes/NoteProse";
|
||||
import type { Note } from "./types";
|
||||
|
||||
const Body: React.FC = () => (
|
||||
<>
|
||||
<NoteParagraph>
|
||||
A workflow that depends on discipline lasts until the first deadline. In
|
||||
my Rust systems the workflow is enforced by scripts instead: a change that
|
||||
skips the spec, drops coverage or leaves a weak test does not get past the
|
||||
gates, no matter who wrote it or how it was generated.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>The gates</NoteSection>
|
||||
<NoteParagraph>
|
||||
There are four, and each one answers a different question.
|
||||
</NoteParagraph>
|
||||
<NoteList>
|
||||
<li>
|
||||
<strong>TDD gate.</strong> Was the behavior specified as a test before
|
||||
it was implemented? Skipping the spec fails the gate.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Functional-style gate.</strong> It forbids methods, traits,{" "}
|
||||
<NoteCode>&mut</NoteCode> and <NoteCode>unwrap</NoteCode> in the
|
||||
domain code. Pure functions over immutable data are easier to test and
|
||||
to mutate, so the style is a rule, not a preference. The gate is itself
|
||||
covered by a self-test script, because a checker that silently stops
|
||||
checking is worse than none.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mutation gate.</strong> The tool changes the code in small ways
|
||||
and the specs must notice. The policy is zero survivors. A surviving
|
||||
mutant means either a weak assertion or dead code, and both get fixed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Coverage ratchet.</strong> Coverage may rise but may not fall.
|
||||
The gate fails when the number drops below the last recorded one.
|
||||
</li>
|
||||
</NoteList>
|
||||
|
||||
<NoteFigure caption="Four gates in sequence, run at three scopes.">
|
||||
<GatePipelineDiagram />
|
||||
</NoteFigure>
|
||||
|
||||
<NoteSection>Three speeds</NoteSection>
|
||||
<NoteParagraph>
|
||||
Mutation testing is slow, so the same gates run at three scopes. In the
|
||||
dev loop they look only at changed lines, which keeps feedback fast enough
|
||||
to use constantly. Before a merge they run on the changed crates. The
|
||||
merge gate runs everything. Nothing is skipped on the way to main; a
|
||||
cheaper check just runs earlier and more often.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
The mutation gate also keeps a proof ledger. When the code bytes and the
|
||||
toolchain are unchanged since a previous run, the mutants already caught
|
||||
are carried over instead of being recomputed. That keeps a zero-survivor
|
||||
policy affordable on a large codebase, and it stays honest because any
|
||||
change to the code or the toolchain invalidates the entry.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>Claims drift, measurements do not</NoteSection>
|
||||
<NoteParagraph>
|
||||
The most useful lesson came from reading my own documentation. A README
|
||||
and a Makefile said "100% coverage at all times", while the
|
||||
measured badge said 87.5%. Nobody lied; the sentence was true once and
|
||||
then the code moved. Prose does not fail a build.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
So I quote the measured number and enforce a ratchet instead of promising
|
||||
an absolute. A ratchet is a claim the machine checks on every run: the
|
||||
number may only go up. It is a weaker sentence than "always
|
||||
100%" and a far more reliable one.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>A story from this website</NoteSection>
|
||||
<NoteParagraph>
|
||||
This site has a small domain core with the same setup: Vitest for the
|
||||
specs and Stryker for mutation testing. At one point Stryker reported a
|
||||
mutation score of 14%. That looked like terrible specs, but the specs were
|
||||
fine. The mutation tool was silently running zero tests per mutant under
|
||||
Vitest 5, so every mutant looked like it had survived.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
The cause was a version mismatch between the Stryker Vitest runner and
|
||||
Vitest itself. Pinning Vitest to 4.1.x fixed it. The first real run then
|
||||
found 9 genuine gaps in the specs, the kind a green test suite had been
|
||||
hiding. Rewriting one date check also removed redundant conditions that no
|
||||
test could ever distinguish from the simpler version.
|
||||
</NoteParagraph>
|
||||
<NoteParagraph>
|
||||
The pin is now written down in the project instructions, with the reason,
|
||||
so the next dependency bump does not quietly undo it.
|
||||
</NoteParagraph>
|
||||
|
||||
<NoteSection>What I take from it</NoteSection>
|
||||
<NoteList>
|
||||
<li>
|
||||
<strong>Verify that your verification runs.</strong> A gate that
|
||||
executes zero tests passes or fails for the wrong reasons. Treat "0
|
||||
tests ran" as a failure, and look at a surprising score before
|
||||
explaining it away.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enforce, do not remind.</strong> If a rule matters, a script
|
||||
should fail when it is broken. Rules that live in a README drift.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Quote measurements.</strong> Write the number the tool printed,
|
||||
with the command that printed it, and let a ratchet protect it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Make the cheap check run first.</strong> Three speeds let the
|
||||
slow, thorough gates exist without slowing the loop that people use
|
||||
every few minutes.
|
||||
</li>
|
||||
</NoteList>
|
||||
<NoteParagraph>
|
||||
None of this is specific to Rust. The same shape works for a TypeScript
|
||||
project: a spec before the code, mutation testing on the changed files,
|
||||
and a coverage number that is only allowed to go up.
|
||||
</NoteParagraph>
|
||||
</>
|
||||
);
|
||||
|
||||
export const specFirstGates: Note = {
|
||||
slug: "how-i-enforce-spec-first-mechanically",
|
||||
title: "How I enforce spec-first mechanically",
|
||||
summary:
|
||||
"Four gates, three speeds, and one lesson: claims drift, so measure and ratchet instead of promising.",
|
||||
publishedOn: "2026-10-03",
|
||||
readingMinutes: 4,
|
||||
tags: ["testing", "mutation testing", "process"],
|
||||
Body,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import type { NoteMeta } from "@/src/domain/notes";
|
||||
|
||||
export type Note = NoteMeta & { readonly Body: React.FC };
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* The three ways a visitor can work with me, written for the visitor, not for engineers.
|
||||
* Each path answers the same four questions: what you get, how it runs, what it costs, what to do next.
|
||||
*/
|
||||
|
||||
export type PathStep = {
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export type PathPrice = {
|
||||
readonly label: string;
|
||||
readonly amount: string;
|
||||
readonly note: string;
|
||||
};
|
||||
|
||||
export type ServicePath = {
|
||||
readonly id: string;
|
||||
/** How the visitor would say it. */
|
||||
readonly need: string;
|
||||
readonly forWhom: string;
|
||||
readonly headline: string;
|
||||
readonly intro: string;
|
||||
readonly youGet: readonly string[];
|
||||
readonly steps: readonly PathStep[];
|
||||
/** How this kind of work is priced, in one line, and why. */
|
||||
readonly pricingModel: string;
|
||||
readonly pricingText: string;
|
||||
readonly prices: readonly PathPrice[];
|
||||
readonly smallPrint: string;
|
||||
readonly cta: string;
|
||||
};
|
||||
|
||||
export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
{
|
||||
id: "developer",
|
||||
need: "We need a developer for our product",
|
||||
forWhom: "Startups, product teams, agencies, Web3 teams",
|
||||
headline: "An experienced developer who joins your project.",
|
||||
intro:
|
||||
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
|
||||
youGet: [
|
||||
"Web apps, dashboards, customer portals and internal tools",
|
||||
"Interfaces with attention to design and usability",
|
||||
"Web3 frontends: wallet connection, live data, dashboards",
|
||||
"Small tools and automation for recurring work",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
|
||||
},
|
||||
{
|
||||
title: "We agree on the days",
|
||||
text: "How many days a week I work for you, and what should come first.",
|
||||
},
|
||||
{
|
||||
title: "I work through the list",
|
||||
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Two weeks notice",
|
||||
text: "The agreement can be ended with two weeks notice to the end of a month.",
|
||||
},
|
||||
],
|
||||
pricingModel: "By effort, for as long as your project runs",
|
||||
pricingText:
|
||||
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
|
||||
prices: [
|
||||
{ label: "Per hour", amount: "100 EUR", note: "net" },
|
||||
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
|
||||
],
|
||||
smallPrint:
|
||||
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
need: "Our app needs to become reliable",
|
||||
forWhom: "Teams whose app was built quickly and is now hard to change",
|
||||
headline: "A week to look at your app and fix what is most urgent.",
|
||||
intro:
|
||||
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
|
||||
youGet: [
|
||||
"An honest assessment: what is solid and what is fragile",
|
||||
"Automated tests for the most important parts",
|
||||
"The most urgent problems fixed, as far as a week allows",
|
||||
"A written list of next steps, in plain language",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You show me the app and tell me where it causes trouble.",
|
||||
},
|
||||
{
|
||||
title: "One week of work",
|
||||
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
|
||||
},
|
||||
{
|
||||
title: "A written summary",
|
||||
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
|
||||
},
|
||||
{
|
||||
title: "You decide how to continue",
|
||||
text: "With me, with your own team, or not at all.",
|
||||
},
|
||||
],
|
||||
pricingModel: "A fixed week at a fixed price",
|
||||
pricingText:
|
||||
"The scope is one week of my time. You know the cost before I start.",
|
||||
prices: [{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" }],
|
||||
smallPrint:
|
||||
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
need: "We want to automate a process",
|
||||
forWhom:
|
||||
"Companies where people copy data between tools or repeat the same steps",
|
||||
headline:
|
||||
"Systems that pass work on to each other, so people do not have to.",
|
||||
intro:
|
||||
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
|
||||
youGet: [
|
||||
"Connections between your systems through their interfaces (APIs)",
|
||||
"Recurring workflows that run without manual steps",
|
||||
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
|
||||
"A short description of how it works, so it can be maintained",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You describe the process and which tools are involved.",
|
||||
},
|
||||
{
|
||||
title: "I look at how it is done today",
|
||||
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
|
||||
},
|
||||
{
|
||||
title: "I connect the systems",
|
||||
text: "One step at a time. You test it with real cases before it replaces the manual work.",
|
||||
},
|
||||
{
|
||||
title: "It runs on its own",
|
||||
text: "You get a note on what it does and what to check when one of the tools changes.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
|
||||
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
|
||||
smallPrint:
|
||||
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
need: "I just need a website",
|
||||
forWhom: "Companies and self-employed people who want it taken care of",
|
||||
headline: "A website, built and looked after for you.",
|
||||
intro:
|
||||
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
|
||||
youGet: [
|
||||
"A design made for your business, not a template",
|
||||
"Works on phone, tablet and desktop",
|
||||
"Hosting taken care of, no server for you to manage",
|
||||
"Changes by email: you write what you need, I make the change",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You tell me about your business and what the website is for.",
|
||||
},
|
||||
{
|
||||
title: "A design to look at",
|
||||
text: "You get a first version to click through. We adjust it until it fits.",
|
||||
},
|
||||
{
|
||||
title: "I build it and put it online",
|
||||
text: "With your texts and images and a contact form. You approve it before it goes live.",
|
||||
},
|
||||
{
|
||||
title: "Changes afterwards",
|
||||
text: "A new photo or a changed price: you send me an email.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
|
||||
prices: [
|
||||
{
|
||||
label: "A typical company website",
|
||||
amount: "from 3,800 EUR",
|
||||
note: "one-off, net",
|
||||
},
|
||||
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
|
||||
],
|
||||
smallPrint:
|
||||
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
];
|
||||
@@ -1,62 +1,27 @@
|
||||
import {
|
||||
parseAvailabilityConfig,
|
||||
type AvailabilityConfig,
|
||||
} from "@/src/domain/availability";
|
||||
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
|
||||
|
||||
export const SITE_NAME = "Marc Mintel";
|
||||
export const SITE_URL = "https://mintel.me";
|
||||
|
||||
// TODO(owner): replace with the real Cal.com link before launch.
|
||||
export const BOOKING_URL = "https://cal.com/PLACEHOLDER/intro";
|
||||
|
||||
// TODO(owner): replace with the real contact address before launch.
|
||||
export const CONTACT_EMAIL = "hello@example.com";
|
||||
|
||||
// PLACEHOLDER VALUES: the owner must confirm the next free date and weekly hours.
|
||||
const RAW_AVAILABILITY_CONFIG = {
|
||||
freeFrom: "2026-11-01",
|
||||
hoursPerWeek: 20,
|
||||
} as const;
|
||||
|
||||
const parsedAvailability = parseAvailabilityConfig(RAW_AVAILABILITY_CONFIG);
|
||||
if (parsedAvailability.ok === false) {
|
||||
// The contact address, kept in parts on purpose: the complete address must never
|
||||
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
||||
const parsedEmail = parseEmailParts({
|
||||
user: "hello",
|
||||
domain: "mintel",
|
||||
tld: "me",
|
||||
});
|
||||
if (parsedEmail.ok === false) {
|
||||
throw new Error(
|
||||
`Invalid AVAILABILITY_CONFIG in src/content/site.ts: ${parsedAvailability.error}`,
|
||||
`Invalid contact address in src/content/site.ts: ${parsedEmail.error}`,
|
||||
);
|
||||
}
|
||||
export const AVAILABILITY_CONFIG: AvailabilityConfig = parsedAvailability.value;
|
||||
export const CONTACT_EMAIL_PARTS: EmailParts = parsedEmail.value;
|
||||
|
||||
export type EngagementBlock = {
|
||||
readonly name: string;
|
||||
readonly hoursPerWeek: string;
|
||||
readonly pricePerMonth: string;
|
||||
};
|
||||
|
||||
export const ENGAGEMENT_BLOCKS: readonly EngagementBlock[] = [
|
||||
{
|
||||
name: "Focus",
|
||||
hoursPerWeek: "15 h/week",
|
||||
pricePerMonth: "6,000 EUR/month",
|
||||
},
|
||||
{
|
||||
name: "Half-time",
|
||||
hoursPerWeek: "20 h/week",
|
||||
pricePerMonth: "8,000 EUR/month",
|
||||
},
|
||||
{
|
||||
name: "Full-time",
|
||||
hoursPerWeek: "35-40 h/week",
|
||||
pricePerMonth: "14,000-16,000 EUR/month",
|
||||
},
|
||||
];
|
||||
|
||||
export const STACK: readonly string[] = [
|
||||
"TypeScript",
|
||||
"Next.js",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Rust",
|
||||
"Solidity",
|
||||
"PostgreSQL",
|
||||
"Tailwind CSS",
|
||||
];
|
||||
/** Details required for the imprint (section 5 DDG). */
|
||||
export const OWNER = {
|
||||
name: "Marc Mintel",
|
||||
street: "Georg-Meistermann-Straße 7",
|
||||
city: "54586 Schüller",
|
||||
country: "Germany",
|
||||
vatId: "DE367588065",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user