habit-gamification

star 0

Sistema de gamificación para formación de hábitos con rachas, deuda de bloques, streak freezes y mecánicas basadas en aversión a la pérdida. Úsalo cuando implementes sistemas de accountability, gamificación de productividad, o tracking de hábitos.

Akxlarre By Akxlarre schedule Updated 1/30/2026

name: habit-gamification description: Sistema de gamificación para formación de hábitos con rachas, deuda de bloques, streak freezes y mecánicas basadas en aversión a la pérdida. Úsalo cuando implementes sistemas de accountability, gamificación de productividad, o tracking de hábitos.

Habit Gamification System

Sistema completo de gamificación basado en los principios de Atomic Habits y psicología de la aversión a la pérdida (loss aversion). Diseñado específicamente para Life Blocks App.

Fundamentos Psicológicos

Aversión a la Pérdida (Loss Aversion)

El dolor de perder una racha de 50 días es 2.5x más intenso que la satisfacción de iniciar una nueva. Este principio impulsa el cumplimiento diario.

Efecto de Dotación (Endowment Effect)

Una vez que el usuario "posee" una racha, la valora más que antes de conseguirla. Cada día completado aumenta la inversión emocional.

Sesgo de Consistencia

Los humanos buscan mantener patrones de comportamiento establecidos. Una racha visible refuerza este sesgo.

Arquitectura del Sistema

┌─────────────────────────────────────────────────────────────┐
│                    USER COMPLETES BLOCK                     │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│  1. CHECK DAILY COMPLETION                                  │
│     • ¿Es el primer bloque completado hoy?                  │
│     • ¿Bloque marcado como "crítico"?                       │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│  2. UPDATE STREAK                                           │
│     • current_count++                                       │
│     • Si current > longest → longest = current              │
│     • last_completed = today                                │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│  3. CHECK MILESTONE                                         │
│     • ¿current_count en [7, 14, 30, 50, 100, 365]?         │
│     • Trigger celebration animation                        │
│     • Award achievement badge                              │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│  4. CLEAR DEBT (if exists)                                  │
│     • Remover bloque de "deuda de hábitos"                  │
│     • Actualizar streak debt counter                        │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    USER SKIPS BLOCK                         │
└────────────────────────┬────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────────────┐
│  1. CHECK STREAK FREEZE AVAILABILITY                        │
│     • ¿Tiene Streak Freeze disponible?                     │
│     • ¿Usuario quiere usarlo?                               │
└────────────┬───────────────────────┬────────────────────────┘
            YES                      NO
             │                        │
             ▼                        ▼
┌──────────────────────────┐  ┌─────────────────────────────┐
│  USE FREEZE              │  │  BREAK STREAK               │
│  • freeze_count--        │  │  • current_count = 0        │
│  • Streak preserved      │  │  • Show loss animation      │
│  • No debt               │  │  • Trigger "recovery mode"  │
└──────────────────────────┘  └────────────┬────────────────┘
                                           │
                                           ▼
                              ┌────────────────────────────┐
                              │  ADD TO DEBT QUEUE         │
                              │  • Si bloque es crítico    │
                              │  • debt_count++            │
                              │  • Show tomorrow           │
                              └────────────────────────────┘

Schema de Datos

Tabla: streaks

CREATE TABLE streaks (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  block_name TEXT NOT NULL,          -- "Deep Work", "Gym", "Reset Nocturno"
  current_count INTEGER DEFAULT 0,
  longest_count INTEGER DEFAULT 0,
  last_completed DATE,
  freeze_count INTEGER DEFAULT 2,    -- Resetea cada mes
  freeze_used_this_month INTEGER DEFAULT 0,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  
  UNIQUE(user_id, block_name)
);

CREATE INDEX idx_streaks_user ON streaks(user_id);
CREATE INDEX idx_streaks_current ON streaks(current_count DESC);

Tabla: habit_debt

