Compare commits

Author SHA1 Message Date
Evandro Fonseca Junior fc7243e5f8 Merge pull request #1 from evandrodevbr/codex/review-2026-09-30-049051
fix: normalize nutrition units and retain zero-valued nutrients
2026-09-30 23:02:14 -03:00
Evandro Fonseca Junior fa07ba8910 fix: normalize nutrition units and retain zero-valued nutrients 2026-09-30 17:13:27 -03:00
Evandro Fonseca Junior f037001339 docs: reescrever README com versões reais e setup verificado
- versões corrigidas conforme package.json e lockfile: Next.js 15 (era 14.2.18),
  React 19 (era 18.3.1), Prisma 6 (era 5.22.0), TypeScript 5.9, Tailwind 3.4.19
- badge de issues, badge de feature e links de clone deixam de apontar para o
  repositório antigo lista-compras
- setup testado na prática: "pnpm db:push" no lugar de "pnpm db:migrate" (o
  repositório não tem migrations, o comando não criava tabela alguma) e
  arquivo ".env" no lugar de ".env.local" (a CLI do Prisma só lê .env)
- README reescrito no padrão do perfil: sobre, como funciona, stack, requisitos,
  início rápido, uso, API com os parâmetros que existem de fato, produção,
  deploy, estrutura, verificação medida e limitações conhecidas
- removidas alegações não sustentadas: 218 produtos no cache (são 232),
  First Load JS de ~87 kB (medido: 102 kB), disponibilidade 24/7 e "12 opções
  de ordenação" como se todas funcionassem
- descreve o estado real: sem testes automatizados e sem CI, busca textual
  dependente do endpoint legado do Open Food Facts (503 na verificação),
  filtros avançados que não chegam à API e ordenação que cobre quatro critérios
2026-09-14 08:26:26 -03:00
Evandro Fonseca Junior 7d4efce025 chore: remover arquivos acidentais do repositório
- null: continha apenas uma mensagem de erro do PowerShell sobre um arquivo
  de configuração local inexistente
- design-handoff.tar.gz: continha apenas o texto "not found." (8 bytes)

Nenhum dos dois é referenciado no código, na documentação ou nos scripts de
build e deploy.
2026-09-14 08:26:26 -03:00
Evandro Fonseca Junior 15ddbfbde9 fix(setup): corrigir nome do pacote e adicionar .env.example
- package.json: name passa de "resenhaitapoa2" para "nutriscan"
- .env.example: arquivo citado no README mas ausente do repositório.
  Documenta DATABASE_URL (obrigatória para o Prisma), o modo cache-only e a
  origem do proxy de analytics
