42 Commits
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
Keith SmithandClaude Sonnet 5 a23600a486 Document custom recurrence RRULE patterns and fix stale field names
API.md now lists the supported custom RRULE patterns (every Wednesday,
every other Wednesday, every second Tuesday, every 15th, etc.) matching
the newly implemented dateutil.rrule evaluation. CLAUDE.md's Task model
reference also gets corrected: it listed recurrence_type/recurrence_interval
fields that never existed on the model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:21:50 -06:00
Keith SmithandClaude Sonnet 5 70dcc1f001 Add custom recurrence patterns (day-of-week, nth-weekday, day-of-month)
Recurring tasks were locked to fixed daily/weekly/biweekly/monthly/yearly
intervals. The `custom` recurrence type and `recurrence_rule` field already
existed in the model and API docs, but RRULE evaluation was a TODO stub
that silently fell back to weekly, and no UI exposed the option.

Implements real RRULE parsing via dateutil.rrule, and adds a builder UI
(day-of-week checkboxes for weekly, day-of-month or Nth-weekday for
monthly) so users can express patterns like "every other Wednesday" or
"every second Tuesday" without hand-writing RRULE strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 18:12:15 -06:00
Keith SmithandClaude Sonnet 5 4d88d10382 Point deployment configs at current git host, strip secrets from docs
Repo moved from git.firebugit.com to git.darksingularity.org; the old
host is gone, which would break both docker-compose's git build
contexts and the Portainer stack setup instructions. Also removes
production secret values (SECRET_KEY, DB passwords, SMTP password)
that were committed in plaintext in the deployment guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 17:15:42 -06:00
Keith SmithandClaude Sonnet 5 a2bc5de205 Default task sort to due date instead of raw model order
The "Default" option cleared the sort param and fell back to the
model's Meta.ordering (sort_order, -priority, due_date, created_at),
which read as broken/unsorted to users. Default is now due date
(earliest first), matching the existing due_date sort option.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 16:55:17 -06:00
Keith Smith 289b5f3856 Document self-registration toggle 2026-04-15 17:47:49 -06:00
Keith Smith 53180a9469 Disable self-registration by default 2026-04-15 17:42:40 -06:00
Keith Smith 586e98fd95 Ignore local tooling files 2026-04-15 17:36:36 -06:00
Keith SmithandClaude Sonnet 4.5 d7d3e8d072 Add comprehensive API documentation
Complete REST API documentation including authentication, rate limiting, all endpoints (users, tasks, tags, time tracking, sharing, sync, notifications), error handling, and usage examples. Intended for developers building applications that integrate with KeepItGoing via the API.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 17:22:32 -07:00
Keith SmithandClaude Sonnet 4.5 c8ffa615f0 Fix recurring task duplication bug
Prevent process_recurring_tasks from creating duplicate tasks for dates that already have completed instances. Previously only checked for pending tasks, causing old completed recurring tasks to reappear as pending when subsequent instances went overdue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 16:52:06 -07:00
Keith SmithandClaude Sonnet 4.5 cdcae378b7 Add CLAUDE.md documentation for Claude Code
Provides comprehensive guidance for future Claude Code instances including development commands, architecture overview, and common patterns.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 16:52:04 -07:00
Keith SmithandClaude Sonnet 4.5 7b4d024334 Add SITE_DOMAIN environment variable to all services
Fixes email notification links showing localhost:8000 instead of the
actual domain. The SITE_DOMAIN variable is now passed to web,
celery-worker, and celery-beat containers so emails include the
correct URL.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 09:15:33 -07:00
Keith SmithandClaude Sonnet 4.5 aca8abca56 Fix daily email timing to work across all timezones
Critical fix: Task now runs every hour instead of once at 6 AM UTC.
This ensures users in all timezones receive their email at 6 AM local time.

Changes:
- Run task every hour instead of once daily
- Check if it's 6-7 AM in user's timezone (1 hour window)
- Track sent emails in Notification model to prevent duplicates
- Add 'daily_email' notification type

Without this fix, users in timezones where 6 AM UTC is not morning
would never receive their daily email.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:34:39 -07:00
Keith SmithandClaude Sonnet 4.5 03cfdea42d Simplify notification system to daily email only
Replace complex notification system with a simple daily email:
- Send ONE email per day between 6-9 AM in user's timezone
- Show tasks due today and overdue tasks
- Only send if user has email_notifications enabled
- Remove all push notification logic
- Keep recurring task processor (runs daily at midnight)

This makes notifications much simpler and less intrusive while
still providing value to users.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:30:46 -07:00
Keith SmithandClaude Sonnet 4.5 842b958b43 Configure docker-compose to build from git repository
Update build contexts to pull from git repository URL instead of
local directory. This allows Portainer to build images directly
from the repository without requiring SSH access to the server.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:19:45 -07:00
Keith SmithandClaude Sonnet 4.5 ff534301f8 Use ps instead of pgrep for celery healthchecks
Change healthchecks to use 'ps aux | grep' instead of 'pgrep' since
procps may not be available in existing images. This works with the
base Python image without requiring a rebuild.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:10:02 -07:00
Keith SmithandClaude Sonnet 4.5 991d4f1eaa Add stack.env.example for Portainer git deployments
This allows deploying from git repository in Portainer by providing
an example environment variables file. Users can copy this and fill
in their actual values in Portainer's environment variables section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:06:40 -07:00
Keith SmithandClaude Sonnet 4.5 6e18baf502 Fix celery-worker healthcheck command
Replace celery inspect ping with pgrep check for reliability.
The inspect command was failing in healthcheck context while
the worker process itself was running fine.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:52:42 -07:00
Keith SmithandClaude Sonnet 4.5 e0ee377a20 Fix celery container health issues
- Fix import error in notifications/tasks.py (timezone.datetime -> datetime)
- Add healthchecks to celery-worker and celery-beat containers
- Add procps package to Dockerfile for pgrep command
- Add email environment variables to celery containers

The import error was causing celery workers to crash when loading tasks.
Missing healthchecks prevented proper container health monitoring.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:47:58 -07:00
Keith SmithandClaude Sonnet 4.5 9dd5d9c154 Replace pytz with zoneinfo for timezone handling
Fixes ModuleNotFoundError by using Python's built-in zoneinfo module
instead of the external pytz dependency. zoneinfo is the standard
library solution for timezone handling in Python 3.9+.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:32:38 -07:00
Keith SmithandClaude Sonnet 4.5 8d5faa8a6e Add error handling for timezone conversion
Fixes 500 error when user timezone is invalid or missing.
Now gracefully falls back to UTC if timezone conversion fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:30:52 -07:00
Keith SmithandClaude Sonnet 4.5 f2ca07d05d Fix overdue calculation to use user's timezone
Tasks were incorrectly showing as overdue when they were due today.
The issue was that the overdue check was comparing the due date
against UTC's "today" instead of the user's local "today".

