Version: 3.3
Date: 2026-06-07
Application: DMH — Task Reminder / Dead Man's Switch + Social + PWA
Stack: Next.js 15.5 (Frontend) + NestJS 11 (Backend) + PostgreSQL 15 + Redis 7
Coverage target: every user-facing feature, every REST endpoint, every WebSocket event, every scheduled job. For task-creation flows the bar is full matrix coverage — every task type × mode × repeat/format × postpone period × link-type combination must be created end-to-end and verified to launch with correct timer values, postpone correctly, and (for Watchdog) forced-start correctly.
Testing order: Onboarding → Watchdog (killer feature, all scenarios) → Other task types → Dashboard → Feed → Chat → Donations → Settings → System & Infrastructure
Changelog vs 3.2 (2026-06-07)
Header / Stack — frontend is Next.js 15.5 (was 15.3).
47.1.1 / 47.2.1 — corrected token TTLs to the shipped auth model: access token 60 min (was 15), refresh token ~1 year (365 days) and rolling — each refresh resets the clock, so the old 7-day figure is gone and there's no "Remember Me" branch.
47.2 / 47.4 (new) — added Multi-Device Sessions (Active Sessions) coverage: a second login no longer kills the first device; the /auth/sessions list + per-device / "log out others" endpoints (with IDOR ownership checks); and reuse-detection now revoking only the affected device's session instead of every session for the account.
Changelog vs 3.1 (2026-05-18)
Header / coverage target — restated that task-creation coverage is matrix-complete, not sample-based. Walking just a "happy path" reminder + watchdog no longer satisfies the spec.
B.0 (new) — added an explicit Matrix Test Coverage Requirements section before Watchdog, defining the exact combination grid the tester must construct and verify (subtask chain types × link types × postpone periods × forced-start path) for both Watchdog and every Reminder sub-mode.
7.3 / 9.1 — fixed previous mislabel: Period 1 is 5 Minutes, not "1 Minute". Added the canonical postpone delay table (POSTPONE_DELAY_MS) so the tester can reconcile UI vs backend timing exactly. Also documented the DMS 10-minute floor: any watchdog postpone with a period < 10 min is clamped server-side and client-side to 10 min.
15.4 (new) — added a Timer Precision Verification sub-section that requires the tester to record observed countdown vs computed start_at for every reminder mode + repeat variant and confirm drift is under 1 second.
Changelog vs 3.0 (2026-05-18)
2.1.2 — feature grid is now 6 cards (Action Chains added).
5.4.1 — /forgot-password no longer 404s; renders a stub page (self-service reset still not implemented).
7.1 — rewrote Watchdog form fields to match the subtask-chain architecture (Email/SMS/Telegram/API/Publish subtasks under "Task Chain (Payload)"). Old per-task recipient/subject/content/attachment fields no longer exist; the same data is now stored on EMAIL subtasks. Adds 7.1.5 for the ESP32 motion-sensor pairing entry point.
7.2.2 / 8.1.3 — minimum start-at lead time is firstWarning + 1 minutes (default 6, configurable in Settings → Tasks → Watchdog Warning Periods), not a hardcoded 10. Backend has no separate lower-bound check on start_at; the rule is enforced client-side.
9.2 — clarified that this endpoint is for internal-service / scripted postpone calls, not the ESP32 path. ESP32 motion sensors use POST /api/watchdog/motion under DeviceTokenGuard. HTTP error codes corrected: 4xx domain errors come back as 422 with TODO_* codes, not 400/404. Defense-in-depth fix (2026-05-18): the JwtAuthGuard was previously applied at the TodoController class level, so callers needed both JWT and internal key. The class-level guard was demoted to per-method; postpone now passes with x-internal-key alone.
19.1.2 — new-task default taskType follows the user's primary enabled task type from Settings → Tasks; for a default profile this is Reminder, not Watchdog.
35.1.2 — gating rule clarified: unverified users are capped at 1 watchdog total, regardless of status. Enforcement is on create only.
35.2 — donations methods are regional: UA region (Europe/Kiev timezone) shows Monobank Jar + Crypto; non-UA regions show Stripe Checkout.
55.1.1(implicit) — /this-route-does-not-exist 404 page now renders with a "Go home" link as the spec already required.
4.2.4 User not found for token's email — shows error
4.3 Navigation
4.3.1 "Back to login" link navigates to /login
5.1 Credentials Login
5.1.1 Login with valid email and password — redirects to /dashboard
5.1.2 Login with invalid email format — shows validation error
5.1.3 Login with wrong password — shows "Invalid credentials" error
5.1.4 Login with non-existent email — shows "Invalid credentials" error
5.1.5 Login with empty email — shows validation error
5.1.6 Login with empty password — shows validation error
5.1.7 Password visibility toggle (eye icon) shows/hides password text
5.1.8 Form inputs disabled while login is processing (spinner visible)
5.1.9 "Sign up" link navigates to registration page
5.2 OAuth Login
5.2.1 Click Google button — redirects to Google OAuth, returns to /dashboard
5.2.2 Click GitHub button — redirects to GitHub OAuth, returns to /dashboard
5.2.3 OAuth with email already used by different provider — shows "Email already in use with different provider"
5.2.4 OAuth with existing same-provider account — updates token, logs in successfully
5.2.5 OAuth with new email — creates new user automatically
5.3 URL Parameters & Redirect Behavior
5.3.1?error=OAuthAccountNotLinked shows error "Email already in use with different provider"
5.3.2?new=user shows legend "Please check the confirmation link in your email"
5.3.3 Successful login redirects to /dashboard
5.3.4 Unauthenticated access to /dashboard redirects to /login
5.3.5 Authenticated user accessing /login redirects to /dashboard
5.4 Forgot Password
5.4.1 Navigate to /forgot-password — page exists (no 404). Current build renders a stub: "Password reset is not available yet" with a "Back to login" link and a link to Report-to-Admin. Self-service password reset is not yet implemented; users on credentials login must contact the admin or use OAuth.
5.5 Logout
5.5.1 Logout clears session and redirects to /login
5.5.2 After logout, accessing /dashboard redirects to /login
5.5.3 After logout, refresh token cookie is cleared
6.1 Telegram WebApp Sign-In
6.1.1POST /api/auth/telegram/webapp with valid signed initData — creates/logs in user, returns access token + sets refresh/csrf cookies
6.1.4 Rate limit: 10/min on /auth/telegram/webapp — 429 after exceeding
6.1.5 First Telegram sign-in for a new user creates a user record and skips email verification (if policy allows)
6.2 Account Linking
6.2.1 Already-authenticated user posts valid initData → Telegram identity is linked to current account (no new account)
6.2.2 Telegram ID already linked to another account → 409 Conflict (or equivalent) with clear message; no silent takeover
6.2.3 After successful linking, subsequent Telegram sign-ins log the user into the linked account
6.3 TelegramAutoSignIn Component
6.3.1 Opening app inside Telegram WebApp — TelegramAutoSignIn detects context and performs silent sign-in (no explicit click)
6.3.2 Auto sign-in failure surfaces a non-blocking error and falls back to normal login UI
6.3.3 Non-Telegram browser context — component is a no-op (no console errors, no unexpected redirects)
6.3.4 With existing session — component does not attempt duplicate sign-in
Block B — Watchdog: Dead Man's Switch (Killer Feature)
Testing sequence: Create a new task → select Watchdog type → configure all fields below → activate → test each scenario in order.
Walking the "happy path" once is not sufficient for this block. The tester must produce, activate and verify the lifecycle of every cell of the following matrices. A cell is "covered" only when the task was actually created, activated, observed to count down (timer value matches the configured start_at), then postponed/forced/triggered as appropriate, and ended up in the correct terminal status.
B.0.1 Watchdog — Subtask Chain × Link Matrix (must construct each combination)
For each cell create a watchdog, activate it, then run it to expiry on a short timer (use start_at = now + 6 min, first warning 5 / second warning 2) and confirm every subtask fires per its link type. Re-run the same chain twice — once where every subtask succeeds, once where the first non-PARALLEL subtask is configured to fail (bad recipient / unreachable webhook) — and confirm the parent task's error_message and final status are correct.
Chain shape
Subtask types covered
Link semantics asserted
Single subtask
EMAIL only
trivial baseline (timer → email → COMPLETED)
Single subtask
SMS only
provider call fires once on expiry
Single subtask
TELEGRAM only
bot/chat message delivered
Single subtask
API_TRIGGER only
HTTP request to webhookUrl with configured method/headers/body
Single subtask
PUBLISH_INFO only
email blast to all journalist emails
2 SEQUENTIAL
EMAIL → SMS
SMS fires only after EMAIL completes; if EMAIL fails 3×, SMS does not fire
2 PARALLEL
EMAIL ∥ TELEGRAM
both fire simultaneously; one failing does not abort the other
2 BLOCKING
API_TRIGGER → EMAIL
EMAIL only fires when API_TRIGGER returns 2xx; non-2xx blocks downstream chain
3 mixed
EMAIL → (SMS ∥ TELEGRAM) → API_TRIGGER
head SEQUENTIAL → PARALLEL group in the middle → BLOCKING tail; observe correct grouped ordering
SMS retries 3× and finishes Failed, TELEGRAM still Completed, PUBLISH_INFO still fires, parent surfaces SMS error message
Acceptance: §7.4 below references this matrix. The block is not passable until every row has a Completed (or expected-Failed) execution recorded with timestamps.
B.0.2 Watchdog — Postpone Period Matrix
For each of the 7 postpone period values, create one watchdog with that period, activate it (start_at = now + 6 min), wait ~30 s, then click the in-app postpone tap-area. Record new_start_at and confirm it equals max(now + max(period_delay, 10 min), start_at). Repeat the cell with metadata.postponeShiftMode === true set on the task and confirm new_start_at == start_at + period_delay.
Period (UI label)
Numeric postpone_period
Delay used by default mode
DMS floor applied?
5 Minutes
1
5 min
YES → clamped to 10 min
Hour
2
1 h
no
Day
3
24 h
no
Week
4
7 d
no
Month
5
30 d
no
Year
6
365 d
no
10 Minutes (fallback)
7
10 min
exact floor
B.0.3 Watchdog — Forced Start Matrix
For each cell, navigate to /dashboard/forced-start/[id] of an Active watchdog and exercise the indicated path. Confirm the resulting task status, terminal notified flag, and resulting redirect.
Forced-start path
Configured forced-start delay
Trigger source
Expected final state
Auto-fire at 0
default 30 s
countdown reaches zero
InProgress → Completed (chain fired)
Auto-fire at 0
custom 5 s (Settings)
countdown reaches zero
InProgress → Completed in ≤6 s
Manual fire
default 30 s
user clicks red 3D button before 0
InProgress → Completed immediately
Postpone exit
default 30 s
user clicks amber Postpone-for-[period] button
Active (new start_at); redirect /dashboard; toast "Postponed!"
Guard: not-watchdog
n/a
navigate while task is a Reminder
toast + redirect, no chain fire
Guard: not-active
n/a
navigate while task is Inactive
toast + redirect
Guard: bad id
n/a
navigate to /dashboard/forced-start/does-not-exist
toast + redirect
B.0.4 Reminder — Mode × Configuration Matrix
Each cell = one created+activated reminder with the indicated combination. Tester records (a) the displayed countdown on the dashboard at t=0, (b) the actual fire time, (c) the post-fire status and re-scheduled start_at (if applicable).
Mode
Variant
Knobs to exercise
Expected behavior to verify
ALARM
NONE (one-time)
sound on/off
fires once → Completed, no re-schedule
ALARM
DAILY — single weekday
alarmDays = [Mon] only
re-schedules to next Mon at the same HH:MM
ALARM
DAILY — subset
alarmDays = [Mon, Wed, Fri]
re-schedules to nearest selected weekday ahead
ALARM
DAILY — every day
alarmDays = [0..6]
re-schedules +1 day
ALARM
DAILY — empty
alarmDays = []
Save/Run blocked with validation error
ALARM
MONTHLY
start_at on 15th
re-schedules to 15th of next month
ALARM
MONTHLY — edge
start_at = Jan 31
re-schedules to Feb 28/29 (clamp), then Mar 31
ALARM
YEARLY
start_at = Jul 4
re-schedules to Jul 4 next year
ALARM
YEARLY — edge
start_at = Feb 29 (leap)
re-schedules to Feb 28 in non-leap year
POMODORO
Default 25/5/15, 4 sessions
sound on
cycle work → short break → work … → long break after 4
POMODORO
Custom 1/1/1, 2 sessions
min boundaries
full cycle observable in under 4 minutes
POMODORO
Working hours on, 9–18
start_at outside hours
task defers to next 09:00
POMODORO
Working hours on, end ≤ start
invalid
Save/Run rejected with error
POMODORO
Default
manual deactivate
does not auto-complete on its own
SPACED_REPETITION
MULTI_DAY default
7 intervals (1,3,7,14,30,60,120 days)
repetitionIndex increments after each fire; final fire → Completed
SPACED_REPETITION
INTRA_DAY default
6 intervals (5, 15, 30, 60, 120, 240 min)
same lifecycle, observable in one short session
SPACED_REPETITION
Edit mid-cycle
change content text only
repetitionIndex / completedRepetitions preserved (no reset)
B.0.5 Postpone × Task Type Matrix
For each (TaskType × Period) cell that supports postpone (Reminder Alarm, Watchdog), call the postpone control once on an Active task and confirm:
new_start_at matches the formula (see B.0.2).
For Reminder Alarm: countdown re-renders instantly with the new value.
For Watchdog: notified=false after postpone, warning notifications re-arm.
Toast "Postponed!" shown.
Reminder POMODORO and SPACED_REPETITION are out of scope here — they don't use the postpone tap-area.
B.0.6 Sequencing Guarantees
A run is only "covered" if the tester observed and recorded these properties for every Watchdog chain in B.0.1 and every Reminder mode in B.0.4:
Timer display matches the computed start_at within ±1 second when the task transitions from Inactive → Active.
After expiry, status flows Active → InProgress → Completed (or → Failed after 3 retries) without skipping intermediate states longer than 5 s.
No duplicate fire: the same task does not trigger its chain / notification twice for a single start_at.
For repeating reminders, the next start_at is in the future when re-rendered (no past-time leftovers).
7.1 Task Form — Watchdog-Specific Fields
7.1.1 "Task Chain (Payload)" section renders with an "Add Subtask" control and a type selector (Email / SMS / Telegram / API Trigger / Publish Info)
7.1.3 Email subtask is required for activation — running a Watchdog with no subtasks (or with a subtask missing required fields) blocks Run with "watchdog requires at least one valid subtask"
7.1.4 Subtask link-type selector (Sequential / Parallel / Blocking) and per-row delete control are visible
7.1.5 "Connect ESP32" control is visible alongside Add Subtask (motion-sensor pairing entry point)
7.2 Validation Errors Specific to Watchdog
7.2.1 Attempting to Run watchdog without a valid subtask — red border on Task Type field with "watchdog requires at least one valid subtask" / "requires valid subtasks with all required fields filled"
7.2.2 Start date set less than firstWarning + 1 minutes from now (default: 6 minutes — see Settings → Tasks → Watchdog Warning Periods) — red border on start date with "watchdog must start at least N minutes from now"; "Maybe the time is out of date?" modal offers the suggested minimum start time
7.3 Postpone Period Options
7.3.1 Period 1 (5 Minutes) — 5 minute postpone delay for non-DMS tasks; for Watchdog clamped to 10 min by DMS_MIN_DELAY_MS
7.3.2 Period 2 (Hour) — 1 hour postpone delay
7.3.3 Period 3 (Day) — 24 hour postpone delay
7.3.4 Period 4 (Week) — 7 day postpone delay
7.3.5 Period 5 (Month) — 30 day postpone delay
7.3.6 Period 6 (Year) — 365 day postpone delay
7.3.7 Period 7 (10 Minutes) — 10 minute postpone delay (default/minimum guarantee, exactly the DMS floor)
7.3.8 Backend table POSTPONE_DELAY_MS (watchdog-postpone.policy.ts) matches the values above 1:1 — divergence is a regression
7.4 Subtask Chain Matrix — Required Combinations (see B.0.1)
For each row construct the chain in the Task Form, save, activate with start_at = now + 6 min, observe the chain execute end-to-end on expiry, and record the outcome.
7.4.1Single EMAIL — recipient receives "[DMH TRIGGERED]" email at expiry; parent task status = Completed
7.4.2Single SMS — provider call recorded once; parent Completed (or Failed gracefully if no provider creds)
7.4.3Single TELEGRAM — message delivered via configured bot/chat; parent Completed
7.4.4Single API_TRIGGER — webhookUrl receives request with method/headers/body from config; non-2xx → retries up to 3
7.4.5Single PUBLISH_INFO — every configured journalist email receives the payload; ≥1 email required for save
7.4.62 SEQUENTIAL (EMAIL → SMS) — SMS only fires after EMAIL Completed; if EMAIL fails 3×, SMS is not invoked
7.4.72 PARALLEL (EMAIL ∥ TELEGRAM) — both fire near-simultaneously (timestamps within ~1 s); one failing does not abort the other
7.4.82 BLOCKING (API_TRIGGER → EMAIL) — EMAIL fires only when API_TRIGGER returns 2xx; non-2xx blocks the rest of the chain
7.4.93 mixed (EMAIL → (SMS ∥ TELEGRAM) → API_TRIGGER) — head runs first, both PARALLEL members run concurrently after head, tail runs only after both PARALLEL complete
7.4.11 Subtask reordering: drag-reorder updates previousSubtaskId and execution order on next run
7.4.12 Subtask deletion mid-edit removes the row and clears any references in the next-subtask graph
7.4.13 Cycle detection: forming a cycle (A → B → A) in the chain UI surfaces a validation error and blocks Save
8.1 Activation
8.1.1 Run watchdog with a valid subtask chain — task moves to Active (status=1), timer starts
8.1.2 Run watchdog without a valid subtask — shows "watchdog requires at least one valid subtask", task stays Inactive
8.1.3 Start date must be at least firstWarning + 1 minutes in the future (default 6 min) — enforced at Run time on the client; backend has no separate start-at lower-bound check
8.2 Timer Display (Active State)
8.2.1 Active watchdog shows countdown timer (HH:MM:SS format)
8.2.2 Long duration timers show Y:M:D:HH:MM:SS format
8.2.3 Expanded active watchdog shows large postpone tap-area with "Tap to postpone" message
8.2.4 Task description is shown in the expanded view
8.3 Force Button
8.3.1 Force button (dark red) visible only on Active Watchdog tasks when expanded
8.3.2 Clicking Force button navigates to /dashboard/forced-start/[id]
8.4 Deactivation
8.4.1 Deactivate button on an Active watchdog — task reverts to Inactive (status=0)
8.4.2 Timer stops and countdown disappears after deactivation
8.5 Active-Timer Verification Matrix (each row = a fresh watchdog)
Goal: confirm the displayed countdown equals the configured start_at - now at activation moment, with drift < 1 s after 30 s of observation.
8.5.1start_at = now + 6 min, postpone period = 5 Minutes (period=1) → countdown 06:00 → 05:30 in 30 s
8.5.2start_at = now + 6 min, postpone period = 10 Minutes (period=7) → countdown 06:00 → 05:30 in 30 s
8.5.3start_at = now + 1 h, postpone period = Hour (period=2) → countdown 1:00:00 → 0:59:30 in 30 s
8.5.4start_at = now + 1 day, postpone period = Day (period=3) → countdown shows 23:59:xx and decreases monotonically
8.5.5start_at = now + 7 days, postpone period = Week (period=4) → Y:M:D:HH:MM:SS long-form rendering
8.5.6start_at = now + 30 days, postpone period = Month (period=5) → long-form rendering, decreases by 1 s/s
8.5.7start_at = now + 365 days, postpone period = Year (period=6) → long-form rendering correct
8.5.8 Activate the same watchdog with runSubtasksOnMotion=true (metadata) — chain also fires on each confirmed motion event in addition to expiry
9.1 In-App Postpone
9.1.1 Tap/click the large postpone area of an expanded Active watchdog — postpones by the configured period
9.1.2 Toast "Postponed!" shown immediately after postpone
9.1.3 Timer recalculates: new_start_at = Date.now() + period_delay (based on current time, not original start_at)
9.1.4notified flag reset to false after postpone
9.2 Internal Postpone (API, used by internal services / scripted callers)
9.2.1POST /api/todos/:id/postpone with valid x-internal-key — postpones task and returns { success, postponed_by_ms, new_start_at }
9.2.2 Only works on Watchdog (DEADMANS_SWITCH) task type — non-watchdog returns 422 with TODO_BAD_REQUEST
9.2.3 Only works on Active (status=1) tasks — InProgress or Inactive returns 422 with descriptive message
9.2.4 Missing or wrong x-internal-key header — 401 "Invalid internal API key"
9.2.5 Non-existent task id — 422 TODO_NOT_FOUND
9.2.6 Inactive task (status=0) — 422 with Postpone is only allowed from Active status (current: INACTIVE)
9.2.7 InProgress task (status=2) — 422 with Postpone is only allowed from Active status (current: IN_PROGRESS)
Note: This endpoint is the internal-service / scripted postpone path. The real ESP32 motion-sensor flow is not/api/todos/:id/postpone — IoT devices pair via POST /api/watchdog/pair (gets a device token) and then report motion to POST /api/watchdog/motion (@UseGuards(DeviceTokenGuard)), which postpones the linked watchdog server-side. See section 7.1.5 / Settings → Tasks → Watchdog motion-sensor section for the device-side spec.
9.3 Postpone Matrix — Default Mode
Default mode formula: new_start_at = max(now + max(period_delay, DMS_MIN_DELAY_MS), start_at).
Set up start_at = now + 6 min, leave Active for ~30 s, click postpone tap-area, record new_start_at.
9.3.1 period=1 (5 Minutes) → new_start_at ≈ now + 10 min (clamp to DMS floor)
9.3.2 period=2 (Hour) → new_start_at ≈ now + 60 min
9.3.3 period=3 (Day) → new_start_at ≈ now + 24 h
9.3.4 period=4 (Week) → new_start_at ≈ now + 7 d
9.3.5 period=5 (Month) → new_start_at ≈ now + 30 d
9.3.6 period=6 (Year) → new_start_at ≈ now + 365 d
9.3.7 period=7 (10 Minutes) → new_start_at ≈ now + 10 min (exactly the floor)
9.3.8notified flag is reset to false after every postpone above
Shift mode formula: new_start_at = start_at + period_delay (regardless of now). Button is always active because the new deadline cannot land in the past.
9.4.1 period=2 (Hour), start_at = now + 6 min → new_start_at = start_at + 1 h (not now + 1 h)
9.4.2 period=3 (Day), task already overdue (start_at < now) → still anchors to start_at + 24 h
9.4.3 Toggle off shift mode mid-cycle: subsequent postpone reverts to default-mode formula
10.1 Layout
10.1.1 Full-screen dark overlay covers entire viewport
10.1.2 Task title displayed in gold at the top
10.1.3 Task settings summary shown below the title
10.1.4 Large red 3D countdown button in the center
10.1.5 Amber postpone button at the bottom
10.2 Countdown Behavior
10.2.1 Countdown starts from the configured delay (default 30 seconds — configurable in Settings)
10.2.2 Number decrements every second
10.2.3 Auto-triggers force start when countdown reaches 0
10.2.4 Click the red button — manually force-starts immediately
10.2.5 Button shows "..." while the action is executing
10.3 Postpone from Forced Start
10.3.1 Click "Postpone for [period]" — postpones task by configured period, redirects to /dashboard
10.3.2 Toast "Postponed!" shown after postpone
10.4 Error & Guard States
10.4.1 Non-existent task ID — error toast, redirect to /dashboard
10.4.2 Task is not Watchdog type — error toast, redirect
10.4.3 Task is not Active — error toast, redirect
10.4.4 Shows "Loading..." with pulse animation while fetching task data
10.5 Forced-Start Variation Matrix (see B.0.3)
For each cell, navigate to /dashboard/forced-start/[id] of an Active watchdog with a non-trivial subtask chain (≥2 SEQUENTIAL members) and exercise the indicated path.
10.5.1Auto-fire @ default 30 s — let countdown drop to 0; chain fires; final status = Completed; subtasks all Completed
10.5.2Auto-fire @ custom 5 s — set Settings → Forced Start Delay to 5 s; reload page; new task auto-fires in ≤6 s
10.5.3Auto-fire @ custom 60 s — set 60 s in Settings; navigate; verify large countdown starts at 60, decrements 1/s
10.5.4Manual fire mid-countdown — at ~15 s remaining click the red 3D button; chain fires immediately; button shows "..." while executing
10.5.5Postpone exit @ period=2 (Hour) — click amber Postpone button; redirect /dashboard; toast "Postponed!"; task back to Active with start_at ≈ now + 1 h
10.5.6Postpone exit @ period=1 (5 Minutes) — same, but start_at ≈ now + 10 min due to DMS floor
10.5.7Guard: Reminder task id — manually browse to /dashboard/forced-start/[reminderId]: toast + redirect, no fire
10.5.8Guard: Inactive task id — same for an Inactive watchdog: toast + redirect, no fire
10.5.9Guard: bad id — /dashboard/forced-start/does-not-exist: toast + redirect
10.5.10Re-entry — fire once, return to /dashboard, then navigate to the same forced-start URL again: should redirect because the task is no longer Active
11.1 Normal Trigger (Timer Expiry)
11.1.1 Timer reaches zero → email sent to owner email address
11.1.2 If recipient email was configured → email also sent to recipient
11.1.3 Email subject line is prefixed with "[DMH TRIGGERED]"
11.1.4 Email body contains dark-themed HTML with the task content
11.1.5 Attachment included in the email if an archive file was uploaded
11.1.6 Task status changes to Completed (status=3) after successful email delivery
11.1.7 If email sending fails 3 times → task status becomes Failed (status=5) with error message stored
11.2 Failure Scenarios
11.2.1 SMTP server down — email delivery retried 3 times, then task status becomes Failed with descriptive error
11.2.2 No SMTP configured in environment — DMS tasks fail immediately with descriptive error (no crash)
11.2.3 No OWNER_EMAIL configured — DMS tasks fail (no recipients); error stored on task
11.2.4 Bull queue job fails 3 times — task marked Failed (status=5) with error message visible in dashboard
11.3 Failed Task Display in Dashboard
11.3.1 Failed watchdog task shows error message in a red box
Requires a paired ESP32 device (real or curl-simulated via POST /api/watchdog/motion with the device token). Each row exercises a different runSubtasksOnMotion × motion-arrival timing scenario.
11.5.1runSubtasksOnMotion=false, motion arrives during Active phase → watchdog is postponed by configured period; chain does not fire
11.5.2runSubtasksOnMotion=true, motion arrives during Active phase → watchdog is postponed and the subtask chain executes once per motion event
11.5.3runSubtasksOnMotion=true, two motion events arrive within 1 s — debounce-or-fire semantics match spec (record observed)
11.5.4 Motion arrives after task became InProgress (chain already firing) — no double-fire; no late postpone applied
11.5.5 Motion arrives after task became Failed — request rejected/ignored; no status flip
Block C — Other Task Types
12.1 Alarm Mode
12.1.1 Select Reminder → Alarm mode in task type options
12.1.2 Repeat schedule: One-time — task completes after single trigger
12.1.3 Repeat schedule: Daily — shows weekday selector (Mon–Sun); reschedules to next matching day after trigger
12.1.4 Daily weekday selector: unselect all days → shows validation error, blocks Save/Run
12.1.5 Daily weekday selector: select subset (e.g. Mon/Wed/Fri) → next occurrence matches first selected day ahead
12.1.6 Repeat schedule: Monthly — reschedules to same date next month
12.1.7 Repeat schedule: Yearly — reschedules to same date next year
12.1.8 Sound toggle — enable/disable notification sound for this alarm
12.1.9 Monthly/Yearly edge case: Jan 31 → next month/day clamps correctly (Feb 28/29); UI shows correct next start time
12.1.10 Monthly/Yearly: alarm history (if present) updates after each trigger (no duplicate triggers for same date)
For each row construct a fresh reminder (Alarm mode), set start_at = now + 2 min, activate, observe the countdown, let it fire, then assert the next-fire calculation and re-fire if applicable.
12.2.1NONE — sound OFF — fires once → Completed; no re-schedule; notified=true
12.2.2NONE — sound ON — same as above but audible notification at fire
12.2.3DAILY — single weekday (Mon) — after fire, next_start_at = next Monday at same HH:MM
12.2.4DAILY — subset (Mon/Wed/Fri) — after fire on Mon, next is Wed; after Wed → Fri; after Fri → next Mon
12.2.5DAILY — weekdays only (Mon–Fri) — Saturday fires forward to Monday, not Sunday
12.2.6DAILY — every day (all 7 selected) — next_start_at = start_at + 1 day
12.2.7DAILY — none selected — Save and Run both rejected with validation error
12.2.8MONTHLY — mid-month (15th) — re-schedules to 15th of next month, same HH:MM
12.2.9MONTHLY — edge Jan 31 — re-schedules to Feb 28 (or 29 in leap), then Mar 31; alarm history records exactly one fire per month
12.2.10YEARLY — mid-year (Jul 4) — re-schedules to Jul 4 of next year
12.2.11YEARLY — edge Feb 29 (leap) — re-schedules to Feb 28 in non-leap years; back to Feb 29 in the next leap year
12.2.12Postpone on Active alarm — postpone tap-area uses Date.now() + period_delay, notstart_at + period_delay; UI countdown re-renders to the new delta within 1 s
13.1 Configuration
13.1.1 Work Duration input (1–120 min, default 25)
13.1.2 Break Duration input (1–30 min, default 5)
13.1.3 Long Break Duration input (1–60 min, default 15)
13.1.4 Sessions before long break (1–10, default 4)
13.2 Behavior
13.2.1 Cycle: work → short break → work → ... → long break → work (repeats)
13.2.2 "Limit to working hours" checkbox — shows start/end hour dropdowns when enabled
13.2.3 Task defers to next working period when outside configured hours
13.2.4 Phase transitions visible via push notifications (work/break labels)
13.2.5 Working hours validation: start/end within 0–23; end > start (or shows error)
13.2.6 Pomodoro never auto-completes — user must stop or deactivate manually
13.3 Pomodoro Configuration Matrix (see B.0.4)
Test each cell by creating a fresh Pomodoro reminder with the indicated knobs, activating, then observing the first complete cycle. Use 1-min durations for quick verification where indicated.
13.3.1Defaults (25/5/15, 4 sessions) — first work phase is 25 min, first short break 5 min, after 4 sessions long break is 15 min
14.1.4 Content to memorize textarea — saved and shown at each review
14.2 Lifecycle
14.2.1 Review interval badges show completion status (gold=current, green=done, gray=future)
14.2.2 Task completes automatically after all intervals are finished
14.2.3 Completed repetitions counter updates correctly after each interval
14.2.4 Editing content mid-cycle does not reset repetitionIndex/completedRepetitions unless user explicitly resets
14.3 Spaced Repetition Format × Interval Matrix (see B.0.4)
For each cell construct a fresh Spaced Repetition reminder with the indicated knobs, activate, and observe at minimum the first 2 interval transitions plus the terminal Completed state. INTRA_DAY format is fully verifiable in one short session; MULTI_DAY needs server-side time shifting (Settings → Tools → Time Shifting) to compress.
14.3.1MULTI_DAY default intervals 1/3/7/14/30/60/120 days — repetitionIndex starts at 0; after first fire it's 1 and nextRepetitionDate ≈ start_at + 1 day
14.3.2MULTI_DAY at index 6 (last) — final fire flips status to Completed, completedRepetitions = 7
14.3.3INTRA_DAY default intervals 5/15/30/60/120/240 min — full cycle observable in ~7 h, or shorter via time-shifting
14.3.4INTRA_DAY at index 5 (last) — final fire flips status to Completed, completedRepetitions = 6
14.3.5Edit content only mid-cycle — change contentToMemorize text at index 2: repetitionIndex remains 2, history preserved
14.3.6Manual reset mid-cycle — explicit reset action returns index to 0 and recomputes nextRepetitionDate from now
14.3.7Postpone is not exposed for Spaced Repetition — no postpone tap-area in the expanded view
14.3.8Switch format mid-cycle (MULTI_DAY → INTRA_DAY) — task either rejects the change or resets the cycle per spec; document observed behavior
15.1 Timing & Firing
15.1.1 Countdown display updates every second when reminder is Active
15.1.2 When reminder reaches 00:00:00: notification fires once, notified becomes true (no duplicates)
15.1.3 Notification click navigates to /dashboard?todoId=<id> and opens the correct accordion
15.1.4 Alarm (repeat NONE): after firing → status becomes Completed and is no longer scheduled
15.1.5 Alarm (repeat DAILY/MONTHLY/YEARLY): after firing → next start_at computed correctly, notified reset to false
15.2 Phase Transitions
15.2.1 Pomodoro: after each phase end → next phase scheduled and notification matches phase type (work/short break/long break)
15.2.2 Spaced repetition: after each interval end → repetitionIndex increments and nextRepetitionDate updates; after last → status Completed
15.3 Background & Offline Behavior
15.3.1 Browser in background (tab not focused): reminder still fires (Service Worker local scheduling) within the browser's throttling limits
15.3.2 Browser closed but SW still active (PWA installed / SW running): reminder still fires via local SW scheduling
15.3.3 Clock skew: set device clock ±10 minutes (where feasible) — server-backed tasks still align via time shifting; local reminders follow device time (document expected behavior)
15.3.4 Timezone change while a reminder is Active: display updates and the due time remains correct
15.3.5 DST boundary (if applicable): repeating alarms schedule the next occurrence correctly (no double-fire, no skip)
15.4 Timer Precision Verification Matrix (see B.0.6)
For each row activate the indicated task type with start_at = now + N, then record the displayed countdown at activation moment and again after 30 s of observation. Drift must be under 1 second per 30 s of wall time.
15.4.1Watchdog, N = 6 min — at t=0 shows 06:00; at t=+30 s shows 05:30 (±1 s)
15.4.2Reminder Alarm NONE, N = 2 min — at t=0 shows 02:00; at t=+30 s shows 01:30
15.4.3Reminder Alarm DAILY, N = 2 min, single weekday subset — at t=0 shows 02:00; fires; after fire next display matches next selected weekday
15.4.4Pomodoro defaults, N = 25 min — first phase shows 25:00 → 24:30 after 30 s
15.4.5Pomodoro min, N = 1 min work — full cycle observable: 01:00 work → 01:00 break → 01:00 work …
15.4.6Spaced Repetition INTRA_DAY, N = 5 min to first review — at t=0 shows 05:00; decreases monotonically; at first fire repetitionIndex becomes 1
15.4.7Multiple active tasks of different types simultaneously — each timer decrements independently; no clobbering or duplicated counters
15.4.8Tab blur + re-focus after 60 s — the visible countdown jumps to the correct value (not 60 s behind)
16.1 Creation & Assignment
16.1.1 Create Personal Report from Dashboard; set assignee user/email
16.1.2 Share report to assignee (role ASSIGNEE) — assignee sees report in dashboard/shared list
16.2 Assignee Actions
16.2.1 Assignee can mark report Completed with an optional note; owner sees the note in report comments
16.2.2 Assignee can mark report Failed with an optional note; owner sees the note in report comments
16.2.3 Repeated updates append comments (no data loss); createdAt timestamps display correctly
17.1 Create Challenge (Dashboard)
17.1.1 Select Task Type filter = Challenge; create shows Challenge options (Public toggle, Max participants)
17.1.2 Create Public challenge: appears in feed/leaderboard after creation (eventual consistency acceptable)
17.1.3 Create Private challenge: does not appear in public feed/leaderboard
17.1.4 Max participants: setting to a number enforces join limit (join fails gracefully when full)
17.2.2 Join challenge increases participants count and shows you in participant list
17.2.3 Leave challenge updates participant status accordingly (dropped/removed per spec)
17.2.4 Report completion (optional comment) increases completion count and appears in reports tab
17.2.5 Tabs: participants/reports lists render and counts match server results
Block D — Dashboard
18.1 Layout
18.1.1 Sidebar visible on the left (50px)
18.1.2 Clock displays current time and date (updates every second)
18.1.3 Title shows "DEAD MAN'S HAND" (dark theme) or "POSTPONED REMINDER" (light theme)
18.1.4 Desktop: resizable left/right panels with drag divider
18.1.5 Mobile: single column layout, right panel hidden
18.1.6 Empty state shows "No tasks found" message
18.2 Task List Behavior
18.2.1 Tasks load on page mount
18.2.2 Tasks auto-refresh every 30 seconds when page is visible
18.2.3 Auto-refresh pauses when a task accordion is open
18.2.4 Tasks sorted by: status priority (Active → InProgress → Completed → Failed → Inactive), then start_at ASC
18.2.5 Loading skeleton shown during initial data fetch
18.2.6 Unauthorized API response triggers auto sign-out
18.3 Quick Create & Search
18.3.1 Type task name in input, click Plus button — creates new task
18.3.2 Search icon toggles between create and search modes
18.3.3 Search input filters visible tasks by title
18.4 Slash Commands (Search Field)
18.4.1 Type /report in search field and press Enter — navigates to /report page
18.4.2 Type /backup in search field and press Enter — navigates to /backup page
18.4.3 Type /exit in search field and press Enter — navigates to /exit page
18.4.4 Search field is cleared after successful slash-command navigation
18.4.5 Unknown slash command (e.g. /foo) — no navigation, field unchanged
18.4.6 Slash commands are case-insensitive (e.g. /Report works same as /report)
18.4.7/report, /backup, /exit are not reachable from sidebar navigation — slash command is the only entry point
19.1 Basic Creation
19.1.1 Enter title in quick-create input, press Plus — task appears in list with status Inactive
19.1.2 New task defaults: status=Inactive, postpone_period=Minute, taskType = the primary enabled task type from Settings → Tasks → Available Task Types (Reminder for a default profile; switches to Watchdog/Report/Challenge when the user's primary type changes, or when the Task Type filter is explicitly set to one of those)
19.1.3 Toast "Good Job!" shown on successful creation
19.2 Validation
19.2.1 Empty title — creation rejected or error shown
19.2.2 Title displays correctly in task list after creation
35.1.2 Verified email requirement gating: unverified user with ≥1 existing Watchdog task hits VerificationRequiredModal on create of a second Watchdog (gate is "1 watchdog max, regardless of status" — see UNVERIFIED_MAX_TOTAL_WATCHDOG in dmh-nest-api/src/todo/policies/todo-limits.policy.ts; no separate gate exists on Run/Update)
35.1.3 User plan endpoint returns expected limits (active watchdog, total tasks, contacts, shares)
35.2 Donations Page UI
35.2.1/donations loads donation summary for the inferred region (based on the user's timezone). Region-specific payment methods are shown: UA region (Europe/Kiev) renders Monobank Jar + Crypto (coming soon); non-UA regions render Stripe Checkout.
47.1.2 Expired token triggers automatic refresh (if within 10 min of expiry)
47.1.3 API calls include Authorization: Bearer <token> header
47.1.4 401 response triggers token refresh and request retry
47.2 Refresh Token
47.2.1 Refresh token is valid ~1 year (365 days) and rolling — every successful refresh resets the expiry, so an active user never expires; only ~1 year of complete inactivity forces re-login. There is no "Remember Me" branch (every login path gets the same 365-day session).
47.2.2 Refresh rotates: old token revoked, new token issued — only the presented device's token, never the user's other sessions
47.2.3 Stored in httpOnly cookie (path: /api/auth)
47.2.6 Reuse detection: replaying a previously-rotated refresh token revokes only the affected device's session (logout on that device); the user's other devices keep working
47.3 OAuth Tokens
47.3.1 Google provider: token validated via Google userinfo endpoint
47.3.2 GitHub provider: token validated via GitHub API + email fetch
47.3.3 Provider header sent with API requests (x-provider)
47.3.4 Stored AccessToken checked first before external validation
47.4 Multi-Device Sessions (Active Sessions)
47.4.1 Logging in on a second device does not terminate the first device's session — both stay active
47.4.2GET /auth/sessions returns only the caller's device list (label from User-Agent, last-active time, "this device" flag); never another user's sessions
47.4.3PATCH /auth/sessions/current sets the current device's label from the client-reported UA and collapses older duplicate sessions from the same device profile
47.4.4DELETE /auth/sessions/:id logs out one device — deletes that session + its tokens; the device is rejected on its next request
47.4.5DELETE /auth/sessions/:id for a session belonging to another user → 403/404 (IDOR ownership guard), nothing deleted
47.4.6DELETE /auth/sessions/others logs out every device except the current one; the current device stays signed in
47.4.7 Settings → Active sessions UI lists devices, shows the "this device" badge, and exposes per-device Log out + "Log out all other devices"
47.4.8 Access token whose session row was deleted is rejected by the guard (immediate "log out this device")
47.4.9 Rotation-race grace window: a just-rotated refresh token replayed within ~30 s returns the already-issued new tokens (no spurious reuse-detection logout)
48.1 Keepalive
48.1.1 While on /dashboard (or any protected route), frontend pings /api/auth/keepalive on the configured interval
48.1.2 Access token close to expiry is refreshed automatically on keepalive; user stays logged in
48.1.3 Transient keepalive failure (network) does not immediately sign user out — retries with backoff
48.1.4 Refresh token truly expired → keepalive eventually triggers sign-out and redirect to /login
48.2 Verification Gate
48.2.1 Unverified user navigating to a gated feature (e.g., /chat, share-task) sees VerificationRequiredModal
48.2.2 Modal "Resend verification" triggers POST /api/auth/resend-verification (rate-limited 3/min)
48.2.3 After email is verified (and user refreshes / re-navigates), the gate no longer blocks the feature
48.2.4SessionVerificationGate around pages allows public content while blocking protected actions behind the gate
49.1 Global
49.1.1 60 requests per 60 seconds globally — 429 after exceeding
49.2 Endpoint-Specific
49.2.1 Login: max 5 attempts per minute — 429 after 6th
49.2.2 Register: max 3 attempts per minute — 429 after 4th
49.2.3 Refresh: max 10 attempts per minute — 429 after 11th
49.2.4 Get token by token: max 10 per minute
49.2.5 Get user by email: max 10 per minute
50.1 Rate Limiting Middleware
50.1.1 Global rate limit: 60 requests per 60 seconds — returns 429 after exceeding
50.1.2 Login endpoint: max 5 attempts per minute — returns 429 after 6th attempt
50.1.3 Register endpoint: max 3 attempts per minute — returns 429 after 4th attempt
50.1.4 Refresh token endpoint: max 10 attempts per minute — returns 429 after 11th attempt
50.2 IP Blacklist
50.2.1POST /api/anti-fraud/blacklist with valid IP adds to blacklist
50.2.2 Blacklisted IP receives 403 Forbidden on all requests
50.2.3DELETE /api/anti-fraud/blacklist/:ip removes IP from blacklist
50.2.4GET /api/anti-fraud/blacklist/:ip returns blacklist status and reason
50.2.5 Blacklist supports TTL (time-to-live) for temporary bans
50.3 Security Events Logging
50.3.1 Bot detection events logged with IP, user-agent, timestamp
50.3.2 Rate limit exceeded events logged
50.3.3 IP blacklisting events logged
50.3.4 Suspicious activity events logged
50.3.5 Security logs retained for 30 days
50.4 Security Stats
50.4.1GET /api/anti-fraud/stats returns total blacklisted IPs
50.4.2 Returns count of active rate limits
50.4.3 Returns count of recent blocks
50.5 Internal Network Bypass
50.5.1 Requests from internal Docker network bypass anti-fraud checks
Disposable account for "self-block" language option test (section 57.5)
Sections 7–11 (Watchdog) — start with a fresh Inactive task; the scheduler runs every minute so set start_at close to now (at least 10 min ahead) to keep wait times short. For email trigger tests, use a real inbox or Mailhog/MailDev locally.
Section 9.2 (External Postpone) requires an API testing tool (Postman/curl) with the x-internal-key header from .env.
Sections 12–14 (Reminder types) require waiting for the scheduler cron (every minute) or using the Run button with a near-future start_at.
Section 15.3 (Background/offline firing) depends on browser and SW scheduling constraints; results vary by OS sleep settings — document actual behavior rather than asserting strict timing.
Section 24 (IndexedDB) — use Chrome devtools Application → IndexedDB to inspect local state and confirm sync.
Section 30 (WebSocket) — easiest to verify with Network → WS filter in devtools; open two browser profiles for multi-user event tests.
Section 44 (Passkeys / WebAuthn) requires HTTPS context and a WebAuthn-capable device/browser.
Section 49 (Rate limiting) requires rapid repeated requests to trigger 429 responses; use a script or Postman runner.
Section 52 (Referral) requires two fresh accounts and waiting one cron cycle (or manually triggering the weekly job) for reward propagation.
Section 55.5 (Chunk error) — reproducible by running QA build, loading the app, redeploying with a different build hash, then navigating in the old tab.
Section 57.5 (Self-block) — use a throwaway account; the block is permanent and requires direct DB access to undo.