A saída da "tutorial hell" é construir algo seu, pequeno e útil, e terminá-lo. Tutorial hell é o nome que muita gente dá ao ciclo de assistir a um curso depois do outro sem nunca construir nada sem a mão do instrutor. Este é o sexto artigo da trilha "Quero virar dev em casa", e você vai construir um projeto completo, o Meu Radar de Vagas, que já é útil para quem está procurando a primeira vaga. Pré-requisitos: JavaScript e Git e GitHub.

Para quem é este artigo

Para quem já fez a lista de tarefas e agora quer um projeto próprio. Tempo de estudo sugerido: de 8 a 15 horas ao longo de duas semanas (estimativa da Redação).

O que você vai aprender

  1. Por que projetos importam e como escolher o primeiro.
  2. Como dividir um projeto em etapas.
  3. Como usar IA sem trapacear no aprendizado.
  4. Como construir o Meu Radar de Vagas, com três cuidados de segurança.
  5. Como documentar e publicar.

O que é a tutorial hell (explicação da Redação)

É quando você estuda muito, entende o que o instrutor faz e, ao abrir um editor vazio, não sabe por onde começar. O sintoma clássico: você consegue acompanhar, mas não consegue criar. A causa, em geral, é que acompanhar um tutorial exige reconhecer, e criar exige decidir. Decidir é uma habilidade que só se treina decidindo.

Por que projetos pesam mais do que certificados

A Redação não encontrou nenhuma fonte oficial com dados sobre o que os recrutadores valorizam, e por isso não vai afirmar que "projetos valem mais do que certificados" como fato. O que dá para dizer com fonte: os currículos gratuitos mais conhecidos são organizados em torno de projetos. O The Odin Project, por exemplo, diz que o aluno constrói "dezenas de projetos" que podem entrar no portfólio. E o motivo prático é seu: um projeto publicado mostra o que você sabe fazer, e um certificado só diz que você assistiu.

Como escolher o primeiro projeto (critérios da Redação)

Um bom primeiro projeto:

  1. Resolve um problema seu. Você vai querer terminar e vai notar quando estiver ruim.
  2. É pequeno. Cabe em uma ou duas semanas, e não em três meses.
  3. Tem começo, meio e fim claros. Você sabe dizer quando está pronto.
  4. Usa o que você já estudou, com um assunto novo no máximo.
  5. Dá para mostrar. Uma página publicada, com endereço.
NívelIdeia de projetoO que treina
IniciantePágina pessoalHTML, CSS e GitHub Pages
InicianteLista de tarefasDOM, eventos e armazenamento
InicianteConversor de moedasfetch e APIs
IntermediárioBuscador de filmesAPIs, JavaScript e interface
IntermediárioControle financeiro pessoalDados, gráficos e CRUD
IntermediárioBlog pessoalComponentes e rotas, com um framework
AvançadoAplicação full stackServidor, banco de dados e autenticação

Esta é uma lista de ideias, e não um currículo oficial. O projeto deste artigo fica entre a lista de tarefas e o controle financeiro.

Como dividir um projeto em etapas

Projeto grande assusta, e o remédio é dividir em entregas pequenas, cada uma funcionando. Para o Radar de Vagas:

  1. Só a página: formulário e lista, com dados fixos no código.
  2. Adicionar de verdade: o formulário cria uma vaga e ela aparece.
  3. Guardar: os dados sobrevivem ao recarregar (localStorage).
  4. Mudar o status e remover.
  5. Filtrar por status e mostrar contadores.
  6. Exportar em CSV.
  7. Proteger: tratar os casos esquisitos (HTML malicioso, links estranhos, fórmulas).
  8. Documentar e publicar.

Faça um commit a cada etapa funcionando. É a prática do artigo anterior, aplicada a um projeto que é seu.

Como usar IA sem trapacear (regras da Redação)

