feat(repairs): поле поиска прямо в ленте ремонтов

Владелец: «не хочу ещё один раздел, хочу чтобы в списке ремонтов просто
появилось поле поиска». Поле встроено в шапку существующего экрана /repairs,
нового раздела и роута нет.

Поиск СЕРВЕРНЫЙ, и это принципиально. Лента грузится курсором по 20 строк:
фильтрация «по уже загруженному» на старте видела бы последние 20 ремонтов
парка и честно отвечала «ничего не найдено» на машину, которая была в ремонте
позавчера. Отсутствие поиска хотя бы не врёт. Вторая ловушка того же варианта:
маркер бесконечной прокрутки стоит сразу под списком, и схлопнутый фильтром
список подтянул бы его в кадр — лента начала бы молча выкачивать всю таблицу
по 20 строк за запрос.

- q уходит на КАЖДОЙ странице и входит в ключ кэша: смена запроса создаёт свой
  список со своим курсором, поэтому курсор нефильтрованной ленты физически не
  может подмешать чужие ремонты во вторую страницу найденного.
- Во время набора на экране остаётся прошлая выдача (приглушённой), а не
  пульсирующие скелетоны на каждое нажатие клавиши.
- «Обновить» перезапрашивает одну страницу вместо всех загруженных: у
  бесконечного запроса refetch тянет их последовательно, и на слабой сети это
  десять round-trip'ов подряд с десятью шансами словить ошибку.
  При этом во время обновления догрузка молчит — иначе схлопывание ленты
  подтягивает маркер в кадр, fetchNextPage отменяет летящий запрос обновления,
  и новые ремонты сверху не появляются, сколько ни жми.
- «Ничего не нашли по этому запросу» отличается от пустой ленты, а сбой сети —
  от ответа сервера с ошибкой: совет «проверьте сеть» на 500-ку отправляет
  механика перезагружать вайфай, когда чинить у него нечего.