CREATE TABLE habit_debt (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  block_id TEXT NOT NULL,
  block_name TEXT NOT NULL,
  original_date DATE NOT NULL,       -- Fecha original que se skipeo
  due_date DATE NOT NULL,            -- Fecha límite para completar
  status TEXT DEFAULT 'pending',     -- 'pending' | 'completed' | 'forgiven'
  completed_at TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_debt_user ON habit_debt(user_id);
CREATE INDEX idx_debt_status ON habit_debt(status);
CREATE INDEX idx_debt_due_date ON habit_debt(due_date);

Tabla: achievements

CREATE TABLE achievements (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  achievement_type TEXT NOT NULL,    -- 'streak_7', 'streak_30', 'debt_free_week'
  block_name TEXT,
  earned_at TEXT DEFAULT CURRENT_TIMESTAMP,
  metadata TEXT                      -- JSON con detalles extra
);

CREATE INDEX idx_achievements_user ON achievements(user_id);
CREATE INDEX idx_achievements_type ON achievements(achievement_type);

Servicio de Streaks (Angular)

// src/app/features/habits/services/streak.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { SQLiteService } from '@core/database/sqlite.service';
import { AnimationService } from '@core/animations/animation.service';

export interface Streak {
  id: string;
  userId: string;
  blockName: string;
  currentCount: number;
  longestCount: number;
  lastCompleted: string | null;
  freezeCount: number;
  freezeUsedThisMonth: number;
}

export interface HabitDebt {
  id: number;
  userId: string;
  blockId: string;
  blockName: string;
  originalDate: string;
  dueDate: string;
  status: 'pending' | 'completed' | 'forgiven';
}

const MILESTONES = [7, 14, 30, 50, 100, 365];
const MAX_FREEZE_PER_MONTH = 2;

@Injectable({ providedIn: 'root' })
export class StreakService {
  private sqlite = inject(SQLiteService);
  private animations = inject(AnimationService);
  
  // Estado reactivo
  streaks = signal<Streak[]>([]);
  debtedBlocks = signal<HabitDebt[]>([]);
  
  // Computeds
  totalStreaks = computed(() => 
    this.streaks().reduce((sum, s) => sum + s.currentCount, 0)
  );
  
  longestStreak = computed(() => 
    Math.max(...this.streaks().map(s => s.longestCount), 0)
  );
  
  pendingDebts = computed(() => 
    this.debtedBlocks().filter(d => d.status === 'pending').length
  );
  
  availableFreezes = computed(() => 
    this.streaks().reduce((sum, s) => sum + s.freezeCount, 0)
  );
  
  /**
   * Completar bloque y actualizar racha
   */
  async completeBlock(
    userId: string, 
    blockId: string, 
    blockName: string
  ): Promise<void> {
    const today = new Date().toISOString().split('T')[0];
    
    // 1. Obtener racha actual
    let streak = await this.getStreak(userId, blockName);
    
    if (!streak) {
      // Primera vez que completa este bloque
      streak = await this.createStreak(userId, blockName);
    }
    
    // 2. Verificar si ya completó hoy (evitar doble conteo)
    if (streak.lastCompleted === today) {
      console.warn('Block already completed today');
      return;
    }
    
    // 3. Verificar si rompió racha (más de 1 día sin completar)
    const daysSinceLastCompletion = this.getDaysDifference(
      streak.lastCompleted,
      today
    );
    
    if (daysSinceLastCompletion > 1) {
      // Racha rota, resetear
      await this.resetStreak(streak.id);
      streak.currentCount = 0;
    }
    
    // 4. Incrementar racha
    const newCount = streak.currentCount + 1;
    const newLongest = Math.max(newCount, streak.longestCount);
    
    await this.sqlite.db.run(
      `UPDATE streaks 
       SET current_count = ?,
           longest_count = ?,
           last_completed = ?,
           updated_at = CURRENT_TIMESTAMP
       WHERE id = ?`,
      [newCount, newLongest, today, streak.id]
    );
    
    // 5. Actualizar señal reactiva
    this.streaks.update(streaks => 
      streaks.map(s => 
        s.id === streak.id 
          ? { ...s, currentCount: newCount, longestCount: newLongest, lastCompleted: today }
          : s
      )
    );
    
    // 6. Verificar milestone
    if (MILESTONES.includes(newCount)) {
      await this.triggerMilestone(userId, blockName, newCount);
    }
    
    // 7. Limpiar deuda si existe
    await this.clearDebt(userId, blockId);
  }
  
  /**
   * Skipear bloque con opción de Streak Freeze
   */
  async skipBlock(
    userId: string,
    blockId: string,
    blockName: string,
    isCritical: boolean,
    useFreeze: boolean = false
  ): Promise<{ streakBroken: boolean; debtAdded: boolean }> {
    const streak = await this.getStreak(userId, blockName);
    
    if (!streak) {
      return { streakBroken: false, debtAdded: false };
    }
    
    let streakBroken = false;
    let debtAdded = false;
    
    if (useFreeze && streak.freezeCount > 0) {
      // Usar Streak Freeze
      await this.useStreakFreeze(streak.id);
      console.log('Streak preserved with freeze');
    } else {
      // Romper racha
      await this.breakStreak(streak.id, blockName);
      streakBroken = true;
      
      // Si es bloque crítico, agregar a deuda
      if (isCritical) {
        await this.addToDebt(userId, blockId, blockName);
        debtAdded = true;
      }
    }
    
    return { streakBroken, debtAdded };
  }
  
  /**
   * Usar Streak Freeze
   */
  private async useStreakFreeze(streakId: string): Promise<void> {
    await this.sqlite.db.run(
      `UPDATE streaks 
       SET freeze_count = freeze_count - 1,
           freeze_used_this_month = freeze_used_this_month + 1
       WHERE id = ?`,
      [streakId]
    );
    
    this.streaks.update(streaks =>
      streaks.map(s =>
        s.id === streakId
          ? { ...s, freezeCount: s.freezeCount - 1, freezeUsedThisMonth: s.freezeUsedThisMonth + 1 }
          : s
      )
    );
  }
  
  /**
   * Romper racha (con animación de pérdida)
   */
  private async breakStreak(streakId: string, blockName: string): Promise<void> {
    await this.sqlite.db.run(
      `UPDATE streaks 
       SET current_count = 0,
           updated_at = CURRENT_TIMESTAMP
       WHERE id = ?`,
      [streakId]
    );
    
    this.streaks.update(streaks =>
      streaks.map(s =>
        s.id === streakId ? { ...s, currentCount: 0 } : s
      )
    );
    
    // Trigger animación de pérdida
    this.animations.playStreakLost(blockName);
  }
  
  /**
   * Agregar bloque a deuda de hábitos
   */
  private async addToDebt(
    userId: string,
    blockId: string,
    blockName: string
  ): Promise<void> {
    const today = new Date().toISOString().split('T')[0];
    const tomorrow = new Date();
    tomorrow.setDate(tomorrow.getDate() + 1);
    const dueDate = tomorrow.toISOString().split('T')[0];
    
    await this.sqlite.db.run(
      `INSERT INTO habit_debt 
       (user_id, block_id, block_name, original_date, due_date, status)
       VALUES (?, ?, ?, ?, ?, 'pending')`,
      [userId, blockId, blockName, today, dueDate]
    );
    
    // Actualizar señal
    await this.loadDebtedBlocks(userId);
  }
  
  /**
   * Limpiar deuda al completar bloque
   */
  private async clearDebt(userId: string, blockId: string): Promise<void> {
    const result = await this.sqlite.db.query(
      `SELECT id FROM habit_debt 
       WHERE user_id = ? AND block_id = ? AND status = 'pending'
       LIMIT 1`,
      [userId, blockId]
    );
    
    if (result.values && result.values.length > 0) {
      const debtId = result.values[0].id;
      
      await this.sqlite.db.run(
        `UPDATE habit_debt 
         SET status = 'completed',
             completed_at = CURRENT_TIMESTAMP
         WHERE id = ?`,
        [debtId]
      );
      
      // Actualizar señal
      await this.loadDebtedBlocks(userId);
    }
  }
  
  /**
   * Trigger milestone (7, 30, 100 días)
   */
  private async triggerMilestone(
    userId: string,
    blockName: string,
    days: number
  ): Promise<void> {
    // 1. Registrar achievement
    const achievementId = `${userId}_${blockName}_${days}`;
    
    await this.sqlite.db.run(
      `INSERT OR IGNORE INTO achievements 
       (id, user_id, achievement_type, block_name, metadata)
       VALUES (?, ?, ?, ?, ?)`,
      [
        achievementId,
        userId,
        `streak_${days}`,
        blockName,
        JSON.stringify({ days, timestamp: new Date().toISOString() })
      ]
    );
    
    // 2. Trigger animación de celebración
    this.animations.playStreakMilestone(blockName, days);
    
    // 3. Mostrar notificación
    // TODO: Integrar con NotificationService
  }
  
  /**
   * Resetear racha a 0
   */
  private async resetStreak(streakId: string): Promise<void> {
    await this.sqlite.db.run(
      `UPDATE streaks 
       SET current_count = 0
       WHERE id = ?`,
      [streakId]
    );
  }
  
  /**
   * Obtener racha de un bloque
   */
  private async getStreak(userId: string, blockName: string): Promise<Streak | null> {
    const result = await this.sqlite.db.query(
      `SELECT * FROM streaks 
       WHERE user_id = ? AND block_name = ?`,
      [userId, blockName]
    );
    
    return result.values?.[0] || null;
  }
  
  /**
   * Crear nueva racha
   */
  private async createStreak(userId: string, blockName: string): Promise<Streak> {
    const id = crypto.randomUUID();
    
    await this.sqlite.db.run(
      `INSERT INTO streaks 
       (id, user_id, block_name, current_count, longest_count, freeze_count)
       VALUES (?, ?, ?, 0, 0, ?)`,
      [id, userId, blockName, MAX_FREEZE_PER_MONTH]
    );
    
    return {
      id,
      userId,
      blockName,
      currentCount: 0,
      longestCount: 0,
      lastCompleted: null,
      freezeCount: MAX_FREEZE_PER_MONTH,
      freezeUsedThisMonth: 0
    };
  }
  
  /**
   * Calcular diferencia de días
   */
  private getDaysDifference(dateStr1: string | null, dateStr2: string): number {
    if (!dateStr1) return Infinity;
    
    const date1 = new Date(dateStr1);
    const date2 = new Date(dateStr2);
    const diffTime = Math.abs(date2.getTime() - date1.getTime());
    return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
  }
  
  /**
   * Cargar rachas del usuario
   */
  async loadStreaks(userId: string): Promise<void> {
    const result = await this.sqlite.db.query(
      'SELECT * FROM streaks WHERE user_id = ?',
      [userId]
    );
    
    this.streaks.set(result.values || []);
  }
  
  /**
   * Cargar bloques en deuda
   */
  async loadDebtedBlocks(userId: string): Promise<void> {
    const result = await this.sqlite.db.query(
      `SELECT * FROM habit_debt 
       WHERE user_id = ? AND status = 'pending'
       ORDER BY due_date ASC`,
      [userId]
    );
    
    this.debtedBlocks.set(result.values || []);
  }
  
  /**
   * Resetear Streak Freezes (mensual)
   */
  async resetMonthlyFreezes(userId: string): Promise<void> {
    await this.sqlite.db.run(
      `UPDATE streaks 
       SET freeze_count = ?,
           freeze_used_this_month = 0
       WHERE user_id = ?`,
      [MAX_FREEZE_PER_MONTH, userId]
    );
    
    await this.loadStreaks(userId);
  }
  
  /**
   * Perdonar deuda (manual)
   */
  async forgiveDebt(debtId: number): Promise<void> {
    await this.sqlite.db.run(
      `UPDATE habit_debt 
       SET status = 'forgiven'
       WHERE id = ?`,
      [debtId]
    );
    
    // Actualizar señal
    this.debtedBlocks.update(debts =>
      debts.filter(d => d.id !== debtId)
    );
  }
}

Componente de Visualización de Racha

// src/app/features/habits/components/streak-badge/streak-badge.component.ts
@Component({
  selector: 'app-streak-badge',
  standalone: true,
  imports: [CommonModule, BadgeModule],
  template: `
    <div class="streak-badge" [class.milestone]="isMilestone()">
      <div class="fire-icon" #fireIcon>
        🔥
      </div>
      <div class="counter">
        <span class="current">{{ streak().currentCount }}</span>
        <span class="label">días</span>
      </div>
      @if (streak().longestCount > streak().currentCount) {
        <div class="longest">
          Récord: {{ streak().longestCount }}
        </div>
      }
    </div>
  `,
  styles: [`
    .streak-badge {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 0.5rem;
      padding: 1rem;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      border-radius: 1rem;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      
      &.milestone {
        animation: glow 2s infinite alternate;
      }
      
      .fire-icon {
        font-size: 3rem;
        filter: drop-shadow(0 0 10px rgba(255, 165, 0, 0.5));
      }
      
      .counter {
        display: flex;
        flex-direction: column;
        align-items: center;
        
        .current {
          font-size: 2.5rem;
          font-weight: bold;
          color: white;
        }
        
        .label {
          font-size: 0.875rem;
          color: rgba(255, 255, 255, 0.8);
        }
      }
      
      .longest {
        font-size: 0.75rem;
        color: rgba(255, 255, 255, 0.6);
      }
    }
    
    @keyframes glow {
      from {
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      }
      to {
        box-shadow: 0 0 20px rgba(255, 165, 0, 0.8);
      }
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StreakBadgeComponent implements AfterViewInit {
  @ViewChild('fireIcon') fireIcon!: ElementRef;
  
  streak = input.required<Streak>();
  
  private animations = inject(AnimationService);
  
  isMilestone = computed(() => 
    MILESTONES.includes(this.streak().currentCount)
  );
  
  ngAfterViewInit() {
    if (this.isMilestone()) {
      this.animations.playStreakAnimation(
        this.fireIcon.nativeElement,
        this.streak().currentCount
      );
    }
  }
}

Componente de Deuda de Hábitos

// src/app/features/habits/components/debt-queue/debt-queue.component.ts
@Component({
  selector: 'app-debt-queue',
  standalone: true,
  imports: [CommonModule, CardModule, ButtonModule],
  template: `
    <div class="debt-queue">
      <h3>Bloques Pendientes (Deuda)</h3>
      
      @if (debts().length === 0) {
        <div class="empty-state">
          <span class="icon">✅</span>
          <p>¡Sin deudas! Estás al día con tus hábitos.</p>
        </div>
      } @else {
        <div class="debt-list">
          @for (debt of debts(); track debt.id) {
            <p-card>
              <div class="debt-item">
                <div class="info">
                  <strong>{{ debt.blockName }}</strong>
                  <small>Pendiente desde {{ debt.originalDate | date }}</small>
                  <small class="due">Vence: {{ debt.dueDate | date }}</small>
                </div>
                <div class="actions">
                  <p-button 
                    label="Completar ahora"
                    icon="pi pi-check"
                    (onClick)="completeDebt(debt)"
                  />
                  <p-button 
                    label="Perdonar"
                    icon="pi pi-times"
                    severity="secondary"
                    (onClick)="forgiveDebt(debt)"
                  />
                </div>
              </div>
            </p-card>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .debt-queue {
      padding: 1rem;
      
      .empty-state {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 1rem;
        padding: 2rem;
        
        .icon {
          font-size: 4rem;
        }
      }
      
      .debt-list {
        display: flex;
        flex-direction: column;
        gap: 1rem;
        
        .debt-item {
          display: flex;
          justify-content: space-between;
          align-items: center;
          
          .info {
            display: flex;
            flex-direction: column;
            gap: 0.25rem;
            
            .due {
              color: var(--red-500);
              font-weight: 600;
            }
          }
          
          .actions {
            display: flex;
            gap: 0.5rem;
          }
        }
      }
    }
  `]
})
export class DebtQueueComponent {
  debts = input.required<HabitDebt[]>();
  
  private streakService = inject(StreakService);
  
  async completeDebt(debt: HabitDebt) {
    // Completar bloque y limpiar deuda
    await this.streakService.completeBlock(
      debt.userId,
      debt.blockId,
      debt.blockName
    );
  }
  
  async forgiveDebt(debt: HabitDebt) {
    await this.streakService.forgiveDebt(debt.id);
  }
}

Estrategias de Retención

1. Recovery Mode

Cuando se rompe una racha larga (30+ días), activar "modo recuperación":

  • Ofrecer 1 Streak Freeze extra
  • Enviar notificación motivacional
  • Mostrar progreso histórico para no desmoralizarse

2. Milestone Rewards

  • 7 días: Badge "Constante"
  • 30 días: Badge "Comprometido"
  • 100 días: Badge "Imparable"
  • 365 días: Badge "Leyenda"

3. Social Proof

Mostrar estadísticas de otros usuarios (anónimas):

  • "El 80% de usuarios con racha de 30+ días usan Streak Freeze"
  • "Promedio de racha más larga: 45 días"

Versión: 1.0.0
Última actualización: 2025-01-30
Basado en: Atomic Habits (James Clear) + Hooked (Nir Eyal)

Install via CLI
npx skills add https://github.com/Akxlarre/APP-personal --skill habit-gamification
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator