2 Commits

Author SHA1 Message Date
mono
6a3352a96c fix: preservar datos y recuperar base local 2026-10-25 21:27:44 -05:00
mono
a21c43684c test: verificar apk y flujos beta 2026-10-25 20:23:13 -05:00
21 changed files with 226 additions and 14 deletions

View File

@@ -25,3 +25,16 @@ Skills del proyecto en `skills/`:
Seguir OpenSpec workflow: propose -> apply -> archive Seguir OpenSpec workflow: propose -> apply -> archive
Usar skills cuando aplique al contexto Usar skills cuando aplique al contexto
Mantener código siguiendo arquitectura limpia Mantener código siguiendo arquitectura limpia
## Release Android
- Antes de publicar una APK, ejecutar `npm run test:run`, `npm run test:e2e`, `npm exec vue-tsc -- --noEmit` y `npm run build`.
- Ejecutar `npx cap sync android` y `./gradlew assembleDebug` usando el builder Android del repositorio cuando el host no tenga Java.
- Verificar el APK con `scripts/verify-apk.sh <apk> <versionName> <versionCode>` antes de adjuntarlo a Gitea.
- El script debe confirmar `applicationId`, `versionName`, `versionCode`, bundle JavaScript, fuentes MDI y textos críticos de la versión.
- Incrementar `versionCode` para cada APK publicable; mantener la misma firma y publicar versiones beta como prerelease.
- Después de subir un asset, comparar su SHA256 con el APK verificado localmente y comprobar el asset de la release mediante la API de Gitea.
## Persistencia local
- La app debe abrir Dexie explícitamente antes de montar Vue.
- Las migraciones deben agregar una nueva versión Dexie y transformar datos; nunca deben borrar datos automáticamente.
- El reset de la base es una acción manual, confirmada y visible sólo como último recurso.

View File