IA pode ser uma ótima tutora, ou a forma mais rápida de não aprender nada. As regras:

  • Tente primeiro, pergunte depois. Escreva sua tentativa, mesmo errada, e só então peça ajuda.
  • Peça explicação, não a resposta pronta: "por que este erro acontece?", "o que esta linha faz?", "que pergunta eu deveria me fazer aqui?".
  • Nunca cole código que você não consegue explicar. Se não explica linha a linha, não é seu ainda.
  • Peça revisão do que você escreveu, e não geração do projeto inteiro.
  • Não coloque dados pessoais nem segredos (chaves, senhas) nas conversas. Veja é seguro colar dados em IA.
  • Desconfie. IA pode inventar funções que não existem. Confirme na documentação (MDN, por exemplo).

Para ferramentas de estudo, veja qual IA é melhor para estudar.

O projeto: Meu Radar de Vagas

O app permite cadastrar vagas (empresa, cargo, link, status e data), mudar o status, filtrar, remover, guardar os dados no navegador e exportar em CSV. Crie três arquivos na mesma pasta: radar.html, radar.css e radar.js.

radar.html

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width" />
  <title>Meu Radar de Vagas</title>
  <link rel="stylesheet" href="radar.css" />
</head>
<body>
  <main>
    <h1>Meu Radar de Vagas</h1>
    <p class="aviso">Os dados ficam só neste navegador. Não coloque informações sensíveis.</p>

    <form id="formulario">
      <label>Empresa <input id="empresa" required maxlength="60" /></label>
      <label>Cargo <input id="cargo" required maxlength="60" /></label>
      <label>Link da vaga <input id="link" type="url" placeholder="https://..." /></label>
      <label>Status
        <select id="status">
          <option value="interesse">Tenho interesse</option>
          <option value="candidatado">Candidatado</option>
          <option value="entrevista">Entrevista</option>
          <option value="finalizado">Finalizado</option>
        </select>
      </label>
      <label>Data <input id="data" type="date" required /></label>
      <button type="submit">Adicionar vaga</button>
    </form>

    <div class="filtros" id="filtros"></div>
    <ul id="lista"></ul>
    <p id="resumo"></p>
    <button id="exportar" type="button">Exportar CSV</button>
  </main>
  <script src="radar.js"></script>
</body>
</html>

radar.css

