Przejdź do treści
Wycena i proces

Prototyp klienta ma 9 widoków i zero ścieżek błędu. Wycena dotyczy 32 stanów, których w nim nie ma

Prototyp z generatora pokazuje jedną ścieżkę. Skrypt liczy, ile ścieżek stanu w nim brakuje, i zamienia ten inwentarz w jednostkę wyceny zlecenia.

Redakcjadeveloper front-end10 minkod + tabela
9liczba z tezy tekstu

Zapytanie ofertowe ma jedno zdanie i załącznik: „wygenerowałem prototyp, zostało tylko dokończyć”. W załączniku jest eksport projektu na App Routerze — dziewięć katalogów z page.tsx, osiem z nich pobiera dane. Skrypt liczący ścieżki stanu w tym eksporcie oddaje 34 oczekiwane i zero obecnych. Klient wycenił w głowie dziewięć podstron. Praca, którą trzeba wykonać, to te 34 ścieżki, a rozmowa o cenie jest rozmową o tej różnicy.

Prototyp pokazuje jedną ścieżkę, bo tylko ta jest widoczna na demie

Generator pisze kod, który renderuje dane. Na demie dane są: lista ma elementy, użytkownik jest zalogowany, żądanie zwróciło 200. Nic w prototypie nie odpowiada na pytanie, co widzi użytkownik, kiedy lista jest pusta, token wygasł, a API oddało 500 — bo na demie to się nie zdarza i nikt o to nie pyta.

Każdy widok pobierający dane ma co najmniej cztery ścieżki poza tą jedną: trwa pobieranie, pobieranie się nie udało, dane są puste, użytkownik nie ma uprawnień. Trasa dynamiczna ma piątą — rekordu o tym identyfikatorze nie ma. To nie są warianty wizualne tego samego ekranu. To osobne gałęzie kodu, osobne komunikaty do napisania i osobne przypadki testowe.

Dziewięć widoków razy pięć ścieżek nie wychodzi, bo nie każdy widok ma wszystkie. Dlatego zamiast mnożyć w głowie, lepiej to policzyć na pliku, który klient przysłał.

Skrypt, który liczy ścieżki stanu w eksporcie

Poniższy skrypt chodzi po katalogu app/, zbiera segmenty mające page, sprawdza obecność granic loading, error i not-found w segmencie lub wyżej, a w samym pliku widoku szuka gałęzi stanu pustego, błędu i braku uprawnień.

// stany.mjs - inwentarz sciezek stanu w projekcie na App Routerze.
// node stany.mjs <katalog-projektu>
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { join, relative, dirname, sep } from 'node:path'

const root = process.argv[2] ?? '.'
const appDir = ['app', 'src/app'].map(d => join(root, d)).find(existsSync)
const EXT = ['tsx', 'jsx', 'ts', 'js']
const plik = (dir, nazwa) => EXT.map(e => join(dir, `${nazwa}.${e}`)).find(existsSync)

const segmenty = []
;(function chodz(dir) {
  for (const w of readdirSync(dir, { withFileTypes: true }))
    if (w.isDirectory() && w.name !== 'node_modules') chodz(join(dir, w.name))
  if (plik(dir, 'page')) segmenty.push(dir)
})(appDir)

// granica dziedziczy sie w dol, wiec szukamy tez w segmentach nadrzednych
const wyzej = (dir, nazwa) => {
  let d = dir
  for (;;) {
    if (plik(d, nazwa)) return true
    if (d === appDir) return false
    d = dirname(d)
  }
}