2026-09-14 08:26:22 -03:00
12 changed files with 479 additions and 279 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Banco de dados SQLite usado pelo Prisma.
# O caminho é relativo à pasta prisma/ (onde fica o schema), então este valor
# aponta para prisma/prisma/db/database.sqlite.
DATABASE_URL="file:./prisma/db/database.sqlite"
# Opcional. Quando "true", a aplicação responde apenas com o cache local
# (data/products-cache.json) e não chama a API do Open Food Facts.
# NEXT_PUBLIC_CACHE_ONLY_MODE="false"
# Opcional. Origem do proxy de analytics (Rybbit) configurado em next.config.mjs.
# Padrão quando vazio: https://analytics.evandro.dev.br/api
# NEXT_PUBLIC_RYBBIT_HOST="https://analytics.evandro.dev.br/api"
+224 -72
View File
@@ -1,109 +1,261 @@
<div align="center"> # NutriScan
<img src="assets/readme/hero.svg" width="100%" alt="NutriScan: busca de alimentos no Open Food Facts por código de barras ou nome, mostrando um produto com Nutri-Score D e NOVA grupo 4"> **Consulta nutricional de produtos de supermercado: busca por nome ou código de barras na base colaborativa do Open Food Facts, mostra Nutri-Score e grau de processamento (NOVA) e organiza listas de compras, com cache local para continuar funcionando sem internet.**
![Next.js 15.5](https://img.shields.io/badge/Next.js-15.5-317257?style=flat-square) ![Next.js](https://img.shields.io/badge/Next.js-15-black?logo=nextdotjs&logoColor=white)
![React 19](https://img.shields.io/badge/React-19-317257?style=flat-square) ![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)
![Prisma 6 + SQLite](https://img.shields.io/badge/Prisma_6-SQLite-317257?style=flat-square) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![Licença: uso pessoal](https://img.shields.io/badge/licen%C3%A7a-uso_pessoal-317257?style=flat-square) ![Prisma](https://img.shields.io/badge/Prisma-6-2D3748?logo=prisma&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-3-003B57?logo=sqlite&logoColor=white)
![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-3.4-06B6D4?logo=tailwindcss&logoColor=white)
![Node](https://img.shields.io/badge/Node-%3E%3D18.18-5FA04E?logo=nodedotjs&logoColor=white)
![Licença](https://img.shields.io/badge/licen%C3%A7a-uso%20pessoal-lightgrey)
[Notas da API Open Food Facts](docs/openFoodFacts.md) · [Issues](https://git.evandro.dev.br/evandro/nutriscan/issues) Demonstração publicada pelo autor: <https://nutriscan.evandro.dev.br>
</div> ## Sobre
O cache JSON do servidor vem no repositório (`data/products-cache.json`) com produtos reais do Open Food Facts. Contado por Nutri-Score, num clone novo e sem instalar nada: Saber o que um produto industrializado tem de verdade exige consultar bases públicas e cruzar informação espalhada: nota Nutri-Score, grau de processamento, alérgenos, aditivos e tabela nutricional. O NutriScan junta isso em uma interface só.
```console A aplicação consulta a base do Open Food Facts (mais de 2 milhões de produtos), aceita busca por texto ou por código de barras (8 a 13 dígitos, com detecção automática do tipo) e exibe Nutri-Score e grau de processamento NOVA de cada produto. O painel de filtros avançados oferece classificação nutricional, categorias, marcas, alérgenos, aditivos e faixas de valores nutricionais, com 12 opções de ordenação. Além da consulta, mantém listas de compras persistidas em SQLite.
$ node -e "const p = Object.values(require('./data/products-cache.json').products); const g = {}; for (const x of p) g[x.nutrition_grades] = (g[x.nutrition_grades] || 0) + 1; console.log(p.length, g)"
232 { e: 26, unknown: 167, d: 24, c: 9, a: 2, b: 4 }
```
## O que é O ponto de arquitetura que diferencia o projeto é o cache em duas camadas: um arquivo JSON no servidor, versionado no repositório, com os produtos já vistos, e um cache no `localStorage` do navegador. Com o modo cache-only ligado, a aplicação responde apenas com o cache local, sem chamar a API externa.
NutriScan é um app web em Next.js para buscar produtos alimentícios no [Open Food Facts](https://world.openfoodfacts.org/) e montar listas de compras. A entrada é detectada sozinha: 8 a 13 dígitos viram busca por código de barras; o resto, busca por nome ou marca.
Páginas: `/` (landing), `/resultados`, `/produto/[barcode]`, `/lista/[id]` e `/sobre`.
| Recurso | O que faz |
|---|---|
| **Filtros** | Nutri-Score A–E, grupos NOVA 1–4, categorias, marcas, alérgenos e aditivos (incluir/excluir, ou "sem aditivos"), faixas por 100 g de energia, gordura, carboidratos, proteínas, açúcares, fibras e sódio, e países |
| **12 critérios de ordenação** | Relevância, Nutri-Score, nome, marca, energia, gordura, açúcares, completude, com imagem, com dados nutricionais, Eco-Score e grupo NOVA, em ordem crescente ou decrescente |
| **País por IP** | A rota `/api/location` descobre o país do visitante e a busca prioriza produtos dele; o resultado fica 7 dias no `localStorage`, com Brasil como fallback |
| **Listas de compras** | Várias listas acessíveis por ID em `/lista/[id]`; cada item tem nome, quantidade, unidade e status de comprado, persistidos com Prisma + SQLite |
| **Interface** | Tema claro/escuro, componentes shadcn/ui sobre Radix UI, animações com Framer Motion, skeletons, paginação e manifest de PWA |
## Por que usar
- **Menos chamadas ao Open Food Facts.** A busca passa por dois caches antes da API: `localStorage` no navegador (TTL padrão de 1 hora) e o cache JSON no servidor. Tudo que vem da API é gravado no cache JSON.
- **Funciona sem a API.** No modo cache-only o servidor responde só com o cache JSON, sem consultar o Open Food Facts.
- **Filtros combinados**: Nutri-Score, NOVA, alérgenos, aditivos e faixas de nutrientes ao mesmo tempo, com 12 ordenações.
Quando não usar: se você precisa publicar o app ou usá-lo comercialmente (a licença só permite uso pessoal em localhost), ler código de barras pela câmera (a entrada é digitada ou colada) ou esperar Nutri-Score em cada produto (no cache acima, 167 de 232 produtos vieram sem nota).
## Como funciona ## Como funciona
1. O navegador procura a busca no `localStorage` (`lib/cacheManager.ts`, `hooks/useSearchCache.ts`). ```
2. Se não achar, chama as rotas Next.js `/api/search` e `/api/products/[barcode]`. navegador
3. As rotas consultam o cache JSON do servidor (`lib/jsonCacheManager.ts`, `data/products-cache.json`). │
4. Fora do modo cache-only, o que falta vem do Open Food Facts (`lib/openFoodFactsApi.ts`: timeout de 8 s, 200 ms entre páginas, até 1000 produtos por página) e é gravado no cache JSON. `POST /api/cache/sync` também grava produtos enviados pelo cliente. ├─ páginas Next.js (App Router, Server Components + Client Components)
5. As listas de compras usam Server Actions (`app/actions.ts`) sobre Prisma + SQLite: modelos `Lista` e `Produtos` em `prisma/schema.prisma`. │
├─ /api/search ──────────────▶ data/products-cache.json (cache do servidor, 232 produtos)
│ └─ se faltam resultados ─▶ Open Food Facts (world.openfoodfacts.org/cgi/search.pl)
├─ /api/products/[barcode] ──▶ cache JSON ─▶ Open Food Facts (api/v2/product/{code}.json)
│ └─ produto novo é gravado no cache JSON
├─ /api/location ────────────▶ headers do proxy ou api.ipify.org (país do usuário)
└─ /api/storage-stats ───────▶ estatísticas do cache JSON
listas de compras: componentes ─▶ Server Actions (app/actions.ts) ─▶ Prisma ─▶ SQLite
cache do cliente: resultados de busca e filtros no localStorage (TTL configurável)
```
- Produto não encontrado no cache JSON é buscado na API e salvo no cache, o que aumenta o acervo offline a cada uso.
- A rota `/api/search` pagina em blocos de 1000 resultados na API externa, com pausa de 200 ms entre requisições.
- Não há autenticação nem multiusuário: cada lista de compras é identificada por um id na URL.
## Stack
| Camada | Escolha |
|---|---|
| Framework | Next.js 15 (App Router, Server Actions, Route Handlers) |
| UI | React 19, shadcn/ui sobre Radix UI, Tailwind CSS 3.4, Framer Motion 11, lucide-react 0.460 |
| Tema | next-themes 0.4 (claro e escuro, persistido) |
| Linguagem | TypeScript 5.9 em modo `strict` |
| Listas de compras | SQLite com Prisma 6 (`prisma/schema.prisma`) |
| Dados de produtos | API pública do Open Food Facts + cache JSON em `data/` |
| Cache do cliente | `localStorage`, com modo cache-only |
| Pacotes | pnpm (lockfile versionado) |
| Deploy | nixpacks (`nixpacks.toml`) e Dokploy (`dokploy.yaml`) |
## Requisitos
- Node.js `>=18.18` (o deploy fixa Node 18; o build foi verificado em Node 24.20.0)
- pnpm 10 ou superior (o deploy fixa 10.34.5; a instalação foi verificada com pnpm 12.4.1 e a resolução de dependências foi verificada com pnpm 10.34.5)
- Nenhum serviço externo é obrigatório para subir a aplicação: sem internet ela responde pelo cache local
## Início rápido ## Início rápido
Requisitos: Node.js 18+ (o deploy usa `nodejs_18`) e pnpm (o deploy fixa `[email protected]`).
```bash ```bash
git clone https://git.evandro.dev.br/evandro/nutriscan.git git clone https://github.com/evandrodevbr/nutriscan.git
cd nutriscan cd nutriscan
pnpm install # o postinstall já roda `prisma generate`
# .env na raiz (o Prisma lê .env, não .env.local) pnpm install # o postinstall roda `prisma generate`
echo 'DATABASE_URL="file:./db/database.sqlite"' > .env cp .env.example .env
pnpm db:push # cria as tabelas Lista e Produtos no SQLite
pnpm db:push # cria o banco SQLite a partir do schema
pnpm dev # http://localhost:3000 pnpm dev # http://localhost:3000
``` ```
> [!WARNING] Duas pegadinhas que quebram a primeira execução e estão resolvidas acima:
> **Não existe `.env.example`** e **`pnpm db:migrate` não funciona num clone novo**: ele roda `prisma migrate deploy`, mas `/prisma/migrations` está no `.gitignore`. Crie o banco com `pnpm db:push`. O `DATABASE_URL` é resolvido a partir da pasta `prisma/`, então `file:./db/database.sqlite` vira `prisma/db/database.sqlite` (pasta também ignorada pelo git).
Outros scripts: `pnpm build` (`prisma generate && next build`), `pnpm start`, `pnpm lint` (`next lint`), `pnpm db:generate`. - O arquivo de ambiente precisa se chamar `.env`, não `.env.local`: a CLI do Prisma (`prisma generate`, `prisma db push`) só lê `.env`. O Next.js lê os dois.
- `pnpm db:migrate` roda `prisma migrate deploy` e não cria nada aqui, porque o repositório não tem a pasta `prisma/migrations`. Quem cria o schema é `pnpm db:push`.
## Configuração Com o servidor no ar:
| Variável | Efeito | ```bash
curl -s "http://localhost:3000/api/search?q=bis&cache_only=true&page_size=5"
curl -s http://localhost:3000/api/storage-stats
```
## Uso
| Rota | O que é |
|---|---| |---|---|
| `DATABASE_URL` | Banco SQLite das listas de compras (obrigatória) | | `/` | Página inicial com busca por nome ou código de barras |
| `NEXT_PUBLIC_CACHE_ONLY_MODE=true` | As rotas de busca e de produto respondem só com o cache JSON | | `/resultados?q=<termo>` | Resultados com filtros, ordenação e paginação |
| `NEXT_PUBLIC_RYBBIT_HOST` | Destino do proxy `/analytics/*` (padrão `https://analytics.evandro.dev.br/api`) | | `/produto/<barcode>` | Detalhe do produto: Nutri-Score, grupo NOVA, composição por 100 g e lista de ingredientes |
| `/lista/<id>` | Lista de compras: adicionar item com quantidade e unidade, marcar como comprado, remover |
| `/sobre` | Página institucional do projeto |
| `/robots.txt`, `/sitemap.xml` | Gerados pelo Next.js |
O switch de cache-only na interface só funciona em produção (`NODE_ENV=production`); as rotas `/api/search` e `/api/products/[barcode]` aceitam `?cache_only=true` em qualquer ambiente. ## API
O que sai do seu servidor: Todas as rotas ficam em `app/api` e são Route Handlers do Next.js.
- **Open Food Facts**: dados e imagens de produtos (`next.config.mjs` libera `static.openfoodfacts.org` e `images.openfoodfacts.org`). | Rota | Método | O que faz |
- **ip-api.com**: o IP do visitante, enviado pelo servidor por HTTP, para descobrir o país. |---|---|---|
- **Rybbit Analytics** (self-hosted, via proxy `/analytics/`): com session replay e rastreamento de erros. | `/api/search` | GET | Busca produtos no cache JSON e, se faltar, no Open Food Facts |
- O app também exibe um card de doação via [Buy Me a Coffee](https://buymeacoffee.com/evandrotruuta). | `/api/products/[barcode]` | GET | Busca um produto pelo código de barras (cache primeiro, depois API) |
| `/api/location` | GET | País estimado a partir do IP (`x-forwarded-for`, `x-real-ip` ou api.ipify.org) |
| `/api/cache/sync` | POST | Grava um lote de produtos no cache JSON. Corpo: `{ "products": [...] }` |
| `/api/storage-stats` | GET | Total de produtos, tamanho do arquivo e data da última atualização do cache |
## Estado do projeto Parâmetros de `/api/search`:
Projeto pessoal, sem suíte de testes; o único check é `pnpm lint`. O último trabalho de código foi o redesign completo e os ajustes no deploy com Prisma/SQLite, em 2026-07-16. O repositório traz [`dokploy.yaml`](dokploy.yaml) e [`nixpacks.toml`](nixpacks.toml) para o deploy do autor, sem workflow de CI. A instância pública do autor em `nutriscan.evandro.dev.br` não respondia com o app em 2026-10-24. | Parâmetro | Valores | Observação |
|---|---|---|
| `q` | texto | Obrigatório; sem ele a rota responde 400 |
| `page`, `page_size` | inteiros `>= 1` | Padrão 1 e 20; valores inválidos respondem 400 |
| `cache_only` | `true` | Responde só com o cache local, sem chamar a API externa |
| `country` | código do país | Enviado como `countries_tags_en` |
| `countries`, `categories`, `brands` | tags | Filtros repassados como `countries_tags`, `categories_tags`, `brands_tags` |
| `nutrition_grades`, `nova_groups` | `a` a `e`, `1` a `4` | Filtros de classificação |
| `allergens`, `exclude_allergens`, `additives`, `exclude_additives`, `no_additives` | tags, `true` | Filtros de composição |
| `energy_min`, `energy_max`, `fat_min`, `fat_max`, `carbohydrates_min`, `carbohydrates_max`, `proteins_min`, `proteins_max`, `sugars_min`, `sugars_max`, `fiber_min`, `fiber_max`, `sodium_min`, `sodium_max` | número (por 100 g) | Faixas nutricionais |
| `sort_by` | `nutrition_grade`, `name`, `brand`, `energy`, `fat`, `sugars` | Ordenação; o padrão é `popularity` |
| `sort_order` | `asc`, `desc` | Padrão `asc` |
Limites conhecidos: No modo `cache_only` a busca considera apenas texto (nome, marca, categoria) e país; os demais filtros só são repassados para o Open Food Facts nas buscas online.
- Toda busca nova depende do Open Food Facts; além do cache, não há fila nem fallback. Exemplo de resposta de `/api/search`:
- A localização é por IP, não pela API de geolocalização do navegador.
- O `sitemap.xml` lista só a página inicial. ```json
{
"products": [{ "code": "7622210575975", "product_name": "Bis", "brands": "Lacta" }],
"count": 5,
"page": 1,
"page_size": 5,
"fromCache": true,
"fromAPI": false,
"cacheStats": { "localProducts": 5, "apiProducts": 0, "newProducts": 0, "totalReturned": 5 }
}
```
## Produção
```bash
pnpm build # prisma generate + next build
pnpm start # next start, porta 3000 (use -p <porta> para trocar)
```
O `build` precisa de `DATABASE_URL` no ambiente para gerar o Prisma Client, e o `start` precisa dela para as rotas de lista de compras.
## Deploy
O repositório traz duas configurações de build, ambas para plataformas que usam nixpacks:
- `nixpacks.toml`: Node 18 e pnpm 10.34.5, instala com `--no-frozen-lockfile`, roda `prisma generate`, `prisma db push --accept-data-loss` e o build, com `DATABASE_URL=file:./db/database.sqlite`. O start é `pnpm start` com a mesma variável.
- `dokploy.yaml`: repete os passos de instalação (`pnpm install --no-frozen-lockfile`), geração do Prisma Client e build.
Como o `DATABASE_URL` do deploy aponta para `file:./db/database.sqlite` e o caminho relativo do SQLite é resolvido a partir da pasta `prisma/`, o banco de produção fica em `prisma/db/database.sqlite` no contêiner, separado do arquivo versionado no repositório.
## Estrutura do projeto
```
app/
├── page.tsx página inicial (hero, recursos, como funciona, doação)
├── sobre/ página institucional
├── resultados/ busca com filtros, ordenação e paginação
├── produto/[barcode]/ detalhe do produto
├── lista/[id]/ lista de compras
├── api/ search, products/[barcode], location, cache/sync, storage-stats
├── actions.ts Server Actions das listas (criar, listar, marcar, remover)
├── components/ componentes de tela (landing/, cards, filtros, paginação)
├── layout.tsx layout raiz: fontes, metadados, analytics
├── robots.ts, sitemap.ts metadados de indexação
├── globals.css estilos globais e tokens de tema
├── fonts/ fontes Geist
hooks/ busca, filtros, cache, geolocalização e monitor de storage
lib/
├── openFoodFactsApi.ts cliente da API do Open Food Facts e ordenação
├── jsonCacheManager.ts cache JSON do servidor (data/products-cache.json)
├── cacheManager.ts cache de buscas no localStorage
├── geolocation.ts detecção de país do usuário
├── searchUtils.ts ordenação por relevância
├── types.ts tipos compartilhados
└── prisma.ts singleton do Prisma Client
components/ui/ componentes shadcn/ui (button, dialog, select, sheet, ...)
prisma/schema.prisma modelos Lista e Produtos
data/products-cache.json cache inicial com 232 produtos
docs/ documentação interna
nixpacks.toml, dokploy.yaml configuração de deploy
```
## Verificação
Há testes offline com o runner nativo do Node.js (sem framework adicional) e não há CI configurada. O que existe hoje:
- `pnpm lint` roda o ESLint (via `next lint`);
- `pnpm build` roda `prisma generate` e o `next build` com checagem de tipos;
- verificação manual de rotas com `curl`, descrita abaixo.
Medições feitas nesta auditoria, em 14/09/2026, com Node 24.20.0 e pnpm 12.4.1:
| Comando / requisição | Resultado |
|---|---|
| `pnpm install` | exit 0, sem precisar de flags |
| `pnpm lint` | exit 0, 6 avisos de variável não usada |
| `pnpm build` | exit 0, 13 páginas geradas, First Load JS compartilhado de 102 kB |
| `pnpm start -p 3111` | servidor no ar, respondeu 200 em 1 s |
| `GET /` | 200, 46 kB |
| `GET /sobre`, `/resultados`, `/robots.txt`, `/sitemap.xml`, `/manifest.json` | 200 |
| `GET /produto/3017620422003` | 200, produto Nutella obtido na hora na API |
| `GET /lista/audit-teste` | 200, página de lista renderizada |
| `GET /api/search?q=bis&cache_only=true` | 200, 20 produtos vindos do cache |
| `GET /api/search?q=bis&cache_only=true&page_size=5` | 200, 5 produtos |
| `GET /api/products/7622210575975?cache_only=true` | 200, `fromCache: true` |
| `GET /api/products/3017620422003` | 200, `fromAPI: true` (produto buscado e gravado no cache) |
| `GET /api/storage-stats` | 200, `totalProducts: 232`, 0,55 MB |
| `GET /api/location` | 200, `{"country":"Brazil","countryCode":"br"}` |
| `POST /api/cache/sync` com `{"products":[]}` | 200, `{"success":true,"saved":0,"failed":0}` |
| `POST /api/cache/sync` com corpo inválido | 400, `{"error":"Produtos inválidos"}` |
| `GET /api/search` sem `q` | 400, `{"error":"Query parameter is required"}` |
| `GET /api/products/123` | 400, `{"error":"Código de barras inválido"}` |
| `GET /api/search?q=x&page=0` | 400, `{"error":"Invalid page parameter"}` |
## Estado atual e limitações
- A busca textual depende do endpoint legado `cgi/search.pl` do Open Food Facts, que respondeu HTTP 503 durante a verificação. Na prática, buscar por nome retorna lista vazia, com a rota respondendo 200 e `count: 0`, sem derrubar a aplicação. A consulta por código de barras continua funcionando pelo endpoint `api/v2/product`.
- Testes offline de normalização e ordenação nutricional; ainda sem CI.
- O painel de filtros avançados da página de resultados guarda as escolhas na chave do cache, mas a busca enviada para `/api/search` leva apenas texto, país e paginação. Hoje o que realmente filtra a lista são os atalhos de classificação Nutri-Score e o botão de produtos sem alérgenos.
- A ordenação implementada em `lib/openFoodFactsApi.ts` cobre quatro critérios (relevância, Nutri-Score, energia e nome). As demais opções do seletor caem no caso padrão da função e não reordenam o resultado.
- O servidor grava em `data/products-cache.json`, que é versionado. Usar a aplicação altera o conteúdo do arquivo no diretório de trabalho.
- Duas lockfiles versionadas: `pnpm-lock.yaml` (usada pelo deploy) e `bun.lock`.
- Metadados, Open Graph e User-Agent apontam para `nutriscan.com.br`, domínio que não responde; a instância publicada está em `nutriscan.evandro.dev.br`, que também é a URL do `sitemap.xml` e do `robots.txt`.
- O banco SQLite versionado em `prisma/prisma/db/` vem só com a tabela `_prisma_migrations`, sem `Lista` e `Produtos`; é necessário rodar `pnpm db:push` antes de usar as listas.
- `pnpm db:migrate` existe no `package.json` mas não tem migrations para aplicar no repositório.
- O analytics é servido via proxy em `/analytics/*` para o Rybbit configurado em `NEXT_PUBLIC_RYBBIT_HOST`; sem esse serviço no ar, o script falha silenciosamente no cliente.
- A página de lista de compras renderiza para qualquer id, mesmo sem registro no banco, porque `verificarLista` retorna sucesso quando a lista não existe.
## Documentação
| Documento | Conteúdo |
|---|---|
| [`docs/openFoodFacts.md`](docs/openFoodFacts.md) | Anotações sobre a API do Open Food Facts |
| [`docs/logo-prompts.md`](docs/logo-prompts.md) | Prompts usados na criação das logos |
| [`docs/resedesign/`](docs/resedesign) | Referência visual do redesign |
| [`prisma/schema.prisma`](prisma/schema.prisma) | Modelo de dados das listas |
## Licença ## Licença
Licença de Uso Pessoal, em [`LICENSE`](LICENSE) (Copyright (c) 2024 Evandro): uso pessoal, não comercial e só em localhost. É proibido fazer deploy público, redistribuir, usar comercialmente ou criar obras derivadas para distribuição. Dúvidas sobre a licença: evandro@evandro.dev.br. Licença de uso pessoal, definida em [`LICENSE`](LICENSE): é permitido usar em máquina local, estudar o código e rodar para desenvolvimento. Não é permitido deploy público, uso comercial, redistribuição nem obras derivadas para distribuição. A instância em `nutriscan.evandro.dev.br` é do próprio autor.
Créditos: [Open Food Facts](https://world.openfoodfacts.org/), [Next.js](https://nextjs.org/), [Prisma](https://www.prisma.io/), [shadcn/ui](https://ui.shadcn.com/) e [Radix UI](https://www.radix-ui.com/). Autor: Evandro, <https://evandro.dev.br> e [@evandrodevbr](https://github.com/evandrodevbr).
<div align="center"> ## Validação de dados nutricionais (2026-09-30)
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub> A exibição e a ordenação de energia priorizam `energy-kcal_100g`; na ausência dele, convertem o campo em kJ para kcal usando `1 kcal = 4,184 kJ`. A página do produto usa a mesma normalização. Zero é preservado como dado válido, e valores ausentes, negativos ou não finitos não entram no cálculo. Produtos sem energia ficam no fim da ordenação nas duas direções. A linha baseada em `salt_100g` é identificada como sal.
</div> Os exemplos de campos vêm da [documentação oficial do Open Food Facts](https://openfoodfacts.github.io/openfoodfacts-server/api/tutorial-off-api/). Valores por 100 g e referências existentes da interface são mantidos; o Nutri-Score continua vindo da API.
Com Node.js 22.7+ (verificado em 24.19.0), `pnpm test` executa 6 regressões offline usando o runner nativo, sem adicionar um framework. `pnpm lint`, `pnpm exec tsc --noEmit` e `pnpm build` completam a validação. O build foi verificado com SQLite temporário, sem conexão com um banco de produção. Next.js e eslint-config-next foram atualizados para 15.5.24.
+6 -5
View File
@@ -12,7 +12,8 @@ import {
Share2, Share2,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { searchByBarcode, Product, formatNutritionData } from "@/lib/openFoodFactsApi"; import { searchByBarcode, Product } from "@/lib/openFoodFactsApi";
import { getEnergyKcal, getNutrientValue } from "@/lib/nutrition";
import { ProductSkeleton } from "@/app/components/ProductSkeleton"; import { ProductSkeleton } from "@/app/components/ProductSkeleton";
/* ── Nutri-Score letter circle ─────────────────────────────────────────── */ /* ── Nutri-Score letter circle ─────────────────────────────────────────── */
@@ -305,7 +306,7 @@ export default function ProdutoPage() {
/* ── Nutrition bars — % of daily reference ─────────────────────────── */ /* ── Nutrition bars — % of daily reference ─────────────────────────── */
const dailyRef: Record<string, number> = { const dailyRef: Record<string, number> = {
energy_100g: 2000, "energy-kcal_100g": 2000,
fat_100g: 70, fat_100g: 70,
saturated_fat_100g: 20, saturated_fat_100g: 20,
sugars_100g: 90, sugars_100g: 90,
@@ -315,11 +316,11 @@ export default function ProdutoPage() {
}; };
const nutriments = product.nutriments || {}; const nutriments = product.nutriments || {};
const nutritionRows = [ const nutritionRows = [
{ key: "energy_100g", label: "Energia", unit: "kcal" }, { key: "energy-kcal_100g", label: "Energia", unit: "kcal" },
{ key: "fat_100g", label: "Gorduras totais", unit: "g" }, { key: "fat_100g", label: "Gorduras totais", unit: "g" },
{ key: "saturated_fat_100g",label: "Gorduras saturadas", unit: "g" }, { key: "saturated_fat_100g",label: "Gorduras saturadas", unit: "g" },
{ key: "sugars_100g", label: "Açúcares", unit: "g" }, { key: "sugars_100g", label: "Açúcares", unit: "g" },
{ key: "salt_100g", label: "Sódio", unit: "g" }, { key: "salt_100g", label: "Sal", unit: "g" },
{ key: "fiber_100g", label: "Fibras", unit: "g" }, { key: "fiber_100g", label: "Fibras", unit: "g" },
{ key: "proteins_100g", label: "Proteínas", unit: "g" }, { key: "proteins_100g", label: "Proteínas", unit: "g" },
]; ];
@@ -701,7 +702,7 @@ export default function ProdutoPage() {
> >
{nutritionRows.map((row) => { {nutritionRows.map((row) => {
const raw = nutriments[row.key as keyof typeof nutriments]; const raw = nutriments[row.key as keyof typeof nutriments];
const v = raw != null ? Number(raw) : null; const v = row.key === "energy-kcal_100g" ? getEnergyKcal(nutriments) : getNutrientValue(raw);
if (v == null) return null; if (v == null) return null;
const ref = dailyRef[row.key] || 100; const ref = dailyRef[row.key] || 100;
const pct = Math.min(100, Math.round((v / ref) * 100)); const pct = Math.min(100, Math.round((v / ref) * 100));
-58
View File
@@ -1,58 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">NutriScan</title>
<desc id="desc">NutriScan busca produtos alimentícios no Open Food Facts por código de barras ou nome. À direita, a busca pelo código 7891962032450 devolve o Biscoito Maizena Bauducco com Nutri-Score D e NOVA grupo 4, dados reais do cache do repositório.</desc>
<rect width="1200" height="420" rx="24" fill="#f4f0e8"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="104" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#7c6a54">Open Food Facts · busca e listas</text>
<text x="64" y="186" font-size="72" font-weight="700" fill="#1e1810">NutriScan</text>
<text x="64" y="246" font-size="28" fill="#1e1810">Busque alimentos por código de barras</text>
<text x="64" y="282" font-size="28" fill="#1e1810">ou nome e compare Nutri-Score e NOVA.</text>
<text x="64" y="344" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="20" fill="#317257">Next.js 15 · Prisma + SQLite</text>
</g>
<g id="project-proof" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="632" y="48" width="504" height="324" rx="20" fill="#ffffff" stroke="#e8e0d0" stroke-width="2"/>
<g id="search">
<rect x="664" y="80" width="440" height="64" rx="12" fill="#faf8f5" stroke="#d4c8b0" stroke-width="2"/>
<g fill="#1e1810">
<rect x="684" y="96" width="3" height="32"/>
<rect x="690" y="96" width="2" height="32"/>
<rect x="695" y="96" width="5" height="32"/>
<rect x="703" y="96" width="2" height="32"/>
<rect x="708" y="96" width="3" height="32"/>
<rect x="715" y="96" width="2" height="32"/>
<rect x="720" y="96" width="5" height="32"/>
<rect x="728" y="96" width="2" height="32"/>
<rect x="733" y="96" width="3" height="32"/>
</g>
<text x="752" y="121" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="24" fill="#1e1810">7891962032450</text>
<text x="1084" y="119" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" fill="#317257" text-anchor="end">EAN-13</text>
</g>
<text x="664" y="204" font-size="26" font-weight="700" fill="#1e1810">Biscoito Maizena</text>
<text x="664" y="236" font-size="20" fill="#7c6a54">Bauducco · pacote 170 g</text>
<g id="nutri-score" font-weight="700" text-anchor="middle">
<text x="664" y="282" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" font-weight="400" fill="#7c6a54" text-anchor="start">NUTRI-SCORE</text>
<rect x="664" y="300" width="44" height="44" rx="8" fill="#038141" fill-opacity="0.3"/>
<text x="686" y="330" font-size="22" fill="#1e1810">A</text>
<rect x="712" y="300" width="44" height="44" rx="8" fill="#85bb2f" fill-opacity="0.3"/>
<text x="734" y="330" font-size="22" fill="#1e1810">B</text>
<rect x="760" y="300" width="44" height="44" rx="8" fill="#fecb02" fill-opacity="0.3"/>
<text x="782" y="330" font-size="22" fill="#1e1810">C</text>
<rect x="808" y="292" width="60" height="60" rx="10" fill="#ee8100"/>
<text x="838" y="333" font-size="32" fill="#ffffff">D</text>
<rect x="872" y="300" width="44" height="44" rx="8" fill="#e63e11" fill-opacity="0.3"/>
<text x="894" y="330" font-size="22" fill="#1e1810">E</text>
</g>
<g id="nova">
<text x="964" y="282" font-family="ui-monospace, 'SF Mono', Consolas, monospace" font-size="18" fill="#7c6a54">NOVA</text>
<rect x="964" y="300" width="140" height="44" rx="22" fill="#1e1810"/>
<text x="1034" y="329" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">grupo 4</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

-1
View File
@@ -1 +0,0 @@
not found
+24
View File
@@ -0,0 +1,24 @@
/** Normalização dos valores nutricionais recebidos do Open Food Facts. */
export function getNutrientValue(value: unknown): number | null {
if (typeof value !== "number" && typeof value !== "string") return null;
if (typeof value === "string" && value.trim() === "") return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : null;
}
/**
* Prefere o campo explícito em kcal. Os campos energy/energy-kj usam kJ;
* 1 kcal = 4.184 kJ. Zero é um valor válido, distinto de dado ausente.
* https://openfoodfacts.github.io/openfoodfacts-server/api/tutorial-off-api/
*/
export function getEnergyKcal(nutriments?: {
"energy-kcal_100g"?: unknown;
"energy-kj_100g"?: unknown;
energy_100g?: unknown;
}): number | null {
const kcal = getNutrientValue(nutriments?.["energy-kcal_100g"]);
if (kcal !== null) return kcal;
const kj = getNutrientValue(nutriments?.["energy-kj_100g"])
?? getNutrientValue(nutriments?.energy_100g);
return kj === null ? null : kj / 4.184;
}
+20 -13
View File
@@ -3,6 +3,8 @@
* Melhorias: Memoização de Scores, Rate Limiting Robusto e Tipagem Estrita. * Melhorias: Memoização de Scores, Rate Limiting Robusto e Tipagem Estrita.
*/ */
import { getEnergyKcal, getNutrientValue } from "./nutrition.ts";
export interface Product { export interface Product {
code: string; code: string;
product_name: string; product_name: string;
@@ -11,6 +13,8 @@ export interface Product {
ingredients_text?: string; ingredients_text?: string;
nutriments?: { nutriments?: {
energy_100g?: number; energy_100g?: number;
"energy-kcal_100g"?: number;
"energy-kj_100g"?: number;
fat_100g?: number; fat_100g?: number;
carbohydrates_100g?: number; carbohydrates_100g?: number;
proteins_100g?: number; proteins_100g?: number;
@@ -185,6 +189,7 @@ export function sortProducts(
value: p, value: p,
score: calculateCompletenessScore(p), score: calculateCompletenessScore(p),
nutritionValue: getGradeValue(p.nutrition_grades), nutritionValue: getGradeValue(p.nutrition_grades),
energyKcal: getEnergyKcal(p.nutriments),
})); }));
// 2. Sort: Ordenação sobre valores em memória (O(n log n)) // 2. Sort: Ordenação sobre valores em memória (O(n log n))
@@ -199,7 +204,9 @@ export function sortProducts(
diff = a.nutritionValue - b.nutritionValue; diff = a.nutritionValue - b.nutritionValue;
break; break;
case "energy": case "energy":
diff = (a.value.nutriments?.energy_100g || 9999) - (b.value.nutriments?.energy_100g || 9999); if (a.energyKcal === null) return b.energyKcal === null ? 0 : 1;
if (b.energyKcal === null) return -1;
diff = a.energyKcal - b.energyKcal;
break; break;
case "name": case "name":
diff = (a.value.product_name || "").localeCompare(b.value.product_name || ""); diff = (a.value.product_name || "").localeCompare(b.value.product_name || "");
@@ -240,21 +247,21 @@ export function formatNutritionData(p: Product) {
const n = p.nutriments; const n = p.nutriments;
if (!n) return null; if (!n) return null;
const fmt = (v: unknown, d: number = 1) => { const fmt = (v: unknown, unit: string, d: number = 1) => {
const num = const num = getNutrientValue(v);
typeof v === "number" return num === null ? "N/A" : `${num.toFixed(d)}${unit}`;
? v
: parseFloat(typeof v === "string" ? v : String(v));
return isNaN(num) ? "N/A" : num.toFixed(d);
}; };
const energy = getEnergyKcal(n);
return { return {
energy: n.energy_100g ? `${Math.round(n.energy_100g)} kcal` : "N/A", energy: energy === null ? "N/A" : `${Math.round(energy)} kcal`,
fat: n.fat_100g ? `${fmt(n.fat_100g)}g` : "N/A", fat: fmt(n.fat_100g, "g"),
carbs: n.carbohydrates_100g ? `${fmt(n.carbohydrates_100g)}g` : "N/A", carbs: fmt(n.carbohydrates_100g, "g"),
proteins: n.proteins_100g ? `${fmt(n.proteins_100g)}g` : "N/A", carbohydrates: fmt(n.carbohydrates_100g, "g"),
sugars: n.sugars_100g ? `${fmt(n.sugars_100g)}g` : "N/A", proteins: fmt(n.proteins_100g, "g"),
sodium: n.sodium_100g ? `${fmt(n.sodium_100g, 2)}g` : "N/A", sugars: fmt(n.sugars_100g, "g"),
fiber: fmt(n.fiber_100g, "g"),
sodium: fmt(n.sodium_100g, "g", 2),
}; };
} }
-1
View File
@@ -1 +0,0 @@
Get-Content: Cannot find path 'C:\Users\Evandro\.config\opencode\agents.json' because it does not exist.
+4 -3
View File
@@ -1,8 +1,9 @@
{ {
"name": "resenhaitapoa2", "name": "nutriscan",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "node --experimental-transform-types --test tests/*.test.ts",
"dev": "next dev", "dev": "next dev",
"lint": "next lint", "lint": "next lint",
"postinstall": "prisma generate", "postinstall": "prisma generate",
@@ -30,7 +31,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.18.2", "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^15.5.10", "next": "^15.5.24",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -43,7 +44,7 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"depcheck": "^1.4.7", "depcheck": "^1.4.7",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-next": "^15.5.10", "eslint-config-next": "^15.5.24",
"postcss": "^8.5.10", "postcss": "^8.5.10",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
+130 -126
View File
@@ -60,8 +60,8 @@ importers:
specifier: ^0.460.0 specifier: ^0.460.0
version: 0.460.0([email protected]) version: 0.460.0([email protected])
next: next:
specifier: ^15.5.10 specifier: ^15.5.24
version: 15.5.20([email protected]([email protected]))([email protected]) version: 15.5.24([email protected]([email protected]))([email protected])
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected]) version: 0.4.6([email protected]([email protected]))([email protected])
@@ -89,13 +89,13 @@ importers:
version: 19.2.3(@types/[email protected]) version: 19.2.3(@types/[email protected])
depcheck: depcheck:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7([email protected])
eslint: eslint:
specifier: ^8.57.1 specifier: ^8.57.1
version: 8.57.1 version: 8.57.1([email protected])
eslint-config-next: eslint-config-next:
specifier: ^15.5.10 specifier: ^15.5.24
version: 15.5.20([email protected])([email protected]) version: 15.5.24([email protected]([email protected]))([email protected])([email protected])
postcss: postcss:
specifier: ^8.5.10 specifier: ^8.5.10
version: 8.5.19 version: 8.5.19
@@ -382,60 +382,60 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==} resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==}
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-MZUgFpVd9rGSZpb8bNceUWvkAZe6aQw/6h2SSqHQuYzKfaiEUEVPIO6mXqaBmiBEBSN1f1sOc1uV8GCM90oegg==} resolution: {integrity: sha512-K6jcYhwMY73qpeJ5SP+rrXs7Fuvv94HEvOCEen/zjwG7aq83utZY8n+8YdKHZR1lv0n5cB4z9C6QqwpE9acN+A==}
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==} resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==} resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==} resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==} resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==} resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==} resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==} resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/[email protected]0': '@next/[email protected]4':
resolution: {integrity: sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==} resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1549,8 +1549,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
[email protected]0: [email protected]4:
resolution: {integrity: sha512-Pl/I5544gmkcVVWcnaOMfhJSBK8lZ1NCJ+mGBkd2qvbGt2Gi7sEpgHF06OR13a2p6THODlncpvGsZzY2vUqwxw==} resolution: {integrity: sha512-RXne8azyj9DKV9N15elyRsnh98BtKINYHCG7bmVhpIhaOtaYvrNJ37XwWpjY9Z09f5RHYOf9vn5uU0MgV1iLrw==}
peerDependencies: peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1' typescript: '>=3.3.1'
@@ -2184,8 +2184,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
[email protected]0: [email protected]4:
resolution: {integrity: sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==} resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -2882,7 +2882,7 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
'@babel/[email protected]': '@babel/[email protected]([email protected])':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7 '@babel/generator': 7.29.7
@@ -2890,7 +2890,7 @@ snapshots:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/template': 7.29.7 '@babel/template': 7.29.7
'@babel/types': 7.29.7 '@babel/types': 7.29.7
debug: 4.4.3 debug: 4.4.3([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -2920,17 +2920,17 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@eslint-community/[email protected]([email protected])': '@eslint-community/[email protected]([email protected]([email protected]))':
dependencies: dependencies:
eslint: 8.57.1 eslint: 8.57.1([email protected])
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/[email protected]': {} '@eslint-community/[email protected]': {}
'@eslint/[email protected]': '@eslint/[email protected]([email protected])':
dependencies: dependencies:
ajv: 6.15.0 ajv: 6.15.0
debug: 4.4.3 debug: 4.4.3([email protected])
espree: 9.6.1 espree: 9.6.1
globals: 13.24.0 globals: 13.24.0
ignore: 5.3.2 ignore: 5.3.2
@@ -2960,10 +2960,10 @@ snapshots:
'@floating-ui/[email protected]': {} '@floating-ui/[email protected]': {}
'@humanwhocodes/[email protected]': '@humanwhocodes/[email protected]([email protected])':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3 debug: 4.4.3([email protected])
minimatch: 3.1.5 minimatch: 3.1.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3090,34 +3090,34 @@ snapshots:
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@next/[email protected]0': {} '@next/[email protected]4': {}
'@next/[email protected]0': '@next/[email protected]4':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@next/[email protected]0': '@next/[email protected]4':
optional: true optional: true
'@nodelib/[email protected]': '@nodelib/[email protected]':
@@ -3643,15 +3643,15 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])': '@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.64.0([email protected])([email protected]) '@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/scope-manager': 8.64.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/type-utils': 8.64.0([email protected])([email protected]) '@typescript-eslint/type-utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/utils': 8.64.0([email protected])([email protected]) '@typescript-eslint/utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.64.0 '@typescript-eslint/visitor-keys': 8.64.0
eslint: 8.57.1 eslint: 8.57.1([email protected])
ignore: 7.0.6 ignore: 7.0.6
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.5.0([email protected]) ts-api-utils: 2.5.0([email protected])
@@ -3659,23 +3659,23 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/[email protected]([email protected])([email protected])': '@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.64.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected]) '@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
'@typescript-eslint/visitor-keys': 8.64.0 '@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3 debug: 4.4.3([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/[email protected]([email protected])': '@typescript-eslint/[email protected]([email protected])([email protected])':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.64.0([email protected]) '@typescript-eslint/tsconfig-utils': 8.64.0([email protected])
'@typescript-eslint/types': 8.64.0 '@typescript-eslint/types': 8.64.0
debug: 4.4.3 debug: 4.4.3([email protected])
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3689,13 +3689,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/[email protected]([email protected])([email protected])': '@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies: dependencies:
'@typescript-eslint/types': 8.64.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected]) '@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
'@typescript-eslint/utils': 8.64.0([email protected])([email protected]) '@typescript-eslint/utils': 8.64.0([email protected]([email protected]))([email protected])([email protected])
debug: 4.4.3 debug: 4.4.3([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
ts-api-utils: 2.5.0([email protected]) ts-api-utils: 2.5.0([email protected])
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3703,13 +3703,13 @@ snapshots:
'@typescript-eslint/[email protected]': {} '@typescript-eslint/[email protected]': {}
'@typescript-eslint/[email protected]([email protected])': '@typescript-eslint/[email protected]([email protected])([email protected])':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.64.0([email protected]) '@typescript-eslint/project-service': 8.64.0([email protected])([email protected])
'@typescript-eslint/tsconfig-utils': 8.64.0([email protected]) '@typescript-eslint/tsconfig-utils': 8.64.0([email protected])
'@typescript-eslint/types': 8.64.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/visitor-keys': 8.64.0 '@typescript-eslint/visitor-keys': 8.64.0
debug: 4.4.3 debug: 4.4.3([email protected])
minimatch: 10.2.5 minimatch: 10.2.5
semver: 7.8.5 semver: 7.8.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
@@ -3718,13 +3718,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/[email protected]([email protected])([email protected])': '@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected])':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1([email protected]) '@eslint-community/eslint-utils': 4.9.1([email protected]([email protected]))
'@typescript-eslint/scope-manager': 8.64.0 '@typescript-eslint/scope-manager': 8.64.0
'@typescript-eslint/types': 8.64.0 '@typescript-eslint/types': 8.64.0
'@typescript-eslint/typescript-estree': 8.64.0([email protected]) '@typescript-eslint/typescript-estree': 8.64.0([email protected])([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -4121,13 +4121,17 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 is-data-view: 1.0.2
[email protected]: [email protected]([email protected]):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
[email protected]: [email protected]([email protected]):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
[email protected]: {} [email protected]: {}
@@ -4147,15 +4151,15 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: [email protected]([email protected]):
dependencies: dependencies:
'@babel/parser': 7.29.7 '@babel/parser': 7.29.7
'@babel/traverse': 7.29.7 '@babel/traverse': 7.29.7([email protected])
'@vue/compiler-sfc': 3.5.39 '@vue/compiler-sfc': 3.5.39
callsite: 1.0.0 callsite: 1.0.0
camelcase: 6.3.0 camelcase: 6.3.0
cosmiconfig: 7.1.0 cosmiconfig: 7.1.0
debug: 4.4.3 debug: 4.4.3([email protected])
deps-regex: 0.2.0 deps-regex: 0.2.0
findup-sync: 5.0.0 findup-sync: 5.0.0
ignore: 5.3.2 ignore: 5.3.2
@@ -4338,19 +4342,19 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]0([email protected])([email protected]): [email protected]4([email protected]([email protected]))([email protected])([email protected]):
dependencies: dependencies:
'@next/eslint-plugin-next': 15.5.20 '@next/eslint-plugin-next': 15.5.24
'@rushstack/eslint-patch': 1.16.1 '@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]) '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
'@typescript-eslint/parser': 8.64.0([email protected])([email protected]) '@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10([email protected])
eslint-import-resolver-typescript: 3.10.1([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]) eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))([email protected])
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]) eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])
eslint-plugin-jsx-a11y: 6.10.2([email protected]) eslint-plugin-jsx-a11y: 6.10.2([email protected]([email protected]))
eslint-plugin-react: 7.37.5([email protected]) eslint-plugin-react: 7.37.5([email protected]([email protected]))
eslint-plugin-react-hooks: 5.2.0([email protected]) eslint-plugin-react-hooks: 5.2.0([email protected]([email protected]))
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -4358,52 +4362,52 @@ snapshots:
- eslint-plugin-import-x - eslint-plugin-import-x
- supports-color - supports-color
[email protected]: [email protected]([email protected]):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7([email protected])
is-core-module: 2.16.2 is-core-module: 2.16.2
resolve: 2.0.0-next.7 resolve: 2.0.0-next.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected]([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]): [email protected]([email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
get-tsconfig: 4.14.0 get-tsconfig: 4.14.0
is-bun-module: 2.0.0 is-bun-module: 2.0.0
stable-hash: 0.0.5 stable-hash: 0.0.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
unrs-resolver: 1.12.2 unrs-resolver: 1.12.2
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]) eslint-plugin-import: 2.32.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])(eslint-import-resolver-[email protected]([email protected](@typescript-eslint/[email protected]([email protected])(typescript@5.9.3))([email protected]))([email protected]))([email protected]): [email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))(eslint-import-resolver-typescript@3.10.1)([email protected]([email protected]))([email protected]):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7([email protected])
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.64.0([email protected])([email protected]) '@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
eslint: 8.57.1 eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10([email protected])
eslint-import-resolver-typescript: 3.10.1([email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected]))([email protected]) eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected]): [email protected](@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
array.prototype.findlastindex: 1.2.6 array.prototype.findlastindex: 1.2.6
array.prototype.flat: 1.3.3 array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
debug: 3.2.7 debug: 3.2.7([email protected])
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 8.57.1 eslint: 8.57.1([email protected])
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10([email protected])
eslint-module-utils: 2.14.0(@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])(eslint-import-resolver-[email protected]([email protected](@typescript-eslint/[email protected]([email protected])(typescript@5.9.3))([email protected]))([email protected]))([email protected]) eslint-module-utils: 2.14.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))(eslint-import-resolver-typescript@3.10.1)([email protected]([email protected]))([email protected])
hasown: 2.0.4 hasown: 2.0.4
is-core-module: 2.16.2 is-core-module: 2.16.2
is-glob: 4.0.3 is-glob: 4.0.3
@@ -4415,13 +4419,13 @@ snapshots:
string.prototype.trimend: 1.0.10 string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.64.0([email protected])([email protected]) '@typescript-eslint/parser': 8.64.0([email protected]([email protected]))([email protected])([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
[email protected]([email protected]): [email protected]([email protected]([email protected])):
dependencies: dependencies:
aria-query: 5.3.2 aria-query: 5.3.2
array-includes: 3.1.9 array-includes: 3.1.9
@@ -4431,7 +4435,7 @@ snapshots:
axobject-query: 4.1.0 axobject-query: 4.1.0
damerau-levenshtein: 1.0.8 damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2 emoji-regex: 9.2.2
eslint: 8.57.1 eslint: 8.57.1([email protected])
hasown: 2.0.4 hasown: 2.0.4
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
language-tags: 1.0.9 language-tags: 1.0.9
@@ -4440,11 +4444,11 @@ snapshots:
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1 string.prototype.includes: 2.0.1
[email protected]([email protected]): [email protected]([email protected]([email protected])):
dependencies: dependencies:
eslint: 8.57.1 eslint: 8.57.1([email protected])
[email protected]([email protected]): [email protected]([email protected]([email protected])):
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
array.prototype.findlast: 1.2.5 array.prototype.findlast: 1.2.5
@@ -4452,7 +4456,7 @@ snapshots:
array.prototype.tosorted: 1.1.4 array.prototype.tosorted: 1.1.4
doctrine: 2.1.0 doctrine: 2.1.0
es-iterator-helpers: 1.4.0 es-iterator-helpers: 1.4.0
eslint: 8.57.1 eslint: 8.57.1([email protected])
estraverse: 5.3.0 estraverse: 5.3.0
hasown: 2.0.4 hasown: 2.0.4
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
@@ -4475,20 +4479,20 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: [email protected]([email protected]):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1([email protected]) '@eslint-community/eslint-utils': 4.9.1([email protected]([email protected]))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/eslintrc': 2.1.4 '@eslint/eslintrc': 2.1.4([email protected])
'@eslint/js': 8.57.1 '@eslint/js': 8.57.1
'@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/config-array': 0.13.0([email protected])
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
'@nodelib/fs.walk': 1.2.8 '@nodelib/fs.walk': 1.2.8
'@ungap/structured-clone': 1.3.3 '@ungap/structured-clone': 1.3.3
ajv: 6.15.0 ajv: 6.15.0
chalk: 4.1.2 chalk: 4.1.2
cross-spawn: 7.0.6 cross-spawn: 7.0.6
debug: 4.4.3 debug: 4.4.3([email protected])
doctrine: 3.0.0 doctrine: 3.0.0
escape-string-regexp: 4.0.0 escape-string-regexp: 4.0.0
eslint-scope: 7.2.2 eslint-scope: 7.2.2
@@ -5055,9 +5059,9 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7([email protected]) react-dom: 19.2.7([email protected])
[email protected]0([email protected]([email protected]))([email protected]): [email protected]4([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 15.5.20 '@next/env': 15.5.24
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001805 caniuse-lite: 1.0.30001805
postcss: 8.4.31 postcss: 8.4.31
@@ -5065,14 +5069,14 @@ snapshots:
react-dom: 19.2.7([email protected]) react-dom: 19.2.7([email protected])
styled-jsx: 5.1.6([email protected]) styled-jsx: 5.1.6([email protected])
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 15.5.20 '@next/swc-darwin-arm64': 15.5.24
'@next/swc-darwin-x64': 15.5.20 '@next/swc-darwin-x64': 15.5.24
'@next/swc-linux-arm64-gnu': 15.5.20 '@next/swc-linux-arm64-gnu': 15.5.24
'@next/swc-linux-arm64-musl': 15.5.20 '@next/swc-linux-arm64-musl': 15.5.24
'@next/swc-linux-x64-gnu': 15.5.20 '@next/swc-linux-x64-gnu': 15.5.24
'@next/swc-linux-x64-musl': 15.5.20 '@next/swc-linux-x64-musl': 15.5.24
'@next/swc-win32-arm64-msvc': 15.5.20 '@next/swc-win32-arm64-msvc': 15.5.24
'@next/swc-win32-x64-msvc': 15.5.20 '@next/swc-win32-x64-msvc': 15.5.24
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getEnergyKcal, getNutrientValue } from "../lib/nutrition.ts";
import { formatNutritionData, sortProducts, type Product } from "../lib/openFoodFactsApi.ts";
test("prefere energia explícita em kcal e preserva zero", () => {
assert.equal(getEnergyKcal({ "energy-kcal_100g": 539, energy_100g: 2255 }), 539);
assert.equal(getEnergyKcal({ "energy-kcal_100g": 0, energy_100g: 418.4 }), 0);
assert.equal(getNutrientValue(0), 0);
assert.equal(getNutrientValue("0"), 0);
});
test("converte kJ para kcal quando o campo kcal está ausente", () => {
assert.ok(Math.abs(getEnergyKcal({ energy_100g: 418.4 })! - 100) < 1e-10);
assert.ok(Math.abs(getEnergyKcal({ "energy-kj_100g": 418.4 })! - 100) < 1e-10);
assert.equal(getEnergyKcal(), null);
assert.equal(getEnergyKcal({}), null);
});
test("valores ausentes, negativos e não finitos não entram nos cálculos", () => {
for (const value of [null, undefined, "", " ", "garbage", "12g", true, -1, NaN, Infinity, "Infinity"]) {
assert.equal(getNutrientValue(value), null);
}
assert.equal(getEnergyKcal({ "energy-kcal_100g": NaN, energy_100g: Infinity }), null);
assert.equal(getNutrientValue("12.5"), 12.5);
});
test("formata energia e nutrientes com zero sem confundir com dados ausentes", () => {
const formatted = formatNutritionData({ code: "zero", product_name: "Zero", nutriments: {
"energy-kcal_100g": 0, energy_100g: 100, fat_100g: 0,
carbohydrates_100g: 0, sodium_100g: 0,
} });
assert.equal(formatted?.energy, "0 kcal");
assert.equal(formatted?.fat, "0.0g");
assert.equal(formatted?.carbs, "0.0g");
assert.equal(formatted?.carbohydrates, "0.0g");
assert.equal(formatted?.sodium, "0.00g");
assert.equal(formatted?.proteins, "N/A");
});
test("formata kcal explícitas e converte o fallback em kJ", () => {
const base = { code: "a", product_name: "A" };
assert.equal(formatNutritionData({ ...base, nutriments: { "energy-kcal_100g": 539, energy_100g: 2255 } })?.energy, "539 kcal");
assert.equal(formatNutritionData({ ...base, nutriments: { energy_100g: 418.4 } })?.energy, "100 kcal");
assert.equal(formatNutritionData({ ...base, nutriments: { fat_100g: Infinity } })?.fat, "N/A");
});
test("ordena por kcal, preserva zero e deixa dados ausentes no fim nas duas direções", () => {
const products: Product[] = [
{ code: "unknown", product_name: "Unknown" },
{ code: "kj", product_name: "kJ", nutriments: { energy_100g: 418.4 } },
{ code: "zero", product_name: "Zero", nutriments: { "energy-kcal_100g": 0 } },
{ code: "kcal", product_name: "kcal", nutriments: { "energy-kcal_100g": 50 } },
];
assert.deepEqual(sortProducts(products, "energy").map(p => p.code), ["zero", "kcal", "kj", "unknown"]);
assert.deepEqual(sortProducts(products, "energy", "desc").map(p => p.code), ["kj", "kcal", "zero", "unknown"]);
assert.equal(products[0].code, "unknown");
});
+1
View File
@@ -9,6 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",