Compare commits
No commits in common. 'main' and 'webui' have entirely different histories.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 574 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
@ -1,335 +1,326 @@
|
||||
<script lang="ts">
|
||||
import logo from "$lib/assets/wtasklogo192.png";
|
||||
import { page } from "$app/stores";
|
||||
import Toast from "$lib/ui/feedback/Toast.svelte";
|
||||
$: pathname = $page.url.pathname;
|
||||
$: currentTitle =
|
||||
pathname === "/app"
|
||||
? "Tareas"
|
||||
: pathname.startsWith("/app/groups")
|
||||
? "Grupos"
|
||||
: pathname.startsWith("/app/preferences")
|
||||
? "Recordatorios"
|
||||
: pathname.startsWith("/app/integrations")
|
||||
? "Calendarios"
|
||||
: "Tareas";
|
||||
import { page } from "$app/stores";
|
||||
import Toast from "$lib/ui/feedback/Toast.svelte";
|
||||
$: pathname = $page.url.pathname;
|
||||
$: currentTitle =
|
||||
pathname === "/app"
|
||||
? "Tareas"
|
||||
: pathname.startsWith("/app/groups")
|
||||
? "Grupos"
|
||||
: pathname.startsWith("/app/preferences")
|
||||
? "Recordatorios"
|
||||
: pathname.startsWith("/app/integrations")
|
||||
? "Calendarios"
|
||||
: "Tareas";
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container row">
|
||||
<a class="brand" href="/app" aria-label="Inicio"
|
||||
><img src={logo} alt="Wtask" /></a
|
||||
>
|
||||
<nav class="nav">
|
||||
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
|
||||
>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
>Recordatorios</a
|
||||
>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
>Calendarios</a
|
||||
>
|
||||
</nav>
|
||||
<form method="POST" action="/api/logout">
|
||||
<button type="submit" class="logout">Cerrar sesión</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="container row">
|
||||
<a class="brand" href="/app" aria-label="Inicio">Tareas</a>
|
||||
<nav class="nav">
|
||||
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
|
||||
>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
>Recordatorios</a
|
||||
>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
>Calendarios</a
|
||||
>
|
||||
</nav>
|
||||
<form method="POST" action="/api/logout">
|
||||
<button type="submit" class="logout">Cerrar sesión</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Barra superior móvil (solo título) -->
|
||||
<div class="mobile-topbar" aria-hidden="true">
|
||||
<div class="container topbar-inner">{currentTitle}</div>
|
||||
<div class="container topbar-inner">{currentTitle}</div>
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="container main">
|
||||
<slot />
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<nav class="tabbar" aria-label="Navegación inferior">
|
||||
<a
|
||||
href="/app"
|
||||
class:active={$page.url.pathname === "/app"}
|
||||
aria-label="Tareas"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 117.45 122.88">
|
||||
<path
|
||||
class="tabbar-icon-svg"
|
||||
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
|
||||
/>
|
||||
</svg></span
|
||||
>
|
||||
<span class="label">Tareas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}
|
||||
aria-label="Grupos"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 122.88 91.99"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Grupos</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
aria-label="Recordatorios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
fill-rule="nonzero"
|
||||
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
|
||||
/></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Alertas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
aria-label="Calendarios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 110.01 122.88"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Calendarios</span>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="/api/logout"
|
||||
class="logout-tab"
|
||||
aria-label="Salir"
|
||||
>
|
||||
<button type="submit">
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 113.525 122.879"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Salir</span>
|
||||
</button>
|
||||
</form>
|
||||
<a
|
||||
href="/app"
|
||||
class:active={$page.url.pathname === "/app"}
|
||||
aria-label="Tareas"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 117.45 122.88">
|
||||
<path
|
||||
class="tabbar-icon-svg"
|
||||
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
|
||||
/>
|
||||
</svg></span
|
||||
>
|
||||
<span class="label">Tareas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}
|
||||
aria-label="Grupos"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 122.88 91.99"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Grupos</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
aria-label="Recordatorios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
fill-rule="nonzero"
|
||||
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
|
||||
/></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Alertas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
aria-label="Calendarios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 110.01 122.88"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Calendarios</span>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="/api/logout"
|
||||
class="logout-tab"
|
||||
aria-label="Salir"
|
||||
>
|
||||
<button type="submit">
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 113.525 122.879"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Salir</span>
|
||||
</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
backdrop-filter: saturate(180%) blur(8px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(8px);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 58px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.2px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.brand img {
|
||||
display: block;
|
||||
height: 48px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav a {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav a.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 3px;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
}
|
||||
}
|
||||
.logout {
|
||||
margin-left: var(--space-2);
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.main {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
backdrop-filter: saturate(180%) blur(8px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(8px);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 58px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.2px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav a {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav a.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 3px;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
}
|
||||
}
|
||||
.logout {
|
||||
margin-left: var(--space-2);
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.main {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Barra superior móvil oculta por defecto */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
/* Barra superior móvil oculta por defecto */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Barra de pestañas inferior (solo móvil) */
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
min-height: 48px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.tabbar a,
|
||||
.tabbar button {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-surface);
|
||||
box-shadow: 0 0 8px 4px var(--color-border);
|
||||
}
|
||||
.tabbar form.logout-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.tabbar a.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Atenuar la pestaña de Calendarios cuando está inactiva */
|
||||
.tabbar a.calendar {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tabbar a.calendar.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.tabbar .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.tabbar .label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.tabbar-icon-svg {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
/* Reservar espacio en el main para no tapar contenido y la barra superior */
|
||||
.main {
|
||||
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tabbar .label {
|
||||
display: auto;
|
||||
}
|
||||
}
|
||||
/* Barra de pestañas inferior (solo móvil) */
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
min-height: 48px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.tabbar a,
|
||||
.tabbar button {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-surface);
|
||||
box-shadow: 0 0 8px 4px var(--color-border);
|
||||
}
|
||||
.tabbar form.logout-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.tabbar a.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Atenuar la pestaña de Calendarios cuando está inactiva */
|
||||
.tabbar a.calendar {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tabbar a.calendar.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.tabbar .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.tabbar .label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.tabbar-icon-svg {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
/* Reservar espacio en el main para no tapar contenido y la barra superior */
|
||||
.main {
|
||||
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tabbar .label {
|
||||
display: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ocultar header y mostrar topbar en móvil */
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
display: none;
|
||||
}
|
||||
.mobile-topbar {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 24px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.mobile-topbar .topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
/* Ocultar header y mostrar topbar en móvil */
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
display: none;
|
||||
}
|
||||
.mobile-topbar {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 24px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.mobile-topbar .topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import "$lib/styles/tokens.css";
|
||||
import "$lib/styles/base.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import Toast from "$lib/ui/feedback/Toast.svelte";
|
||||
import '$lib/styles/tokens.css';
|
||||
import '$lib/styles/base.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import Toast from '$lib/ui/feedback/Toast.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<link rel="icon" href={favicon} />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
<slot />
|
||||
<Toast />
|
||||
|
||||
|
||||
@ -1,251 +1,240 @@
|
||||
<script lang="ts">
|
||||
import TaskItem from "$lib/ui/data/TaskItem.svelte";
|
||||
import Card from "$lib/ui/layout/Card.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
type GroupItem = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
counts: { open: number; unassigned: number };
|
||||
};
|
||||
type Task = {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
display_code: number | null;
|
||||
group_id?: string | null;
|
||||
assignees?: string[];
|
||||
};
|
||||
|
||||
export let data: {
|
||||
userId: string | null;
|
||||
groups: GroupItem[];
|
||||
itemsByGroup: Record<string, Task[]>;
|
||||
unassignedFirst?: boolean;
|
||||
};
|
||||
const groups = data.groups || [];
|
||||
let itemsByGroup: Record<string, Task[]> = {};
|
||||
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
|
||||
itemsByGroup[gid] = Array.isArray(arr as any) ? [...(arr as any)] : [];
|
||||
}
|
||||
|
||||
function buildQuery(params: { unassignedFirst?: boolean }) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
|
||||
let collapsed: Record<string, boolean> = {};
|
||||
|
||||
function hasTasks(groupId: string): boolean {
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
}
|
||||
|
||||
function defaultCollapsedFor(groupId: string): boolean {
|
||||
// Por defecto, colapsado si no tiene tareas abiertas
|
||||
return !hasTasks(groupId);
|
||||
}
|
||||
|
||||
function isOpen(groupId: string): boolean {
|
||||
const v = collapsed[groupId];
|
||||
if (typeof v === "boolean") return !v;
|
||||
return !defaultCollapsedFor(groupId);
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
try {
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
const pruned: Record<string, boolean> = {};
|
||||
for (const id of Object.keys(collapsed)) {
|
||||
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(pruned));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleToggle(groupId: string, e: Event) {
|
||||
const open = (e.currentTarget as HTMLDetailsElement).open;
|
||||
collapsed = { ...collapsed, [groupId]: !open };
|
||||
saveCollapsed();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
const map: Record<string, boolean> = {};
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
for (const g of groups) {
|
||||
map[g.id] =
|
||||
typeof saved?.[g.id] === "boolean"
|
||||
? !!saved[g.id]
|
||||
: defaultCollapsedFor(g.id);
|
||||
}
|
||||
// Limpieza de claves obsoletas en storage
|
||||
const cleaned: Record<string, boolean> = {};
|
||||
for (const k of Object.keys(saved || {})) {
|
||||
if (currentIds.has(k)) cleaned[k] = !!saved[k];
|
||||
}
|
||||
collapsed = map;
|
||||
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
|
||||
} catch {
|
||||
// si falla, dejamos los defaults (basados en tareas)
|
||||
collapsed = {};
|
||||
}
|
||||
});
|
||||
|
||||
function maintainScrollWhile(mutate: () => void) {
|
||||
const y = window.scrollY;
|
||||
mutate();
|
||||
queueMicrotask(() => window.scrollTo({ top: y }));
|
||||
}
|
||||
|
||||
function updateGroupTask(
|
||||
groupId: string,
|
||||
detail: {
|
||||
id: number;
|
||||
action: string;
|
||||
patch: Partial<
|
||||
Task & { completed?: boolean; completed_at?: string | null }
|
||||
>;
|
||||
},
|
||||
) {
|
||||
const { id, action, patch } = detail;
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
const idx = arr.findIndex((t) => t.id === id);
|
||||
|
||||
if (action === "complete") {
|
||||
if (idx >= 0) {
|
||||
maintainScrollWhile(() => {
|
||||
arr.splice(idx, 1);
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx >= 0) {
|
||||
arr[idx] = { ...arr[idx], ...patch };
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
}
|
||||
}
|
||||
import TaskItem from "$lib/ui/data/TaskItem.svelte";
|
||||
import Card from "$lib/ui/layout/Card.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
type GroupItem = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
counts: { open: number; unassigned: number };
|
||||
};
|
||||
type Task = {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
display_code: number | null;
|
||||
group_id?: string | null;
|
||||
assignees?: string[];
|
||||
};
|
||||
|
||||
export let data: {
|
||||
userId: string | null;
|
||||
groups: GroupItem[];
|
||||
itemsByGroup: Record<string, Task[]>;
|
||||
unassignedFirst?: boolean;
|
||||
};
|
||||
const groups = data.groups || [];
|
||||
let itemsByGroup: Record<string, Task[]> = {};
|
||||
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
|
||||
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
|
||||
}
|
||||
|
||||
function buildQuery(params: { unassignedFirst?: boolean }) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
|
||||
let collapsed: Record<string, boolean> = {};
|
||||
|
||||
function hasTasks(groupId: string): boolean {
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
}
|
||||
|
||||
function defaultCollapsedFor(groupId: string): boolean {
|
||||
// Por defecto, colapsado si no tiene tareas abiertas
|
||||
return !hasTasks(groupId);
|
||||
}
|
||||
|
||||
function isOpen(groupId: string): boolean {
|
||||
const v = collapsed[groupId];
|
||||
if (typeof v === "boolean") return !v;
|
||||
return !defaultCollapsedFor(groupId);
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
try {
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
const pruned: Record<string, boolean> = {};
|
||||
for (const id of Object.keys(collapsed)) {
|
||||
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(pruned));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleToggle(groupId: string, e: Event) {
|
||||
const open = (e.currentTarget as HTMLDetailsElement).open;
|
||||
collapsed = { ...collapsed, [groupId]: !open };
|
||||
saveCollapsed();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
const map: Record<string, boolean> = {};
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
for (const g of groups) {
|
||||
map[g.id] =
|
||||
typeof saved?.[g.id] === "boolean"
|
||||
? !!saved[g.id]
|
||||
: defaultCollapsedFor(g.id);
|
||||
}
|
||||
// Limpieza de claves obsoletas en storage
|
||||
const cleaned: Record<string, boolean> = {};
|
||||
for (const k of Object.keys(saved || {})) {
|
||||
if (currentIds.has(k)) cleaned[k] = !!saved[k];
|
||||
}
|
||||
collapsed = map;
|
||||
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
|
||||
} catch {
|
||||
// si falla, dejamos los defaults (basados en tareas)
|
||||
collapsed = {};
|
||||
}
|
||||
});
|
||||
|
||||
function maintainScrollWhile(mutate: () => void) {
|
||||
const y = window.scrollY;
|
||||
mutate();
|
||||
queueMicrotask(() => window.scrollTo({ top: y }));
|
||||
}
|
||||
|
||||
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
|
||||
const { id, action, patch } = detail;
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
const idx = arr.findIndex((t) => t.id === id);
|
||||
|
||||
if (action === 'complete') {
|
||||
if (idx >= 0) {
|
||||
maintainScrollWhile(() => {
|
||||
arr.splice(idx, 1);
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx >= 0) {
|
||||
arr[idx] = { ...arr[idx], ...patch };
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Grupos</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Grupos</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<p>No perteneces a ningún grupo permitido.</p>
|
||||
<p>No perteneces a ningún grupo permitido.</p>
|
||||
{:else}
|
||||
<h1 class="title">Grupos</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!data.unassignedFirst}
|
||||
on:change={(e) => {
|
||||
const checked = (e.currentTarget as HTMLInputElement).checked;
|
||||
const q = buildQuery({ unassignedFirst: checked });
|
||||
location.href = q ? `/app/groups?${q}` : `/app/groups`;
|
||||
}}
|
||||
/>
|
||||
Sin responsable primero
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#each groups as g (g.id)}
|
||||
<details
|
||||
class="group"
|
||||
open={isOpen(g.id)}
|
||||
on:toggle={(e) => handleToggle(g.id, e)}
|
||||
>
|
||||
<summary class="group-header">
|
||||
<span class="name">{g.name ?? g.id}</span>
|
||||
<span class="counts">
|
||||
<span class="badge">tareas: {g.counts.open}</span>
|
||||
<span class="badge warn">🙅♂️: {g.counts.unassigned}</span>
|
||||
</span>
|
||||
</summary>
|
||||
{#if isOpen(g.id)}
|
||||
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each itemsByGroup[g.id] || [] as t (t.id)}
|
||||
<TaskItem
|
||||
id={t.id}
|
||||
description={t.description}
|
||||
due_date={t.due_date}
|
||||
display_code={t.display_code}
|
||||
assignees={t.assignees || []}
|
||||
currentUserId={data.userId}
|
||||
groupName={g.name ?? g.id}
|
||||
groupId={t.group_id ?? g.id}
|
||||
on:changed={(e) => updateGroupTask(g.id, e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
{/each}
|
||||
<h1 class="title">Grupos</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!data.unassignedFirst}
|
||||
on:change={(e) => {
|
||||
const checked = (e.currentTarget as HTMLInputElement).checked;
|
||||
const q = buildQuery({ unassignedFirst: checked });
|
||||
location.href = q ? `/app/groups?${q}` : `/app/groups`;
|
||||
}}
|
||||
/>
|
||||
Sin responsable primero
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#each groups as g (g.id)}
|
||||
<details
|
||||
class="group"
|
||||
open={isOpen(g.id)}
|
||||
on:toggle={(e) => handleToggle(g.id, e)}
|
||||
>
|
||||
<summary class="group-header">
|
||||
<span class="name">{g.name ?? g.id}</span>
|
||||
<span class="counts">
|
||||
<span class="badge">tareas: {g.counts.open}</span>
|
||||
<span class="badge warn">🙅♂️: {g.counts.unassigned}</span>
|
||||
</span>
|
||||
</summary>
|
||||
{#if isOpen(g.id)}
|
||||
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each itemsByGroup[g.id] || [] as t (t.id)}
|
||||
<TaskItem
|
||||
id={t.id}
|
||||
description={t.description}
|
||||
due_date={t.due_date}
|
||||
display_code={t.display_code}
|
||||
assignees={t.assignees || []}
|
||||
currentUserId={data.userId}
|
||||
groupName={g.name ?? g.id}
|
||||
groupId={t.group_id ?? g.id}
|
||||
on:changed={(e) => updateGroupTask(g.id, e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.title {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.group {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.group-header .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.group {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.group-header .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
{
|
||||
"metrics": [
|
||||
"commands_alias_used_total",
|
||||
"ver_dm_transition_total",
|
||||
"web_tokens_issued_total",
|
||||
"commands_unknown_total",
|
||||
"commands_blocked_total",
|
||||
"onboarding_prompts_sent_total",
|
||||
"onboarding_prompts_skipped_total",
|
||||
"onboarding_assign_failures_total",
|
||||
"onboarding_bundle_sent_total",
|
||||
"onboarding_recipients_capped_total",
|
||||
"onboarding_dm_skipped_total"
|
||||
],
|
||||
"labels": {
|
||||
"commands_alias_used_total": ["info", "mias", "todas"]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,262 +0,0 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ensureUserExists } from '../../../db';
|
||||
import { normalizeWhatsAppId, isGroupId } from '../../../utils/whatsapp';
|
||||
import { TaskService } from '../../../tasks/service';
|
||||
import { GroupSyncService } from '../../group-sync';
|
||||
import { ContactsService } from '../../contacts';
|
||||
import { IdentityService } from '../../identity';
|
||||
import { Metrics } from '../../metrics';
|
||||
import { ICONS } from '../../../utils/icons';
|
||||
import { codeId, formatDDMM } from '../../../utils/formatting';
|
||||
import { parseNueva } from '../parsers/nueva';
|
||||
import { CTA_HELP } from '../shared';
|
||||
import { buildJitAssigneePrompt, maybeEnqueueOnboardingBundle } from '../../onboarding';
|
||||
|
||||
type Ctx = {
|
||||
sender: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
mentions: string[];
|
||||
messageId?: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
type Msg = {
|
||||
recipient: string;
|
||||
message: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
|
||||
export async function handleNueva(context: Ctx, deps: { db: Database }): Promise<Msg[]> {
|
||||
const tokens = (context.message || '').trim().split(/\s+/);
|
||||
|
||||
// Normalizar menciones del contexto para parseo y asignaciones (A2: fallback a números plausibles)
|
||||
const MIN_FALLBACK_DIGITS = (() => {
|
||||
const raw = (process.env.ONBOARDING_FALLBACK_MIN_DIGITS || '').trim();
|
||||
const n = parseInt(raw || '8', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 8;
|
||||
})();
|
||||
const MAX_FALLBACK_DIGITS = (() => {
|
||||
const raw = (process.env.ONBOARDING_FALLBACK_MAX_DIGITS || '').trim();
|
||||
const n = parseInt(raw || '15', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 15;
|
||||
})();
|
||||
|
||||
type FailReason = 'non_numeric' | 'too_short' | 'too_long' | 'from_lid' | 'invalid';
|
||||
const isDigits = (s: string) => /^\d+$/.test(s);
|
||||
const plausibility = (s: string, opts?: { fromLid?: boolean }): { ok: boolean; reason?: FailReason } => {
|
||||
if (!s) return { ok: false, reason: 'invalid' };
|
||||
if (opts?.fromLid) return { ok: false, reason: 'from_lid' };
|
||||
if (!isDigits(s)) return { ok: false, reason: 'non_numeric' };
|
||||
if (s.length < MIN_FALLBACK_DIGITS) return { ok: false, reason: 'too_short' };
|
||||
if (s.length >= MAX_FALLBACK_DIGITS) return { ok: false, reason: 'too_long' };
|
||||
return { ok: true };
|
||||
};
|
||||
const incOnboardingFailure = (source: 'mentions' | 'tokens', reason: FailReason) => {
|
||||
try {
|
||||
const gid = isGroupId(context.groupId) ? context.groupId : 'dm';
|
||||
Metrics.inc('onboarding_assign_failures_total', 1, { group_id: String(gid), source, reason });
|
||||
} catch { }
|
||||
};
|
||||
|
||||
// 1) Menciones aportadas por el backend (JIDs crudos)
|
||||
const unresolvedAssigneeDisplays: string[] = [];
|
||||
const mentionsNormalizedFromContext = Array.from(new Set(
|
||||
(context.mentions || []).map((j) => {
|
||||
const norm = normalizeWhatsAppId(j);
|
||||
if (!norm) {
|
||||
// agregar a no resolubles para JIT (mostrar sin @ ni dominio)
|
||||
const raw = String(j || '');
|
||||
const disp = raw.split('@')[0].split(':')[0].replace(/^@+/, '').replace(/^\+/, '');
|
||||
if (disp) unresolvedAssigneeDisplays.push(disp);
|
||||
incOnboardingFailure('mentions', 'invalid');
|
||||
return null;
|
||||
}
|
||||
const resolved = IdentityService.resolveAliasOrNull(norm);
|
||||
if (resolved) return resolved;
|
||||
// detectar si la mención proviene de un JID @lid (no plausible aunque sea numérico)
|
||||
const dom = String(j || '').split('@')[1]?.toLowerCase() || '';
|
||||
const fromLid = dom.includes('lid');
|
||||
const p = plausibility(norm, { fromLid });
|
||||
if (p.ok) return norm;
|
||||
// conservar para copy JIT
|
||||
unresolvedAssigneeDisplays.push(norm);
|
||||
incOnboardingFailure('mentions', p.reason!);
|
||||
return null;
|
||||
}).filter((id): id is string => !!id)
|
||||
));
|
||||
|
||||
// 2) Tokens de texto que empiezan por '@' como posibles asignados
|
||||
const atTokenCandidates = tokens.slice(2)
|
||||
.filter(t => t.startsWith('@'))
|
||||
.map(t => t.replace(/^@+/, '').replace(/^\+/, '').replace(/[.,;:!?)\]}¿¡"'’”]+$/, ''));
|
||||
const normalizedFromAtTokens = Array.from(new Set(
|
||||
atTokenCandidates.map((v) => {
|
||||
// Token especial: '@yo' → autoasignación; no cuenta como fallo
|
||||
if (String(v).toLowerCase() === 'yo') {
|
||||
return null;
|
||||
}
|
||||
const norm = normalizeWhatsAppId(v);
|
||||
if (!norm) {
|
||||
// agregar a no resolubles para JIT (texto ya viene sin @/+)
|
||||
if (v) unresolvedAssigneeDisplays.push(v);
|
||||
incOnboardingFailure('tokens', 'invalid');
|
||||
return null;
|
||||
}
|
||||
const resolved = IdentityService.resolveAliasOrNull(norm);
|
||||
if (resolved) return resolved;
|
||||
const p = plausibility(norm, { fromLid: false });
|
||||
if (p.ok) return norm;
|
||||
// conservar para copy JIT (preferimos el token limpio v)
|
||||
unresolvedAssigneeDisplays.push(v);
|
||||
incOnboardingFailure('tokens', p.reason!);
|
||||
return null;
|
||||
}).filter((id): id is string => !!id)
|
||||
));
|
||||
|
||||
// 3) Unir y deduplicar
|
||||
const combinedAssigneeCandidates = Array.from(new Set([
|
||||
...mentionsNormalizedFromContext,
|
||||
...normalizedFromAtTokens
|
||||
]));
|
||||
|
||||
const { description, dueDate, selfAssign } = parseNueva((context.message || '').trim(), mentionsNormalizedFromContext);
|
||||
|
||||
// Asegurar creador
|
||||
const createdBy = ensureUserExists(context.sender, deps.db);
|
||||
if (!createdBy) {
|
||||
throw new Error('No se pudo asegurar el usuario creador');
|
||||
}
|
||||
|
||||
// Normalizar menciones y excluir duplicados y el número del bot
|
||||
const botNumber = process.env.CHATBOT_PHONE_NUMBER || '';
|
||||
const assigneesNormalized = Array.from(new Set(
|
||||
[
|
||||
...(selfAssign ? [context.sender] : []),
|
||||
...combinedAssigneeCandidates
|
||||
].filter(id => !botNumber || id !== botNumber)
|
||||
));
|
||||
|
||||
// Asegurar usuarios asignados
|
||||
const ensuredAssignees = assigneesNormalized
|
||||
.map(id => ensureUserExists(id, deps.db))
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
// Asignación por defecto según contexto:
|
||||
// - En grupos: si no hay menciones → sin dueño (ningún asignado)
|
||||
// - En DM: si no hay menciones → asignada al creador
|
||||
let assignmentUserIds: string[] = [];
|
||||
if (ensuredAssignees.length > 0) {
|
||||
assignmentUserIds = ensuredAssignees;
|
||||
} else {
|
||||
assignmentUserIds = (context.groupId && isGroupId(context.groupId)) ? [] : [createdBy];
|
||||
}
|
||||
|
||||
// Definir group_id solo si el grupo está activo
|
||||
const groupIdToUse = (context.groupId && GroupSyncService.isGroupActive(context.groupId))
|
||||
? context.groupId
|
||||
: null;
|
||||
|
||||
// Crear tarea y asignaciones
|
||||
const taskId = TaskService.createTask(
|
||||
{
|
||||
description: description || '',
|
||||
due_date: dueDate ?? null,
|
||||
group_id: groupIdToUse,
|
||||
created_by: createdBy,
|
||||
},
|
||||
assignmentUserIds.map(uid => ({
|
||||
user_id: uid,
|
||||
assigned_by: createdBy,
|
||||
}))
|
||||
);
|
||||
|
||||
// Registrar origen del comando para esta tarea (si aplica)
|
||||
try {
|
||||
if (groupIdToUse && isGroupId(groupIdToUse) && context.messageId) {
|
||||
const participant = typeof context.participant === 'string' ? context.participant : null;
|
||||
const fromMe = typeof context.fromMe === 'boolean' ? (context.fromMe ? 1 : 0) : null;
|
||||
try {
|
||||
deps.db.prepare(`
|
||||
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id, participant, from_me)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(taskId, groupIdToUse, context.messageId, participant, fromMe);
|
||||
} catch {
|
||||
deps.db.prepare(`
|
||||
INSERT OR IGNORE INTO task_origins (task_id, chat_id, message_id)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, groupIdToUse, context.messageId);
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// Recuperar la tarea creada para obtener display_code asignado
|
||||
const createdTask = TaskService.getTaskById(taskId);
|
||||
|
||||
const mentionsForSending = assignmentUserIds.map(uid => `${uid}@s.whatsapp.net`);
|
||||
|
||||
// Resolver nombres útiles
|
||||
const groupName = groupIdToUse ? GroupSyncService.activeGroupsCache.get(groupIdToUse) : null;
|
||||
|
||||
const assignedDisplayNames = await Promise.all(
|
||||
assignmentUserIds.map(async uid => {
|
||||
const name = await ContactsService.getDisplayName(uid);
|
||||
return name || uid;
|
||||
})
|
||||
);
|
||||
|
||||
const responses: Msg[] = [];
|
||||
|
||||
// 1) Ack al creador con formato compacto
|
||||
const dueFmt = formatDDMM(dueDate);
|
||||
const ownerPart = assignmentUserIds.length === 0
|
||||
? `${ICONS.unassigned} ${groupName ? ` (${groupName})` : ''}`
|
||||
: `${assignmentUserIds.length > 1 ? '👥' : '👤'} ${assignedDisplayNames.join(', ')}`;
|
||||
const ackLines = [
|
||||
`${ICONS.create} ${codeId(taskId, createdTask?.display_code)} ${description || '(sin descripción)'}`,
|
||||
dueFmt ? `${ICONS.date} ${dueFmt}` : null,
|
||||
ownerPart
|
||||
].filter(Boolean);
|
||||
responses.push({
|
||||
recipient: createdBy,
|
||||
message: [ackLines.join('\n'), '', CTA_HELP].join('\n'),
|
||||
mentions: mentionsForSending.length > 0 ? mentionsForSending : undefined
|
||||
});
|
||||
|
||||
// 2) DM a cada asignado (excluyendo al creador para evitar duplicados)
|
||||
for (const uid of assignmentUserIds) {
|
||||
if (uid === createdBy) continue;
|
||||
responses.push({
|
||||
recipient: uid,
|
||||
message: [
|
||||
`${ICONS.assignNotice} ${codeId(taskId, createdTask?.display_code)}`,
|
||||
`${description || '(sin descripción)'}`,
|
||||
formatDDMM(dueDate) ? `${ICONS.date} ${formatDDMM(dueDate)}` : null,
|
||||
groupName ? `Grupo: ${groupName}` : null,
|
||||
`- Completar: \`/t x ${createdTask?.display_code}\``,
|
||||
`- Soltar: \`/t soltar ${createdTask?.display_code}\``
|
||||
].filter(Boolean).join('\n') + '\n\n' + CTA_HELP,
|
||||
mentions: [`${createdBy}@s.whatsapp.net`]
|
||||
});
|
||||
}
|
||||
|
||||
// A4: DM JIT al asignador si quedaron menciones/tokens irrecuperables
|
||||
responses.push(...buildJitAssigneePrompt(createdBy, context.groupId, unresolvedAssigneeDisplays));
|
||||
|
||||
// Fase 2: disparar paquete de onboarding (2 DMs) tras crear tarea en grupo
|
||||
try {
|
||||
const gid = groupIdToUse || (isGroupId(context.groupId) ? context.groupId : null);
|
||||
maybeEnqueueOnboardingBundle(deps.db, {
|
||||
gid,
|
||||
createdBy,
|
||||
assignmentUserIds,
|
||||
taskId,
|
||||
displayCode: createdTask?.display_code ?? null,
|
||||
description: description || ''
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return responses;
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Router de comandos (Etapa 3)
|
||||
* Maneja 'configurar' y 'web', y delega el resto al código actual (null → fallback).
|
||||
* Nota: No importar CommandService aquí para evitar ciclos de import.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ACTION_ALIASES } from './shared';
|
||||
import { handleConfigurar } from './handlers/configurar';
|
||||
import { handleWeb } from './handlers/web';
|
||||
import { handleVer } from './handlers/ver';
|
||||
import { handleCompletar } from './handlers/completar';
|
||||
import { handleTomar } from './handlers/tomar';
|
||||
import { handleSoltar } from './handlers/soltar';
|
||||
import { handleNueva } from './handlers/nueva';
|
||||
import { ResponseQueue } from '../response-queue';
|
||||
import { isGroupId } from '../../utils/whatsapp';
|
||||
import { Metrics } from '../metrics';
|
||||
|
||||
function getQuickHelp(): string {
|
||||
return [
|
||||
'Guía rápida:',
|
||||
'- Ver tus tareas: `/t mias`',
|
||||
'- Ver todas: `/t todas`',
|
||||
'- Crear: `/t n Descripción 2028-11-26 @Ana`',
|
||||
'- Completar: `/t x 123`',
|
||||
'- Tomar: `/t tomar 12`',
|
||||
'- Configurar recordatorios: `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'- Web: `/t web`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getFullHelp(): string {
|
||||
return [
|
||||
'Ayuda avanzada:',
|
||||
'Comandos y alias:',
|
||||
' · Crear: `n`, `nueva`, `crear`, `+`',
|
||||
' · Ver: `ver`, `listar`, `mostrar`, `ls` (scopes: `mis` | `todas`)',
|
||||
' · Completar: `x`, `hecho`, `completar`, `done`',
|
||||
' · Tomar: `tomar`, `claim`',
|
||||
' · Soltar: `soltar`, `unassign`',
|
||||
'Preferencias:',
|
||||
' · `/t configurar diario|l-v|semanal|off [HH:MM]`',
|
||||
'Fechas:',
|
||||
' · `YYYY-MM-DD` o `YY-MM-DD` → `20YY-MM-DD` (ej.: 27-09-04)',
|
||||
' · Palabras: `hoy`, `mañana`',
|
||||
'Acceso web:',
|
||||
' · `/t web`',
|
||||
'Atajos:',
|
||||
' · `/t mias`',
|
||||
' · `/t todas`'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildUnknownHelp(): string {
|
||||
const header = '❓ COMANDO NO RECONOCIDO';
|
||||
const cta = 'Prueba `/t ayuda`';
|
||||
return [header, cta, '', getQuickHelp()].join('\n');
|
||||
}
|
||||
|
||||
export type RoutedMessage = {
|
||||
recipient: string;
|
||||
message: string;
|
||||
mentions?: string[];
|
||||
};
|
||||
|
||||
export type RouteContext = {
|
||||
sender: string;
|
||||
groupId: string;
|
||||
message: string;
|
||||
mentions: string[];
|
||||
messageId?: string;
|
||||
participant?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
export async function route(context: RouteContext, deps?: { db: Database }): Promise<RoutedMessage[] | null> {
|
||||
const trimmed = (context.message || '').trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
const rawAction = (tokens[1] || '').toLowerCase();
|
||||
const action = ACTION_ALIASES[rawAction] || rawAction;
|
||||
|
||||
// Ayuda (no requiere DB)
|
||||
if (action === 'ayuda') {
|
||||
// Métrica de alias "info" (compatibilidad con legacy)
|
||||
try {
|
||||
if (rawAction === 'info' || rawAction === '?') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'info' });
|
||||
}
|
||||
} catch {}
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
const isAdvanced = (tokens[2] || '').toLowerCase() === 'avanzada';
|
||||
const message = isAdvanced
|
||||
? getFullHelp()
|
||||
: [getQuickHelp(), '', 'Ayuda avanzada: `/t ayuda avanzada`'].join('\n');
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message
|
||||
}];
|
||||
}
|
||||
|
||||
// Requiere db inyectada para poder operar (CommandService la inyecta)
|
||||
const database = deps?.db;
|
||||
if (!database) return null;
|
||||
|
||||
if (action === 'nueva') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleNueva(context as any, { db: database });
|
||||
}
|
||||
|
||||
if (action === 'ver') {
|
||||
// Métricas de alias (mias/todas) como en el código actual
|
||||
try {
|
||||
if (rawAction === 'mias' || rawAction === 'mías') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'mias' });
|
||||
} else if (rawAction === 'todas' || rawAction === 'todos') {
|
||||
Metrics.inc('commands_alias_used_total', 1, { action: 'todas' });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
|
||||
// En grupo: transición a DM
|
||||
if (isGroupId(context.groupId)) {
|
||||
try { Metrics.inc('ver_dm_transition_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: 'No respondo en grupos. Tus tareas: /t mias · Todas: /t todas · Info: /t info · Web: /t web'
|
||||
}];
|
||||
}
|
||||
|
||||
return await handleVer(context as any);
|
||||
}
|
||||
|
||||
if (action === 'completar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleCompletar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'tomar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleTomar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'soltar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleSoltar(context as any);
|
||||
}
|
||||
|
||||
if (action === 'configurar') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return handleConfigurar(context as any, { db: database });
|
||||
}
|
||||
|
||||
if (action === 'web') {
|
||||
try { ResponseQueue.setOnboardingAggregatesMetrics(); } catch {}
|
||||
return await handleWeb(context as any, { db: database });
|
||||
}
|
||||
|
||||
// Desconocido → ayuda rápida
|
||||
try { Metrics.inc('commands_unknown_total'); } catch {}
|
||||
return [{
|
||||
recipient: context.sender,
|
||||
message: buildUnknownHelp()
|
||||
}];
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
export function parseNueva(message: string, _mentionsNormalized: string[]): {
|
||||
action: string;
|
||||
description: string;
|
||||
dueDate: string | null;
|
||||
selfAssign: boolean;
|
||||
} {
|
||||
const parts = (message || '').trim().split(/\s+/);
|
||||
const action = (parts[1] || '').toLowerCase();
|
||||
|
||||
// Zona horaria configurable (por defecto Europe/Madrid)
|
||||
const TZ = process.env.TZ && process.env.TZ.trim() ? process.env.TZ : 'Europe/Madrid';
|
||||
|
||||
// Utilidades locales para operar con fechas en la TZ elegida sin depender del huso del host
|
||||
const ymdFromDateInTZ = (d: Date): string => {
|
||||
const fmt = new Intl.DateTimeFormat('es-ES', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => fmt.find(p => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
};
|
||||
|
||||
const addDaysToYMD = (ymd: string, days: number): string => {
|
||||
const [Y, M, D] = ymd.split('-').map(n => parseInt(n, 10));
|
||||
const base = new Date(Date.UTC(Y, (M || 1) - 1, D || 1));
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return ymdFromDateInTZ(base);
|
||||
};
|
||||
|
||||
const todayYMD = ymdFromDateInTZ(new Date());
|
||||
|
||||
// Helpers para validar y normalizar fechas explícitas
|
||||
const isLeap = (y: number) => (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||
const daysInMonth = (y: number, m: number) => {
|
||||
if (m === 2) return isLeap(y) ? 29 : 28;
|
||||
return [31, -1, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
|
||||
};
|
||||
const isValidYMD = (ymd: string): boolean => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return false;
|
||||
const Y = parseInt(m[1], 10);
|
||||
const MM = parseInt(m[2], 10);
|
||||
const DD = parseInt(m[3], 10);
|
||||
if (MM < 1 || MM > 12) return false;
|
||||
const dim = daysInMonth(Y, MM);
|
||||
if (!dim || DD < 1 || DD > dim) return false;
|
||||
return true;
|
||||
};
|
||||
const normalizeDateToken = (t: string): string | null => {
|
||||
// YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t)) {
|
||||
return isValidYMD(t) ? t : null;
|
||||
}
|
||||
// YY-MM-DD -> 20YY-MM-DD
|
||||
const m = /^(\d{2})-(\d{2})-(\d{2})$/.exec(t);
|
||||
if (m) {
|
||||
const yy = parseInt(m[1], 10);
|
||||
const mm = m[2];
|
||||
const dd = m[3];
|
||||
const yyyy = 2000 + yy;
|
||||
const ymd = `${String(yyyy)}-${mm}-${dd}`;
|
||||
return isValidYMD(ymd) ? ymd : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type DateCandidate = { index: number; ymd: string };
|
||||
const dateCandidates: DateCandidate[] = [];
|
||||
const dateTokenIndexes = new Set<number>();
|
||||
const selfTokenIndexes = new Set<number>();
|
||||
let selfAssign = false;
|
||||
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
// Normalizar token: minúsculas y sin puntuación adyacente simple
|
||||
const raw = parts[i];
|
||||
const low = raw.toLowerCase().replace(/^[([{¿¡"']+/, '').replace(/[.,;:!?)\]}¿¡"'']+$/, '');
|
||||
|
||||
// Fecha explícita en formatos permitidos: YYYY-MM-DD o YY-MM-DD (expandido a 20YY)
|
||||
{
|
||||
const norm = normalizeDateToken(low);
|
||||
if (norm && norm >= todayYMD) {
|
||||
dateCandidates.push({ index: i, ymd: norm });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tokens naturales "hoy"/"mañana" (con o sin acento)
|
||||
if (low === 'hoy') {
|
||||
dateCandidates.push({ index: i, ymd: todayYMD });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
if (low === 'mañana' || low === 'manana') {
|
||||
dateCandidates.push({ index: i, ymd: addDaysToYMD(todayYMD, 1) });
|
||||
dateTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Autoasignación: detectar 'yo' o '@yo' como palabra aislada (insensible a mayúsculas; ignora puntuación simple)
|
||||
if (low === 'yo' || low === '@yo') {
|
||||
selfAssign = true;
|
||||
selfTokenIndexes.add(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const dueDate = dateCandidates.length > 0
|
||||
? dateCandidates[dateCandidates.length - 1].ymd
|
||||
: null;
|
||||
|
||||
const isMentionToken = (token: string) => token.startsWith('@');
|
||||
|
||||
const descriptionTokens: string[] = [];
|
||||
for (let i = 2; i < parts.length; i++) {
|
||||
if (dateTokenIndexes.has(i)) continue;
|
||||
if (selfTokenIndexes.has(i)) continue;
|
||||
const token = parts[i];
|
||||
if (isMentionToken(token)) continue;
|
||||
descriptionTokens.push(token);
|
||||
}
|
||||
|
||||
const description = descriptionTokens.join(' ').trim();
|
||||
|
||||
return { action, description, dueDate, selfAssign };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,131 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { WebhookServer } from '../../../src/server';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||
|
||||
function makePayload(event: string, data: any) {
|
||||
return {
|
||||
event,
|
||||
instance: 'test-instance',
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
async function postWebhook(payload: any) {
|
||||
const req = new Request('http://localhost/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return await WebhookServer.handleRequest(req);
|
||||
}
|
||||
|
||||
describe('WebhookServer E2E - reacciones por comando', () => {
|
||||
let memdb: Database;
|
||||
const envBackup = { ...process.env };
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(WebhookServer as any).dbInstance = memdb;
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
(AllowedGroups as any).dbInstance = memdb;
|
||||
(GroupSyncService as any).dbInstance = memdb;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...envBackup,
|
||||
NODE_ENV: 'test',
|
||||
REACTIONS_ENABLED: 'true',
|
||||
REACTIONS_SCOPE: 'groups',
|
||||
GROUP_GATING_MODE: 'enforce',
|
||||
CHATBOT_PHONE_NUMBER: '999'
|
||||
};
|
||||
memdb.exec(`
|
||||
DELETE FROM response_queue;
|
||||
DELETE FROM task_origins;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM groups;
|
||||
DELETE FROM allowed_groups;
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache?.clear?.();
|
||||
});
|
||||
|
||||
it('encola 🤖 en grupo allowed y activo tras /t n', async () => {
|
||||
const groupId = 'g1@g.us';
|
||||
// Sembrar grupo activo y allowed
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('${groupId}', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache.set(groupId, 'G1');
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: groupId, id: 'MSG-OK-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
|
||||
message: { conversation: '/t n prueba e2e' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
const meta = JSON.parse(String(row.metadata));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('🤖');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe('MSG-OK-1');
|
||||
});
|
||||
|
||||
it('no encola reacción en DM cuando REACTIONS_SCOPE=groups', async () => {
|
||||
const dmJid = '600111222@s.whatsapp.net';
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: dmJid, id: 'MSG-DM-1', fromMe: false },
|
||||
message: { conversation: '/t n en DM no reacciona' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%'`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
|
||||
it('encola ⚠️ en grupo allowed y activo para comando inválido (/t x sin IDs)', async () => {
|
||||
const groupId = 'g2@g.us';
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('${groupId}', 'comm-1', 'G2', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
GroupSyncService.activeGroupsCache.set(groupId, 'G2');
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const payload = makePayload('MESSAGES_UPSERT', {
|
||||
key: { remoteJid: groupId, id: 'MSG-ERR-1', fromMe: false, participant: '600111222@s.whatsapp.net' },
|
||||
message: { conversation: '/t x' }
|
||||
});
|
||||
|
||||
const res = await postWebhook(payload);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
const meta = JSON.parse(String(row.metadata));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('⚠️');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe('MSG-ERR-1');
|
||||
});
|
||||
});
|
||||
@ -1,159 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { CommandService } from '../../../src/services/command';
|
||||
import { Metrics } from '../../../src/services/metrics';
|
||||
|
||||
describe('CommandService - autoasignación con "yo" / "@yo"', () => {
|
||||
let memdb: Database;
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
TaskService.dbInstance = memdb;
|
||||
CommandService.dbInstance = memdb;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.METRICS_ENABLED = 'true';
|
||||
Metrics.reset?.();
|
||||
|
||||
memdb.exec(`
|
||||
DELETE FROM task_assignments;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM user_preferences;
|
||||
`);
|
||||
});
|
||||
|
||||
function getLastTask() {
|
||||
return memdb.prepare(`SELECT id, description FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
}
|
||||
|
||||
function getAssignees(taskId: number): string[] {
|
||||
const rows = memdb.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`).all(taskId) as any[];
|
||||
return rows.map(r => String(r.user_id));
|
||||
}
|
||||
|
||||
it('en grupo: "yo" autoasigna al remitente y no queda en la descripción', async () => {
|
||||
const sender = '600111222';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: '12345@g.us', // contexto grupo
|
||||
message: '/t n Hacer algo yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Hacer algo');
|
||||
});
|
||||
|
||||
it('en grupo: "@yo" autoasigna y no incrementa métricas de fallo', async () => {
|
||||
const sender = '600222333';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'group@g.us',
|
||||
message: '/t n Revisar docs @yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Revisar docs');
|
||||
|
||||
const prom = Metrics.render?.('prom') || '';
|
||||
expect(prom).not.toContain('onboarding_assign_failures_total');
|
||||
});
|
||||
|
||||
it('no falsos positivos: "yoyo" y "hoyo" no autoasignan en grupo (queda sin dueño)', async () => {
|
||||
const sender = '600333444';
|
||||
// yoyo
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp@g.us',
|
||||
message: '/t n Caso yoyo',
|
||||
mentions: [],
|
||||
});
|
||||
let t = getLastTask();
|
||||
let assignees = getAssignees(Number(t.id));
|
||||
expect(assignees.length).toBe(0);
|
||||
|
||||
// hoyo
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp@g.us',
|
||||
message: '/t n Voy a cavar un hoyo',
|
||||
mentions: [],
|
||||
});
|
||||
t = getLastTask();
|
||||
assignees = getAssignees(Number(t.id));
|
||||
expect(assignees.length).toBe(0);
|
||||
});
|
||||
|
||||
it('combinado: "yo @34600123456" asigna al remitente y al otro usuario', async () => {
|
||||
const sender = '600444555';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'g@g.us',
|
||||
message: '/t n Tarea combinada yo @34600123456',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(new Set(assignees)).toEqual(new Set([sender, '34600123456']));
|
||||
});
|
||||
|
||||
it('en DM: "yo" también se asigna al remitente y no queda en la descripción', async () => {
|
||||
const sender = '600555666';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: `${sender}@s.whatsapp.net`, // DM
|
||||
message: '/t n Mi tarea yo',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Mi tarea');
|
||||
});
|
||||
|
||||
it('en grupo: "@yo," autoasigna y no incrementa métricas de fallo', async () => {
|
||||
const sender = '600666777';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'group2@g.us',
|
||||
message: '/t n Revisar algo @yo,',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Revisar algo');
|
||||
|
||||
const prom = Metrics.render?.('prom') || '';
|
||||
expect(prom).not.toContain('onboarding_assign_failures_total');
|
||||
});
|
||||
|
||||
it('en grupo: "(yo)" autoasigna y no queda en la descripción', async () => {
|
||||
const sender = '600777888';
|
||||
await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'grp2@g.us',
|
||||
message: '/t n Hacer (yo)',
|
||||
mentions: [],
|
||||
});
|
||||
|
||||
const t = getLastTask();
|
||||
const assignees = getAssignees(Number(t.id));
|
||||
expect(assignees).toContain(sender);
|
||||
expect(String(t.description)).toBe('Hacer');
|
||||
});
|
||||
});
|
||||
@ -1,55 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { CommandService } from '../../../src/services/command';
|
||||
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||
|
||||
describe('CommandService - inserta task_origins al crear en grupo con messageId', () => {
|
||||
let memdb: Database;
|
||||
|
||||
beforeAll(() => {
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(TaskService as any).dbInstance = memdb;
|
||||
(CommandService as any).dbInstance = memdb;
|
||||
|
||||
// Sembrar grupo activo y cache
|
||||
memdb.exec(`
|
||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
||||
VALUES ('g1@g.us', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
`);
|
||||
try { (GroupSyncService as any).dbInstance = memdb; } catch {}
|
||||
GroupSyncService.activeGroupsCache?.clear?.();
|
||||
GroupSyncService.activeGroupsCache?.set?.('g1@g.us', 'G1');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
memdb.exec('DELETE FROM task_assignments; DELETE FROM tasks; DELETE FROM task_origins;');
|
||||
});
|
||||
|
||||
it('crea tarea en grupo y registra (task_id, chat_id, message_id)', async () => {
|
||||
const sender = '600111222';
|
||||
const res = await CommandService.handle({
|
||||
sender,
|
||||
groupId: 'g1@g.us',
|
||||
message: '/t n pruebas origen 2099-01-05',
|
||||
mentions: [],
|
||||
messageId: 'MSG-ORIG-1'
|
||||
});
|
||||
|
||||
expect(res.length).toBeGreaterThan(0);
|
||||
|
||||
const t = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(t).toBeTruthy();
|
||||
const row = memdb.prepare(`
|
||||
SELECT task_id, chat_id, message_id FROM task_origins WHERE task_id = ?
|
||||
`).get(Number(t.id)) as any;
|
||||
|
||||
expect(row).toBeTruthy();
|
||||
expect(Number(row.task_id)).toBe(Number(t.id));
|
||||
expect(String(row.chat_id)).toBe('g1@g.us');
|
||||
expect(String(row.message_id)).toBe('MSG-ORIG-1');
|
||||
});
|
||||
});
|
||||
@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
|
||||
describe('ResponseQueue - payload de reacción', () => {
|
||||
const OLD_FETCH = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EVOLUTION_API_URL = 'http://evolution.local';
|
||||
process.env.EVOLUTION_API_INSTANCE = 'instance-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = OLD_FETCH;
|
||||
delete process.env.EVOLUTION_API_URL;
|
||||
delete process.env.EVOLUTION_API_INSTANCE;
|
||||
});
|
||||
|
||||
it('incluye participant y fromMe cuando están presentes', async () => {
|
||||
const calls: any[] = [];
|
||||
globalThis.fetch = (async (url: any, init?: any) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => ''
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
const item = {
|
||||
id: 1,
|
||||
recipient: '12345-67890@g.us',
|
||||
message: '',
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '12345-67890@g.us',
|
||||
messageId: 'MSG-123',
|
||||
fromMe: true,
|
||||
participant: '34600123456@s.whatsapp.net'
|
||||
}),
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(calls.length).toBe(1);
|
||||
const { url, init } = calls[0];
|
||||
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
|
||||
const payload = JSON.parse(String(init.body || '{}'));
|
||||
expect(payload).toBeTruthy();
|
||||
expect(payload.reaction).toBe('✅');
|
||||
expect(payload.key).toEqual({
|
||||
remoteJid: '12345-67890@g.us',
|
||||
fromMe: true,
|
||||
id: 'MSG-123',
|
||||
participant: '34600123456@s.whatsapp.net'
|
||||
});
|
||||
});
|
||||
|
||||
it('omite participant y usa fromMe=false por defecto cuando no se proveen', async () => {
|
||||
const calls: any[] = [];
|
||||
globalThis.fetch = (async (url: any, init?: any) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => ''
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
const item = {
|
||||
id: 2,
|
||||
recipient: '12345-67890@g.us',
|
||||
message: '',
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '12345-67890@g.us',
|
||||
messageId: 'MSG-456'
|
||||
}),
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const { url, init } = calls[0];
|
||||
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
|
||||
const payload = JSON.parse(String(init.body || '{}'));
|
||||
expect(payload.reaction).toBe('✅');
|
||||
expect(payload.key.remoteJid).toBe('12345-67890@g.us');
|
||||
expect(payload.key.id).toBe('MSG-456');
|
||||
expect(payload.key.fromMe).toBe(false);
|
||||
expect('participant' in payload.key).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
const envBackup = { ...process.env };
|
||||
|
||||
describe('ResponseQueue - jobs de reacción (enqueue + sendOne)', () => {
|
||||
let memdb: Database;
|
||||
let captured: { url?: string; payload?: any } = {};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...envBackup,
|
||||
NODE_ENV: 'test',
|
||||
EVOLUTION_API_URL: 'http://evolution.test',
|
||||
EVOLUTION_API_INSTANCE: 'instance-1',
|
||||
EVOLUTION_API_KEY: 'apikey',
|
||||
RQ_REACTIONS_MAX_ATTEMPTS: '3',
|
||||
};
|
||||
|
||||
memdb = new Database(':memory:');
|
||||
memdb.exec('PRAGMA foreign_keys = ON;');
|
||||
initializeDatabase(memdb);
|
||||
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
|
||||
globalThis.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
captured.url = String(url);
|
||||
try {
|
||||
captured.payload = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
} catch {
|
||||
captured.payload = null;
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
memdb.exec('DELETE FROM response_queue');
|
||||
captured = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
it('enqueueReaction aplica idempotencia por (chatId, messageId, emoji) en ventana 24h', async () => {
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖');
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖'); // duplicado → ignorar
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(1);
|
||||
|
||||
// Mismo chat y mensaje, emoji distinto → debe insertar
|
||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '⚠️');
|
||||
const cnt2 = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt2.c)).toBe(2);
|
||||
});
|
||||
|
||||
it('sendOne con metadata.kind === "reaction" usa /message/sendReaction y payload esperado', async () => {
|
||||
const item = {
|
||||
id: 42,
|
||||
recipient: '123@g.us',
|
||||
message: '', // no se usa para reaction
|
||||
attempts: 0,
|
||||
metadata: JSON.stringify({ kind: 'reaction', emoji: '🤖', chatId: '123@g.us', messageId: 'MSG-99' }),
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
|
||||
expect(captured.payload).toBeDefined();
|
||||
expect(captured.payload.reaction).toBe('🤖');
|
||||
expect(captured.payload.key).toEqual({ remoteJid: '123@g.us', fromMe: false, id: 'MSG-99' });
|
||||
});
|
||||
|
||||
it('sendOne incluye key.participant cuando viene en metadata (grupo, fromMe:false)', async () => {
|
||||
const item = {
|
||||
id: 43,
|
||||
recipient: '120363401791776728@g.us',
|
||||
message: '',
|
||||
attempts: 0,
|
||||
metadata: JSON.stringify({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: '120363401791776728@g.us',
|
||||
messageId: 'MSG-100',
|
||||
participant: '34650861805:32@s.whatsapp.net',
|
||||
fromMe: false
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await ResponseQueue.sendOne(item as any);
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
|
||||
expect(captured.payload).toBeDefined();
|
||||
expect(captured.payload.reaction).toBe('✅');
|
||||
expect(captured.payload.key).toEqual({
|
||||
remoteJid: '120363401791776728@g.us',
|
||||
fromMe: false,
|
||||
id: 'MSG-100',
|
||||
participant: '34650861805:32@s.whatsapp.net'
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { initializeDatabase } from '../../../src/db';
|
||||
import { TaskService } from '../../../src/tasks/service';
|
||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
describe('TaskService - reacción ✅ al completar (Fase 2)', () => {
|
||||
let memdb: Database;
|
||||
let envBackup: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(() => {
|
||||
envBackup = { ...process.env };
|
||||
memdb = new Database(':memory:');
|
||||
initializeDatabase(memdb);
|
||||
(TaskService as any).dbInstance = memdb;
|
||||
(ResponseQueue as any).dbInstance = memdb;
|
||||
(AllowedGroups as any).dbInstance = memdb;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = envBackup;
|
||||
try { memdb.close(); } catch {}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.REACTIONS_ENABLED = 'true';
|
||||
process.env.REACTIONS_SCOPE = 'groups';
|
||||
process.env.REACTIONS_TTL_DAYS = '14';
|
||||
process.env.GROUP_GATING_MODE = 'enforce';
|
||||
|
||||
memdb.exec(`
|
||||
DELETE FROM response_queue;
|
||||
DELETE FROM task_origins;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM users;
|
||||
DELETE FROM allowed_groups;
|
||||
`);
|
||||
});
|
||||
|
||||
it('enqueuea ✅ al completar una tarea con task_origins dentro de TTL y grupo allowed', async () => {
|
||||
const groupId = 'grp-1@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Prueba ✅',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
// Origen reciente (dentro de TTL)
|
||||
const msgId = 'MSG-OK-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status).toBe('updated');
|
||||
|
||||
const row = memdb.prepare(`SELECT id, recipient, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
expect(String(row.recipient)).toBe(groupId);
|
||||
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta.kind).toBe('reaction');
|
||||
expect(meta.emoji).toBe('✅');
|
||||
expect(meta.chatId).toBe(groupId);
|
||||
expect(meta.messageId).toBe(msgId);
|
||||
});
|
||||
|
||||
it('no encola ✅ si el origen está fuera de TTL', async () => {
|
||||
const groupId = 'grp-2@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
// TTL 7 días para forzar expiración
|
||||
process.env.REACTIONS_TTL_DAYS = '7';
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Fuera TTL',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-OLD-1';
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 días atrás
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(old));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status).toBe('updated');
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
|
||||
it('idempotencia: completar dos veces encola solo un ✅', async () => {
|
||||
const groupId = 'grp-3@g.us';
|
||||
AllowedGroups.setStatus(groupId, 'allowed');
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'Idempotencia ✅',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-IDEMP-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const r1 = TaskService.completeTask(taskId, '600111222');
|
||||
const r2 = TaskService.completeTask(taskId, '600111222');
|
||||
expect(r1.status === 'updated' || r1.status === 'already').toBe(true);
|
||||
expect(r2.status === 'updated' || r2.status === 'already').toBe(true);
|
||||
|
||||
const rows = memdb.query(`SELECT metadata FROM response_queue`).all() as any[];
|
||||
expect(rows.length).toBe(1);
|
||||
const meta = JSON.parse(String(rows[0].metadata || '{}'));
|
||||
expect(meta.emoji).toBe('✅');
|
||||
});
|
||||
|
||||
it('enforce: grupo no allowed → no encola ✅', async () => {
|
||||
const groupId = 'grp-4@g.us';
|
||||
// Estado por defecto 'pending' (no allowed)
|
||||
|
||||
const taskId = TaskService.createTask({
|
||||
description: 'No allowed',
|
||||
due_date: null,
|
||||
group_id: groupId,
|
||||
created_by: '600111222'
|
||||
});
|
||||
|
||||
const msgId = 'MSG-NO-ALLOW-1';
|
||||
memdb.prepare(`
|
||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
||||
|
||||
const res = TaskService.completeTask(taskId, '600111222');
|
||||
expect(res.status === 'updated' || res.status === 'already').toBe(true);
|
||||
|
||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c)).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -1,194 +0,0 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'bun:test';
|
||||
import { createTempDb } from './helpers/db';
|
||||
// Los imports del handler y closeDb se hacen dinámicos dentro de cada test/teardown
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
describe('Web API - completar tarea encola reacción ✅', () => {
|
||||
let cleanup: () => void;
|
||||
let db: any;
|
||||
let path: string;
|
||||
|
||||
const USER = '34600123456';
|
||||
const GROUP_ID = '12345-67890@g.us';
|
||||
|
||||
beforeEach(() => {
|
||||
const tmp = createTempDb();
|
||||
cleanup = tmp.cleanup;
|
||||
db = tmp.db;
|
||||
path = tmp.path;
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DB_PATH = path;
|
||||
process.env.REACTIONS_ENABLED = 'true';
|
||||
process.env.REACTIONS_SCOPE = 'groups';
|
||||
process.env.REACTIONS_TTL_DAYS = '14';
|
||||
process.env.GROUP_GATING_MODE = 'enforce';
|
||||
|
||||
// Sembrar usuario y grupo permitido + membresía activa
|
||||
db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER);
|
||||
db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Group', 1)`).run(GROUP_ID);
|
||||
db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'Test', 'allowed')`).run(GROUP_ID);
|
||||
db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cerrar la conexión singleton de la web antes de borrar el archivo
|
||||
try {
|
||||
const { closeDb } = await import('../../apps/web/src/lib/server/db.ts');
|
||||
closeDb();
|
||||
} catch {}
|
||||
if (cleanup) cleanup();
|
||||
// Limpiar env relevantes
|
||||
delete process.env.DB_PATH;
|
||||
delete process.env.REACTIONS_ENABLED;
|
||||
delete process.env.REACTIONS_SCOPE;
|
||||
delete process.env.REACTIONS_TTL_DAYS;
|
||||
delete process.env.GROUP_GATING_MODE;
|
||||
});
|
||||
|
||||
it('caso feliz: encola 1 reacción ✅ con metadata canónica', async () => {
|
||||
// Crear tarea en grupo (no completada)
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Probar reacción', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
// Origen reciente con participant y from_me=1
|
||||
const messageId = 'MSG-abc-123';
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at, participant, from_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()), `${USER}@s.whatsapp.net`, 1);
|
||||
|
||||
// Ejecutar endpoint
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
const payload = await res.json();
|
||||
expect(payload.status).toBe('updated');
|
||||
|
||||
// Verificar encolado
|
||||
const row = db.prepare(`SELECT recipient, message, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
expect(row).toBeTruthy();
|
||||
expect(String(row.recipient)).toBe(GROUP_ID);
|
||||
expect(String(row.message)).toBe('');
|
||||
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta).toEqual({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: GROUP_ID,
|
||||
messageId,
|
||||
fromMe: true,
|
||||
participant: `${USER}@s.whatsapp.net`
|
||||
});
|
||||
|
||||
// Idempotencia del endpoint: segunda llamada no crea nuevo job
|
||||
const res2 = await completeHandler(event);
|
||||
expect(res2.status).toBe(200);
|
||||
const body2 = await res2.json();
|
||||
expect(body2.status).toBe('already');
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata = ?`).get(JSON.stringify(meta)) as any;
|
||||
expect(Number(cnt.c || 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('TTL vencido: no encola reacción', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Vieja', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-old-001';
|
||||
const old = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000); // 20 días
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(old));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('updated');
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('scope=groups: origen DM no encola', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('DM scope', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-dm-001';
|
||||
const dmChat = `${USER}@s.whatsapp.net`; // no @g.us
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, dmChat, messageId, toIsoSql(new Date()));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
||||
expect(Number(cnt.c || 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('sin participant/from_me: metadata no incluye claves opcionales', async () => {
|
||||
const ins = db.prepare(`
|
||||
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
|
||||
VALUES ('Sin opcionales', NULL, ?, ?, 0, NULL)
|
||||
`).run(GROUP_ID, USER) as any;
|
||||
const taskId = Number(ins.lastInsertRowid);
|
||||
|
||||
const messageId = 'MSG-nopts-001';
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()));
|
||||
|
||||
const event: any = {
|
||||
locals: { userId: USER },
|
||||
params: { id: String(taskId) },
|
||||
request: new Request('http://localhost', { method: 'POST' })
|
||||
};
|
||||
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
|
||||
const res = await completeHandler(event);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = db.prepare(`SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
||||
const meta = JSON.parse(String(row.metadata || '{}'));
|
||||
expect(meta).toEqual({
|
||||
kind: 'reaction',
|
||||
emoji: '✅',
|
||||
chatId: GROUP_ID,
|
||||
messageId
|
||||
});
|
||||
expect('fromMe' in meta).toBe(false);
|
||||
expect('participant' in meta).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue