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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user