body { margin: 0; font-family: system-ui, sans-serif; background: #f5f7fa; color: #1f2933; }
main { max-width: 44rem; margin: 0 auto; padding: 1.5rem; }
.aviso { font-size: 0.875rem; color: #52606d; }
form { display: grid; gap: 0.75rem; padding: 1rem; background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }
label { display: grid; gap: 0.25rem; font-weight: 600; font-size: 0.9rem; }
input, select, button { padding: 0.5rem; font: inherit; }
button { cursor: pointer; }
@media (width >= 40rem) { form { grid-template-columns: 1fr 1fr; } form button { grid-column: 1 / -1; } }
.filtros { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 1rem 0; }
.filtros button[aria-pressed="true"] { background: #102a43; color: white; }
ul { padding: 0; list-style: none; }
li { display: grid; gap: 0.25rem; padding: 0.75rem 0; border-bottom: 1px solid #d9e2ec; }
li .titulo { font-weight: 700; }
li .linha { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; font-size: 0.9rem; }

radar.js

const STATUS = {
  interesse: "Tenho interesse",
  candidatado: "Candidatado",
  entrevista: "Entrevista",
  finalizado: "Finalizado",
};

const formulario = document.querySelector("#formulario");
const campoData = document.querySelector("#data");
const lista = document.querySelector("#lista");
const resumo = document.querySelector("#resumo");
const filtros = document.querySelector("#filtros");

let vagas = JSON.parse(localStorage.getItem("radar-vagas") || "[]");
let filtro = "todas";

campoData.value = new Date().toLocaleDateString("sv-SE"); // aaaa-mm-dd, no fuso do usuário

function salvar() {
  localStorage.setItem("radar-vagas", JSON.stringify(vagas));
}

// Aceita só endereços http e https. Qualquer outra coisa (como "javascript:") é descartada.
function linkSeguro(texto) {
  try {
    const url = new URL(texto);
    return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
  } catch {
    return "";
  }
}

// Planilhas tratam células que começam com = + - @ (ou tab, quebra de linha e versões de largura total) como fórmula.
// Um apóstrofo na frente neutraliza. Nenhum método é à prova de tudo; veja a nota do OWASP no artigo.
function csvCelula(valor) {
  const texto = String(valor ?? "");
  const seguro = /^[=+\-@\t\r\n=+-@]/.test(texto) ? `'${texto}` : texto;
  return `"${seguro.replace(/"/g, '""')}"`;
}

function gerarCsv(lista) {
  const cabecalho = ["Empresa", "Cargo", "Link", "Status", "Data"];
  const linhas = lista.map((v) => [v.empresa, v.cargo, v.link, STATUS[v.status], v.data]);
  return "" + [cabecalho, ...linhas].map((l) => l.map(csvCelula).join(",")).join("\r\n") + "\r\n";
}

function desenharFiltros() {
  filtros.textContent = "";
  for (const chave of ["todas", ...Object.keys(STATUS)]) {
    const botao = document.createElement("button");
    botao.type = "button";
    const total = chave === "todas" ? vagas.length : vagas.filter((v) => v.status === chave).length;
    botao.textContent = `${chave === "todas" ? "Todas" : STATUS[chave]} (${total})`;
    botao.setAttribute("aria-pressed", String(chave === filtro));
    botao.addEventListener("click", () => {
      filtro = chave;
      desenhar();
    });
    filtros.appendChild(botao);
  }
}

function desenhar() {
  desenharFiltros();
  lista.textContent = "";
  const visiveis = vagas.filter((v) => filtro === "todas" || v.status === filtro);

  for (const vaga of visiveis) {
    const item = document.createElement("li");

    const titulo = document.createElement("span");
    titulo.className = "titulo";
    titulo.textContent = `${vaga.cargo} · ${vaga.empresa}`;

    const linha = document.createElement("div");
    linha.className = "linha";

    const seletor = document.createElement("select");
    seletor.setAttribute("aria-label", `Status: ${vaga.cargo}`);
    for (const [valor, rotulo] of Object.entries(STATUS)) {
      const opcao = document.createElement("option");
      opcao.value = valor;
      opcao.textContent = rotulo;
      opcao.selected = valor === vaga.status;
      seletor.appendChild(opcao);
    }
    seletor.addEventListener("change", () => {
      vaga.status = seletor.value;
      salvar();
      desenhar();
    });

    const data = document.createElement("span");
    data.textContent = vaga.data;
    linha.append(seletor, data);

    if (vaga.link) {
      const a = document.createElement("a");
      a.href = vaga.link;
      a.textContent = "Abrir vaga";
      a.target = "_blank";
      a.rel = "noopener noreferrer";
      linha.appendChild(a);
    }

    const remover = document.createElement("button");
    remover.type = "button";
    remover.textContent = "Remover";
    remover.addEventListener("click", () => {
      vagas = vagas.filter((v) => v.id !== vaga.id);
      salvar();
      desenhar();
    });
    linha.appendChild(remover);

    item.append(titulo, linha);
    lista.appendChild(item);
  }

  resumo.textContent = `${vagas.length} vaga(s) no radar`;
}

formulario.addEventListener("submit", (evento) => {
  evento.preventDefault();
  const empresa = document.querySelector("#empresa").value.trim();
  const cargo = document.querySelector("#cargo").value.trim();
  if (empresa === "" || cargo === "") return;

  vagas.push({
    id: crypto.randomUUID(),
    empresa,
    cargo,
    link: linkSeguro(document.querySelector("#link").value.trim()),
    status: document.querySelector("#status").value,
    data: campoData.value,
  });
  salvar();
  formulario.reset();
  campoData.value = new Date().toLocaleDateString("sv-SE");
  desenhar();
});

document.querySelector("#exportar").addEventListener("click", () => {
  const blob = new Blob([gerarCsv(vagas)], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "radar-de-vagas.csv";
  a.click();
  URL.revokeObjectURL(url);
});

desenhar();

Como a Redação testou

A Redação abriu o app no Chrome, por um servidor local, e verificou: o formulário recusa empresa e cargo vazios, as vagas são adicionadas, o filtro mostra só o status escolhido e os contadores batem, o status muda e fica salvo, a remoção funciona e tudo continua lá depois de recarregar a página. A data padrão do formulário usa o fuso do usuário (a Redação comparou com a data local do computador).

Dica que evita um bug clássico: new Date().toISOString() devolve a data em UTC, e à noite, no Brasil, ela já é a do dia seguinte. Por isso o código usa toLocaleDateString("sv-SE"), que devolve aaaa-mm-dd no fuso do usuário. O MDN diz que o valor de um campo type="date" é sempre normalizado no formato aaaa-mm-dd, mesmo que o navegador o exiba no formato local.

Três cuidados de segurança que viram lição

Este projeto tem poucas linhas, mas ensina três hábitos que valem para qualquer site.

1. Nunca monte HTML com o que o usuário digitou

No código, os textos entram com textContent, e não com innerHTML. O MDN desaconselha usar innerHTML para texto, porque ele lida com HTML bruto e pode ficar suscetível a ataques XSS; já definir textContent coloca um único nó de texto. Teste feito: a Redação cadastrou uma empresa chamada <img src=x onerror=window.__xss=1>, e ela apareceu como texto, sem executar nada.

2. Só aceite links http e https

Um campo de link pode receber javascript:alert(1), que, em um link clicável, executa código. A função linkSeguro usa new URL() e só aceita https: e http:. Teste feito: javascript:, ftp:, texto que não é endereço e campo vazio foram descartados, e https://ok.com/a?b=1 passou. Os links abrem com rel="noopener noreferrer".

3. Cuidado com planilhas: a injeção de fórmula

Se um texto digitado começa com =, uma planilha pode executá-lo como fórmula ao abrir o CSV. A OWASP chama isso de CSV injection. Os sinais que podem iniciar uma fórmula são =, +, -, @, tabulação, retorno de carro e quebra de linha, além de versões de largura total desses sinais. A mitigação indicada é cercar o campo com aspas duplas, pôr um apóstrofo na frente e dobrar as aspas internas, o que a função csvCelula faz. Teste feito: a Redação testou onze entradas, entre elas =1+2, +55 11, -3, @SOMA(A1), tabulação, quebra de linha e =1+2, e as que começam com sinal de fórmula saíram com o apóstrofo na frente; o texto normal, as aspas internas (dobradas) e um = no meio do texto (a=b) ficaram corretos.

O limite honesto: a própria OWASP avisa que o Excel pode remover as aspas ao reabrir o arquivo e que nenhum método serve para todos os programas. A Redação não abriu o CSV exportado em Excel, Google Sheets nem LibreOffice. Então trate a proteção como uma camada, e não como garantia.

Um erro que a Redação cometeu e corrigiu: a primeira versão da função só tratava =, +, -, @, tabulação e retorno de carro. Ao conferir a lista da OWASP, faltavam a quebra de linha e os sinais de largura total. A versão acima já os inclui. Lição: confira a fonte oficial antes de achar que terminou.

Vídeos para estudar

Os quatro vídeos foram verificados quanto ao título, ao canal, à duração, à data e à possibilidade de incorporação. A Redação não os assistiu na íntegra nem verificou o que dizem.

Em português, com cerca de 8 minutos, publicado em 11 de maio de 2021. Ideias de projetos para praticar. Tem mais de cinco anos; use como inspiração. Nível: iniciante.
Vídeo: Attekita Dev
Em inglês, com cerca de 5 minutos, publicado em 28 de março de 2023. Um passo a passo curto para sair do ciclo de tutoriais. Nível: iniciante.
Vídeo: bigboxSWE
Em inglês, com cerca de 14 minutos, publicado em 6 de outubro de 2025. A descrição divulga produtos de terceiros com desconto; trate como opinião do criador. Nível: iniciante.
Vídeo: Developete
Em inglês, com cerca de 8 horas e 24 minutos, publicado em 9 de junho de 2020. Curso longo com 15 projetos em JavaScript puro; use por partes, para praticar. Tem mais de seis anos. Nível: iniciante a intermediário.
Vídeo: freeCodeCamp.org

Exercícios práticos

  1. Campo novo: acrescente "Salário pretendido" ou "Observações" ao formulário, ao armazenamento e ao CSV.
  2. Editar vaga: permita editar empresa e cargo.
  3. Ordenar: ordene as vagas por data, da mais recente para a mais antiga.
  4. Lembrete: mostre em destaque vagas "Candidatado" há mais de 7 dias, sem resposta.
  5. Importar CSV: pesquise como ler um arquivo escolhido pelo usuário e carregue as vagas dele (tratando erros).
  6. Teste de segurança seu: tente cadastrar <script>alert(1)</script> e javascript:alert(1) e confirme que nada executa.

Como documentar o projeto

Leve o projeto para o GitHub com a prática do artigo anterior:

  • README com o que o app faz, como usar, uma captura de tela ou GIF e o link da demonstração.
  • Commits a cada etapa, com mensagens claras.
  • Demonstração no GitHub Pages, como no artigo de HTML e CSS. Lembre que o app guarda dados só no navegador de quem usa: não coloque informações sensíveis, e deixe isso escrito na página, como no exemplo.
  • Uma seção "O que aprendi", com os três cuidados de segurança acima.

Erros comuns de iniciantes

  • Escolher um projeto grande demais e abandonar na metade.
  • Começar pelo visual e deixar a função para depois.
  • Não guardar os dados e achar que o app está pronto.
  • Colar código de IA sem entender.
  • Montar HTML com texto do usuário (innerHTML).
  • Confiar em campo de link sem validar.
  • Não testar o caso esquisito: vazio, texto enorme, caracteres estranhos.
  • Esperar o projeto ficar perfeito para publicar. Publique a versão que funciona e melhore.

Checklist de conclusão

  • Escolhi um projeto pequeno, que resolve um problema meu.
  • Dividi o projeto em etapas e fiz um commit a cada uma.
  • Adicionei, filtrei, mudei o status e removi vagas.
  • Os dados continuam lá depois de recarregar a página.
  • Exportei o CSV e conferi o arquivo.
  • Testei HTML malicioso, link javascript: e texto com = no começo.
  • Escrevi o README e publiquei a demonstração.
  • Consigo explicar cada linha do código.

Próximo passo da trilha

O próximo artigo trata do que costuma derrubar mais gente do que qualquer linguagem: como estudar programação sem desistir.

Perguntas frequentes

O que é tutorial hell?

É o nome popular para o ciclo de assistir a tutoriais sem conseguir criar nada por conta própria. A definição desta matéria é da Redação.

Por que fazer projetos em vez de só cursos?

Porque um projeto exige decidir, e decidir só se aprende decidindo. Além disso, um projeto publicado mostra o que você sabe fazer. Cursos conhecidos, como o The Odin Project, são organizados em torno de projetos.

Qual deve ser o meu primeiro projeto?

Um que resolva um problema seu, seja pequeno (uma ou duas semanas), tenha fim claro e use o que você já estudou, com no máximo um assunto novo. O Meu Radar de Vagas é um exemplo.

Posso usar IA para ajudar no projeto?

Pode, como tutora: para explicar erros e revisar o que você escreveu. Não use para gerar o projeto inteiro, e nunca cole código que você não consegue explicar.

Por que não usar innerHTML com o que o usuário digita?

Porque ele trata o texto como HTML bruto e pode ficar suscetível a ataques XSS, segundo o MDN. Use textContent, que insere só texto.

O que é CSV injection?

É quando um texto digitado, que começa com sinais como =, entra em um CSV e uma planilha o executa como fórmula. A OWASP indica cercar o campo com aspas e pôr um apóstrofo na frente, mas avisa que nenhum método serve para todos os programas.

Onde guardo os dados de um app sem servidor?

No localStorage, como no projeto. Ele guarda só no navegador de quem usa e não deve receber informações sensíveis.

Fontes