# Seu primeiro projeto real: como sair dos tutoriais e criar o Meu Radar de Vagas, com código testado

> Assistir a cursos sem construir nada próprio é o ciclo que muita gente chama de tutorial hell. A saída é um projeto pequeno e seu. Veja como escolher, dividir em etapas e usar IA sem trapacear, e construa o Meu Radar de Vagas, um app que a Redação testou no navegador, incluindo três cuidados de segurança que viram lição.

- Editoria: Carreira
- Autor: Redação PushTech
- Publicado em: 2026-10-24T21:50:00-03:00
- URL canônica: https://pushtech.tech/carreira/seu-primeiro-projeto-real-como-sair-dos-tutoriais-e-criar-o-meu-radar-de-vagas

**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](/carreira/javascript-para-iniciantes-dom-eventos-fetch-e-sua-primeira-lista-de-tarefas) e [Git e GitHub](/carreira/git-e-github-para-iniciantes-commits-branches-pull-requests-e-seu-portfolio).

## 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ível | Ideia de projeto | O que treina |
| --- | --- | --- |
| Iniciante | Página pessoal | HTML, CSS e GitHub Pages |
| Iniciante | Lista de tarefas | DOM, eventos e armazenamento |
| Iniciante | Conversor de moedas | `fetch` e APIs |
| Intermediário | Buscador de filmes | APIs, JavaScript e interface |
| Intermediário | Controle financeiro pessoal | Dados, gráficos e CRUD |
| Intermediário | Blog pessoal | Componentes e rotas, com um framework |
| Avançado | Aplicação full stack | Servidor, 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](/pro/e-seguro-colar-dados-da-empresa-no-chatgpt-gemini-ou-claude).
- **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](/pro/qual-ia-e-melhor-para-estudar-chatgpt-gemini-ou-claude-modos-de-estudo).

## 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`**

```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`**

```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`**

```javascript
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.

_Vídeo: 7 ideias de projetos JAVASCRIPT, para INICIANTES (Attekita Dev)_

_Vídeo: How To Get Out of Tutorial Hell (Step by Step Guide) (bigboxSWE)_

_Vídeo: How I'd Escape Tutorial Hell in 14 Days (If I Had to Start Over) (Developete)_

_Vídeo: Build 15 JavaScript Projects - Vanilla JavaScript Course (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](/carreira/html-e-css-como-criar-e-publicar-sua-primeira-pagina-de-verdade). 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

- [The Odin Project](https://www.theodinproject.com/), The Odin Project
- [Node.textContent](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent), MDN Web Docs (Mozilla)
- [URL.createObjectURL()](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static), MDN Web Docs (Mozilla)
- [Blob() constructor](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob), MDN Web Docs (Mozilla)
- [input type=date](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/date), MDN Web Docs (Mozilla)
- [CSV Injection](https://community.owasp.org/attacks/CSV_Injection), OWASP
