codingcollectibles

Musclo: Redesign do acompanhamento nutricional e por que a assinatura do seu app de fitness é uma enganação

Nova interface, bancos de dados de alimentos reais e um desabafo sobre assinaturas de apps de fitness que estou guardando há dois anos

Escrito em 22 de março de 2026 - 🕒 28 min. de leitura

Imagina a cena: você tá na academia. Entre séries. Suado. Com uns 90 segundos antes de ter que pegar a barra de novo, e você tá ali cutucando o seu próprio aplicativo igual ao pai de alguém tentando navegar num site de 2008. Quatro toques pra registrar uma série. Quatro toques. Num app que você mesmo escreveu. Que você poderia mudar. E mesmo assim, toda vez, você faz os quatro toques porque sempre tem outra coisa mais urgente pra fazer antes.

Era o Musclog. Meu app de treino. Meu projeto de “300 horas que não vou recuperar” que eu escrevi lá em 2024. Funcionava. Meus amigos usavam. Eu registrei cada treino e cada grama de comida por meses com ele.

Também era, objetivamente, um dos apps mais feios que eu já tinha colocado no meu celular.

Design original do Musclog - a foto de antes que ninguém pediu
Design original do Musclog - a foto de antes que ninguém pediu

Eu sabia. Meus amigos sabiam. O amigo honesto o suficiente pra falar “cara, isso parece um site” sabia, e falou na minha cara sem a menor cerimônia. Mas funcionava, e por um tempo isso era suficiente. “Funciona” é o equivalente do desenvolvedor ao “tá bom” - tecnicamente verdade, emocionalmente uma mentira completa com a qual você concordou em viver.

Aí a dívida de UX foi rendendo juros na minha vida. Registrar um treino parecia preencher papelada. Adicionar comida exigia pular entre três telas diferentes quando deveria ser uma. Navegar no app na academia, já suado e com tempo contado, era um exercício de fricção que eu tinha projetado pra mim mesmo sem querer. Fui empilhando features em cima desse caos visual, que é um jeito excelente de deixar a casa do acumulador ainda mais difícil de navegar. Sem falar nos bugs, que às vezes simplesmente crashavam o app e ficavam lá no Sentry, sem solução.

Tinha que mudar alguma coisa. E por “alguma coisa” eu quero dizer tudo.

Eu não dou pra um bom designer

Eu tenho muitos amigos que dão pra um bom designer. Muitos. Não é o meu caso. O app original foi feito com react-native-paper, que é uma biblioteca perfeitamente decente. O problema não era o react-native-paper. O problema era eu. Fui jogando componentes juntos na ordem que fazia sentido na hora, escolhendo cores no feeling, e shippando sem nenhum design system de verdade. Sem escala de espaçamento. Sem paleta de cores semântica. Puro instinto de desenvolvedor aplicado diretamente numa UI, que, como se descobriu, produz exatamente o tipo de app que eu terminei tendo.

O mesmo conceito parecia diferente em três telas diferentes porque cada tela foi construída num momento diferente da minha compreensão do app. Botões primários tinham alturas diferentes dependendo de onde você os encontrava. Cards tinham três raios de canto diferentes que eu provavelmente escolhi com grande intenção e depois esqueci completamente. Cores eram hardcoded em todo lugar. A coisa toda era mantida unida por confiança e fita adesiva.

O plano de migração: arrancar o react-native-paper, substituir pelo NativeWind (Tailwind CSS pro React Native), e construir um design system de verdade do zero. Simples. Direto. Completamente insano dado o tamanho do codebase nessa altura.

Fui e fiz mesmo assim, porque não tenho nenhum instinto de autopreservação quando se trata de side projects.

Entra o Stitch

Eu estava de olho no Google Stitch desde que lançou. A versão curta: é uma ferramenta de IA que gera UI de app mobile a partir de prompts e screenshots. Você descreve a estética que quer, joga umas telas de referência, e ele gera componentes React coerentes. Pra um desenvolvedor que genuinamente tentou aprender design três vezes separadas e falhou cada vez por razões fundamentalmente diferentes, isso pareceu cola no melhor sentido possível.

Alimentei com as telas existentes do Musclog e descrevi o que queria: tema escuro, estética de fitness e performance, algo que parecesse estar olhando pra dados sérios ao invés de uma lista de tarefas. Queria que parecesse o dashboard de algo que faz sentido, não um app de bem-estar que vai sugerir que você “respire fundo” antes de registrar seu deadlift.

Nova tela inicial do Musclog
Nova tela inicial do Musclog

