Author SHA1 Message Date
Keith SmithandClaude Sonnet 5 f41939983a Update README changelog, remove Firebug IT branding, retire dead mobile middleware
README.md: backfill the Changelog with the 2026.9.5, 2026.8.31.1, 2026.8.31,
and v1.2.0 releases that were missing (it jumped straight from 1.0.0 to
1.1.0). Also correct the stale "Mobile App Support"/"Mobile App
Integration" sections - the native Android app is retired, and the
sync protocol they describe now powers the offline PWA instead.

Remove Firebug IT branding/contact info across README.md, API.md, and
the site footer, and drop the stale tasks.firebugit.com fallback from
development.py's ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINS.

AllowMobileAppFramingMiddleware detected the native app via a
'com.firebugit.keepitgoing' User-Agent check to allow WebView iframe
embedding. With that app retired, replaced it with
SecurityHeadersMiddleware, which applies the same X-Frame-Options/CSP
headers unconditionally instead of only for non-mobile requests -
same protection for real users, dead branch and dead branding gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:12:39 -06:00
Keith SmithandClaude Sonnet 5 6791f008e9 Document reminders, offline PWA, and fix missing Celery Beat in deploy guide
API.md: document the reminder/due_soon/overdue notification schedule
(including the due_time-aware is_overdue fix and the 1-hour overdue
delay) and how default_reminder_minutes/reminder_at interact.

README.md: add Offline Support (PWA) and Notifications & Reminders to
the feature list - both were fully functional but never listed. Also
fixes a real gap in the manual systemd deployment guide: it only set
up the Celery worker, never Celery Beat, so the daily digest, recurring
task safety net, and now reminders would never fire for anyone
following that guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:27:18 -06:00
Keith SmithandClaude Sonnet 5 f92ce7a3d4 Delay overdue notification an hour, retroactively apply reminder setting
Two refinements from live testing of the reminder feature:

- Due-time tasks scheduled "due now" and "overdue" at the exact same
  moment. Task._due_and_overdue_moments() now delays "overdue" by an
  hour so they don't arrive together. The overdue badge/styling
  elsewhere is unaffected - only this notification's timing changes.

- Task.reschedule_reminders() only captures user.default_reminder_minutes
  at the moment a task's own due_date/due_time is set, so changing the
  profile setting didn't reach tasks whose due date was already set.
  User.save() now detects a change to that setting and calls the new
  User.reschedule_reminder_notifications(), which recomputes the
  "before due" reminder on active due tasks that don't have their own
  explicit reminder_at override.

8 new/updated tests cover the overdue delay and the retroactive
rescheduling (including that unrelated profile saves and tasks with an
explicit reminder_at are left untouched).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:48:55 -06:00
Keith SmithandClaude Sonnet 5 a27fcc4c69 Fix task edit crashing after due_date/due_time save (regression from reminders)
The web edit/create/quick-add views and the sync endpoint assigned
due_date/due_time/reminder_at straight from request data as raw strings.
Django tolerates this at save() (types get coerced deep in the SQL
layer), but the in-memory instance keeps the raw strings afterward.
Task.reschedule_reminders(), added for per-task reminders and run from
save() on that same instance, was the first code to actually operate on
those fields before a page reload and crashed with a TypeError - which
the service worker's 5xx-treated-as-offline fallback then masked as a
misleading "you're offline" screen instead of a real error.

Fixed by parsing with Django's parse_date/parse_time/parse_datetime at
the point these values are read from the request/payload in
tasks/views.py and sync/views.py, rather than trusting raw strings.

