diff --git a/e2e/blog.spec.ts b/e2e/blog.spec.ts index ee46c97..7b2fd08 100644 --- a/e2e/blog.spec.ts +++ b/e2e/blog.spec.ts @@ -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" diff --git a/public/blog/why-i-built-car-brain/dashboard.jpg b/public/blog/why-i-built-car-brain/dashboard.jpg new file mode 100644 index 0000000..3d81b30 Binary files /dev/null and b/public/blog/why-i-built-car-brain/dashboard.jpg differ diff --git a/public/blog/why-i-built-car-brain/settings.jpg b/public/blog/why-i-built-car-brain/settings.jpg deleted file mode 100644 index 169dcd6..0000000 Binary files a/public/blog/why-i-built-car-brain/settings.jpg and /dev/null differ diff --git a/src/components/Blog/Blog.test.tsx b/src/components/Blog/Blog.test.tsx index 8390706..584ef23 100644 --- a/src/components/Blog/Blog.test.tsx +++ b/src/components/Blog/Blog.test.tsx @@ -69,13 +69,19 @@ test("mobile stack and sample screens stay in the article", () => { const { container } = render( <> + ); 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})`, () => { diff --git a/src/components/Blog/MobileStack.tsx b/src/components/Blog/MobileStack.tsx index 15fc873..e29bd74 100644 --- a/src/components/Blog/MobileStack.tsx +++ b/src/components/Blog/MobileStack.tsx @@ -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)"; @@ -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} /> ; + +const row = { en: [ { src: "/blog/why-i-built-car-brain/refuels.jpg", @@ -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: [ { @@ -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; + +function PhoneShot({ + shot, + loading = "lazy", +}: { + shot: Shot; + loading?: "eager" | "lazy"; +}) { + return ( +
+
+ {/* A plain img: the site is a static export with no image optimiser. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {shot.alt} +
+

{shot.caption}

+
+ ); +} -export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) { +export function ProductScreens({ + locale = "en", + variant = "row", +}: { + locale?: BlogLocale; + variant?: "hero" | "row"; +}) { + if (variant === "hero") { + return ( +
+ +
+ ); + } const pl = locale === "pl"; return (
@@ -53,23 +104,8 @@ export function ProductScreens({ locale = "en" }: { locale?: BlogLocale }) { : "App screens. On a narrow screen, scroll sideways." } > - {shots[locale].map((shot) => ( -
-
- {/* A plain img: the site is a static export with no image optimiser. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - {shot.alt} -
-

{shot.caption}

-
+ {row[locale].map((shot) => ( + ))}
diff --git a/src/components/Blog/blog.css b/src/components/Blog/blog.css index f2dee77..357bc1c 100644 --- a/src/components/Blog/blog.css +++ b/src/components/Blog/blog.css @@ -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; diff --git a/src/content/blog/articles/why-i-built-car-brain/en.mdx b/src/content/blog/articles/why-i-built-car-brain/en.mdx index c99d56f..0365b4c 100644 --- a/src/content/blog/articles/why-i-built-car-brain/en.mdx +++ b/src/content/blog/articles/why-i-built-car-brain/en.mdx @@ -5,13 +5,19 @@ 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. + + ## 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 @@ -19,7 +25,11 @@ I wanted sign-in, structured records, file storage and a small place for server -**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. @@ -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 diff --git a/src/content/blog/articles/why-i-built-car-brain/pl.mdx b/src/content/blog/articles/why-i-built-car-brain/pl.mdx index 7563706..456556f 100644 --- a/src/content/blog/articles/why-i-built-car-brain/pl.mdx +++ b/src/content/blog/articles/why-i-built-car-brain/pl.mdx @@ -5,13 +5,19 @@ 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. + + ## 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ć @@ -19,7 +25,11 @@ Chciałem logowanie, dane o aucie, pliki i trochę kodu po stronie serwera. Appw -**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. @@ -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