@@ -1,12 +1,12 @@
{ {
"name": "toldo-app", "name": "toldo-app",
"version": "0.0.4", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "toldo-app", "name": "toldo-app",
"version": "0.0.4", "version": "0.0.6",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.1", "@capacitor/android": "^6.2.1",
"@capacitor/core": "^6.2.1", "@capacitor/core": "^6.2.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "toldo-app", "name": "toldo-app",
"private": true, "private": true,
"version": "0.0.4", "version": "0.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -19,7 +19,7 @@ describe('contratos de operación y respaldo', () => {
it('expone la versión actual en las exportaciones', () => { it('expone la versión actual en las exportaciones', () => {
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] }); const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
expect(result.appVersion).toBe('0.0.4'); expect(result.appVersion).toBe('0.0.6');
}); });
it('exporta jornadas junto con los datos operativos', () => { it('exporta jornadas junto con los datos operativos', () => {

View File

@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db, openDatabase, resetDatabase } from '@toldo/data';
describe('bootstrap y recuperación de base local', () => {
beforeEach(async () => {
await resetDatabase();
});
it('abre el esquema actual y conserva datos al cerrar y abrir', async () => {
await db.productores.add({ id: 'persisted', nombre: 'Productor persistente', activo: true, createdAt: new Date() } as any);
db.close();
await openDatabase();
expect(await db.productores.get('persisted')).toMatchObject({ nombre: 'Productor persistente' });
expect(db.auditorias).toBeTruthy();
});
it('restablece sólo cuando se solicita explícitamente', async () => {
await db.productores.add({ id: 'to-reset', nombre: 'Temporal', activo: true, createdAt: new Date() } as any);
await resetDatabase();
expect(await db.productores.count()).toBe(0);
});
});

View File

@@ -2,5 +2,27 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import vuetify from './plugins/vuetify' import vuetify from './plugins/vuetify'
import { openDatabase, resetDatabase } from '@toldo/data'
createApp(App).use(router).use(vuetify).mount('#app') async function bootstrap() {
try {
await openDatabase()
createApp(App).use(router).use(vuetify).mount('#app')
} catch (error) {
const root = document.querySelector('#app')
if (!root) return
root.innerHTML = ''
const card = document.createElement('main')
card.className = 'database-recovery'
card.innerHTML = '<h1>No se pudo abrir los datos locales</h1><p>La aplicación no continuará para evitar mostrar información incompleta. Puedes intentar recargar o restablecer el entorno como último recurso.</p>'
const details = document.createElement('pre')
details.textContent = error instanceof Error ? error.message : String(error)
const button = document.createElement('button')
button.textContent = 'Restablecer entorno local'
button.onclick = async () => { button.disabled = true; await resetDatabase(); window.location.reload() }
card.append(details, button)
root.append(card)
}
}
bootstrap()

View File

@@ -56,6 +56,11 @@ body {
background: #f5f7f2; background: #f5f7f2;
} }
.database-recovery { max-width: 640px; margin: 12vh auto; padding: 24px; font-family: system-ui, sans-serif; color: #26352b; background: #fffdf8; border: 1px solid #d9e2d7; border-radius: 12px; box-shadow: 0 8px 30px rgba(38, 53, 43, .12); }
.database-recovery p { margin: 12px 0; }
.database-recovery pre { white-space: pre-wrap; font-size: 12px; color: #6b4a25; background: #f7f1e7; padding: 12px; border-radius: 8px; }
.database-recovery button { padding: 12px 16px; color: white; background: #2d5f3b; border: 0; border-radius: 8px; cursor: pointer; }
h1, h1,
h2 { h2 {
font-family: var(--heading); font-family: var(--heading);

View File

@@ -8,19 +8,25 @@
<section> <section>
<v-card><v-card-title>Restaurar datos</v-card-title><v-card-text><v-file-input accept=".json" label="Seleccionar copia JSON" @update:model-value="archivo = $event" /><v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert><v-btn @click="importar" :disabled="!archivo" color="secondary" prepend-icon="mdi-upload">Importar datos</v-btn></v-card-text></v-card> <v-card><v-card-title>Restaurar datos</v-card-title><v-card-text><v-file-input accept=".json" label="Seleccionar copia JSON" @update:model-value="archivo = $event" /><v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert><v-btn @click="importar" :disabled="!archivo" color="secondary" prepend-icon="mdi-upload">Importar datos</v-btn></v-card-text></v-card>
<v-card class="mt-4" variant="tonal" color="error"><v-card-title>Mantenimiento</v-card-title><v-card-text><div class="text-body-2 mb-3">Restablece la base local sólo si la aplicación no puede migrarla. Esta acción elimina ventas, inventario, jornadas y respaldos locales.</div><v-btn color="error" variant="outlined" prepend-icon="mdi-database-refresh" @click="reiniciarEntorno">Restablecer entorno local</v-btn></v-card-text></v-card>
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { db } from '@toldo/data'; import { db, resetDatabase } from '@toldo/data';
import { ExportImportService } from '@toldo/services'; import { ExportImportService } from '@toldo/services';
const exportService = new ExportImportService(); const exportService = new ExportImportService();
const archivo = ref<File | null>(null); const archivo = ref<File | null>(null);
const mensaje = ref(''); const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success'); const tipoMensaje = ref<'success' | 'error'>('success');
async function reiniciarEntorno() {
if (!window.confirm('Se eliminarán todos los datos locales. Exporta un respaldo antes de continuar. ¿Restablecer?')) return;
await resetDatabase();
window.location.reload();
}
function onFileChange(e: any) { function onFileChange(e: any) {
archivo.value = e.target.files[0]; archivo.value = e.target.files[0];

View File

@@ -29,3 +29,18 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
await page.getByTestId('back-button').click(); await page.getByTestId('back-button').click();
await expect(page.getByText('Panel de control')).toBeVisible(); await expect(page.getByText('Panel de control')).toBeVisible();
}); });
test('muestra los flujos compactos en móvil', async ({ page }) => {
test.skip(test.info().project.name !== 'mobile', 'Este escenario se ejecuta en el proyecto móvil.');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/inventario');
await expect(page.getByRole('tab', { name: 'Catálogo' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Ingresos' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Ajustes' })).toBeVisible();
await expect(page.getByText('Migas de pan')).toHaveCount(0);
await page.goto('/ventas');
await expect(page.getByRole('button', { name: 'Buscar productos' })).toBeVisible();
await page.goto('/jornada');
await expect(page.getByLabel('Efectivo contado (obligatorio)')).toBeVisible();
await expect(page.getByLabel('Dinero en banco por QR (opcional)')).toBeVisible();
});

View File

@@ -1,2 +1,2 @@
versionName=0.0.4 versionName=0.0.6
versionCode=4 versionCode=6

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@@ -0,0 +1,23 @@
# Design
## Context
Dexie abre la base al ejecutar la primera consulta. El esquema tiene migraciones 1, 2 y 3, pero `main.ts` monta Vue inmediatamente y no distingue una base en migración de un error de operación.
## Goals / Non-Goals
**Goals:** hacer explícita la apertura, evitar operaciones contra una base no lista, ofrecer recuperación manual y preservar datos durante actualizaciones compatibles.
**Non-Goals:** borrar o reconstruir automáticamente una base con error, sincronizar datos con servidor o reemplazar las migraciones Dexie.
## Decisions
- `db.open()` se ejecutará antes de montar la aplicación.
- Un fallo de apertura montará una vista mínima de recuperación con mensaje técnico resumido y botón de reset destructivo.
- El reset se expondrá también desde Exportar/Importar, exigiendo confirmación y recomendando exportar antes.
- Las migraciones se mantendrán como versiones Dexie; cada cambio futuro de esquema deberá agregar una versión y upgrade explícito.
## Risks / Trade-offs
- El usuario puede resetear por error -> confirmación explícita y texto de pérdida de datos.
- Una base realmente corrupta puede no permitir exportación -> la pantalla explicará que el reset es último recurso.

View File

@@ -0,0 +1,29 @@
# Proposal
## Why
La app abre Dexie de forma implícita y no informa cuando una migración o apertura de IndexedDB falla. Esto puede parecer una pérdida de datos y obliga a borrar manualmente el almacenamiento para continuar.
## What Changes
- Abrir y validar la base local antes de montar Vue.
- Mostrar una pantalla de recuperación si la apertura/migración falla.
- Añadir reset manual del entorno desde Exportar/Importar, con confirmación destructiva.
- Mantener las migraciones Dexie y documentar que nunca deben borrar datos automáticamente.
- Añadir pruebas de persistencia y reset.
## Capabilities
### New Capabilities
- `database-bootstrap-recovery`: apertura explícita, diagnóstico y recuperación controlada de IndexedDB.
### Modified Capabilities
- `toldo-poc`: persistencia local y operación offline.
## Impact
- `packages/data`: bootstrap/reset de base.
- `apps/toldo-app`: arranque, pantalla de error y mantenimiento.
- Tests Vitest, documentación de agentes y APK.

View File

@@ -0,0 +1,33 @@
# Spec Delta
## Purpose
Protege los datos locales durante actualizaciones y ofrece una recuperación explícita cuando IndexedDB no puede abrirse o migrarse.
## ADDED Requirements
### Requirement: Apertura explícita de base
The system SHALL open and complete local database migrations before allowing operational views to run.
#### Scenario: Actualización compatible
- **GIVEN** data exists in an older supported schema
- **WHEN** the app starts after an update
- **THEN** migrations complete before the main UI mounts
- **AND** existing records remain available
### Requirement: Recuperación ante error
The system SHALL show a recovery screen when the local database cannot be opened.
#### Scenario: Migración fallida
- **WHEN** database opening fails
- **THEN** the app shows an actionable error
- **AND** no operational action is rendered as if data were empty
- **AND** user can explicitly reset local data
### Requirement: Reset manual
The system SHALL require explicit confirmation before deleting all local data.
#### Scenario: Reset confirmado
- **WHEN** user confirms reset from maintenance
- **THEN** local database is deleted and recreated on reload
- **AND** the app starts with an empty environment

View File

@@ -0,0 +1,12 @@
# Tasks
## 1. Bootstrap y recuperación
- [x] 1.1 Añadir apertura explícita Dexie antes de montar Vue y vista de error recuperable
- [x] 1.2 Añadir helper de reset local sin usarlo automáticamente
- [x] 1.3 Añadir reset manual en Exportar/Importar con confirmación
## 2. Verificación
- [x] 2.1 Cubrir apertura, persistencia y reset con tests
- [x] 2.2 Ejecutar tests, `vue-tsc`, Playwright y build/APK

View File

@@ -8,7 +8,7 @@ La aplicación usa versiones distintas o hardcodeadas entre npm, Android y las e
- Definir `versionName` y `versionCode` en un archivo versionado compartido. - Definir `versionName` y `versionCode` en un archivo versionado compartido.
- Usar esos valores en Gradle, npm y exportaciones JSON. - Usar esos valores en Gradle, npm y exportaciones JSON.
- Publicar la siguiente versión como `0.0.4` con `versionCode 4`. - Publicar la siguiente versión como `0.0.6` con `versionCode 6`.
- Validar el `applicationId` y la versión en el APK generado. - Validar el `applicationId` y la versión en el APK generado.
## Capabilities ## Capabilities

View File

@@ -9,10 +9,10 @@ Asegura que cada actualización de ToldoConjunto pueda identificarse y actualiza
### Requirement: Versionado consistente ### Requirement: Versionado consistente
The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName. The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName.
#### Scenario: Version 0.0.4 #### Scenario: Version 0.0.6
- **WHEN** the app is built - **WHEN** the app is built
- **THEN** npm and exported data report `0.0.4` - **THEN** npm and exported data report `0.0.6`
- **AND** Android reports versionName `0.0.4` and versionCode `4` - **AND** Android reports versionName `0.0.6` and versionCode `6`
### Requirement: Identidad Android estable ### Requirement: Identidad Android estable
The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases. The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases.

View File

@@ -2,7 +2,7 @@
## 1. Versionado ## 1. Versionado
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.4` y `versionCode=4`, y actualizar npm/exportaciones - [x] 1.1 Crear la fuente compartida con `versionName=0.0.6` y `versionCode=6`, y actualizar npm/exportaciones
- [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId - [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId
- [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación - [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación

View File

@@ -52,3 +52,13 @@ export class ToldoDB extends Dexie {
} }
export const db = new ToldoDB(); export const db = new ToldoDB();
export async function openDatabase() {
await db.open();
return db;
}
export async function resetDatabase() {
await db.delete();
await db.open();
}

View File

@@ -19,7 +19,7 @@ export interface ExportData {
} }
export const CURRENT_SCHEMA_VERSION = 1; export const CURRENT_SCHEMA_VERSION = 1;
export const APP_VERSION = '0.0.4'; export const APP_VERSION = '0.0.6';
export class ExportImportService { export class ExportImportService {
exportAll(data: ExportData['data']): ExportData { exportAll(data: ExportData['data']): ExportData {

20
scripts/verify-apk.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
apk="${1:?Uso: verify-apk.sh <apk> <versionName> <versionCode>}"
version_name="${2:?Falta versionName}"
version_code="${3:?Falta versionCode}"
aapt_bin="${AAPT:-aapt2}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
badging="$($aapt_bin dump badging "$apk")"
printf '%s\n' "$badging" | grep -F "package: name='com.ilusion.toldoconjunto' versionCode='$version_code' versionName='$version_name'" >/dev/null
unzip -q "$apk" -d "$tmp_dir"
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Buscar productos' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Dinero en banco por QR' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Cerrar caja y jornada' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.woff2' | grep materialdesignicons >/dev/null
printf 'APK verificada: applicationId=com.ilusion.toldoconjunto versionName=%s versionCode=%s\n' "$version_name" "$version_code"