← Back to project room
Featured projectshipped & live

QuestLog // dev log

A productivity application that makes habit building and task management more engaging through game-inspired progression systems — track goals, build habits, level four attributes, and get AI-assisted analysis of your activity.

fun side project~2 weeks
Next.jsGoogle Drive APIAI AnalysisGamificationCloud Sync
5,271lines in app.js
20character levels
6save slots
3AI providers
01

overview & stack

QuestLog runs on Next.js 16 with React 19, but underneath the framework shell it's almost entirely vanilla JavaScript — no Redux, no Zustand, no UI library. The Pages-Router app (pages/index.js) mounts a single component that hands control to a 5,271-line public/app.js, which owns the game state, every screen render, the AI integration, and Google Drive sync.

That's a deliberate choice: the whole thing is a single-file game engine that happens to live inside a Next.js shell for easy deployment. public/constants.js holds the tunable numbers (XP curves, titles, AI tiers), public/aiCore.js abstracts three different AI providers behind one function, and public/storage.js wraps localStorage with a SHA-256 integrity check and an in-memory fallback for browsers that block storage (looking at you, iOS private mode).

02

the central state object

Everything the game knows about you lives in one state object: player level, total XP, the four RPG attributes (STR / END / INT / WIL), coins, streaks, inventory, perks, and the full task list. Every UI update is just: mutate state, call scheduleSave(), re-render.

Persistence wraps that object in a tamper-evident envelope — the JSON is hashed with SHA-256 via the Web Crypto API before it touches localStorage, so a corrupted or hand-edited save is detected on load and falls back gracefully instead of crashing the app.

public/app.js:172–201js
let state = {
    version: 1,
    player: {
        level: 1,
        totalXP: 0,
        totalCompleted: 0,
        streak: 0, maxStreak: 0,
        attributes: { STR: 0, END: 0, INT: 0, WIL: 0 },
        coins: 0,
        ascensionCount: 0,
        inventory: { streakFreezes: 0, rerollLocks: 0, glitchText: false, goldenMatrix: false },
        hp: 100, maxHp: 100,
        limitBreakUntil: 0,
        comboQueue: [],
        perks: { heavyLifter: false, neuralNet: false, ironWill: false },
    },
    tasks: [],
    unlockedAchievements: [],
    marketItems: [],
    aiConfig: { tier: 'normal', thinkingLevel: 'standard' },
};
03

the leveling engine

Leveling is table-driven, not formula-driven — XP_THRESH is a hand-tuned array of 20 thresholds (0, 100, 250, 450 … 13,700), and xpToLevel() just walks the array to find where your total XP lands. Each level has a flavor title pulled from TITLES, running from NOVICE at level 1 all the way up to INFINITE at level 20.

On top of the level curve sits Ascension — once you hit level 20 with 1,000+ coins, you can reset your level, XP, coins and attributes back to zero in exchange for a permanent +10% multiplier on all future XP and coin gains. Stack it enough times and a single Easy quest starts paying out like an Epic one did at Ascension 0.

public/constants.js:4–19js
export const XP_THRESH = [
    0, 100, 250, 450, 700, 1000,
    1400, 1850, 2350, 2900, 3500,
    4200, 5000, 5900, 6900, 8000,
    9200, 10500, 12000, 13700
];

export const TITLES = [
    '', 'NOVICE', 'APPRENTICE', 'JOURNEYMAN', 'ADEPT', 'EXPERT',
    'VETERAN', 'MASTER', 'GRANDMASTER', 'CHAMPION', 'LEGEND',
    'MYTHIC', 'IMMORTAL', 'ASCENDANT', 'DEMIGOD', 'GOD',
    'BEYOND GOD', 'RIFT WALKER', 'VOID WALKER', 'ETERNAL', 'INFINITE'
];

export const XP    = { easy: 10, medium: 25, hard: 50, epic: 100, workout: 20 };
export const COINS = { easy: 5,  medium: 15, hard: 20, epic: 25,  workout: 10 };
04

quests, difficulty tiers & the completion flow

Quests carry a difficulty (easy / medium / hard / epic), a recurrence (once / daily / weekly), and an attribute tag that decides which of the four stats gets the XP. Completing a quest is the one function that touches almost everything: it awards XP and coins (scaled by Ascension and any active Limit Break multiplier), grows the matching attribute, updates streaks, feeds the combo queue, checks achievements, and — if it pushed you over a level threshold — queues a level-up toast.

Three completions inside a 30-minute rolling window trigger Limit Break: a 45-minute window where all XP and coin gains are multiplied ×1.5 and the UI shifts to an amber glow. It's a small thing, but it turns 'knock out three quick tasks back to back' into a genuine in-app strategy.

Daily/weekly quests don't just toggle a completed flag — they push a date string into completionLog, so the streak system and the AI debrief can both reconstruct exactly which days a habit was actually kept.

