Alpha v0.1.0 — KAPPA Hub inicial

- Auth con KAPPA (login + token Bearer)
- Cliente HTTP para 10 endpoints (proyectos, HUs, bitácoras, planeaciones)
- Dashboard multi-proyecto con concepto médico Teloprax
- Calendario colombiano con 19 feriados (Ley Emiliani + Pascua)
- Scheduler tipo cron con Dexie (reglas recurrentes, toasts, log)
- Diseño marca Teloprax: Inter, Space Grotesk, #1A1A2E, rojo #E63946
- Stack: Vue 3 + TypeScript + Pinia + Vite + Bun
This commit is contained in:
2026-05-22 20:18:54 -05:00
commit 66fd4e175a
26 changed files with 2227 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
/**
* Feriados colombianos.
*
* Incluye:
* - Fechas fijas (no se mueven)
* - Fechas sujetas a Ley Emiliani (se trasladan al lunes siguiente)
* - Fechas variables basadas en Pascua
*
* Para RUMBO esto se vuelve multi-país. Por ahora solo Colombia.
*/
interface HolidayDef {
name: string
/** Si true, si cae entre martes y domingo se mueve al lunes siguiente */
emiliani: boolean
/** Mes 1-12 */
month: number
/** Día 1-31 (solo si no es basado en Pascua) */
day: number
/** Offset desde Pascua en días (solo si es basado en Pascua) */
easterOffset?: number
}
const HOLIDAY_DEFS: HolidayDef[] = [
{ name: 'Año Nuevo', emiliani: false, month: 1, day: 1 },
{ name: 'Reyes Magos', emiliani: true, month: 1, day: 6 },
{ name: 'San José', emiliani: true, month: 3, day: 19 },
{ name: 'Jueves Santo', emiliani: false, month: 0, day: 0, easterOffset: -3 },
{ name: 'Viernes Santo', emiliani: false, month: 0, day: 0, easterOffset: -2 },
{ name: 'Domingo de Pascua', emiliani: false, month: 0, day: 0, easterOffset: 0 },
{ name: 'Día del Trabajo', emiliani: false, month: 5, day: 1 },
{ name: 'Ascensión del Señor', emiliani: true, month: 0, day: 0, easterOffset: 43 },
{ name: 'Corpus Christi', emiliani: true, month: 0, day: 0, easterOffset: 64 },
{ name: 'Sagrado Corazón', emiliani: true, month: 0, day: 0, easterOffset: 71 },
{ name: 'San Pedro y San Pablo', emiliani: true, month: 6, day: 29 },
{ name: 'Día de la Independencia',emiliani: false, month: 7, day: 20 },
{ name: 'Batalla de Boyacá', emiliani: false, month: 8, day: 7 },
{ name: 'Asunción de la Virgen', emiliani: true, month: 8, day: 15 },
{ name: 'Día de la Raza', emiliani: true, month: 10, day: 12 },
{ name: 'Todos los Santos', emiliani: true, month: 11, day: 1 },
{ name: 'Indep. de Cartagena', emiliani: true, month: 11, day: 11 },
{ name: 'Inmaculada Concepción', emiliani: false, month: 12, day: 8 },
{ name: 'Navidad', emiliani: false, month: 12, day: 25 },
]
/**
* Calcula la fecha de Pascua (Domingo de Resurrección) para un año dado.
* Algoritmo de Butcher (Gregoriano).
*/
function easterSunday(year: number): Date {
const a = year % 19
const b = Math.floor(year / 100)
const c = year % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const month = Math.floor((h + l - 7 * m + 114) / 31)
const day = ((h + l - 7 * m + 114) % 31) + 1
return new Date(year, month - 1, day)
}
/**
* Aplica la Ley Emiliani: si la fecha no cae en lunes, la mueve al lunes siguiente.
*/
function applyEmiliani(date: Date): Date {
const dayOfWeek = date.getDay() // 0=dom, 1=lun, ..., 6=sáb
if (dayOfWeek === 1) return date // ya es lunes
const daysUntilMonday = (8 - dayOfWeek) % 7
const result = new Date(date)
result.setDate(result.getDate() + daysUntilMonday)
return result
}
function dateFromDayMonth(year: number, month: number, day: number): Date {
return new Date(year, month - 1, day)
}
export function getColombianHolidays(year: number): { date: Date; name: string }[] {
const easter = easterSunday(year)
const holidays: { date: Date; name: string }[] = []
for (const def of HOLIDAY_DEFS) {
let date: Date
if (def.easterOffset !== undefined) {
date = new Date(easter)
date.setDate(date.getDate() + def.easterOffset)
} else {
date = dateFromDayMonth(year, def.month, def.day)
}
if (def.emiliani) {
date = applyEmiliani(date)
}
// Solo agregar si no es domingo ya (los domingos siempre son no laborales)
holidays.push({ date, name: def.name })
}
return holidays.sort((a, b) => a.date.getTime() - b.date.getTime())
}
export function isHoliday(date: Date): { holiday: boolean; name?: string } {
const year = date.getFullYear()
const holidays = getColombianHolidays(year)
const found = holidays.find(h =>
h.date.getFullYear() === date.getFullYear() &&
h.date.getMonth() === date.getMonth() &&
h.date.getDate() === date.getDate()
)
return found ? { holiday: true, name: found.name } : { holiday: false }
}
export function isWorkingDay(date: Date): boolean {
const day = date.getDay()
if (day === 0 || day === 6) return false
return !isHoliday(date).holiday
}
export function addWorkingDays(date: Date, days: number): Date {
const result = new Date(date)
let added = 0
while (added < days) {
result.setDate(result.getDate() + 1)
if (isWorkingDay(result)) added++
}
return result
}
export function nextWorkingDay(date: Date): Date {
return addWorkingDays(date, 1)
}
+128
View File
@@ -0,0 +1,128 @@
import type {
KappaLoginPayload,
KappaLoginResponse,
KappaInitiative,
KappaUserStory,
KappaLogbookMaster,
KappaLogbookEntry,
KappaPlanningMaster,
KappaPlanningEntry,
KappaBusinessRule,
KappaRequirement,
} from '@/types/kappa'
const BASE = '/api'
class KappaAPI {
private token: string | null = null
constructor() {
this.token = localStorage.getItem('kappa_token')
}
private get headers(): Record<string, string> {
const h: Record<string, string> = {
'Content-Type': 'application/json',
}
if (this.token) {
h['Authorization'] = `Bearer ${this.token}`
}
return h
}
private async request<T>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const opts: RequestInit = {
method,
headers: this.headers,
}
if (body && method !== 'GET') {
opts.body = JSON.stringify(body)
}
const res = await fetch(`${BASE}${path}`, opts)
if (!res.ok) {
const text = await res.text()
throw new Error(`KAPPA ${method} ${path}: ${res.status}${text.slice(0, 200)}`)
}
return res.json()
}
// ─── Auth ────────────────────────────────────────────
async login(payload: KappaLoginPayload): Promise<KappaLoginResponse> {
const data = await this.request<KappaLoginResponse>(
'POST',
'/users/login/',
payload
)
this.token = data.access || data.token || data.key || null
if (this.token) {
localStorage.setItem('kappa_token', this.token)
}
return data
}
logout() {
this.token = null
localStorage.removeItem('kappa_token')
}
get isAuthenticated(): boolean {
return !!this.token
}
// ─── Proyectos (Initiatives) ─────────────────────────
async getInitiatives(): Promise<KappaInitiative[]> {
return this.request<KappaInitiative[]>('GET', '/initiatives-all/')
}
// ─── User Stories ────────────────────────────────────
async createUserStory(story: KappaUserStory): Promise<KappaUserStory> {
return this.request<KappaUserStory>('POST', '/userstorys/create/', story)
}
// ─── Users ───────────────────────────────────────────
async getUsers(): Promise<unknown[]> {
return this.request<unknown[]>('GET', '/users/all/')
}
// ─── Bitácoras (Logbooks) ────────────────────────────
async createLogbookMaster(data: KappaLogbookMaster): Promise<KappaLogbookMaster> {
return this.request<KappaLogbookMaster>('POST', '/logbooks_master/create/', data)
}
async createLogbookEntry(data: KappaLogbookEntry): Promise<KappaLogbookEntry> {
return this.request<KappaLogbookEntry>('POST', '/logbooks/create/', data)
}
// ─── Planeaciones (Plannings) ────────────────────────
async createPlanningMaster(data: KappaPlanningMaster): Promise<KappaPlanningMaster> {
return this.request<KappaPlanningMaster>('POST', '/plannings_master/create/', data)
}
async createPlanningEntry(data: KappaPlanningEntry): Promise<KappaPlanningEntry> {
return this.request<KappaPlanningEntry>('POST', '/plannings/create/', data)
}
// ─── Business Rules ──────────────────────────────────
async createBusinessRule(data: KappaBusinessRule): Promise<unknown> {
return this.request<unknown>('POST', '/business-rules/create/', data)
}
// ─── Requisitos ──────────────────────────────────────
async createRequirement(data: KappaRequirement): Promise<unknown> {
return this.request<unknown>('POST', '/functionalrequirements/create/', data)
}
}
export const kappa = new KappaAPI()
+201
View File
@@ -0,0 +1,201 @@
/**
* Scheduler tipo cron para KAPPA Hub.
*
* Las tareas se ejecutan solo cuando la app está abierta (web app).
* En RUMBO (Tauri) esto se vuelve un proceso en segundo plano real.
*/
import Dexie, { type EntityTable } from 'dexie'
interface ScheduleRule {
id?: number
name: string
description?: string
/** 0=dom, 1=lun, ..., 6=sáb */
daysOfWeek: number[]
/** 0-23 */
hour: number
/** 0-59 */
minute: number
action: ScheduleAction
enabled: boolean
lastRun: string | null
createdAt: string
}
type ScheduleAction =
| { type: 'generate_progress_report' }
| { type: 'check_hus' }
| { type: 'daily_prep' }
| { type: 'reminder'; message: string }
interface ExecutionLog {
id?: number
ruleId: number
ruleName: string
executedAt: string
success: boolean
message: string
}
const db = new Dexie('kappa-hub-scheduler') as Dexie & {
rules: EntityTable<ScheduleRule, 'id'>
logs: EntityTable<ExecutionLog, 'id'>
}
db.version(1).stores({
rules: '++id, enabled, hour, minute',
logs: '++id, ruleId, executedAt',
})
export { db }
export type { ScheduleRule, ScheduleAction, ExecutionLog }
// ─── Engine ─────────────────────────────────────────────────
type Subscriber = (ruleId: number, message: string) => void
let intervalId: ReturnType<typeof setInterval> | null = null
let subscribers: Subscriber[] = []
function now(): string {
return new Date().toISOString()
}
function matchesRule(rule: ScheduleRule, date: Date): boolean {
if (!rule.enabled) return false
if (!rule.daysOfWeek.includes(date.getDay())) return false
if (rule.hour !== date.getHours()) return false
if (rule.minute !== date.getMinutes()) return false
return true
}
async function executeRule(rule: ScheduleRule): Promise<string> {
const projectNames: string[] = [] // se llenará del store
switch (rule.action.type) {
case 'generate_progress_report':
return `📊 Informe de avance generado para ${projectNames.length || 'todos los'} proyectos.`
case 'check_hus': {
// En la iteración real consulta KAPPA API
return `🎫 HUs revisadas. ${projectNames.length || 'N'} proyectos actualizados.`
}
case 'daily_prep':
return `📋 Daily prep: recordatorio de revisar HUs pendientes y bloqueos para hoy.`
case 'reminder':
return `${rule.action.message}`
default:
return `✅ Tarea "${rule.name}" ejecutada.`
}
}
async function runRule(rule: ScheduleRule) {
let success = true
let message = ''
try {
message = await executeRule(rule)
} catch (e: any) {
success = false
message = e.message
}
await db.rules.update(rule.id!, { lastRun: now() })
await db.logs.add({
ruleId: rule.id!,
ruleName: rule.name,
executedAt: now(),
success,
message,
})
for (const sub of subscribers) {
sub(rule.id!, message)
}
}
async function tick() {
const date = new Date()
const rules = await db.rules.where('enabled').equals(1).toArray()
for (const rule of rules) {
if (matchesRule(rule, date)) {
await runRule(rule)
}
}
}
export function startScheduler() {
if (intervalId) return
tick() // ejecutar inmediatamente por si acaba de pasar el minuto
intervalId = setInterval(tick, 60_000) // cada minuto
}
export function stopScheduler() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
export function onScheduledTask(cb: Subscriber) {
subscribers.push(cb)
return () => {
subscribers = subscribers.filter(s => s !== cb)
}
}
// ─── Helpers ────────────────────────────────────────────────
export function computeNextRun(rule: ScheduleRule): Date | null {
if (!rule.enabled || rule.daysOfWeek.length === 0) return null
const now = new Date()
const candidate = new Date(now)
candidate.setHours(rule.hour, rule.minute, 0, 0)
// Buscar el próximo día que coincida
for (let i = 0; i < 8; i++) {
const check = new Date(candidate)
check.setDate(check.getDate() + i)
if (rule.daysOfWeek.includes(check.getDay())) {
if (check > now) return check
}
}
return null
}
export const DAY_LABELS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']
export const DEFAULT_RULES: Omit<ScheduleRule, 'id' | 'createdAt' | 'lastRun'>[] = [
{
name: 'Informe semanal de avance',
description: 'Genera un resumen de estado de cada proyecto activo',
daysOfWeek: [5], // viernes
hour: 9,
minute: 0,
action: { type: 'generate_progress_report' },
enabled: true,
},
{
name: 'Revisión de HUs pendientes',
description: 'Revisa HUs en progreso, bloqueadas y vencidas en todos los proyectos',
daysOfWeek: [1, 2, 3, 4, 5], // lun-vie
hour: 8,
minute: 0,
action: { type: 'check_hus' },
enabled: true,
},
{
name: 'Daily prep',
description: 'Prepara el resumen diario: qué HUs toca hoy, bloqueos, entregables próximos',
daysOfWeek: [1, 2, 3, 4, 5],
hour: 7,
minute: 30,
action: { type: 'daily_prep' },
enabled: true,
},
]