A parte inesperada foi o quanto esse processo me forçou a pensar claramente sobre o produto em si. Pra gerar uma boa tela no Stitch, eu precisava articular pra que aquela tela servia antes de poder descrever como deveria parecer. Esse processo expôs problemas de UX que não tinham nada a ver com cores. Um passo no fluxo de registro de treino existia puramente por causa de uma limitação técnica que eu tinha contornado com UI ao invés de resolver o problema de verdade. Resolvido. Fingi que nunca aconteceu. Resolvido, depois refatorei o serviço por baixo pra que nunca voltasse.

Eu escrevi O Stitch também escreveu um documento de design de verdade pela primeira vez na vida desse projeto: cores de superfície com nomes, cores semânticas pra macros (proteína é sempre índigo #6366f1, gordura é sempre âmbar #f59e0b, carbo é esmeralda #10b981, fibra é rosa #ec4899), escala de espaçamento em 4/8/12/16/20/24/32px, padrões de border radius em 12px pra inputs e 16px pra cards primários. Coisas que eu deveria ter definido antes de escrever uma única linha de código de UI. A conta do “manda ver” chegou, como sempre chega, com juros.

A arquitetura que ninguém pediu

Já que eu estava queimando a UI até os alicerces, aproveitei pra apertar a arquitetura por baixo também. Essa é a parte onde a maioria das pessoas desliga, mas se você tá construindo algo com armazenamento local-first em React Native, algumas dessas decisões podem te poupar tempo.

O Musclog usa WatermelonDB pro armazenamento local. Não SQLite puro, não MMKV, não AsyncStorage. O WatermelonDB fica em cima do SQLite no native e LokiJS no web, e te dá uma camada de modelo com queries reativas. Quando o dado muda, qualquer componente observando aquela query re-renderiza automaticamente. Sem sync de estado manual. Sem bug de “lembrei de atualizar a lista depois de salvar?“. O mesmo codebase roda no Android e no browser sem tocar na camada de dados, o que importa quando você quer testar algo rápido sem subir um device.

A estrutura em camadas vai assim: definição de schema na base, depois models, depois services que cuidam de CRUD e lógica de negócio. Serviços que não são de banco (IA, notificações, sync do Health Connect) vivem separados no próprio dir services/. Todo write passa por database.write(async () => { ... }). Sem exceções. Blocos de write aninhados causam deadlocks no WatermelonDB, e são uma dor de cabeça pra debugar, então a regra é simples: nunca aninhe, e se você quiser fazer isso, você projetou algo errado lá atrás.

É assim que fica no NutritionCheckinService.ts criando um lote de check-ins semanais:

static async createBatch(
    nutritionGoalId: string,
    checkins: NutritionCheckinInput[]
): Promise<NutritionCheckin[]> {
    return await database.write(async () => {
        const collection = database.get<NutritionCheckin>('nutrition_checkins');

        const preparedRecords = checkins.map((data) =>
            collection.prepareCreate((record) => {
                record.nutritionGoalId = nutritionGoalId;
                record.checkinDate = data.checkinDate;
                record.targetWeight = data.targetWeight;
                record.status = data.status ?? 'pending';
            })
        );

        await database.batch(...preparedRecords);
        return preparedRecords;
    });
}

A combinação prepareCreate + database.batch() é como o WatermelonDB lida com múltiplos inserts atomicamente sem múltiplas viagens de ida e volta. Um bloco write(), um batch, feito. Você nunca chama o database.write() de outro service lá de dentro, porque é assim que você consegue um deadlock às 23h que misteriosamente só reproduz no device.

Tela de registro de treino do Musclog
Tela de registro de treino do Musclog

Dados sensíveis, especificamente seu histórico de peso, percentuais de gordura corporal e logs de nutrição, são criptografados com AES antes de entrar no banco. Não porque eu espero que alguém invada um arquivo SQLite local, mas porque são dados de saúde. Merecem ser tratados como tal. O arquivo encryptionHelpers.ts cuida de encrypt/decrypt de forma transparente pela camada de service. Você nunca pensa nisso como usuário. Nem precisa pensar nisso como contribuidor, porque tá em um lugar e tudo passa por ele.

Então quando você loga uma refeição, é isso que realmente fica armazenado:

// database/encryptionHelpers.ts
export async function encryptNutritionLogSnapshot(plain: {
    loggedFoodName?: string;
    loggedCalories: number;
    loggedProtein: number;
    loggedCarbs: number;
    loggedFat: number;
    loggedFiber: number;
    loggedMicros?: Record<string, number | undefined>;
}) {
    const [
        loggedFoodName,
        loggedCalories,
        loggedProtein,
        loggedCarbs,
        loggedFat,
        loggedFiber,
        loggedMicrosJson,
    ] = await Promise.all([
        encryptOptionalString(plain.loggedFoodName),
        encryptNumber(plain.loggedCalories),
        encryptNumber(plain.loggedProtein),
        encryptNumber(plain.loggedCarbs),
        encryptNumber(plain.loggedFat),
        encryptNumber(plain.loggedFiber),
        encryptJson(plain.loggedMicros),
    ]);

    return { loggedFoodName, loggedCalories, loggedProtein, loggedCarbs, loggedFat, loggedFiber, loggedMicrosJson };
}

Aquele loggedCalories: 165 que você digitou? É uma string criptografada com AES no banco. loggedFoodName: "Chicken breast" também é criptografado. Até o blob de JSON dos micronutrientes é criptografado. Tudo descriptografa na hora da leitura pela camada de service. A chave de criptografia é derivada por device e nunca sai dele.

O sistema de gráficos tem uma peculiaridade que vale mencionar. O Musclog usa Victory Native pra gráficos no mobile, que usa Skia pra renderizar. Skia não funciona no web. Então todo componente de gráfico tem um par .web.tsx usando Victory normal com SVG no lugar. O bundler do Expo pega o arquivo certo automaticamente baseado na extensão. Parece o dobro de trabalho e é meio que isso mesmo, mas a alternativa são gráficos que quebram silenciosamente no web, e eu uso a versão web bastante durante o desenvolvimento.

O mesmo LineChart, dois runtimes:

// components/charts/LineChart.tsx — native (Skia)
import { Area, CartesianChart, Line, Scatter } from 'victory-native';
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';

// components/charts/LineChart.web.tsx — web (SVG)
import { VictoryArea, VictoryAxis, VictoryChart, VictoryLine, VictoryScatter } from 'victory';

Mesma interface de props, mesmo comportamento, backends de renderização diferentes. O bundler do Expo resolve LineChart pro arquivo .web.tsx no web e pro .tsx em todo o resto. Zero condicionais no componente que realmente o usa.

O tracking de volume funciona da mesma forma na infraestrutura, exceto que o problema interessante aí é a matemática. O Musclog rastreia volume como máximo estimado de uma repetição, não peso x reps bruto, porque 5 reps em 80kg e 12 reps em 60kg são estímulos de treino diferentes que produzem o mesmo número num gráfico de volume ingênuo. O problema é que não existe uma fórmula de 1RM aceita universalmente. Brzycki, Epley, Lander, Mayhew - todas dão um número ligeiramente diferente pro mesmo set, e a pesquisa não elege um vencedor claro. Então o Musclog roda as sete e tira a média:

// utils/workoutCalculator.ts
export function calculateAverage1RM(weight: number, reps: number, rir: number = 0): number {
    const formulas: FormulaType[] = [
        'Epley', 'Brzycki', 'Lander', 'Lombardi', 'Mayhew', 'OConner', 'Wathan',
    ];
    let total1RM = 0;
    let validFormulas = 0;

    formulas.forEach((formula) => {
        const oneRM = calculate1RM(weight, reps, formula, rir);
        if (oneRM !== null) {
            total1RM += oneRM;
            validFormulas++;
        }
    });

    return validFormulas > 0 ? total1RM / validFormulas : 0;
}

O parâmetro rir é Reps in Reserve: se você parou em 8 mas tinha mais 2 no tanque, rir = 2 ajusta a estimativa pra cima pra refletir o que você realmente poderia levantar. Registrar seu RIR é opcional. O tipo de pessoa que construiu seu próprio app de fitness e rastreia tudo em planilha geralmente também é o tipo de pessoa que rastreia o RIR. Não tô dizendo que sou eu. Sou eu.

Espera, e os dados reais de comida?

É. O redesign foi a parte visível dessa atualização. A parte mais significativa, pelo menos do ponto de vista de “esse app é realmente útil”, foi reconstruir completamente como o tracking de comida funciona.

A versão original dependia muito das informações nutricionais vindas do Health Connect. Se você não tinha outro app pra fazer o tracking de nutrição, ficava a deriva. Isso é aceitável pra um projeto de fim de semana. Não é aceitável pra um app que as pessoas usam diariamente pra rastrear calorias, proteína, carbo, gordura, e mais de 40 micronutrientes em múltiplas refeições.

Pra minha genuína surpresa, descobri que dados de alta qualidade sobre comida não ficam atrás de um portão corporativo. Existem APIs públicas e gratuitas enormes - como USDA e Open Food Facts - que fornecem de quebra de micronutrientes detalhada a consulta de código de barras global sem cobrar um centavo. Encontrar isso foi um grande momento “aha!”: se o dado é público e o processamento acontece direto no seu device, não existe justificativa técnica alguma pra rastreamento de nutrição ser um SaaS baseado em assinatura. A maioria dos apps “premium” basicamente te cobra uma mensalidade pra ser intermediário de dados que nem são deles, mas essa é uma discussão picante que guardei pra outra hora. O Musclog agora conecta a dois bancos de dados de comida reais.

USDA FoodData Central

USDA FoodData Central é o banco de dados nutricional público do Departamento de Agricultura dos EUA. Centenas de milhares de alimentos, de produtos de marcas a ingredientes brutos, com detalhamento de macro e micronutrientes. São dados governamentais, é gratuito, e a API não requer cartão de crédito, o que como você vai ver é uma consideração não trivial pra mim. A cobertura de produtos de marcas americanos e globais é genuinamente sólida. Essa é a espinha dorsal da busca.

O USDA identifica nutrientes por códigos numéricos, então mapeá-los pra algo legível por humanos requer uma camada de lookup. É assim que o mapper parece na prática:

// utils/usdaMapper.ts
export function mapUSDAFoodToUnified(food: USDAFood): UnifiedFoodResult {
    const nutrients = food.foodNutrients;

    // USDA usa códigos numéricos de nutrientes — 1008/208 é energia, 1003/203 é proteína, etc.
    const calories = mapUSDANutritient(nutrients, '1008') ?? mapUSDANutritient(nutrients, '208');
    const protein  = mapUSDANutritient(nutrients, '1003') ?? mapUSDANutritient(nutrients, '203');
    const carbs    = mapUSDANutritient(nutrients, '1005') ?? mapUSDANutritient(nutrients, '205');
    const fat      = mapUSDANutritient(nutrients, '1004') ?? mapUSDANutritient(nutrients, '204');
    const fiber    = mapUSDANutritient(nutrients, '1079') ?? mapUSDANutritient(nutrients, '291');

    return {
        id: String(food.fdcId),
        name: food.description,
        brand: food.brandOwner,
        calories: calories !== undefined ? Math.round(calories) : undefined,
        protein, carbs, fat, fiber,
        source: 'usda',
    };
}

O fallback duplo ?? existe porque o USDA tem dois esquemas de numeração de nutrientes diferentes dependendo do tipo de dado (Foundation Foods vs. Branded Foods). Ambos mapeiam pro mesmo formato de saída.

Open Food Facts

Open Food Facts é um banco de dados comunitário de produtos alimentícios do mundo inteiro. Pensa num Wikipedia mas pra rótulos nutricionais: qualquer um pode adicionar um produto, os dados são abertos sob a licença ODbL, e como é crowdsourced globalmente cobre produtos que o banco do USDA ignora na maior, tipo o negócio de laticínio fermentado que tem no supermercado holandês a cinco minutos do meu apartamento. Quando seu banco de dados nutricional principal assume que você come exclusivamente produtos americanos e você mora na Holanda, você começa a apreciar datasets globais abertos muito rapidamente.

Fazer queries nele é surpreendentemente simples pra algo tão abrangente:

// hooks/useUnifiedFoodSearch.ts
const url = `https://world.openfoodfacts.org/cgi/search.pl` +
    `?search_terms=${encodeURIComponent(query)}` +
    `&json=1` +
    `&page_size=20` +
    `&fields=code,product_name,brands,nutriments,serving_size,image_url`;

const response = await fetch(url, { signal: abortController.signal });
const result = await response.json();

const products = result.products.filter(
    (product: SearchResultProduct) => getProductName(product)
);

Ambas as buscas rodam em paralelo (com abort controllers pra requests velhas não brigarem entre si) e os resultados se fundem em uma única lista unificada. O usuário escolhe entre comidas locais, resultados do Open Food Facts, e resultados do USDA, tudo de uma vez, sem saber ou se importar de qual backend cada um veio.

Entre os dois, você consegue buscar praticamente qualquer comida e obter dados nutricionais reais sem digitar nada manualmente. E porque o Musclog agora rastreia mais de 40 micronutrientes além dos macros padrão, você consegue ver coisas como sua ingestão de magnésio, seus níveis de zinco, sua vitamina D. Acontece que isso importa quando você começa a olhar o que está consistentemente faltando. Pra mim é magnésio. É sempre magnésio.

Tracking de nutrição do Musclog com breakdown completo de macros
Tracking de nutrição do Musclog com breakdown completo de macros

Tem também um scanner de código de barras. Aponta a câmera pro produto, ele busca o código de barras no Open Food Facts, adiciona ao seu log. Eu escanio minha embalagem de iogurte grego toda manhã por puro hábito a essa altura. Registro sem fricção era o que eu queria quando comecei esse projeto e finalmente tenho, duas versões e uns 500 horas depois.

Scanner de código de barras do Musclog em ação
Scanner de código de barras do Musclog em ação

Pra quando você não consegue escanear nada porque tá comendo num restaurante ou olhando pra um prato de “provavelmente é frango com algum molho,” tem OCR de rótulo (tesseract.js no web, rn-mlkit-ocr no native) e estimativa por foto com IA, que vou chegar lá em um segundo.

O OCR segue o mesmo padrão de arquivo duplo dos gráficos. Assinatura de função idêntica, engine diferente, o bundler pega o certo na hora do build:

// utils/ocr.ts — native (Google ML Kit, roda no device)
import { recognizeText } from 'rn-mlkit-ocr';

export async function performOcr(imageUri: string): Promise<string | null> {
    const result = await recognizeText(imageUri);
    const text = result.text.trim();
    return text.length > 0 ? text : null;
}

// utils/ocr.web.ts — web (Tesseract.js, roda inteiramente no browser)
import { createWorker } from 'tesseract.js';

const OCR_LANGS = 'eng+spa+por+nld+deu+fra';

export async function performOcr(imageUri: string): Promise<string | null> {
    const worker = await createWorker(OCR_LANGS);
    const { data: { text } } = await worker.recognize(imageUri);
    await worker.terminate();
    return text.trim() || null;
}

ML Kit roda no device e é rápido. Tesseract.js sobe e mata um worker completo pra cada scan, o que não é elegante, mas funciona offline e nada sai do browser. O pacote de idiomas cobre inglês, espanhol, português, holandês, alemão e francês. Moro na Holanda, sou brasileiro, e não ia shipar um scanner de rótulo de supermercado que engasgasse com embalagem de queijo holandês.

O coach de IA cresceu

O chatbot original se chamava Chad. Sim, eu sei, não é muito criativo, mas eu tava mais focado em “shipar” features do que em deixá-las atraentes. E tá beleza, né, Chad?

sim.
sim.

Agora se chama Loggy, suporta tanto OpenAI quanto Google Gemini, e tá realmente conectado aos seus dados de forma significativa. Quando você pergunta algo pro Loggy, ele sabe quem você é: seus treinos recentes, seus logs de nutrição, sua tendência de peso, seus objetivos atuais. “Meu volume de treino da semana passada tava no caminho certo?” recebe uma resposta real baseada em dados reais, não uma dica genérica sobre sobrecarga progressiva que você já leu quinze vezes.

A camada de saída estruturada foi um dos problemas técnicos mais interessantes aqui. Fazer LLMs outputarem dados estruturados de forma confiável (ao invés de prosa que parece dados estruturados mas quebra seu parser de JSON) requer cuidado. O utilitário makeSchemaStrict em utils/coachAI.ts pega qualquer schema JSON e força additionalProperties: false em todo objeto aninhado enquanto marca todos os campos como required. Isso vai pra config de function calling do OpenAI e fala pro modelo “retorna exatamente esse formato ou falha limpo.” É a diferença entre uma feature que funciona 95% do tempo e uma que realmente funciona.

A função em si é simples o suficiente que eu quase não escrevi sobre ela:

// utils/coachAI.ts
function makeSchemaStrict(schema: any): any {
    if (schema.type === 'object') {
        const properties = schema.properties ? { ...schema.properties } : {};

        Object.keys(properties).forEach((key) => {
            properties[key] = makeSchemaStrict(properties[key]);
        });

        return {
            ...schema,
            properties,
            additionalProperties: false, // OpenAI requer isso pro modo estrito
        };
    }

    if (schema.type === 'array' && schema.items) {
        return { ...schema, items: makeSchemaStrict(schema.items) };
    }

    return schema;
}

Percorre recursivamente cada objeto aninhado no schema e enfia additionalProperties: false nele. Sem isso, o function calling estrito do OpenAI rejeita o schema completamente. Com isso, o modelo é restringido exatamente ao formato que você definiu. Sem chaves extras surpresa. Sem campos que existem numa resposta mas não em outra. A IA propõe, o schema impõe.

Chat com o coach de IA do Musclog
Chat com o coach de IA do Musclog

A feature de análise por foto é a que faz novos usuários olharem duas vezes. Você tira uma foto da sua refeição, o Loggy estima as porções e o conteúdo nutricional. Não substitui uma balança de alimentos pra tracking de precisão, mas pra comer fora ou em dias que você genuinamente não consegue escanear nada, te deixa na bola certa rápido. O fluxo manda a imagem pro modelo com um schema estruturado pra resposta, extrai as estimativas, e aí te faz confirmar antes de logar. Esse último passo importa: a IA propõe, você decide.

Os system prompts ficam em utils/prompts.ts e puxam instruções customizadas da tabela ai_custom_prompts, então o comportamento da IA é configurável sem tocar no código.

Check-ins semanais: a feature que mais me orgulha

Todo semana, o Musclog roda uma análise automatizada das suas médias móveis de 7 dias pra peso, ingestão calórica e atividade. Você recebe um status: No Caminho, Adiantado ou Atrasado. Se seus números estão divergindo dos seus objetivos, ele pode recalcular suas metas nutricionais baseado no que realmente aconteceu ao invés de te prender num plano que claramente não tá batendo com a realidade.

O coração disso é getCheckinMetrics, que pega um registro de check-in e puxa todos os dados da janela de 7 dias terminando naquela data:

// database/services/NutritionCheckinService.ts
static async getCheckinMetrics(checkin: NutritionCheckin): Promise<CheckinMetrics> {
    const periodEnd = checkin.checkinDate;
    const periodStart = periodEnd - 7 * 24 * 60 * 60 * 1000;

    // Puxa todas as medições de peso da janela de 7 dias
    const weightMetrics = await database
        .get<UserMetric>('user_metrics')
        .query(
            Q.where('type', 'weight'),
            Q.where('date', Q.between(periodStart, periodEnd)),
            Q.where('deleted_at', Q.eq(null)),
            Q.sortBy('date', Q.asc)
        )
        .fetch();

    // Descriptografa cada valor (pesos são criptografados com AES no banco)
    const decryptedWeights: number[] = [];
    for (const metric of weightMetrics) {
        const { value } = await metric.getDecrypted();
        decryptedWeights.push(value);
    }

    const avgWeight = decryptedWeights.length > 0
        ? decryptedWeights.reduce((a, b) => a + b, 0) / decryptedWeights.length
        : checkin.targetWeight;

    // Quão longe o average real está de onde esperávamos estar?
    const trend = avgWeight - checkin.targetWeight;

    // Nutrição: agrupa logs por dia, calcula média de calorias e consistência
    const nutritionLogs = await database
        .get<NutritionLog>('nutrition_logs')
        .query(Q.where('date', Q.between(periodStart, periodEnd)), Q.where('deleted_at', Q.eq(null)))
        .fetch();

    const caloriesByDay = new Map<number, number>();
    for (const log of nutritionLogs) {
        const snapshot = await log.getDecryptedSnapshot();
        const dayKey = Math.floor(log.date / (24 * 60 * 60 * 1000));
        caloriesByDay.set(dayKey, (caloriesByDay.get(dayKey) ?? 0) + snapshot.loggedCalories);
    }

    const consistency = Math.round((caloriesByDay.size / 7) * 100); // % de dias com logs
    // ...e assim por diante pra treinos, gordura corporal, minutos ativos
}

O trend é o número chave: positivo significa que você tá mais pesado do que o alvo previa, negativo significa que você tá à frente. Combinado com consistency (que porcentagem dos 7 dias você realmente logou comida), o service consegue inferir se a variância é real ou só dado faltando.

O cálculo de TDEE é empírico ao invés de baseado em fórmula. O Musclog olha sua mudança real de peso ao longo do tempo combinada com sua ingestão calórica real logada e trabalha ao contrário pra estimar suas calorias reais de manutenção. Se você tá comendo 2.200 calorias por dia e perdendo 0,3kg por semana, a matemática te diz algo sobre seu metabolismo real que a fórmula de Harris-Benedict nunca vai te dizer, especialmente se seu nível de atividade não se encaixa perfeitamente em “sedentário” ou “moderadamente ativo” ou qualquer categoria vaga que você escolheu no onboarding.

A função que faz isso mora em utils/nutritionCalculator.ts. A lógica é direta; as constantes, não:

// utils/nutritionCalculator.ts
export const calculateTDEE = (params: TDEEParams): number => {
    const { totalCalories, totalDays, initialWeight, finalWeight,
            initialFatPercentage, finalFatPercentage, bmr, activityLevel } = params;

    // Caminho 1: dados reais de tracking existem — deriva TDEE da Primeira Lei da Termodinâmica
    if (totalDays && totalCalories && initialWeight && finalWeight) {
        const weightDifference = finalWeight - initialWeight;

        // Divide a mudança de peso em gordura vs massa magra.
        // Exato quando temos % de gordura nos dois extremos; estimativa pela curva Hall/Forbes caso contrário.
        let fatDifference: number;
        let leanDifference: number;

        if (initialFatPercentage !== undefined && finalFatPercentage !== undefined) {
            const initialFatMass = (initialFatPercentage * initialWeight) / 100;
            const finalFatMass = (finalFatPercentage * finalWeight) / 100;
            fatDifference = finalFatMass - initialFatMass;
            leanDifference = weightDifference - fatDifference;
        } else {
            const comp = getWeightChangeComposition(initialWeight * 0.25, weightDifference);
            fatDifference = comp.fatChangeKg;
            leanDifference = comp.leanChangeKg;
        }

        // Construir vs queimar gordura e músculo têm custos termodinâmicos diferentes.
        // Essas não são constantes intercambiáveis — são valores medidos separados.
        const leanCalories = leanDifference > 0
            ? leanDifference * CALORIES_BUILD_KG_MUSCLE   // 3900 kcal/kg pra construir
            : leanDifference * CALORIES_STORED_KG_MUSCLE; // 1250 kcal/kg armazenados

        const fatCalories = fatDifference > 0
            ? fatDifference * CALORIES_BUILD_KG_FAT   // 8840 kcal/kg pra construir
            : fatDifference * CALORIES_STORED_KG_FAT; // 7730 kcal/kg armazenados

        // TDEE = (energia consumida − energia presa em mudanças de tecido) / dias
        return Math.round((totalCalories - (fatCalories + leanCalories)) / totalDays);
    }

    // Caminho 2: ainda sem histórico — cai de volta pra BMR x multiplicador de atividade
    if (bmr && activityLevel) {
        return Math.round(bmr * ACTIVITY_MULTIPLIERS[activityLevel]);
    }

    return 0;
};

A assimetria entre construir gordura (8840 kcal/kg) e queimá-la (7730 kcal/kg) é real. A eficiência termodinâmica não é 100%, então criar novo tecido custa mais do que o que acaba sendo armazenado. O mesmo princípio se aplica ao músculo. Se você só hardcoda 7700 e segue em frente você tem algo que é aproximadamente certo, mas você tá tirando a média exatamente do sinal que você construiu o sistema de check-in inteiro pra encontrar.

Tela de check-in semanal do Musclog
Tela de check-in semanal do Musclog

Essa não é uma feature chamativa. Você não percebe até o fim da semana. Mas ter um app que percebe “você ficou abaixo do seu alvo calórico a semana inteira e seu peso ainda não mudou, vamos descobrir por quê” é exatamente o tipo de insight que eu estava construindo quando comecei esse projeto. Só não sabia disso na época. Achei que estava construindo um registrador de treinos.

As outras coisas que fui shippando no sapatinho

Widgets de tela inicial pra registro rápido e resumos diários. Um rastreador de ciclo menstrual com recomendações de intensidade de treino por fase, porque assumir uma base de usuários só masculina é um ponto de partida razoável não tá certo, então o Musclog não usa gênero pra inferir dados de ciclo menstrual. Integração com Health Connect pra sincronizar peso, nutrição e dados de exercício com outros apps de saúde Android. Exportação completa de dados como JSON encriptado (ou não encriptado). Importação de JSON pra migrar entre devices sem perder seu histórico.

A feature de exportação de dados parece chata até seu celular morrer e você não ter. Nesse ponto você tá simultaneamente grato por ter construído e levemente furioso com o seu eu do passado por não ter testado o fluxo de importação mais a fundo. Pode perguntar como eu sei.

Detalhe do tracking de micronutrientes do Musclog
Detalhe do tracking de micronutrientes do Musclog

Por que a assinatura do seu app de fitness é problema de outro

Quero falar algo sobre o mercado de apps de fitness, porque faz uns dois anos que tô segurando isso e esse é o meu blog.

A história se repete num cronograma que você pode acertar seu relógio: app lança gratuito, adquire usuários, introduz um nível premium, gradualmente migra features centrais pra trás do paywall, aumenta os preços, usuários reclamam no Reddit, nada muda. Assisti isso acontecer com apps que eu genuinamente gostava de usar. MyFitnessPal passou de realmente bom e gratuito pra uma assinatura onde definir suas próprias metas de macro custa dinheiro. Outros apps introduziram “bancos de dados de alimentos premium” que tiraram a coisa útil e a venderam de volta mensalmente. Um app que usei por um tempo moveu os gráficos de progresso pra trás de um paywall. Os gráficos de progresso. Num app de rastreamento de fitness. A única feature que mostra se o app tá funcionando.

“Mas sem receita de assinatura, como você mantém as luzes acesas?” As luzes são um cabo de carregamento e custam zero por mês. Próxima pergunta.

Entendo a economia. Servidores custam dinheiro. Times de engenharia custam dinheiro. Construir um negócio de produto sustentável é genuinamente difícil e alguém tem que pagar por isso. Tudo bem.

Mas o Musclog não tem servidores. Não tem infraestrutura de nuvem. Nenhum banco de dados que estou pagando pra hospedar em algum lugar. Seus dados vivem no seu celular. Os bancos de dados de alimentos que uso são públicos e gratuitos. As features de IA, se você usá-las, falam diretamente com a OpenAI ou o Google usando sua própria chave de API: você paga pra eles, sem intermediário, sem comissão. O app é gratuito no Google Play e open source no GitHub.

O ciclo de assinatura de apps de fitness, ilustrado
O ciclo de assinatura de apps de fitness, ilustrado

Isso não é uma posição de princípio contra o capitalismo. É uma decisão de design que faz sentido pro que isso realmente é: uma ferramenta pessoal que construí pra mim mesmo e depois compartilhei com outras pessoas. A arquitetura que escolhi naturalmente não cria custos de servidor, o que significa que não tenho pressão financeira pra monetizar, o que significa que os usuários não têm motivo pra desconfiar do que estou fazendo com os dados deles. Porque não estou fazendo nada com os dados deles. Estão no celular deles.

O design offline-first é deliberado pelo mesmo motivo. Histórico de peso, composição corporal, o que você come, seu ciclo menstrual, seu histórico de treinos: nada disso deveria ficar no servidor de um estranho por padrão. A maioria dos apps de fitness não explica claramente o que faz com esses dados porque uma resposta transparente seria impopular. O Musclog mantém tudo local. O único dado que sai do seu device é o que você explicitamente manda pra IA, e só quando você escolhe usar essa feature.

Tem uma população inteira de pessoas treinando em academias com WiFi ruim, em porões, em garagens, em parques, que nunca tiveram o Musclog falhar porque precisava de conexão. Isso importa pra mim mais do que um número de receita recorrente mensal.

O que vem por aí

O onboarding é a parte mais fraca do app agora. O Musclog é significativamente mais útil quanto mais dados tem, mas a experiência de novo usuário ainda tá muito perto de “aqui tá o app inteiro, se vira.” Quero construir um fluxo de onboarding de verdade que deixe alguém configurado com seus objetivos, seu primeiro template de treino, e seu primeiro log de comida em menos de cinco minutos. Você não deveria precisar de 500 horas de contexto pra ter valor desde o primeiro dia.

Suporte a devices BLE pra balanças inteligentes também está na lista. Entrar o peso e gordura corporal manualmente todo dia é ok. Ter a balança mandar pro app automaticamente quando você sobe nela é melhor, e a infraestrutura não é tão complexa. É principalmente uma questão de conseguir hardware pra testar, o que é um problema muito solucionável e definitivamente algo que vou fazer assim que terminar as outras dezessete coisas que já comecei.

Vai ser gratuito pra sempre?

Pergunta justa. A resposta curta é: não faço a menor ideia. A resposta mais longa é mais interessante.

O código é open source sob a licença Attribution-NonCommercial-NoDerivatives 4.0 International. Você pode ler, fazer fork pra uso pessoal, aprender com ele. O que você não pode é shipar um produto construído em cima dele sem falar comigo primeiro, porque Musclog é uma marca minha, e porque as quinhentas horas de trabalho por trás disso representam algo em torno de €25000 em salário de desenvolvedor europeu. Não tô tocando uma ONG. Tô tocando um side project que ainda não te pediu dinheiro.

Se isso mudar algum dia, aqui tá minha promessa: não vou fazer assinatura. Se o Musclog algum dia custar dinheiro, vai custar uma vez. Você paga o preço de um café, o app é seu, pra sempre. Sem mensalidade. Sem “seu plano premium foi pausado.” O modelo bom e velho, aquele que a App Store basicamente matou.

O que tá me empurrando mais perto desse cenário agora é o iOS. Minha namorada quer no iPhone. Alguns amigos próximos querem no iPhone. Há meses escuto “coloca na App Store” como se fosse uma coisa trivial. O programa de desenvolvedor da Apple custa 100 USD por ano. Por ano. Não uma vez. Todo ano. A Apple transformou o direito de distribuir software num SaaS, e falo isso como alguém que acha que a taxa única de 35 USD do Google Play é como deveria ser em todo lugar. Então sim, se o Musclog algum dia chegar no iOS, provavelmente vai ter um preço, porque não vou pagar 100 USD de pedágio anual pra Apple de boa vontade.

Por enquanto: Android, gratuito, sem planos de mudar isso. Então baixe agora enquanto tá de graça, porque uma vez que você instala no Google Play, ele é seu pra sempre. Mesmo que você não tenha pagado nada. Especialmente se você não pagou nada.

Conclusão

Dois anos, duas versões, 500 horas. O Musclog finalmente parece algo pelo qual eu não me desculpo mentalmente antes de mostrar pra alguém. O redesign limpou anos de dívida de UI do “manda ver”, e fazer direito me forçou a pensar sobre o produto de maneiras que eu deveria ter pensado desde o começo. Acontece que design systems existem por boas razões e não só pra dar aos designers algo pra discutir no Figma.

É gratuito. É open source. Não tem seus dados. Funciona no modo avião. Bancos de dados de comida reais. IA que realmente sabe quem você é. Check-ins semanais automatizados. E um esquema de cores verde escuro do qual eu me orgulho genuinamente, mesmo que uma ferramenta de IA tenha feito a maior parte do trabalho visual pesado.

Se quiser experimentar: Google Play.

Se quiser ver como foi construído ou contribuir com algo: github.com/blopa/musclog-app.

Treina, Loga, Repete.

Até a próxima!

Tags:


Publicar um comentário

Comentários

Nenhum comentário.