Edoruin commited on
Commit
3ca9355
·
1 Parent(s): 6822d8a

migrate from angular to flask

Browse files
.editorconfig DELETED
@@ -1,16 +0,0 @@
1
- # Editor configuration, see https://editorconfig.org
2
- root = true
3
-
4
- [*]
5
- charset = utf-8
6
- indent_style = space
7
- indent_size = 2
8
- insert_final_newline = true
9
- trim_trailing_whitespace = true
10
-
11
- [*.ts]
12
- quote_type = single
13
-
14
- [*.md]
15
- max_line_length = off
16
- trim_trailing_whitespace = false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -1,42 +1,46 @@
1
- # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- # Compiled output
4
- /dist
5
- /tmp
6
- /out-tsc
7
- /bazel-out
8
 
9
- # Node
10
- /node_modules
11
- npm-debug.log
12
- yarn-error.log
13
-
14
- # IDEs and editors
15
  .idea/
16
- .project
17
- .classpath
18
- .c9/
19
- *.launch
20
- .settings/
21
- *.sublime-workspace
22
-
23
- # Visual Studio Code
24
- .vscode/*
25
- !.vscode/settings.json
26
- !.vscode/tasks.json
27
- !.vscode/launch.json
28
- !.vscode/extensions.json
29
- .history/*
30
-
31
- # Miscellaneous
32
- /.angular/cache
33
- .sass-cache/
34
- /connect.lock
35
- /coverage
36
- /libpeerconnection.log
37
- testem.log
38
- /typings
39
 
40
- # System files
41
  .DS_Store
42
  Thumbs.db
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
 
26
+ # Flask
27
+ instance/
28
+ .webassets-cache
 
 
29
 
30
+ # IDEs
31
+ .vscode/
 
 
 
 
32
  .idea/
33
+ *.swp
34
+ *.swo
35
+ *~
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ # OS
38
  .DS_Store
39
  Thumbs.db
40
+
41
+ # Logs
42
+ *.log
43
+
44
+ # Environment
45
+ .env
46
+ .env.local
Dockerfile CHANGED
@@ -1,26 +1,23 @@
1
- # ETAPA 1: Construcción
2
- FROM node:20-alpine AS build
3
- WORKDIR /app
4
-
5
- # Instalar dependencias
6
- COPY package*.json ./
7
- RUN npm install
8
 
9
- # Copiar código y compilar
10
- COPY . .
11
- RUN npx ng build --configuration production
12
 
13
- # ETAPA 2: Ejecución (Hugging Face)
14
- FROM node:20-alpine
15
  WORKDIR /app
16
 
17
- # Copiamos la carpeta completa de dist que contiene 'browser' y 'server'
18
- COPY --from=build /app/dist/mi-pesca-rd /app/dist/mi-pesca-rd
 
19
 
20
- # Hugging Face requiere el puerto 7860
 
 
 
 
 
21
  ENV PORT=7860
 
22
  EXPOSE 7860
23
 
24
- # Comando para iniciar el servidor de Angular SSR
25
- # IMPORTANTE: En Angular 17/18 la ruta suele ser esta:
26
- CMD ["node", "dist/mi-pesca-rd/server/server.mjs"]
 
1
+ # Hugging Face Spaces Configuration
2
+ # Mi Pesca RD - Flask PWA
 
 
 
 
 
3
 
4
+ FROM python:3.11-slim
 
 
5
 
 
 
6
  WORKDIR /app
7
 
8
+ # Copy requirements and install dependencies
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
 
12
+ # Copy application files
13
+ COPY app.py .
14
+ COPY models.py .
15
+ COPY static/ ./static/
16
+
17
+ # Hugging Face Spaces requires port 7860
18
  ENV PORT=7860
19
+
20
  EXPOSE 7860
21
 
22
+ # Run with Gunicorn for production
23
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "--timeout", "120", "app:app"]
 
README.md CHANGED
@@ -1,8 +1,55 @@
1
- ---
2
- title: Mi Pesca RD
3
- emoji: 🎣
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: docker
7
- app_port: 7860
8
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mi Pesca RD
2
+
3
+ Aplicación PWA offline-first para registro de capturas pesqueras en República Dominicana.
4
+
5
+ ## Características
6
+
7
+ - 🎣 Registro rápido de capturas (3 clics)
8
+ - 📍 Captura automática de coordenadas GPS
9
+ - 📱 Funciona sin conexión (offline-first)
10
+ - 🔄 Sincronización automática cuando hay conexión
11
+ - 🐟 Selector visual de especies con fotos
12
+ - 📊 Historial de capturas
13
+ - 🌊 Diseñado para uso en ambientes marinos
14
+
15
+ ## Tecnologías
16
+
17
+ - **Backend**: Flask (Python)
18
+ - **Frontend**: HTML/CSS/JavaScript vanilla
19
+ - **Base de datos local**: IndexedDB (Dexie.js)
20
+ - **PWA**: Service Worker para funcionalidad offline
21
+ - **Despliegue**: Docker (Hugging Face Spaces)
22
+
23
+ ## Instalación Local
24
+
25
+ ```bash
26
+ # Instalar dependencias
27
+ pip install -r requirements.txt
28
+
29
+ # Ejecutar aplicación
30
+ python app.py
31
+ ```
32
+
33
+ La aplicación estará disponible en `http://localhost:7860`
34
+
35
+ ## Docker
36
+
37
+ ```bash
38
+ # Construir imagen
39
+ docker build -t mipesca .
40
+
41
+ # Ejecutar contenedor
42
+ docker run -p 7860:7860 mipesca
43
+ ```
44
+
45
+ ## Hugging Face Spaces
46
+
47
+ Este proyecto está configurado para ejecutarse en Hugging Face Spaces:
48
+
49
+ - SDK: Docker
50
+ - Puerto: 7860
51
+ - Arquitectura: Offline-first PWA
52
+
53
+ ## Licencia
54
+
55
+ MIT
angular.json DELETED
@@ -1,100 +0,0 @@
1
- {
2
- "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
- "version": 1,
4
- "newProjectRoot": "projects",
5
- "projects": {
6
- "mi-pesca-rd": {
7
- "projectType": "application",
8
- "schematics": {},
9
- "root": "",
10
- "sourceRoot": "src",
11
- "prefix": "app",
12
- "architect": {
13
- "build": {
14
- "builder": "@angular-devkit/build-angular:application",
15
- "options": {
16
- "outputPath": "dist/mi-pesca-rd",
17
- "index": "src/index.html",
18
- "browser": "src/main.ts",
19
- "polyfills": [
20
- "zone.js"
21
- ],
22
- "tsConfig": "tsconfig.app.json",
23
- "assets": [
24
- "src/favicon.ico",
25
- "src/assets"
26
- ],
27
- "styles": [
28
- "src/styles.css"
29
- ],
30
- "scripts": [],
31
- "server": "src/main.server.ts",
32
- "prerender": true,
33
- "ssr": {
34
- "entry": "server.ts"
35
- }
36
- },
37
- "configurations": {
38
- "production": {
39
- "budgets": [
40
- {
41
- "type": "initial",
42
- "maximumWarning": "500kb",
43
- "maximumError": "1mb"
44
- },
45
- {
46
- "type": "anyComponentStyle",
47
- "maximumWarning": "2kb",
48
- "maximumError": "4kb"
49
- }
50
- ],
51
- "outputHashing": "all"
52
- },
53
- "development": {
54
- "optimization": false,
55
- "extractLicenses": false,
56
- "sourceMap": true
57
- }
58
- },
59
- "defaultConfiguration": "production"
60
- },
61
- "serve": {
62
- "builder": "@angular-devkit/build-angular:dev-server",
63
- "configurations": {
64
- "production": {
65
- "buildTarget": "mi-pesca-rd:build:production"
66
- },
67
- "development": {
68
- "buildTarget": "mi-pesca-rd:build:development"
69
- }
70
- },
71
- "defaultConfiguration": "development"
72
- },
73
- "extract-i18n": {
74
- "builder": "@angular-devkit/build-angular:extract-i18n",
75
- "options": {
76
- "buildTarget": "mi-pesca-rd:build"
77
- }
78
- },
79
- "test": {
80
- "builder": "@angular-devkit/build-angular:karma",
81
- "options": {
82
- "polyfills": [
83
- "zone.js",
84
- "zone.js/testing"
85
- ],
86
- "tsConfig": "tsconfig.spec.json",
87
- "assets": [
88
- "src/favicon.ico",
89
- "src/assets"
90
- ],
91
- "styles": [
92
- "src/styles.css"
93
- ],
94
- "scripts": []
95
- }
96
- }
97
- }
98
- }
99
- }
100
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flask backend for Mi Pesca RD application."""
2
+ import os
3
+ import json
4
+ from datetime import datetime
5
+ from flask import Flask, request, jsonify, send_from_directory
6
+ from flask_cors import CORS
7
+ from models import Capture, Species, ClosedSeason
8
+
9
+ app = Flask(__name__, static_folder='static', static_url_path='')
10
+ CORS(app)
11
+
12
+ # Sample species data (in production, this would come from a database)
13
+ SPECIES_DATA = [
14
+ Species(
15
+ id='chillo',
16
+ commonName='Chillo',
17
+ scientificName='Lutjanus campechanus',
18
+ category='Fondo',
19
+ imageUrl='/assets/species/chillo.jpg',
20
+ protected=False,
21
+ closedSeason=ClosedSeason(
22
+ start='04-01',
23
+ end='06-30',
24
+ description='Veda de reproducción'
25
+ )
26
+ ),
27
+ Species(
28
+ id='dorado',
29
+ commonName='Dorado',
30
+ scientificName='Coryphaena hippurus',
31
+ category='Pelágico',
32
+ imageUrl='/assets/species/dorado.jpg',
33
+ protected=False
34
+ ),
35
+ Species(
36
+ id='langosta',
37
+ commonName='Langosta del Caribe',
38
+ scientificName='Panulirus argus',
39
+ category='Invertebrado',
40
+ imageUrl='/assets/species/langosta.jpg',
41
+ protected=False,
42
+ closedSeason=ClosedSeason(
43
+ start='03-01',
44
+ end='06-30',
45
+ description='Veda de reproducción'
46
+ )
47
+ ),
48
+ ]
49
+
50
+ # In-memory storage for captures (in production, use a database)
51
+ captures_storage = []
52
+
53
+
54
+ @app.route('/')
55
+ def index():
56
+ """Serve the main HTML page."""
57
+ return send_from_directory('static', 'index.html')
58
+
59
+
60
+ @app.route('/api/species', methods=['GET'])
61
+ def get_species():
62
+ """Get list of all species."""
63
+ return jsonify([species.to_dict() for species in SPECIES_DATA])
64
+
65
+
66
+ @app.route('/api/captures', methods=['POST'])
67
+ def create_capture():
68
+ """Receive and store a new capture."""
69
+ try:
70
+ data = request.get_json()
71
+
72
+ # Create Capture object from request data
73
+ capture = Capture.from_dict(data)
74
+
75
+ # TODO: Enrich with environmental data (temperature, sea state)
76
+ # This would call external APIs like NOAA or OpenWeatherMap
77
+
78
+ # Store capture (in production, save to database)
79
+ captures_storage.append(capture)
80
+
81
+ # Log for debugging
82
+ print(f"Received capture: {capture.id} at {capture.latitude}, {capture.longitude}")
83
+
84
+ return jsonify({
85
+ 'success': True,
86
+ 'id': capture.id,
87
+ 'message': 'Captura registrada exitosamente'
88
+ }), 201
89
+
90
+ except Exception as e:
91
+ print(f"Error processing capture: {str(e)}")
92
+ return jsonify({
93
+ 'success': False,
94
+ 'error': str(e)
95
+ }), 400
96
+
97
+
98
+ @app.route('/api/sync', methods=['POST'])
99
+ def sync_captures():
100
+ """Batch synchronization endpoint for multiple captures."""
101
+ try:
102
+ data = request.get_json()
103
+ captures = data.get('captures', [])
104
+
105
+ synced_ids = []
106
+ errors = []
107
+
108
+ for capture_data in captures:
109
+ try:
110
+ capture = Capture.from_dict(capture_data)
111
+ captures_storage.append(capture)
112
+ synced_ids.append(capture.id)
113
+ except Exception as e:
114
+ errors.append({
115
+ 'id': capture_data.get('id'),
116
+ 'error': str(e)
117
+ })
118
+
119
+ return jsonify({
120
+ 'success': True,
121
+ 'synced': synced_ids,
122
+ 'errors': errors,
123
+ 'total': len(synced_ids)
124
+ }), 200
125
+
126
+ except Exception as e:
127
+ return jsonify({
128
+ 'success': False,
129
+ 'error': str(e)
130
+ }), 400
131
+
132
+
133
+ @app.route('/api/captures', methods=['GET'])
134
+ def get_captures():
135
+ """Get all stored captures (for admin/debugging)."""
136
+ return jsonify([capture.to_dict() for capture in captures_storage])
137
+
138
+
139
+ @app.route('/health', methods=['GET'])
140
+ def health_check():
141
+ """Health check endpoint."""
142
+ return jsonify({
143
+ 'status': 'healthy',
144
+ 'timestamp': datetime.now().isoformat()
145
+ })
146
+
147
+
148
+ if __name__ == '__main__':
149
+ # Get port from environment variable (Hugging Face uses 7860)
150
+ port = int(os.environ.get('PORT', 7860))
151
+
152
+ # Run the Flask app
153
+ app.run(host='0.0.0.0', port=port, debug=False)
docker-compose.yml CHANGED
@@ -1,11 +1,9 @@
1
  version: '3.8'
 
2
  services:
3
- angular-dev:
4
  build: .
5
- volumes:
6
- - .:/app
7
  ports:
8
- - "4200:4200"
9
  - "7860:7860"
10
- stdin_open: true
11
- tty: true
 
1
  version: '3.8'
2
+
3
  services:
4
+ mipesca:
5
  build: .
 
 
6
  ports:
 
7
  - "7860:7860"
8
+ environment:
9
+ - PORT=7860
models.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for Mi Pesca RD application."""
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import List, Optional, Literal
5
+
6
+
7
+ @dataclass
8
+ class ClosedSeason:
9
+ """Closed season information for a species."""
10
+ start: str # MM-DD format
11
+ end: str # MM-DD format
12
+ description: str
13
+
14
+
15
+ @dataclass
16
+ class Species:
17
+ """Species data model."""
18
+ id: str
19
+ commonName: str
20
+ scientificName: str
21
+ category: Literal['Fondo', 'Pelágico', 'Invertebrado']
22
+ imageUrl: str
23
+ protected: bool
24
+ closedSeason: Optional[ClosedSeason] = None
25
+
26
+ def to_dict(self):
27
+ """Convert to dictionary for JSON serialization."""
28
+ data = {
29
+ 'id': self.id,
30
+ 'commonName': self.commonName,
31
+ 'scientificName': self.scientificName,
32
+ 'category': self.category,
33
+ 'imageUrl': self.imageUrl,
34
+ 'protected': self.protected
35
+ }
36
+ if self.closedSeason:
37
+ data['closedSeason'] = {
38
+ 'start': self.closedSeason.start,
39
+ 'end': self.closedSeason.end,
40
+ 'description': self.closedSeason.description
41
+ }
42
+ return data
43
+
44
+
45
+ @dataclass
46
+ class CaptureItem:
47
+ """Individual capture item (species with quantity)."""
48
+ speciesId: str
49
+ quantity: float
50
+ unit: Literal['lbs', 'units']
51
+
52
+ def to_dict(self):
53
+ """Convert to dictionary for JSON serialization."""
54
+ return {
55
+ 'speciesId': self.speciesId,
56
+ 'quantity': self.quantity,
57
+ 'unit': self.unit
58
+ }
59
+
60
+
61
+ @dataclass
62
+ class Capture:
63
+ """Capture data model."""
64
+ id: str
65
+ timestamp: datetime
66
+ latitude: float
67
+ longitude: float
68
+ species: List[str]
69
+ items: List[CaptureItem]
70
+ fishingMethod: str
71
+ depth: float
72
+ synced: bool
73
+ userId: Optional[str] = None
74
+ syncedAt: Optional[datetime] = None
75
+ temperature: Optional[float] = None
76
+ seaState: Optional[str] = None
77
+
78
+ def to_dict(self):
79
+ """Convert to dictionary for JSON serialization."""
80
+ return {
81
+ 'id': self.id,
82
+ 'timestamp': self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
83
+ 'latitude': self.latitude,
84
+ 'longitude': self.longitude,
85
+ 'species': self.species,
86
+ 'items': [item.to_dict() if hasattr(item, 'to_dict') else item for item in self.items],
87
+ 'fishingMethod': self.fishingMethod,
88
+ 'depth': self.depth,
89
+ 'synced': self.synced,
90
+ 'userId': self.userId,
91
+ 'syncedAt': self.syncedAt.isoformat() if self.syncedAt and isinstance(self.syncedAt, datetime) else self.syncedAt,
92
+ 'temperature': self.temperature,
93
+ 'seaState': self.seaState
94
+ }
95
+
96
+ @classmethod
97
+ def from_dict(cls, data: dict):
98
+ """Create Capture instance from dictionary."""
99
+ items = []
100
+ for item in data.get('items', []):
101
+ if isinstance(item, dict):
102
+ items.append(CaptureItem(**item))
103
+ else:
104
+ items.append(item)
105
+
106
+ timestamp = data.get('timestamp')
107
+ if isinstance(timestamp, str):
108
+ timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
109
+
110
+ syncedAt = data.get('syncedAt')
111
+ if syncedAt and isinstance(syncedAt, str):
112
+ syncedAt = datetime.fromisoformat(syncedAt.replace('Z', '+00:00'))
113
+
114
+ return cls(
115
+ id=data.get('id'),
116
+ timestamp=timestamp,
117
+ latitude=data.get('latitude'),
118
+ longitude=data.get('longitude'),
119
+ species=data.get('species', []),
120
+ items=items,
121
+ fishingMethod=data.get('fishingMethod'),
122
+ depth=data.get('depth'),
123
+ synced=data.get('synced', False),
124
+ userId=data.get('userId'),
125
+ syncedAt=syncedAt,
126
+ temperature=data.get('temperature'),
127
+ seaState=data.get('seaState')
128
+ )
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json DELETED
@@ -1,45 +0,0 @@
1
- {
2
- "name": "mi-pesca-rd",
3
- "version": "0.0.0",
4
- "scripts": {
5
- "ng": "ng",
6
- "start": "ng serve",
7
- "build": "ng build",
8
- "watch": "ng build --watch --configuration development",
9
- "test": "ng test",
10
- "serve:ssr:mi-pesca-rd": "node dist/mi-pesca-rd/server/server.mjs"
11
- },
12
- "private": true,
13
- "dependencies": {
14
- "@angular/service-worker": "^17.0.0",
15
- "@angular/animations": "^17.3.0",
16
- "@angular/common": "^17.3.0",
17
- "@angular/compiler": "^17.3.0",
18
- "@angular/core": "^17.3.0",
19
- "@angular/forms": "^17.3.0",
20
- "@angular/platform-browser": "^17.3.0",
21
- "@angular/platform-browser-dynamic": "^17.3.0",
22
- "@angular/platform-server": "^17.3.0",
23
- "@angular/router": "^17.3.0",
24
- "@angular/ssr": "^17.3.17",
25
- "express": "^4.18.2",
26
- "rxjs": "~7.8.0",
27
- "tslib": "^2.3.0",
28
- "zone.js": "~0.14.3"
29
- },
30
- "devDependencies": {
31
- "@angular-devkit/build-angular": "^17.3.17",
32
- "@angular/cli": "^17.3.17",
33
- "@angular/compiler-cli": "^17.3.0",
34
- "@types/express": "^4.17.17",
35
- "@types/jasmine": "~5.1.0",
36
- "@types/node": "^18.18.0",
37
- "jasmine-core": "~5.1.0",
38
- "karma": "~6.4.0",
39
- "karma-chrome-launcher": "~3.2.0",
40
- "karma-coverage": "~2.2.0",
41
- "karma-jasmine": "~5.1.0",
42
- "karma-jasmine-html-reporter": "~2.1.0",
43
- "typescript": "~5.4.2"
44
- }
45
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ Flask-CORS==4.0.0
3
+ python-dotenv==1.0.0
4
+ requests==2.31.0
5
+ gunicorn==21.2.0
server.ts DELETED
@@ -1,56 +0,0 @@
1
- import { APP_BASE_HREF } from '@angular/common';
2
- import { CommonEngine } from '@angular/ssr';
3
- import express from 'express';
4
- import { fileURLToPath } from 'node:url';
5
- import { dirname, join, resolve } from 'node:path';
6
- import bootstrap from './src/main.server';
7
-
8
- // The Express app is exported so that it can be used by serverless Functions.
9
- export function app(): express.Express {
10
- const server = express();
11
- const serverDistFolder = dirname(fileURLToPath(import.meta.url));
12
- const browserDistFolder = resolve(serverDistFolder, '../browser');
13
- const indexHtml = join(serverDistFolder, 'index.server.html');
14
-
15
- const commonEngine = new CommonEngine();
16
-
17
- server.set('view engine', 'html');
18
- server.set('views', browserDistFolder);
19
-
20
- // Example Express Rest API endpoints
21
- // server.get('/api/**', (req, res) => { });
22
- // Serve static files from /browser
23
- server.get('*.*', express.static(browserDistFolder, {
24
- maxAge: '1y'
25
- }));
26
-
27
- // All regular routes use the Angular engine
28
- server.get('*', (req, res, next) => {
29
- const { protocol, originalUrl, baseUrl, headers } = req;
30
-
31
- commonEngine
32
- .render({
33
- bootstrap,
34
- documentFilePath: indexHtml,
35
- url: `${protocol}://${headers.host}${originalUrl}`,
36
- publicPath: browserDistFolder,
37
- providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
38
- })
39
- .then((html) => res.send(html))
40
- .catch((err) => next(err));
41
- });
42
-
43
- return server;
44
- }
45
-
46
- function run(): void {
47
- const port = process.env['PORT'] || 4000;
48
-
49
- // Start up the Node server
50
- const server = app();
51
- server.listen(port, () => {
52
- console.log(`Node Express server listening on http://localhost:${port}`);
53
- });
54
- }
55
-
56
- run();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.component.html DELETED
@@ -1,41 +0,0 @@
1
- <div class="container" style="padding: 20px; max-width: 500px; margin: auto;">
2
- <header style="text-align: center; border-bottom: 2px solid #004488;">
3
- <h1 style="color: #004488; margin-bottom: 5px;">🎣 MI PESCA RD</h1>
4
- <p style="font-size: 0.9em; color: #666;">Investigación de Recursos Pesqueros</p>
5
- </header>
6
-
7
- <main style="margin-top: 20px;">
8
- <div style="margin-bottom: 15px;">
9
- <label>Especie:</label>
10
- <select #esp style="width: 100%; padding: 15px; font-size: 1.2rem; border-radius: 8px;">
11
- <option value="Chillo">Chillo</option>
12
- <option value="Colirrubia">Colirrubia</option>
13
- <option value="Mero">Mero Rojo</option>
14
- <option value="Langosta">Langosta (Veda check)</option>
15
- </select>
16
- </div>
17
-
18
- <div style="margin-bottom: 15px;">
19
- <label>Peso Estimado (Lbs):</label>
20
- <input #peso type="number" style="width: 100%; padding: 15px; font-size: 1.2rem; border-radius: 8px;">
21
- </div>
22
-
23
- <div style="margin-bottom: 25px;">
24
- <label>Profundidad (Brazadas):</label>
25
- <input #braz type="range" min="1" max="150" value="20" style="width: 100%;">
26
- <p style="text-align: center; font-weight: bold;">{{braz.value}} brazadas</p>
27
- </div>
28
-
29
- <button (click)="guardarCaptura(esp.value, +peso.value, +braz.value)"
30
- style="width: 100%; padding: 20px; background: #004488; color: white; border: none; border-radius: 10px; font-weight: bold; font-size: 1.1rem; cursor: pointer;">
31
- REGISTRAR CAPTURA
32
- </button>
33
- </main>
34
-
35
- <section style="margin-top: 30px;">
36
- <h3>Capturas hoy: {{registros.length}}</h3>
37
- <div *ngFor="let r of registros.slice(-3)" style="background: #f0f0f0; margin-bottom: 10px; padding: 10px; border-radius: 5px; font-size: 0.9em;">
38
- {{r.especie}} - {{r.pesoLbs}}lbs - {{r.fecha | date:'shortTime'}}
39
- </div>
40
- </section>
41
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.component.spec.ts DELETED
@@ -1,29 +0,0 @@
1
- import { TestBed } from '@angular/core/testing';
2
- import { AppComponent } from './app.component';
3
-
4
- describe('AppComponent', () => {
5
- beforeEach(async () => {
6
- await TestBed.configureTestingModule({
7
- imports: [AppComponent],
8
- }).compileComponents();
9
- });
10
-
11
- it('should create the app', () => {
12
- const fixture = TestBed.createComponent(AppComponent);
13
- const app = fixture.componentInstance;
14
- expect(app).toBeTruthy();
15
- });
16
-
17
- it(`should have the 'mi-pesca-rd' title`, () => {
18
- const fixture = TestBed.createComponent(AppComponent);
19
- const app = fixture.componentInstance;
20
- expect(app.title).toEqual('mi-pesca-rd');
21
- });
22
-
23
- it('should render title', () => {
24
- const fixture = TestBed.createComponent(AppComponent);
25
- fixture.detectChanges();
26
- const compiled = fixture.nativeElement as HTMLElement;
27
- expect(compiled.querySelector('h1')?.textContent).toContain('Hello, mi-pesca-rd');
28
- });
29
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.component.ts DELETED
@@ -1,58 +0,0 @@
1
- import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
2
- import { CommonModule,isPlatformBrowser } from '@angular/common';
3
- import { RegistroPesca } from './models/pesca.model';
4
-
5
- @Component({
6
- selector: 'app-root',
7
- standalone: true,
8
- imports:[CommonModule],
9
- templateUrl: './app.component.html',
10
- styleUrls: ['./app.component.css']
11
- })
12
- export class AppComponent implements OnInit {
13
- registros: RegistroPesca[] = [];
14
- isBrowser: boolean;
15
-
16
- constructor(@Inject(PLATFORM_ID) private platformId: Object) {
17
- // Verificamos si estamos en el navegador del pescador
18
- this.isBrowser = isPlatformBrowser(this.platformId);
19
- }
20
-
21
- ngOnInit() {
22
- if (this.isBrowser) {
23
- this.cargarDatosLocales();
24
- }
25
- }
26
-
27
- guardarCaptura(especie: string, peso: number, brazadas: number) {
28
- if (!this.isBrowser) return;
29
-
30
- // Captura de GPS obligatoria para el mapa de calor
31
- navigator.geolocation.getCurrentPosition(
32
- (pos) => {
33
- const nuevo: RegistroPesca = {
34
- especie,
35
- pesoLbs: peso,
36
- brazadas,
37
- latitud: pos.coords.latitude,
38
- longitud: pos.coords.longitude,
39
- fecha: new Date().toISOString(),
40
- sincronizado: false
41
- };
42
-
43
- this.registros.push(nuevo);
44
- localStorage.setItem('db_pesca', JSON.stringify(this.registros));
45
- alert('Captura registrada exitosamente en alta mar 🌊');
46
- },
47
- (error) => {
48
- alert('Por favor activa el GPS para registrar la ubicación del recurso.');
49
- },
50
- { enableHighAccuracy: true } // Alta precisión para investigación científica
51
- );
52
- }
53
-
54
- cargarDatosLocales() {
55
- const data = localStorage.getItem('db_pesca');
56
- if (data) this.registros = JSON.parse(data);
57
- }
58
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.config.server.ts DELETED
@@ -1,11 +0,0 @@
1
- import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
2
- import { provideServerRendering } from '@angular/platform-server';
3
- import { appConfig } from './app.config';
4
-
5
- const serverConfig: ApplicationConfig = {
6
- providers: [
7
- provideServerRendering()
8
- ]
9
- };
10
-
11
- export const config = mergeApplicationConfig(appConfig, serverConfig);
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.config.ts DELETED
@@ -1,16 +0,0 @@
1
- import { ApplicationConfig, isDevMode } from '@angular/core';
2
- import { provideRouter } from '@angular/router';
3
- import { routes } from './app.routes';
4
- import { provideClientHydration } from '@angular/platform-browser';
5
- import { provideServiceWorker } from '@angular/service-worker';
6
-
7
- export const appConfig: ApplicationConfig = {
8
- providers: [
9
- provideRouter(routes),
10
- provideClientHydration(), // Importante para SSR
11
- provideServiceWorker('ngsw-worker.js', {
12
- enabled: !isDevMode(),
13
- registrationStrategy: 'registerWhenStable:30000'
14
- })
15
- ]
16
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.routes.ts DELETED
@@ -1,3 +0,0 @@
1
- import { Routes } from '@angular/router';
2
-
3
- export const routes: Routes = [];
 
 
 
 
src/app/models/pesca.model.ts DELETED
@@ -1,9 +0,0 @@
1
- export interface RegistroPesca {
2
- especie: string;
3
- pesoLbs: number;
4
- brazadas: number;
5
- latitud: number;
6
- longitud: number;
7
- fecha: string; // ISO String
8
- sincronizado: boolean;
9
- }
 
 
 
 
 
 
 
 
 
 
src/assets/.gitkeep DELETED
File without changes
src/favicon.ico DELETED

Git LFS Details

  • SHA256: f9102be80297c0529207607be5277b4f90bca89d65988fa1771b91c7894e815f
  • Pointer size: 130 Bytes
  • Size of remote file: 15.1 kB
src/index.html DELETED
@@ -1,13 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <title>MiPescaRd</title>
6
- <base href="/">
7
- <meta name="viewport" content="width=device-width, initial-scale=1">
8
- <link rel="icon" type="image/x-icon" href="favicon.ico">
9
- </head>
10
- <body>
11
- <app-root></app-root>
12
- </body>
13
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/main.server.ts DELETED
@@ -1,7 +0,0 @@
1
- import { bootstrapApplication } from '@angular/platform-browser';
2
- import { AppComponent } from './app/app.component';
3
- import { config } from './app/app.config.server';
4
-
5
- const bootstrap = () => bootstrapApplication(AppComponent, config);
6
-
7
- export default bootstrap;
 
 
 
 
 
 
 
 
src/main.ts DELETED
@@ -1,6 +0,0 @@
1
- import { bootstrapApplication } from '@angular/platform-browser';
2
- import { appConfig } from './app/app.config';
3
- import { AppComponent } from './app/app.component';
4
-
5
- bootstrapApplication(AppComponent, appConfig)
6
- .catch((err) => console.error(err));
 
 
 
 
 
 
 
src/styles.css DELETED
@@ -1 +0,0 @@
1
- /* You can add global styles to this file, and also import other style files */
 
 
static/css/styles.css ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Global Styles - Mi Pesca RD */
2
+ :root {
3
+ --primary-color: #0D47A1;
4
+ --secondary-color: #4CAF50;
5
+ --warning-color: #FF9800;
6
+ --danger-color: #F44336;
7
+ --background-color: #ffffff;
8
+ --text-color: #000000;
9
+ --card-bg: #f8f9fa;
10
+ --border-radius: 8px;
11
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ padding: 0;
22
+ background-color: var(--background-color);
23
+ color: var(--text-color);
24
+ font-size: 18px;
25
+ }
26
+
27
+ h1,
28
+ h2,
29
+ h3 {
30
+ margin-top: 0;
31
+ color: var(--primary-color);
32
+ }
33
+
34
+ html {
35
+ scroll-behavior: smooth;
36
+ }
37
+
38
+ /* App Container */
39
+ #app {
40
+ max-width: 600px;
41
+ margin: 0 auto;
42
+ min-height: 100vh;
43
+ display: flex;
44
+ flex-direction: column;
45
+ }
46
+
47
+ /* Loading State */
48
+ .loading {
49
+ display: flex;
50
+ flex-direction: column;
51
+ align-items: center;
52
+ justify-content: center;
53
+ min-height: 100vh;
54
+ text-align: center;
55
+ }
56
+
57
+ /* Navigation */
58
+ .nav {
59
+ background-color: var(--primary-color);
60
+ color: white;
61
+ padding: 1rem;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ box-shadow: var(--shadow);
66
+ }
67
+
68
+ .nav h1 {
69
+ margin: 0;
70
+ font-size: 1.5rem;
71
+ color: white;
72
+ }
73
+
74
+ .nav-back {
75
+ background: none;
76
+ border: none;
77
+ color: white;
78
+ font-size: 1.5rem;
79
+ cursor: pointer;
80
+ padding: 0.5rem;
81
+ }
82
+
83
+ /* Main Content */
84
+ .content {
85
+ flex: 1;
86
+ padding: 1rem;
87
+ }
88
+
89
+ /* Cards */
90
+ .card {
91
+ background-color: var(--card-bg);
92
+ border-radius: var(--border-radius);
93
+ padding: 1.5rem;
94
+ margin-bottom: 1rem;
95
+ box-shadow: var(--shadow);
96
+ }
97
+
98
+ /* Buttons */
99
+ button {
100
+ min-height: 50px;
101
+ padding: 0.75rem 1.5rem;
102
+ font-size: 1rem;
103
+ font-weight: 600;
104
+ border: none;
105
+ border-radius: var(--border-radius);
106
+ cursor: pointer;
107
+ touch-action: manipulation;
108
+ transition: all 0.2s;
109
+ }
110
+
111
+ .btn-primary {
112
+ background-color: var(--primary-color);
113
+ color: white;
114
+ }
115
+
116
+ .btn-primary:hover {
117
+ background-color: #0a3a7f;
118
+ }
119
+
120
+ .btn-secondary {
121
+ background-color: var(--secondary-color);
122
+ color: white;
123
+ }
124
+
125
+ .btn-secondary:hover {
126
+ background-color: #45a049;
127
+ }
128
+
129
+ .btn-danger {
130
+ background-color: var(--danger-color);
131
+ color: white;
132
+ }
133
+
134
+ .btn-full {
135
+ width: 100%;
136
+ margin-bottom: 1rem;
137
+ }
138
+
139
+ /* Forms */
140
+ .form-group {
141
+ margin-bottom: 1.5rem;
142
+ }
143
+
144
+ .form-group label {
145
+ display: block;
146
+ margin-bottom: 0.5rem;
147
+ font-weight: 600;
148
+ color: var(--primary-color);
149
+ }
150
+
151
+ .form-group input,
152
+ .form-group select,
153
+ .form-group textarea {
154
+ width: 100%;
155
+ padding: 0.75rem;
156
+ font-size: 1rem;
157
+ border: 2px solid #ddd;
158
+ border-radius: var(--border-radius);
159
+ background-color: white;
160
+ }
161
+
162
+ .form-group input:focus,
163
+ .form-group select:focus,
164
+ .form-group textarea:focus {
165
+ outline: none;
166
+ border-color: var(--primary-color);
167
+ }
168
+
169
+ /* Species Grid */
170
+ .species-grid {
171
+ display: grid;
172
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
173
+ gap: 1rem;
174
+ margin-bottom: 1rem;
175
+ }
176
+
177
+ .species-card {
178
+ background: white;
179
+ border: 3px solid transparent;
180
+ border-radius: var(--border-radius);
181
+ padding: 1rem;
182
+ cursor: pointer;
183
+ text-align: center;
184
+ transition: all 0.2s;
185
+ box-shadow: var(--shadow);
186
+ }
187
+
188
+ .species-card:hover {
189
+ transform: translateY(-2px);
190
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
191
+ }
192
+
193
+ .species-card.selected {
194
+ border-color: var(--secondary-color);
195
+ background-color: #e8f5e9;
196
+ }
197
+
198
+ .species-card.protected {
199
+ border-color: var(--danger-color);
200
+ }
201
+
202
+ .species-card img {
203
+ width: 100%;
204
+ height: 100px;
205
+ object-fit: cover;
206
+ border-radius: 4px;
207
+ margin-bottom: 0.5rem;
208
+ }
209
+
210
+ .species-card h3 {
211
+ font-size: 1rem;
212
+ margin: 0.5rem 0 0 0;
213
+ color: var(--text-color);
214
+ }
215
+
216
+ .species-card .badge {
217
+ display: inline-block;
218
+ padding: 0.25rem 0.5rem;
219
+ font-size: 0.75rem;
220
+ border-radius: 4px;
221
+ margin-top: 0.5rem;
222
+ }
223
+
224
+ .badge-protected {
225
+ background-color: var(--danger-color);
226
+ color: white;
227
+ }
228
+
229
+ .badge-veda {
230
+ background-color: var(--warning-color);
231
+ color: white;
232
+ }
233
+
234
+ /* History List */
235
+ .history-list {
236
+ list-style: none;
237
+ padding: 0;
238
+ margin: 0;
239
+ }
240
+
241
+ .history-item {
242
+ background: white;
243
+ border-left: 4px solid var(--primary-color);
244
+ padding: 1rem;
245
+ margin-bottom: 1rem;
246
+ border-radius: var(--border-radius);
247
+ box-shadow: var(--shadow);
248
+ }
249
+
250
+ .history-item.synced {
251
+ border-left-color: var(--secondary-color);
252
+ }
253
+
254
+ .history-item.unsynced {
255
+ border-left-color: var(--warning-color);
256
+ }
257
+
258
+ .history-item h3 {
259
+ margin: 0 0 0.5rem 0;
260
+ font-size: 1rem;
261
+ }
262
+
263
+ .history-item p {
264
+ margin: 0.25rem 0;
265
+ font-size: 0.9rem;
266
+ color: #666;
267
+ }
268
+
269
+ /* Status Indicators */
270
+ .status-bar {
271
+ background-color: var(--card-bg);
272
+ padding: 0.5rem 1rem;
273
+ text-align: center;
274
+ font-size: 0.9rem;
275
+ border-bottom: 1px solid #ddd;
276
+ }
277
+
278
+ .status-online {
279
+ color: var(--secondary-color);
280
+ }
281
+
282
+ .status-offline {
283
+ color: var(--warning-color);
284
+ }
285
+
286
+ /* Confirmation View */
287
+ .confirmation-details {
288
+ background: white;
289
+ padding: 1rem;
290
+ border-radius: var(--border-radius);
291
+ margin-bottom: 1rem;
292
+ }
293
+
294
+ .confirmation-details dt {
295
+ font-weight: 600;
296
+ color: var(--primary-color);
297
+ margin-top: 0.5rem;
298
+ }
299
+
300
+ .confirmation-details dd {
301
+ margin: 0.25rem 0 0.5rem 0;
302
+ }
303
+
304
+ /* Mobile Responsive */
305
+ @media (max-width: 600px) {
306
+ body {
307
+ font-size: 16px;
308
+ }
309
+
310
+ .species-grid {
311
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
312
+ }
313
+
314
+ .content {
315
+ padding: 0.75rem;
316
+ }
317
+ }
318
+
319
+ /* Utility Classes */
320
+ .text-center {
321
+ text-align: center;
322
+ }
323
+
324
+ .mt-1 {
325
+ margin-top: 1rem;
326
+ }
327
+
328
+ .mb-1 {
329
+ margin-bottom: 1rem;
330
+ }
331
+
332
+ .hidden {
333
+ display: none;
334
+ }
src/app/app.component.css → static/favicon.ico RENAMED
File without changes
static/index.html ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="description" content="Mi Pesca RD - Registro de capturas pesqueras en República Dominicana">
8
+ <meta name="theme-color" content="#0D47A1">
9
+
10
+ <!-- PWA Meta Tags -->
11
+ <link rel="manifest" href="/manifest.json">
12
+ <link rel="icon" type="image/x-icon" href="/favicon.ico">
13
+ <meta name="apple-mobile-web-app-capable" content="yes">
14
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15
+ <meta name="apple-mobile-web-app-title" content="Mi Pesca RD">
16
+
17
+ <title>Mi Pesca RD</title>
18
+
19
+ <!-- Styles -->
20
+ <link rel="stylesheet" href="/css/styles.css">
21
+
22
+ <!-- Dexie.js for IndexedDB -->
23
+ <script src="https://unpkg.com/dexie@3.2.4/dist/dexie.min.js"></script>
24
+ </head>
25
+
26
+ <body>
27
+ <div id="app">
28
+ <!-- App content will be rendered here -->
29
+ <div class="loading">
30
+ <h1>🎣 Mi Pesca RD</h1>
31
+ <p>Cargando...</p>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Scripts -->
36
+ <script src="/js/db.js"></script>
37
+ <script src="/js/geolocation.js"></script>
38
+ <script src="/js/api.js"></script>
39
+ <script src="/js/sync.js"></script>
40
+ <script src="/js/components/home.js"></script>
41
+ <script src="/js/components/species-selector.js"></script>
42
+ <script src="/js/components/capture-form.js"></script>
43
+ <script src="/js/components/confirmation.js"></script>
44
+ <script src="/js/components/history.js"></script>
45
+ <script src="/js/app.js"></script>
46
+
47
+ <!-- Register Service Worker -->
48
+ <script>
49
+ if ('serviceWorker' in navigator) {
50
+ window.addEventListener('load', () => {
51
+ navigator.serviceWorker.register('/sw.js')
52
+ .then(registration => {
53
+ console.log('Service Worker registered:', registration);
54
+ })
55
+ .catch(error => {
56
+ console.log('Service Worker registration failed:', error);
57
+ });
58
+ });
59
+ }
60
+ </script>
61
+ </body>
62
+
63
+ </html>
static/js/api.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * API Service for backend communication
3
+ * Migrated from Angular api.service.ts
4
+ */
5
+
6
+ const apiService = {
7
+ baseUrl: window.location.origin,
8
+
9
+ /**
10
+ * Fetch wrapper with error handling
11
+ */
12
+ async request(endpoint, options = {}) {
13
+ try {
14
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ ...options.headers
18
+ },
19
+ ...options
20
+ });
21
+
22
+ if (!response.ok) {
23
+ throw new Error(`HTTP error! status: ${response.status}`);
24
+ }
25
+
26
+ return await response.json();
27
+ } catch (error) {
28
+ console.error('API request failed:', error);
29
+ throw error;
30
+ }
31
+ },
32
+
33
+ /**
34
+ * Get species list from backend
35
+ */
36
+ async getSpecies() {
37
+ return await this.request('/api/species');
38
+ },
39
+
40
+ /**
41
+ * Send a capture to the backend
42
+ */
43
+ async sendCapture(capture) {
44
+ return await this.request('/api/captures', {
45
+ method: 'POST',
46
+ body: JSON.stringify(capture)
47
+ });
48
+ },
49
+
50
+ /**
51
+ * Batch sync multiple captures
52
+ */
53
+ async syncCaptures(captures) {
54
+ return await this.request('/api/sync', {
55
+ method: 'POST',
56
+ body: JSON.stringify({ captures })
57
+ });
58
+ },
59
+
60
+ /**
61
+ * Check if online
62
+ */
63
+ isOnline() {
64
+ return navigator.onLine;
65
+ }
66
+ };
static/js/app.js ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Main Application
3
+ * SPA Router and State Management
4
+ */
5
+
6
+ const app = {
7
+ currentRoute: 'home',
8
+ currentComponent: null,
9
+ state: {},
10
+
11
+ routes: {
12
+ 'home': HomeComponent,
13
+ 'species-selector': SpeciesSelectorComponent,
14
+ 'capture-form': CaptureFormComponent,
15
+ 'confirmation': ConfirmationComponent,
16
+ 'history': HistoryComponent
17
+ },
18
+
19
+ /**
20
+ * Initialize the application
21
+ */
22
+ async init() {
23
+ console.log('Initializing Mi Pesca RD...');
24
+
25
+ // Initialize sync service
26
+ syncService.init();
27
+
28
+ // Load initial route
29
+ this.navigate('home');
30
+ },
31
+
32
+ /**
33
+ * Navigate to a route
34
+ */
35
+ async navigate(route) {
36
+ if (!this.routes[route]) {
37
+ console.error(`Route not found: ${route}`);
38
+ return;
39
+ }
40
+
41
+ // Destroy previous component
42
+ if (this.currentComponent && this.currentComponent.destroy) {
43
+ this.currentComponent.destroy();
44
+ }
45
+
46
+ // Update current route
47
+ this.currentRoute = route;
48
+ this.currentComponent = this.routes[route];
49
+
50
+ // Initialize component
51
+ if (this.currentComponent.init) {
52
+ await this.currentComponent.init();
53
+ }
54
+
55
+ // Render component
56
+ this.render();
57
+ },
58
+
59
+ /**
60
+ * Render current component
61
+ */
62
+ render() {
63
+ const appContainer = document.getElementById('app');
64
+
65
+ if (!appContainer) {
66
+ console.error('App container not found');
67
+ return;
68
+ }
69
+
70
+ if (this.currentComponent && this.currentComponent.render) {
71
+ appContainer.innerHTML = this.currentComponent.render();
72
+ }
73
+ }
74
+ };
75
+
76
+ // Initialize app when DOM is ready
77
+ document.addEventListener('DOMContentLoaded', () => {
78
+ app.init();
79
+ });
static/js/components/capture-form.js ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Capture Form Component
3
+ * Data entry form for capture details
4
+ */
5
+
6
+ const CaptureFormComponent = {
7
+ captureData: null,
8
+ location: null,
9
+
10
+ render() {
11
+ const selectedSpecies = app.state.selectedSpecies || [];
12
+
13
+ return `
14
+ <div class="nav">
15
+ <button class="nav-back" onclick="app.navigate('species-selector')">←</button>
16
+ <h1>Detalles de Captura</h1>
17
+ </div>
18
+
19
+ <div class="content">
20
+ <div class="card">
21
+ <h3>📍 Ubicación</h3>
22
+ <p id="location-status">Obteniendo ubicación GPS...</p>
23
+ </div>
24
+
25
+ <form id="capture-form" onsubmit="CaptureFormComponent.submit(event)">
26
+ ${selectedSpecies.map((speciesId, index) => `
27
+ <div class="card">
28
+ <h3>${this.getSpeciesName(speciesId)}</h3>
29
+
30
+ <div class="form-group">
31
+ <label for="quantity-${index}">Cantidad</label>
32
+ <input
33
+ type="number"
34
+ id="quantity-${index}"
35
+ name="quantity-${index}"
36
+ min="0.1"
37
+ step="0.1"
38
+ required
39
+ >
40
+ </div>
41
+
42
+ <div class="form-group">
43
+ <label for="unit-${index}">Unidad</label>
44
+ <select id="unit-${index}" name="unit-${index}" required>
45
+ <option value="lbs">Libras (lbs)</option>
46
+ <option value="units">Unidades</option>
47
+ </select>
48
+ </div>
49
+ </div>
50
+ `).join('')}
51
+
52
+ <div class="card">
53
+ <div class="form-group">
54
+ <label for="fishing-method">Método de Pesca</label>
55
+ <select id="fishing-method" name="fishing-method" required>
56
+ <option value="">Seleccionar...</option>
57
+ <option value="Línea de mano">Línea de mano</option>
58
+ <option value="Nasa">Nasa</option>
59
+ <option value="Red de enmalle">Red de enmalle</option>
60
+ <option value="Palangre">Palangre</option>
61
+ <option value="Buceo">Buceo</option>
62
+ <option value="Otro">Otro</option>
63
+ </select>
64
+ </div>
65
+
66
+ <div class="form-group">
67
+ <label for="depth">Profundidad (Brazadas)</label>
68
+ <input
69
+ type="number"
70
+ id="depth"
71
+ name="depth"
72
+ min="0"
73
+ step="1"
74
+ required
75
+ >
76
+ </div>
77
+ </div>
78
+
79
+ <button type="submit" class="btn-primary btn-full">
80
+ Guardar Captura
81
+ </button>
82
+ </form>
83
+ </div>
84
+ `;
85
+ },
86
+
87
+ async init() {
88
+ // Get GPS location
89
+ try {
90
+ this.location = await geolocationService.getCurrentPosition();
91
+
92
+ const locationStatus = document.getElementById('location-status');
93
+ if (locationStatus) {
94
+ locationStatus.innerHTML = `
95
+ ✅ Ubicación obtenida<br>
96
+ <small>Lat: ${this.location.latitude.toFixed(6)}, Lon: ${this.location.longitude.toFixed(6)}</small>
97
+ `;
98
+ }
99
+ } catch (error) {
100
+ console.error('Error getting location:', error);
101
+ const locationStatus = document.getElementById('location-status');
102
+ if (locationStatus) {
103
+ locationStatus.innerHTML = `❌ ${error.message}`;
104
+ }
105
+ }
106
+ },
107
+
108
+ getSpeciesName(speciesId) {
109
+ const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
110
+ return species ? species.commonName : speciesId;
111
+ },
112
+
113
+ async submit(event) {
114
+ event.preventDefault();
115
+
116
+ if (!this.location) {
117
+ alert('No se pudo obtener la ubicación GPS. Por favor, intenta de nuevo.');
118
+ return;
119
+ }
120
+
121
+ const selectedSpecies = app.state.selectedSpecies || [];
122
+ const items = [];
123
+
124
+ // Collect data for each species
125
+ selectedSpecies.forEach((speciesId, index) => {
126
+ const quantity = parseFloat(document.getElementById(`quantity-${index}`).value);
127
+ const unit = document.getElementById(`unit-${index}`).value;
128
+
129
+ items.push({
130
+ speciesId: speciesId,
131
+ quantity: quantity,
132
+ unit: unit
133
+ });
134
+ });
135
+
136
+ // Create capture object
137
+ const capture = {
138
+ id: this.generateUUID(),
139
+ timestamp: new Date().toISOString(),
140
+ latitude: this.location.latitude,
141
+ longitude: this.location.longitude,
142
+ species: selectedSpecies,
143
+ items: items,
144
+ fishingMethod: document.getElementById('fishing-method').value,
145
+ depth: parseFloat(document.getElementById('depth').value),
146
+ synced: false
147
+ };
148
+
149
+ // Save to IndexedDB
150
+ try {
151
+ await dbService.addCapture(capture);
152
+
153
+ // Store in app state for confirmation
154
+ app.state.lastCapture = capture;
155
+
156
+ // Navigate to confirmation
157
+ app.navigate('confirmation');
158
+
159
+ // Trigger sync if online
160
+ if (navigator.onLine) {
161
+ syncService.syncNow();
162
+ }
163
+ } catch (error) {
164
+ console.error('Error saving capture:', error);
165
+ alert('Error al guardar la captura. Por favor, intenta de nuevo.');
166
+ }
167
+ },
168
+
169
+ generateUUID() {
170
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
171
+ const r = Math.random() * 16 | 0;
172
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
173
+ return v.toString(16);
174
+ });
175
+ }
176
+ };
static/js/components/confirmation.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Confirmation Component
3
+ * Shows capture summary after submission
4
+ */
5
+
6
+ const ConfirmationComponent = {
7
+ render() {
8
+ const capture = app.state.lastCapture;
9
+
10
+ if (!capture) {
11
+ return `
12
+ <div class="nav">
13
+ <h1>Confirmación</h1>
14
+ </div>
15
+ <div class="content">
16
+ <div class="card">
17
+ <p>No hay datos de captura.</p>
18
+ </div>
19
+ <button class="btn-primary btn-full" onclick="app.navigate('home')">
20
+ Volver al Inicio
21
+ </button>
22
+ </div>
23
+ `;
24
+ }
25
+
26
+ return `
27
+ <div class="nav">
28
+ <h1>✅ Captura Registrada</h1>
29
+ </div>
30
+
31
+ <div class="content">
32
+ <div class="card">
33
+ <h2 style="color: var(--secondary-color); text-align: center;">
34
+ ¡Captura guardada exitosamente!
35
+ </h2>
36
+ <p class="text-center">
37
+ ${capture.synced ? '✅ Sincronizada con el servidor' : '⏳ Se sincronizará cuando haya conexión'}
38
+ </p>
39
+ </div>
40
+
41
+ <div class="card confirmation-details">
42
+ <h3>Resumen de Captura</h3>
43
+
44
+ <dl>
45
+ <dt>📅 Fecha y Hora</dt>
46
+ <dd>${this.formatDate(capture.timestamp)}</dd>
47
+
48
+ <dt>📍 Ubicación</dt>
49
+ <dd>
50
+ Lat: ${capture.latitude.toFixed(6)}<br>
51
+ Lon: ${capture.longitude.toFixed(6)}
52
+ </dd>
53
+
54
+ <dt>🐟 Especies</dt>
55
+ <dd>
56
+ ${capture.items.map(item => `
57
+ ${this.getSpeciesName(item.speciesId)}:
58
+ ${item.quantity} ${item.unit === 'lbs' ? 'libras' : 'unidades'}
59
+ `).join('<br>')}
60
+ </dd>
61
+
62
+ <dt>🎣 Método de Pesca</dt>
63
+ <dd>${capture.fishingMethod}</dd>
64
+
65
+ <dt>📏 Profundidad</dt>
66
+ <dd>${capture.depth} brazadas</dd>
67
+ </dl>
68
+ </div>
69
+
70
+ <button class="btn-primary btn-full" onclick="app.navigate('home')">
71
+ 🏠 Volver al Inicio
72
+ </button>
73
+
74
+ <button class="btn-secondary btn-full" onclick="app.navigate('species-selector')">
75
+ ➕ Registrar Otra Captura
76
+ </button>
77
+ </div>
78
+ `;
79
+ },
80
+
81
+ init() {
82
+ // Component initialized
83
+ },
84
+
85
+ formatDate(dateString) {
86
+ const date = new Date(dateString);
87
+ return date.toLocaleString('es-DO', {
88
+ year: 'numeric',
89
+ month: 'long',
90
+ day: 'numeric',
91
+ hour: '2-digit',
92
+ minute: '2-digit'
93
+ });
94
+ },
95
+
96
+ getSpeciesName(speciesId) {
97
+ const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
98
+ return species ? species.commonName : speciesId;
99
+ }
100
+ };
static/js/components/history.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * History Component
3
+ * Display list of saved captures
4
+ */
5
+
6
+ const HistoryComponent = {
7
+ captures: [],
8
+
9
+ render() {
10
+ return `
11
+ <div class="nav">
12
+ <button class="nav-back" onclick="app.navigate('home')">←</button>
13
+ <h1>Historial de Capturas</h1>
14
+ </div>
15
+
16
+ <div class="content">
17
+ <div class="card">
18
+ <p><strong>${this.captures.length}</strong> captura(s) registrada(s)</p>
19
+ </div>
20
+
21
+ ${this.captures.length === 0 ? `
22
+ <div class="card text-center">
23
+ <p>No hay capturas registradas aún.</p>
24
+ <button class="btn-primary" onclick="app.navigate('species-selector')">
25
+ ➕ Registrar Primera Captura
26
+ </button>
27
+ </div>
28
+ ` : `
29
+ <ul class="history-list">
30
+ ${this.renderCapturesList()}
31
+ </ul>
32
+ `}
33
+ </div>
34
+ `;
35
+ },
36
+
37
+ renderCapturesList() {
38
+ return this.captures
39
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
40
+ .map(capture => `
41
+ <li class="history-item ${capture.synced ? 'synced' : 'unsynced'}">
42
+ <h3>
43
+ ${this.formatDate(capture.timestamp)}
44
+ ${capture.synced ? '✅' : '⏳'}
45
+ </h3>
46
+
47
+ <p>
48
+ <strong>Especies:</strong>
49
+ ${capture.items.map(item =>
50
+ `${this.getSpeciesName(item.speciesId)} (${item.quantity} ${item.unit === 'lbs' ? 'lbs' : 'unidades'})`
51
+ ).join(', ')}
52
+ </p>
53
+
54
+ <p>
55
+ <strong>Método:</strong> ${capture.fishingMethod} |
56
+ <strong>Profundidad:</strong> ${capture.depth} brazadas
57
+ </p>
58
+
59
+ <p>
60
+ <strong>Ubicación:</strong>
61
+ ${capture.latitude.toFixed(4)}, ${capture.longitude.toFixed(4)}
62
+ </p>
63
+
64
+ <p style="font-size: 0.85rem; color: #666;">
65
+ ${capture.synced ?
66
+ `Sincronizada: ${this.formatDate(capture.syncedAt)}` :
67
+ 'Pendiente de sincronización'
68
+ }
69
+ </p>
70
+ </li>
71
+ `).join('');
72
+ },
73
+
74
+ async init() {
75
+ // Load all captures from IndexedDB
76
+ this.captures = await dbService.getAllCaptures();
77
+ },
78
+
79
+ formatDate(dateString) {
80
+ if (!dateString) return 'N/A';
81
+
82
+ const date = new Date(dateString);
83
+ return date.toLocaleString('es-DO', {
84
+ year: 'numeric',
85
+ month: 'short',
86
+ day: 'numeric',
87
+ hour: '2-digit',
88
+ minute: '2-digit'
89
+ });
90
+ },
91
+
92
+ getSpeciesName(speciesId) {
93
+ const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
94
+ return species ? species.commonName : speciesId;
95
+ }
96
+ };
static/js/components/home.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Home Component
3
+ * Main dashboard view
4
+ */
5
+
6
+ const HomeComponent = {
7
+ render() {
8
+ return `
9
+ <div class="nav">
10
+ <h1>🎣 Mi Pesca RD</h1>
11
+ </div>
12
+
13
+ <div id="status-bar" class="status-bar">
14
+ <span id="online-status"></span>
15
+ </div>
16
+
17
+ <div class="content">
18
+ <div class="card">
19
+ <h2>Bienvenido</h2>
20
+ <p>Registra tus capturas de forma rápida y sencilla. Los datos se guardan localmente y se sincronizan automáticamente cuando tengas conexión.</p>
21
+ </div>
22
+
23
+ <button class="btn-primary btn-full" onclick="app.navigate('species-selector')">
24
+ ➕ Nueva Captura
25
+ </button>
26
+
27
+ <button class="btn-secondary btn-full" onclick="app.navigate('history')">
28
+ 📋 Ver Historial
29
+ </button>
30
+
31
+ <div class="card">
32
+ <h3>Estado de Sincronización</h3>
33
+ <p id="sync-info">Cargando...</p>
34
+ <button class="btn-secondary" onclick="syncService.syncNow()" id="sync-btn">
35
+ 🔄 Sincronizar Ahora
36
+ </button>
37
+ </div>
38
+ </div>
39
+ `;
40
+ },
41
+
42
+ async init() {
43
+ await this.updateSyncStatus();
44
+
45
+ // Update status every 5 seconds
46
+ this.statusInterval = setInterval(() => {
47
+ this.updateOnlineStatus();
48
+ }, 5000);
49
+
50
+ // Listen for sync events
51
+ window.addEventListener('syncComplete', () => {
52
+ this.updateSyncStatus();
53
+ });
54
+ },
55
+
56
+ updateOnlineStatus() {
57
+ const statusEl = document.getElementById('online-status');
58
+ if (statusEl) {
59
+ if (navigator.onLine) {
60
+ statusEl.innerHTML = '🟢 En línea';
61
+ statusEl.className = 'status-online';
62
+ } else {
63
+ statusEl.innerHTML = '🔴 Sin conexión';
64
+ statusEl.className = 'status-offline';
65
+ }
66
+ }
67
+ },
68
+
69
+ async updateSyncStatus() {
70
+ this.updateOnlineStatus();
71
+
72
+ const status = await syncService.getSyncStatus();
73
+ const syncInfo = document.getElementById('sync-info');
74
+
75
+ if (syncInfo) {
76
+ if (status.unsyncedCount === 0) {
77
+ syncInfo.innerHTML = '✅ Todas las capturas sincronizadas';
78
+ } else {
79
+ syncInfo.innerHTML = `⚠️ ${status.unsyncedCount} captura(s) pendiente(s) de sincronizar`;
80
+ }
81
+ }
82
+ },
83
+
84
+ destroy() {
85
+ if (this.statusInterval) {
86
+ clearInterval(this.statusInterval);
87
+ }
88
+ }
89
+ };
static/js/components/species-selector.js ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Species Selector Component
3
+ * Visual species picker with images
4
+ */
5
+
6
+ const SpeciesSelectorComponent = {
7
+ selectedSpecies: [],
8
+ allSpecies: [],
9
+
10
+ render() {
11
+ return `
12
+ <div class="nav">
13
+ <button class="nav-back" onclick="app.navigate('home')">←</button>
14
+ <h1>Seleccionar Especies</h1>
15
+ </div>
16
+
17
+ <div class="content">
18
+ <div class="card">
19
+ <p>Selecciona las especies capturadas (puedes elegir varias):</p>
20
+ <p><strong>${this.selectedSpecies.length}</strong> especie(s) seleccionada(s)</p>
21
+ </div>
22
+
23
+ <div id="species-grid" class="species-grid">
24
+ ${this.renderSpeciesGrid()}
25
+ </div>
26
+
27
+ <button
28
+ class="btn-primary btn-full"
29
+ onclick="SpeciesSelectorComponent.continue()"
30
+ ${this.selectedSpecies.length === 0 ? 'disabled' : ''}
31
+ >
32
+ Continuar →
33
+ </button>
34
+ </div>
35
+ `;
36
+ },
37
+
38
+ renderSpeciesGrid() {
39
+ if (this.allSpecies.length === 0) {
40
+ return '<p>Cargando especies...</p>';
41
+ }
42
+
43
+ return this.allSpecies.map(species => {
44
+ const isSelected = this.selectedSpecies.includes(species.id);
45
+ const isProtected = species.protected;
46
+ const hasVeda = species.closedSeason;
47
+
48
+ return `
49
+ <div
50
+ class="species-card ${isSelected ? 'selected' : ''} ${isProtected ? 'protected' : ''}"
51
+ onclick="SpeciesSelectorComponent.toggleSpecies('${species.id}')"
52
+ >
53
+ <img src="${species.imageUrl}" alt="${species.commonName}" onerror="this.src='/assets/species/placeholder.jpg'">
54
+ <h3>${species.commonName}</h3>
55
+ <p style="font-size: 0.8rem; color: #666; margin: 0;">${species.scientificName}</p>
56
+ ${isProtected ? '<span class="badge badge-protected">PROTEGIDA</span>' : ''}
57
+ ${hasVeda ? '<span class="badge badge-veda">VEDA</span>' : ''}
58
+ </div>
59
+ `;
60
+ }).join('');
61
+ },
62
+
63
+ async init() {
64
+ this.selectedSpecies = [];
65
+
66
+ // Load species from IndexedDB
67
+ let species = await dbService.getAllSpecies();
68
+
69
+ // If no species in DB, fetch from API
70
+ if (species.length === 0) {
71
+ try {
72
+ species = await apiService.getSpecies();
73
+ await dbService.saveSpecies(species);
74
+ } catch (error) {
75
+ console.error('Error loading species:', error);
76
+ }
77
+ }
78
+
79
+ this.allSpecies = species;
80
+ },
81
+
82
+ toggleSpecies(speciesId) {
83
+ const index = this.selectedSpecies.indexOf(speciesId);
84
+
85
+ if (index > -1) {
86
+ this.selectedSpecies.splice(index, 1);
87
+ } else {
88
+ this.selectedSpecies.push(speciesId);
89
+ }
90
+
91
+ // Re-render
92
+ app.render();
93
+ },
94
+
95
+ continue() {
96
+ if (this.selectedSpecies.length > 0) {
97
+ // Store selected species in app state
98
+ app.state.selectedSpecies = [...this.selectedSpecies];
99
+ app.navigate('capture-form');
100
+ }
101
+ }
102
+ };
static/js/db.js ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * IndexedDB Database Service using Dexie.js
3
+ * Migrated from Angular db.service.ts
4
+ */
5
+
6
+ const db = new Dexie('MiPescaDB');
7
+
8
+ // Define database schema
9
+ db.version(1).stores({
10
+ captures: 'id, timestamp, synced',
11
+ species: 'id, commonName, category'
12
+ });
13
+
14
+ // Database operations
15
+ const dbService = {
16
+ /**
17
+ * Add a new capture to the database
18
+ */
19
+ async addCapture(capture) {
20
+ return await db.captures.add(capture);
21
+ },
22
+
23
+ /**
24
+ * Get all captures
25
+ */
26
+ async getAllCaptures() {
27
+ return await db.captures.toArray();
28
+ },
29
+
30
+ /**
31
+ * Get unsynced captures
32
+ */
33
+ async getUnsyncedCaptures() {
34
+ return await db.captures.where('synced').equals(false).toArray();
35
+ },
36
+
37
+ /**
38
+ * Update sync status of a capture
39
+ */
40
+ async updateSyncStatus(id, synced) {
41
+ return await db.captures.update(id, {
42
+ synced: synced,
43
+ syncedAt: new Date()
44
+ });
45
+ },
46
+
47
+ /**
48
+ * Save species list to database
49
+ */
50
+ async saveSpecies(speciesList) {
51
+ return await db.species.bulkPut(speciesList);
52
+ },
53
+
54
+ /**
55
+ * Get all species
56
+ */
57
+ async getAllSpecies() {
58
+ return await db.species.toArray();
59
+ },
60
+
61
+ /**
62
+ * Clear all data (for testing)
63
+ */
64
+ async clearAll() {
65
+ await db.captures.clear();
66
+ await db.species.clear();
67
+ }
68
+ };
static/js/geolocation.js ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Geolocation Service
3
+ * Migrated from Angular geolocation.service.ts
4
+ */
5
+
6
+ const geolocationService = {
7
+ /**
8
+ * Get current GPS coordinates
9
+ */
10
+ async getCurrentPosition() {
11
+ return new Promise((resolve, reject) => {
12
+ if (!navigator.geolocation) {
13
+ reject(new Error('Geolocation no está soportada por este navegador'));
14
+ return;
15
+ }
16
+
17
+ navigator.geolocation.getCurrentPosition(
18
+ (position) => {
19
+ resolve({
20
+ latitude: position.coords.latitude,
21
+ longitude: position.coords.longitude,
22
+ accuracy: position.coords.accuracy,
23
+ timestamp: new Date(position.timestamp)
24
+ });
25
+ },
26
+ (error) => {
27
+ let errorMessage = 'Error obteniendo ubicación';
28
+
29
+ switch (error.code) {
30
+ case error.PERMISSION_DENIED:
31
+ errorMessage = 'Permiso de ubicación denegado';
32
+ break;
33
+ case error.POSITION_UNAVAILABLE:
34
+ errorMessage = 'Ubicación no disponible';
35
+ break;
36
+ case error.TIMEOUT:
37
+ errorMessage = 'Tiempo de espera agotado';
38
+ break;
39
+ }
40
+
41
+ reject(new Error(errorMessage));
42
+ },
43
+ {
44
+ enableHighAccuracy: true,
45
+ timeout: 10000,
46
+ maximumAge: 0
47
+ }
48
+ );
49
+ });
50
+ },
51
+
52
+ /**
53
+ * Watch position for continuous tracking
54
+ */
55
+ watchPosition(onSuccess, onError) {
56
+ if (!navigator.geolocation) {
57
+ onError(new Error('Geolocation no está soportada'));
58
+ return null;
59
+ }
60
+
61
+ return navigator.geolocation.watchPosition(
62
+ (position) => {
63
+ onSuccess({
64
+ latitude: position.coords.latitude,
65
+ longitude: position.coords.longitude,
66
+ accuracy: position.coords.accuracy,
67
+ timestamp: new Date(position.timestamp)
68
+ });
69
+ },
70
+ (error) => {
71
+ onError(error);
72
+ },
73
+ {
74
+ enableHighAccuracy: true,
75
+ timeout: 10000,
76
+ maximumAge: 0
77
+ }
78
+ );
79
+ },
80
+
81
+ /**
82
+ * Clear position watch
83
+ */
84
+ clearWatch(watchId) {
85
+ if (watchId && navigator.geolocation) {
86
+ navigator.geolocation.clearWatch(watchId);
87
+ }
88
+ }
89
+ };
static/js/sync.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Synchronization Service
3
+ * Migrated from Angular sync.service.ts
4
+ * Handles offline-online data synchronization
5
+ */
6
+
7
+ const syncService = {
8
+ isSyncing: false,
9
+ syncInterval: null,
10
+
11
+ /**
12
+ * Initialize sync service
13
+ */
14
+ init() {
15
+ // Listen for online/offline events
16
+ window.addEventListener('online', () => {
17
+ console.log('Connection restored, syncing...');
18
+ this.syncNow();
19
+ });
20
+
21
+ window.addEventListener('offline', () => {
22
+ console.log('Connection lost, working offline');
23
+ });
24
+
25
+ // Auto-sync every 5 minutes when online
26
+ this.syncInterval = setInterval(() => {
27
+ if (navigator.onLine) {
28
+ this.syncNow();
29
+ }
30
+ }, 5 * 60 * 1000);
31
+
32
+ // Initial sync if online
33
+ if (navigator.onLine) {
34
+ this.syncNow();
35
+ }
36
+ },
37
+
38
+ /**
39
+ * Sync unsynced captures to backend
40
+ */
41
+ async syncNow() {
42
+ if (this.isSyncing || !navigator.onLine) {
43
+ return;
44
+ }
45
+
46
+ this.isSyncing = true;
47
+
48
+ try {
49
+ const unsyncedCaptures = await dbService.getUnsyncedCaptures();
50
+
51
+ if (unsyncedCaptures.length === 0) {
52
+ console.log('No captures to sync');
53
+ this.isSyncing = false;
54
+ return;
55
+ }
56
+
57
+ console.log(`Syncing ${unsyncedCaptures.length} captures...`);
58
+
59
+ const result = await apiService.syncCaptures(unsyncedCaptures);
60
+
61
+ // Update sync status for successfully synced captures
62
+ for (const id of result.synced) {
63
+ await dbService.updateSyncStatus(id, true);
64
+ }
65
+
66
+ console.log(`Successfully synced ${result.synced.length} captures`);
67
+
68
+ // Dispatch event for UI updates
69
+ window.dispatchEvent(new CustomEvent('syncComplete', {
70
+ detail: {
71
+ synced: result.synced.length,
72
+ errors: result.errors.length
73
+ }
74
+ }));
75
+
76
+ } catch (error) {
77
+ console.error('Sync failed:', error);
78
+ } finally {
79
+ this.isSyncing = false;
80
+ }
81
+ },
82
+
83
+ /**
84
+ * Get sync status
85
+ */
86
+ async getSyncStatus() {
87
+ const unsyncedCount = (await dbService.getUnsyncedCaptures()).length;
88
+ return {
89
+ isOnline: navigator.onLine,
90
+ isSyncing: this.isSyncing,
91
+ unsyncedCount: unsyncedCount
92
+ };
93
+ },
94
+
95
+ /**
96
+ * Stop sync service
97
+ */
98
+ stop() {
99
+ if (this.syncInterval) {
100
+ clearInterval(this.syncInterval);
101
+ }
102
+ }
103
+ };
static/manifest.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Mi Pesca RD",
3
+ "short_name": "MiPesca",
4
+ "description": "Aplicación para registro de capturas pesqueras en República Dominicana",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#0D47A1",
9
+ "orientation": "portrait",
10
+ "icons": [
11
+ {
12
+ "src": "/assets/icon-192.png",
13
+ "sizes": "192x192",
14
+ "type": "image/png",
15
+ "purpose": "any maskable"
16
+ },
17
+ {
18
+ "src": "/assets/icon-512.png",
19
+ "sizes": "512x512",
20
+ "type": "image/png",
21
+ "purpose": "any maskable"
22
+ }
23
+ ],
24
+ "categories": [
25
+ "productivity",
26
+ "utilities"
27
+ ],
28
+ "lang": "es-DO"
29
+ }
static/sw.js ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Service Worker for PWA
3
+ * Handles offline functionality and caching
4
+ */
5
+
6
+ const CACHE_NAME = 'mipesca-v1';
7
+ const STATIC_CACHE = [
8
+ '/',
9
+ '/index.html',
10
+ '/css/styles.css',
11
+ '/js/app.js',
12
+ '/js/db.js',
13
+ '/js/geolocation.js',
14
+ '/js/api.js',
15
+ '/js/sync.js',
16
+ '/js/components/home.js',
17
+ '/js/components/species-selector.js',
18
+ '/js/components/capture-form.js',
19
+ '/js/components/confirmation.js',
20
+ '/js/components/history.js',
21
+ '/manifest.json',
22
+ 'https://unpkg.com/dexie@3.2.4/dist/dexie.min.js'
23
+ ];
24
+
25
+ // Install event - cache static assets
26
+ self.addEventListener('install', (event) => {
27
+ console.log('Service Worker installing...');
28
+
29
+ event.waitUntil(
30
+ caches.open(CACHE_NAME)
31
+ .then((cache) => {
32
+ console.log('Caching static assets');
33
+ return cache.addAll(STATIC_CACHE);
34
+ })
35
+ .then(() => self.skipWaiting())
36
+ );
37
+ });
38
+
39
+ // Activate event - clean up old caches
40
+ self.addEventListener('activate', (event) => {
41
+ console.log('Service Worker activating...');
42
+
43
+ event.waitUntil(
44
+ caches.keys().then((cacheNames) => {
45
+ return Promise.all(
46
+ cacheNames.map((cacheName) => {
47
+ if (cacheName !== CACHE_NAME) {
48
+ console.log('Deleting old cache:', cacheName);
49
+ return caches.delete(cacheName);
50
+ }
51
+ })
52
+ );
53
+ }).then(() => self.clients.claim())
54
+ );
55
+ });
56
+
57
+ // Fetch event - serve from cache, fallback to network
58
+ self.addEventListener('fetch', (event) => {
59
+ const { request } = event;
60
+ const url = new URL(request.url);
61
+
62
+ // API requests - network first, then cache
63
+ if (url.pathname.startsWith('/api/')) {
64
+ event.respondWith(
65
+ fetch(request)
66
+ .then((response) => {
67
+ // Clone response to cache it
68
+ const responseClone = response.clone();
69
+ caches.open(CACHE_NAME).then((cache) => {
70
+ cache.put(request, responseClone);
71
+ });
72
+ return response;
73
+ })
74
+ .catch(() => {
75
+ // If network fails, try cache
76
+ return caches.match(request);
77
+ })
78
+ );
79
+ return;
80
+ }
81
+
82
+ // Static assets - cache first, then network
83
+ event.respondWith(
84
+ caches.match(request)
85
+ .then((cachedResponse) => {
86
+ if (cachedResponse) {
87
+ return cachedResponse;
88
+ }
89
+
90
+ return fetch(request)
91
+ .then((response) => {
92
+ // Don't cache non-successful responses
93
+ if (!response || response.status !== 200 || response.type === 'error') {
94
+ return response;
95
+ }
96
+
97
+ // Clone response to cache it
98
+ const responseClone = response.clone();
99
+ caches.open(CACHE_NAME).then((cache) => {
100
+ cache.put(request, responseClone);
101
+ });
102
+
103
+ return response;
104
+ });
105
+ })
106
+ .catch(() => {
107
+ // Return offline page if available
108
+ if (request.mode === 'navigate') {
109
+ return caches.match('/index.html');
110
+ }
111
+ })
112
+ );
113
+ });
114
+
115
+ // Background sync for captures
116
+ self.addEventListener('sync', (event) => {
117
+ if (event.tag === 'sync-captures') {
118
+ console.log('Background sync triggered');
119
+ event.waitUntil(
120
+ // Notify clients to sync
121
+ self.clients.matchAll().then((clients) => {
122
+ clients.forEach((client) => {
123
+ client.postMessage({
124
+ type: 'BACKGROUND_SYNC',
125
+ action: 'sync-captures'
126
+ });
127
+ });
128
+ })
129
+ );
130
+ }
131
+ });
tsconfig.app.json DELETED
@@ -1,18 +0,0 @@
1
- /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
- {
3
- "extends": "./tsconfig.json",
4
- "compilerOptions": {
5
- "outDir": "./out-tsc/app",
6
- "types": [
7
- "node"
8
- ]
9
- },
10
- "files": [
11
- "src/main.ts",
12
- "src/main.server.ts",
13
- "server.ts"
14
- ],
15
- "include": [
16
- "src/**/*.d.ts"
17
- ]
18
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.json DELETED
@@ -1,32 +0,0 @@
1
- /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
- {
3
- "compileOnSave": false,
4
- "compilerOptions": {
5
- "outDir": "./dist/out-tsc",
6
- "strict": true,
7
- "noImplicitOverride": true,
8
- "noPropertyAccessFromIndexSignature": true,
9
- "noImplicitReturns": true,
10
- "noFallthroughCasesInSwitch": true,
11
- "skipLibCheck": true,
12
- "esModuleInterop": true,
13
- "sourceMap": true,
14
- "declaration": false,
15
- "experimentalDecorators": true,
16
- "moduleResolution": "node",
17
- "importHelpers": true,
18
- "target": "ES2022",
19
- "module": "ES2022",
20
- "useDefineForClassFields": false,
21
- "lib": [
22
- "ES2022",
23
- "dom"
24
- ]
25
- },
26
- "angularCompilerOptions": {
27
- "enableI18nLegacyMessageIdFormat": false,
28
- "strictInjectionParameters": true,
29
- "strictInputAccessModifiers": true,
30
- "strictTemplates": true
31
- }
32
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.spec.json DELETED
@@ -1,14 +0,0 @@
1
- /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
- {
3
- "extends": "./tsconfig.json",
4
- "compilerOptions": {
5
- "outDir": "./out-tsc/spec",
6
- "types": [
7
- "jasmine"
8
- ]
9
- },
10
- "include": [
11
- "src/**/*.spec.ts",
12
- "src/**/*.d.ts"
13
- ]
14
- }