Versão da aplicação: v3.23.0 URL de produção: https://qzte.github.io/supermercados/
Não editar index.html à mão. É produzido a partir de src/index.src.html e qualquer alteração directa é perdida no build seguinte. O ficheiro que se edita é o src/index.src.html.
Os ficheiros servidos estão todos na raiz — a app faz fetch() de caminhos relativos sem subpastas. As fontes e as ferramentas ficam à parte.
supermercados/
│
│ ── EDITAR ────────────────────────────────────────────────
├── src/index.src.html # ✏️ a aplicação (contém JSX)
├── tools/build.mjs # compila o JSX
├── workflow-default.json # template do workflow — 7 fases
├── email-template.json # templates de e-mail ⚠ minúsculas
├── supermercados_backup.json # dados dos processos
│
│ ── TERCEIROS — substituir, não editar ───────────────────
├── xlsx.full.min.js # 📦 SheetJS 0.20.3 (hash fixado no validate)
│
│ ── GERADO — não editar ──────────────────────────────────
├── index.html # 🤖 aplicação servida
├── supermercados_<versão>.html # 🤖 cópia arquivada (idêntica)
│
├── .github/workflows/build.yml # compila e faz commit no push
├── .github/workflows/ci.yml # valida os pull requests
├── tools/validate.mjs # valida os ficheiros servidos
├── tools/lint.mjs # lint do código da aplicação
├── tests/harness.mjs # carrega a app num contexto Node
├── tests/unit.mjs # testes sem browser (node:test)
├── tests/browser.mjs # testes em Chromium (Playwright)
├── package.json # dependências do build
├── .nojekyll # desativa o processamento Jekyll
└── README.md # este ficheiro
⚠️ Case-sensitive. O GitHub Pages distingue maiúsculas de minúsculas. O ficheiro tem de se chamaremail-template.json—EMAIL-template.jsondevolve 404 e a app cai no fallback inline.
.nojekyllé obrigatório — sem ele o Jekyll ignora ficheiros iniciados por_e pode interferir com o servir dos JSON.
- Criar o repositório
supermercadosna contaqzte(público). - Copiar todos os ficheiros acima para a raiz do repositório.
Settings→Pages→ Source:Deploy from a branch→ Branch:main// (root).- Aguardar 1–2 min pela primeira publicação.
- Abrir https://qzte.github.io/supermercados/
- Editar
src/index.src.html. - Ao mudar de versão, actualizar os dois sítios onde ela aparece —
const APP_VERSIONe o badge no cabeçalho. O build recusa-se a correr se divergirem. - Commit + push. A Action compila e faz commit do
index.htmle da cópia versionada; o Pages republica.
O nome do ficheiro arquivado deriva de APP_VERSION, e a cópia da versão anterior é removida automaticamente — deixa de ser preciso fazê-lo à mão.
Podes editar
src/index.src.htmlpela interface web do GitHub. A Action trata do resto; oindex.htmlaparece num segundo commit, cerca de um minuto depois.
npm ci
npm run build # gera index.html + cópia versionada
npm run check # verifica se estão actualizados, sem escrever
npm run validate # valida os ficheiros JSON servidos
npm run lint # lint do código da aplicação (regras de bugs, não de estilo)
npm test # testes unitários (~1 s, sem dependências novas)
npm run ci # validate + lint + test + check (segundos)
npm run test:browser # testes em Chromium — instala o Playwright, leva minutos
npm run ci:full # ci + testes de browser — a mesma cobertura que o CI dá em dois jobsnpm run check é o que evita que o index.html fique dessincronizado da fonte.
Duas Actions, com papéis separados de propósito:
| Workflow | Quando corre | O que faz | Permissões |
|---|---|---|---|
build.yml |
push que toque em src/, tools/ ou package*.json |
compila e faz commit dos ficheiros gerados | contents: write |
ci.yml |
todos os pull requests e push para main |
apenas verifica; não escreve nada | contents: read |
O ci.yml existe para fechar uma lacuna concreta: uma alteração só a
workflow-default.json ou a email-template.json não aciona o build.yml,
porque não está nos caminhos do gatilho. Eram por isso os únicos ficheiros a
chegar a main sem verificação nenhuma — e são precisamente os que falham em
silêncio, porque a app cai num fallback inline em vez de dar erro. O
workflow-default.json já esteve meses em esquema legado, servido mas nunca
lido, sem ninguém dar por isso.
O que o ci.yml verifica:
npm run validate— os três ficheiros que a app vai buscar porfetch(): nome exacto (o Pages é case-sensitive), JSON válido, ids de fase e de passo únicos, campos obrigatórios dos templates de e-mail, e a referência cruzada entre os dois — umemailKeysem template correspondente não dá erro nenhum em runtime, o botão "Ver template de e-mail" simplesmente não faz nada. Valida ainda o envelope do backup e os ícones do manifest.npm run lint— extrai os dois blocos<script>desrc/index.src.htmle aplica regras que detectam bugs, não estilo: entre elas oreact-hooks/exhaustive-deps, que apanha sozinho a classe de erro dos PRs #76 e #77 (dependência em falta numuseCallback), e ano-dupe-keys, que já encontrou uma chavecursorrepetida num botão. Ver Lint abaixo.npm run build— compila o JSX. Falha se o JSX estiver partido, seAPP_VERSIONe o badge do cabeçalho divergirem, ou se oworkflow-default.jsonestiver em esquema legado.npm test— os testes unitários (ver abaixo).npm run check— só em PRs vindos de forks, onde obuild.ymlnão corre e os ficheiros gerados têm de vir já actualizados no próprio PR. Em PRs deste repositório a verificação seria uma corrida com o commit do bot, que sincroniza os ficheiros um minuto depois.
Correr npm run ci localmente reproduz os pontos 1, 2, 3 e 5.
Num segundo job, separado, correm os testes de browser. A separação é
deliberada: o Validar responde em segundos e é o que dá o sinal rápido em cada
push; o de browser descarrega um Chromium e leva minutos. Juntá-los faria toda a
gente esperar pelo lento para saber o resultado do rápido.
npm run lint (tools/lint.mjs). O código vive dentro de src/index.src.html,
em dois blocos <script>, e o ESLint não lê HTML — a ferramenta extrai os
blocos e linta-os como ficheiros soltos. O bloco JSX é isolado pela mesma
expressão que o tools/build.mjs usa: lintar algo diferente do que é
compilado não provaria nada sobre o que vai para produção. As linhas
comunicadas são as do src/index.src.html, já com o deslocamento somado.
Regras de bugs, não de estilo. Um preset completo traria centenas de avisos sobre código que já funciona, e um lint que ninguém lê é pior do que nenhum. Cada regra foi escolhida por detectar um defeito real e medida contra o código actual antes de entrar — o lint nasceu a zero achados, sem baseline nem excepções herdadas, por isso qualquer problema novo é mesmo novo.
O que já apanhou:
| Regra | Achado |
|---|---|
react-hooks/exhaustive-deps |
A classe de erro dos PRs #76 e #77 — dependência em falta num useCallback. Ambos foram encontrados à mão; é para não ser preciso repetir isso que o lint existe. |
no-dupe-keys |
Uma chave cursor repetida no botão de exportar Excel: a primeira era código morto, e os botões CSV e PDF ao lado já usavam a forma correcta. |
require-atomic-updates foi considerada e ficou de fora: dava 5 achados,
todos falsos positivos (e.target.value e iframe.style depois de um await),
porque não distingue estado partilhado de um nó do DOM.
Medir primeiro. Se der achados, ou se corrige o que ela encontrou no mesmo passo, ou a regra não entra — nunca uma lista de excepções, que é como um lint deixa de ser lido.
Duas camadas, porque provam coisas diferentes.
tests/unit.mjs |
tests/browser.mjs |
|
|---|---|---|
| Corre com | node:test nativo |
Playwright + Chromium |
| Dependências novas | nenhuma | Playwright, instalado à parte |
| Duração | ~1 s | minutos |
| Alvo | as funções de src/index.src.html |
o index.html construído |
| No CI | job Validar |
job Testes de browser |
| Localmente | npm run ci |
npm run test:browser |
Os dois juntos são o npm run ci:full. O npm run ci fica deliberadamente
rápido — é o que se corre a cada alteração; o ci:full é o que se corre antes de
abrir um PR.
Os testes correm o código real, não uma cópia. O tests/harness.mjs extrai
os dois blocos <script> da aplicação, compila o JSX com o mesmo Babel do build
e avalia-os num contexto vm. Uma cópia da lógica diverge da fonte sem avisar, e
um teste que valida uma cópia não prova nada sobre o que é publicado.
Um teste existe por uma de duas razões, e escreve no comentário qual é.
- Corresponde a uma falha que já aconteceu aqui — documentada no
SECURITY_AUDIT.md, neste ficheiro ou num comentário do código. Foi durante muito tempo o único critério, e continua a ser o principal: é a resposta ao achado A10, em que correcções de segurança deste repositório se perderam duas vezes por substituição de ficheiro. - É a rede de uma reestruturação planeada. O
KpisPanel(1200 linhas), oProcessDetail(890) e oApp(650) estão para ser partidos em componentes, e o editor de workflow imperativo para ser absorvido pelo React. Nada disso se faz com segurança sobre código que nenhum teste renderiza: um erro de render não falha nada — o React desmonta a árvore e a vista fica em branco, sem mensagem. Os grupos que percorrem as três vistas, os filtros, a ida e volta do backup, o refresh da sessão e o Plano Anual existem para isso.
O que continua a não haver é teste escrito por cobertura. Um teste que não saiba dizer que falha impede — passada ou futura — não entra.
O Node não tem DOMParser, e o sanitizeRichText — a defesa central do achado
A2 — depende dele: sem parser cai num ramo de recurso. Um teste de payloads em
Node passaria por esse ramo e daria uma garantia falsa sobre o filtro que
corre no browser. O harness não injecta um DOMParser de mentira, de
propósito; a sanitização de HTML, os templates de PDF e o percurso da interface
são cobertos em browser.mjs, contra um parser verdadeiro.
O valor desta separação foi medido: neutralizando o sanitizeRichText (a
devolver o HTML sem o filtrar), o npm run build e o npm test continuam os
dois a passar — as guardas do build só verificam que a função existe e é
chamada, não o que ela faz — e os 9 testes de XSS no browser ficam vermelhos.
É exactamente a lacuna que a segunda camada existe para fechar.
Escreve o comentário que diz que falha é que ele impede. Sem isso, o teste seguinte a ficar vermelho por uma razão legítima é apagado por não se perceber o que protegia.
Verifica também que ele falha quando a protecção é removida. Um teste que passa nos dois casos não é rede nenhuma — só ruído com aparência de segurança.
Aplica-se aos dois workflows:
- Actions fixadas por SHA, não por tag — uma tag pode ser reapontada para outro commit, e o CI passaria a executar código diferente sem que nada mudasse no repositório. Ao actualizar, trocar o SHA e o comentário com a versão que fica ao lado.
npm ci --ignore-scripts— o build precisa do Babel e o lint do ESLint, ambos como bibliotecas.persist-credentials: falsenocheckout. Sem isto o token fica gravado em.git/configdurante todo o job, incluindo enquanto correm onpm cie otools/build.mjs— passos que executam código do repositório e das suas dependências.
No build.yml, que é o único que escreve, o token existe apenas no ambiente do
passo de commit e o URL vai como argumento do git push (que, ao contrário do
git remote add, não grava nada). O refspec é explícito porque, sem credenciais
persistidas, não há upstream configurado.
O passo de commit/push esteve muitos meses sem nunca correr — quem edita tem
feito sempre o build localmente, pelo que o workflow encontrava "nada a fazer" e
saía antes do push. Foi exercitado de propósito em v3.15.6, com um commit que
alterava só o src/index.src.html: a Action regenerou o index.html, renomeou a
cópia arquivada e fez commit como github-actions[bot], e o resultado ficou byte
a byte igual ao de um build local. O caminho está confirmado a funcionar.
Se um dia falhar, falha de forma visível: o passo dá erro e o workflow fica vermelho — nunca deixa os ficheiros gerados dessincronizados em silêncio.
src/index.src.html contém ~5 500 linhas de JSX, que o browser não percebe. Sem build, cada carregamento de página descarregava o Babel (2,8 MB; 589 KB comprimidos) e traduzia essas linhas outra vez — trabalho idêntico, repetido em cada visita de cada utilizador, com a página em branco enquanto corria.
Tempo até a app estar utilizável (mediana de 3 execuções, bibliotecas locais):
| CPU | Sem build | Com build | |
|---|---|---|---|
| Desktop | 1 529 ms | 140 ms | 10,9× |
| Telemóvel médio (4× mais lento) | 4 958 ms | 456 ms | 10,9× |
| Telemóvel modesto (6×) | 7 044 ms | 809 ms | 8,7× |
Em produção a diferença é maior: o Babel vinha do cdnjs, pelo que desaparecem também 589 KB de transferência. O index.html cresce 28 KB (o JS compilado é mais verboso que o JSX) — troca amplamente favorável.
O ficheiro-fonte mantém o <script type="text/babel"> de propósito: abre e corre directamente no browser, sem passo de build, o que o mantém utilizável para testar alterações antes do commit.
Servida por HTTPS, a app resolve os fetch() relativos ao subpath /supermercados/:
| Ficheiro | Fetch no código | Efeito se ausente |
|---|---|---|
workflow-default.json |
?nocache= / ?v= |
fallback inline window.__SUPERMERCADOS_INLINE_DEFAULT |
email-template.json |
?v= |
fallback window.__SUPERMERCADOS_INLINE_EMAIL_TEMPLATES |
supermercados_backup.json |
?v= |
arranca vazio (BootScreen) |
Todos usam cache-busting por querystring — actualizações são apanhadas sem refresh forçado.
Em file:// os fetch() são bloqueados pelo protocolo e a app usa exclusivamente os fallbacks inline.
Os ficheiros de dados acima são relidos a cada arranque, mas o próprio HTML não: uma página aberta continua a correr a versão com que foi carregada até alguém a recarregar. Como a app fica aberta um turno inteiro, uma publicação nova podia passar horas sem chegar a quem está a usá-la — e o sintoma é enganador (dois postos com números diferentes, uma correcção que "não fez nada").
A app passou a avisar. O bloco avisoDeVersaoNova, no guarda de arranque de
src/index.src.html:
| Sinal | Quando dispara |
|---|---|
controllerchange |
um service worker novo assume uma página já aberta — o estado híbrido em que o HTML em memória é o antigo e quem serve os pedidos seguintes já é o worker novo |
| sondagem do documento | de 15 em 15 minutos, ao voltar ao separador e ao recuperar a ligação: vai buscar o HTML publicado com cache: 'no-store' e compara o badge de versão com o desta página |
Hoje a app não regista nenhum service worker, pelo que o primeiro sinal nunca dispara — está lá para que registar um não implique descobrir este problema outra vez. É a sondagem que faz o trabalho.
Quando há versão nova aparece um aviso discreto — «Nova versão disponível (vX.Y.Z)», com «Recarregar» e «Agora não». Nunca recarrega sozinho: deitaria fora o formulário meio preenchido de quem estivesse a escrever. Falhas de rede na sondagem são silenciosas, e o aviso fica por baixo do ecrã de arranque e dos modais, que são modais de propósito.
As duas versões comparadas saem ambas do badge #app-version-badge (o build já
recusa construir se ele divergir do APP_VERSION), por isso não há uma segunda
fonte de verdade a manter. Renomear esse id, ou perder o listener, desligava o
aviso sem erro nenhum — o tools/build.mjs (secção 1g) falha se isso acontecer.
Na primeira utilização a app pergunta pelos dois ficheiros — workflow e backup de dados — em vez de aplicar automaticamente o que está no repositório. O ecrã mostra nº de fases/passos e nº de processos e data de cada versão publicada, para a escolha ser informada.
A escolha fica memorizada em localStorage (supermercados:bootChoice), mas o que se guarda é a origem, nunca o conteúdo:
| Origem | Repetida sem perguntar? | Porquê |
|---|---|---|
publicada |
✅ | é relida do repositório em cada arranque — fresca por construção |
incluida |
✅ | vem do próprio ficheiro da app |
sem-backup |
✅ | não há nada a reler |
local |
❌ | um ficheiro do disco não pode ser relido sem nova interacção; guardar uma cópia sua seria precisamente o snapshot desactualizado que este ecrã evita |
Ou seja: quem usa as versões publicadas não volta a ser interrompido, e continua a receber dados actuais porque são refetchados. Quem usa um ficheiro local é sempre questionado — não há alternativa correcta. Se a releitura falhar (offline, ficheiro removido, formato mudado), o arranque volta a perguntar em vez de adivinhar.
A barra de topo do Tracking mostra a origem dos dados em title e tem um botão ⇄ trocar que reabre o ecrã de arranque.
A sessão persiste em sessionStorage — um refresh não regressa ao ecrã de arranque; fechar a aba limpa-a. Os processos e o plano anual são ambos guardados nessa sessão.
O workflow-default.json é a via para alterar o processo para toda a gente sem tocar no código: editar no editor de workflow, exportar, e fazer commit do ficheiro. O loadWorkflow dá-lhe precedência sobre a versão embutida na app (e o localStorage tem precedência sobre ambos, para não sobrescrever edições locais em curso).
⚠️ O ficheiro tem de estar no esquema actual — fases comid/code/color/labele passos comrole/instructions/emailKey. Um ficheiro em esquema legado é ignorado em silêncio: parece estar a ser servido, mas a app usa a versão embutida. Foi o que aconteceu durante meses. Onpm run buildpassa a falhar nesse caso, com a indicação do que corrigir.
O index.html traz uma <meta http-equiv="Content-Security-Policy"> logo a
seguir ao <meta charset>. Tem de ficar aí: uma meta CSP só governa o que
aparece depois dela, e o GitHub Pages não permite configurar cabeçalhos HTTP.
O npm run build falha se a meta desaparecer, se descer para depois do primeiro
<script>/<link>, ou se lhe faltar uma directiva essencial.
Há duas políticas, e é de propósito. A que está no src/index.src.html é a
de desenvolvimento: abrir esse ficheiro directamente compila o JSX com o
babel-standalone, que precisa de 'unsafe-eval' e do cdnjs. O npm run build
reescreve a script-src no index.html publicado, onde o Babel já não
existe. Ao alterar uma, alterar a outra.
O que a política publicada entrega:
script-src 'self' 'sha256-…' |
sem 'unsafe-inline' e sem 'unsafe-eval'. Cada um dos blocos inline é autorizado pelo seu próprio hash, pelo que um <script> ou onerror= injectado não corre |
connect-src 'self' |
corta a exfiltração — é o que um XSS faria a seguir a executar |
default-src 'none' |
nega por omissão tudo o que não esteja enumerado |
object-src · base-uri · form-action |
fechados, todos sem uso aqui |
Isto só é possível porque os 30 handlers em atributos passaram a
addEventListener (ver ligarControlosEstaticos) e a exportação de PDF deixou
de usar eval(). O npm run build falha se qualquer um dos dois voltar — um
onclick= no markup ou um eval() no código obrigariam a reabrir a política.
⚠️ Os hashes são calculados pelo build, nunca escritos à mão. Um hash errado não dá erro visível: o bloco é bloqueado e a aplicação não arranca, em silêncio. É por isso que os testes de browser escutamsecuritypolicyviolation— sem isso, uma CSP partida passava no CI.Pela mesma razão, um bloco
<script>novo no ficheiro-fonte falha o build até ter hash: são esperados exactamente 4 (guarda de arranque, PINs/templates, aplicação, JSX compilado), mais os dois dos templates de relatório, que herdam esta política por serem carregados emsrcdoc.
Ao acrescentar uma biblioteca ou uma origem nova, a lista de directivas tem de ser actualizada, senão o recurso é bloqueado em silêncio para o utilizador.
React, ReactDOM, jsPDF, html2canvas e SheetJS são todas servidas pela própria
aplicação (react.production.min.js, react-dom.production.min.js,
jspdf.umd.min.js, html2canvas.min.js, xlsx.full.min.js), não por CDN. Sem
terceiro capaz de substituir o ficheiro depois de publicado, e a app arranca e
exporta PDF/Excel sem rede, incluindo em file://. Se algum destes ficheiros
não carregar (falha ao servir, corrupção), a app mostra um ecrã de erro
explícito com botão de recarregar — em vez de uma página em branco. O mesmo
acontece se o arranque falhar por outro motivo ou demorar mais de 12 s.
O babel-standalone continua a vir de cdnjs.cloudflare.com, mas só é usado
ao abrir src/index.src.html directamente em desenvolvimento — o build.mjs
remove-o do index.html publicado, pelo que não é uma dependência de produção.
Para actualizar qualquer uma das cinco bibliotecas:
npm view <pacote>@<versão> dist.tarball(ou, para o SheetJS, descarregar de https://cdn.sheetjs.com), copiar o ficheirodist/umdde produção correcto, corrersha256sum <ficheiro>e actualizarversionesha256da entrada correspondente emtools/validate.mjsno mesmo commit. Onpm run validatefalha se algum ficheiro e o seu hash divergirem — uma troca silenciosa da biblioteca não passa despercebida. A versão publicada de SheetJS no cdnjs está congelada na 0.18.5, que tem duas vulnerabilidades conhecidas (CVE-2023-30533, prototype pollution; CVE-2024-22363, ReDoS) — o SheetJS saiu do npm e do cdnjs, e as versões corrigidas só existem fora deles.
Se existir uma versão editada em localStorage (workflowTemplate), esta tem precedência sobre o workflow-default.json servido — o editor local não é sobrescrito por uma versão potencialmente mais antiga do repositório.
Os templates seguem a mesma regra, e pela mesma razão. O editor grava-os dentro de workflowTemplate ({ emailTemplates, phases }), pelo que a ordem no arranque é:
workflowTemplate.emailTemplatesnolocalStorage— as edições do utilizador;email-template.jsonservido pelo repositório;emailTemplatesembutidos no workflow (inline default, usado emfile://).
São lidos independentemente das phases: um workflow guardado que seja rejeitado por inválido não leva os templates editados atrás.
Consequência prática: num browser onde já se editaram templates, uma actualização de email-template.json no repositório não é apanhada. É o comportamento pretendido (uma edição local nunca é desfeita em silêncio); para voltar à versão publicada, usar ↺ Repor original no editor de workflow, que limpa o localStorage.
O GitHub Pages é read-only. O supermercados_backup.json no repositório é apenas o estado inicial de leitura.
Fluxo de trabalho:
- Abrir a app → dados carregados do repositório.
Entrar→ modo Editor → PIN → editar. O PIN éSUPERMERCADO(emwindow.__SUPERMERCADOS_EDITOR_PINS) e ignora maiúsculas/minúsculas (supermercado=SUPERMERCADO).
O PIN não protege informação. Está em texto simples no ficheiro servido, que é público, e quem quiser contorná-lo contorna-o em segundos. Existe para obrigar a um acto consciente antes de se concluir passos do workflow — fricção deliberada, para que avançar um processo nunca seja um clique distraído. O que protege o estado oficial dos dados é o acesso de escrita a este repositório.
Não o transformar em autenticação: hashear ou cifrar o PIN no cliente é teatro, porque a chave viaja com a fechadura. E não o remover por parecer inútil — o travão é a funcionalidade.
O modo de edição expira uma hora depois de o PIN ser aceite. A contagem é absoluta e não por inactividade, de propósito: quem está a editar sem parar é precisamente quem deve voltar a parar. Expirar não perde trabalho — os processos continuam na sessão e o aviso ● não guardado mantém-se; o que desaparece são os controlos de edição, incluindo o 💾 Guardar backup, que voltam com o PIN.
3. 💾 Guardar backup → ficheiro descarregado localmente.
4. Commit do ficheiro actualizado sobre supermercados_backup.json.
⚠️ Não editar em simultâneo — o último commit sobrescreve.
O backup incluído neste pacote está vazio (version: 2, data: []) — arranque limpo.
Cancelar o diálogo de gravação não conta como gravar. O indicador ● não guardado só desaparece depois de o ficheiro ser mesmo escrito; se o diálogo do browser for fechado sem escolher destino, o aviso mantém-se e o alerta de fecho de página continua armado.
O plano anual viaja dentro do backup. Trocar de ficheiro — pelo ⇄ trocar ou pelo 📂 Carregar backup — substitui o plano pelo do ficheiro novo. Um backup sem plano deixa o plano vazio, em vez de herdar o do ficheiro anterior.
O id de cada processo é a chave usada para gravar e eliminar. Backups gerados por versões antigas podem conter ids repetidos (derivavam de Date.now(), igual para processos criados no mesmo milissegundo). No carregamento, os duplicados são reatribuídos — a primeira ocorrência mantém o id original. Sem isto, editar um processo reescrevia os homónimos e eliminá-lo eliminava-os todos.
O currentPhase de um percurso é um ordinal: nasce a 1, é incrementado de 1 em 1 e é limitado a phases.length. Metade do código lê-o como tal (phases[currentPhase - 1]), a outra metade compara-o com o id da fase (ph.id === currentPhase) — no stepper, na barra de fases e no painel de tempos — e o phaseLog grava o ordinal num campo chamado phaseId, consultado depois por timings[ph.id].
As duas leituras só coincidem enquanto o id de uma fase for a sua posição. Por isso o id que vem no JSON do workflow é descartado e reatribuído a partir do índice, tanto no buildPhasesFromData como no serializeWorkflow. Um workflow importado com ids fora de 1..N fazia o percurso avançar correctamente sem que nenhuma fase aparecesse como actual ou concluída. O tools/build.mjs recusa-se a construir se qualquer das duas funções voltar a aceitar o id do ficheiro.
Um processo pode ter só o percurso clínico ou só o de farmácia (roles.clinico / roles.farmacia). O tracking do percurso desactivado continua a existir no objecto, parado na fase 1. Qualquer contagem ou indicador tem por isso de verificar roles antes de olhar para o estado — caso contrário conta percursos que não existem: os totais 🏥/💊, a barra de distribuição por fase e o throughput a 30 dias já o fazem.
A app é utilizável em telemóvel a partir de 320 px de largura, sem scroll horizontal em nenhuma das três vistas. Abaixo de 900 px a barra de navegação passa a duas linhas e a variável --nav-h acompanha a mudança — qualquer elemento sticky deve usar top: var(--nav-h) em vez de um valor fixo.
As grelhas da app são definidas em estilos inline do React, que têm precedência sobre CSS normal. Por isso as media queries responsivas usam classes utilitárias com !important:
| Classe | Efeito |
|---|---|
.rg-2 … .rg-6 |
colapsa N colunas para 2 e depois 1, por patamares |
.rg-stats |
cartões de indicadores; mantém 2 colunas até 360 px |
.rp-pad |
reduz o padding lateral dos contentores principais |
.scroll-x |
põe conteúdo largo a rolar dentro do próprio contentor |
Ao adicionar uma grelha nova, aplicar a classe correspondente — não basta o estilo inline.
Do lado da acessibilidade: as três vistas são um tablist navegável por setas/Home/End, os controlos de filtro expõem aria-pressed, e o foco por teclado é sempre visível (:focus-visible). São respeitados prefers-reduced-motion e prefers-contrast.
Todas as modais são diálogos a sério — as cinco imperativas (PIN, PDF, e-mail, templates, pré-visualização) e as cinco React (StepDrawer, StepDatesModal, NewProcessModal, ExportModal, ServiceStepsModal). Todas têm role="dialog" e aria-modal, fecham com Escape, confinam o Tab e devolvem o foco ao elemento que as abriu. As imperativas são tratadas pelo modalAccessibility() do bloco <script>; as React pelo hook useModalA11y, que além disso as renderiza num portal para o <body> — sem isso, marcar #view-tracking como inert tornaria inerte também a modal, que é renderizada lá dentro. Uma modal nova tem de usar um dos dois; não há terceiro caminho.
Não usar alert() nem confirm(). São bloqueantes, não são estilizáveis e trazem, em vários browsers, a opção de «impedir que esta página crie mais diálogos» — uma vez marcada, o confirm() devolve false sem perguntar nada e as operações passam a desaparecer sem explicação. Em vez deles há duas funções, definidas no bloco <script> e disponíveis também no bloco JSX:
| Função | Para quê |
|---|---|
await askConfirm(mensagem, { titulo, confirmar, cancelar, tom }) |
pergunta de sim/não; devolve uma promessa, portanto o chamador é async |
notify(mensagem, tom) |
aviso sem resposta, em cartão no canto inferior direito |
O tom é 'info' | 'ok' | 'aviso' | 'erro'. A interface é desenhada pelo DialogHost, montado uma vez pelo App, que se regista em window.__SUPERMERCADOS_confirm e window.__SUPERMERCADOS_notify — é assim que o editor de workflow, que é código imperativo, usa os mesmos diálogos. Nas perguntas o foco arranca no Cancelar: ao contrário do confirm() nativo, um Enter dado à pressa não elimina nada.