5 new regression tests exercise the actual views/endpoint with raw
date/time strings and assert proper types land on the model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:00:45 -06:00
Keith SmithandClaude Sonnet 5 5359bf243a Add per-task reminder notifications: before due, due now, and overdue
Only proactive notification was the once-a-day 6-7AM digest. Wires up
the previously-unused User.default_reminder_minutes profile setting
and ScheduledReminder model (Gitea #8) to send timely per-task alerts:

- Task.reschedule_reminders(), hooked into Task.save() via a dirty-check
  against due_date/due_time/reminder_at/status/is_deleted, (re)creates up
  to 3 ScheduledReminder rows per active due task: a "before due" reminder
  (from reminder_at if set, else default_reminder_minutes before due), a
  "due now" notification, and an "overdue" notification (mirroring
  is_overdue's day-after rule for date-only due tasks). Hooking into
  save() means every call site - web views, the API, and sync - picks
  this up automatically.
- New Celery task send_scheduled_reminders (beat schedule: every 5 min)
  sends due reminders via whichever of email/push the user has enabled,
  reusing the existing per-user channel toggles, and logs them to the
  Notification table.
- 14 new tests covering the scheduling math, due-date-change/completion/
  deletion cleanup, and the sending task's channel and timing behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:46:12 -06:00
Keith SmithandClaude Sonnet 5 d67a7e4d8f Fix overdue detection ignoring due_time, harden SW precache installs
Task.is_overdue only compared due_date to today, so a task due at 9am
stayed "not overdue" until midnight regardless of due_time (Gitea #7).
Now factors in due_time when the due date is today, mirrored in the
offline app's isTaskOverdue().

Also hardened the service worker's install step: cache.addAll() lets
the browser's HTTP cache satisfy each precache fetch, and since static
assets here send no Cache-Control/ETag (just Last-Modified), a
CACHE_NAME bump wasn't reliably guaranteed to pick up fresh files.
Forcing {cache: 'reload'} on each precache fetch fixes that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:29:37 -06:00
Keith SmithandClaude Sonnet 5 c454423d4d Sync offline cache on every page load, not just every 2 minutes
Online-side mutations (e.g. deleting a tag) only update the server, not
IndexedDB, so a throttled background sync could leave the offline cache
stale for up to two minutes. Going offline in that window resurrected
deleted/stale data (e.g. a deleted tag reappearing). Removing the throttle
so every real page load pulls fresh state keeps the offline cache in
sync with whatever was just done online.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:36:49 -06:00
Keith SmithandClaude Sonnet 5 bde7be454b Show overdue styling on tasks in offline mode
renderTaskItem() never applied the "overdue" CSS class at all, so
overdue tasks in offline mode looked like any other task instead of
getting the red highlight/due-date treatment they get online. Adds
isTaskOverdue(), mirroring Task.is_overdue, applied the same way
_task_item.html does it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:15:22 -06:00
Keith SmithandClaude Sonnet 5 373bfe6a6e Backfill data missed by devices with a pre-tag-sync token
/api/sync/ only returns rows changed since a device's last sync token.
Any device that synced before tag/time-entry syncing existed already
has a token from that era; pre-existing tags untouched since then were
never "changed since" that token and so were permanently invisible to
that device, even after tag syncing shipped. Adds a sync_format_version
check that ignores the stored token and forces exactly one full resync
per device when it detects an old-format token, backfilling anything
that was missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:15:14 -06:00
Keith SmithandClaude Sonnet 5 3fbab2b831 Add sidebar Tags section to offline mode
Offline mode had tag chips and per-task tag assignment, but was
missing the dashboard sidebar's Tags section entirely: filtering the
task list by tag, per-tag task counts, and tag create/rename/delete.
Ports that section over (reusing the same tag modal pattern as the
online create/edit flow), combinable with the existing status filters
the same way it works online.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:05:39 -06:00
Keith SmithandClaude Sonnet 5 ae9ab596bb Refresh the page automatically after a pending offline sync completes
The real dashboard is server-rendered before any client JS runs, so
the first page load after reconnecting always reflects the pre-sync
database state -- the background sync then completes silently, but
nothing told that already-rendered page to update. A task created
offline would appear to vanish until the next navigation happened to
load fresh data. runBackgroundSync() now reports whether it pushed
pending changes, and both places that trigger it automatically reload
the page when it did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:05:27 -06:00
Keith SmithandClaude Sonnet 5 a6f227e931 Bring offline mode to full parity with the online dashboard
Fixes subtasks appearing flattened into the main task list (missing
parent filter), and adds everything else needed for full offline
functionality: tag chips/assignment/creation, full field editing
(description, due time, recurrence presets), time tracking with a
live-updating timer, and properly nested subtasks -- all queued
locally and synced via the existing /api/sync/ protocol.

The offline page now reuses the real dashboard's app-layout/header/
sidebar/detail-pane structure instead of a bespoke layout, with an
amber-tinted header and banner so it's unmistakable which mode you're
in. The detail panel mirrors _task_detail.html's fields and actions.

Also fixes a real backend gap this feature depends on: apply_conflict_data()
in sync/views.py silently no-op'd on time_entry conflicts. And computes
duration_seconds client-side on merge, since TimeEntrySyncSerializer
doesn't include it in the wire format at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:45:43 -06:00
Keith SmithandClaude Sonnet 5 fc045a0c4b Match dashboard filter/sort behavior in offline task app
The offline task app was showing every cached task including completed
ones, with no sorting -- unlike the online dashboard, which hides
completed tasks by default and sorts by due date. Ports the same
filter tabs (All/Today/Upcoming/Overdue/Completed) and sort options
(due date asc/desc, priority high/low) from tasks/views.py's
DashboardView so offline mode isn't a degraded view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:20:27 -06:00
Keith SmithandClaude Sonnet 5 19697fbc01 Treat 5xx navigation responses as offline in the service worker
fetch() only rejects on true network failures, not bad status codes,
so a reverse proxy (Nginx Proxy Manager) returning 502/503 while the
app server itself is down was passed through as-is instead of falling
back to the offline experience. Now a >=500 response is treated the
same as a network error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:07:59 -06:00
Keith SmithandClaude Sonnet 5 922d4d1bf2 Add offline task CRUD, phase 1 (PWA)
Lets users create, edit, and complete tasks with no network connection,
queued locally in IndexedDB and replayed via the existing /api/sync/
protocol once back online -- the same protocol the native Android app
already uses, with zero backend changes. The service worker now routes
the dashboard's offline fallback to a small client-rendered task app
instead of the generic "you're offline" page; every other route keeps
the generic fallback.

Conflicts (server row touched elsewhere since last sync) auto-resolve
as "local wins" rather than surfacing a resolution UI. Scope is title/
status/priority/due-date only -- tags, subtasks, time tracking, and
recurrence editing offline are out of scope for this phase.

Also adds the first real test coverage for sync/views.py (previously
untested): new-item creation, soft-delete-bypasses-conflict-check,
conflict detection/discarding, and both resolve_conflict paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:01:47 -06:00
Keith SmithandClaude Sonnet 5 9ac1c41897 Pass VAPID env vars through to containers in docker-compose.yml
Portainer stack variables only populate a container's environment when
docker-compose.yml explicitly references them; the web/celery-worker/
celery-beat services never listed VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/
VAPID_ADMIN_EMAIL, so settings.VAPID_PUBLIC_KEY read as empty even with
the vars set in Portainer, breaking the push subscribe flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:35:13 -06:00
Keith SmithandClaude Sonnet 5 7e905a0566 Add Web Push notifications (PWA)
Wires up the previously-scaffolded VAPID/DeviceToken infrastructure end
to end: browser subscription flow on the Profile page's existing "Push
Notifications" toggle, a service worker push/notificationclick handler,
and server-side sending from the daily task digest. Also broadens that
digest's eligibility query so push-only users (email notifications off)
aren't silently skipped, and adds `generate_vapid_keys` since the pinned
py-vapid's own key generator is broken against current cryptography.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:30:56 -06:00
Keith SmithandClaude Sonnet 5 cf37389655 Remove CLAUDE.md from version control
It's local AI-assistant guidance, not project documentation meant for
the repo. Kept on disk (gitignored) so it still works locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:05:40 -06:00
Keith SmithandClaude Sonnet 5 30c61351de Make the web app installable as a PWA (Tier 1: app shell + offline fallback)
Adds a manifest, service worker, and branded icons so the site can be
installed to a home screen/desktop, plus an offline fallback page so a
dropped connection shows something friendlier than the browser's default
error. Icons are rasterized from the existing favicon.svg mark via
rsvg-convert. The manifest and service worker are served through small
Django views (not raw static files) so their asset URLs pick up
WhiteNoise's content-hashed filenames in prod/selfhosted, and the service
worker is served from the site root so its scope covers the whole app.

Does not include true offline task data or Web Push notifications --
those are tracked separately as larger follow-up projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:03:12 -06:00
44 changed files with 2726 additions and 602 deletions
+3
View File
@@ -46,3 +46,6 @@ htmlcov/
# Local Node artifacts used for tooling in this repo
package.json
package-lock.json
# Local AI assistant instructions (not for the repo)
CLAUDE.md
+13 -5
View File
@@ -324,6 +324,8 @@ Registers device for push notifications.
**Platform Options:** `android`, `web`, `desktop`
For `platform: "web"`, `token` is the JSON-stringified `PushSubscription` object from the browser's `PushManager.subscribe()` (i.e. `JSON.stringify(subscription)`), not a bare string token. The web app's own subscribe flow (Profile page → Push Notifications) handles this automatically.
**Response (201 Created):**
```json
{
@@ -431,7 +433,7 @@ Creates a new task.
| priority | enum | No | `low`, `medium`, `high`, `urgent` |
| due_date | date | No | YYYY-MM-DD |
| due_time | time | No | HH:MM:SS |
| reminder_at | datetime | No | ISO 8601 format |
| reminder_at | datetime | No | ISO 8601 format. Overrides the user's `default_reminder_minutes` for this task's "before due" reminder - see Notifications |
| recurrence | enum | No | `none`, `daily`, `weekly`, `biweekly`, `monthly`, `yearly`, `custom` |
| recurrence_rule | string | No | RRULE string for custom recurrence |
| recurrence_end_date | date | No | End date for recurring tasks |
@@ -467,7 +469,7 @@ Creates a new task.
**Notes:**
- When a recurring task is completed, the next instance is automatically created
- `completed_at` is automatically set when `status` changes to `completed`
- `is_overdue` is calculated based on user's timezone
- `is_overdue` is calculated in the user's timezone; if `due_time` is set, the task becomes overdue once that time passes on `due_date`, otherwise it becomes overdue starting the day after `due_date`
### Get Task Details
@@ -947,6 +949,13 @@ Returns notifications for current user.
- `shared` - Task/tag shared with user
- `daily_email` - Daily email digest sent
**Reminder scheduling:** For any task with `due_date` and `due_time` set, up to three notifications are sent automatically (delivered by email and/or push per the user's `email_notifications`/`push_notifications` settings):
- `reminder` - before due, at `reminder_at` if set on the task, otherwise `default_reminder_minutes` before the due moment
- `due_soon` - exactly at the due moment
- `overdue` - one hour after the due moment (kept separate from `due_soon` so they don't arrive together)
A task with `due_date` but no `due_time` only gets the `overdue` notification, fired at the start of the day after `due_date` (matching `is_overdue`'s day-after rule). Changing `default_reminder_minutes` retroactively reschedules the `reminder` notification on existing active tasks that don't have their own `reminder_at` override; it does not affect tasks whose `reminder_at` was explicitly set.
### Mark Notification as Read
**POST** `/api/notifications/{notification_id}/read/` (Authenticated)
@@ -1173,10 +1182,9 @@ Current version: 1.0 (no version prefix in URLs)
For API support or questions:
- GitHub Issues: Create an issue in the repository
- Email: keith@firebugit.com
- Email: keithsmith@darksingularity.org
---
**Built by Firebug IT**
**Documentation Version: 1.0**
**Last Updated: 2025-01-10**
**Last Updated: 2026-09-05**
-446
View File
@@ -1,446 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Local Development (Non-Docker)
```bash
# Set environment for development
export DJANGO_SETTINGS_MODULE=config.settings.development
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Database operations
python manage.py migrate
python manage.py makemigrations
python manage.py createsuperuser
# Run development server
python manage.py runserver
# Run tests
python manage.py test
# Django shell
python manage.py shell
# Collect static files (for production)
python manage.py collectstatic --noinput
```
### Docker Development
```bash
# Development with live reload
make dev
make dev-build # Rebuild containers
# Production mode
make prod
make build # Build production containers
make deploy # Full deployment (pull, build, migrate, restart)
# Database operations
make migrate
make makemigrations
make createsuperuser
make dbshell # Access database shell
make backup # Backup database
# Monitoring
make logs # All container logs
make logs-web # Web container logs only
make logs-celery # Celery worker logs
make status # Container status
# Control
make stop
make restart
make clean # Stop and remove all containers/volumes
# Shell access
make shell # Django shell
make test # Run tests in container
```
### Celery (Background Tasks)
```bash
# Local development (non-Docker)
# Terminal 1: Run Celery worker
celery -A config worker -l info
# Terminal 2: Run Celery beat (scheduled tasks)
celery -A config beat -l info
# In Docker, Celery runs automatically as separate services
```
## High-Level Architecture
### Django Apps
**users/** - User authentication and management
- Custom User model with email-based authentication (no username)
- Three-tier verification: email verification → admin approval → login
- JWT token authentication with refresh tokens
- Device token management for push notifications
- Rate-limited registration (5/hour) and login (10/hour)
**tasks/** - Core task management
- Hierarchical tasks with parent/subtask relationships
- Task status (pending, in_progress, completed, cancelled) and priority (low, medium, high, urgent)
- Recurrence patterns (daily, weekly, biweekly, monthly, yearly, or custom RRULE for day-of-week/nth-weekday/day-of-month control)
- Time tracking with start/stop timer functionality
- Tag system with colors and icons for organization
- Task sharing with permission levels (viewer/editor)
- Soft-delete architecture (is_deleted flag) for sync compatibility
**sync/** - Mobile app synchronization
- Offline-first bidirectional sync protocol
- Timestamp-based conflict detection with three resolution strategies (local, server, merged)
- Multi-device support with independent sync tokens per device
- Incremental sync (delta updates) and full sync support
- Uses sync_id (UUID) separate from Django id for mobile compatibility
- Soft-delete propagation to enable client-side deletion
**notifications/** - Email notifications and reminders
- Daily email digest (overdue + due today tasks)
- Timezone-aware scheduling (6-7 AM in user's local time)
- Deduplication (one email per user per day)
- Celery-based background processing
- Recurring task safety net (creates missed recurrences)
### Key Architectural Patterns
**UUID Primary Keys** - All models use UUID as primary key for distributed system compatibility and security.
**Dual ID System** - Each entity has both `id` (Django PK) and `sync_id` (for mobile sync). Sync protocol uses `sync_id` to maintain consistency across devices.
**Soft Delete Pattern** - All entities have `is_deleted` boolean instead of hard deletes. Dual managers enable filtering:
- `objects` - Default manager (excludes deleted)
- `all_objects` - Includes deleted items (used in sync)
**Multiple Serializers Per Context** - Different serializers for list/detail/sync endpoints to optimize performance:
- `TaskListSerializer` - Lightweight for list views
- `TaskSerializer` - Full details with nested subtasks
- `TaskSyncSerializer` - Uses sync_id, includes is_deleted
**Permission System** - Custom `IsOwnerOrReadOnlyIfShared` permission:
- Read access: Owner OR users with shared access
- Write access: Owner only
- Security filtering in serializers prevents tag/task leakage
**Custom Authentication Backend** - `EmailVerifiedApprovedBackend` enforces:
1. Email verification check
2. Admin approval check
3. Superusers bypass both checks
4. Stores error state in session for web UI
**Timezone-Aware Operations** - All time calculations use user's timezone:
- Daily emails scheduled in user's local 6-7 AM
- Overdue calculation relative to user's local date
- UTC storage, converted for display and logic
**Mobile App Support** - `AllowMobileAppFramingMiddleware` detects mobile app via User-Agent and removes X-Frame-Options to allow WebView embedding while maintaining security for web browsers.
### Sync Protocol Details
The sync endpoint (`POST /api/sync/`) handles offline-first synchronization:
**Request Format:**
```json
{
"device_id": "unique-device-id",
"last_sync_token": "previous-token-or-null",
"changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
}
}
```
**Response Format:**
```json
{
"sync_token": "new-sync-token",
"server_time": "2025-01-10T12:00:00Z",
"server_changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
},
"conflicts": [...]
}
```
**Conflict Detection:** Compares `updated_at` timestamp between client's last sync and server's current state. If server modified entity since last sync, conflict is flagged.
**Conflict Resolution Strategies:**
- **local** - Apply client changes (overwrite server)
- **server** - Keep server version (discard client)
- **merged** - Accept manually merged data
### Celery Task Schedule
Configured in `config/celery.py`:
**send_daily_task_email** - Runs every hour
- Fetches users with email notifications enabled
- Converts UTC to user's timezone
- Sends email only if user's local time is 6-7 AM
- Checks for existing notification to prevent duplicates
- Groups tasks: overdue + due today (pending/in_progress only)
**process_recurring_tasks** - Runs daily at midnight
- Safety net for recurring task creation
- Processes completions from last 48 hours
- Creates next recurrence if missing
- Prevents duplicate recurrences
### Settings Modules
**config.settings.development** - Local development
- DEBUG=True
- SQLite database
- Console email backend
- Minimal security
**config.settings.production** - Production deployment
- DEBUG=False
- PostgreSQL required
- SMTP email backend
- Full security (HTTPS, CSP, HSTS)
- Gunicorn with multiple workers
**config.settings.selfhosted** - Docker/self-hosted
- Environment-based configuration
- PostgreSQL with health checks
- Redis for Celery
- Configurable workers and timeout
- WhiteNoise for static files
### Database Models Reference
**Task Model Fields:**
- Hierarchy: `parent` (ForeignKey to self)
- Status: pending, in_progress, completed, cancelled
- Priority: low, medium, high, urgent
- Recurrence: `recurrence` (none/daily/weekly/biweekly/monthly/yearly/custom), `recurrence_rule` (RRULE string, used when `recurrence='custom'`, parsed via `dateutil.rrule`), `recurrence_end_date`
- Timing: `due_date`, `due_time`, `completed_at`, `created_at`, `updated_at`
- Soft delete: `is_deleted` (filters via `SoftDeleteManager`)
- Sync: `sync_id` (UUID for mobile sync)
- Relationships: `tags` (ManyToMany), `time_entries` (reverse FK)
**User Model Fields:**
- Authentication: `email` (unique), `password`, `first_name`, `last_name`
- Verification: `email_verified`, `is_approved`, `approved_by`
- Preferences: `timezone`, `reminder_minutes_before`, `notifications_enabled`, `email_notifications_enabled`
- UUID primary key for security
**TimeEntry Model:**
- `start_time`, `end_time`, `duration` (auto-calculated)
- Foreign keys: `task`, `user`
- Soft delete: `is_deleted`
**TaskShare Model:**
- `shared_by`, `shared_with` (User FKs)
- `permission_level`: viewer, editor
- Can share tasks or tags
- Generic relation pattern
### API Endpoint Overview
**Authentication:**
- `POST /api/users/register/` - Create account (rate limited 5/hour)
- `POST /api/users/token/` - Login (rate limited 10/hour)
- `POST /api/users/token/refresh/` - Refresh JWT
- `POST /api/users/verify-email/` - Verify email token
- `POST /api/users/resend-verification/` - Resend verification email
**Tasks:**
- `GET/POST /api/tasks/` - List/create tasks (supports filtering by status, priority, tags, search)
- `GET/PUT/DELETE /api/tasks/<id>/` - Task detail operations
- `POST /api/tasks/<id>/start-timer/` - Start time tracking
- `POST /api/tasks/time-entries/<id>/stop/` - Stop timer
- `GET/POST /api/tasks/tags/` - Tag management
- `GET/POST /api/tasks/shares/` - Share management
**Sync:**
- `POST /api/sync/` - Main sync endpoint (rate limited 100/hour)
- `GET /api/sync/conflicts/` - List pending conflicts
- `POST /api/sync/conflicts/<id>/resolve/` - Resolve conflict
**Notifications:**
- `GET /api/notifications/` - List notifications
- `POST /api/notifications/<id>/read/` - Mark as read
- `POST /api/notifications/mark-all-read/` - Mark all read
- `GET /api/notifications/unread-count/` - Get unread count
### Environment Variables
Key variables (see `.env.example` and `stack.env.example`):
**Django Core:**
- `SECRET_KEY` - Django secret (generate with `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"`)
- `DEBUG` - Debug mode (False in production)
- `ALLOWED_HOSTS` - Comma-separated hosts
- `CSRF_TRUSTED_ORIGINS` - HTTPS origins
- `SITE_DOMAIN` - Domain for email links
**Database:**
- `DATABASE_URL` - Connection string (e.g., `postgresql://user:pass@host:5432/db`)
**Redis & Celery:**
- `REDIS_URL` - Redis connection (e.g., `redis://localhost:6379/0`)
**Email:**
- `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`
- `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`
- `DEFAULT_FROM_EMAIL` - Sender address
- `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS` - Token validity (default: 24)
**Docker/Gunicorn:**
- `GUNICORN_WORKERS` - Worker processes (default: 3)
- `GUNICORN_TIMEOUT` - Request timeout (default: 60)
- `WEB_PORT` - External port (default: 8000)
### Testing
Tests located in each app's `tests.py`. Run with:
```bash
python manage.py test # All tests
python manage.py test users # Specific app
python manage.py test users.tests.TestUserModel # Specific test
```
Docker:
```bash
make test # Run tests in container
```
### Deployment Notes
**Docker Deployment:**
- Production uses PostgreSQL (postgres:16-alpine)
- Redis for Celery message broker (redis:7-alpine)
- Web service runs Gunicorn with configurable workers
- Celery worker and beat run as separate services
- Health checks on all services
- Named volumes for persistence (db_data, redis_data, static_files)
- Networks: frontend (web-facing), backend (internal services)
**Manual Deployment:**
- Use `config.settings.production` settings module
- Requires PostgreSQL (not SQLite)
- Configure SMTP for email
- Use Gunicorn behind Nginx reverse proxy
- Set up systemd services for Gunicorn, Celery worker, Celery beat
- Configure SSL/TLS certificates (Let's Encrypt recommended)
- Enable firewall (UFW) with SSH, HTTP, HTTPS only
**Production Checklist:**
- Set DEBUG=False
- Use strong SECRET_KEY
- Configure ALLOWED_HOSTS (no wildcards)
- Set CSRF_TRUSTED_ORIGINS for HTTPS
- Use PostgreSQL (not SQLite)
- Configure email backend (SMTP)
- Enable security headers (in settings)
- Set up SSL/TLS certificates
- Configure database backups
- Set up log rotation
- Monitor error logs and uptime
### Common Development Patterns
**Creating Recurring Tasks:** When a recurring task is marked complete, the next recurrence is automatically created in `tasks/models.py` `create_next_recurrence()` method, which calls `calculate_next_due_date()`. For `recurrence='custom'`, the next date is computed by evaluating `recurrence_rule` (an RRULE string) via `dateutil.rrule`; the web UI's Custom recurrence builder (in `templates/tasks/_recurrence_fields.html` and `static/js/app.js`) generates these strings for day-of-week, nth-weekday, and day-of-month patterns. Celery task `process_recurring_tasks()` runs as safety net.
**Time Entry Workflow:**
1. `POST /api/tasks/<id>/start-timer/` creates TimeEntry with start_time
2. Running timer tracked in UI
3. `POST /api/time-entries/<id>/stop/` sets end_time and calculates duration
**Email Verification Flow:**
1. User registers → `email_verified=False`, `is_approved=False`
2. EmailVerificationToken created with 24-hour expiry
3. User clicks link → `email_verified=True`, token marked used
4. Admins notified via email
5. Admin approves in Django admin → `is_approved=True`
6. User notified and can now login
**Adding New Celery Tasks:**
1. Create task function in app's `tasks.py` with `@shared_task` decorator
2. Add to beat schedule in `config/celery.py` if periodic
3. Import in app's `__init__.py` to ensure task registration
4. Restart Celery worker and beat services
**Extending User Model:** The User model is in `users/models.py`. Add fields there, create migration, update serializers and admin if needed.
**Security Filtering:** When adding new endpoints that query related objects, filter by user in viewset's `get_queryset()`. Example from TaskViewSet:
```python
def get_queryset(self):
return Task.objects.filter(user=self.request.user)
```
### File Structure Reference
```
config/ # Django project configuration
settings/ # Environment-specific settings
base.py # Shared settings
development.py # Local development
production.py # Production deployment
selfhosted.py # Docker/self-hosted
celery.py # Celery configuration
urls.py # Root URL configuration
wsgi.py # WSGI entry point
users/ # User management app
models.py # User, DeviceToken, EmailVerificationToken
views.py # Registration, login, profile
serializers.py # User serializers
backends.py # EmailVerifiedApprovedBackend
utils.py # Email utilities
tasks/ # Task management app
models.py # Task, Tag, TimeEntry, TaskShare
views.py # Task CRUD, filtering, timers
serializers.py # Multiple serializers per context
middleware.py # AllowMobileAppFramingMiddleware
permissions.py # IsOwnerOrReadOnlyIfShared
sync/ # Mobile sync app
models.py # SyncLog, SyncConflict
views.py # Sync endpoint, conflict resolution
serializers.py # SyncSerializer with sync_id
notifications/ # Notifications app
models.py # Notification, ScheduledReminder
views.py # Notification CRUD
tasks.py # Celery tasks (daily email, recurring tasks)
templates/ # Django templates
base.html # Base template with dark mode
tasks/ # Task-related templates
users/ # User-related templates
static/ # Static assets
css/ # Stylesheets
js/ # JavaScript
docker/ # Docker-related files
docker-compose.yml # Production Docker setup
docker-compose.dev.yml # Development overrides
Dockerfile # Production image
Dockerfile.dev # Development image
Makefile # Docker command shortcuts
```
+5
View File
@@ -79,6 +79,11 @@ SERVER_EMAIL=kig@keepitgoing.app
# Email Verification
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_ALLOWED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
+116 -30
View File
@@ -39,10 +39,20 @@ A powerful Django-based task management system with time tracking, tag organizat
### API & Sync
- **RESTful API**: Full REST API for programmatic access
- **Mobile App Support**: Sync protocol for Android app
- **Offline-First Sync Protocol**: Bidirectional sync with conflict resolution, powering the offline PWA
- **Real-time Updates**: Task changes sync across devices
- **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
- **Backend**: Django 5.x, Django REST Framework
@@ -62,7 +72,7 @@ A powerful Django-based task management system with time tracking, tag organizat
1. **Clone the repository**
```bash
git clone https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git
git clone https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git
cd KeepItGoingServer
```
@@ -413,9 +423,9 @@ Key environment variables (see `.env.example`):
#### Core Settings
- `SECRET_KEY` - Django secret key (required in production, generate with `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"`)
- `DEBUG` - Enable debug mode (True/False, must be False in production)
- `ALLOWED_HOSTS` - Comma-separated list of allowed hosts (e.g., `tasks.firebugit.com,localhost`)
- `CSRF_TRUSTED_ORIGINS` - HTTPS origins for CSRF (e.g., `https://tasks.firebugit.com`)
- `SITE_DOMAIN` - Domain name for email links (e.g., `tasks.firebugit.com`)
- `ALLOWED_HOSTS` - Comma-separated list of allowed hosts (e.g., `tasks.darksingularity.org,localhost`)
- `CSRF_TRUSTED_ORIGINS` - HTTPS origins for CSRF (e.g., `https://tasks.darksingularity.org`)
- `SITE_DOMAIN` - Domain name for email links (e.g., `tasks.darksingularity.org`)
#### Database
- `DATABASE_URL` - Database connection string (optional, overrides settings)
@@ -431,7 +441,7 @@ Key environment variables (see `.env.example`):
- `EMAIL_USE_SSL` - Use SSL (True/False, use True for port 465)
- `EMAIL_HOST_USER` - SMTP username/email
- `EMAIL_HOST_PASSWORD` - SMTP password or API key
- `DEFAULT_FROM_EMAIL` - From email address (e.g., `KeepItGoing <noreply@firebugit.com>`)
- `DEFAULT_FROM_EMAIL` - From email address (e.g., `KeepItGoing <noreply@darksingularity.org>`)
- `SERVER_EMAIL` - Server error email address
#### Email Verification Settings
@@ -632,7 +642,7 @@ sudo useradd -m -s /bin/bash keepitgoing
sudo su - keepitgoing
# Clone repository
git clone https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git
git clone https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git
cd KeepItGoingServer
# Create virtual environment
@@ -655,9 +665,9 @@ cat > .env << 'EOF'
# Django Settings
SECRET_KEY=GENERATE_WITH_get_random_secret_key
DEBUG=False
ALLOWED_HOSTS=tasks.firebugit.com,localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=https://tasks.firebugit.com
SITE_DOMAIN=tasks.firebugit.com
ALLOWED_HOSTS=tasks.darksingularity.org,localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=https://tasks.darksingularity.org
SITE_DOMAIN=tasks.darksingularity.org
# Database Configuration
# Choose ONE of the following based on your database from Step 2:
@@ -678,8 +688,8 @@ 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@firebugit.com>
SERVER_EMAIL=server@firebugit.com
DEFAULT_FROM_EMAIL=KeepItGoing <noreply@darksingularity.org>
SERVER_EMAIL=server@darksingularity.org
# Email Verification
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
@@ -830,7 +840,9 @@ sudo systemctl start 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
sudo nano /etc/systemd/system/keepitgoing-celery.service
@@ -861,6 +873,37 @@ sudo systemctl enable 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
```bash
@@ -876,7 +919,7 @@ upstream keepitgoing {
server {
listen 80;
server_name tasks.firebugit.com;
server_name tasks.darksingularity.org;
# Redirect HTTP to HTTPS
return 301 https://$server_name$request_uri;
@@ -884,11 +927,11 @@ server {
server {
listen 443 ssl http2;
server_name tasks.firebugit.com;
server_name tasks.darksingularity.org;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/tasks.firebugit.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tasks.firebugit.com/privkey.pem;
ssl_certificate /etc/letsencrypt/live/tasks.darksingularity.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tasks.darksingularity.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
@@ -942,7 +985,7 @@ sudo systemctl restart nginx
```bash
# Obtain SSL certificate
sudo certbot --nginx -d tasks.firebugit.com
sudo certbot --nginx -d tasks.darksingularity.org
# Certbot will automatically configure nginx
# Certificate auto-renews via cron
@@ -961,7 +1004,7 @@ sudo ufw status
#### Step 10: Initial Admin User and Approval
```bash
# Log in to the web interface at https://tasks.firebugit.com/admin
# Log in to the web interface at https://tasks.darksingularity.org/admin
# Use the superuser credentials created in Step 3
# If ALLOW_SELF_REGISTRATION=True and new users register:
@@ -1016,8 +1059,8 @@ Complete this checklist before going live:
**Services:**
- [ ] Set up Gunicorn systemd service
- [ ] Configure nginx as reverse proxy
- [ ] Set up Redis for Celery (if using background tasks)
- [ ] Configure Celery systemd service (if needed)
- [ ] Set up Redis for Celery (required for the daily digest, recurring tasks, and reminders)
- [ ] 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
**Monitoring & Logging:**
@@ -1072,7 +1115,7 @@ from django.core.mail import send_mail
send_mail(
'Test Subject',
'Test message.',
'noreply@firebugit.com',
'noreply@darksingularity.org',
['your-email@example.com'],
)
# Check for any errors
@@ -1155,9 +1198,9 @@ sudo tail -f /var/log/nginx/access.log
sudo journalctl -u keepitgoing -f
```
## Mobile App Integration
## Offline-First Sync
This server works with the KeepItGoing Android app via the sync endpoint.
The sync endpoint (`/api/sync/`) powers the offline PWA's background sync. It was originally built for a native Android app, which has since been retired in favor of the PWA, but the protocol itself is unchanged.
**Sync Protocol:**
- Client sends current state and last sync timestamp
@@ -1176,16 +1219,63 @@ This server works with the KeepItGoing Android app via the sync endpoint.
## License
Proprietary - All Rights Reserved
Copyright (c) 2025 Firebug IT
Copyright (c) 2026 Keith Smith
## Support
For issues and questions:
- Create an issue in the repository
- Email: keith@firebugit.com
- Email: keithsmith@darksingularity.org
## Changelog
### 2026.9.5
**New Features:**
- Installable Progressive Web App (PWA): app manifest, service worker, and offline fallback pages
- Web Push notifications
- Full offline task management: create, edit, complete tasks, manage subtasks and tags, and track time while offline, with automatic sync once back online
- Per-task reminder notifications - "before due", "due now", and "overdue" - delivered by email and/or push, timed per task and configurable via the profile's Default Reminder setting
**Bug Fixes:**
- Overdue status now accounts for due time, not just due date
- Background sync now runs on every page load instead of being throttled, so the offline cache can no longer serve stale data (e.g. a tag deleted online reappearing offline)
- Offline mode now matches the online dashboard: correct subtask nesting, tag sidebar, overdue styling, and full task editing/tags/time tracking
- Fixed a crash when editing a task's due date/time
- Service worker precache now reliably picks up updated files after a deploy instead of a stale cached copy
- "Overdue" and "due now" notifications no longer arrive at the same instant - overdue now waits an hour
- Changing the reminder-timing setting now retroactively updates reminders already scheduled on existing tasks
### 2026.8.31.1
- Custom recurrence patterns: recurring tasks can repeat on specific days of the week, an interval of weeks (e.g. every other Wednesday), a specific day of the month, or the Nth weekday of the month (e.g. every second Tuesday)
- New "Custom" recurrence builder in the task create/edit UI generates the underlying RRULE pattern automatically
### 2026.8.31
**New Features:**
- Notification system simplified to a single daily task-due email, sent within each user's local morning window
- Self-registration can now be enabled/disabled via configuration (disabled by default)
**Bug Fixes:**
- Task sort now defaults to due date instead of raw/unsorted ordering
- Priority sort now orders correctly (high to low / low to high)
- Recurring tasks no longer create duplicate instances on completion
- Daily email timing now correctly accounts for all user timezones
- Overdue calculation now uses the user's local timezone
- Fixed Celery worker/beat health check issues
**Infrastructure:**
- Docker Compose now builds from the git repository
- Replaced pytz with zoneinfo for timezone handling
### v1.2.0 - Recurring Tasks
- Fully functional recurring tasks (daily, weekly, biweekly, monthly, yearly)
- Automatic creation of next task instance on completion
- Recurrence end date support
- Hourly background job to ensure no missed recurrences
### Version 1.1.0 (2025-01-22)
- **Email Verification System**: Users must verify email before logging in
- **Admin Approval Workflow**: Admins approve new users via Django admin
@@ -1206,7 +1296,3 @@ For issues and questions:
- Web interface with dark mode
- Responsive design
- RESTful API
---
Built with ❤️ by Firebug IT
+4
View File
@@ -30,6 +30,10 @@ app.conf.beat_schedule = {
'task': 'notifications.tasks.process_recurring_tasks',
'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
},
}
+6 -1
View File
@@ -44,7 +44,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tasks.middleware.AllowMobileAppFramingMiddleware', # Allow mobile app iframe embedding
'tasks.middleware.SecurityHeadersMiddleware',
]
ROOT_URLCONF = 'config.urls'
@@ -175,3 +175,8 @@ EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS = int(
os.environ.get('EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24)
)
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', '')
+2 -2
View File
@@ -20,10 +20,10 @@ if not SECRET_KEY:
DEBUG = True
# Development-only hosts
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', '10.0.2.2', '192.168.1.241', 'tasks.firebugit.com']
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', '10.0.2.2', '192.168.1.241']
# CSRF - for development testing only
CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if os.environ.get('CSRF_TRUSTED_ORIGINS') else ['https://tasks.firebugit.com']
CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if os.environ.get('CSRF_TRUSTED_ORIGINS') else []
# Database - SQLite for development
DATABASES = {
+8 -1
View File
@@ -6,18 +6,25 @@ from django.contrib import admin
from django.urls import path, include
from django.conf import settings
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 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 notifications.urls import api_urlpatterns as notifications_api_urls
from tasks.views_debug import debug_user_agent
from .views import api_root
from .views import api_root, manifest_webmanifest, service_worker
urlpatterns = [
# Admin
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)
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
+17
View File
@@ -3,6 +3,23 @@ API root views for KeepItGoing.
"""
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):
+9
View File
@@ -75,6 +75,9 @@ services:
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:-}
# Gunicorn
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
@@ -120,6 +123,9 @@ services:
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:
- backend
depends_on:
@@ -156,6 +162,9 @@ services:
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:
- backend
depends_on:
+2 -2
View File
@@ -21,8 +21,8 @@ class NotificationAdmin(admin.ModelAdmin):
class ScheduledReminderAdmin(admin.ModelAdmin):
"""Admin for ScheduledReminder."""
list_display = ['task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
list_filter = ['is_sent', 'remind_at']
list_display = ['task', 'reminder_type', 'remind_at', 'is_sent', 'sent_at', 'created_at']
list_filter = ['reminder_type', 'is_sent', 'remind_at']
search_fields = ['task__title']
ordering = ['remind_at']
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),
),
]
+11 -2
View File
@@ -50,14 +50,23 @@ class Notification(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)
task = models.ForeignKey(
'tasks.Task',
on_delete=models.CASCADE,
related_name='scheduled_reminders'
)
reminder_type = models.CharField(max_length=20, choices=REMINDER_TYPES, default='reminder')
remind_at = models.DateTimeField()
is_sent = models.BooleanField(default=False)
sent_at = models.DateTimeField(null=True, blank=True)
@@ -68,4 +77,4 @@ class ScheduledReminder(models.Model):
ordering = ['remind_at']
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}"
+1 -1
View File
@@ -25,5 +25,5 @@ class ScheduledReminderSerializer(serializers.ModelSerializer):
class Meta:
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']
+138 -17
View File
@@ -2,18 +2,90 @@
Celery tasks for notifications.
"""
import json
import logging
from celery import shared_task
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 zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
def notify_user(user, title, body, notification_type, task=None, url='/'):
"""
Send a notification to a user via whichever of email/push they have
enabled, and record it in the Notification log. Returns True if it went
out on at least one channel.
"""
from .models import Notification
notified = False
if user.email_notifications and user.email_verified:
try:
send_mail(
subject=title,
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}")
if user.push_notifications:
send_web_push_to_user(user, title=title, body=body, url=url)
notified = True
if notified:
Notification.objects.create(
user=user,
notification_type=notification_type,
title=title,
message=body,
task=task,
)
return notified
def send_web_push_to_user(user, title, body, url='/'):
"""Send a Web Push notification to all of a user's registered web devices."""
if not settings.VAPID_PRIVATE_KEY:
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
def send_daily_task_email():
"""
@@ -25,14 +97,13 @@ def send_daily_task_email():
User = get_user_model()
# Get all users who have email notifications enabled
# Get all users who have email and/or push notifications enabled
users = User.objects.filter(
email_notifications=True,
email_verified=True,
Q(email_notifications=True) | Q(push_notifications=True),
is_active=True
)
).distinct()
emails_sent = 0
notifications_sent = 0
current_utc_hour = timezone.now().hour
for user in users:
@@ -110,7 +181,10 @@ def send_daily_task_email():
message = '\n'.join(message_lines)
notified = False
# Send email
if user.email_notifications and user.email_verified:
try:
send_mail(
subject=subject,
@@ -119,22 +193,69 @@ def send_daily_task_email():
recipient_list=[user.email],
fail_silently=False,
)
# Record that we sent the email (prevents duplicates)
Notification.objects.create(
user=user,
notification_type='daily_email',
title=subject,
message=f"Daily email sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
)
emails_sent += 1
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}")
logger.info(f"Daily task email job completed. Sent {emails_sent} emails.")
return emails_sent
# 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
def send_scheduled_reminders():
"""
Send "before due", "due now", and "overdue" notifications from
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 .models import ScheduledReminder
due_reminders = ScheduledReminder.objects.filter(
is_sent=False, remind_at__lte=timezone.now()
).select_related('task', 'task__user')
sent_count = 0
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
reminder.is_sent = True
reminder.sent_at = timezone.now()
reminder.save(update_fields=['is_sent', 'sent_at'])
return sent_count
@shared_task
+284 -2
View File
@@ -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)
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

+112 -1
View File
@@ -3,12 +3,123 @@
* Handles theme toggle, task selection, mobile menus, and AJAX operations
*/
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
initTheme();
initTaskSelection();
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
============================================ */
+118
View File
@@ -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;
}
+173
View File
@@ -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;
}
+797
View File
@@ -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' ? '&#10003;' : ''}</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
View File
@@ -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
View File
@@ -8,7 +8,7 @@ import logging
import uuid
from datetime import datetime
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.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import IsAuthenticated
@@ -289,6 +289,14 @@ def process_time_entry_changes(user, entry_changes, last_sync_at):
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):
"""Update a task from sync data."""
# 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.status = data.get('status', task.status)
task.priority = data.get('priority', task.priority)
task.due_date = data.get('due_date', task.due_date)
task.due_time = data.get('due_time', task.due_time)
task.reminder_at = data.get('reminder_at', task.reminder_at)
task.due_date = _parsed(data, 'due_date', task.due_date, parse_date)
task.due_time = _parsed(data, 'due_time', task.due_time, parse_time)
task.reminder_at = _parsed(data, 'reminder_at', task.reminder_at, parse_datetime)
task.recurrence = data.get('recurrence', task.recurrence)
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
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', ''),
status=data.get('status', 'pending'),
priority=data.get('priority', 'medium'),
due_date=data.get('due_date'),
due_time=data.get('due_time'),
reminder_at=data.get('reminder_at'),
due_date=_parsed(data, 'due_date', None, parse_date),
due_time=_parsed(data, 'due_time', None, parse_time),
reminder_at=_parsed(data, 'reminder_at', None, parse_datetime),
recurrence=data.get('recurrence', 'none'),
recurrence_rule=data.get('recurrence_rule', ''),
sort_order=data.get('sort_order', 0),
@@ -474,3 +482,21 @@ def apply_conflict_data(entity_type, entity_id, data):
tag.save()
except Tag.DoesNotExist:
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
+2 -2
View File
@@ -1,3 +1,3 @@
from .mobile_app import AllowMobileAppFramingMiddleware
from .security_headers import SecurityHeadersMiddleware
__all__ = ['AllowMobileAppFramingMiddleware']
__all__ = ['SecurityHeadersMiddleware']
-61
View File
@@ -1,61 +0,0 @@
"""
Middleware to allow iframe embedding for the KeepItGoing mobile app.
The mobile app uses Capacitor WebView which embeds the website in an iframe.
This middleware detects requests from the mobile app and removes both
X-Frame-Options and Content-Security-Policy frame-ancestors headers to allow
iframe embedding, while keeping clickjacking protection for regular web browsers.
"""
class AllowMobileAppFramingMiddleware:
"""
Remove frame-blocking headers for requests from KeepItGoing mobile app.
The mobile app uses a Capacitor WebView. We detect these requests via
User-Agent and remove X-Frame-Options and CSP frame-ancestors headers.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Check if request is from KeepItGoing mobile app
user_agent = request.META.get('HTTP_USER_AGENT', '')
# Detect Capacitor/Android WebView patterns
is_mobile_app = (
'wv' in user_agent.lower() or # Android WebView
'CapacitorHttp' in user_agent or
'com.firebugit.keepitgoing' in user_agent or
('KeepItGoing' in user_agent and 'Mobile' in user_agent)
)
if is_mobile_app:
# Mobile app: Allow iframe embedding - don't add frame-blocking headers
# Remove any existing frame headers
if 'X-Frame-Options' in response:
del response['X-Frame-Options']
if 'Content-Security-Policy' in response:
del response['Content-Security-Policy']
else:
# Regular browsers: Add security headers for clickjacking protection
if 'X-Frame-Options' not in response:
response['X-Frame-Options'] = 'DENY'
if 'Content-Security-Policy' not in response:
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
+35
View File
@@ -0,0 +1,35 @@
"""
Middleware that applies clickjacking protection headers to every response.
Previously also allowed iframe embedding for a native mobile app's WebView
(detected via User-Agent); that app has been retired in favor of the PWA,
so the headers are now applied unconditionally.
"""
class SecurityHeadersMiddleware:
"""Add X-Frame-Options and a CSP frame-ancestors policy to every response."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if 'X-Frame-Options' not in response:
response['X-Frame-Options'] = 'DENY'
if 'Content-Security-Policy' not in response:
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
+92 -4
View File
@@ -136,9 +136,18 @@ class Task(models.Model):
db_table = 'tasks'
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):
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):
# Auto-set completed_at when status changes to completed
if self.status == 'completed' and self.completed_at is None:
@@ -146,7 +155,83 @@ class Task(models.Model):
self.completed_at = timezone.now()
elif self.status != 'completed':
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)
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
def is_overdue(self):
@@ -155,16 +240,19 @@ class Task(models.Model):
from django.utils import timezone
from zoneinfo import ZoneInfo
# Get current date in user's timezone
# Get current date/time in user's timezone
try:
user_tz = ZoneInfo(self.user.timezone)
user_now = timezone.now().astimezone(user_tz)
user_today = user_now.date()
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
user_today = timezone.now().date()
user_now = timezone.now()
return self.due_date < user_today
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
@property
+93 -1
View File
@@ -1,7 +1,9 @@
from datetime import date
from datetime import date, datetime, time as dt_time
from unittest.mock import patch
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
@@ -85,3 +87,93 @@ class CustomRecurrenceTests(TestCase):
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))
+6 -5
View File
@@ -7,6 +7,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
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.views import View
from django.views.decorators.http import require_POST
@@ -466,10 +467,10 @@ class TaskDetailView(View):
task.priority = priority
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')
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
recurrence = request.POST.get('recurrence', 'none')
@@ -540,8 +541,8 @@ class TaskCreateView(View):
description=request.POST.get('description', ''),
status=status,
priority=priority,
due_date=request.POST.get('due_date') or None,
due_time=request.POST.get('due_time') or None,
due_date=parse_date(request.POST.get('due_date')) if request.POST.get('due_date') else None,
due_time=parse_time(request.POST.get('due_time')) if request.POST.get('due_time') else None,
recurrence=recurrence,
recurrence_rule=request.POST.get('recurrence_rule', '') if recurrence == 'custom' else '',
)
@@ -559,7 +560,7 @@ def task_quick_add(request):
task = Task.objects.create(
user=request.user,
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
tag_id = request.POST.get('tag')
+11 -2
View File
@@ -9,6 +9,13 @@
<!-- Favicon -->
<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="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">
{% block extra_css %}{% endblock %}
@@ -135,12 +142,14 @@
{% block auth_content %}{% endblock %}
</div>
<footer class="footer">
<p>&copy; 2025 Firebug IT. All rights reserved.</p>
<p>&copy; 2026 Keith Smith. All rights reserved.</p>
</footer>
{% endif %}
{% endblock %}
<script src="{% static 'js/app.js' %}?v=4"></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 %}
</body>
</html>
+26
View File
@@ -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"
}
]
}
+19
View File
@@ -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 %}
+166
View File
@@ -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>
+96
View File
@@ -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 || '/'));
});
+2 -1
View File
@@ -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);">
<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 %}
<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;">
<input type="checkbox"
name="push_notifications"
data-vapid-public-key="{{ vapid_public_key }}"
{% if user.push_notifications %}checked{% endif %}
style="margin-right: 0.75rem; width: 1rem; height: 1rem;">
<div>
View File
@@ -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>')
+33
View File
@@ -57,6 +57,39 @@ class User(AbstractUser):
def __str__(self):
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):
"""
+5 -2
View File
@@ -424,7 +424,9 @@ class ProfileView(View):
def get(self, request):
if not request.user.is_authenticated:
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):
if not request.user.is_authenticated:
@@ -440,7 +442,8 @@ class ProfileView(View):
user.save()
return render(request, 'users/profile.html', {
'success': 'Profile updated successfully.'
'success': 'Profile updated successfully.',
'vapid_public_key': settings.VAPID_PUBLIC_KEY,
})