Internal
Public Access
Compare commits
@@ -24,6 +24,7 @@ stack.env
|
|||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.codex
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
@@ -41,3 +42,10 @@ htmlcov/
|
|||||||
|
|
||||||
# Firebase credentials
|
# Firebase credentials
|
||||||
*firebase*.json
|
*firebase*.json
|
||||||
|
|
||||||
|
# Local Node artifacts used for tooling in this repo
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Local AI assistant instructions (not for the repo)
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ FROM python:3.12-slim-bookworm
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
curl \
|
curl \
|
||||||
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create django user (non-root for security)
|
# Create django user (non-root for security)
|
||||||
|
|||||||
+15
-11
@@ -8,7 +8,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
- Portainer installed and accessible
|
- Portainer installed and accessible
|
||||||
- Access to your Docker server via Portainer
|
- Access to your Docker server via Portainer
|
||||||
- Git repository: `https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git`
|
- Git repository: `https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git`
|
||||||
- Git credentials configured in Portainer (or repository is public)
|
- Git credentials configured in Portainer (or repository is public)
|
||||||
|
|
||||||
## Deployment Steps
|
## Deployment Steps
|
||||||
@@ -31,7 +31,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
**Repository URL:**
|
**Repository URL:**
|
||||||
```
|
```
|
||||||
https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git
|
https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git
|
||||||
```
|
```
|
||||||
|
|
||||||
**Repository reference:** `refs/heads/main`
|
**Repository reference:** `refs/heads/main`
|
||||||
@@ -40,17 +40,16 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
**Authentication:**
|
**Authentication:**
|
||||||
- If repository is private, enable authentication
|
- If repository is private, enable authentication
|
||||||
- Username: `keith@firebugit.com`
|
- Username: your Gitea account username
|
||||||
- Password: (your Git password)
|
- Password: a Gitea access token (recommended over your account password)
|
||||||
- Or use an access token if available
|
|
||||||
|
|
||||||
### Step 4: Configure Environment Variables
|
### Step 4: Configure Environment Variables
|
||||||
|
|
||||||
Scroll down to the **Environment variables** section and paste the following:
|
Scroll down to the **Environment variables** section and paste the following, replacing every `<...>` placeholder with the actual value from your password manager / secrets store (never commit real values to this file):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Django Core Settings
|
# Django Core Settings
|
||||||
SECRET_KEY=Zfb$!,3Gm\b,x~:a)9@k*Gun.#F{ju3dH-G8aWp4R"&NQ{1x>14v#)EbhpB*XMWD
|
SECRET_KEY=<django-secret-key>
|
||||||
DEBUG=False
|
DEBUG=False
|
||||||
ALLOWED_HOSTS=keepitgoing.app,www.keepitgoing.app,localhost
|
ALLOWED_HOSTS=keepitgoing.app,www.keepitgoing.app,localhost
|
||||||
CSRF_TRUSTED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
CSRF_TRUSTED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
||||||
@@ -59,8 +58,8 @@ SITE_DOMAIN=keepitgoing.app
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_NAME=keepitgoing
|
DB_NAME=keepitgoing
|
||||||
DB_USER=keepitgoing
|
DB_USER=keepitgoing
|
||||||
DB_PASSWORD=6SWh6Pz01hBFGNzWM0Pszz2moluqxyv7
|
DB_PASSWORD=<db-password>
|
||||||
DB_ROOT_PASSWORD=l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq
|
DB_ROOT_PASSWORD=<db-root-password>
|
||||||
DB_HOST=mariadb
|
DB_HOST=mariadb
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|
||||||
@@ -73,13 +72,18 @@ EMAIL_HOST=smtp.dreamhost.com
|
|||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_USE_TLS=True
|
EMAIL_USE_TLS=True
|
||||||
EMAIL_HOST_USER=kig@keepitgoing.app
|
EMAIL_HOST_USER=kig@keepitgoing.app
|
||||||
EMAIL_HOST_PASSWORD=Frankenmonster1!
|
EMAIL_HOST_PASSWORD=<email-host-password>
|
||||||
DEFAULT_FROM_EMAIL=KeepItGoing <kig@keepitgoing.app>
|
DEFAULT_FROM_EMAIL=KeepItGoing <kig@keepitgoing.app>
|
||||||
SERVER_EMAIL=kig@keepitgoing.app
|
SERVER_EMAIL=kig@keepitgoing.app
|
||||||
|
|
||||||
# Email Verification
|
# Email Verification
|
||||||
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
|
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
|
||||||
|
|
||||||
|
# Web Push (generate with `python manage.py generate_vapid_keys`)
|
||||||
|
VAPID_PUBLIC_KEY=<vapid-public-key>
|
||||||
|
VAPID_PRIVATE_KEY=<vapid-private-key>
|
||||||
|
VAPID_ADMIN_EMAIL=<admin-contact-email>
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
CORS_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ To backup the database:
|
|||||||
```bash
|
```bash
|
||||||
mysqldump -u root -p keepitgoing > /tmp/backup.sql
|
mysqldump -u root -p keepitgoing > /tmp/backup.sql
|
||||||
```
|
```
|
||||||
4. Enter root password: `l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq`
|
4. Enter the `DB_ROOT_PASSWORD` value from your Portainer stack environment variables
|
||||||
5. Use **File browser** to download `/tmp/backup.sql`
|
5. Use **File browser** to download `/tmp/backup.sql`
|
||||||
|
|
||||||
### Scaling
|
### Scaling
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A powerful Django-based task management system with time tracking, tag organizat
|
|||||||
- **Priority Levels**: Low, Medium, High, and Urgent
|
- **Priority Levels**: Low, Medium, High, and Urgent
|
||||||
- **Status Tracking**: Pending, In Progress, Completed, Cancelled
|
- **Status Tracking**: Pending, In Progress, Completed, Cancelled
|
||||||
- **Due Dates & Times**: Set specific due dates and times for tasks
|
- **Due Dates & Times**: Set specific due dates and times for tasks
|
||||||
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, and Custom patterns
|
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, plus Custom RRULE patterns (e.g. every other Wednesday, every second Tuesday, every 15th of the month)
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
- **Tag-Based System**: Organize tasks with multiple colored tags
|
- **Tag-Based System**: Organize tasks with multiple colored tags
|
||||||
@@ -25,8 +25,8 @@ A powerful Django-based task management system with time tracking, tag organizat
|
|||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
- **Multi-user Support**: Full authentication and user management
|
- **Multi-user Support**: Full authentication and user management
|
||||||
- **Email Verification**: Secure email verification for new user registrations
|
- **Email Verification**: Secure email verification for admin-created or self-registered accounts
|
||||||
- **Admin Approval**: Optional admin approval workflow for new users
|
- **Admin Approval**: Optional approval workflow for new users
|
||||||
- **User Profiles**: Customizable profiles with timezone and notification preferences
|
- **User Profiles**: Customizable profiles with timezone and notification preferences
|
||||||
- **Password Management**: Secure password change functionality
|
- **Password Management**: Secure password change functionality
|
||||||
|
|
||||||
@@ -43,6 +43,16 @@ A powerful Django-based task management system with time tracking, tag organizat
|
|||||||
- **Real-time Updates**: Task changes sync across devices
|
- **Real-time Updates**: Task changes sync across devices
|
||||||
- **Conflict Resolution**: Handles offline changes and syncing
|
- **Conflict Resolution**: Handles offline changes and syncing
|
||||||
|
|
||||||
|
### Offline Support (PWA)
|
||||||
|
- **Installable Web App**: Add to home screen with an offline-capable service worker
|
||||||
|
- **Full Offline Task Management**: Create, edit, complete tasks, manage subtasks and tags, and track time while offline
|
||||||
|
- **Automatic Sync**: Offline changes queue locally and sync automatically once back online
|
||||||
|
|
||||||
|
### Notifications & Reminders
|
||||||
|
- **Daily Digest**: Morning email summarizing tasks due today and overdue tasks
|
||||||
|
- **Per-Task Reminders**: Automatic "before due", "due now", and "overdue" notifications, timed per task
|
||||||
|
- **Multi-Channel Delivery**: Email, push, or both, per user
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Django 5.x, Django REST Framework
|
- **Backend**: Django 5.x, Django REST Framework
|
||||||
@@ -119,6 +129,15 @@ Set via environment variable:
|
|||||||
export DJANGO_SETTINGS_MODULE=config.settings.development
|
export DJANGO_SETTINGS_MODULE=config.settings.development
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Registration Control
|
||||||
|
|
||||||
|
Self-registration is controlled with `ALLOW_SELF_REGISTRATION`.
|
||||||
|
|
||||||
|
- `ALLOW_SELF_REGISTRATION=False` disables public signup and requires an administrator to create accounts
|
||||||
|
- `ALLOW_SELF_REGISTRATION=True` enables the `/register/` page and `/api/users/register/` endpoint
|
||||||
|
|
||||||
|
If the variable is omitted, self-registration is disabled by default.
|
||||||
|
|
||||||
### Database Configuration
|
### Database Configuration
|
||||||
|
|
||||||
The application supports multiple database backends. Choose the one that fits your needs.
|
The application supports multiple database backends. Choose the one that fits your needs.
|
||||||
@@ -821,7 +840,9 @@ sudo systemctl start keepitgoing
|
|||||||
sudo systemctl status keepitgoing
|
sudo systemctl status keepitgoing
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Step 6: Celery Service (Optional, for background tasks)
|
#### Step 6: Celery Services (Worker + Beat)
|
||||||
|
|
||||||
|
Both services are required - the worker executes tasks, Beat is the scheduler that queues the daily digest, recurring task safety net, and per-task reminders on their configured schedules (see `config/celery.py`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nano /etc/systemd/system/keepitgoing-celery.service
|
sudo nano /etc/systemd/system/keepitgoing-celery.service
|
||||||
@@ -852,6 +873,37 @@ sudo systemctl enable keepitgoing-celery
|
|||||||
sudo systemctl start keepitgoing-celery
|
sudo systemctl start keepitgoing-celery
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Beat runs as its own separate service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/keepitgoing-celerybeat.service
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=KeepItGoing Celery Beat
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=keepitgoing
|
||||||
|
Group=keepitgoing
|
||||||
|
WorkingDirectory=/home/keepitgoing/KeepItGoingServer
|
||||||
|
Environment="PATH=/home/keepitgoing/KeepItGoingServer/venv/bin"
|
||||||
|
Environment="DJANGO_SETTINGS_MODULE=config.settings.production"
|
||||||
|
EnvironmentFile=/home/keepitgoing/KeepItGoingServer/.env
|
||||||
|
ExecStart=/home/keepitgoing/KeepItGoingServer/venv/bin/celery -A config beat -l info
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable keepitgoing-celerybeat
|
||||||
|
sudo systemctl start keepitgoing-celerybeat
|
||||||
|
```
|
||||||
|
|
||||||
#### Step 7: Nginx Configuration
|
#### Step 7: Nginx Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -955,11 +1007,15 @@ sudo ufw status
|
|||||||
# Log in to the web interface at https://tasks.firebugit.com/admin
|
# Log in to the web interface at https://tasks.firebugit.com/admin
|
||||||
# Use the superuser credentials created in Step 3
|
# Use the superuser credentials created in Step 3
|
||||||
|
|
||||||
# When new users register:
|
# If ALLOW_SELF_REGISTRATION=True and new users register:
|
||||||
# 1. They receive verification email
|
# 1. They receive verification email
|
||||||
# 2. After clicking verification link, you receive admin notification
|
# 2. After clicking verification link, you receive admin notification
|
||||||
# 3. Go to Admin > Users > select user(s) > Actions > "Approve selected users"
|
# 3. Go to Admin > Users > select user(s) > Actions > "Approve selected users"
|
||||||
# 4. User receives approval email and can now log in
|
# 4. User receives approval email and can now log in
|
||||||
|
#
|
||||||
|
# If ALLOW_SELF_REGISTRATION=False:
|
||||||
|
# 1. Create users through Django admin or management commands
|
||||||
|
# 2. Mark them verified/approved as needed for your onboarding flow
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production Checklist
|
### Production Checklist
|
||||||
@@ -994,7 +1050,8 @@ Complete this checklist before going live:
|
|||||||
- [ ] Run `python manage.py migrate` to apply all migrations
|
- [ ] Run `python manage.py migrate` to apply all migrations
|
||||||
- [ ] Run `python manage.py collectstatic` for static files
|
- [ ] Run `python manage.py collectstatic` for static files
|
||||||
- [ ] Create superuser account for admin access
|
- [ ] Create superuser account for admin access
|
||||||
- [ ] Test user registration and email verification flow
|
- [ ] Confirm `ALLOW_SELF_REGISTRATION` is set correctly for production
|
||||||
|
- [ ] Test account onboarding flow (admin-created users or public registration)
|
||||||
- [ ] Test admin approval workflow
|
- [ ] Test admin approval workflow
|
||||||
- [ ] Configure `SITE_DOMAIN` to your production domain
|
- [ ] Configure `SITE_DOMAIN` to your production domain
|
||||||
- [ ] Set appropriate `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS`
|
- [ ] Set appropriate `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS`
|
||||||
@@ -1002,8 +1059,8 @@ Complete this checklist before going live:
|
|||||||
**Services:**
|
**Services:**
|
||||||
- [ ] Set up Gunicorn systemd service
|
- [ ] Set up Gunicorn systemd service
|
||||||
- [ ] Configure nginx as reverse proxy
|
- [ ] Configure nginx as reverse proxy
|
||||||
- [ ] Set up Redis for Celery (if using background tasks)
|
- [ ] Set up Redis for Celery (required for the daily digest, recurring tasks, and reminders)
|
||||||
- [ ] Configure Celery systemd service (if needed)
|
- [ ] Configure both the Celery worker and Celery Beat systemd services - Beat is what fires scheduled tasks, the worker alone won't
|
||||||
- [ ] Verify all services start on boot
|
- [ ] Verify all services start on boot
|
||||||
|
|
||||||
**Monitoring & Logging:**
|
**Monitoring & Logging:**
|
||||||
|
|||||||
+8
-8
@@ -22,17 +22,17 @@ app.autodiscover_tasks()
|
|||||||
|
|
||||||
# Celery Beat schedule for periodic tasks
|
# Celery Beat schedule for periodic tasks
|
||||||
app.conf.beat_schedule = {
|
app.conf.beat_schedule = {
|
||||||
'send-due-reminders': {
|
'send-daily-task-email': {
|
||||||
'task': 'notifications.tasks.send_due_reminders',
|
'task': 'notifications.tasks.send_daily_task_email',
|
||||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
'schedule': crontab(minute=0), # Every hour on the hour
|
||||||
},
|
|
||||||
'check-overdue-tasks': {
|
|
||||||
'task': 'notifications.tasks.check_overdue_tasks',
|
|
||||||
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
|
||||||
},
|
},
|
||||||
'process-recurring-tasks': {
|
'process-recurring-tasks': {
|
||||||
'task': 'notifications.tasks.process_recurring_tasks',
|
'task': 'notifications.tasks.process_recurring_tasks',
|
||||||
'schedule': crontab(minute=0), # Every hour
|
'schedule': crontab(hour=0, minute=0), # Daily at midnight
|
||||||
|
},
|
||||||
|
'send-scheduled-reminders': {
|
||||||
|
'task': 'notifications.tasks.send_scheduled_reminders',
|
||||||
|
'schedule': crontab(minute='*/5'), # Every 5 minutes - these are time-sensitive
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ CELERY_RESULT_SERIALIZER = 'json'
|
|||||||
SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true'
|
SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true'
|
||||||
BILLING_ENABLED = SAAS_MODE
|
BILLING_ENABLED = SAAS_MODE
|
||||||
USAGE_LIMITS_ENABLED = SAAS_MODE
|
USAGE_LIMITS_ENABLED = SAAS_MODE
|
||||||
|
ALLOW_SELF_REGISTRATION = os.environ.get('ALLOW_SELF_REGISTRATION', 'False').lower() == 'true'
|
||||||
|
|
||||||
# Login settings
|
# Login settings
|
||||||
LOGIN_URL = '/login/'
|
LOGIN_URL = '/login/'
|
||||||
@@ -174,3 +175,8 @@ EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = int(
|
|||||||
os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
|
os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
|
||||||
)
|
)
|
||||||
SITE_DOMAIN = os.environ.get('SITE_DOMAIN', 'localhost:8000')
|
SITE_DOMAIN = os.environ.get('SITE_DOMAIN', 'localhost:8000')
|
||||||
|
|
||||||
|
# Web Push (VAPID)
|
||||||
|
VAPID_PUBLIC_KEY = os.environ.get('VAPID_PUBLIC_KEY', '')
|
||||||
|
VAPID_PRIVATE_KEY = os.environ.get('VAPID_PRIVATE_KEY', '')
|
||||||
|
VAPID_ADMIN_EMAIL = os.environ.get('VAPID_ADMIN_EMAIL', '')
|
||||||
|
|||||||
+8
-1
@@ -6,18 +6,25 @@ from django.contrib import admin
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as users_web_urls
|
from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as users_web_urls
|
||||||
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
|
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
|
||||||
from sync.urls import api_urlpatterns as sync_api_urls
|
from sync.urls import api_urlpatterns as sync_api_urls
|
||||||
from notifications.urls import api_urlpatterns as notifications_api_urls
|
from notifications.urls import api_urlpatterns as notifications_api_urls
|
||||||
from tasks.views_debug import debug_user_agent
|
from tasks.views_debug import debug_user_agent
|
||||||
from .views import api_root
|
from .views import api_root, manifest_webmanifest, service_worker
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Admin
|
# Admin
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
|
# PWA
|
||||||
|
path('manifest.webmanifest', manifest_webmanifest, name='manifest'),
|
||||||
|
path('sw.js', service_worker, name='service-worker'),
|
||||||
|
path('offline/', TemplateView.as_view(template_name='offline.html'), name='offline'),
|
||||||
|
path('offline/tasks/', TemplateView.as_view(template_name='offline_tasks.html'), name='offline-tasks'),
|
||||||
|
|
||||||
# Debug endpoint (remove in production)
|
# Debug endpoint (remove in production)
|
||||||
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
|
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ API root views for KeepItGoing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_webmanifest(request):
|
||||||
|
"""Serve the PWA web app manifest, rendered so icon URLs pick up static hashing."""
|
||||||
|
return render(request, 'manifest.webmanifest', content_type='application/manifest+json')
|
||||||
|
|
||||||
|
|
||||||
|
def service_worker(request):
|
||||||
|
"""
|
||||||
|
Serve the service worker script from the site root so its default scope
|
||||||
|
covers the whole app (a service worker can only control paths at or
|
||||||
|
below where it's served from).
|
||||||
|
"""
|
||||||
|
response = render(request, 'sw.js', content_type='application/javascript')
|
||||||
|
response['Cache-Control'] = 'no-cache'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def api_root(request):
|
def api_root(request):
|
||||||
|
|||||||
+39
-3
@@ -46,7 +46,7 @@ services:
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: keepitgoing-web
|
container_name: keepitgoing-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -74,6 +74,10 @@ services:
|
|||||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
|
||||||
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
|
||||||
|
|
||||||
# Gunicorn
|
# Gunicorn
|
||||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
|
||||||
@@ -103,7 +107,7 @@ services:
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
celery-worker:
|
celery-worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: keepitgoing-celery-worker
|
container_name: keepitgoing-celery-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -112,6 +116,16 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
|
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_HOST: ${EMAIL_HOST:-}
|
||||||
|
EMAIL_PORT: ${EMAIL_PORT:-587}
|
||||||
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||||
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
|
||||||
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -120,13 +134,19 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: celery-worker
|
command: celery-worker
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*worker' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Celery Beat Service - Scheduled task scheduler
|
# Celery Beat Service - Scheduled task scheduler
|
||||||
# =================================================================
|
# =================================================================
|
||||||
celery-beat:
|
celery-beat:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: keepitgoing-celery-beat
|
container_name: keepitgoing-celery-beat
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -135,6 +155,16 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
|
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
EMAIL_HOST: ${EMAIL_HOST:-}
|
||||||
|
EMAIL_PORT: ${EMAIL_PORT:-587}
|
||||||
|
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
|
||||||
|
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
|
||||||
|
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
|
||||||
|
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -143,6 +173,12 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: celery-beat
|
command: celery-beat
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*beat' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Named Volumes
|
# Named Volumes
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ class NotificationAdmin(admin.ModelAdmin):
|
|||||||
class ScheduledReminderAdmin(admin.ModelAdmin):
|
class ScheduledReminderAdmin(admin.ModelAdmin):
|
||||||
"""Admin for ScheduledReminder."""
|
"""Admin for ScheduledReminder."""
|
||||||
|
|
||||||
list_display = ['task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
list_display = ['task', 'reminder_type', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||||
list_filter = ['is_sent', 'remind_at']
|
list_filter = ['reminder_type', 'is_sent', 'remind_at']
|
||||||
search_fields = ['task__title']
|
search_fields = ['task__title']
|
||||||
ordering = ['remind_at']
|
ordering = ['remind_at']
|
||||||
raw_id_fields = ['task']
|
raw_id_fields = ['task']
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-09-05 15:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('notifications', '0003_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scheduledreminder',
|
||||||
|
name='reminder_type',
|
||||||
|
field=models.CharField(choices=[('reminder', 'Before Due'), ('due_soon', 'Due Now'), ('overdue', 'Overdue')], default='reminder', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='notification',
|
||||||
|
name='notification_type',
|
||||||
|
field=models.CharField(choices=[('reminder', 'Task Reminder'), ('due_soon', 'Due Soon'), ('overdue', 'Overdue'), ('shared', 'Task Shared'), ('comment', 'Comment'), ('daily_email', 'Daily Email')], max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
+12
-2
@@ -17,6 +17,7 @@ class Notification(models.Model):
|
|||||||
('overdue', 'Overdue'),
|
('overdue', 'Overdue'),
|
||||||
('shared', 'Task Shared'),
|
('shared', 'Task Shared'),
|
||||||
('comment', 'Comment'),
|
('comment', 'Comment'),
|
||||||
|
('daily_email', 'Daily Email'),
|
||||||
]
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
@@ -49,14 +50,23 @@ class Notification(models.Model):
|
|||||||
|
|
||||||
class ScheduledReminder(models.Model):
|
class ScheduledReminder(models.Model):
|
||||||
"""
|
"""
|
||||||
Tracks scheduled reminders for tasks.
|
Tracks scheduled reminders for tasks. Rows are (re)created by
|
||||||
|
Task.reschedule_reminders() whenever a task's due info changes, and
|
||||||
|
consumed by notifications.tasks.send_scheduled_reminders().
|
||||||
"""
|
"""
|
||||||
|
REMINDER_TYPES = [
|
||||||
|
('reminder', 'Before Due'),
|
||||||
|
('due_soon', 'Due Now'),
|
||||||
|
('overdue', 'Overdue'),
|
||||||
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
task = models.ForeignKey(
|
task = models.ForeignKey(
|
||||||
'tasks.Task',
|
'tasks.Task',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='scheduled_reminders'
|
related_name='scheduled_reminders'
|
||||||
)
|
)
|
||||||
|
reminder_type = models.CharField(max_length=20, choices=REMINDER_TYPES, default='reminder')
|
||||||
remind_at = models.DateTimeField()
|
remind_at = models.DateTimeField()
|
||||||
is_sent = models.BooleanField(default=False)
|
is_sent = models.BooleanField(default=False)
|
||||||
sent_at = models.DateTimeField(null=True, blank=True)
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
@@ -67,4 +77,4 @@ class ScheduledReminder(models.Model):
|
|||||||
ordering = ['remind_at']
|
ordering = ['remind_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Reminder for {self.task.title} at {self.remind_at}"
|
return f"{self.get_reminder_type_display()} for {self.task.title} at {self.remind_at}"
|
||||||
|
|||||||
@@ -25,5 +25,5 @@ class ScheduledReminderSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ScheduledReminder
|
model = ScheduledReminder
|
||||||
fields = ['id', 'task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
fields = ['id', 'task', 'reminder_type', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||||
read_only_fields = ['id', 'is_sent', 'sent_at', 'created_at']
|
read_only_fields = ['id', 'is_sent', 'sent_at', 'created_at']
|
||||||
|
|||||||
+219
-202
@@ -2,255 +2,273 @@
|
|||||||
Celery tasks for notifications.
|
Celery tasks for notifications.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Q
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
def notify_user(user, title, body, notification_type, task=None, url='/'):
|
||||||
def send_due_reminders():
|
|
||||||
"""
|
"""
|
||||||
Check for tasks due soon and send reminders.
|
Send a notification to a user via whichever of email/push they have
|
||||||
Runs every 5 minutes via Celery Beat.
|
enabled, and record it in the Notification log. Returns True if it went
|
||||||
|
out on at least one channel.
|
||||||
"""
|
"""
|
||||||
from tasks.models import Task
|
|
||||||
from users.models import DeviceToken
|
|
||||||
from .models import Notification, ScheduledReminder
|
|
||||||
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
# Find scheduled reminders that need to be sent
|
|
||||||
reminders = ScheduledReminder.objects.filter(
|
|
||||||
remind_at__lte=now,
|
|
||||||
is_sent=False,
|
|
||||||
task__status__in=['pending', 'in_progress']
|
|
||||||
).select_related('task', 'task__user')
|
|
||||||
|
|
||||||
for reminder in reminders:
|
|
||||||
task = reminder.task
|
|
||||||
user = task.user
|
|
||||||
|
|
||||||
# Create notification
|
|
||||||
Notification.objects.create(
|
|
||||||
user=user,
|
|
||||||
notification_type='reminder',
|
|
||||||
title=f'Reminder: {task.title}',
|
|
||||||
message=f'Task "{task.title}" is due soon.',
|
|
||||||
task=task,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send push notifications
|
|
||||||
send_push_to_user.delay(
|
|
||||||
user_id=str(user.id),
|
|
||||||
title=f'Reminder: {task.title}',
|
|
||||||
body=f'Task is due soon.',
|
|
||||||
data={'task_id': str(task.id)}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mark as sent
|
|
||||||
reminder.is_sent = True
|
|
||||||
reminder.sent_at = now
|
|
||||||
reminder.save()
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def check_overdue_tasks():
|
|
||||||
"""
|
|
||||||
Check for overdue tasks and notify users.
|
|
||||||
Runs daily.
|
|
||||||
"""
|
|
||||||
from tasks.models import Task
|
|
||||||
from .models import Notification
|
from .models import Notification
|
||||||
|
|
||||||
today = timezone.now().date()
|
notified = False
|
||||||
|
|
||||||
# Find overdue tasks that haven't been notified today
|
if user.email_notifications and user.email_verified:
|
||||||
overdue_tasks = Task.objects.filter(
|
try:
|
||||||
due_date__lt=today,
|
send_mail(
|
||||||
status__in=['pending', 'in_progress']
|
subject=title,
|
||||||
).select_related('user')
|
message=body,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
notified = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to email {user.email}: {e}")
|
||||||
|
|
||||||
for task in overdue_tasks:
|
if user.push_notifications:
|
||||||
# Check if we already sent an overdue notification today
|
send_web_push_to_user(user, title=title, body=body, url=url)
|
||||||
existing = Notification.objects.filter(
|
notified = True
|
||||||
user=task.user,
|
|
||||||
|
if notified:
|
||||||
|
Notification.objects.create(
|
||||||
|
user=user,
|
||||||
|
notification_type=notification_type,
|
||||||
|
title=title,
|
||||||
|
message=body,
|
||||||
task=task,
|
task=task,
|
||||||
notification_type='overdue',
|
)
|
||||||
created_at__date=today
|
|
||||||
).exists()
|
|
||||||
|
|
||||||
if not existing:
|
return notified
|
||||||
Notification.objects.create(
|
|
||||||
user=task.user,
|
|
||||||
notification_type='overdue',
|
|
||||||
title=f'Overdue: {task.title}',
|
|
||||||
message=f'Task "{task.title}" is overdue.',
|
|
||||||
task=task,
|
|
||||||
)
|
|
||||||
|
|
||||||
send_push_to_user.delay(
|
|
||||||
user_id=str(task.user.id),
|
def send_web_push_to_user(user, title, body, url='/'):
|
||||||
title=f'Overdue: {task.title}',
|
"""Send a Web Push notification to all of a user's registered web devices."""
|
||||||
body='This task is overdue.',
|
if not settings.VAPID_PRIVATE_KEY:
|
||||||
data={'task_id': str(task.id)}
|
return
|
||||||
|
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
from users.models import DeviceToken
|
||||||
|
|
||||||
|
devices = DeviceToken.objects.filter(user=user, platform='web', is_active=True)
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
try:
|
||||||
|
subscription_info = json.loads(device.token)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=json.dumps({'title': title, 'body': body, 'url': url}),
|
||||||
|
vapid_private_key=settings.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims={'sub': f'mailto:{settings.VAPID_ADMIN_EMAIL}'},
|
||||||
)
|
)
|
||||||
|
except WebPushException as e:
|
||||||
|
status_code = e.response.status_code if e.response is not None else None
|
||||||
|
if status_code in (404, 410):
|
||||||
|
device.is_active = False
|
||||||
|
device.save(update_fields=['is_active'])
|
||||||
|
logger.warning(f"Web push failed for {user.email}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_push_to_user(user_id, title, body, data=None):
|
def send_daily_task_email():
|
||||||
"""
|
"""
|
||||||
Send push notification to all user devices.
|
Send daily email to users with their tasks due today.
|
||||||
|
Runs once per day and sends to users in their local morning time.
|
||||||
"""
|
"""
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from users.models import DeviceToken
|
from tasks.models import Task
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
try:
|
# Get all users who have email and/or push notifications enabled
|
||||||
user = User.objects.get(id=user_id)
|
users = User.objects.filter(
|
||||||
except User.DoesNotExist:
|
Q(email_notifications=True) | Q(push_notifications=True),
|
||||||
return
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
if not user.push_notifications:
|
notifications_sent = 0
|
||||||
return
|
current_utc_hour = timezone.now().hour
|
||||||
|
|
||||||
tokens = DeviceToken.objects.filter(user=user, is_active=True)
|
for user in users:
|
||||||
|
# Convert current UTC time to user's local time
|
||||||
|
try:
|
||||||
|
user_tz = ZoneInfo(user.timezone)
|
||||||
|
except Exception:
|
||||||
|
user_tz = ZoneInfo('UTC')
|
||||||
|
|
||||||
for token in tokens:
|
user_local_time = timezone.now().astimezone(user_tz)
|
||||||
if token.platform == 'android':
|
user_local_hour = user_local_time.hour
|
||||||
send_fcm_notification.delay(token.token, title, body, data)
|
|
||||||
elif token.platform == 'web':
|
# Only send if it's between 6-7 AM in the user's timezone
|
||||||
send_web_push_notification.delay(token.token, title, body, data)
|
# (gives 1 hour window, task runs every hour)
|
||||||
elif token.platform == 'desktop':
|
if not (6 <= user_local_hour < 7):
|
||||||
# Desktop notifications are handled via WebSocket
|
continue
|
||||||
pass
|
|
||||||
|
# Get today's date in user's timezone
|
||||||
|
today = user_local_time.date()
|
||||||
|
|
||||||
|
# Check if we already sent an email today
|
||||||
|
from .models import Notification
|
||||||
|
already_sent_today = Notification.objects.filter(
|
||||||
|
user=user,
|
||||||
|
notification_type='daily_email',
|
||||||
|
created_at__date=today
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if already_sent_today:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get tasks due today
|
||||||
|
tasks_due_today = Task.objects.filter(
|
||||||
|
user=user,
|
||||||
|
due_date=today,
|
||||||
|
status__in=['pending', 'in_progress']
|
||||||
|
).order_by('due_time', 'priority', 'title')
|
||||||
|
|
||||||
|
# Get overdue tasks
|
||||||
|
overdue_tasks = Task.objects.filter(
|
||||||
|
user=user,
|
||||||
|
due_date__lt=today,
|
||||||
|
status__in=['pending', 'in_progress']
|
||||||
|
).order_by('due_date', 'due_time', 'priority', 'title')
|
||||||
|
|
||||||
|
# Only send email if there are tasks
|
||||||
|
if not tasks_due_today.exists() and not overdue_tasks.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prepare email content
|
||||||
|
subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}"
|
||||||
|
|
||||||
|
# Create plain text message
|
||||||
|
message_lines = [
|
||||||
|
f"Good morning! Here are your tasks for today:\n"
|
||||||
|
]
|
||||||
|
|
||||||
|
if overdue_tasks.exists():
|
||||||
|
message_lines.append(f"\n⚠️ OVERDUE TASKS ({overdue_tasks.count()}):")
|
||||||
|
for task in overdue_tasks[:10]: # Limit to 10
|
||||||
|
due_str = task.due_date.strftime('%b %d')
|
||||||
|
message_lines.append(f" • {task.title} (due {due_str})")
|
||||||
|
if overdue_tasks.count() > 10:
|
||||||
|
message_lines.append(f" ... and {overdue_tasks.count() - 10} more")
|
||||||
|
|
||||||
|
if tasks_due_today.exists():
|
||||||
|
message_lines.append(f"\n📅 DUE TODAY ({tasks_due_today.count()}):")
|
||||||
|
for task in tasks_due_today:
|
||||||
|
time_str = task.due_time.strftime('%I:%M %p') if task.due_time else ''
|
||||||
|
priority_icon = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}.get(task.priority, '')
|
||||||
|
message_lines.append(f" • {priority_icon} {task.title} {time_str}".strip())
|
||||||
|
|
||||||
|
message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}")
|
||||||
|
message_lines.append("\nYou can change your email preferences in your profile settings.")
|
||||||
|
|
||||||
|
message = '\n'.join(message_lines)
|
||||||
|
|
||||||
|
notified = False
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
if user.email_notifications and user.email_verified:
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
notified = True
|
||||||
|
logger.info(f"Sent daily task email to {user.email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send daily email to {user.email}: {e}")
|
||||||
|
|
||||||
|
# Send push
|
||||||
|
if user.push_notifications:
|
||||||
|
send_web_push_to_user(
|
||||||
|
user,
|
||||||
|
title=subject,
|
||||||
|
body=f"{tasks_due_today.count()} due today, {overdue_tasks.count()} overdue",
|
||||||
|
url='/',
|
||||||
|
)
|
||||||
|
notified = True
|
||||||
|
|
||||||
|
if notified:
|
||||||
|
# Record that we notified the user today (prevents duplicates)
|
||||||
|
Notification.objects.create(
|
||||||
|
user=user,
|
||||||
|
notification_type='daily_email',
|
||||||
|
title=subject,
|
||||||
|
message=f"Daily notification sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
|
||||||
|
)
|
||||||
|
notifications_sent += 1
|
||||||
|
|
||||||
|
logger.info(f"Daily task notification job completed. Notified {notifications_sent} users.")
|
||||||
|
return notifications_sent
|
||||||
|
|
||||||
|
|
||||||
|
REMINDER_MESSAGES = {
|
||||||
|
'reminder': lambda task: (f'Upcoming: {task.title}', f'"{task.title}" is due soon.'),
|
||||||
|
'due_soon': lambda task: (f'Due now: {task.title}', f'"{task.title}" is due now.'),
|
||||||
|
'overdue': lambda task: (f'Overdue: {task.title}', f'"{task.title}" is overdue.'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_fcm_notification(token, title, body, data=None):
|
def send_scheduled_reminders():
|
||||||
"""Send FCM notification to Android device."""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Only send if FCM is configured
|
|
||||||
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
import firebase_admin
|
|
||||||
from firebase_admin import credentials, messaging
|
|
||||||
|
|
||||||
# Initialize Firebase if not already done
|
|
||||||
if not firebase_admin._apps:
|
|
||||||
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
|
|
||||||
firebase_admin.initialize_app(cred)
|
|
||||||
|
|
||||||
message = messaging.Message(
|
|
||||||
notification=messaging.Notification(
|
|
||||||
title=title,
|
|
||||||
body=body,
|
|
||||||
),
|
|
||||||
data=data or {},
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
|
|
||||||
messaging.send(message)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Log error but don't fail
|
|
||||||
logger.error(f"FCM notification failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_web_push_notification(subscription_info, title, body, data=None):
|
|
||||||
"""Send Web Push notification."""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
|
||||||
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
|
|
||||||
|
|
||||||
if not vapid_private_key or not vapid_email:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
from pywebpush import webpush, WebPushException
|
|
||||||
import json
|
|
||||||
|
|
||||||
webpush(
|
|
||||||
subscription_info=json.loads(subscription_info),
|
|
||||||
data=json.dumps({
|
|
||||||
'title': title,
|
|
||||||
'body': body,
|
|
||||||
'data': data or {}
|
|
||||||
}),
|
|
||||||
vapid_private_key=vapid_private_key,
|
|
||||||
vapid_claims={'sub': f'mailto:{vapid_email}'}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Web push notification failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def schedule_task_reminder(task_id):
|
|
||||||
"""
|
"""
|
||||||
Schedule a reminder for a task based on its due date and user preferences.
|
Send "before due", "due now", and "overdue" notifications from
|
||||||
Called when a task is created or updated.
|
ScheduledReminder rows created by Task.reschedule_reminders(). Runs
|
||||||
|
frequently (every few minutes) since these are time-sensitive, unlike
|
||||||
|
the once-a-day digest above.
|
||||||
"""
|
"""
|
||||||
from tasks.models import Task
|
|
||||||
from .models import ScheduledReminder
|
from .models import ScheduledReminder
|
||||||
|
|
||||||
try:
|
due_reminders = ScheduledReminder.objects.filter(
|
||||||
task = Task.objects.get(id=task_id)
|
is_sent=False, remind_at__lte=timezone.now()
|
||||||
except Task.DoesNotExist:
|
).select_related('task', 'task__user')
|
||||||
return
|
|
||||||
|
|
||||||
# Remove existing scheduled reminders for this task
|
sent_count = 0
|
||||||
ScheduledReminder.objects.filter(task=task, is_sent=False).delete()
|
for reminder in due_reminders:
|
||||||
|
task = reminder.task
|
||||||
|
if not task.is_deleted and task.status not in ('completed', 'cancelled'):
|
||||||
|
title, body = REMINDER_MESSAGES[reminder.reminder_type](task)
|
||||||
|
if notify_user(task.user, title, body, reminder.reminder_type, task=task, url=f'/tasks/{task.id}/'):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
# Only schedule if task has a reminder time or due date
|
reminder.is_sent = True
|
||||||
if task.reminder_at:
|
reminder.sent_at = timezone.now()
|
||||||
ScheduledReminder.objects.create(
|
reminder.save(update_fields=['is_sent', 'sent_at'])
|
||||||
task=task,
|
|
||||||
remind_at=task.reminder_at
|
|
||||||
)
|
|
||||||
elif task.due_date:
|
|
||||||
# Default reminder based on user preference
|
|
||||||
reminder_minutes = task.user.default_reminder_minutes
|
|
||||||
if task.due_time:
|
|
||||||
from datetime import datetime
|
|
||||||
due_datetime = datetime.combine(task.due_date, task.due_time)
|
|
||||||
due_datetime = timezone.make_aware(due_datetime)
|
|
||||||
else:
|
|
||||||
# Default to 9 AM on due date
|
|
||||||
due_datetime = timezone.make_aware(
|
|
||||||
timezone.datetime.combine(task.due_date, timezone.datetime.min.time())
|
|
||||||
).replace(hour=9)
|
|
||||||
|
|
||||||
remind_at = due_datetime - timedelta(minutes=reminder_minutes)
|
return sent_count
|
||||||
|
|
||||||
if remind_at > timezone.now():
|
|
||||||
ScheduledReminder.objects.create(
|
|
||||||
task=task,
|
|
||||||
remind_at=remind_at
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def process_recurring_tasks():
|
def process_recurring_tasks():
|
||||||
"""
|
"""
|
||||||
Process completed recurring tasks and create next instances.
|
Process completed recurring tasks and create next instances.
|
||||||
This runs periodically as a safety net to catch any tasks that weren't
|
This runs daily as a safety net to catch any tasks that weren't
|
||||||
automatically handled when marked as completed.
|
automatically handled when marked as completed.
|
||||||
Runs every hour via Celery Beat.
|
|
||||||
"""
|
"""
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
# Find completed recurring tasks that don't have a next instance created yet
|
# Find completed recurring tasks from the last 48 hours
|
||||||
# We look for tasks completed in the last 24 hours to avoid reprocessing old tasks
|
yesterday = timezone.now() - timedelta(days=2)
|
||||||
yesterday = timezone.now() - timedelta(days=1)
|
|
||||||
|
|
||||||
completed_recurring_tasks = Task.objects.filter(
|
completed_recurring_tasks = Task.objects.filter(
|
||||||
status='completed',
|
status='completed',
|
||||||
@@ -261,16 +279,15 @@ def process_recurring_tasks():
|
|||||||
tasks_created = 0
|
tasks_created = 0
|
||||||
for task in completed_recurring_tasks:
|
for task in completed_recurring_tasks:
|
||||||
# Check if a next recurrence already exists
|
# Check if a next recurrence already exists
|
||||||
# Look for pending tasks with the same title, user, and recurrence pattern
|
|
||||||
next_due_date = task.calculate_next_due_date()
|
next_due_date = task.calculate_next_due_date()
|
||||||
if next_due_date:
|
if next_due_date:
|
||||||
# Check if we already created this recurrence
|
# Check if we already created this recurrence
|
||||||
|
# Check for any task (pending OR completed) to avoid duplicates
|
||||||
existing = Task.objects.filter(
|
existing = Task.objects.filter(
|
||||||
user=task.user,
|
user=task.user,
|
||||||
title=task.title,
|
title=task.title,
|
||||||
due_date=next_due_date,
|
due_date=next_due_date,
|
||||||
recurrence=task.recurrence,
|
recurrence=task.recurrence
|
||||||
status='pending'
|
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
+284
-2
@@ -1,3 +1,285 @@
|
|||||||
from django.test import TestCase
|
from datetime import date, datetime, time as dt_time, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core import mail
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone as django_timezone
|
||||||
|
|
||||||
|
from notifications.models import ScheduledReminder
|
||||||
|
from notifications.tasks import send_scheduled_reminders
|
||||||
|
from tasks.models import Task
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RescheduleRemindersTests(TestCase):
|
||||||
|
"""Tests for Task.reschedule_reminders(), triggered from Task.save()."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='reminderuser',
|
||||||
|
email='reminderuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
default_reminder_minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reminder_types(self, task):
|
||||||
|
return set(
|
||||||
|
ScheduledReminder.objects.filter(task=task, is_sent=False).values_list('reminder_type', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_due_time_task_gets_all_three_reminders(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
self.assertEqual(self.reminder_types(task), {'reminder', 'due_soon', 'overdue'})
|
||||||
|
|
||||||
|
before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon')
|
||||||
|
overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue')
|
||||||
|
|
||||||
|
expected_due = django_timezone.make_aware(datetime(2026, 1, 15, 17, 0, 0))
|
||||||
|
self.assertEqual(due_soon.remind_at, expected_due)
|
||||||
|
self.assertEqual(overdue.remind_at, expected_due + timedelta(hours=1))
|
||||||
|
self.assertEqual(before_due.remind_at, expected_due - timedelta(minutes=30))
|
||||||
|
|
||||||
|
def test_date_only_task_only_gets_overdue_reminder_next_day(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Task', due_date=date(2026, 1, 15))
|
||||||
|
self.assertEqual(self.reminder_types(task), {'overdue'})
|
||||||
|
overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue')
|
||||||
|
self.assertEqual(overdue.remind_at, django_timezone.make_aware(datetime(2026, 1, 16, 0, 0, 0)))
|
||||||
|
|
||||||
|
def test_no_reminder_minutes_skips_before_due_reminder(self):
|
||||||
|
self.user.default_reminder_minutes = 0
|
||||||
|
self.user.save()
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
self.assertEqual(self.reminder_types(task), {'due_soon', 'overdue'})
|
||||||
|
|
||||||
|
def test_explicit_reminder_at_overrides_default_minutes(self):
|
||||||
|
custom_reminder = django_timezone.make_aware(datetime(2026, 1, 15, 9, 0, 0))
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
reminder_at=custom_reminder,
|
||||||
|
)
|
||||||
|
before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
self.assertEqual(before_due.remind_at, custom_reminder)
|
||||||
|
|
||||||
|
def test_no_due_date_gets_no_reminders(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Task')
|
||||||
|
self.assertEqual(self.reminder_types(task), set())
|
||||||
|
|
||||||
|
def test_completing_task_clears_pending_reminders(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
self.assertTrue(ScheduledReminder.objects.filter(task=task, is_sent=False).exists())
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save()
|
||||||
|
self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists())
|
||||||
|
|
||||||
|
def test_deleting_task_clears_pending_reminders(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
task.is_deleted = True
|
||||||
|
task.save()
|
||||||
|
self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists())
|
||||||
|
|
||||||
|
def test_changing_due_date_replaces_reminders(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
task.due_date = date(2026, 2, 1)
|
||||||
|
task.save()
|
||||||
|
self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3)
|
||||||
|
due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon')
|
||||||
|
self.assertEqual(due_soon.remind_at, django_timezone.make_aware(datetime(2026, 2, 1, 17, 0, 0)))
|
||||||
|
|
||||||
|
def test_unrelated_field_save_does_not_duplicate_reminders(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
task.title = 'Renamed task'
|
||||||
|
task.save()
|
||||||
|
self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultReminderMinutesChangeTests(TestCase):
|
||||||
|
"""
|
||||||
|
Task.reschedule_reminders() only captures user.default_reminder_minutes
|
||||||
|
at the moment a task's own due_date/due_time is set - changing the
|
||||||
|
profile setting afterward doesn't reach existing tasks on its own.
|
||||||
|
User.reschedule_reminder_notifications(), triggered from User.save()
|
||||||
|
when the setting changes, is what makes that retroactive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='reminderuser2', email='reminderuser2@example.com', password='testpass123',
|
||||||
|
default_reminder_minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_changing_default_minutes_reschedules_before_due_reminder(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
due_moment = django_timezone.make_aware(datetime(2026, 1, 15, 17, 0, 0))
|
||||||
|
before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
self.assertEqual(before_due.remind_at, due_moment - timedelta(minutes=30))
|
||||||
|
|
||||||
|
self.user.default_reminder_minutes = 15
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
self.assertEqual(before_due.remind_at, due_moment - timedelta(minutes=15))
|
||||||
|
|
||||||
|
def test_task_with_explicit_reminder_at_is_left_alone(self):
|
||||||
|
custom_reminder = django_timezone.make_aware(datetime(2026, 1, 15, 9, 0, 0))
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
reminder_at=custom_reminder,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.user.default_reminder_minutes = 15
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
self.assertEqual(before_due.remind_at, custom_reminder)
|
||||||
|
|
||||||
|
def test_completed_task_is_not_touched(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), status='completed',
|
||||||
|
)
|
||||||
|
self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists())
|
||||||
|
|
||||||
|
self.user.default_reminder_minutes = 15
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists())
|
||||||
|
|
||||||
|
def test_unrelated_profile_field_save_does_not_reschedule(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
original_id = ScheduledReminder.objects.get(task=task, reminder_type='reminder').id
|
||||||
|
|
||||||
|
self.user.first_name = 'Changed'
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
# Same row (not deleted and recreated by reschedule_reminders()).
|
||||||
|
self.assertEqual(ScheduledReminder.objects.get(task=task, reminder_type='reminder').id, original_id)
|
||||||
|
|
||||||
|
|
||||||
|
class SendScheduledRemindersTests(TestCase):
|
||||||
|
"""Tests for the send_scheduled_reminders Celery task."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='sendreminderuser',
|
||||||
|
email='sendreminderuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
email_notifications=True,
|
||||||
|
push_notifications=False,
|
||||||
|
email_verified=True,
|
||||||
|
default_reminder_minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_due_reminder_sends_email_and_marks_sent(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Water the plants', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon')
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = reminder.remind_at + timedelta(seconds=1)
|
||||||
|
sent_count = send_scheduled_reminders()
|
||||||
|
|
||||||
|
# "reminder" (30 min before) and "due_soon" have passed; "overdue"
|
||||||
|
# is delayed an hour past due_soon so it hasn't fired yet.
|
||||||
|
self.assertEqual(sent_count, 2)
|
||||||
|
self.assertEqual(len(mail.outbox), 2)
|
||||||
|
reminder.refresh_from_db()
|
||||||
|
self.assertTrue(reminder.is_sent)
|
||||||
|
self.assertIsNotNone(reminder.sent_at)
|
||||||
|
self.assertFalse(ScheduledReminder.objects.get(task=task, reminder_type='overdue').is_sent)
|
||||||
|
|
||||||
|
def test_overdue_reminder_fires_an_hour_after_due(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Water the plants', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue')
|
||||||
|
due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon')
|
||||||
|
self.assertEqual(overdue.remind_at, due_soon.remind_at + timedelta(hours=1))
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = overdue.remind_at + timedelta(seconds=1)
|
||||||
|
send_scheduled_reminders()
|
||||||
|
|
||||||
|
overdue.refresh_from_db()
|
||||||
|
self.assertTrue(overdue.is_sent)
|
||||||
|
self.assertIn(f'Overdue: {task.title}', [m.subject for m in mail.outbox])
|
||||||
|
|
||||||
|
def test_future_reminder_not_sent_yet(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0))
|
||||||
|
sent_count = send_scheduled_reminders()
|
||||||
|
|
||||||
|
self.assertEqual(sent_count, 0)
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
|
||||||
|
def test_completed_task_reminder_marked_sent_without_notifying(self):
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
remind_at = ScheduledReminder.objects.get(task=task, reminder_type='due_soon').remind_at
|
||||||
|
task.status = 'completed'
|
||||||
|
task.save() # reschedule_reminders() already clears pending reminders for the task
|
||||||
|
|
||||||
|
# Simulate a stale reminder surviving anyway (e.g. a race with the poller).
|
||||||
|
stale = ScheduledReminder.objects.create(task=task, reminder_type='due_soon', remind_at=remind_at)
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = remind_at + timedelta(seconds=1)
|
||||||
|
send_scheduled_reminders()
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
stale.refresh_from_db()
|
||||||
|
self.assertTrue(stale.is_sent)
|
||||||
|
|
||||||
|
def test_channel_choice_email_only(self):
|
||||||
|
self.user.push_notifications = False
|
||||||
|
self.user.save()
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
reminder = ScheduledReminder.objects.get(task=task, reminder_type='reminder')
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = reminder.remind_at + timedelta(seconds=1)
|
||||||
|
send_scheduled_reminders()
|
||||||
|
|
||||||
|
sent_subjects = [m.subject for m in mail.outbox]
|
||||||
|
self.assertIn(f'Upcoming: {task.title}', sent_subjects)
|
||||||
|
|
||||||
|
def test_channel_choice_none_still_marks_sent(self):
|
||||||
|
self.user.email_notifications = False
|
||||||
|
self.user.push_notifications = False
|
||||||
|
self.user.save()
|
||||||
|
task = Task.objects.create(
|
||||||
|
user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0),
|
||||||
|
)
|
||||||
|
reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon')
|
||||||
|
|
||||||
|
with patch('django.utils.timezone.now') as mock_now:
|
||||||
|
mock_now.return_value = reminder.remind_at + timedelta(seconds=1)
|
||||||
|
send_scheduled_reminders()
|
||||||
|
|
||||||
|
self.assertEqual(len(mail.outbox), 0)
|
||||||
|
reminder.refresh_from_db()
|
||||||
|
self.assertTrue(reminder.is_sent)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# KeepItGoing Server - Portainer Stack Environment Configuration
|
||||||
|
# ===================================================================
|
||||||
|
# This file is an EXAMPLE for Portainer deployments from git repository.
|
||||||
|
# When deploying in Portainer:
|
||||||
|
# 1. Copy the contents of this file
|
||||||
|
# 2. Paste into the "Environment variables" section in Portainer
|
||||||
|
# 3. Replace all placeholder values with your actual values
|
||||||
|
# DO NOT commit your actual stack.env file to version control!
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Django Core Settings
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# SECURITY WARNING: Generate a strong secret key!
|
||||||
|
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||||
|
SECRET_KEY=CHANGE_ME_GENERATE_A_STRONG_SECRET_KEY_50_PLUS_CHARACTERS
|
||||||
|
|
||||||
|
# Debug mode (MUST be False in production)
|
||||||
|
DEBUG=False
|
||||||
|
|
||||||
|
# Comma-separated list of allowed hosts
|
||||||
|
# Example: api.yourdomain.com,localhost
|
||||||
|
ALLOWED_HOSTS=yourdomain.com,localhost
|
||||||
|
|
||||||
|
# Comma-separated list of trusted origins for CSRF (must include https://)
|
||||||
|
# Example: https://api.yourdomain.com,https://yourdomain.com
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://yourdomain.com
|
||||||
|
|
||||||
|
# Domain used in email links
|
||||||
|
SITE_DOMAIN=yourdomain.com
|
||||||
|
|
||||||
|
# Public self-registration toggle
|
||||||
|
# Set to False to require admin-created accounts
|
||||||
|
ALLOW_SELF_REGISTRATION=False
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Database Configuration (PostgreSQL)
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
DB_NAME=keepitgoing
|
||||||
|
DB_USER=keepitgoing
|
||||||
|
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Redis Configuration
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Email Configuration
|
||||||
|
# ===================================================================
|
||||||
|
# Required for email verification, approval emails, and notifications
|
||||||
|
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
|
||||||
|
# Gmail Example (use App Password, not regular password):
|
||||||
|
# EMAIL_HOST=smtp.gmail.com
|
||||||
|
# EMAIL_PORT=587
|
||||||
|
# EMAIL_USE_TLS=True
|
||||||
|
# EMAIL_HOST_USER=your-email@gmail.com
|
||||||
|
# EMAIL_HOST_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# SendGrid Example:
|
||||||
|
# EMAIL_HOST=smtp.sendgrid.net
|
||||||
|
# EMAIL_PORT=587
|
||||||
|
# EMAIL_USE_TLS=True
|
||||||
|
# EMAIL_HOST_USER=apikey
|
||||||
|
# EMAIL_HOST_PASSWORD=your-sendgrid-api-key
|
||||||
|
|
||||||
|
# AWS SES Example:
|
||||||
|
# EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
|
||||||
|
# EMAIL_PORT=587
|
||||||
|
# EMAIL_USE_TLS=True
|
||||||
|
# EMAIL_HOST_USER=your-aws-access-key
|
||||||
|
# EMAIL_HOST_PASSWORD=your-aws-secret-key
|
||||||
|
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=your-email@gmail.com
|
||||||
|
EMAIL_HOST_PASSWORD=your-app-password
|
||||||
|
DEFAULT_FROM_EMAIL=KeepItGoing <noreply@yourdomain.com>
|
||||||
|
SERVER_EMAIL=server@yourdomain.com
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Email Verification Settings
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# CORS Configuration
|
||||||
|
# ===================================================================
|
||||||
|
# Comma-separated list of allowed origins
|
||||||
|
# Example: https://yourdomain.com,https://app.yourdomain.com
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS=https://yourdomain.com
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# SaaS Mode
|
||||||
|
# ===================================================================
|
||||||
|
# Set to True for SaaS deployment with billing/usage limits
|
||||||
|
# Set to False for self-hosted deployment (no limits)
|
||||||
|
|
||||||
|
SAAS_MODE=False
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Web Server Configuration
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# Port to expose the web service on (default: 8000)
|
||||||
|
# Change this if port 8000 is already in use
|
||||||
|
WEB_PORT=8000
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Gunicorn Configuration
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_TIMEOUT=60
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Push Notifications (Optional)
|
||||||
|
# ===================================================================
|
||||||
|
# Leave empty if not using push notifications
|
||||||
|
|
||||||
|
# Firebase Cloud Messaging (FCM)
|
||||||
|
FCM_CREDENTIALS_PATH=
|
||||||
|
|
||||||
|
# Web Push (VAPID keys)
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_ADMIN_EMAIL=
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Additional Settings
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# Django settings module (usually don't need to change)
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.selfhosted
|
||||||
@@ -91,6 +91,36 @@
|
|||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Scrollbar Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Firefox scrollbar styling */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chromium/Webkit scrollbar styling */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Reset & Base
|
Reset & Base
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
+180
-1
@@ -3,12 +3,123 @@
|
|||||||
* Handles theme toggle, task selection, mobile menus, and AJAX operations
|
* Handles theme toggle, task selection, mobile menus, and AJAX operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
initTheme();
|
initTheme();
|
||||||
initTaskSelection();
|
initTaskSelection();
|
||||||
initTimerDisplays();
|
initTimerDisplays();
|
||||||
|
registerServiceWorker();
|
||||||
|
// Every real page load is a chance for the online UI to have mutated
|
||||||
|
// something (a tag delete, a task edit) that IndexedDB doesn't know
|
||||||
|
// about yet - sync unconditionally (no throttle) so the offline cache
|
||||||
|
// never lags behind what the user just did while online. The server's
|
||||||
|
// own rate limit (SyncRateThrottle) is the backstop against excess calls.
|
||||||
|
const pushedChanges = await runBackgroundSync();
|
||||||
|
if (pushedChanges) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('online', async function() {
|
||||||
|
const pushedChanges = await runBackgroundSync();
|
||||||
|
if (pushedChanges) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Service Worker
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function registerServiceWorker() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Push Notifications
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeToPush(vapidPublicKey) {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
throw new Error('Push notifications are not supported in this browser.');
|
||||||
|
}
|
||||||
|
if (!vapidPublicKey) {
|
||||||
|
throw new Error('Push notifications are not configured on this server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission was not granted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||||
|
const response = await fetch('/api/users/devices/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
platform: 'web',
|
||||||
|
token: JSON.stringify(subscription),
|
||||||
|
device_name: navigator.userAgent.slice(0, 255),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to register push subscription with the server.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProfileFormSubmit(event, form) {
|
||||||
|
const pushCheckbox = form.querySelector('[name=push_notifications]');
|
||||||
|
if (!pushCheckbox || !pushCheckbox.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// preventDefault() must happen synchronously (before any await) or the
|
||||||
|
// browser will have already submitted the form by the time we get to it.
|
||||||
|
event.preventDefault();
|
||||||
|
enablePushThenSubmitProfile(form, pushCheckbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enablePushThenSubmitProfile(form, pushCheckbox) {
|
||||||
|
try {
|
||||||
|
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration();
|
||||||
|
const existing = registration && await registration.pushManager.getSubscription();
|
||||||
|
if (!existing) {
|
||||||
|
await subscribeToPush(pushCheckbox.dataset.vapidPublicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Could not enable push notifications: ' + err.message);
|
||||||
|
pushCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Theme Toggle
|
Theme Toggle
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -162,6 +273,74 @@ function closeDetail() {
|
|||||||
closeMobileMenus();
|
closeMobileMenus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Custom Recurrence Builder
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function toggleCustomRecurrenceBuilder(selectEl) {
|
||||||
|
const form = selectEl.closest('form');
|
||||||
|
const builder = form.querySelector('.custom-recurrence-builder');
|
||||||
|
if (builder) {
|
||||||
|
builder.classList.toggle('hidden', selectEl.value !== 'custom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomFreqPanel(radioEl) {
|
||||||
|
const builder = radioEl.closest('.custom-recurrence-builder');
|
||||||
|
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
|
||||||
|
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
|
||||||
|
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
|
||||||
|
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleCustomRecurrenceRule(formEl) {
|
||||||
|
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
|
||||||
|
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
|
||||||
|
if (!recurrenceSelect || !ruleInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceSelect.value !== 'custom') {
|
||||||
|
ruleInput.value = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = formEl.querySelector('.custom-recurrence-builder');
|
||||||
|
if (!builder) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freqMode = builder.querySelector('.custom-freq-mode:checked');
|
||||||
|
const freq = freqMode ? freqMode.value : 'weekly';
|
||||||
|
|
||||||
|
if (freq === 'weekly') {
|
||||||
|
const panel = builder.querySelector('.custom-weekly-panel');
|
||||||
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
||||||
|
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
|
||||||
|
if (days.length === 0) {
|
||||||
|
ruleInput.value = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
|
||||||
|
} else {
|
||||||
|
const panel = builder.querySelector('.custom-monthly-panel');
|
||||||
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
||||||
|
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
|
||||||
|
const mode = monthlyMode ? monthlyMode.value : 'day';
|
||||||
|
|
||||||
|
if (mode === 'day') {
|
||||||
|
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
|
||||||
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
|
||||||
|
} else {
|
||||||
|
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
|
||||||
|
const weekday = panel.querySelector('.custom-nth-weekday').value;
|
||||||
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Mobile Navigation
|
Mobile Navigation
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* KeepItGoing - Offline IndexedDB helpers
|
||||||
|
* Pure local storage access for offline task data. No network calls here —
|
||||||
|
* see offline-sync.js for the /api/sync/ replay logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OFFLINE_DB_NAME = 'keepitgoing-offline';
|
||||||
|
const OFFLINE_DB_VERSION = 2;
|
||||||
|
|
||||||
|
function openOfflineDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(OFFLINE_DB_NAME, OFFLINE_DB_VERSION);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains('tasks')) {
|
||||||
|
db.createObjectStore('tasks', { keyPath: 'sync_id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('meta')) {
|
||||||
|
db.createObjectStore('meta', { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('tags')) {
|
||||||
|
db.createObjectStore('tags', { keyPath: 'sync_id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('time_entries')) {
|
||||||
|
db.createObjectStore('time_entries', { keyPath: 'sync_id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promisifyRequest(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMeta(key) {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('meta', 'readonly');
|
||||||
|
const result = await promisifyRequest(tx.objectStore('meta').get(key));
|
||||||
|
return result ? result.value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMeta(key, value) {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('meta', 'readwrite');
|
||||||
|
await promisifyRequest(tx.objectStore('meta').put({ key, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDeviceId() {
|
||||||
|
let deviceId = await getMeta('device_id');
|
||||||
|
if (!deviceId) {
|
||||||
|
deviceId = 'web-' + crypto.randomUUID();
|
||||||
|
await setMeta('device_id', deviceId);
|
||||||
|
}
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllTasks() {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('tasks', 'readonly');
|
||||||
|
return promisifyRequest(tx.objectStore('tasks').getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putLocalTask(task) {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('tasks', 'readwrite');
|
||||||
|
await promisifyRequest(tx.objectStore('tasks').put(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDirtyTasks() {
|
||||||
|
const tasks = await getAllTasks();
|
||||||
|
return tasks.filter((t) => t._dirty === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllTags() {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('tags', 'readonly');
|
||||||
|
return promisifyRequest(tx.objectStore('tags').getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putLocalTag(tag) {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('tags', 'readwrite');
|
||||||
|
await promisifyRequest(tx.objectStore('tags').put(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDirtyTags() {
|
||||||
|
const tags = await getAllTags();
|
||||||
|
return tags.filter((t) => t._dirty === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllTimeEntries() {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('time_entries', 'readonly');
|
||||||
|
return promisifyRequest(tx.objectStore('time_entries').getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putLocalTimeEntry(entry) {
|
||||||
|
const db = await openOfflineDB();
|
||||||
|
const tx = db.transaction('time_entries', 'readwrite');
|
||||||
|
await promisifyRequest(tx.objectStore('time_entries').put(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDirtyTimeEntries() {
|
||||||
|
const entries = await getAllTimeEntries();
|
||||||
|
return entries.filter((e) => e._dirty === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRunningTimeEntry() {
|
||||||
|
const entries = await getAllTimeEntries();
|
||||||
|
return entries.find((e) => !e.is_deleted && !e.ended_at) || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* KeepItGoing - Offline sync replay
|
||||||
|
* Talks to /api/sync/ to pull server changes into IndexedDB and push any
|
||||||
|
* locally-queued offline edits back up. Only ever runs from a real,
|
||||||
|
* network-loaded page (never from the cached offline app).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bump this whenever a previously-untracked entity type starts being synced
|
||||||
|
// (e.g. tags/time_entries were added after tasks-only syncing already
|
||||||
|
// shipped). /api/sync/ only returns rows changed since a device's last sync
|
||||||
|
// token, so any device with an old token would otherwise never receive
|
||||||
|
// pre-existing rows of the newly-tracked type - they were never "changed
|
||||||
|
// since" a token issued before that type existed at all. Forces exactly one
|
||||||
|
// full resync (by clearing the stored token) per bump, per device.
|
||||||
|
const SYNC_FORMAT_VERSION = 2;
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
const el = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||||
|
return el ? el.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripLocalFields(record) {
|
||||||
|
const { _dirty, _pending_conflict_id, ...clean } = record;
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// server_changes.time_entries[] uses a field named "task" holding the task's
|
||||||
|
// sync_id, but *creating* a new entry requires "task_sync_id" instead
|
||||||
|
// (sync/views.py process_time_entry_changes). Normalize to task_sync_id
|
||||||
|
// locally so the rest of the app only ever deals with one field name.
|
||||||
|
function timeEntryToWire(entry) {
|
||||||
|
return stripLocalFields(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeEntryFromWire(serverEntry) {
|
||||||
|
const { task, ...rest } = serverEntry;
|
||||||
|
// duration_seconds isn't part of the sync wire format at all (a
|
||||||
|
// pre-existing gap in TimeEntrySyncSerializer) - compute it locally so
|
||||||
|
// the offline app's time totals are correct regardless of where an
|
||||||
|
// entry came from.
|
||||||
|
const durationSeconds = rest.ended_at
|
||||||
|
? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000)
|
||||||
|
: null;
|
||||||
|
return { ...rest, task_sync_id: task, duration_seconds: durationSeconds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared merge-then-auto-resolve logic for one entity type's slice of a
|
||||||
|
// sync response: merge server rows (skipping anything about to be
|
||||||
|
// re-asserted by a conflict resolution), clear dirty flags for rows that
|
||||||
|
// synced cleanly, then resolve each conflict as "local wins".
|
||||||
|
async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) {
|
||||||
|
const normalize = fromWire || ((r) => r);
|
||||||
|
const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType);
|
||||||
|
const conflictedIds = new Set(
|
||||||
|
relevantConflicts.map((c) => c.local_data && c.local_data.sync_id).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const serverRow of serverRows) {
|
||||||
|
const normalized = normalize(serverRow);
|
||||||
|
if (conflictedIds.has(normalized.sync_id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await putLocal({ ...normalized, _dirty: false, _pending_conflict_id: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of dirtyLocal) {
|
||||||
|
if (!conflictedIds.has(row.sync_id)) {
|
||||||
|
await putLocal({ ...row, _dirty: false, _pending_conflict_id: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const conflict of relevantConflicts) {
|
||||||
|
const syncId = conflict.local_data && conflict.local_data.sync_id;
|
||||||
|
if (!syncId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resolveResponse = await fetch(`/api/sync/conflicts/${conflict.id}/resolve/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ resolution: 'local' }),
|
||||||
|
});
|
||||||
|
if (!resolveResponse.ok) {
|
||||||
|
throw new Error('resolve failed');
|
||||||
|
}
|
||||||
|
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
||||||
|
if (localRow) {
|
||||||
|
await putLocal({ ...localRow, _dirty: false, _pending_conflict_id: null });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
||||||
|
if (localRow) {
|
||||||
|
await putLocal({ ...localRow, _dirty: true, _pending_conflict_id: conflict.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if locally-queued offline changes were successfully pushed up
|
||||||
|
// this call - meaning the current (server-rendered) page was rendered before
|
||||||
|
// those changes existed and is now stale, so the caller should refresh it.
|
||||||
|
// Returns false if there was nothing to push, or the sync didn't complete.
|
||||||
|
async function runBackgroundSync() {
|
||||||
|
const csrfToken = getCsrfToken();
|
||||||
|
if (!csrfToken) {
|
||||||
|
return false; // Not on an authenticated page (e.g. login/register).
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = await ensureDeviceId();
|
||||||
|
const syncFormatVersion = await getMeta('sync_format_version');
|
||||||
|
const forceFullResync = (syncFormatVersion || 1) < SYNC_FORMAT_VERSION;
|
||||||
|
const lastSyncToken = forceFullResync ? null : await getMeta('last_sync_token');
|
||||||
|
|
||||||
|
const dirtyTasks = await getDirtyTasks();
|
||||||
|
const dirtyTags = await getDirtyTags();
|
||||||
|
const dirtyTimeEntries = await getDirtyTimeEntries();
|
||||||
|
const hadPendingChanges = dirtyTasks.length + dirtyTags.length + dirtyTimeEntries.length > 0;
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch('/api/sync/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
device_id: deviceId,
|
||||||
|
last_sync_token: lastSyncToken || null,
|
||||||
|
changes: {
|
||||||
|
tasks: dirtyTasks.map(stripLocalFields),
|
||||||
|
tags: dirtyTags.map(stripLocalFields),
|
||||||
|
time_entries: dirtyTimeEntries.map(timeEntryToWire),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return false; // Offline or network error - retry next time, nothing to clean up.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false; // Includes 429 throttled - retry next time.
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const conflicts = data.conflicts || [];
|
||||||
|
|
||||||
|
await mergeAndResolveEntity({
|
||||||
|
entityType: 'task', dirtyLocal: dirtyTasks, serverRows: data.server_changes.tasks,
|
||||||
|
conflicts, csrfToken, putLocal: putLocalTask,
|
||||||
|
});
|
||||||
|
await mergeAndResolveEntity({
|
||||||
|
entityType: 'tag', dirtyLocal: dirtyTags, serverRows: data.server_changes.tags,
|
||||||
|
conflicts, csrfToken, putLocal: putLocalTag,
|
||||||
|
});
|
||||||
|
await mergeAndResolveEntity({
|
||||||
|
entityType: 'time_entry', dirtyLocal: dirtyTimeEntries, serverRows: data.server_changes.time_entries,
|
||||||
|
conflicts, csrfToken, putLocal: putLocalTimeEntry, fromWire: timeEntryFromWire,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setMeta('last_sync_token', data.sync_token);
|
||||||
|
await setMeta('last_sync_at', new Date().toISOString());
|
||||||
|
await setMeta('sync_format_version', SYNC_FORMAT_VERSION);
|
||||||
|
|
||||||
|
// A forced full resync just backfilled previously-missed data (e.g. tags
|
||||||
|
// that existed before tag syncing shipped) - worth a refresh even if
|
||||||
|
// nothing local was dirty, since the current page may be missing it too.
|
||||||
|
return hadPendingChanges || forceFullResync;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
/**
|
||||||
|
* KeepItGoing - Offline mini task app
|
||||||
|
* Renders and edits tasks straight from IndexedDB while there's no network.
|
||||||
|
* Reuses .task-item/.task-checkbox/.priority-badge/.task-group-label/.modal-*
|
||||||
|
* classes from the real app's templates for visual consistency, and mirrors
|
||||||
|
* the dashboard's default filter/sort (tasks/views.py DashboardView) and the
|
||||||
|
* task-detail view's fields/actions (templates/tasks/_task_detail.html).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIORITY_ORDER = { urgent: 4, high: 3, medium: 2, low: 1 };
|
||||||
|
const RECURRENCE_CHOICES = ['none', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly'];
|
||||||
|
const TAG_COLOR_PRESETS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
|
||||||
|
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let currentSort = 'due_date';
|
||||||
|
let currentTagId = null;
|
||||||
|
let openTaskSyncId = null;
|
||||||
|
let timerDisplayInterval = null;
|
||||||
|
let currentTagModalSyncId = null;
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Date/format helpers
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function todayLocalStr() {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDueDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(totalSeconds) {
|
||||||
|
const seconds = Math.max(0, totalSeconds || 0);
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Filter/sort (mirrors DashboardView's defaults)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function applyFilter(tasks, filter) {
|
||||||
|
const today = todayLocalStr();
|
||||||
|
if (filter === 'completed') {
|
||||||
|
return tasks.filter((t) => t.status === 'completed');
|
||||||
|
}
|
||||||
|
const notCompleted = tasks.filter((t) => t.status !== 'completed');
|
||||||
|
switch (filter) {
|
||||||
|
case 'today':
|
||||||
|
return notCompleted.filter((t) => t.due_date === today);
|
||||||
|
case 'upcoming':
|
||||||
|
return notCompleted.filter((t) => t.due_date && t.due_date > today);
|
||||||
|
case 'overdue':
|
||||||
|
return notCompleted.filter((t) => t.due_date && t.due_date < today && t.status !== 'cancelled');
|
||||||
|
default:
|
||||||
|
return notCompleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTagFilter(tasks, tagId) {
|
||||||
|
if (!tagId) return tasks;
|
||||||
|
return tasks.filter((t) => (t.tag_sync_ids || []).includes(tagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort(tasks, sort) {
|
||||||
|
const sorted = [...tasks];
|
||||||
|
const dueOrDefault = (t) => t.due_date || '9999-99-99';
|
||||||
|
const priorityOf = (t) => PRIORITY_ORDER[t.priority] || 0;
|
||||||
|
|
||||||
|
switch (sort) {
|
||||||
|
case 'due_date_desc':
|
||||||
|
sorted.sort((a, b) => dueOrDefault(b).localeCompare(dueOrDefault(a)) || priorityOf(b) - priorityOf(a));
|
||||||
|
break;
|
||||||
|
case 'priority':
|
||||||
|
sorted.sort((a, b) => priorityOf(b) - priorityOf(a) || dueOrDefault(a).localeCompare(dueOrDefault(b)));
|
||||||
|
break;
|
||||||
|
case 'priority_low':
|
||||||
|
sorted.sort((a, b) => priorityOf(a) - priorityOf(b) || dueOrDefault(a).localeCompare(dueOrDefault(b)));
|
||||||
|
break;
|
||||||
|
case 'due_date':
|
||||||
|
default:
|
||||||
|
sorted.sort((a, b) => dueOrDefault(a).localeCompare(dueOrDefault(b)) || priorityOf(b) - priorityOf(a));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Task list rendering
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function renderTagChips(task, tagsBySyncId) {
|
||||||
|
const tagIds = task.tag_sync_ids || [];
|
||||||
|
if (tagIds.length === 0) return '';
|
||||||
|
return tagIds
|
||||||
|
.map((id) => tagsBySyncId[id])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tag) => `<span class="task-group-label" style="background-color: ${tag.color}; color: white; font-weight: 600;">${escapeHtml(tag.name)}</span>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str == null ? '' : String(str);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskOverdue(task) {
|
||||||
|
// Mirrors Task.is_overdue in tasks/models.py.
|
||||||
|
if (!task.due_date || task.status === 'completed' || task.status === 'cancelled') return false;
|
||||||
|
const today = todayLocalStr();
|
||||||
|
if (task.due_date < today) return true;
|
||||||
|
if (task.due_date === today && task.due_time) {
|
||||||
|
const now = new Date();
|
||||||
|
const nowStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||||
|
return task.due_time < nowStr;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskItem(task, tagsBySyncId) {
|
||||||
|
const overdue = isTaskOverdue(task);
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `task-item priority-${task.priority}${overdue ? ' overdue' : ''}${task.status === 'completed' ? ' completed' : ''}`;
|
||||||
|
|
||||||
|
const toggleForm = document.createElement('button');
|
||||||
|
toggleForm.type = 'button';
|
||||||
|
toggleForm.className = `task-checkbox${task.status === 'completed' ? ' checked' : ''}`;
|
||||||
|
toggleForm.setAttribute('aria-label', 'Toggle complete');
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
toggleForm.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>';
|
||||||
|
}
|
||||||
|
toggleForm.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleTaskComplete(task.sync_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'task-content';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'task-title';
|
||||||
|
title.textContent = task.title;
|
||||||
|
content.appendChild(title);
|
||||||
|
|
||||||
|
const metaParts = [];
|
||||||
|
if (task.due_date) {
|
||||||
|
metaParts.push(`<span class="task-due${overdue ? ' overdue' : ''}">📅 ${formatDueDate(task.due_date)}</span>`);
|
||||||
|
}
|
||||||
|
const tagChips = renderTagChips(task, tagsBySyncId);
|
||||||
|
if (metaParts.length || tagChips) {
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'task-meta';
|
||||||
|
meta.innerHTML = metaParts.join(' ') + tagChips;
|
||||||
|
content.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityBadge = document.createElement('span');
|
||||||
|
priorityBadge.className = `priority-badge ${task.priority}`;
|
||||||
|
priorityBadge.textContent = task.priority;
|
||||||
|
|
||||||
|
item.appendChild(toggleForm);
|
||||||
|
item.appendChild(content);
|
||||||
|
item.appendChild(priorityBadge);
|
||||||
|
|
||||||
|
item.addEventListener('click', () => openTaskDetail(task.sync_id));
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTaskComplete(syncId) {
|
||||||
|
const tasks = await getAllTasks();
|
||||||
|
const task = tasks.find((t) => t.sync_id === syncId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
task.status = task.status === 'completed' ? 'pending' : 'completed';
|
||||||
|
task.updated_at = new Date().toISOString();
|
||||||
|
task._dirty = true;
|
||||||
|
await putLocalTask(task);
|
||||||
|
renderOfflineTasks();
|
||||||
|
if (openTaskSyncId === syncId) {
|
||||||
|
openTaskDetail(syncId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankTask(overrides) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const syncId = crypto.randomUUID();
|
||||||
|
return {
|
||||||
|
id: syncId,
|
||||||
|
sync_id: syncId,
|
||||||
|
parent: null,
|
||||||
|
parent_sync_id: null,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'medium',
|
||||||
|
due_date: null,
|
||||||
|
due_time: null,
|
||||||
|
reminder_at: null,
|
||||||
|
recurrence: 'none',
|
||||||
|
recurrence_rule: '',
|
||||||
|
recurrence_end_date: null,
|
||||||
|
tag_sync_ids: [],
|
||||||
|
sort_order: 0,
|
||||||
|
is_deleted: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
_dirty: true,
|
||||||
|
_pending_conflict_id: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOfflineTask(title) {
|
||||||
|
await putLocalTask(blankTask({ title }));
|
||||||
|
renderOfflineTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOfflineTasks() {
|
||||||
|
const listEl = document.getElementById('offline-task-list');
|
||||||
|
const emptyEl = document.getElementById('offline-empty-state');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const [allTasks, allTags] = await Promise.all([getAllTasks(), getAllTags()]);
|
||||||
|
const tagsBySyncId = {};
|
||||||
|
allTags.forEach((t) => { if (!t.is_deleted) tagsBySyncId[t.sync_id] = t; });
|
||||||
|
|
||||||
|
// Only top-level tasks in the main list - subtasks appear inside their
|
||||||
|
// parent's detail modal, matching the online dashboard.
|
||||||
|
const topLevel = allTasks.filter((t) => !t.is_deleted && !t.parent_sync_id);
|
||||||
|
const statusFiltered = applyFilter(topLevel, currentFilter);
|
||||||
|
const visibleTasks = applySort(applyTagFilter(statusFiltered, currentTagId), currentSort);
|
||||||
|
|
||||||
|
setActiveFilterNav(currentFilter);
|
||||||
|
renderTagSidebarNav(allTags, topLevel);
|
||||||
|
|
||||||
|
const titleEl = document.getElementById('offline-page-title');
|
||||||
|
if (titleEl) {
|
||||||
|
const tagName = currentTagId && tagsBySyncId[currentTagId] ? tagsBySyncId[currentTagId].name : null;
|
||||||
|
titleEl.textContent = (FILTER_TITLES[currentFilter] || 'All Tasks') + (tagName ? ` • ${tagName}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (visibleTasks.length === 0) {
|
||||||
|
const lastSyncToken = await getMeta('last_sync_token');
|
||||||
|
emptyEl.textContent = lastSyncToken
|
||||||
|
? 'No tasks here.'
|
||||||
|
: "No offline data yet — connect once while online to sync your tasks.";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
listEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyEl.hidden = true;
|
||||||
|
listEl.hidden = false;
|
||||||
|
for (const task of visibleTasks) {
|
||||||
|
listEl.appendChild(renderTaskItem(task, tagsBySyncId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_TITLES = { all: 'All Tasks', today: 'Today', upcoming: 'Upcoming', overdue: 'Overdue', completed: 'Completed' };
|
||||||
|
|
||||||
|
function setActiveFilterNav(filter) {
|
||||||
|
document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
|
||||||
|
item.classList.toggle('active', item.dataset.filter === filter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Sidebar Tags section (filter-by-tag + manage)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function renderTagSidebarNav(allTags, topLevelTasks) {
|
||||||
|
const nav = document.getElementById('offline-tag-nav');
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
|
const visibleTags = allTags.filter((t) => !t.is_deleted && !t.is_archived);
|
||||||
|
// Counts match the sidebar Filters' "all" semantics: top-level, not completed.
|
||||||
|
const countable = topLevelTasks.filter((t) => t.status !== 'completed');
|
||||||
|
|
||||||
|
nav.innerHTML = visibleTags.map((tag) => {
|
||||||
|
const count = countable.filter((t) => (t.tag_sync_ids || []).includes(tag.sync_id)).length;
|
||||||
|
return `
|
||||||
|
<div class="sidebar-group-row">
|
||||||
|
<a href="#" class="sidebar-item sidebar-group-item ${currentTagId === tag.sync_id ? 'active' : ''}" data-tag-id="${tag.sync_id}">
|
||||||
|
<span class="group-color-dot" style="background-color: ${tag.color}"></span>
|
||||||
|
${escapeHtml(tag.name)}
|
||||||
|
${count ? `<span class="sidebar-item-count">${count}</span>` : ''}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="sidebar-group-edit" title="Edit tag" data-edit-tag-id="${tag.sync_id}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('') + `
|
||||||
|
<a href="#" class="sidebar-item sidebar-group-item ${!currentTagId ? 'active' : ''}" id="offline-all-tags-link">
|
||||||
|
<span class="group-color-dot" style="background-color: var(--text-muted)"></span>
|
||||||
|
All Tags
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
|
nav.querySelectorAll('.sidebar-group-item[data-tag-id]').forEach((link) => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
currentTagId = link.dataset.tagId;
|
||||||
|
renderOfflineTasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
nav.querySelectorAll('.sidebar-group-edit').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const tag = visibleTags.find((t) => t.sync_id === btn.dataset.editTagId);
|
||||||
|
if (tag) openTagModal(tag.sync_id, tag.name, tag.color);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const allTagsLink = document.getElementById('offline-all-tags-link');
|
||||||
|
if (allTagsLink) {
|
||||||
|
allTagsLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
currentTagId = null;
|
||||||
|
renderOfflineTasks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagColorPresets(selectedColor) {
|
||||||
|
const container = document.getElementById('tag-modal-color-presets');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = TAG_COLOR_PRESETS.map((c) => `
|
||||||
|
<button type="button" class="color-preset ${c.toLowerCase() === selectedColor.toLowerCase() ? 'selected' : ''}" data-color="${c}" style="background-color: ${c}"></button>
|
||||||
|
`).join('');
|
||||||
|
container.querySelectorAll('.color-preset').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.getElementById('tag-modal-color').value = btn.dataset.color;
|
||||||
|
container.querySelectorAll('.color-preset').forEach((b) => b.classList.toggle('selected', b === btn));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagModal(tagSyncId = null, name = '', color = '#3b82f6') {
|
||||||
|
currentTagModalSyncId = tagSyncId;
|
||||||
|
document.getElementById('tag-modal-title').textContent = tagSyncId ? 'Edit Tag' : 'New Tag';
|
||||||
|
document.getElementById('tag-modal-name').value = name;
|
||||||
|
document.getElementById('tag-modal-color').value = color;
|
||||||
|
document.getElementById('tag-modal-submit-btn').textContent = tagSyncId ? 'Save' : 'Create';
|
||||||
|
document.getElementById('tag-modal-delete-btn').style.display = tagSyncId ? 'inline-flex' : 'none';
|
||||||
|
renderTagColorPresets(color);
|
||||||
|
document.getElementById('tag-modal-backdrop').classList.add('open');
|
||||||
|
document.getElementById('tag-modal-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagModal() {
|
||||||
|
document.getElementById('tag-modal-backdrop').classList.remove('open');
|
||||||
|
currentTagModalSyncId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTagModal() {
|
||||||
|
const name = document.getElementById('tag-modal-name').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const color = document.getElementById('tag-modal-color').value;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (currentTagModalSyncId) {
|
||||||
|
const tags = await getAllTags();
|
||||||
|
const tag = tags.find((t) => t.sync_id === currentTagModalSyncId);
|
||||||
|
if (tag) {
|
||||||
|
tag.name = name;
|
||||||
|
tag.color = color;
|
||||||
|
tag.updated_at = now;
|
||||||
|
tag._dirty = true;
|
||||||
|
await putLocalTag(tag);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const syncId = crypto.randomUUID();
|
||||||
|
await putLocalTag({
|
||||||
|
id: syncId, sync_id: syncId, name, description: '', color, icon: '',
|
||||||
|
sort_order: 0, is_archived: false, is_deleted: false,
|
||||||
|
created_at: now, updated_at: now, _dirty: true, _pending_conflict_id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTagModal();
|
||||||
|
renderOfflineTasks();
|
||||||
|
if (openTaskSyncId) {
|
||||||
|
renderTaskDetailPane();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentTag() {
|
||||||
|
if (!currentTagModalSyncId) return;
|
||||||
|
if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tags = await getAllTags();
|
||||||
|
const tag = tags.find((t) => t.sync_id === currentTagModalSyncId);
|
||||||
|
if (tag) {
|
||||||
|
tag.is_deleted = true;
|
||||||
|
tag.updated_at = new Date().toISOString();
|
||||||
|
tag._dirty = true;
|
||||||
|
await putLocalTag(tag);
|
||||||
|
}
|
||||||
|
if (currentTagId === currentTagModalSyncId) {
|
||||||
|
currentTagId = null;
|
||||||
|
}
|
||||||
|
closeTagModal();
|
||||||
|
renderOfflineTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Task detail pane
|
||||||
|
(internal element ids keep the "modal-" prefix from an earlier design,
|
||||||
|
but this now renders into the real #detail-pane, matching the online
|
||||||
|
dashboard's slide-in panel instead of a popup modal.)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function closeTaskDetail() {
|
||||||
|
document.getElementById('detail-pane').classList.add('hidden');
|
||||||
|
document.getElementById('app').classList.add('detail-closed');
|
||||||
|
closeOfflineMobileMenus();
|
||||||
|
stopTimerDisplayInterval();
|
||||||
|
openTaskSyncId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaskDetail(syncId) {
|
||||||
|
openTaskSyncId = syncId;
|
||||||
|
await renderTaskDetailPane();
|
||||||
|
const detailPane = document.getElementById('detail-pane');
|
||||||
|
detailPane.classList.remove('hidden');
|
||||||
|
document.getElementById('app').classList.remove('detail-closed');
|
||||||
|
if (window.innerWidth <= 1024) {
|
||||||
|
detailPane.classList.add('open');
|
||||||
|
document.getElementById('mobile-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTaskDetailPane() {
|
||||||
|
const syncId = openTaskSyncId;
|
||||||
|
if (!syncId) return;
|
||||||
|
|
||||||
|
const [allTasks, allTags, allTimeEntries] = await Promise.all([
|
||||||
|
getAllTasks(), getAllTags(), getAllTimeEntries(),
|
||||||
|
]);
|
||||||
|
const task = allTasks.find((t) => t.sync_id === syncId);
|
||||||
|
if (!task) {
|
||||||
|
closeTaskDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = allTags.filter((t) => !t.is_deleted);
|
||||||
|
const subtasks = allTasks.filter((t) => !t.is_deleted && t.parent_sync_id === syncId);
|
||||||
|
const taskTimeEntries = allTimeEntries.filter((e) => !e.is_deleted && e.task_sync_id === syncId);
|
||||||
|
const totalSeconds = taskTimeEntries.reduce((sum, e) => sum + (e.duration_seconds || 0), 0);
|
||||||
|
const runningEntry = taskTimeEntries.find((e) => !e.ended_at);
|
||||||
|
|
||||||
|
const body = document.getElementById('detail-pane');
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<h2 style="font-size: var(--font-lg); font-weight: 600;">Task Details</h2>
|
||||||
|
<button class="detail-close" id="detail-close-btn" aria-label="Close detail panel">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="task-edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-title">Title</label>
|
||||||
|
<input type="text" class="form-input" id="modal-title" required value="${escapeHtml(task.title)}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-description">Description</label>
|
||||||
|
<textarea class="form-textarea" id="modal-description" rows="3">${escapeHtml(task.description)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-status">Status</label>
|
||||||
|
<select class="form-select" id="modal-status">
|
||||||
|
<option value="pending" ${task.status === 'pending' ? 'selected' : ''}>Pending</option>
|
||||||
|
<option value="in_progress" ${task.status === 'in_progress' ? 'selected' : ''}>In Progress</option>
|
||||||
|
<option value="completed" ${task.status === 'completed' ? 'selected' : ''}>Completed</option>
|
||||||
|
<option value="cancelled" ${task.status === 'cancelled' ? 'selected' : ''}>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-priority">Priority</label>
|
||||||
|
<select class="form-select" id="modal-priority">
|
||||||
|
<option value="low" ${task.priority === 'low' ? 'selected' : ''}>Low</option>
|
||||||
|
<option value="medium" ${task.priority === 'medium' ? 'selected' : ''}>Medium</option>
|
||||||
|
<option value="high" ${task.priority === 'high' ? 'selected' : ''}>High</option>
|
||||||
|
<option value="urgent" ${task.priority === 'urgent' ? 'selected' : ''}>Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-due-date">Due Date</label>
|
||||||
|
<input type="date" class="form-input" id="modal-due-date" value="${task.due_date || ''}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-due-time">Due Time</label>
|
||||||
|
<input type="time" class="form-input" id="modal-due-time" value="${task.due_time || ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-recurrence">Recurrence</label>
|
||||||
|
<select class="form-select" id="modal-recurrence">
|
||||||
|
${RECURRENCE_CHOICES.map((r) => `<option value="${r}" ${task.recurrence === r ? 'selected' : ''}>${r === 'none' ? 'None' : r.charAt(0).toUpperCase() + r.slice(1)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="modal-recurrence-end">Ends</label>
|
||||||
|
<input type="date" class="form-input" id="modal-recurrence-end" value="${task.recurrence_end_date || ''}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<div id="modal-tag-checkboxes" style="display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-bottom: var(--space-sm);">
|
||||||
|
${tags.map((tag) => `
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="modal-tag-checkbox" value="${tag.sync_id}" ${(task.tag_sync_ids || []).includes(tag.sync_id) ? 'checked' : ''}>
|
||||||
|
<span style="color: ${tag.color}">${escapeHtml(tag.name)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('') || '<span class="text-muted">No tags yet.</span>'}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--space-sm);">
|
||||||
|
<input type="text" id="modal-new-tag-name" class="form-input" placeholder="New tag name" style="flex: 1;">
|
||||||
|
<input type="color" id="modal-new-tag-color" value="${TAG_COLOR_PRESETS[0]}" style="width: 40px; padding: 2px;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="modal-add-tag-btn">+ Tag</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: var(--space-lg); background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
|
||||||
|
<div style="font-size: var(--font-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-sm);">Subtasks</div>
|
||||||
|
<form id="modal-subtask-form" style="display: flex; gap: var(--space-sm); margin-bottom: var(--space-sm);">
|
||||||
|
<input type="text" id="modal-subtask-title" class="form-input" placeholder="Add a subtask..." style="flex: 1; padding: var(--space-xs) var(--space-sm); font-size: var(--font-sm);" required>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
<div id="modal-subtask-list" style="display: flex; flex-direction: column; gap: var(--space-xs);">
|
||||||
|
${subtasks.length === 0 ? '<div style="font-size: var(--font-sm); color: var(--text-muted);">No subtasks</div>' : subtasks.map((st) => `
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs); border-radius: var(--radius-xs); background: var(--surface);">
|
||||||
|
<button type="button" class="task-checkbox modal-subtask-toggle ${st.status === 'completed' ? 'checked' : ''}" data-sync-id="${st.sync_id}" style="width: 16px; height: 16px; font-size: 10px;">${st.status === 'completed' ? '✓' : ''}</button>
|
||||||
|
<span style="font-size: var(--font-sm); ${st.status === 'completed' ? 'text-decoration: line-through; color: var(--text-muted);' : ''}">${escapeHtml(st.title)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time-tracker" style="margin-top: var(--space-lg);">
|
||||||
|
<div class="time-tracker-header">
|
||||||
|
<span class="time-tracker-label">Time Spent</span>
|
||||||
|
<span class="time-tracker-total" id="modal-time-total">${formatDuration(totalSeconds)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
|
||||||
|
${runningEntry
|
||||||
|
? `<button type="button" class="btn btn-danger btn-full btn-sm" id="modal-timer-btn" data-running="true" data-entry-sync-id="${runningEntry.sync_id}" data-started="${runningEntry.started_at}">Stop Timer</button>`
|
||||||
|
: `<button type="button" class="btn btn-secondary btn-full btn-sm" id="modal-timer-btn" data-running="false">Start Timer</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
wireModalEvents(task, totalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireModalEvents(task, baseTotalSeconds) {
|
||||||
|
document.getElementById('detail-close-btn').addEventListener('click', closeTaskDetail);
|
||||||
|
|
||||||
|
document.getElementById('task-edit-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveTaskModalEdits(task.sync_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modal-add-tag-btn').addEventListener('click', () => {
|
||||||
|
addNewTagInline(task.sync_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modal-subtask-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('modal-subtask-title');
|
||||||
|
const title = input.value.trim();
|
||||||
|
if (title) {
|
||||||
|
addOfflineSubtask(task.sync_id, title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.modal-subtask-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => toggleTaskComplete(btn.dataset.syncId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const timerBtn = document.getElementById('modal-timer-btn');
|
||||||
|
timerBtn.addEventListener('click', () => {
|
||||||
|
if (timerBtn.dataset.running === 'true') {
|
||||||
|
stopTimer(timerBtn.dataset.entrySyncId, task.sync_id);
|
||||||
|
} else {
|
||||||
|
startTimer(task.sync_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stopTimerDisplayInterval();
|
||||||
|
if (timerBtn.dataset.running === 'true') {
|
||||||
|
const startedAt = new Date(timerBtn.dataset.started).getTime();
|
||||||
|
timerDisplayInterval = setInterval(() => {
|
||||||
|
const liveSeconds = baseTotalSeconds + Math.floor((Date.now() - startedAt) / 1000);
|
||||||
|
const totalEl = document.getElementById('modal-time-total');
|
||||||
|
if (totalEl) totalEl.textContent = formatDuration(liveSeconds);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimerDisplayInterval() {
|
||||||
|
if (timerDisplayInterval) {
|
||||||
|
clearInterval(timerDisplayInterval);
|
||||||
|
timerDisplayInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTaskModalEdits(syncId) {
|
||||||
|
const tasks = await getAllTasks();
|
||||||
|
const task = tasks.find((t) => t.sync_id === syncId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
task.title = document.getElementById('modal-title').value.trim() || task.title;
|
||||||
|
task.description = document.getElementById('modal-description').value;
|
||||||
|
task.status = document.getElementById('modal-status').value;
|
||||||
|
task.priority = document.getElementById('modal-priority').value;
|
||||||
|
task.due_date = document.getElementById('modal-due-date').value || null;
|
||||||
|
task.due_time = document.getElementById('modal-due-time').value || null;
|
||||||
|
task.recurrence = document.getElementById('modal-recurrence').value;
|
||||||
|
task.recurrence_end_date = document.getElementById('modal-recurrence-end').value || null;
|
||||||
|
task.tag_sync_ids = Array.from(document.querySelectorAll('.modal-tag-checkbox:checked')).map((cb) => cb.value);
|
||||||
|
task.updated_at = new Date().toISOString();
|
||||||
|
task._dirty = true;
|
||||||
|
|
||||||
|
await putLocalTask(task);
|
||||||
|
closeTaskDetail();
|
||||||
|
renderOfflineTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNewTagInline(taskSyncId) {
|
||||||
|
const nameInput = document.getElementById('modal-new-tag-name');
|
||||||
|
const colorInput = document.getElementById('modal-new-tag-color');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const syncId = crypto.randomUUID();
|
||||||
|
await putLocalTag({
|
||||||
|
id: syncId, sync_id: syncId, name, description: '', color: colorInput.value,
|
||||||
|
icon: '', sort_order: 0, is_archived: false, is_deleted: false,
|
||||||
|
created_at: now, updated_at: now, _dirty: true, _pending_conflict_id: null,
|
||||||
|
});
|
||||||
|
nameInput.value = '';
|
||||||
|
await renderTaskDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOfflineSubtask(parentSyncId, title) {
|
||||||
|
const tasks = await getAllTasks();
|
||||||
|
const parent = tasks.find((t) => t.sync_id === parentSyncId);
|
||||||
|
const subtask = blankTask({
|
||||||
|
title,
|
||||||
|
parent: parentSyncId,
|
||||||
|
parent_sync_id: parentSyncId,
|
||||||
|
// Subtasks inherit the parent's tags, matching tasks/views.py subtask_create.
|
||||||
|
tag_sync_ids: parent ? [...(parent.tag_sync_ids || [])] : [],
|
||||||
|
});
|
||||||
|
await putLocalTask(subtask);
|
||||||
|
await renderTaskDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTimer(taskSyncId) {
|
||||||
|
const running = await getRunningTimeEntry();
|
||||||
|
if (running) {
|
||||||
|
// Only one timer can run per user at a time, matching web_timer_start.
|
||||||
|
running.ended_at = new Date().toISOString();
|
||||||
|
running.duration_seconds = Math.round((new Date(running.ended_at) - new Date(running.started_at)) / 1000);
|
||||||
|
running._dirty = true;
|
||||||
|
await putLocalTimeEntry(running);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const syncId = crypto.randomUUID();
|
||||||
|
await putLocalTimeEntry({
|
||||||
|
id: syncId, sync_id: syncId, task_sync_id: taskSyncId, started_at: now, ended_at: null,
|
||||||
|
duration_seconds: null, notes: '', is_deleted: false, created_at: now, updated_at: now,
|
||||||
|
_dirty: true, _pending_conflict_id: null,
|
||||||
|
});
|
||||||
|
await renderTaskDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTimer(entrySyncId) {
|
||||||
|
const entries = await getAllTimeEntries();
|
||||||
|
const entry = entries.find((e) => e.sync_id === entrySyncId);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
entry.ended_at = new Date().toISOString();
|
||||||
|
entry.duration_seconds = Math.round((new Date(entry.ended_at) - new Date(entry.started_at)) / 1000);
|
||||||
|
entry.updated_at = entry.ended_at;
|
||||||
|
entry._dirty = true;
|
||||||
|
await putLocalTimeEntry(entry);
|
||||||
|
await renderTaskDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Init
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
renderOfflineTasks();
|
||||||
|
|
||||||
|
const form = document.getElementById('offline-add-task-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = document.getElementById('offline-task-title');
|
||||||
|
const title = input.value.trim();
|
||||||
|
if (title) {
|
||||||
|
addOfflineTask(title);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#offline-filter-nav .sidebar-item').forEach((item) => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
currentFilter = item.dataset.filter;
|
||||||
|
renderOfflineTasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortSelect = document.getElementById('offline-sort-select');
|
||||||
|
if (sortSelect) {
|
||||||
|
sortSelect.value = currentSort;
|
||||||
|
sortSelect.addEventListener('change', () => {
|
||||||
|
currentSort = sortSelect.value;
|
||||||
|
renderOfflineTasks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('offline-add-tag-btn').addEventListener('click', () => openTagModal());
|
||||||
|
document.getElementById('tag-modal-close-btn').addEventListener('click', closeTagModal);
|
||||||
|
document.getElementById('tag-modal-cancel-btn').addEventListener('click', closeTagModal);
|
||||||
|
document.getElementById('tag-modal-delete-btn').addEventListener('click', deleteCurrentTag);
|
||||||
|
document.getElementById('tag-edit-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveTagModal();
|
||||||
|
});
|
||||||
|
document.getElementById('tag-modal-backdrop').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'tag-modal-backdrop') {
|
||||||
|
closeTagModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Sidebar / mobile chrome
|
||||||
|
(app.js isn't loaded on this self-contained offline page, so these
|
||||||
|
mirror its toggleSidebar()/closeMobileMenus()/toggleTheme() directly.)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function toggleOfflineSidebar() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('mobile-overlay');
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOfflineMobileMenus() {
|
||||||
|
document.getElementById('sidebar').classList.remove('open');
|
||||||
|
document.getElementById('detail-pane').classList.remove('open');
|
||||||
|
document.getElementById('mobile-overlay').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOfflineTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
}
|
||||||
+224
-2
@@ -1,3 +1,225 @@
|
|||||||
from django.test import TestCase
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from tasks.models import Task, TimeEntry
|
||||||
|
from sync.models import SyncLog, SyncConflict
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SyncEndpointTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='synctestuser',
|
||||||
|
email='synctest@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_new_sync_id_creates_task(self):
|
||||||
|
new_sync_id = str(uuid.uuid4())
|
||||||
|
response = self.client.post('/api/sync/', {
|
||||||
|
'device_id': 'web-test-device',
|
||||||
|
'last_sync_token': None,
|
||||||
|
'changes': {
|
||||||
|
'tasks': [{
|
||||||
|
'sync_id': new_sync_id,
|
||||||
|
'title': 'Offline-created task',
|
||||||
|
'status': 'pending',
|
||||||
|
'priority': 'medium',
|
||||||
|
}],
|
||||||
|
'tags': [],
|
||||||
|
'time_entries': [],
|
||||||
|
},
|
||||||
|
}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task = Task.objects.get(sync_id=new_sync_id)
|
||||||
|
self.assertEqual(task.title, 'Offline-created task')
|
||||||
|
self.assertEqual(task.user, self.user)
|
||||||
|
|
||||||
|
def test_is_deleted_soft_deletes_regardless_of_conflict(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='To be deleted')
|
||||||
|
|
||||||
|
# Force a conflict window: server row touched after "last sync".
|
||||||
|
last_sync_at = timezone.now() - timedelta(minutes=5)
|
||||||
|
SyncLog.objects.create(
|
||||||
|
user=self.user, device_id='web-test-device',
|
||||||
|
sync_token='old-token', last_sync_at=last_sync_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post('/api/sync/', {
|
||||||
|
'device_id': 'web-test-device',
|
||||||
|
'last_sync_token': 'old-token',
|
||||||
|
'changes': {
|
||||||
|
'tasks': [{'sync_id': str(task.sync_id), 'is_deleted': True}],
|
||||||
|
'tags': [],
|
||||||
|
'time_entries': [],
|
||||||
|
},
|
||||||
|
}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertTrue(task.is_deleted)
|
||||||
|
self.assertEqual(SyncConflict.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_conflict_created_and_client_change_discarded(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Original title')
|
||||||
|
|
||||||
|
last_sync_at = timezone.now() - timedelta(minutes=5)
|
||||||
|
SyncLog.objects.create(
|
||||||
|
user=self.user, device_id='web-test-device',
|
||||||
|
sync_token='old-token', last_sync_at=last_sync_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server row modified after last_sync_at (updated_at auto-bumps on save).
|
||||||
|
task.title = 'Changed on server'
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
response = self.client.post('/api/sync/', {
|
||||||
|
'device_id': 'web-test-device',
|
||||||
|
'last_sync_token': 'old-token',
|
||||||
|
'changes': {
|
||||||
|
'tasks': [{
|
||||||
|
'sync_id': str(task.sync_id),
|
||||||
|
'title': 'Changed offline',
|
||||||
|
'status': 'pending',
|
||||||
|
}],
|
||||||
|
'tags': [],
|
||||||
|
'time_entries': [],
|
||||||
|
},
|
||||||
|
}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data['conflicts']), 1)
|
||||||
|
self.assertEqual(data['conflicts'][0]['local_data']['sync_id'], str(task.sync_id))
|
||||||
|
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.title, 'Changed on server')
|
||||||
|
|
||||||
|
def test_resolve_conflict_local_applies_local_data(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Server title')
|
||||||
|
conflict = SyncConflict.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
entity_type='task',
|
||||||
|
entity_id=task.id,
|
||||||
|
local_data={'sync_id': str(task.sync_id), 'title': 'Local title', 'status': 'pending'},
|
||||||
|
server_data={'title': 'Server title'},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/sync/conflicts/{conflict.id}/resolve/',
|
||||||
|
{'resolution': 'local'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.title, 'Local title')
|
||||||
|
conflict.refresh_from_db()
|
||||||
|
self.assertEqual(conflict.status, 'resolved_local')
|
||||||
|
|
||||||
|
def test_resolve_conflict_server_is_noop(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Server title')
|
||||||
|
conflict = SyncConflict.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
entity_type='task',
|
||||||
|
entity_id=task.id,
|
||||||
|
local_data={'sync_id': str(task.sync_id), 'title': 'Local title'},
|
||||||
|
server_data={'title': 'Server title'},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/sync/conflicts/{conflict.id}/resolve/',
|
||||||
|
{'resolution': 'server'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.title, 'Server title')
|
||||||
|
conflict.refresh_from_db()
|
||||||
|
self.assertEqual(conflict.status, 'resolved_server')
|
||||||
|
|
||||||
|
def test_resolve_conflict_local_applies_time_entry_data(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Timed task')
|
||||||
|
start = timezone.now() - timedelta(hours=1)
|
||||||
|
entry = TimeEntry.objects.create(user=self.user, task=task, started_at=start, notes='server notes')
|
||||||
|
conflict = SyncConflict.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
entity_type='time_entry',
|
||||||
|
entity_id=entry.id,
|
||||||
|
local_data={'sync_id': str(entry.sync_id), 'notes': 'local notes'},
|
||||||
|
server_data={'notes': 'server notes'},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/sync/conflicts/{conflict.id}/resolve/',
|
||||||
|
{'resolution': 'local'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
entry.refresh_from_db()
|
||||||
|
self.assertEqual(entry.notes, 'local notes')
|
||||||
|
conflict.refresh_from_db()
|
||||||
|
self.assertEqual(conflict.status, 'resolved_local')
|
||||||
|
|
||||||
|
def test_creating_task_with_due_date_and_time_does_not_crash(self):
|
||||||
|
"""
|
||||||
|
Sync payloads carry due_date/due_time as JSON strings. Regression
|
||||||
|
test: these must end up as real date/time objects on the created
|
||||||
|
Task, not raw strings left for Task.reschedule_reminders() (called
|
||||||
|
from save()) to choke on.
|
||||||
|
"""
|
||||||
|
new_sync_id = str(uuid.uuid4())
|
||||||
|
response = self.client.post('/api/sync/', {
|
||||||
|
'device_id': 'web-test-device',
|
||||||
|
'last_sync_token': None,
|
||||||
|
'changes': {
|
||||||
|
'tasks': [{
|
||||||
|
'sync_id': new_sync_id,
|
||||||
|
'title': 'Due-dated task',
|
||||||
|
'status': 'pending',
|
||||||
|
'priority': 'medium',
|
||||||
|
'due_date': '2026-09-10',
|
||||||
|
'due_time': '17:00:00',
|
||||||
|
}],
|
||||||
|
'tags': [],
|
||||||
|
'time_entries': [],
|
||||||
|
},
|
||||||
|
}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task = Task.objects.get(sync_id=new_sync_id)
|
||||||
|
self.assertEqual(task.due_date.isoformat(), '2026-09-10')
|
||||||
|
self.assertEqual(task.due_time.isoformat(), '17:00:00')
|
||||||
|
|
||||||
|
def test_updating_task_due_date_and_time_does_not_crash(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Task to update')
|
||||||
|
|
||||||
|
response = self.client.post('/api/sync/', {
|
||||||
|
'device_id': 'web-test-device',
|
||||||
|
'last_sync_token': None,
|
||||||
|
'changes': {
|
||||||
|
'tasks': [{
|
||||||
|
'sync_id': str(task.sync_id),
|
||||||
|
'due_date': '2026-09-10',
|
||||||
|
'due_time': '17:00:00',
|
||||||
|
}],
|
||||||
|
'tags': [],
|
||||||
|
'time_entries': [],
|
||||||
|
},
|
||||||
|
}, format='json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.due_date.isoformat(), '2026-09-10')
|
||||||
|
self.assertEqual(task.due_time.isoformat(), '17:00:00')
|
||||||
|
|||||||
+33
-7
@@ -8,7 +8,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.dateparse import parse_datetime
|
from django.utils.dateparse import parse_date, parse_datetime, parse_time
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -289,6 +289,14 @@ def process_time_entry_changes(user, entry_changes, last_sync_at):
|
|||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def _parsed(data, key, default, parser):
|
||||||
|
"""Get data[key], parsing it if it's still a raw string (e.g. from JSON), else default if absent."""
|
||||||
|
if key not in data:
|
||||||
|
return default
|
||||||
|
value = data[key]
|
||||||
|
return parser(value) if isinstance(value, str) else value
|
||||||
|
|
||||||
|
|
||||||
def update_task_from_data(task, data):
|
def update_task_from_data(task, data):
|
||||||
"""Update a task from sync data."""
|
"""Update a task from sync data."""
|
||||||
# Track old status to detect completion
|
# Track old status to detect completion
|
||||||
@@ -298,9 +306,9 @@ def update_task_from_data(task, data):
|
|||||||
task.description = data.get('description', task.description)
|
task.description = data.get('description', task.description)
|
||||||
task.status = data.get('status', task.status)
|
task.status = data.get('status', task.status)
|
||||||
task.priority = data.get('priority', task.priority)
|
task.priority = data.get('priority', task.priority)
|
||||||
task.due_date = data.get('due_date', task.due_date)
|
task.due_date = _parsed(data, 'due_date', task.due_date, parse_date)
|
||||||
task.due_time = data.get('due_time', task.due_time)
|
task.due_time = _parsed(data, 'due_time', task.due_time, parse_time)
|
||||||
task.reminder_at = data.get('reminder_at', task.reminder_at)
|
task.reminder_at = _parsed(data, 'reminder_at', task.reminder_at, parse_datetime)
|
||||||
task.recurrence = data.get('recurrence', task.recurrence)
|
task.recurrence = data.get('recurrence', task.recurrence)
|
||||||
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
|
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
|
||||||
task.sort_order = data.get('sort_order', task.sort_order)
|
task.sort_order = data.get('sort_order', task.sort_order)
|
||||||
@@ -341,9 +349,9 @@ def create_task_from_data(user, data):
|
|||||||
description=data.get('description', ''),
|
description=data.get('description', ''),
|
||||||
status=data.get('status', 'pending'),
|
status=data.get('status', 'pending'),
|
||||||
priority=data.get('priority', 'medium'),
|
priority=data.get('priority', 'medium'),
|
||||||
due_date=data.get('due_date'),
|
due_date=_parsed(data, 'due_date', None, parse_date),
|
||||||
due_time=data.get('due_time'),
|
due_time=_parsed(data, 'due_time', None, parse_time),
|
||||||
reminder_at=data.get('reminder_at'),
|
reminder_at=_parsed(data, 'reminder_at', None, parse_datetime),
|
||||||
recurrence=data.get('recurrence', 'none'),
|
recurrence=data.get('recurrence', 'none'),
|
||||||
recurrence_rule=data.get('recurrence_rule', ''),
|
recurrence_rule=data.get('recurrence_rule', ''),
|
||||||
sort_order=data.get('sort_order', 0),
|
sort_order=data.get('sort_order', 0),
|
||||||
@@ -474,3 +482,21 @@ def apply_conflict_data(entity_type, entity_id, data):
|
|||||||
tag.save()
|
tag.save()
|
||||||
except Tag.DoesNotExist:
|
except Tag.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
elif entity_type == 'time_entry':
|
||||||
|
try:
|
||||||
|
entry = TimeEntry.objects.get(id=entity_id)
|
||||||
|
|
||||||
|
started_at = data.get('started_at', entry.started_at)
|
||||||
|
if isinstance(started_at, str):
|
||||||
|
started_at = parse_datetime(started_at)
|
||||||
|
|
||||||
|
ended_at = data.get('ended_at', entry.ended_at)
|
||||||
|
if ended_at and isinstance(ended_at, str):
|
||||||
|
ended_at = parse_datetime(ended_at)
|
||||||
|
|
||||||
|
entry.started_at = started_at
|
||||||
|
entry.ended_at = ended_at
|
||||||
|
entry.notes = data.get('notes', entry.notes)
|
||||||
|
entry.save()
|
||||||
|
except TimeEntry.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|||||||
+170
-4
@@ -136,9 +136,18 @@ class Task(models.Model):
|
|||||||
db_table = 'tasks'
|
db_table = 'tasks'
|
||||||
ordering = ['sort_order', '-priority', 'due_date', 'created_at']
|
ordering = ['sort_order', '-priority', 'due_date', 'created_at']
|
||||||
|
|
||||||
|
# Fields that affect when/whether reminder notifications should fire.
|
||||||
|
REMINDER_TRACKED_FIELDS = ('due_date', 'due_time', 'reminder_at', 'status', 'is_deleted')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db(cls, db, field_names, values):
|
||||||
|
instance = super().from_db(db, field_names, values)
|
||||||
|
instance._loaded_values = dict(zip(field_names, values))
|
||||||
|
return instance
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Auto-set completed_at when status changes to completed
|
# Auto-set completed_at when status changes to completed
|
||||||
if self.status == 'completed' and self.completed_at is None:
|
if self.status == 'completed' and self.completed_at is None:
|
||||||
@@ -146,14 +155,104 @@ class Task(models.Model):
|
|||||||
self.completed_at = timezone.now()
|
self.completed_at = timezone.now()
|
||||||
elif self.status != 'completed':
|
elif self.status != 'completed':
|
||||||
self.completed_at = None
|
self.completed_at = None
|
||||||
|
|
||||||
|
is_new = self._state.adding
|
||||||
|
loaded_values = getattr(self, '_loaded_values', None)
|
||||||
|
reminder_fields_changed = is_new or loaded_values is None or any(
|
||||||
|
loaded_values.get(field) != getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
self._loaded_values = {field: getattr(self, field) for field in self.REMINDER_TRACKED_FIELDS}
|
||||||
|
|
||||||
|
if reminder_fields_changed:
|
||||||
|
self.reschedule_reminders()
|
||||||
|
|
||||||
|
def _due_and_overdue_moments(self):
|
||||||
|
"""
|
||||||
|
Returns (due_moment, overdue_moment) as timezone-aware datetimes in
|
||||||
|
the owner's timezone, or (None, None) if there's nothing to schedule
|
||||||
|
against. Mirrors is_overdue's rule that a date-only due task doesn't
|
||||||
|
flip overdue until the day after due_date.
|
||||||
|
"""
|
||||||
|
if not self.due_date:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
from datetime import datetime, time as dt_time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_tz = ZoneInfo(self.user.timezone)
|
||||||
|
except Exception:
|
||||||
|
user_tz = ZoneInfo('UTC')
|
||||||
|
|
||||||
|
if self.due_time:
|
||||||
|
due_moment = datetime.combine(self.due_date, self.due_time, tzinfo=user_tz)
|
||||||
|
# Give "overdue" some breathing room after "due now" instead of
|
||||||
|
# firing both notifications at the exact same moment.
|
||||||
|
return due_moment, due_moment + timedelta(hours=1)
|
||||||
|
|
||||||
|
overdue_moment = datetime.combine(self.due_date + timedelta(days=1), dt_time.min, tzinfo=user_tz)
|
||||||
|
return None, overdue_moment
|
||||||
|
|
||||||
|
def reschedule_reminders(self):
|
||||||
|
"""
|
||||||
|
Recompute this task's ScheduledReminder rows from its current
|
||||||
|
due_date/due_time/reminder_at/status. Safe to call any time due
|
||||||
|
info changes - clears out not-yet-sent reminders and, if the task
|
||||||
|
is still active and due, schedules fresh ones for "before due"
|
||||||
|
(from reminder_at, or user.default_reminder_minutes before due),
|
||||||
|
"due now", and "overdue".
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from notifications.models import ScheduledReminder
|
||||||
|
|
||||||
|
ScheduledReminder.objects.filter(task=self, is_sent=False).delete()
|
||||||
|
|
||||||
|
if self.is_deleted or self.status in ('completed', 'cancelled'):
|
||||||
|
return
|
||||||
|
|
||||||
|
due_moment, overdue_moment = self._due_and_overdue_moments()
|
||||||
|
reminders = []
|
||||||
|
|
||||||
|
if due_moment:
|
||||||
|
if self.reminder_at:
|
||||||
|
before_due_moment = self.reminder_at
|
||||||
|
elif self.user.default_reminder_minutes:
|
||||||
|
before_due_moment = due_moment - timedelta(minutes=self.user.default_reminder_minutes)
|
||||||
|
else:
|
||||||
|
before_due_moment = None
|
||||||
|
|
||||||
|
if before_due_moment:
|
||||||
|
reminders.append(ScheduledReminder(task=self, reminder_type='reminder', remind_at=before_due_moment))
|
||||||
|
reminders.append(ScheduledReminder(task=self, reminder_type='due_soon', remind_at=due_moment))
|
||||||
|
|
||||||
|
if overdue_moment:
|
||||||
|
reminders.append(ScheduledReminder(task=self, reminder_type='overdue', remind_at=overdue_moment))
|
||||||
|
|
||||||
|
if reminders:
|
||||||
|
ScheduledReminder.objects.bulk_create(reminders)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_overdue(self):
|
def is_overdue(self):
|
||||||
"""Check if task is overdue."""
|
"""Check if task is overdue."""
|
||||||
if self.due_date and self.status not in ('completed', 'cancelled'):
|
if self.due_date and self.status not in ('completed', 'cancelled'):
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
return self.due_date < timezone.now().date()
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# Get current date/time in user's timezone
|
||||||
|
try:
|
||||||
|
user_tz = ZoneInfo(self.user.timezone)
|
||||||
|
user_now = timezone.now().astimezone(user_tz)
|
||||||
|
except (Exception,):
|
||||||
|
# Fall back to UTC if user timezone is invalid or missing
|
||||||
|
user_now = timezone.now()
|
||||||
|
|
||||||
|
if self.due_date < user_now.date():
|
||||||
|
return True
|
||||||
|
if self.due_date == user_now.date() and self.due_time:
|
||||||
|
return self.due_time < user_now.time()
|
||||||
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -202,9 +301,17 @@ class Task(models.Model):
|
|||||||
elif self.recurrence == 'yearly':
|
elif self.recurrence == 'yearly':
|
||||||
next_date = base_date + relativedelta(years=1)
|
next_date = base_date + relativedelta(years=1)
|
||||||
elif self.recurrence == 'custom' and self.recurrence_rule:
|
elif self.recurrence == 'custom' and self.recurrence_rule:
|
||||||
# TODO: Implement RRULE parsing for custom recurrence
|
from dateutil.rrule import rrulestr
|
||||||
# For now, default to weekly
|
from datetime import datetime, time as dt_time
|
||||||
next_date = base_date + timedelta(weeks=1)
|
|
||||||
|
dtstart = datetime.combine(base_date, dt_time.min)
|
||||||
|
try:
|
||||||
|
next_occurrence = rrulestr(self.recurrence_rule, dtstart=dtstart).after(dtstart, inc=False)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not next_occurrence:
|
||||||
|
return None
|
||||||
|
next_date = next_occurrence.date()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -214,6 +321,65 @@ class Task(models.Model):
|
|||||||
|
|
||||||
return next_date
|
return next_date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parsed_custom_recurrence(self):
|
||||||
|
"""
|
||||||
|
Decompose recurrence_rule (an RRULE string) into simple fields for
|
||||||
|
prepopulating the custom recurrence builder UI. Never raises; returns
|
||||||
|
safe defaults for a blank or malformed rule.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'freq': None,
|
||||||
|
'interval': 1,
|
||||||
|
'byweekday': [],
|
||||||
|
'monthly_mode': None,
|
||||||
|
'bymonthday': None,
|
||||||
|
'nth_ordinal': None,
|
||||||
|
'nth_weekday': None,
|
||||||
|
}
|
||||||
|
if not self.recurrence_rule:
|
||||||
|
return result
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
for part in self.recurrence_rule.split(';'):
|
||||||
|
if '=' in part:
|
||||||
|
key, value = part.split('=', 1)
|
||||||
|
params[key.strip().upper()] = value.strip()
|
||||||
|
|
||||||
|
freq = params.get('FREQ', '').upper()
|
||||||
|
if freq not in ('WEEKLY', 'MONTHLY'):
|
||||||
|
return result
|
||||||
|
result['freq'] = freq.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result['interval'] = int(params.get('INTERVAL', '1'))
|
||||||
|
except ValueError:
|
||||||
|
result['interval'] = 1
|
||||||
|
|
||||||
|
byday = params.get('BYDAY', '')
|
||||||
|
|
||||||
|
if freq == 'WEEKLY':
|
||||||
|
if byday:
|
||||||
|
result['byweekday'] = [d.strip() for d in byday.split(',') if d.strip()]
|
||||||
|
elif freq == 'MONTHLY':
|
||||||
|
bymonthday = params.get('BYMONTHDAY')
|
||||||
|
if bymonthday:
|
||||||
|
try:
|
||||||
|
result['bymonthday'] = int(bymonthday)
|
||||||
|
result['monthly_mode'] = 'day'
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif byday:
|
||||||
|
match = re.match(r'^(-?\d+)([A-Z]{2})$', byday)
|
||||||
|
if match:
|
||||||
|
result['nth_ordinal'] = int(match.group(1))
|
||||||
|
result['nth_weekday'] = match.group(2)
|
||||||
|
result['monthly_mode'] = 'nth'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def create_next_recurrence(self):
|
def create_next_recurrence(self):
|
||||||
"""
|
"""
|
||||||
Create the next instance of this recurring task.
|
Create the next instance of this recurring task.
|
||||||
|
|||||||
+178
-2
@@ -1,3 +1,179 @@
|
|||||||
from django.test import TestCase
|
from datetime import date, datetime, time as dt_time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone as django_timezone
|
||||||
|
|
||||||
|
from tasks.models import Task
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRecurrenceTests(TestCase):
|
||||||
|
"""Tests for Task.calculate_next_due_date() with custom RRULE patterns."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
email='testuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_task(self, **kwargs):
|
||||||
|
defaults = {
|
||||||
|
'user': self.user,
|
||||||
|
'title': 'Test task',
|
||||||
|
'recurrence': 'custom',
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Task.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_every_wednesday(self):
|
||||||
|
# 2026-01-07 is a Wednesday
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 14))
|
||||||
|
|
||||||
|
def test_every_other_wednesday(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 21))
|
||||||
|
|
||||||
|
def test_every_second_tuesday(self):
|
||||||
|
# 2026-01-13 is the second Tuesday of January 2026
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 13), recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 10))
|
||||||
|
|
||||||
|
def test_every_15th(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 15))
|
||||||
|
|
||||||
|
def test_respects_recurrence_end_date(self):
|
||||||
|
task = self.make_task(
|
||||||
|
due_date=date(2026, 1, 7),
|
||||||
|
recurrence_rule='FREQ=WEEKLY;BYDAY=WE',
|
||||||
|
recurrence_end_date=date(2026, 1, 10),
|
||||||
|
)
|
||||||
|
self.assertIsNone(task.calculate_next_due_date())
|
||||||
|
|
||||||
|
def test_malformed_rule_returns_none(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='not a valid rrule')
|
||||||
|
self.assertIsNone(task.calculate_next_due_date())
|
||||||
|
|
||||||
|
def test_create_next_recurrence(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
new_task = task.create_next_recurrence()
|
||||||
|
self.assertIsNotNone(new_task)
|
||||||
|
self.assertEqual(new_task.due_date, date(2026, 2, 15))
|
||||||
|
self.assertEqual(new_task.recurrence_rule, 'FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
self.assertEqual(new_task.status, 'pending')
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_weekly(self):
|
||||||
|
task = self.make_task(recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertEqual(parsed['freq'], 'weekly')
|
||||||
|
self.assertEqual(parsed['interval'], 2)
|
||||||
|
self.assertEqual(parsed['byweekday'], ['WE'])
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_monthly_nth(self):
|
||||||
|
task = self.make_task(recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertEqual(parsed['freq'], 'monthly')
|
||||||
|
self.assertEqual(parsed['monthly_mode'], 'nth')
|
||||||
|
self.assertEqual(parsed['nth_ordinal'], 2)
|
||||||
|
self.assertEqual(parsed['nth_weekday'], 'TU')
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_blank(self):
|
||||||
|
task = self.make_task(recurrence_rule='')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertIsNone(parsed['freq'])
|
||||||
|
self.assertEqual(parsed['byweekday'], [])
|
||||||
|
|
||||||
|
|
||||||
|
class IsOverdueTests(TestCase):
|
||||||
|
"""Tests for Task.is_overdue, which must account for due_time, not just due_date (Gitea #7)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='overdueuser',
|
||||||
|
email='overdueuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_task(self, **kwargs):
|
||||||
|
defaults = {'user': self.user, 'title': 'Test task', 'status': 'pending'}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Task.objects.create(**defaults)
|
||||||
|
|
||||||
|
@patch('django.utils.timezone.now')
|
||||||
|
def test_not_overdue_before_due_time_same_day(self, mock_now):
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0))
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0))
|
||||||
|
self.assertFalse(task.is_overdue)
|
||||||
|
|
||||||
|
@patch('django.utils.timezone.now')
|
||||||
|
def test_overdue_after_due_time_same_day(self, mock_now):
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 18, 0, 0))
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0))
|
||||||
|
self.assertTrue(task.is_overdue)
|
||||||
|
|
||||||
|
@patch('django.utils.timezone.now')
|
||||||
|
def test_not_overdue_same_day_without_due_time(self, mock_now):
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 59, 0))
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), due_time=None)
|
||||||
|
self.assertFalse(task.is_overdue)
|
||||||
|
|
||||||
|
@patch('django.utils.timezone.now')
|
||||||
|
def test_overdue_once_date_has_passed(self, mock_now):
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 16, 0, 1, 0))
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(23, 0, 0))
|
||||||
|
self.assertTrue(task.is_overdue)
|
||||||
|
|
||||||
|
@patch('django.utils.timezone.now')
|
||||||
|
def test_completed_task_never_overdue(self, mock_now):
|
||||||
|
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 0, 0))
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 1), due_time=dt_time(9, 0, 0), status='completed')
|
||||||
|
self.assertFalse(task.is_overdue)
|
||||||
|
|
||||||
|
|
||||||
|
class WebFormDueDateTypeTests(TestCase):
|
||||||
|
"""
|
||||||
|
The web views assign due_date/due_time straight from request.POST (raw
|
||||||
|
strings), relying on Django to coerce them at the DB layer - which
|
||||||
|
leaves the in-memory instance holding strings after save(). Anything
|
||||||
|
that touches those fields on that same instance afterward (e.g.
|
||||||
|
Task.reschedule_reminders(), called from save() itself) must not choke
|
||||||
|
on that. Regression test for a real production crash on task edit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='webformuser', email='webformuser@example.com', password='testpass123',
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_editing_due_date_and_time_does_not_crash(self):
|
||||||
|
task = Task.objects.create(user=self.user, title='Task')
|
||||||
|
response = self.client.post(f'/tasks/{task.id}/', {
|
||||||
|
'title': 'Task', 'status': 'pending', 'priority': 'medium',
|
||||||
|
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.due_date, date(2026, 9, 10))
|
||||||
|
self.assertEqual(task.due_time, dt_time(17, 0))
|
||||||
|
|
||||||
|
def test_creating_task_with_due_date_does_not_crash(self):
|
||||||
|
response = self.client.post('/tasks/new/', {
|
||||||
|
'title': 'New task', 'status': 'pending', 'priority': 'medium',
|
||||||
|
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
task = Task.objects.get(title='New task')
|
||||||
|
self.assertEqual(task.due_date, date(2026, 9, 10))
|
||||||
|
self.assertEqual(task.due_time, dt_time(17, 0))
|
||||||
|
|
||||||
|
def test_quick_add_task_with_due_date_does_not_crash(self):
|
||||||
|
response = self.client.post('/tasks/quick-add/', {'title': 'Quick task', 'due_date': '2026-09-10'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
task = Task.objects.get(title='Quick task')
|
||||||
|
self.assertEqual(task.due_date, date(2026, 9, 10))
|
||||||
|
|||||||
+44
-15
@@ -7,6 +7,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_date, parse_time
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
@@ -218,13 +219,21 @@ class DashboardView(View):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect('login')
|
return redirect('login')
|
||||||
|
|
||||||
today = timezone.now().date()
|
# Get today's date in user's timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
try:
|
||||||
|
user_tz = ZoneInfo(request.user.timezone)
|
||||||
|
user_now = timezone.now().astimezone(user_tz)
|
||||||
|
today = user_now.date()
|
||||||
|
except (Exception,):
|
||||||
|
# Fall back to UTC if user timezone is invalid or missing
|
||||||
|
today = timezone.now().date()
|
||||||
|
|
||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
current_filter = request.GET.get('filter', 'all')
|
current_filter = request.GET.get('filter', 'all')
|
||||||
current_tag_id = request.GET.get('tag')
|
current_tag_id = request.GET.get('tag')
|
||||||
selected_task_id = request.GET.get('selected')
|
selected_task_id = request.GET.get('selected')
|
||||||
current_sort = request.GET.get('sort', 'default')
|
current_sort = request.GET.get('sort', 'due_date')
|
||||||
|
|
||||||
# Base query
|
# Base query
|
||||||
tasks = Task.objects.filter(
|
tasks = Task.objects.filter(
|
||||||
@@ -271,20 +280,37 @@ class DashboardView(View):
|
|||||||
|
|
||||||
# Apply sorting before converting to list
|
# Apply sorting before converting to list
|
||||||
if not isinstance(tasks, list):
|
if not isinstance(tasks, list):
|
||||||
|
# Define priority order for sorting
|
||||||
|
from django.db.models import Case, When, IntegerField, F
|
||||||
|
priority_order_case = Case(
|
||||||
|
When(priority='urgent', then=4),
|
||||||
|
When(priority='high', then=3),
|
||||||
|
When(priority='medium', then=2),
|
||||||
|
When(priority='low', then=1),
|
||||||
|
default=0,
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
if current_sort == 'due_date':
|
if current_sort == 'due_date':
|
||||||
# Sort by due date (nulls last), then priority
|
# Sort by due date (nulls last), then priority
|
||||||
from django.db.models import F
|
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
|
||||||
tasks = tasks.order_by(F('due_date').asc(nulls_last=True), '-priority')
|
F('due_date').asc(nulls_last=True), '-priority_order'
|
||||||
|
)
|
||||||
elif current_sort == 'due_date_desc':
|
elif current_sort == 'due_date_desc':
|
||||||
# Sort by due date descending (nulls last), then priority
|
# Sort by due date descending (nulls last), then priority
|
||||||
from django.db.models import F
|
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
|
||||||
tasks = tasks.order_by(F('due_date').desc(nulls_last=True), '-priority')
|
F('due_date').desc(nulls_last=True), '-priority_order'
|
||||||
|
)
|
||||||
elif current_sort == 'priority':
|
elif current_sort == 'priority':
|
||||||
# Sort by priority, then due date
|
# Sort by priority (high to low), then due date
|
||||||
tasks = tasks.order_by('-priority', 'due_date')
|
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
|
||||||
|
'-priority_order', 'due_date'
|
||||||
|
)
|
||||||
elif current_sort == 'priority_low':
|
elif current_sort == 'priority_low':
|
||||||
# Sort by priority (low to high), then due date
|
# Sort by priority (low to high), then due date
|
||||||
tasks = tasks.order_by('priority', 'due_date')
|
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
|
||||||
|
'priority_order', 'due_date'
|
||||||
|
)
|
||||||
# else: default ordering from model (sort_order, -priority, due_date, created_at)
|
# else: default ordering from model (sort_order, -priority, due_date, created_at)
|
||||||
|
|
||||||
# Convert to list if not already (for overdue filter)
|
# Convert to list if not already (for overdue filter)
|
||||||
@@ -293,7 +319,7 @@ class DashboardView(View):
|
|||||||
|
|
||||||
# Apply sorting to lists (for overdue filter case)
|
# Apply sorting to lists (for overdue filter case)
|
||||||
else:
|
else:
|
||||||
priority_order = {'high': 3, 'medium': 2, 'low': 1}
|
priority_order = {'urgent': 4, 'high': 3, 'medium': 2, 'low': 1}
|
||||||
if current_sort == 'due_date':
|
if current_sort == 'due_date':
|
||||||
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)))
|
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)))
|
||||||
elif current_sort == 'due_date_desc':
|
elif current_sort == 'due_date_desc':
|
||||||
@@ -441,10 +467,10 @@ class TaskDetailView(View):
|
|||||||
task.priority = priority
|
task.priority = priority
|
||||||
|
|
||||||
due_date = request.POST.get('due_date')
|
due_date = request.POST.get('due_date')
|
||||||
task.due_date = due_date if due_date else None
|
task.due_date = parse_date(due_date) if due_date else None
|
||||||
|
|
||||||
due_time = request.POST.get('due_time')
|
due_time = request.POST.get('due_time')
|
||||||
task.due_time = due_time if due_time else None
|
task.due_time = parse_time(due_time) if due_time else None
|
||||||
|
|
||||||
# Validate recurrence against allowed choices
|
# Validate recurrence against allowed choices
|
||||||
recurrence = request.POST.get('recurrence', 'none')
|
recurrence = request.POST.get('recurrence', 'none')
|
||||||
@@ -454,6 +480,8 @@ class TaskDetailView(View):
|
|||||||
else:
|
else:
|
||||||
task.recurrence = 'none'
|
task.recurrence = 'none'
|
||||||
|
|
||||||
|
task.recurrence_rule = request.POST.get('recurrence_rule', '') if task.recurrence == 'custom' else ''
|
||||||
|
|
||||||
# Create next recurrence if task is being marked as completed
|
# Create next recurrence if task is being marked as completed
|
||||||
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
||||||
task.create_next_recurrence()
|
task.create_next_recurrence()
|
||||||
@@ -513,9 +541,10 @@ class TaskCreateView(View):
|
|||||||
description=request.POST.get('description', ''),
|
description=request.POST.get('description', ''),
|
||||||
status=status,
|
status=status,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
due_date=request.POST.get('due_date') or None,
|
due_date=parse_date(request.POST.get('due_date')) if request.POST.get('due_date') else None,
|
||||||
due_time=request.POST.get('due_time') or None,
|
due_time=parse_time(request.POST.get('due_time')) if request.POST.get('due_time') else None,
|
||||||
recurrence=recurrence,
|
recurrence=recurrence,
|
||||||
|
recurrence_rule=request.POST.get('recurrence_rule', '') if recurrence == 'custom' else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
tag_ids = request.POST.getlist('tags')
|
tag_ids = request.POST.getlist('tags')
|
||||||
@@ -531,7 +560,7 @@ def task_quick_add(request):
|
|||||||
task = Task.objects.create(
|
task = Task.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
title=request.POST.get('title'),
|
title=request.POST.get('title'),
|
||||||
due_date=request.POST.get('due_date') or None,
|
due_date=parse_date(request.POST.get('due_date')) if request.POST.get('due_date') else None,
|
||||||
)
|
)
|
||||||
# Handle optional tag
|
# Handle optional tag
|
||||||
tag_id = request.POST.get('tag')
|
tag_id = request.POST.get('tag')
|
||||||
|
|||||||
+10
-1
@@ -9,6 +9,13 @@
|
|||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
|
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
|
||||||
<link rel="alternate icon" href="{% static 'favicons/favicon.svg' %}" type="image/svg+xml">
|
<link rel="alternate icon" href="{% static 'favicons/favicon.svg' %}" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="{% static 'favicons/apple-touch-icon-180.png' %}">
|
||||||
|
|
||||||
|
<!-- PWA -->
|
||||||
|
<link rel="manifest" href="{% url 'manifest' %}">
|
||||||
|
<meta name="theme-color" content="#3B82F6">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'css/app.css' %}?v=6">
|
<link rel="stylesheet" href="{% static 'css/app.css' %}?v=6">
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
@@ -140,7 +147,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<script src="{% static 'js/app.js' %}?v=3"></script>
|
<script src="{% static 'js/offline-db.js' %}?v=7"></script>
|
||||||
|
<script src="{% static 'js/offline-sync.js' %}?v=10"></script>
|
||||||
|
<script src="{% static 'js/app.js' %}?v=9"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% load static %}{
|
||||||
|
"name": "KeepItGoing",
|
||||||
|
"short_name": "KeepItGoing",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#3B82F6",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "{% static 'favicons/icon-192.png' %}",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "{% static 'favicons/icon-512.png' %}",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "{% static 'favicons/icon-512-maskable.png' %}",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}You're Offline - KeepItGoing{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You're offline</p>
|
||||||
|
<p class="text-muted">This page isn't available without a connection. Reconnect and try again.</p>
|
||||||
|
<button class="btn btn-primary" onclick="window.location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block auth_content %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>You're offline</p>
|
||||||
|
<p class="text-muted">This page isn't available without a connection. Reconnect and try again.</p>
|
||||||
|
<button class="btn btn-primary" onclick="window.location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
{% load static %}<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - KeepItGoing</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{% static 'favicons/favicon.svg' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'css/app.css' %}">
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (saved === 'dark' || (!saved && systemDark)) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout detail-closed" id="app">
|
||||||
|
<!-- Header (offline-tinted so it's unmistakable which mode you're in) -->
|
||||||
|
<header class="app-header" style="background-color: #d97706; color: #fff; border-bottom-color: #b45309;">
|
||||||
|
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||||
|
<button class="sidebar-toggle" onclick="toggleOfflineSidebar()" aria-label="Toggle sidebar" style="color: #fff;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="app-brand" style="color: #fff;">KeepItGoing</span>
|
||||||
|
<span style="font-size: var(--font-sm); font-weight: 600;">⚠ Offline — changes sync automatically once you're back online</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-actions">
|
||||||
|
<button class="theme-toggle" onclick="toggleOfflineTheme()" aria-label="Toggle theme" title="Toggle dark/light mode" style="color: #fff;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="app-sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">Filters</div>
|
||||||
|
<nav class="sidebar-nav" id="offline-filter-nav">
|
||||||
|
<a href="#" class="sidebar-item active" data-filter="all">
|
||||||
|
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||||
|
</svg>
|
||||||
|
All Tasks
|
||||||
|
</a>
|
||||||
|
<a href="#" class="sidebar-item" data-filter="today">
|
||||||
|
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 6v6l4 2"/>
|
||||||
|
</svg>
|
||||||
|
Today
|
||||||
|
</a>
|
||||||
|
<a href="#" class="sidebar-item" data-filter="upcoming">
|
||||||
|
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18"/>
|
||||||
|
</svg>
|
||||||
|
Upcoming
|
||||||
|
</a>
|
||||||
|
<a href="#" class="sidebar-item" data-filter="overdue">
|
||||||
|
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 8v4M12 16h.01"/>
|
||||||
|
</svg>
|
||||||
|
Overdue
|
||||||
|
</a>
|
||||||
|
<a href="#" class="sidebar-item" data-filter="completed">
|
||||||
|
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<path d="M22 4L12 14.01l-3-3"/>
|
||||||
|
</svg>
|
||||||
|
Completed
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title">Tags</div>
|
||||||
|
<nav class="sidebar-nav" id="offline-tag-nav"></nav>
|
||||||
|
<button type="button" class="sidebar-add-btn" id="offline-add-tag-btn">
|
||||||
|
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 5v14M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Add Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Task Pane -->
|
||||||
|
<main class="task-pane">
|
||||||
|
<div class="task-pane-header">
|
||||||
|
<h1 class="task-pane-title" id="offline-page-title">All Tasks</h1>
|
||||||
|
<select id="offline-sort-select" class="form-select" style="width: auto;">
|
||||||
|
<option value="due_date">Due Date (Earliest)</option>
|
||||||
|
<option value="due_date_desc">Due Date (Latest)</option>
|
||||||
|
<option value="priority">Priority (High to Low)</option>
|
||||||
|
<option value="priority_low">Priority (Low to High)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="offline-add-task-form" class="quick-add">
|
||||||
|
<input type="text" id="offline-task-title" class="quick-add-input" placeholder="Add a new task..." required>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="offline-empty-state" class="empty-state" hidden>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="task-list" id="offline-task-list"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Detail Panel -->
|
||||||
|
<aside class="detail-pane hidden" id="detail-pane">
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Select a task to view details</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<div class="mobile-overlay" id="mobile-overlay" onclick="closeOfflineMobileMenus()"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag Edit/Create Modal (outside app-layout for proper fixed positioning) -->
|
||||||
|
<div class="modal-backdrop" id="tag-modal-backdrop">
|
||||||
|
<div class="modal" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title" id="tag-modal-title">New Tag</h2>
|
||||||
|
<button type="button" class="modal-close" id="tag-modal-close-btn">
|
||||||
|
<svg style="width: 20px; height: 20px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="tag-edit-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="tag-modal-name">Name</label>
|
||||||
|
<input type="text" class="form-input" id="tag-modal-name" required placeholder="Tag name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Color</label>
|
||||||
|
<div class="color-presets" id="tag-modal-color-presets"></div>
|
||||||
|
<div class="color-custom-row">
|
||||||
|
<label class="color-custom-label">Custom:</label>
|
||||||
|
<input type="color" id="tag-modal-color" class="color-custom-input" value="#3b82f6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="tag-modal-cancel-btn">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="tag-modal-delete-btn" style="display: none;">Delete</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="tag-modal-submit-btn">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/offline-db.js' %}"></script>
|
||||||
|
<script src="{% static 'js/offline-tasks.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{% load static %}const CACHE_NAME = 'keepitgoing-shell-v10';
|
||||||
|
const OFFLINE_URL = '{% url "offline" %}';
|
||||||
|
const OFFLINE_TASKS_URL = '{% url "offline-tasks" %}';
|
||||||
|
|
||||||
|
const PRECACHE_URLS = [
|
||||||
|
"{% static 'css/app.css' %}",
|
||||||
|
"{% static 'js/app.js' %}",
|
||||||
|
"{% static 'js/offline-db.js' %}",
|
||||||
|
"{% static 'js/offline-tasks.js' %}",
|
||||||
|
"{% static 'favicons/favicon.svg' %}",
|
||||||
|
"{% static 'favicons/icon-192.png' %}",
|
||||||
|
"{% static 'favicons/icon-512.png' %}",
|
||||||
|
"{% static 'favicons/icon-512-maskable.png' %}",
|
||||||
|
"{% static 'favicons/apple-touch-icon-180.png' %}",
|
||||||
|
OFFLINE_URL,
|
||||||
|
OFFLINE_TASKS_URL,
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
|
// cache.addAll() lets the browser's own HTTP cache satisfy each
|
||||||
|
// fetch - static assets here have no Cache-Control/ETag (only
|
||||||
|
// Last-Modified), so a heuristically-cached stale response can
|
||||||
|
// silently win even on a CACHE_NAME bump. { cache: 'reload' }
|
||||||
|
// forces a real network round-trip so precache always reflects
|
||||||
|
// what the server currently serves.
|
||||||
|
Promise.all(
|
||||||
|
PRECACHE_URLS.map((url) =>
|
||||||
|
fetch(url, { cache: 'reload' }).then((response) => cache.put(url, response))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
|
||||||
|
// Navigations: try the network first, fall back to the offline page.
|
||||||
|
// The dashboard route falls back to the offline-capable task app instead
|
||||||
|
// of the generic offline page, since it can still show/edit cached tasks.
|
||||||
|
// A 5xx response (e.g. a reverse proxy up-front returning 502/503 while
|
||||||
|
// the app server itself is down) is treated the same as a network
|
||||||
|
// failure - fetch() only rejects on true network errors, not bad status
|
||||||
|
// codes, so that has to be checked explicitly.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).then((response) => {
|
||||||
|
if (response.status >= 500) {
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => {
|
||||||
|
const path = new URL(request.url).pathname;
|
||||||
|
return caches.match(path === '/' ? OFFLINE_TASKS_URL : OFFLINE_URL);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precached shell assets: serve from cache first.
|
||||||
|
const path = new URL(request.url).pathname;
|
||||||
|
if (PRECACHE_URLS.includes(path)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => cached || fetch(request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const data = event.data ? event.data.json() : {};
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'KeepItGoing', {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '{% static "favicons/icon-192.png" %}',
|
||||||
|
badge: '{% static "favicons/icon-192.png" %}',
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(clients.openWindow(event.notification.data.url || '/'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!-- Recurrence -->
|
||||||
|
{% with parsed=task.parsed_custom_recurrence %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="{{ id_prefix }}recurrence">Recurrence</label>
|
||||||
|
<select class="form-select" id="{{ id_prefix }}recurrence" name="recurrence" onchange="toggleCustomRecurrenceBuilder(this)">
|
||||||
|
<option value="none" {% if not task or task.recurrence == 'none' %}selected{% endif %}>None</option>
|
||||||
|
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
||||||
|
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
||||||
|
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
||||||
|
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
||||||
|
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
||||||
|
<option value="custom" {% if task.recurrence == 'custom' %}selected{% endif %}>Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="recurrence_rule" value="{{ task.recurrence_rule|default:'' }}">
|
||||||
|
|
||||||
|
<div class="custom-recurrence-builder form-group {% if not task or task.recurrence != 'custom' %}hidden{% endif %}" style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
|
||||||
|
<input type="radio" class="custom-freq-mode" value="weekly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq != 'monthly' %}checked{% endif %}>
|
||||||
|
Weekly
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="radio" class="custom-freq-mode" value="monthly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq == 'monthly' %}checked{% endif %}>
|
||||||
|
Monthly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly panel -->
|
||||||
|
<div class="custom-weekly-panel {% if parsed.freq == 'monthly' %}hidden{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Every
|
||||||
|
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
|
||||||
|
week(s) on:
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="SU" {% if "SU" in parsed.byweekday %}checked{% endif %}> Su
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="MO" {% if "MO" in parsed.byweekday %}checked{% endif %}> Mo
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="TU" {% if "TU" in parsed.byweekday %}checked{% endif %}> Tu
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="WE" {% if "WE" in parsed.byweekday %}checked{% endif %}> We
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="TH" {% if "TH" in parsed.byweekday %}checked{% endif %}> Th
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="FR" {% if "FR" in parsed.byweekday %}checked{% endif %}> Fr
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="SA" {% if "SA" in parsed.byweekday %}checked{% endif %}> Sa
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly panel -->
|
||||||
|
<div class="custom-monthly-panel {% if parsed.freq != 'monthly' %}hidden{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Every
|
||||||
|
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
|
||||||
|
month(s):
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
|
||||||
|
<input type="radio" class="custom-monthly-mode" value="day" {% if parsed.monthly_mode != 'nth' %}checked{% endif %}>
|
||||||
|
On day
|
||||||
|
<input type="number" class="custom-bymonthday" min="1" max="31" value="{{ parsed.bymonthday|default:1 }}" style="width: 60px;">
|
||||||
|
of the month
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="radio" class="custom-monthly-mode" value="nth" {% if parsed.monthly_mode == 'nth' %}checked{% endif %}>
|
||||||
|
On the
|
||||||
|
<select class="custom-nth-ordinal form-select" style="width: auto;">
|
||||||
|
<option value="1" {% if parsed.nth_ordinal == 1 %}selected{% endif %}>First</option>
|
||||||
|
<option value="2" {% if parsed.nth_ordinal == 2 %}selected{% endif %}>Second</option>
|
||||||
|
<option value="3" {% if parsed.nth_ordinal == 3 %}selected{% endif %}>Third</option>
|
||||||
|
<option value="4" {% if parsed.nth_ordinal == 4 %}selected{% endif %}>Fourth</option>
|
||||||
|
<option value="-1" {% if parsed.nth_ordinal == -1 %}selected{% endif %}>Last</option>
|
||||||
|
</select>
|
||||||
|
<select class="custom-nth-weekday form-select" style="width: auto;">
|
||||||
|
<option value="SU" {% if parsed.nth_weekday == "SU" %}selected{% endif %}>Sunday</option>
|
||||||
|
<option value="MO" {% if parsed.nth_weekday == "MO" %}selected{% endif %}>Monday</option>
|
||||||
|
<option value="TU" {% if parsed.nth_weekday == "TU" %}selected{% endif %}>Tuesday</option>
|
||||||
|
<option value="WE" {% if parsed.nth_weekday == "WE" %}selected{% endif %}>Wednesday</option>
|
||||||
|
<option value="TH" {% if parsed.nth_weekday == "TH" %}selected{% endif %}>Thursday</option>
|
||||||
|
<option value="FR" {% if parsed.nth_weekday == "FR" %}selected{% endif %}>Friday</option>
|
||||||
|
<option value="SA" {% if parsed.nth_weekday == "SA" %}selected{% endif %}>Saturday</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
|
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
|
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
|
||||||
|
|
||||||
@@ -75,18 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recurrence -->
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='detail-' task=task %}
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="detail-recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="detail-recurrence" name="recurrence">
|
|
||||||
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
|
|
||||||
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
|
||||||
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
|
||||||
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
|
||||||
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
|
||||||
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Button (moved inside form, before subtasks) -->
|
<!-- Save Button (moved inside form, before subtasks) -->
|
||||||
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
|
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||||
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
|
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
|
||||||
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
|
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
|
||||||
<option value="default" {% if current_sort == 'default' %}selected{% endif %}>Default</option>
|
|
||||||
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
|
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
|
||||||
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
|
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
|
||||||
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
|
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
|
||||||
@@ -76,7 +75,7 @@ if (timerDisplay) {
|
|||||||
function updateSort() {
|
function updateSort() {
|
||||||
const sortValue = document.getElementById('sort-select').value;
|
const sortValue = document.getElementById('sort-select').value;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (sortValue === 'default') {
|
if (sortValue === 'due_date') {
|
||||||
url.searchParams.delete('sort');
|
url.searchParams.delete('sort');
|
||||||
} else {
|
} else {
|
||||||
url.searchParams.set('sort', sortValue);
|
url.searchParams.set('sort', sortValue);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
|
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
|
||||||
<form method="post">
|
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -55,17 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='' %}
|
||||||
<label class="form-label" for="recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="recurrence" name="recurrence">
|
|
||||||
<option value="none" selected>None</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="biweekly">Bi-weekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if tags %}
|
{% if tags %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
|
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
|
||||||
<!-- Main Form -->
|
<!-- Main Form -->
|
||||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
|
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
|
||||||
<form method="post">
|
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -81,31 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group">
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='' task=task %}
|
||||||
<label class="form-label" for="recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="recurrence" name="recurrence">
|
|
||||||
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
|
|
||||||
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
|
||||||
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
|
||||||
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
|
||||||
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
|
||||||
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if tags %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Tags</label>
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
|
|
||||||
{% for tag in tags %}
|
|
||||||
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
|
||||||
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
|
|
||||||
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -33,8 +33,10 @@
|
|||||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if allow_self_registration %}
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
Don't have an account? <a href="{% url 'register' %}">Register</a>
|
Don't have an account? <a href="{% url 'register' %}">Register</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; border: 1px solid var(--border);">
|
<div class="card" style="background: var(--surface); padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1.5rem; border: 1px solid var(--border);">
|
||||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">Personal Information</h2>
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1.5rem;">Personal Information</h2>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" onsubmit="handleProfileFormSubmit(event, this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
@@ -96,6 +96,7 @@
|
|||||||
<label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem;">
|
<label style="display: flex; align-items: center; cursor: pointer; padding: 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.375rem;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="push_notifications"
|
name="push_notifications"
|
||||||
|
data-vapid-public-key="{{ vapid_public_key }}"
|
||||||
{% if user.push_notifications %}checked{% endif %}
|
{% if user.push_notifications %}checked{% endif %}
|
||||||
style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
|
style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Generate a VAPID keypair for Web Push notifications.
|
||||||
|
|
||||||
|
Uses `cryptography` directly rather than py_vapid's own Vapid02.generate_keys(),
|
||||||
|
which raises `TypeError: curve must be an EllipticCurve instance` against
|
||||||
|
newer versions of `cryptography` (confirmed with cryptography 46.0.3 / py-vapid 1.9.2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Generate a VAPID public/private keypair for Web Push notifications'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
|
private_value = private_key.private_numbers().private_value
|
||||||
|
private_bytes = private_value.to_bytes(32, 'big')
|
||||||
|
private_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
public_bytes = public_key.public_bytes(
|
||||||
|
encoding=serialization.Encoding.X962,
|
||||||
|
format=serialization.PublicFormat.UncompressedPoint,
|
||||||
|
)
|
||||||
|
public_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode()
|
||||||
|
|
||||||
|
self.stdout.write('Add these to your environment configuration:\n')
|
||||||
|
self.stdout.write(f'VAPID_PUBLIC_KEY={public_b64}')
|
||||||
|
self.stdout.write(f'VAPID_PRIVATE_KEY={private_b64}')
|
||||||
|
self.stdout.write('VAPID_ADMIN_EMAIL=<your admin contact email>')
|
||||||
@@ -57,6 +57,39 @@ class User(AbstractUser):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db(cls, db, field_names, values):
|
||||||
|
instance = super().from_db(db, field_names, values)
|
||||||
|
instance._loaded_default_reminder_minutes = dict(zip(field_names, values)).get('default_reminder_minutes')
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
loaded_reminder_minutes = getattr(self, '_loaded_default_reminder_minutes', None)
|
||||||
|
reminder_minutes_changed = (
|
||||||
|
loaded_reminder_minutes is not None and loaded_reminder_minutes != self.default_reminder_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
self._loaded_default_reminder_minutes = self.default_reminder_minutes
|
||||||
|
|
||||||
|
if reminder_minutes_changed:
|
||||||
|
self.reschedule_reminder_notifications()
|
||||||
|
|
||||||
|
def reschedule_reminder_notifications(self):
|
||||||
|
"""
|
||||||
|
Recompute "before due" reminders for active tasks using the current
|
||||||
|
default_reminder_minutes. Task.reschedule_reminders() only captures
|
||||||
|
this setting at the moment a task's own due_date/due_time is set,
|
||||||
|
so it doesn't apply retroactively on its own - this is what makes a
|
||||||
|
profile-level change to the setting reach existing tasks. Tasks
|
||||||
|
with their own explicit reminder_at override are left alone.
|
||||||
|
"""
|
||||||
|
tasks = self.tasks.filter(
|
||||||
|
due_date__isnull=False, is_deleted=False, reminder_at__isnull=True,
|
||||||
|
).exclude(status__in=('completed', 'cancelled'))
|
||||||
|
for task in tasks:
|
||||||
|
task.reschedule_reminders()
|
||||||
|
|
||||||
|
|
||||||
class DeviceToken(models.Model):
|
class DeviceToken(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+44
-14
@@ -3,6 +3,7 @@ User views for KeepItGoing.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model, login, logout
|
from django.contrib.auth import get_user_model, login, logout
|
||||||
|
from django.conf import settings
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
@@ -27,6 +28,13 @@ from .throttles import LoginRateThrottle, RegisterRateThrottle
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def self_registration_disabled_response():
|
||||||
|
"""Standard response payload for disabled self-registration."""
|
||||||
|
return {
|
||||||
|
'detail': 'Self-registration is disabled. Contact an administrator to create your account.'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API Views
|
# API Views
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -36,18 +44,18 @@ class UsersAPIRoot(APIView):
|
|||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
return Response({
|
endpoints = {
|
||||||
'endpoints': {
|
'login': '/api/users/token/',
|
||||||
'register': '/api/users/register/',
|
'refresh_token': '/api/users/token/refresh/',
|
||||||
'login': '/api/users/token/',
|
'verify_email': '/api/users/verify-email/',
|
||||||
'refresh_token': '/api/users/token/refresh/',
|
'resend_verification': '/api/users/resend-verification/',
|
||||||
'verify_email': '/api/users/verify-email/',
|
'profile': '/api/users/profile/',
|
||||||
'resend_verification': '/api/users/resend-verification/',
|
'change_password': '/api/users/change-password/',
|
||||||
'profile': '/api/users/profile/',
|
'devices': '/api/users/devices/',
|
||||||
'change_password': '/api/users/change-password/',
|
}
|
||||||
'devices': '/api/users/devices/',
|
if settings.ALLOW_SELF_REGISTRATION:
|
||||||
}
|
endpoints['register'] = '/api/users/register/'
|
||||||
})
|
return Response({'endpoints': endpoints})
|
||||||
|
|
||||||
|
|
||||||
class RegisterAPIView(generics.CreateAPIView):
|
class RegisterAPIView(generics.CreateAPIView):
|
||||||
@@ -59,6 +67,12 @@ class RegisterAPIView(generics.CreateAPIView):
|
|||||||
throttle_classes = [RegisterRateThrottle]
|
throttle_classes = [RegisterRateThrottle]
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
if not settings.ALLOW_SELF_REGISTRATION:
|
||||||
|
return Response(
|
||||||
|
self_registration_disabled_response(),
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
@@ -284,6 +298,7 @@ class LoginView(View):
|
|||||||
'error': error,
|
'error': error,
|
||||||
'email': email,
|
'email': email,
|
||||||
'can_resend': can_resend,
|
'can_resend': can_resend,
|
||||||
|
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
|
||||||
})
|
})
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -319,6 +334,7 @@ class LoginView(View):
|
|||||||
'error': error,
|
'error': error,
|
||||||
'email': email,
|
'email': email,
|
||||||
'can_resend': can_resend,
|
'can_resend': can_resend,
|
||||||
|
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -340,9 +356,20 @@ class RegisterView(View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return redirect('dashboard')
|
return redirect('dashboard')
|
||||||
|
if not settings.ALLOW_SELF_REGISTRATION:
|
||||||
|
return render(request, 'users/login.html', {
|
||||||
|
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
|
||||||
|
'allow_self_registration': False,
|
||||||
|
}, status=403)
|
||||||
return render(request, 'users/register.html')
|
return render(request, 'users/register.html')
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
if not settings.ALLOW_SELF_REGISTRATION:
|
||||||
|
return render(request, 'users/login.html', {
|
||||||
|
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
|
||||||
|
'allow_self_registration': False,
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
email = request.POST.get('email')
|
email = request.POST.get('email')
|
||||||
username = request.POST.get('username')
|
username = request.POST.get('username')
|
||||||
password = request.POST.get('password')
|
password = request.POST.get('password')
|
||||||
@@ -397,7 +424,9 @@ class ProfileView(View):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect('login')
|
return redirect('login')
|
||||||
return render(request, 'users/profile.html')
|
return render(request, 'users/profile.html', {
|
||||||
|
'vapid_public_key': settings.VAPID_PUBLIC_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
@@ -413,7 +442,8 @@ class ProfileView(View):
|
|||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return render(request, 'users/profile.html', {
|
return render(request, 'users/profile.html', {
|
||||||
'success': 'Profile updated successfully.'
|
'success': 'Profile updated successfully.',
|
||||||
|
'vapid_public_key': settings.VAPID_PUBLIC_KEY,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user