Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion e2e/blog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ for (const skin of ["standard", "terminal"] as const)
[]
);
if (path.includes(introSlug)) {
await expect(page.locator(".product-screens img")).toHaveCount(3);
await expect(page.locator(".product-screens-hero img")).toHaveCount(
1
);
await expect(page.locator(".product-screens-row img")).toHaveCount(
2
);
await expect(page.locator(".product-screens-row")).toHaveCSS(
"overflow-x",
"auto"
Expand Down
Binary file added public/blog/why-i-built-car-brain/dashboard.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/blog/why-i-built-car-brain/settings.jpg
Binary file not shown.
8 changes: 7 additions & 1 deletion src/components/Blog/Blog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,19 @@ test("mobile stack and sample screens stay in the article", () => {
const { container } = render(
<>
<MobileStack locale="en" />
<ProductScreens locale="en" variant="hero" />
<ProductScreens locale="en" />
</>
);
expect(
screen.getByRole("img", { name: /Car Brain mobile stack/ })
).toBeVisible();
expect(container.querySelectorAll(".product-screens img")).toHaveLength(3);
expect(container.querySelectorAll(".product-screens-hero img")).toHaveLength(
1
);
expect(container.querySelectorAll(".product-screens-row img")).toHaveLength(
2
);
});
for (const locale of ["en", "pl"] as const) {
test(`architecture exposes its boundaries and planned data path (${locale})`, () => {
Expand Down
5 changes: 3 additions & 2 deletions src/components/Blog/MobileStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const ink = "var(--text, #1c282c)";
const muted = "var(--text-secondary, #52646a)";
const accent = "var(--accent, #0f6b75)";
const surface = "var(--surface, #ffffff)";
const canvas = "var(--canvas, #f7f5f0)";
const soft = "var(--accent-soft, #e2efee)";
const border = "var(--separator, #d4deda)";

Expand Down Expand Up @@ -71,8 +72,8 @@ export function MobileStack({ locale = "en" }: { locale?: BlogLocale }) {
width={230}
height={150}
rx={12}
fill={surface}
stroke={border}
fill={canvas}
stroke={muted}
strokeWidth={1.5}
/>
<text
Expand Down
96 changes: 66 additions & 30 deletions src/components/Blog/ProductScreens.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import type { BlogLocale } from "@/content/blog/types";

const shots = {
type Shot = {
src: string;
alt: string;
caption: string;
};

const hero = {
en: {
src: "/blog/why-i-built-car-brain/dashboard.jpg",
alt: "Car Brain home screen for the current car",
caption:
"Home: the current car, fuel use, and what is due soon. The numbers and the car are sample data. On this capture the last-service time is clipped by the add button.",
},
pl: {
src: "/blog/why-i-built-car-brain/dashboard.jpg",
alt: "Ekran główny Car Brain dla aktualnego auta",
caption:
"Ekran główny: aktualne auto, spalanie i to, co wkrótce do zrobienia. Liczby i auto to dane przykładowe. Na tym kadrze godzina ostatniego serwisu jest ucięta przez przycisk dodawania.",
},
} as const satisfies Record<BlogLocale, Shot>;

const row = {
en: [
{
src: "/blog/why-i-built-car-brain/refuels.jpg",
Expand All @@ -13,11 +34,6 @@ const shots = {
caption:
"Service history, split into what is done and what is coming up.",
},
{
src: "/blog/why-i-built-car-brain/settings.jpg",
alt: "Theme settings in Car Brain, including night driving",
caption: "Themes, including the red night-driving mode.",
},
],
pl: [
{
Expand All @@ -31,15 +47,50 @@ const shots = {
caption:
"Historia serwisu: to, co już zrobione, i to, co dopiero nadchodzi.",
},
{
src: "/blog/why-i-built-car-brain/settings.jpg",
alt: "Ustawienia motywu w Car Brain, w tym jazda nocą",
caption: "Motywy, w tym czerwony tryb jazdy nocą.",
},
],
} as const;
} as const satisfies Record<BlogLocale, readonly Shot[]>;

function PhoneShot({
shot,
loading = "lazy",
}: {
shot: Shot;
loading?: "eager" | "lazy";
}) {
return (
<div className="product-screens-shot">
<div className="phone-tile">
{/* A plain img: the site is a static export with no image optimiser. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="phone-shot"
src={shot.src}
alt={shot.alt}
width={560}
height={1174}
loading={loading}
decoding="async"
/>
</div>
<p>{shot.caption}</p>
</div>
);
}

export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) {
export function ProductScreens({
locale = "en",
variant = "row",
}: {
locale?: BlogLocale;
variant?: "hero" | "row";
}) {
if (variant === "hero") {
return (
<figure className="product-screens product-screens-hero">
<PhoneShot shot={hero[locale]} loading="eager" />
</figure>
);
}
const pl = locale === "pl";
return (
<figure className="product-screens">
Expand All @@ -53,23 +104,8 @@ export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) {
: "App screens. On a narrow screen, scroll sideways."
}
>
{shots[locale].map((shot) => (
<div className="product-screens-shot" key={shot.src}>
<div className="phone-tile">
{/* A plain img: the site is a static export with no image optimiser. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="phone-shot"
src={shot.src}
alt={shot.alt}
width={560}
height={1174}
loading="lazy"
decoding="async"
/>
</div>
<p>{shot.caption}</p>
</div>
{row[locale].map((shot) => (
<PhoneShot shot={shot} key={shot.src} />
))}
</div>
</figure>
Expand Down
4 changes: 4 additions & 0 deletions src/components/Blog/blog.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@
margin: 32px 0;
min-width: 0;
}
.product-screens-hero {
display: flex;
justify-content: center;
}
.product-screens-row {
display: flex;
gap: 12px;
Expand Down
26 changes: 22 additions & 4 deletions src/content/blog/articles/why-i-built-car-brain/en.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,31 @@ import { ProductScreens } from "@/components/Blog/ProductScreens";

Fuel, service, costs and receipts were scattered. A spreadsheet is a poor filing cabinet for photos. I also wanted to learn mobile by shipping a whole product, not by finishing a tutorial. Car Brain is that product: React Native for iOS and Android, built in 2024–2025, which I am bringing back now.

<ProductScreens locale="en" variant="hero" />

## What I chose, and the bill

### React Native, through Expo

I already knew React and TypeScript. A second language was a worse deal than one codebase for two phones.

**Gain:** iOS and Android from the same project, in tools I use every day. **Cost:** a dev client, native projects, and the reminder that the two platforms still disagree. **I would revisit it** if a feature needed a native interface I could only reach by fighting the abstraction.
**Gain:** iOS and Android from the same project, in tools I use every day.

**Cost:** a dev client, native projects, and the reminder that the two platforms still disagree.

**I would revisit it** if a feature needed a native interface I could only reach by fighting the abstraction.

### Appwrite, instead of a server I would have to babysit

I wanted sign-in, structured records, file storage and a small place for server code. Appwrite looked simpler than writing that myself, and it is open source. Firebase and Supabase were the other names in my head. I never wrote the comparison down, so this is a preference, not a leaderboard.

<MobileStack locale="en" />

**Gain:** email, Google and Apple sign-in, tables, a bucket for photos and receipts, and functions. The mobile SDK talks to Appwrite directly. **Cost:** product rules live on the device or inside a function, and the schema lives in Appwrite's console as well as in the repo. Those two copies can drift. **I would revisit it** if the domain logic outgrew functions and deserved a backend I actually operate.
**Gain:** email, Google and Apple sign-in, tables, a bucket for photos and receipts, and functions. The mobile SDK talks to Appwrite directly.

**Cost:** product rules live on the device or inside a function, and the schema lives in Appwrite's console as well as in the repo. Those two copies can drift.

**I would revisit it** if the domain logic outgrew functions and deserved a backend I actually operate.

For a mobile product, and if you already write React, I would pick this pair again. Expo is how I would reach both stores without a second language. Appwrite is the piece I would recommend most plainly: sign-in, a database, files, and a function for anything that must not ship inside the app, without a server to run. I would not call it better than Firebase or Supabase in general. I would start with it again for an app like this.

Expand All @@ -29,13 +39,21 @@ One scar from that setup is a separate story. An agent put a third-party API key

Data that comes from Appwrite goes through TanStack Query. The session, language and theme stay in React Context. Preferences that belong to the device stay in AsyncStorage. I did not add Redux or Zustand. The first plan had been Context and AsyncStorage alone, which meant writing caching and invalidation by hand.

**Gain:** a cache with an explicit way to mark it stale and refetch. **Cost:** two homes for state, and dashboard numbers computed on the phone from cached rows instead of stored a second time. **I would revisit a global store** only if those homes started disagreeing.
**Gain:** a cache with an explicit way to mark it stale and refetch.

**Cost:** two homes for state, and dashboard numbers computed on the phone from cached rows instead of stored a second time.

**I would revisit a global store** only if those homes started disagreeing.

### Maestro, after Detox stayed a name in a plan

The early notes named Detox or Appium for end-to-end tests. Neither was added. The repository runs Maestro flows against a development build, aimed at `testID`s.

**Gain:** readable YAML and no extra native test harness inside the app. **Cost:** a local CLI, a booted simulator, and flows that are not what CI runs. The decision record does not say why Maestro won, so I will not invent a reason after the fact. **I would revisit it** when those flows need to run unattended.
**Gain:** readable YAML and no extra native test harness inside the app.

**Cost:** a local CLI, a booted simulator, and flows that are not what CI runs. The decision record does not say why Maestro won, so I will not invent a reason after the fact.

**I would revisit it** when those flows need to run unattended.

## What is actually in the app

Expand Down
26 changes: 22 additions & 4 deletions src/content/blog/articles/why-i-built-car-brain/pl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,31 @@ import { ProductScreens } from "@/components/Blog/ProductScreens";

Paliwo, serwis, koszty i paragony miałem porozrzucane. Arkusz kalkulacyjny słabo nadaje się do trzymania zdjęć. Chciałem też nauczyć się aplikacji mobilnych na prawdziwym produkcie, a nie na kolejnym tutorialu. Car Brain jest właśnie tą aplikacją: React Native na iOS i Androida. Zbudowałem ją w latach 2024–2025 i teraz do niej wracam.

<ProductScreens locale="pl" variant="hero" />

## Co wybrałem i ile mnie to kosztuje

### React Native przez Expo

Reacta i TypeScript już znałem. Uczenie się drugiego języka byłoby gorszym pomysłem niż jedna baza kodu na dwa telefony.

**Zysk:** iOS i Android z jednego projektu, w narzędziach, których używam na co dzień. **Koszt:** klient deweloperski, projekty natywne i przypomnienie, że obie platformy nadal mają własne zdanie. **Zmieniłbym tę decyzję,** gdyby jakaś funkcja wymagała natywnego ekranu, a Expo tylko stało na drodze.
**Zysk:** iOS i Android z jednego projektu, w narzędziach, których używam na co dzień.

**Koszt:** klient deweloperski, projekty natywne i przypomnienie, że obie platformy nadal mają własne zdanie.

**Zmieniłbym tę decyzję,** gdyby jakaś funkcja wymagała natywnego ekranu, a Expo tylko stało na drodze.

### Appwrite zamiast serwera, który musiałbym sam utrzymywać

Chciałem logowanie, dane o aucie, pliki i trochę kodu po stronie serwera. Appwrite wyglądał prościej niż składanie tego od zera i jest open source. Firebase i Supabase też brałem pod uwagę. Porównania nie zapisałem, więc to moja preferencja, nie ranking.

<MobileStack locale="pl" />

**Zysk:** logowanie e-mailem, kontem Google i Apple, tabele, miejsce na zdjęcia i paragony oraz funkcje. Mobilne SDK rozmawia z Appwrite bezpośrednio. **Koszt:** reguły produktu siedzą albo w telefonie, albo w funkcji, a schemat jest i w konsoli Appwrite, i w repozytorium. Te dwie kopie potrafią się rozjechać. **Zmieniłbym tę decyzję,** gdyby logika produktu przestała mieścić się w funkcjach i potrzebowała backendu, który naprawdę utrzymuję.
**Zysk:** logowanie e-mailem, kontem Google i Apple, tabele, miejsce na zdjęcia i paragony oraz funkcje. Mobilne SDK rozmawia z Appwrite bezpośrednio.

**Koszt:** reguły produktu siedzą albo w telefonie, albo w funkcji, a schemat jest i w konsoli Appwrite, i w repozytorium. Te dwie kopie potrafią się rozjechać.

**Zmieniłbym tę decyzję,** gdyby logika produktu przestała mieścić się w funkcjach i potrzebowała backendu, który naprawdę utrzymuję.

Skoro i tak piszę w Reakcie, do takiej aplikacji wziąłbym tę parę jeszcze raz. Expo jest sposobem na oba sklepy bez drugiego języka. Appwrite polecam wprost: logowanie, baza, pliki i funkcja na wszystko, czego nie wolno wkładać do aplikacji. Nie trzeba do tego stawiać własnego serwera. Nie mówię, że jest lepszy od Firebase albo Supabase w każdej sytuacji. Przy takiej aplikacji zacząłbym od niego znowu.

Expand All @@ -29,13 +39,21 @@ Została z tego też jedna wpadka. O niej jest osobny tekst. Agent zapisał kluc

Dane z Appwrite idą przez TanStack Query. Sesja, język i motyw zostają w React Context. Ustawienia telefonu zostają w AsyncStorage. Nie dodałem Reduxa ani Zustand. Pierwszy plan zakładał sam Context i AsyncStorage, czyli cache i odświeżanie pisane ręcznie.

**Zysk:** cache, w którym widać, kiedy dane są już stare, i jest sposób, żeby pobrać je ponownie. **Koszt:** stan jest w dwóch miejscach, a liczby na ekranie głównym liczę w telefonie z wierszy w cache, zamiast zapisywać je drugi raz. **Wspólny store wziąłbym** dopiero wtedy, gdy te dwa miejsca zaczęłyby sobie przeczyć.
**Zysk:** cache, w którym widać, kiedy dane są już stare, i jest sposób, żeby pobrać je ponownie.

**Koszt:** stan jest w dwóch miejscach, a liczby na ekranie głównym liczę w telefonie z wierszy w cache, zamiast zapisywać je drugi raz.

**Wspólny store wziąłbym** dopiero wtedy, gdy te dwa miejsca zaczęłyby sobie przeczyć.

### Maestro, kiedy Detox został tylko nazwą w planie

Wczesne notatki do testów E2E wymieniały Detox albo Appium. Żadnego z nich nie dodałem. Repozytorium odpala scenariusze Maestro na buildzie deweloperskim i szuka elementów po `testID`.

**Zysk:** czytelny YAML, bez dokładania natywnej biblioteki testowej do aplikacji. **Koszt:** lokalne CLI, włączony symulator i scenariusze, których CI nie odpala. W zapisie decyzji nie ma, dlaczego wygrało Maestro, więc nie dopiszę powodu po fakcie. **Zmieniłbym ten wybór,** gdyby te testy miały lecieć same, bez ręcznego odpalania.
**Zysk:** czytelny YAML, bez dokładania natywnej biblioteki testowej do aplikacji.

**Koszt:** lokalne CLI, włączony symulator i scenariusze, których CI nie odpala. W zapisie decyzji nie ma, dlaczego wygrało Maestro, więc nie dopiszę powodu po fakcie.

**Zmieniłbym ten wybór,** gdyby te testy miały lecieć same, bez ręcznego odpalania.

## Co naprawdę jest w aplikacji

Expand Down
Loading