public/app.js:1039–1091 (abridged)js
function completeTask(id) {
    const task = state.tasks.find(t => t.id === id);
    const baseXP  = XP[task.difficulty] ?? 10;
    const gained  = Math.round(baseXP * (1 + 0.10 * (state.player.ascensionCount || 0)));
    const coinGained = COINS[task.difficulty] ?? 5;
    const attr = task.attribute || 'WIL';

    task.completed = true;
    task.completedAt = Date.now();
    if (task.recurrence === 'daily') {
        task.completionLog = task.completionLog || [];
        task.completionLog.push(todayStr());
    }

    const lbMult = _getLimitBreakMult();          // 1.5 during Limit Break
    const finalXP    = Math.round(gained * lbMult);
    const finalCoins = Math.round(coinGained * lbMult) + perkBonus;

    applyXPGain(finalXP, attr, finalCoins);
    touchStreak();
    _updateComboQueue();                            // 3 completions / 30min -> Limit Break
    state.player.level = xpToLevel(state.player.totalXP);
    checkAchievements();
    scheduleSave();
}
05

the black market economy

Coins earned from quests feed a small in-game shop: streak freezes, reroll locks (pin a quest so the daily reroll can't touch it), a 'Forgiveness Protocol' to manually restore a broken streak, cosmetic flairs like [👑 CHOSEN ONE], and extra save slots. Slot pricing scales aggressively — 500 × 200^((level-1)/19), so the 4th slot is cheap at level 1 but costs six figures by level 20, keeping it a genuine late-game goal rather than an early unlock.

Attributes double as a soft skill tree: hit STR ≥ 10 and Hard/Epic strength quests pay an extra 10 coins (heavyLifter); INT ≥ 10 unlocks neuralNet, which guarantees one '✦ Golden Task' (an Epic-difficulty quest) in every Smart Reroll; WIL ≥ 10 grants one free streak-protection per day.

06

AI integration — daily analysis & weekly debrief

QuestLog talks to Gemini, Claude, or GPT-4o behind a single callAI(system, messages, maxTokens, opts) function in aiCore.js — same call signature, three different fetch implementations and auth schemes swapped based on which provider the user configured. Gemini is the default because it has a generous free tier and needs no credit card.

Two features actually call it: 'Analyze My Day' collects every quest completed today and asks for a blunt 3-sentence summary of momentum and tomorrow's priority. 'Weekly Debrief' builds a much richer prompt — level, class title, streaks, total XP, your strongest *and* weakest attribute, recent workouts — and asks the model to grade the week A–F with one specific action for next week, explicitly told to 'be harsh but fair'.

The same callAI function also powers Smart Reroll: instead of a static quest pool, the AI is shown your weakest attribute and the quests currently on your board, and told to generate a fresh set that's genuinely different and biased toward shoring up that weak attribute.

public/aiCore.js:105–140 (Gemini branch, abridged)js
export async function callAI(system, messages, maxTokens = 300, opts = {}) {
  const { apiKey, provider = 'gemini', tier = 'normal' } = opts;
  const model = AI_TIERS[tier].geminiModel;

  if (provider === 'gemini') {
    const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
    const contents = messages.map(m => ({
      role: m.role === 'assistant' ? 'model' : 'user',
      parts: [{ text: m.content }],
    }));
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        system_instruction: { parts: [{ text: system }] },
        contents,
        generationConfig: { maxOutputTokens: maxTokens },
      }),
    });
    return (await res.json()).candidates[0].content.parts[0].text;
  }
  // ...openai / anthropic branches follow the same shape
}
07

cloud sync — per-slot, never lose progress

Up to 6 character save slots sync to Google Drive's app-data folder via OAuth (scoped narrowly to drive.appdata — the app can't see any of your other files). The interesting design decision is *how* sync resolves conflicts: instead of comparing one global timestamp, every slot carries its own lastUpdated, and sync walks all six slots independently — a newer cloud slot overwrites the local one, a newer local slot gets uploaded, and slots that only exist on one side are left untouched.

That per-slot merge means playing character #2 on your phone and character #5 on your laptop can never stomp on each other — each slot's most recent edit wins, and nothing regresses.

08

small details worth knowing about

A few things stood out while building this that I'd reuse on future projects:

DOM reconciliation by hand
updateDOM() diffs old vs. new child nodes by a data-id key and patches only className / innerHTML that actually changed, instead of re-rendering the whole task list on every tick.
Random encounters
A 5% chance per app-open spawns a 'Goblin Thief' (steals coins, offers a recovery quest) or a 'Wandering Merchant' (50%-off flair for 60 seconds) — tiny surprises that make the app feel alive between quests.
Character card export
A <canvas> renders your name, level, class, radar-chart stats and achievements into a downloadable PNG, so progress is shareable outside the app.
29-step interactive tour
A spotlight overlay for first-time users, gated behind a questlog_tour_done flag so it only ever runs once.