Строка ремонта, дневные разделители, месячный итог и поведение ленты без
поиска не менялись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 22:04:02 +10:00
co-authored by Claude Opus 5
parent b7302c23d6
commit 5a65f8f1ca
3 changed files with 291 additions and 60 deletions
@@ -27,12 +27,17 @@ export interface FeedResponse {
export interface FeedParams {
limit?: number;
cursor?: string | null;
// Поисковая подстрока: госномер, марка или модель.
q?: string | null;
}
export async function getRepairsFeed(params: FeedParams = {}): Promise<FeedResponse> {
const search = new URLSearchParams();
if (params.limit) search.set("limit", String(params.limit));
if (params.cursor) search.set("cursor", params.cursor);
// Пустой/пробельный запрос не шлём — это обычная лента, а не поиск.
const q = params.q?.trim();
if (q) search.set("q", q);
const qs = search.toString();
return api
.get(`repairs${qs ? `?${qs}` : ""}`)
@@ -3,42 +3,122 @@
*
* Использует `useInfiniteQuery` для бесконечной пагинации по 20 (У38).
* На `visibilitychange` (когда вкладка/приложение получают фокус) —
* автоматический рефреш первой страницы. Pull-to-refresh вызывается
* напрямую из компонента через `refetch()`.
* автоматическое обновление через `refresh()`. Кнопка «Обновить» в шапке
* вызывает тот же `refresh()`.
*
* Поддерживает поиск по подстроке (госномер/марка/модель): `q` уходит
* в каждый запрос страницы и входит в ключ кэша. При смене запроса на
* экране остаётся прошлая выдача (`keepPreviousData`), а не скелетоны.
*
* SWR-кэш живёт в react-query (память + localStorage persist уже
* сконфигурирован в QueryClient на уровне приложения, если такого нет —
* первая отрисовка идёт без кэша, но дальше работает).
*/
import { useEffect } from "react";
import { useInfiniteQuery, type QueryKey } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import {
keepPreviousData,
useInfiniteQuery,
useQueryClient,
type InfiniteData,
type QueryKey,
} from "@tanstack/react-query";
import { getRepairsFeed, type FeedItem, type FeedResponse } from "@/api/repairsFeed";
export const FEED_QUERY_KEY: QueryKey = ["repairs-feed"];
export const FEED_PAGE_SIZE = 20;
export function useRepairsFeed() {
const query = useInfiniteQuery<FeedResponse>({
queryKey: FEED_QUERY_KEY,
/**
* Ключ кэша ленты. Поисковый запрос обязан входить в ключ: иначе страницы
* разных запросов лягут в один кэш и в списке перемешаются чужие ремонты.
*/
export function feedQueryKey(q?: string | null): QueryKey {
return [...FEED_QUERY_KEY, q?.trim() || null];
}
/** Что лежит в кэше ленты: загруженные страницы и курсоры, по которым их брали. */
type FeedPages = InfiniteData<FeedResponse, string | null>;
export function useRepairsFeed(q?: string | null) {
// Нормализуем один раз: и ключ, и запрос должны видеть одно и то же
// значение, иначе кэш и фильтр разъедутся на пробелах.
const search = q?.trim() || null;
const queryClient = useQueryClient();
const query = useInfiniteQuery<FeedResponse, Error, FeedPages, QueryKey, string | null>({
queryKey: feedQueryKey(search),
queryFn: ({ pageParam }) =>
getRepairsFeed({ limit: FEED_PAGE_SIZE, cursor: (pageParam as string | null) ?? null }),
initialPageParam: null as string | null,
// q передаём на КАЖДОЙ странице — иначе догрузка вернёт нефильтрованный
// хвост и подмешает в результаты поиска чужие ремонты.
getRepairsFeed({
limit: FEED_PAGE_SIZE,
cursor: pageParam,
q: search,
}),
initialPageParam: null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
staleTime: 30_000, // 30 секунд считаем «свежим» — потом фон обновляет
refetchOnWindowFocus: false, // делаем свой visibilitychange-листенер ниже
// Каждая буква в поиске — новый ключ, а у нового ключа данных ещё нет.
// Без этого список на каждое нажатие подменялся бы скелетонами и мигал.
// Держим прошлую выдачу до прихода актуальной; что она устарела —
// показываем через isPreviousResults.
placeholderData: keepPreviousData,
});
// Листенер вешаем один раз, а refetch держим в ref — после смены поискового
// запроса обновлять надо актуальную выдачу, а не ту, что была на монтировании.
const refetchRef = useRef(query.refetch);
useEffect(() => {
refetchRef.current = query.refetch;
}, [query.refetch]);
/**
* Обновление ленты. `refetch()` у бесконечного запроса перезапрашивает ВСЕ
* загруженные страницы, причём последовательно (курсор следующей приходит
* из предыдущей). У механика, пролиставшего ленту вглубь, это десяток
* запросов на слабой сети и десять шансов словить ошибку вместо одного.
* Поэтому сначала схлопываем ленту до первой страницы, а потом обновляем —
* ровно один запрос. Плата: догруженный хвост сбрасывается, лента
* возвращается к началу (как у обычного pull-to-refresh).
*/
// Пока обновление летит, догрузка следующей страницы обязана молчать.
// Схлопывание укорачивает список, браузер прижимает скролл к новому низу, и
// маркер бесконечной прокрутки попадает в зону видимости. Его fetchNextPage
// по умолчанию ОТМЕНЯЕТ запрос в полёте — обновление не доезжает вовсе, а
// вместо него подтягивается вторая страница по старому курсору. Новых
// ремонтов сверху человек так и не увидит, сколько ни жми.
const [isRefreshing, setIsRefreshing] = useState(false);
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
queryClient.setQueryData<FeedPages>(feedQueryKey(search), (old) =>
old && old.pages.length > 1
? { pages: old.pages.slice(0, 1), pageParams: old.pageParams.slice(0, 1) }
: old,
);
await refetchRef.current();
} finally {
setIsRefreshing(false);
}
}, [queryClient, search]);
// Тот же приём с ref: refresh меняется вместе с поисковым запросом,
// а листенер ниже вешается один раз.
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
// У38: обновление при возврате в приложение/вкладку.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") {
// Рефрешим только первую страницу, бесконечный скролл сбрасывается.
void query.refetch();
void refreshRef.current();
}
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Удобный flatten для рендера в одну ленту.
@@ -48,11 +128,15 @@ export function useRepairsFeed() {
items,
isLoading: query.isLoading,
isFetching: query.isFetching,
// На экране выдача предыдущего запроса, актуальная ещё летит —
// повод приглушить список, но не повод его прятать.
isPreviousResults: query.isPlaceholderData,
isError: query.isError,
error: query.error,
fetchNextPage: query.fetchNextPage,
hasNextPage: !!query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
refetch: query.refetch,
refresh,
isRefreshing,
};
}
@@ -5,13 +5,18 @@
* `created_at DESC`. Бесконечный скролл по 20. Pull-to-refresh,
* обновление при возврате в приложение (через useRepairsFeed).
*
* В шапке — поиск по госномеру, марке и модели (отдельного экрана нет:
* это фильтр той же ленты, со своим кэшем и своей пагинацией).
*
* Пустая лента — плейсхолдер «В парке пока нет ремонтов» (У41).
*/
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, Plus, Wrench } from "lucide-react";
import { HTTPError } from "ky";
import { ChevronLeft, Plus, Search, Wrench, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useRepairsFeed } from "@/hooks/useRepairsFeed";
import { formatFeedTimestamp } from "@/lib/timeFormat";
import { getRepairsMonthStats, type FeedItem } from "@/api/repairsFeed";
@@ -19,16 +24,35 @@ import { groupByLocalDay, formatDayLabel, fmtMoney, pluralRepairs, pluralWorks }
export default function RepairsFeedPage() {
const navigate = useNavigate();
// Поле ввода и запрос разведены: в сеть уходит только «устоявшееся»
// значение, иначе каждая буква — отдельный запрос и отдельный кэш.
const [queryInput, setQueryInput] = useState("");
const [q, setQ] = useState("");
useEffect(() => {
const t = setTimeout(() => setQ(queryInput.trim()), 200); // debounce
return () => clearTimeout(t);
}, [queryInput]);
const isSearching = q.length > 0;
const {
items,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
refresh,
isRefreshing,
isFetching,
} = useRepairsFeed();
isPreviousResults,
} = useRepairsFeed(q);
const resetSearch = useCallback(() => {
setQueryInput("");
setQ("");
}, []);
// Сумма и количество ремонтов за текущий месяц (отдельный endpoint —
// не зависит от пагинации ленты).
@@ -45,10 +69,14 @@ export default function RepairsFeedPage() {
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
if (!el || !hasNextPage || isRefreshing) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
// Во время обновления не догружаем: refresh схлопывает ленту, скролл
// прижимается к новому низу, маркер попадает в кадр — и fetchNextPage
// отменил бы летящий запрос обновления. Человек жал бы «Обновить»
// сколько угодно, а новые ремонты сверху так и не появлялись бы.
if (entries[0]?.isIntersecting && !isFetchingNextPage && !isRefreshing) {
void fetchNextPage();
}
},
@@ -56,14 +84,15 @@ export default function RepairsFeedPage() {
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage, isRefreshing]);
// Pull-to-refresh: простой UX через тач-скролл сверху.
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refetch.
// Делаем минимально-инвазивно: на тап-кнопку «обновить» вызываем refresh
// (он схлопывает ленту до первой страницы — см. useRepairsFeed).
// Полноценный pull-to-refresh с жестом — отдельная задача.
const handleRefresh = useCallback(() => {
void refetch();
}, [refetch]);
void refresh();
}, [refresh]);
const handleAdd = () => navigate("/repairs/new");
const handleOpen = (item: FeedItem) => navigate(`/repairs/${item.id}`);
@@ -93,24 +122,59 @@ export default function RepairsFeedPage() {
<span className="hidden sm:inline">Добавить</span>
</Button>
</div>
{/* Поиск по ленте. Без autoFocus: экран открывают ради списка, а не
ради поиска — не выдёргиваем клавиатуру на каждом заходе. */}
<div className="px-3 pb-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Госномер, марка или модель…"
aria-label="Поиск по ремонтам"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
// Столько же, сколько принимает сервер. Меньше нельзя: на
// «тойота ленд крузер прадо а123вс125» поле молча перестало бы
// печатать, и это выглядит как залипшая клавиатура.
maxLength={64}
className="pl-8 pr-9"
/>
{queryInput && (
<button
type="button"
onClick={resetSearch}
aria-label="Очистить поиск"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</header>
<main className="flex-1 container max-w-2xl py-3 space-y-2">
{/* Итого слева — сумма работ и количество ремонтов за текущий месяц
(с 1-го числа по now). Обновить справа. */}
(с 1-го числа по now). Обновить справа.
При активном поиске месячный итог прячем: он считается по всему
месяцу и к отфильтрованному списку отношения не имеет — рядом с
выдачей поиска читался бы как «итог найденного». */}
<div className="flex items-center justify-between">
<div className="text-sm">
<span className="text-muted-foreground">За этот месяц:</span>{" "}
<span className="font-semibold tabular-nums">
{fmtMoney(monthStatsQuery.data?.total ?? 0)}
</span>
{(monthStatsQuery.data?.count ?? 0) > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({monthStatsQuery.data!.count}{" "}
{pluralRepairs(monthStatsQuery.data!.count)})
{isSearching ? (
<div className="text-sm text-muted-foreground">Результаты поиска</div>
) : (
<div className="text-sm">
<span className="text-muted-foreground">За этот месяц:</span>{" "}
<span className="font-semibold tabular-nums">
{fmtMoney(monthStatsQuery.data?.total ?? 0)}
</span>
)}
</div>
{(monthStatsQuery.data?.count ?? 0) > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({monthStatsQuery.data!.count}{" "}
{pluralRepairs(monthStatsQuery.data!.count)})
</span>
)}
</div>
)}
<button
type="button"
onClick={handleRefresh}
@@ -129,30 +193,56 @@ export default function RepairsFeedPage() {
</div>
)}
{/* Сбой — отдельная ветка ДО пустого состояния: «не дозвонились»
не должно выглядеть как «ничего не нашлось». */}
{isError && !isLoading && (
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm">
Не удалось загрузить ленту ремонтов. Проверьте сеть и попробуйте снова.
<div className="rounded-md border border-destructive bg-destructive/10 p-3 text-sm space-y-2">
<div>{feedErrorMessage(error, isSearching)}</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isFetching}>
{isFetching ? "Повторяем…" : "Повторить"}
</Button>
</div>
)}
{!isLoading && !isError && items.length === 0 && <EmptyState onAdd={handleAdd} />}
{/* Пока летит выдача нового запроса, показываем прошлую — приглушённой.
Устарело ≠ пропало: подмена списка скелетонами на каждую букву
читается как «всё исчезло» и мешает целиться в нужную строку. */}
<div
className={`transition-opacity ${isPreviousResults ? "opacity-50" : ""}`}
aria-busy={isPreviousResults}
>
{!isLoading && !isError && items.length === 0 && (
isSearching ? (
<NothingFoundState query={q} onReset={resetSearch} />
) : (
<EmptyState onAdd={handleAdd} />
)
)}
{items.length > 0 && (
<ul className="space-y-2">
{grouped.map((group) => (
<li key={group.key}>
<DaySeparator dateKey={group.key} total={group.total} count={group.items.length} />
<ul className="space-y-2 mt-2">
{group.items.map((item) => (
<li key={item.id}>
<RepairRow item={item} onOpen={() => handleOpen(item)} />
</li>
))}
</ul>
</li>
))}
</ul>
)}
{items.length > 0 && (
<ul className="space-y-2">
{grouped.map((group) => (
<li key={group.key}>
<DaySeparator
dateKey={group.key}
total={group.total}
count={group.items.length}
// При поиске дневные итоги молчат: они считаются по строкам
// на экране, а это уже не «итог дня», а «итог совпадений».
showTotals={!isSearching}
/>
<ul className="space-y-2 mt-2">
{group.items.map((item) => (
<li key={item.id}>
<RepairRow item={item} onOpen={() => handleOpen(item)} />
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage && (
@@ -162,7 +252,7 @@ export default function RepairsFeedPage() {
)}
{!hasNextPage && items.length > 0 && !isFetchingNextPage && (
<div className="text-center text-xs text-muted-foreground py-4">
Это все ремонты.
{isSearching ? "Это все совпадения." : "Это все ремонты."}
</div>
)}
</main>
@@ -171,18 +261,47 @@ export default function RepairsFeedPage() {
}
/**
* Текст сбоя. «Проверьте сеть» уместно только когда до сервера не дозвонились:
* если сервер ответил 500, этот совет отправляет механика чинить не то —
* он перезагружает вайфай, а чинить нечего, надо просто позвать нас.
*/
function feedErrorMessage(error: unknown, isSearching: boolean): string {
const what = isSearching ? "выполнить поиск" : "загрузить ленту ремонтов";
// HTTPError = связь была, ответ пришёл — виноват сервер, а не сеть.
if (error instanceof HTTPError) {
const code = error.response.status;
return `Не удалось ${what}: сервер ответил ошибкой (${code}). Попробуйте позже или сообщите в поддержку.`;
}
// Всё остальное (обрыв, таймаут, самолётный режим) — ответа не было вовсе.
return `Не удалось ${what}: сервер не ответил. Проверьте связь и попробуйте снова.`;
}
// Группировка по локальной дате (VLA), форматирование сумм/склонений —
// в @/lib/repairsFeed (покрыто юнит-тестами).
function DaySeparator({ dateKey, total, count }: { dateKey: string; total: number; count: number }) {
function DaySeparator({
dateKey,
total,
count,
showTotals,
}: {
dateKey: string;
total: number;
count: number;
showTotals: boolean;
}) {
return (
<div className="flex items-center gap-2 pt-3 pb-1 text-xs">
<span className="font-medium text-foreground">{formatDayLabel(dateKey)}</span>
<div className="flex-1 border-t border-dashed border-border" />
<span className="tabular-nums text-muted-foreground">
{fmtMoney(total)}
<span className="ml-1">({count} {pluralRepairs(count)})</span>
</span>
{showTotals && (
<span className="tabular-nums text-muted-foreground">
{fmtMoney(total)}
<span className="ml-1">({count} {pluralRepairs(count)})</span>
</span>
)}
</div>
);
}
@@ -258,6 +377,29 @@ function WorksLine({ works, extra }: { works: string[]; extra: number }) {
}
// Пустой поиск — это НЕ пустая лента: тут не «ремонтов ещё нет», а
// «под фильтр ничего не попало», и выход отсюда — сбросить поиск.
function NothingFoundState({ query, onReset }: { query: string; onReset: () => void }) {
return (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">
<div className="rounded-full bg-muted p-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h2 className="text-base font-semibold">Ничего не найдено</h2>
<p className="text-sm text-muted-foreground max-w-xs">
По запросу «{query}» ремонтов нет. Проверьте номер или поищите по марке
и модели.
</p>
</div>
<Button variant="outline" onClick={onReset}>
Сбросить поиск
</Button>
</div>
);
}
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="flex flex-col items-center justify-center text-center py-16 px-4 space-y-4">