Now uses the user's timezone setting to determine the current date
when checking if a task is overdue. This ensures tasks due "today"
only become overdue when the user's local date advances to tomorrow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:28:59 -07:00
Keith SmithandClaude Sonnet 4.5 c994458e24 Fix priority sorting to use correct order
Previously priority sorting was alphabetical (high < low < medium < urgent)
instead of by importance. Now uses Django Case/When to map priority strings
to numeric values: urgent=4, high=3, medium=2, low=1.

Fixes both queryset sorting and list sorting for overdue filter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:23:07 -07:00
Keith SmithandClaude Sonnet 4.5 5985fd010e Fix Chromium scrollbar styling to match dark theme
Add custom scrollbar styles for Chromium/Webkit browsers to match the
dark theme aesthetic. Firefox already had proper scrollbar styling, but
Chrome was showing default light gray scrollbars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:18:13 -07:00
52 changed files with 4641 additions and 434 deletions
+8
View File
@@ -24,6 +24,7 @@ stack.env
# IDE
.idea/
.vscode/
.codex
*.swp
*.swo
@@ -41,3 +42,10 @@ htmlcov/
# Firebase credentials
*firebase*.json
# Local Node artifacts used for tooling in this repo
package.json
package-lock.json
# Local AI assistant instructions (not for the repo)
CLAUDE.md
+1190
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@ FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
curl \
procps \
&& rm -rf /var/lib/apt/lists/*
# Create django user (non-root for security)
+15 -11
View File
@@ -8,7 +8,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
- Portainer installed and accessible
- Access to your Docker server via Portainer
- Git repository: `https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git`
- Git repository: `https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git`
- Git credentials configured in Portainer (or repository is public)
## Deployment Steps
@@ -31,7 +31,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
**Repository URL:**
```
https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git
https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git
```
**Repository reference:** `refs/heads/main`
@@ -40,17 +40,16 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
**Authentication:**
- If repository is private, enable authentication
- Username: `keith@firebugit.com`
- Password: (your Git password)
- Or use an access token if available
- Username: your Gitea account username
- Password: a Gitea access token (recommended over your account password)
### Step 4: Configure Environment Variables
Scroll down to the **Environment variables** section and paste the following:
Scroll down to the **Environment variables** section and paste the following, replacing every `<...>` placeholder with the actual value from your password manager / secrets store (never commit real values to this file):
```env
# Django Core Settings
SECRET_KEY=Zfb$!,3Gm\b,x~:a)9@k*Gun.#F{ju3dH-G8aWp4R"&NQ{1x>14v#)EbhpB*XMWD
SECRET_KEY=<django-secret-key>
DEBUG=False
ALLOWED_HOSTS=keepitgoing.app,www.keepitgoing.app,localhost
CSRF_TRUSTED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
@@ -59,8 +58,8 @@ SITE_DOMAIN=keepitgoing.app
# Database Configuration
DB_NAME=keepitgoing
DB_USER=keepitgoing
DB_PASSWORD=6SWh6Pz01hBFGNzWM0Pszz2moluqxyv7
DB_ROOT_PASSWORD=l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq
DB_PASSWORD=<db-password>
DB_ROOT_PASSWORD=<db-root-password>
DB_HOST=mariadb
DB_PORT=3306
@@ -73,13 +72,18 @@ EMAIL_HOST=smtp.dreamhost.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=kig@keepitgoing.app
EMAIL_HOST_PASSWORD=Frankenmonster1!
EMAIL_HOST_PASSWORD=<email-host-password>
DEFAULT_FROM_EMAIL=KeepItGoing <kig@keepitgoing.app>
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
@@ -210,7 +214,7 @@ To backup the database:
```bash
mysqldump -u root -p keepitgoing > /tmp/backup.sql
```
4. Enter root password: `l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq`
4. Enter the `DB_ROOT_PASSWORD` value from your Portainer stack environment variables
5. Use **File browser** to download `/tmp/backup.sql`
### Scaling
+135 -35
View File
@@ -9,7 +9,7 @@ A powerful Django-based task management system with time tracking, tag organizat
- **Priority Levels**: Low, Medium, High, and Urgent
- **Status Tracking**: Pending, In Progress, Completed, Cancelled
- **Due Dates & Times**: Set specific due dates and times for tasks
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, and Custom patterns
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, plus Custom RRULE patterns (e.g. every other Wednesday, every second Tuesday, every 15th of the month)
### Organization
- **Tag-Based System**: Organize tasks with multiple colored tags
@@ -25,8 +25,8 @@ A powerful Django-based task management system with time tracking, tag organizat
### User Management
- **Multi-user Support**: Full authentication and user management
- **Email Verification**: Secure email verification for new user registrations
- **Admin Approval**: Optional admin approval workflow for new users
- **Email Verification**: Secure email verification for admin-created or self-registered accounts
- **Admin Approval**: Optional approval workflow for new users
- **User Profiles**: Customizable profiles with timezone and notification preferences
- **Password Management**: Secure password change functionality
@@ -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
```
@@ -119,6 +129,15 @@ Set via environment variable:
export DJANGO_SETTINGS_MODULE=config.settings.development
```
### Registration Control
Self-registration is controlled with `ALLOW_SELF_REGISTRATION`.
- `ALLOW_SELF_REGISTRATION=False` disables public signup and requires an administrator to create accounts
- `ALLOW_SELF_REGISTRATION=True` enables the `/register/` page and `/api/users/register/` endpoint
If the variable is omitted, self-registration is disabled by default.
### Database Configuration
The application supports multiple database backends. Choose the one that fits your needs.
@@ -404,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)
@@ -422,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
@@ -623,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
@@ -646,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:
@@ -669,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
@@ -821,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
@@ -852,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
@@ -867,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;
@@ -875,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;
@@ -933,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
@@ -952,14 +1004,18 @@ 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
# When new users register:
# If ALLOW_SELF_REGISTRATION=True and new users register:
# 1. They receive verification email
# 2. After clicking verification link, you receive admin notification
# 3. Go to Admin > Users > select user(s) > Actions > "Approve selected users"
# 4. User receives approval email and can now log in
#
# If ALLOW_SELF_REGISTRATION=False:
# 1. Create users through Django admin or management commands
# 2. Mark them verified/approved as needed for your onboarding flow
```
### Production Checklist
@@ -994,7 +1050,8 @@ Complete this checklist before going live:
- [ ] Run `python manage.py migrate` to apply all migrations
- [ ] Run `python manage.py collectstatic` for static files
- [ ] Create superuser account for admin access
- [ ] Test user registration and email verification flow
- [ ] Confirm `ALLOW_SELF_REGISTRATION` is set correctly for production
- [ ] Test account onboarding flow (admin-created users or public registration)
- [ ] Test admin approval workflow
- [ ] Configure `SITE_DOMAIN` to your production domain
- [ ] Set appropriate `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS`
@@ -1002,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:**
@@ -1058,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
@@ -1141,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
@@ -1162,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
@@ -1192,7 +1296,3 @@ For issues and questions:
- Web interface with dark mode
- Responsive design
- RESTful API
---
Built with ❤️ by Firebug IT
+8 -8
View File
@@ -22,17 +22,17 @@ app.autodiscover_tasks()
# Celery Beat schedule for periodic tasks
app.conf.beat_schedule = {
'send-due-reminders': {
'task': 'notifications.tasks.send_due_reminders',
'schedule': crontab(minute='*/5'), # Every 5 minutes
},
'check-overdue-tasks': {
'task': 'notifications.tasks.check_overdue_tasks',
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
'send-daily-task-email': {
'task': 'notifications.tasks.send_daily_task_email',
'schedule': crontab(minute=0), # Every hour on the hour
},
'process-recurring-tasks': {
'task': 'notifications.tasks.process_recurring_tasks',
'schedule': crontab(minute=0), # Every hour
'schedule': crontab(hour=0, minute=0), # Daily at midnight
},
'send-scheduled-reminders': {
'task': 'notifications.tasks.send_scheduled_reminders',
'schedule': crontab(minute='*/5'), # Every 5 minutes - these are time-sensitive
},
}
+7 -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'
@@ -163,6 +163,7 @@ CELERY_RESULT_SERIALIZER = 'json'
SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true'
BILLING_ENABLED = SAAS_MODE
USAGE_LIMITS_ENABLED = SAAS_MODE
ALLOW_SELF_REGISTRATION = os.environ.get('ALLOW_SELF_REGISTRATION', 'False').lower() == 'true'
# Login settings
LOGIN_URL = '/login/'
@@ -174,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):
+39 -3
View File
@@ -46,7 +46,7 @@ services:
# =================================================================
web:
build:
context: .
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
dockerfile: Dockerfile
container_name: keepitgoing-web
restart: unless-stopped
@@ -74,6 +74,10 @@ services:
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
# Gunicorn
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
@@ -103,7 +107,7 @@ services:
# =================================================================
celery-worker:
build:
context: .
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
dockerfile: Dockerfile
container_name: keepitgoing-celery-worker
restart: unless-stopped
@@ -112,6 +116,16 @@ services:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
REDIS_URL: redis://redis:6379/0
EMAIL_HOST: ${EMAIL_HOST:-}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
networks:
- backend
depends_on:
@@ -120,13 +134,19 @@ services:
redis:
condition: service_healthy
command: celery-worker
healthcheck:
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*worker' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# =================================================================
# Celery Beat Service - Scheduled task scheduler
# =================================================================
celery-beat:
build:
context: .
context: https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git#main
dockerfile: Dockerfile
container_name: keepitgoing-celery-beat
restart: unless-stopped
@@ -135,6 +155,16 @@ services:
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
REDIS_URL: redis://redis:6379/0
EMAIL_HOST: ${EMAIL_HOST:-}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_ADMIN_EMAIL: ${VAPID_ADMIN_EMAIL:-}
networks:
- backend
depends_on:
@@ -143,6 +173,12 @@ services:
redis:
condition: service_healthy
command: celery-beat
healthcheck:
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*beat' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# =================================================================
# Named Volumes
+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),
),
]
+12 -2
View File
@@ -17,6 +17,7 @@ class Notification(models.Model):
('overdue', 'Overdue'),
('shared', 'Task Shared'),
('comment', 'Comment'),
('daily_email', 'Daily Email'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -49,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)
@@ -67,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']
+219 -202
View File
@@ -2,255 +2,273 @@
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__)
@shared_task
def send_due_reminders():
def notify_user(user, title, body, notification_type, task=None, url='/'):
"""
Check for tasks due soon and send reminders.
Runs every 5 minutes via Celery Beat.
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 tasks.models import Task
from users.models import DeviceToken
from .models import Notification, ScheduledReminder
now = timezone.now()
# Find scheduled reminders that need to be sent
reminders = ScheduledReminder.objects.filter(
remind_at__lte=now,
is_sent=False,
task__status__in=['pending', 'in_progress']
).select_related('task', 'task__user')
for reminder in reminders:
task = reminder.task
user = task.user
# Create notification
Notification.objects.create(
user=user,
notification_type='reminder',
title=f'Reminder: {task.title}',
message=f'Task "{task.title}" is due soon.',
task=task,
)
# Send push notifications
send_push_to_user.delay(
user_id=str(user.id),
title=f'Reminder: {task.title}',
body=f'Task is due soon.',
data={'task_id': str(task.id)}
)
# Mark as sent
reminder.is_sent = True
reminder.sent_at = now
reminder.save()
@shared_task
def check_overdue_tasks():
"""
Check for overdue tasks and notify users.
Runs daily.
"""
from tasks.models import Task
from .models import Notification
today = timezone.now().date()
notified = False
# Find overdue tasks that haven't been notified today
overdue_tasks = Task.objects.filter(
due_date__lt=today,
status__in=['pending', 'in_progress']
).select_related('user')
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}")
for task in overdue_tasks:
# Check if we already sent an overdue notification today
existing = Notification.objects.filter(
user=task.user,
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,
notification_type='overdue',
created_at__date=today
).exists()
)
if not existing:
Notification.objects.create(
user=task.user,
notification_type='overdue',
title=f'Overdue: {task.title}',
message=f'Task "{task.title}" is overdue.',
task=task,
)
return notified
send_push_to_user.delay(
user_id=str(task.user.id),
title=f'Overdue: {task.title}',
body='This task is overdue.',
data={'task_id': str(task.id)}
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_push_to_user(user_id, title, body, data=None):
def send_daily_task_email():
"""
Send push notification to all user devices.
Send daily email to users with their tasks due today.
Runs once per day and sends to users in their local morning time.
"""
from django.contrib.auth import get_user_model
from users.models import DeviceToken
from tasks.models import Task
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return
# Get all users who have email and/or push notifications enabled
users = User.objects.filter(
Q(email_notifications=True) | Q(push_notifications=True),
is_active=True
).distinct()
if not user.push_notifications:
return
notifications_sent = 0
current_utc_hour = timezone.now().hour
tokens = DeviceToken.objects.filter(user=user, is_active=True)
for user in users:
# Convert current UTC time to user's local time
try:
user_tz = ZoneInfo(user.timezone)
except Exception:
user_tz = ZoneInfo('UTC')
for token in tokens:
if token.platform == 'android':
send_fcm_notification.delay(token.token, title, body, data)
elif token.platform == 'web':
send_web_push_notification.delay(token.token, title, body, data)
elif token.platform == 'desktop':
# Desktop notifications are handled via WebSocket
pass
user_local_time = timezone.now().astimezone(user_tz)
user_local_hour = user_local_time.hour
# Only send if it's between 6-7 AM in the user's timezone
# (gives 1 hour window, task runs every hour)
if not (6 <= user_local_hour < 7):
continue
# Get today's date in user's timezone
today = user_local_time.date()
# Check if we already sent an email today
from .models import Notification
already_sent_today = Notification.objects.filter(
user=user,
notification_type='daily_email',
created_at__date=today
).exists()
if already_sent_today:
continue
# Get tasks due today
tasks_due_today = Task.objects.filter(
user=user,
due_date=today,
status__in=['pending', 'in_progress']
).order_by('due_time', 'priority', 'title')
# Get overdue tasks
overdue_tasks = Task.objects.filter(
user=user,
due_date__lt=today,
status__in=['pending', 'in_progress']
).order_by('due_date', 'due_time', 'priority', 'title')
# Only send email if there are tasks
if not tasks_due_today.exists() and not overdue_tasks.exists():
continue
# Prepare email content
subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}"
# Create plain text message
message_lines = [
f"Good morning! Here are your tasks for today:\n"
]
if overdue_tasks.exists():
message_lines.append(f"\n⚠️ OVERDUE TASKS ({overdue_tasks.count()}):")
for task in overdue_tasks[:10]: # Limit to 10
due_str = task.due_date.strftime('%b %d')
message_lines.append(f"{task.title} (due {due_str})")
if overdue_tasks.count() > 10:
message_lines.append(f" ... and {overdue_tasks.count() - 10} more")
if tasks_due_today.exists():
message_lines.append(f"\n📅 DUE TODAY ({tasks_due_today.count()}):")
for task in tasks_due_today:
time_str = task.due_time.strftime('%I:%M %p') if task.due_time else ''
priority_icon = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}.get(task.priority, '')
message_lines.append(f"{priority_icon} {task.title} {time_str}".strip())
message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}")
message_lines.append("\nYou can change your email preferences in your profile settings.")
message = '\n'.join(message_lines)
notified = False
# Send email
if user.email_notifications and user.email_verified:
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
)
notified = True
logger.info(f"Sent daily task email to {user.email}")
except Exception as e:
logger.error(f"Failed to send daily email to {user.email}: {e}")
# Send push
if user.push_notifications:
send_web_push_to_user(
user,
title=subject,
body=f"{tasks_due_today.count()} due today, {overdue_tasks.count()} overdue",
url='/',
)
notified = True
if notified:
# Record that we notified the user today (prevents duplicates)
Notification.objects.create(
user=user,
notification_type='daily_email',
title=subject,
message=f"Daily notification sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
)
notifications_sent += 1
logger.info(f"Daily task notification job completed. Notified {notifications_sent} users.")
return notifications_sent
REMINDER_MESSAGES = {
'reminder': lambda task: (f'Upcoming: {task.title}', f'"{task.title}" is due soon.'),
'due_soon': lambda task: (f'Due now: {task.title}', f'"{task.title}" is due now.'),
'overdue': lambda task: (f'Overdue: {task.title}', f'"{task.title}" is overdue.'),
}
@shared_task
def send_fcm_notification(token, title, body, data=None):
"""Send FCM notification to Android device."""
from django.conf import settings
# Only send if FCM is configured
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
return
try:
import firebase_admin
from firebase_admin import credentials, messaging
# Initialize Firebase if not already done
if not firebase_admin._apps:
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
message = messaging.Message(
notification=messaging.Notification(
title=title,
body=body,
),
data=data or {},
token=token,
)
messaging.send(message)
except Exception as e:
# Log error but don't fail
logger.error(f"FCM notification failed: {e}")
@shared_task
def send_web_push_notification(subscription_info, title, body, data=None):
"""Send Web Push notification."""
from django.conf import settings
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
if not vapid_private_key or not vapid_email:
return
try:
from pywebpush import webpush, WebPushException
import json
webpush(
subscription_info=json.loads(subscription_info),
data=json.dumps({
'title': title,
'body': body,
'data': data or {}
}),
vapid_private_key=vapid_private_key,
vapid_claims={'sub': f'mailto:{vapid_email}'}
)
except Exception as e:
logger.error(f"Web push notification failed: {e}")
@shared_task
def schedule_task_reminder(task_id):
def send_scheduled_reminders():
"""
Schedule a reminder for a task based on its due date and user preferences.
Called when a task is created or updated.
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 tasks.models import Task
from .models import ScheduledReminder
try:
task = Task.objects.get(id=task_id)
except Task.DoesNotExist:
return
due_reminders = ScheduledReminder.objects.filter(
is_sent=False, remind_at__lte=timezone.now()
).select_related('task', 'task__user')
# Remove existing scheduled reminders for this task
ScheduledReminder.objects.filter(task=task, is_sent=False).delete()
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
# Only schedule if task has a reminder time or due date
if task.reminder_at:
ScheduledReminder.objects.create(
task=task,
remind_at=task.reminder_at
)
elif task.due_date:
# Default reminder based on user preference
reminder_minutes = task.user.default_reminder_minutes
if task.due_time:
from datetime import datetime
due_datetime = datetime.combine(task.due_date, task.due_time)
due_datetime = timezone.make_aware(due_datetime)
else:
# Default to 9 AM on due date
due_datetime = timezone.make_aware(
timezone.datetime.combine(task.due_date, timezone.datetime.min.time())
).replace(hour=9)
reminder.is_sent = True
reminder.sent_at = timezone.now()
reminder.save(update_fields=['is_sent', 'sent_at'])
remind_at = due_datetime - timedelta(minutes=reminder_minutes)
if remind_at > timezone.now():
ScheduledReminder.objects.create(
task=task,
remind_at=remind_at
)
return sent_count
@shared_task
def process_recurring_tasks():
"""
Process completed recurring tasks and create next instances.
This runs periodically as a safety net to catch any tasks that weren't
This runs daily as a safety net to catch any tasks that weren't
automatically handled when marked as completed.
Runs every hour via Celery Beat.
"""
from tasks.models import Task
# Find completed recurring tasks that don't have a next instance created yet
# We look for tasks completed in the last 24 hours to avoid reprocessing old tasks
yesterday = timezone.now() - timedelta(days=1)
# Find completed recurring tasks from the last 48 hours
yesterday = timezone.now() - timedelta(days=2)
completed_recurring_tasks = Task.objects.filter(
status='completed',
@@ -261,16 +279,15 @@ def process_recurring_tasks():
tasks_created = 0
for task in completed_recurring_tasks:
# Check if a next recurrence already exists
# Look for pending tasks with the same title, user, and recurrence pattern
next_due_date = task.calculate_next_due_date()
if next_due_date:
# Check if we already created this recurrence
# Check for any task (pending OR completed) to avoid duplicates
existing = Task.objects.filter(
user=task.user,
title=task.title,
due_date=next_due_date,
recurrence=task.recurrence,
status='pending'
recurrence=task.recurrence
).exists()
if not existing:
+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)
+142
View File
@@ -0,0 +1,142 @@
# ===================================================================
# KeepItGoing Server - Portainer Stack Environment Configuration
# ===================================================================
# This file is an EXAMPLE for Portainer deployments from git repository.
# When deploying in Portainer:
# 1. Copy the contents of this file
# 2. Paste into the "Environment variables" section in Portainer
# 3. Replace all placeholder values with your actual values
# DO NOT commit your actual stack.env file to version control!
# ===================================================================
# Django Core Settings
# ===================================================================
# SECURITY WARNING: Generate a strong secret key!
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
SECRET_KEY=CHANGE_ME_GENERATE_A_STRONG_SECRET_KEY_50_PLUS_CHARACTERS
# Debug mode (MUST be False in production)
DEBUG=False
# Comma-separated list of allowed hosts
# Example: api.yourdomain.com,localhost
ALLOWED_HOSTS=yourdomain.com,localhost
# Comma-separated list of trusted origins for CSRF (must include https://)
# Example: https://api.yourdomain.com,https://yourdomain.com
CSRF_TRUSTED_ORIGINS=https://yourdomain.com
# Domain used in email links
SITE_DOMAIN=yourdomain.com
# Public self-registration toggle
# Set to False to require admin-created accounts
ALLOW_SELF_REGISTRATION=False
# ===================================================================
# Database Configuration (PostgreSQL)
# ===================================================================
DB_NAME=keepitgoing
DB_USER=keepitgoing
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
# ===================================================================
# Redis Configuration
# ===================================================================
REDIS_URL=redis://redis:6379/0
# ===================================================================
# Email Configuration
# ===================================================================
# Required for email verification, approval emails, and notifications
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# Gmail Example (use App Password, not regular password):
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=your-email@gmail.com
# EMAIL_HOST_PASSWORD=your-app-password
# SendGrid Example:
# EMAIL_HOST=smtp.sendgrid.net
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=apikey
# EMAIL_HOST_PASSWORD=your-sendgrid-api-key
# AWS SES Example:
# EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=your-aws-access-key
# EMAIL_HOST_PASSWORD=your-aws-secret-key
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
DEFAULT_FROM_EMAIL=KeepItGoing <noreply@yourdomain.com>
SERVER_EMAIL=server@yourdomain.com
# ===================================================================
# Email Verification Settings
# ===================================================================
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
# ===================================================================
# CORS Configuration
# ===================================================================
# Comma-separated list of allowed origins
# Example: https://yourdomain.com,https://app.yourdomain.com
CORS_ALLOWED_ORIGINS=https://yourdomain.com
# ===================================================================
# SaaS Mode
# ===================================================================
# Set to True for SaaS deployment with billing/usage limits
# Set to False for self-hosted deployment (no limits)
SAAS_MODE=False
# ===================================================================
# Web Server Configuration
# ===================================================================
# Port to expose the web service on (default: 8000)
# Change this if port 8000 is already in use
WEB_PORT=8000
# ===================================================================
# Gunicorn Configuration
# ===================================================================
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=60
# ===================================================================
# Push Notifications (Optional)
# ===================================================================
# Leave empty if not using push notifications
# Firebase Cloud Messaging (FCM)
FCM_CREDENTIALS_PATH=
# Web Push (VAPID keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_ADMIN_EMAIL=
# ===================================================================
# Additional Settings
# ===================================================================
# Django settings module (usually don't need to change)
DJANGO_SETTINGS_MODULE=config.settings.selfhosted
+30
View File
@@ -91,6 +91,36 @@
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
}
/* ============================================
Scrollbar Styling
============================================ */
/* Firefox scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--surface);
}
/* Chromium/Webkit scrollbar styling */
*::-webkit-scrollbar {
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
background: var(--surface);
}
*::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 6px;
border: 3px solid var(--surface);
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-hover);
}
/* ============================================
Reset & Base
============================================ */
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+180 -1
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
============================================ */
@@ -162,6 +273,74 @@ function closeDetail() {
closeMobileMenus();
}
/* ============================================
Custom Recurrence Builder
============================================ */
function toggleCustomRecurrenceBuilder(selectEl) {
const form = selectEl.closest('form');
const builder = form.querySelector('.custom-recurrence-builder');
if (builder) {
builder.classList.toggle('hidden', selectEl.value !== 'custom');
}
}
function toggleCustomFreqPanel(radioEl) {
const builder = radioEl.closest('.custom-recurrence-builder');
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
}
function assembleCustomRecurrenceRule(formEl) {
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
if (!recurrenceSelect || !ruleInput) {
return true;
}
if (recurrenceSelect.value !== 'custom') {
ruleInput.value = '';
return true;
}
const builder = formEl.querySelector('.custom-recurrence-builder');
if (!builder) {
return true;
}
const freqMode = builder.querySelector('.custom-freq-mode:checked');
const freq = freqMode ? freqMode.value : 'weekly';
if (freq === 'weekly') {
const panel = builder.querySelector('.custom-weekly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
if (days.length === 0) {
ruleInput.value = '';
return true;
}
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
} else {
const panel = builder.querySelector('.custom-monthly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
const mode = monthlyMode ? monthlyMode.value : 'day';
if (mode === 'day') {
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
} else {
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
const weekday = panel.querySelector('.custom-nth-weekday').value;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
}
}
return true;
}
/* ============================================
Mobile Navigation
============================================ */
+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
+170 -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,14 +155,104 @@ 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):
"""Check if task is overdue."""
if self.due_date and self.status not in ('completed', 'cancelled'):
from django.utils import timezone
return self.due_date < timezone.now().date()
from zoneinfo import ZoneInfo
# Get current date/time in user's timezone
try:
user_tz = ZoneInfo(self.user.timezone)
user_now = timezone.now().astimezone(user_tz)
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
user_now = timezone.now()
if self.due_date < user_now.date():
return True
if self.due_date == user_now.date() and self.due_time:
return self.due_time < user_now.time()
return False
return False
@property
@@ -202,9 +301,17 @@ class Task(models.Model):
elif self.recurrence == 'yearly':
next_date = base_date + relativedelta(years=1)
elif self.recurrence == 'custom' and self.recurrence_rule:
# TODO: Implement RRULE parsing for custom recurrence
# For now, default to weekly
next_date = base_date + timedelta(weeks=1)
from dateutil.rrule import rrulestr
from datetime import datetime, time as dt_time
dtstart = datetime.combine(base_date, dt_time.min)
try:
next_occurrence = rrulestr(self.recurrence_rule, dtstart=dtstart).after(dtstart, inc=False)
except Exception:
return None
if not next_occurrence:
return None
next_date = next_occurrence.date()
else:
return None
@@ -214,6 +321,65 @@ class Task(models.Model):
return next_date
@property
def parsed_custom_recurrence(self):
"""
Decompose recurrence_rule (an RRULE string) into simple fields for
prepopulating the custom recurrence builder UI. Never raises; returns
safe defaults for a blank or malformed rule.
"""
result = {
'freq': None,
'interval': 1,
'byweekday': [],
'monthly_mode': None,
'bymonthday': None,
'nth_ordinal': None,
'nth_weekday': None,
}
if not self.recurrence_rule:
return result
import re
params = {}
for part in self.recurrence_rule.split(';'):
if '=' in part:
key, value = part.split('=', 1)
params[key.strip().upper()] = value.strip()
freq = params.get('FREQ', '').upper()
if freq not in ('WEEKLY', 'MONTHLY'):
return result
result['freq'] = freq.lower()
try:
result['interval'] = int(params.get('INTERVAL', '1'))
except ValueError:
result['interval'] = 1
byday = params.get('BYDAY', '')
if freq == 'WEEKLY':
if byday:
result['byweekday'] = [d.strip() for d in byday.split(',') if d.strip()]
elif freq == 'MONTHLY':
bymonthday = params.get('BYMONTHDAY')
if bymonthday:
try:
result['bymonthday'] = int(bymonthday)
result['monthly_mode'] = 'day'
except ValueError:
pass
elif byday:
match = re.match(r'^(-?\d+)([A-Z]{2})$', byday)
if match:
result['nth_ordinal'] = int(match.group(1))
result['nth_weekday'] = match.group(2)
result['monthly_mode'] = 'nth'
return result
def create_next_recurrence(self):
"""
Create the next instance of this recurring task.
+178 -2
View File
@@ -1,3 +1,179 @@
from django.test import TestCase
from datetime import date, datetime, time as dt_time
from unittest.mock import patch
# Create your tests here.
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone as django_timezone
from tasks.models import Task
User = get_user_model()
class CustomRecurrenceTests(TestCase):
"""Tests for Task.calculate_next_due_date() with custom RRULE patterns."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123',
)
def make_task(self, **kwargs):
defaults = {
'user': self.user,
'title': 'Test task',
'recurrence': 'custom',
}
defaults.update(kwargs)
return Task.objects.create(**defaults)
def test_every_wednesday(self):
# 2026-01-07 is a Wednesday
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE')
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 14))
def test_every_other_wednesday(self):
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 21))
def test_every_second_tuesday(self):
# 2026-01-13 is the second Tuesday of January 2026
task = self.make_task(due_date=date(2026, 1, 13), recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 10))
def test_every_15th(self):
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 15))
def test_respects_recurrence_end_date(self):
task = self.make_task(
due_date=date(2026, 1, 7),
recurrence_rule='FREQ=WEEKLY;BYDAY=WE',
recurrence_end_date=date(2026, 1, 10),
)
self.assertIsNone(task.calculate_next_due_date())
def test_malformed_rule_returns_none(self):
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='not a valid rrule')
self.assertIsNone(task.calculate_next_due_date())
def test_create_next_recurrence(self):
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
new_task = task.create_next_recurrence()
self.assertIsNotNone(new_task)
self.assertEqual(new_task.due_date, date(2026, 2, 15))
self.assertEqual(new_task.recurrence_rule, 'FREQ=MONTHLY;BYMONTHDAY=15')
self.assertEqual(new_task.status, 'pending')
def test_parsed_custom_recurrence_weekly(self):
task = self.make_task(recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
parsed = task.parsed_custom_recurrence
self.assertEqual(parsed['freq'], 'weekly')
self.assertEqual(parsed['interval'], 2)
self.assertEqual(parsed['byweekday'], ['WE'])
def test_parsed_custom_recurrence_monthly_nth(self):
task = self.make_task(recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
parsed = task.parsed_custom_recurrence
self.assertEqual(parsed['freq'], 'monthly')
self.assertEqual(parsed['monthly_mode'], 'nth')
self.assertEqual(parsed['nth_ordinal'], 2)
self.assertEqual(parsed['nth_weekday'], 'TU')
def test_parsed_custom_recurrence_blank(self):
task = self.make_task(recurrence_rule='')
parsed = task.parsed_custom_recurrence
self.assertIsNone(parsed['freq'])
self.assertEqual(parsed['byweekday'], [])
class IsOverdueTests(TestCase):
"""Tests for Task.is_overdue, which must account for due_time, not just due_date (Gitea #7)."""
def setUp(self):
self.user = User.objects.create_user(
username='overdueuser',
email='overdueuser@example.com',
password='testpass123',
)
def make_task(self, **kwargs):
defaults = {'user': self.user, 'title': 'Test task', 'status': 'pending'}
defaults.update(kwargs)
return Task.objects.create(**defaults)
@patch('django.utils.timezone.now')
def test_not_overdue_before_due_time_same_day(self, mock_now):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0))
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0))
self.assertFalse(task.is_overdue)
@patch('django.utils.timezone.now')
def test_overdue_after_due_time_same_day(self, mock_now):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 18, 0, 0))
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0))
self.assertTrue(task.is_overdue)
@patch('django.utils.timezone.now')
def test_not_overdue_same_day_without_due_time(self, mock_now):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 59, 0))
task = self.make_task(due_date=date(2026, 1, 15), due_time=None)
self.assertFalse(task.is_overdue)
@patch('django.utils.timezone.now')
def test_overdue_once_date_has_passed(self, mock_now):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 16, 0, 1, 0))
task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(23, 0, 0))
self.assertTrue(task.is_overdue)
@patch('django.utils.timezone.now')
def test_completed_task_never_overdue(self, mock_now):
mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 0, 0))
task = self.make_task(due_date=date(2026, 1, 1), due_time=dt_time(9, 0, 0), status='completed')
self.assertFalse(task.is_overdue)
class WebFormDueDateTypeTests(TestCase):
"""
The web views assign due_date/due_time straight from request.POST (raw
strings), relying on Django to coerce them at the DB layer - which
leaves the in-memory instance holding strings after save(). Anything
that touches those fields on that same instance afterward (e.g.
Task.reschedule_reminders(), called from save() itself) must not choke
on that. Regression test for a real production crash on task edit.
"""
def setUp(self):
self.user = User.objects.create_user(
username='webformuser', email='webformuser@example.com', password='testpass123',
)
self.client.force_login(self.user)
def test_editing_due_date_and_time_does_not_crash(self):
task = Task.objects.create(user=self.user, title='Task')
response = self.client.post(f'/tasks/{task.id}/', {
'title': 'Task', 'status': 'pending', 'priority': 'medium',
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
})
self.assertEqual(response.status_code, 302)
task.refresh_from_db()
self.assertEqual(task.due_date, date(2026, 9, 10))
self.assertEqual(task.due_time, dt_time(17, 0))
def test_creating_task_with_due_date_does_not_crash(self):
response = self.client.post('/tasks/new/', {
'title': 'New task', 'status': 'pending', 'priority': 'medium',
'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none',
})
self.assertEqual(response.status_code, 302)
task = Task.objects.get(title='New task')
self.assertEqual(task.due_date, date(2026, 9, 10))
self.assertEqual(task.due_time, dt_time(17, 0))
def test_quick_add_task_with_due_date_does_not_crash(self):
response = self.client.post('/tasks/quick-add/', {'title': 'Quick task', 'due_date': '2026-09-10'})
self.assertEqual(response.status_code, 302)
task = Task.objects.get(title='Quick task')
self.assertEqual(task.due_date, date(2026, 9, 10))
+44 -15
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
@@ -218,13 +219,21 @@ class DashboardView(View):
if not request.user.is_authenticated:
return redirect('login')
today = timezone.now().date()
# Get today's date in user's timezone
from zoneinfo import ZoneInfo
try:
user_tz = ZoneInfo(request.user.timezone)
user_now = timezone.now().astimezone(user_tz)
today = user_now.date()
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
today = timezone.now().date()
# Get filter parameters
current_filter = request.GET.get('filter', 'all')
current_tag_id = request.GET.get('tag')
selected_task_id = request.GET.get('selected')
current_sort = request.GET.get('sort', 'default')
current_sort = request.GET.get('sort', 'due_date')
# Base query
tasks = Task.objects.filter(
@@ -271,20 +280,37 @@ class DashboardView(View):
# Apply sorting before converting to list
if not isinstance(tasks, list):
# Define priority order for sorting
from django.db.models import Case, When, IntegerField, F
priority_order_case = Case(
When(priority='urgent', then=4),
When(priority='high', then=3),
When(priority='medium', then=2),
When(priority='low', then=1),
default=0,
output_field=IntegerField(),
)
if current_sort == 'due_date':
# Sort by due date (nulls last), then priority
from django.db.models import F
tasks = tasks.order_by(F('due_date').asc(nulls_last=True), '-priority')
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
F('due_date').asc(nulls_last=True), '-priority_order'
)
elif current_sort == 'due_date_desc':
# Sort by due date descending (nulls last), then priority
from django.db.models import F
tasks = tasks.order_by(F('due_date').desc(nulls_last=True), '-priority')
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
F('due_date').desc(nulls_last=True), '-priority_order'
)
elif current_sort == 'priority':
# Sort by priority, then due date
tasks = tasks.order_by('-priority', 'due_date')
# Sort by priority (high to low), then due date
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'-priority_order', 'due_date'
)
elif current_sort == 'priority_low':
# Sort by priority (low to high), then due date
tasks = tasks.order_by('priority', 'due_date')
tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'priority_order', 'due_date'
)
# else: default ordering from model (sort_order, -priority, due_date, created_at)
# Convert to list if not already (for overdue filter)
@@ -293,7 +319,7 @@ class DashboardView(View):
# Apply sorting to lists (for overdue filter case)
else:
priority_order = {'high': 3, 'medium': 2, 'low': 1}
priority_order = {'urgent': 4, 'high': 3, 'medium': 2, 'low': 1}
if current_sort == 'due_date':
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)))
elif current_sort == 'due_date_desc':
@@ -441,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')
@@ -454,6 +480,8 @@ class TaskDetailView(View):
else:
task.recurrence = 'none'
task.recurrence_rule = request.POST.get('recurrence_rule', '') if task.recurrence == 'custom' else ''
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
@@ -513,9 +541,10 @@ 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 '',
)
tag_ids = request.POST.getlist('tags')
@@ -531,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=3"></script>
<script src="{% static 'js/offline-db.js' %}?v=7"></script>
<script src="{% static 'js/offline-sync.js' %}?v=10"></script>
<script src="{% static 'js/app.js' %}?v=9"></script>
{% block extra_js %}{% endblock %}
</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 || '/'));
});
+102
View File
@@ -0,0 +1,102 @@
<!-- Recurrence -->
{% with parsed=task.parsed_custom_recurrence %}
<div class="form-group">
<label class="form-label" for="{{ id_prefix }}recurrence">Recurrence</label>
<select class="form-select" id="{{ id_prefix }}recurrence" name="recurrence" onchange="toggleCustomRecurrenceBuilder(this)">
<option value="none" {% if not task or task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
<option value="custom" {% if task.recurrence == 'custom' %}selected{% endif %}>Custom</option>
</select>
</div>
<input type="hidden" name="recurrence_rule" value="{{ task.recurrence_rule|default:'' }}">
<div class="custom-recurrence-builder form-group {% if not task or task.recurrence != 'custom' %}hidden{% endif %}" style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="radio" class="custom-freq-mode" value="weekly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq != 'monthly' %}checked{% endif %}>
Weekly
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="radio" class="custom-freq-mode" value="monthly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq == 'monthly' %}checked{% endif %}>
Monthly
</label>
</div>
<!-- Weekly panel -->
<div class="custom-weekly-panel {% if parsed.freq == 'monthly' %}hidden{% endif %}">
<div class="form-group">
<label class="form-label">Every
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
week(s) on:
</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="SU" {% if "SU" in parsed.byweekday %}checked{% endif %}> Su
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="MO" {% if "MO" in parsed.byweekday %}checked{% endif %}> Mo
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="TU" {% if "TU" in parsed.byweekday %}checked{% endif %}> Tu
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="WE" {% if "WE" in parsed.byweekday %}checked{% endif %}> We
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="TH" {% if "TH" in parsed.byweekday %}checked{% endif %}> Th
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="FR" {% if "FR" in parsed.byweekday %}checked{% endif %}> Fr
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="SA" {% if "SA" in parsed.byweekday %}checked{% endif %}> Sa
</label>
</div>
</div>
</div>
<!-- Monthly panel -->
<div class="custom-monthly-panel {% if parsed.freq != 'monthly' %}hidden{% endif %}">
<div class="form-group">
<label class="form-label">Every
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
month(s):
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="radio" class="custom-monthly-mode" value="day" {% if parsed.monthly_mode != 'nth' %}checked{% endif %}>
On day
<input type="number" class="custom-bymonthday" min="1" max="31" value="{{ parsed.bymonthday|default:1 }}" style="width: 60px;">
of the month
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="radio" class="custom-monthly-mode" value="nth" {% if parsed.monthly_mode == 'nth' %}checked{% endif %}>
On the
<select class="custom-nth-ordinal form-select" style="width: auto;">
<option value="1" {% if parsed.nth_ordinal == 1 %}selected{% endif %}>First</option>
<option value="2" {% if parsed.nth_ordinal == 2 %}selected{% endif %}>Second</option>
<option value="3" {% if parsed.nth_ordinal == 3 %}selected{% endif %}>Third</option>
<option value="4" {% if parsed.nth_ordinal == 4 %}selected{% endif %}>Fourth</option>
<option value="-1" {% if parsed.nth_ordinal == -1 %}selected{% endif %}>Last</option>
</select>
<select class="custom-nth-weekday form-select" style="width: auto;">
<option value="SU" {% if parsed.nth_weekday == "SU" %}selected{% endif %}>Sunday</option>
<option value="MO" {% if parsed.nth_weekday == "MO" %}selected{% endif %}>Monday</option>
<option value="TU" {% if parsed.nth_weekday == "TU" %}selected{% endif %}>Tuesday</option>
<option value="WE" {% if parsed.nth_weekday == "WE" %}selected{% endif %}>Wednesday</option>
<option value="TH" {% if parsed.nth_weekday == "TH" %}selected{% endif %}>Thursday</option>
<option value="FR" {% if parsed.nth_weekday == "FR" %}selected{% endif %}>Friday</option>
<option value="SA" {% if parsed.nth_weekday == "SA" %}selected{% endif %}>Saturday</option>
</select>
</label>
</div>
</div>
</div>
{% endwith %}
+2 -13
View File
@@ -8,7 +8,7 @@
</button>
</div>
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
@@ -75,18 +75,7 @@
</div>
{% endif %}
<!-- Recurrence -->
<div class="form-group">
<label class="form-label" for="detail-recurrence">Recurrence</label>
<select class="form-select" id="detail-recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
{% include 'tasks/_recurrence_fields.html' with id_prefix='detail-' task=task %}
<!-- Save Button (moved inside form, before subtasks) -->
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
+1 -2
View File
@@ -9,7 +9,6 @@
<div style="display: flex; align-items: center; gap: var(--space-md);">
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
<option value="default" {% if current_sort == 'default' %}selected{% endif %}>Default</option>
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
@@ -76,7 +75,7 @@ if (timerDisplay) {
function updateSort() {
const sortValue = document.getElementById('sort-select').value;
const url = new URL(window.location.href);
if (sortValue === 'default') {
if (sortValue === 'due_date') {
url.searchParams.delete('sort');
} else {
url.searchParams.set('sort', sortValue);
+2 -12
View File
@@ -10,7 +10,7 @@
</div>
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
<form method="post">
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<div class="form-group">
@@ -55,17 +55,7 @@
</div>
</div>
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" selected>None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
{% include 'tasks/_recurrence_fields.html' with id_prefix='' %}
{% if tags %}
<div class="form-group">
+2 -26
View File
@@ -19,7 +19,7 @@
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
<!-- Main Form -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
<form method="post">
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<div class="form-group">
@@ -81,31 +81,7 @@
</div>
{% endif %}
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% include 'tasks/_recurrence_fields.html' with id_prefix='' task=task %}
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
+2
View File
@@ -33,8 +33,10 @@
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
{% if allow_self_registration %}
<p class="auth-footer">
Don't have an account? <a href="{% url 'register' %}">Register</a>
</p>
{% endif %}
</div>
{% endblock %}
+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):
"""
+44 -14
View File
@@ -3,6 +3,7 @@ User views for KeepItGoing.
"""
from django.contrib.auth import get_user_model, login, logout
from django.conf import settings
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views import View
@@ -27,6 +28,13 @@ from .throttles import LoginRateThrottle, RegisterRateThrottle
User = get_user_model()
def self_registration_disabled_response():
"""Standard response payload for disabled self-registration."""
return {
'detail': 'Self-registration is disabled. Contact an administrator to create your account.'
}
# =============================================================================
# API Views
# =============================================================================
@@ -36,18 +44,18 @@ class UsersAPIRoot(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request):
return Response({
'endpoints': {
'register': '/api/users/register/',
'login': '/api/users/token/',
'refresh_token': '/api/users/token/refresh/',
'verify_email': '/api/users/verify-email/',
'resend_verification': '/api/users/resend-verification/',
'profile': '/api/users/profile/',
'change_password': '/api/users/change-password/',
'devices': '/api/users/devices/',
}
})
endpoints = {
'login': '/api/users/token/',
'refresh_token': '/api/users/token/refresh/',
'verify_email': '/api/users/verify-email/',
'resend_verification': '/api/users/resend-verification/',
'profile': '/api/users/profile/',
'change_password': '/api/users/change-password/',
'devices': '/api/users/devices/',
}
if settings.ALLOW_SELF_REGISTRATION:
endpoints['register'] = '/api/users/register/'
return Response({'endpoints': endpoints})
class RegisterAPIView(generics.CreateAPIView):
@@ -59,6 +67,12 @@ class RegisterAPIView(generics.CreateAPIView):
throttle_classes = [RegisterRateThrottle]
def create(self, request, *args, **kwargs):
if not settings.ALLOW_SELF_REGISTRATION:
return Response(
self_registration_disabled_response(),
status=status.HTTP_403_FORBIDDEN
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
@@ -284,6 +298,7 @@ class LoginView(View):
'error': error,
'email': email,
'can_resend': can_resend,
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
})
def post(self, request):
@@ -319,6 +334,7 @@ class LoginView(View):
'error': error,
'email': email,
'can_resend': can_resend,
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
})
@@ -340,9 +356,20 @@ class RegisterView(View):
def get(self, request):
if request.user.is_authenticated:
return redirect('dashboard')
if not settings.ALLOW_SELF_REGISTRATION:
return render(request, 'users/login.html', {
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
'allow_self_registration': False,
}, status=403)
return render(request, 'users/register.html')
def post(self, request):
if not settings.ALLOW_SELF_REGISTRATION:
return render(request, 'users/login.html', {
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
'allow_self_registration': False,
}, status=403)
email = request.POST.get('email')
username = request.POST.get('username')
password = request.POST.get('password')
@@ -397,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:
@@ -413,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,
})