const WZORCE = {
  pobranie: /\bawait\s+fetch\(|\buseQuery\(|\buseSWR\(|\bawait\s+(db|prisma|sql|supabase)\b/g,
  pusto: /\.length\s*===?\s*0|\.length\s*>\s*0|\bisEmpty\b|\.length\s*&&|!\s*[\w.]*\??\.length\b/g,
  blad: /\bisError\b|\bcatch\s*\(|\btry\s*\{|\bonError\b|throw new Error/g,
  ladowanie: /\bisPending\b|\bisLoading\b|\bSuspense\b|[Ss]keleton/g,
  uprawnienia: /\b401\b|\b403\b|\bunauthorized\(|\bforbidden\(/g,
}
const ile = (t, re) => (t.match(re) ?? []).length

let oczekiwane = 0, obecne = 0
for (const dir of segmenty) {
  const tresc = readFileSync(plik(dir, 'page'), 'utf8')
  const trasa = '/' + relative(appDir, dir).split(sep).filter(Boolean).join('/')
  if (ile(tresc, WZORCE.pobranie) === 0) continue
  const stany = [
    ['ladowanie', wyzej(dir, 'loading') || ile(tresc, WZORCE.ladowanie) > 0],
    ['blad', wyzej(dir, 'error') || ile(tresc, WZORCE.blad) > 0],
    ['pusto', ile(tresc, WZORCE.pusto) > 0],
    ['uprawnienia', ile(tresc, WZORCE.uprawnienia) > 0],
  ]
  if (trasa.includes('[')) stany.push(['brak rekordu', wyzej(dir, 'not-found') || /notFound\(/.test(tresc)])
  for (const [nazwa, jest] of stany) {
    oczekiwane++
    if (jest) obecne++; else console.log(`${trasa}: brak ${nazwa}`)
  }
}
console.log(`oczekiwane: ${oczekiwane}, obecne: ${obecne}, brakujace: ${oczekiwane - obecne}`)

Warunki pomiaru, którego liczby są w tym tekście: przykładowy eksport sklepu z dziewięcioma segmentami page.tsx (strona główna, katalog, karta produktu, koszyk, lista zamówień, karta zamówienia, konto, kontakt, wyszukiwanie), bez ani jednego pliku loading, error, not-found i global-error, dane pobierane przez await fetch w komponentach serwerowych i przez useQuery z TanStack Query w dwóch widokach klienckich. Uruchomienie na Node 22. Wynik pierwszego przebiegu:

Trasa Pobrania Ładowanie Błąd Pusto Uprawnienia Brak rekordu
/ 0 — — — — nie dotyczy
/produkty 1 — — — — nie dotyczy
/produkty/[slug] 1 — — — — —
/koszyk 1 — — — — nie dotyczy
/zamowienia 1 — — — — nie dotyczy
/zamowienia/[id] 1 — — — — —
/konto 1 — — — — nie dotyczy
/kontakt 1 — — — — nie dotyczy
/szukaj 1 — — — — nie dotyczy

Dziewięć widoków, osiem z pobraniem danych, 34 oczekiwane ścieżki stanu, zero obecnych, brak globalnej granicy błędu. Dwie pozycje z tych 34 nie dotyczą publicznego formularza kontaktowego — nie ma tam listy, która może być pusta, ani zasobu, do którego trzeba mieć uprawnienia. Po tej korekcie realny brak to 32 ścieżki.

Jeden plik loading.tsx nie zamyka tematu

Pierwsza reakcja na taki wynik to dorzucenie loading.tsx i error.tsx w korzeniu i odhaczenie pozycji. Dokumentacja mówi, gdzie ta droga się kończy. W hierarchii komponentów loading.js owija not-found.js, page.js i zagnieżdżone pliki layout.js w granicę Suspense, ale nie owija layout.js, template.js ani error.js w tym samym segmencie. Jeśli layout sięga po dane nieskeszowane — cookies(), headers(), niekeszowany fetch — fallback dla niego się nie pokaże, a nawigacja blokuje się do końca renderu layoutu.

Analogicznie error.js owija loading.js, not-found.js, page.js i zagnieżdżone layouty, ale nie owija layout.js ani template.js powyżej w tym samym segmencie; błąd w layoucie korzenia wymaga osobnego global-error.jsx, który renderuje własne html i body i nie dostaje globalnych styli. To nie detal implementacyjny, tylko dwa dodatkowe pliki i dwa dodatkowe komunikaty do napisania.

Po stronie klienta jest pułapka, która w prototypach występuje regularnie. Dokumentacja Reacta stwierdza, że Suspense nie wykrywa pobrania danych wykonanego w efekcie ani w obsłudze zdarzenia — fallback się nie pokaże, a lista zostanie pusta do momentu, w którym dane dojdą. Kod wygenerowany pod starsze wzorce trafia w to wprost:

// granica Suspense tego nie zobaczy - fallback sie nie pokaze
useEffect(() => {
  fetchData(`/${artistId}/albums`).then(setAlbums)
}, [artistId])

// to aktywuje granice
const albums = use(fetchData(`/${artistId}/albums`))

Przy TanStack Query trzeba rozdzielić dwa pytania, które generator zwykle skleja w jedno. Dokumentacja rozdziela status, mówiący czy mamy dane, od fetchStatus, mówiącego czy queryFn właśnie działa; isFetching jest prawdziwe także przy odświeżaniu w tle, więc podpięcie pod nie szkieletu wygasza interfejs przy każdym refetchu. Stan pierwszego ładowania to isPending.

Lista stanów, które wycenia się osobno

Stan Co trzeba dopisać Pytanie do klienta przed wyceną
Trwa pobieranie loading w segmencie lub Suspense, szkielet zgodny z układem docelowym Szkielet treści czy spinner? Szkielet wymaga znajomości finalnego układu.
Pobieranie nieudane error w segmencie, global-error w korzeniu, log z error.digest Gdzie lecą logi i czy użytkownik dostaje przycisk ponowienia?
Dane puste Gałąź w widoku plus treść: co to znaczy i jaki jest następny krok Kto pisze te komunikaty — Wy czy my? To tekst, nie kod.
Brak uprawnień unauthorized() lub forbidden(), przekierowanie, ekran dla niezalogowanego Które widoki są publiczne, a które za logowaniem? W prototypie nie ma logowania.
Brak rekordu notFound() w trasie dynamicznej plus not-found z drogą wyjścia Co widzi użytkownik, który wszedł ze starego linku lub z indeksu Google?

Trzecia kolumna jest tu ważniejsza niż druga. Cztery z pięciu pozycji wymagają decyzji, której nie podejmie wykonawca: treści komunikatu, zakresu logowania, polityki logów. Wycena bez tych odpowiedzi to wycena z buforem na cudze decyzje.

Pomiar po dopisaniu granic: 29 z 34 i pięć zgłoszeń do przejrzenia

Po dopisaniu do tego samego eksportu pięciu plików granic (loading, error, global-error i dwa not-found dla tras dynamicznych) oraz gałęzi stanu w ośmiu widokach drugi przebieg skryptu oddał 29 obecnych ścieżek z 34 i pięć braków. Pliki granic to 12 linii w pięciu plikach. Gałęzie w widokach to od trzech do sześciu na widok — trzy przy prostej liście, sześć przy wyszukiwaniu, które ma dodatkowo stan pustego zapytania.

Pięć zgłoszonych braków warto rozpisać, bo pokazuje, czym ten skrypt jest. Dwa dotyczą formularza kontaktowego, gdzie stan pusty i uprawnienia nie mają sensu. Trzy to fałszywe trafienia wyrażenia: stan pusty zapisano tam jako !user.email, !produkt.warianty?.length i !z.pozycje?.length, a pierwsza wersja wzorca szukała wyłącznie .length === 0 i nie łapała negacji z opcjonalnym łańcuchem. Wzorzec w skrypcie powyżej jest już poprawiony, ale na tym samym zestawie plików zaczął też raportować stan pusty w formularzu, gdzie go nie ma. Realnych luk po przejrzeniu listy: zero.

Wniosek praktyczny: licznik produkuje inwentarz, nie wyrok. Zanim lista pójdzie do klienta jako załącznik do oferty, przechodzi się ją ręcznie — inaczej płaci się za ścieżki, które już istnieją, albo obiecuje się komplet, w którym brakuje tych naprawdę brakujących. Ten sam mechanizm rozchodzenia się dwóch liczników tego samego zjawiska opisuje tekst o dwóch raportach skuteczności za ten sam miesiąc, i konkluzja jest tam identyczna: najpierw definicja licznika, potem liczba.

Trzy konstrukcje oferty na dokończenie prototypu

Rozbieżność oczekiwań ma tu podstawę szerszą niż jeden klient. Raport DORA 2025, oparty na odpowiedziach blisko pięciu tysięcy osób z branży, podaje, że 90 proc. badanych używa AI w pracy, a 30 proc. ma do wygenerowanego kodu niewielkie zaufanie albo nie ma go wcale. Ten sam raport notuje dodatnią zależność między wykorzystaniem AI a przepustowością dostarczania i jednocześnie utrzymującą się negatywną zależność ze stabilnością. Klient jest w tej pierwszej grupie i widzi tempo. Rachunek za niestabilność trafia do wykonawcy.

Stąd trzy konstrukcje, które da się obronić przy stole:

  • Za widok z zadeklarowanym kompletem stanów. Cena za widok, a w zakresie wymienione wszystkie ścieżki z tabeli powyżej. Klient porównuje z własną listą dziewięciu podstron i rozumie różnicę w cenie, bo widzi, co kupuje. Działa, gdy liczba widoków jest zamknięta.
  • Za ścieżkę stanu. Jednostką jest ścieżka, nie widok. Wycena jest liniowa wobec wyniku skryptu, a zmiana zakresu w trakcie ma cennik. Działa przy projektach, gdzie klient dorzuca widoki.
  • Ryczałt z listą wyłączeń. Cena stała, a w załączniku jawnie wymienione ścieżki poza zakresem — zwykle uprawnienia i treści komunikatów. Działa tylko wtedy, gdy wyłączenia są wypisane z nazwy; „bez obsługi przypadków brzegowych” nie jest wyłączeniem, bo nikt nie wie, co to znaczy.

W każdej z trzech ścieżka stanu jest jednostką rozmowy. To przestawia negocjację z „czy AI już to zrobiło” na „ile tych pozycji bierzemy w tym etapie”, a to jest pytanie, na które klient umie odpowiedzieć.

Co zrobić, zanim podasz cenę

  • Uruchom skrypt na eksporcie klienta i przejrzyj listę braków ręcznie, pozycja po pozycji.
  • Policz osobno trasy dynamiczne — każda ma piątą ścieżkę i wymaga not-found z drogą wyjścia, nie samego komunikatu.
  • Sprawdź, czy layouty sięgają po cookies() lub headers(). Jeśli tak, loading ich nie zasłoni i trzeba albo przenieść pobranie do page, albo owinąć je własną granicą.
  • Policz, ile komunikatów trzeba napisać, i zapytaj, kto je pisze. Czterdzieści komunikatów to zadanie dla copywritera, nie pozycja w wycenie godzin developera.
  • Ustal, które widoki są za logowaniem. W prototypie z generatora logowania zwykle nie ma wcale, a to osobny widok plus pięć ścieżek na nim.
  • Wpisz do protokołu odbioru liczbę ścieżek, nie listę podstron. „Osiem widoków z kompletem stanów zgodnie z załącznikiem” jest sprawdzalne, „gotowy sklep” nie jest.

Do podjęcia jest jedna decyzja: czy wycena wychodzi od tego, co w prototypie widać, czy od tego, co skrypt w nim policzył. Pierwsza droga daje ofertę, którą klient chętnie podpisze i której nie da się zrealizować w tej cenie. Druga wymaga wysłania tabeli z 32 pozycjami i obrony każdej z nich — i to jest ta rozmowa, którą lepiej odbyć przed podpisaniem niż po.