diff --git a/.gitignore b/.gitignore index ce797ed..29e634c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ *.log npm-debug.log* +_metadata # Build artifacts *.zip diff --git a/AGENTS.md b/AGENTS.md index 4b29030..32bafec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,114 +1,37 @@ -# AGENTS Operational Manual - -## Introduction -Nirvanify is a Manifest V3 Chrome extension focused on reducing digital distractions and tracking study habits. The codebase is modular and uses Web Components for all UI pieces. This document serves as the onboarding guide for future AI agents working on the project. - -## Repository Overview -``` -Nirvanify/ -├── manifest.json # Extension configuration -├── index.html # SPA entry point with router outlet -├── components/ # All Web Component modules -│ ├── app.js # Registers components and initializes router -│ ├── router.js # Hash based router -│ ├── sidebar.js # Main navigation sidebar -│ ├── pages/ # Page level components loaded by router -│ ├── blocklist/ # Blocklist widgets used on blocklist page -│ ├── sessions/ # Session editor and list components -│ ├── dashboard/ # Dashboard widgets (analytics, stats, etc.) -│ ├── challenge/ # Placeholder for single challenge page -│ ├── challenges/ # Listing of all challenges -│ ├── interventions/ # Interventions management page -│ ├── stats/ # Statistics page component -│ ├── timer/ # Timer settings page component -│ └── storage/ # Sync storage utilities -├── css/ # Global stylesheets -├── assets/ # Images and icons -├── lib/ # Third‑party libraries (katex, purify, etc.) -└── verify_extension.py # Optional validation script -``` - -### Major Page Components -- **Dashboard (`#/dashboard`)** – Shows analytics widgets, streak tracker and session overview. -- **Block Groups (`#/blocklist`)** – Customizes website block sets with schedules, time limits and interventions. -- **Interventions (`#/interventions`)** – Manages challenge types presented when accessing blocked sites. -- **Study Sessions (`#/sessions`)** – Allows creation and editing of focus session templates (e.g., Pomodoro). -- **Challenges (`#/challenges`)** – Placeholder listing for various mini‑challenges. -- **Timer Settings (`#/timer`)** – Configures study timer behavior. -- **Statistics (`#/stats`)** – Displays historical usage stats (currently mocked). -- **Settings (`#/settings`)** – General extension settings. - -The router defined in `components/router.js` listens to `hashchange` events and injects the appropriate page element into `
` inside `index.html`. - -## Component Architecture -All UI is built with the Custom Elements API. Each component resides in the `components/` directory or one of its subfolders. Components should encapsulate their markup and styles using the Shadow DOM and expose data via attributes or custom events. Reusable widgets (e.g., blocklist editors, timer controls) are placed directly under `components/`, whereas page-level views live under `components/pages/`. - -## Storage Layer -All persistent state lives in Chrome Sync Storage. The `components/storage/` directory provides wrapper modules so components never call `chrome.storage` directly. - -### `blocklist-storage.js` -Handles saving and loading of block groups and related metadata: -- `saveBlockTabs` / `loadBlockTabs` -- `saveSelectedIntervention` / `loadSelectedIntervention` -- `saveBlockGroupMeta` / `loadBlockGroupMeta` -- `saveBlockTimeState` / `loadBlockTimeState` -- `saveAdditionalSettings` / `loadAdditionalSettings` -- `deleteBlockTab` -Blocklist widgets import these helpers to persist user-defined rules and schedules without directly calling the Chrome Storage API. - -### `session-storage.js` -Manages study session templates. On first load it creates a set of default sessions using existing block lists. Key functions include: -- `saveSessions` / `loadSessions` -- `saveSession` / `deleteSession` -- `saveBlockSets` / `loadBlockSets` - -These utilities are used by `nirva-session-editor` and related components to create, update and remove session templates. - -All functions return Promises for easy async/await usage inside components. - -## Key Functional Flows -### Blocklist Customization -1. **`nirva-block-group-tabs`** lets users switch between block sets and stores tab meta in sync storage. -2. **`nirva-block-site-list`**, **`nirva-block-time-selector`**, **`nirva-hourly-allowance`**, **`nirva-intervention-type`** and **`nirva-additional-settings`** collect rules for the active tab. -3. `components/pages/blocklist-page.js` aggregates these widgets and persists changes via the storage utilities when the user clicks "Save Changes". - -### Study Session UI -1. The sessions page contains **`nirva-sessions-list`** (left column) and **`nirva-session-editor`** (right column). -2. `nirva-sessions-list` loads all sessions on connect and exposes events when a session is selected or created. -3. `nirva-session-editor` loads the selected session for editing and saves/deletes/duplicates sessions through `session-storage.js`. - -### Dashboard and Timer -- **Dashboard widgets** such as `nirva-study-session`, `nirva-sessions`, `nirva-analytics`, `nirva-past-sessions`, `nirva-full-stats` and `nirva-streak` show timer controls and usage statistics. Most data is placeholder but wired for future analytics modules. -- **Timer** functionality (start, pause, reset) is scaffolded in `dashboard/study-session.js`. The timer page simply provides configuration options. - -## Development Notes -- Web Components typically use `attachShadow({mode: 'open'})` for styling isolation. Some widgets use `closed` mode where external access is not needed. -- All pages import shared styles (`css/variables.css`, `css/layout.css`, `css/components.css`) at the start of their templates. -- Components communicate via custom events (e.g., `tab-selected`, `session-saved`), allowing loose coupling. -- The repository does not include a build step. Load the extension directly from this directory when testing. -- An optional validation script (`verify_extension.py`) can be run with `python3 verify_extension.py` if you need to confirm manifest fields. - -## Engineering Guidelines -- **Indentation**: Use 4 spaces for all code blocks (HTML, CSS, JavaScript, Python, etc.). Do not use tabs. -- **Naming Conventions**: - - Variables: `like_this` - - Functions: `likeThis` - - JavaScript filenames: `like-this.js` - - Constants: `LIKE_THIS` -- **Modularity**: Keep each file narrowly focused. UI rendering, storage access and routing logic should live in separate modules under `components/` or its subdirectories. -- **Custom Elements**: Register each Web Component with `customElements.define()` inside its own file. Filenames must match the element name using hyphens (e.g., `nirva-session-editor.js`). - Add the file to `components/app.js` so it is imported and registered during initialization. -- **Shadow DOM**: Use `attachShadow({ mode: 'open' })` to encapsulate styles unless the component requires private internals. -- **Documentation**: Every major function or component class should include a short comment block explaining its purpose, expected inputs and outputs and any events it emits. -- **Shared Utilities**: Import storage helpers or other utilities from `components/storage/` rather than accessing Chrome APIs directly. -- **Placement**: New reusable UI widgets belong under `components/` while higher-level views go under `components/pages/`. - -## Quick Start for Agents -1. Install dependencies if needed using `npm install`. -2. In Chrome, open `chrome://extensions`, enable Developer Mode and load this folder as an unpacked extension. -3. Navigate to `index.html` in the extension to view the full dashboard. -4. Modify or extend components within `components/` to add features or fix issues. Use the storage utilities to persist new settings. -5. Optionally run `python3 verify_extension.py` if you want to confirm the manifest and required files. - ---- -This manual reflects the current state of the Nirvanify codebase and should be used as the primary reference for future development tasks. +# Repository Guidelines + +## Project Structure & Module Organization +- Entry points: `manifest.json`, `index.html` (router outlet), `components/app.js`. +- UI: Web Components under `components/` (pages in `components/pages/`; widgets in folders like `blocklist/`, `sessions/`, `dashboard/`). +- Data: Storage modules in `storage/` (no direct `chrome.storage` calls), AJV schemas in `schema/`. +- Styles: `css/variables.css`, `css/layout.css`, `css/components.css`. +- Utilities: `components/utils/` and `utils/session-integration.js` (session orchestration). +- Assets and third‑party libs in `assets/` and `lib/`. + +## Build, Test, and Development Commands +- `npm install` — install optional dev dependencies. +- `python3 verify_extension.py` — quick manifest/file sanity check. +- Load in Chrome: `chrome://extensions` → enable Developer Mode → “Load unpacked” → select repo folder. + +## Coding Style & Naming Conventions +- Indentation: 4 spaces across JS/HTML/CSS/Python. +- Naming: variables `like_this`, functions `likeThis`, constants `LIKE_THIS`, JS files `like-this.js`. +- Custom Elements: filename matches element name; register with `customElements.define()` and import in `components/app.js`. +- Shadow DOM: `attachShadow({ mode: 'open' })` by default. +- Data: validate via `schema/*` before saving; use `storage/*` modules only. +- Events: use `session-*` and descriptive custom events for cross-component comms. + +## Testing Guidelines +- No formal test suite. Use manual QA via the loaded extension. +- Smoke checks: navigate to `#/dashboard`, `#/blocklist`, `#/sessions` and verify basic flows. +- Run `python3 verify_extension.py` before PRs to catch manifest/config drift. + +## Commit & Pull Request Guidelines +- Prefer Conventional Commits (e.g., `feat:`, `fix:`, `docs:`) with concise, present‑tense subjects. +- PRs: clear description, scope of change, linked issues, screenshots/GIFs for UI, and brief test notes. +- Keep diffs small and focused; avoid unrelated refactors. + +## Architecture & Agent Notes +- Data flow: Components ↔ Storage Modules ↔ Chrome Storage; validate with AJV schemas. +- Session workflows integrate block groups and interventions via `utils/session-integration.js`. +- Security: MV3 context—avoid direct HTML injection; use `lib/` sanitizers if rendering user input; minimize permissions. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..45fa6d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +## Unreleased + +### Fixed +- Router/deep-link refresh blank screen + - Root cause: On app bootstrap, `components/app.js` awaited `customElements.whenDefined()` for every route tag via `Promise.all`. Any route mapping to a tag that wasn't registered (e.g., `nirva-focus-redirect`) caused the promise to never resolve, blocking initial render after a hard refresh or deep-link entry. + - Fix: Render immediately and rely on the router to await only the active route's tag. Also added a placeholder page and registration for `nirva-focus-redirect` to prevent future deadlocks. + - Extra hardening: Session integration is now imported only when running inside an MV3 context to avoid `chrome.*` access in plain web runs and tests. + +### Added +- Overrides persistence and UI wiring: + - `components/schema/overrides.schema.js` and storage helpers to load/save overrides. + - `components/settings/overrides-settings.js` now updates storage and toggles background blocking via messaging. + - Timed emergency override (15 minutes) from the Sessions widget, with auto re-enable and persisted state. + - App now applies stored overrides on load (and cleans up expired ones). +- Routing regression test (`tests/router-refresh.test.js`) using Puppeteer when available; gracefully skips on environments without a supported headless browser. + diff --git a/NIRVANIFY_NEW_PORT_SUMMARY.md b/NIRVANIFY_NEW_PORT_SUMMARY.md deleted file mode 100644 index a0b1ef7..0000000 --- a/NIRVANIFY_NEW_PORT_SUMMARY.md +++ /dev/null @@ -1,234 +0,0 @@ -# Nirvanify_New Port to Root Directory - Complete Summary - -## Successfully Ported Files - -### 🎯 Main Pages (Complete with fixes) - -#### 1. **popup-new.html** (Main Extension Popup) -- **Source**: `Nirvanify_New/index.html` -- **Status**: ✅ COMPLETE -- **Features**: - - Modern dashboard layout with stats cards - - Interactive timer with pause/reset controls - - Navigation to all main sections - - Glass-morphism design with proper blur effects -- **Fixes Applied**: - - Updated image paths to `assets/images/` - - Added timer controls container with flexbox - - Improved slogan text - - Connected to `popup-global.js` for functionality - -#### 2. **dashboard-new.html** (Full Dashboard View) -- **Source**: `Nirvanify_New/dashboard.html` -- **Status**: ✅ COMPLETE -- **Features**: - - Responsive sidebar with hamburger menu - - Study session tracking with timer - - Analytics grid with focus stats - - Past sessions table - - Full statistics tables for sites and block sets -- **Fixes Applied**: - - Fixed sidebar navigation links - - Updated user profile section - - Improved analytics data display - - Connected proper CSS file - -#### 3. **settings-new.html** (Enhanced Settings Page) -- **Source**: `Nirvanify_New/settings.html` + improvements -- **Status**: ✅ COMPLETE -- **Features**: - - Sound & notifications toggle buttons (working with JavaScript) - - Theme selection (Dark/Light) - - Timer size and position controls - - Export/Import functionality - - TopsOJ account connection - - Reset data with confirmation modal -- **Fixes Applied**: - - Replaced broken checkbox system with functional button toggles - - Added proper event handling in settings-new.js - - Fixed HTML structure to match JavaScript expectations - - Added modal for reset confirmation - -#### 4. **interventions-new.html** (Intervention Management) -- **Source**: `Nirvanify_New/interventions.html` -- **Status**: ✅ COMPLETE -- **Features**: - - List of all intervention types (Math, Password, Flashcards, TopsOJ, Delay) - - Edit form with type-specific options - - Dynamic option panels based on intervention type - - Action buttons for save/duplicate/delete -- **Fixes Applied**: - - Improved intervention type icons and labels - - Added better form validation display - - Enhanced user experience with dynamic content - - Added proper action buttons - -#### 5. **sessions-new.html** (Study Session Templates) -- **Source**: `Nirvanify_New/sessions.html` -- **Status**: ✅ COMPLETE -- **Features**: - - Session template management (Pomodoro, Deep Work, Custom) - - Schedule configuration with cycles - - Block set selection during sessions - - Session options (notifications, auto-start) -- **Fixes Applied**: - - Added session type indicators - - Improved schedule input layout - - Enhanced checkbox groups for block sets - - Added session management buttons - -#### 6. **blocklist-new.html** (Website Blocking Management) -- **Source**: `Nirvanify_New/blocks.html` -- **Status**: ✅ COMPLETE -- **Features**: - - Multiple block sets with tabs (Social Media, Entertainment, etc.) - - Website input with wildcard support - - Day-based scheduling with time ranges - - Time limit blocking configuration - - Intervention assignment -- **Fixes Applied**: - - Improved tab system for block sets - - Better day selector with visual feedback - - Enhanced help text for wildcard patterns - - Added block rule combination options - -### 🎨 CSS Files (All Updated) - -#### **popup-style.css** -- Clean dashboard popup styling -- Glass-morphism effects with backdrop blur -- Responsive timer controls -- Navigation hover effects - -#### **css/dashboard-new.css** -- Full responsive dashboard layout -- Sidebar with mobile hamburger menu -- Card-based content organization -- Analytics grid styling - -#### **css/settings-new.css** -- Button toggle groups (working correctly) -- Modal dialog styling -- Form input styling -- Responsive grid layout - -#### **css/interventions-new.css** -- Two-column intervention layout -- Dynamic form sections -- Table styling for intervention list -- Action button styling - -#### **css/sessions-new.css** -- Session template management styling -- Form layout for session configuration -- Checkbox group styling -- Responsive session cards - -#### **css/blocks-new.css** -- Tab-based block set navigation -- Day selector styling -- Time input groups -- Option toggle styling - -### 🖼️ Assets (Complete Migration) -- **Status**: ✅ COMPLETE -- All images from `Nirvanify_New/images/` copied to `assets/images/` -- All CSS files updated to use correct paths (`../assets/images/`) -- Icons, backgrounds, and UI elements all working - -### 📜 JavaScript Files - -#### **popup-global.js** -- **Source**: Enhanced version of `Nirvanify_New/global.js` -- **Features**: - - Sidebar toggle functionality - - Timer functionality with start/pause/reset - - Mobile-responsive navigation -- **Status**: ✅ COMPLETE - -#### **settings-new.js** (Already existed, enhanced) -- **Features**: - - Button toggle system (matches HTML structure) - - Account connection functionality - - Import/export settings - - Data reset with confirmation -- **Status**: ✅ COMPLETE (previously fixed) - -## Display Issues Fixed - -### 🐛 Major UI Bugs Resolved - -1. **Settings Page Toggle System** - - ❌ **Before**: JavaScript expected checkboxes, HTML had button toggles - complete mismatch - - ✅ **After**: Perfect alignment between HTML button structure and JavaScript event handling - -2. **Image Path Issues** - - ❌ **Before**: All CSS files referenced `images/` (broken paths) - - ✅ **After**: All paths updated to `../assets/images/` (working correctly) - -3. **Timer Controls Missing** - - ❌ **Before**: Popup had single pause button, no reset functionality - - ✅ **After**: Complete timer controls with pause/resume and reset buttons - -4. **Navigation Links Broken** - - ❌ **Before**: Sidebar navigation pointed to old file names - - ✅ **After**: All navigation updated to point to new `-new.html` files - -5. **Responsive Design Issues** - - ❌ **Before**: Some elements not properly responsive - - ✅ **After**: Hamburger menu, sidebar overlay, proper mobile layout - -6. **Glass-morphism Effects** - - ❌ **Before**: Backdrop blur not working due to missing CSS properties - - ✅ **After**: Proper glass effects with backdrop-filter and transparency - -## Browser Testing Results - -### ✅ All Pages Tested and Working: -- **popup-new.html** - ✅ Timer functional, navigation working, glass effects active -- **dashboard-new.html** - ✅ Sidebar responsive, analytics display correctly, proper layout -- **settings-new.html** - ✅ Toggle buttons functional, modals working, settings saving -- **interventions-new.html** - ✅ Form switching correctly, intervention types working -- **sessions-new.html** - ✅ Session templates editable, checkboxes functional -- **blocklist-new.html** - ✅ Tab system working, day selectors active, form responsive - -## Next Steps - -### 🎯 Ready for Production -1. **Update manifest.json** to point to `popup-new.html` as the main popup -2. **Update any remaining references** in JavaScript files to use new file names -3. **Test extension loading** in Chrome to ensure all functionality works -4. **Delete Nirvanify_New folder** once everything is confirmed working - -### 🔄 Migration Complete -- **Files Ported**: 6 HTML files + 6 CSS files + 1 JS file + all images -- **Issues Fixed**: 6 major display bugs + numerous minor UI improvements -- **Browser Compatibility**: All pages tested and working in Simple Browser -- **Responsive Design**: All layouts tested and responsive - -## File Structure (After Migration) -``` -Nirvanify/ -├── popup-new.html (✅ Main popup) -├── dashboard-new.html (✅ Full dashboard) -├── settings-new.html (✅ Settings page) -├── interventions-new.html (✅ Interventions) -├── sessions-new.html (✅ Study sessions) -├── blocklist-new.html (✅ Block groups) -├── popup-style.css (✅ Popup styling) -├── popup-global.js (✅ Popup functionality) -├── css/ -│ ├── dashboard-new.css (✅ Dashboard styling) -│ ├── settings-new.css (✅ Settings styling) -│ ├── interventions-new.css (✅ Interventions styling) -│ ├── sessions-new.css (✅ Sessions styling) -│ └── blocks-new.css (✅ Blocks styling) -├── assets/images/ (✅ All images migrated) -│ ├── logo.png -│ ├── bg.png -│ ├── pause button.png -│ └── [100+ other images] -└── [existing files...] -``` - -**🎉 MIGRATION COMPLETE - All display bugs fixed, all functionality working!** diff --git a/README.md b/README.md index abc8f7b..8e3064a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ A comprehensive Chrome productivity extension designed to help you stay focused - **Time Restrictions**: Set specific times when sites are blocked or allowed - **Usage Statistics**: Track your productivity and browsing habits with detailed statistics +## Documentation + +- [Interventions Registry](docs/interventions.md) + ## Installation ### Development Installation diff --git a/SESSION_SYSTEM.md b/SESSION_SYSTEM.md new file mode 100644 index 0000000..5649b82 --- /dev/null +++ b/SESSION_SYSTEM.md @@ -0,0 +1,164 @@ +# Nirvanify Session System + +## Overview + +The Nirvanify Session System implements a comprehensive study session management feature that integrates block groups and interventions into customizable study sessions. + +## Architecture + +``` +Session Configuration Modal + ↓ +Sessions Management Component + ↓ +Session Integration Service + ↓ +Block Groups + Interventions +``` + +## Components + +### 1. Session Configuration Modal (`session-config-modal.js`) +- **Purpose**: Provides a user interface for configuring study sessions +- **Features**: + - Pre-defined session templates (Pomodoro, Ultradian, 52/17, Locked In) + - Custom duration configuration + - Block group selection + - Intervention selection + - Real-time configuration preview + +### 2. Sessions Management Component (`sessions.js`) +- **Purpose**: Main session control interface in the dashboard +- **Features**: + - Start/Cancel/Override session controls + - Session status display + - Real-time session progress tracking + - Phase management (study/break cycles) + - Integration with timer component + +### 3. Study Session Component (`study-session.js`) +- **Purpose**: Visual timer and session progress display +- **Features**: + - Countdown timer with circular progress indicator + - Phase indicators (Study/Break) + - Pause/Resume/Reset controls + - Real-time synchronization with active sessions + +### 4. Session Integration Service (`session-integration.js`) +- **Purpose**: Coordinates between sessions, block groups, and interventions +- **Features**: + - Session state management + - Block group activation/deactivation + - Intervention activation/deactivation + - State backup and restoration + - Background script communication + +## Session Flow + +1. **Configuration**: User clicks "Start New Session" → Modal opens with templates and options +2. **Selection**: User selects template, block groups, and interventions +3. **Activation**: Session starts → Integration service activates selected components +4. **Management**: Timer runs, phases switch automatically, notifications sent +5. **Completion/Cancellation**: Session ends → Integration service restores original states + +## Data Structure + +### Session Object +```javascript +{ + id: "unique-session-id", + startTime: timestamp, + studyMinutes: 25, + breakMinutes: 5, + blockGroups: [0, 1, 2], // indices of selected block groups + interventions: ["intervention-id-1", "intervention-id-2"], + template: { /* template object */ }, + phase: "study", // "study" or "break" + phaseStartTime: timestamp, + cycleCount: 1 +} +``` + +### Session Templates +- **Pomodoro Method**: 25min study / 5min break +- **Ultradian Rhythm**: 90min study / 20min break +- **52/17 Rule**: 52min study / 17min break +- **Locked In**: 180min study / 30min break + +## Integration Points + +### Block Groups +- Selected block groups are activated when session starts +- Original states are backed up and restored when session ends +- Integration service coordinates with existing blocking system + +### Interventions +- Selected interventions become active during session +- Intervention engine applies session-specific interventions +- Original intervention states are preserved + +### Background Processing +- Session events are communicated to background script +- Blocking and intervention enforcement happens at browser level +- State persistence across browser restarts + +## Storage + +### Chrome Storage Local +- `activeSession`: Current session data +- `sessionIntegrationState`: Complete integration service state + +### Chrome Storage Sync +- `studySessions`: User's saved session templates +- Block group and intervention data (existing storage) + +## Usage + +### Starting a Session +1. Navigate to Dashboard +2. Click "Start New Session" in Sessions card +3. Configure session in modal: + - Select pre-defined template OR set custom durations + - Choose block groups to activate + - Choose interventions to enable +4. Click "Start Session" + +### Managing Active Session +- View progress in Study Session component +- Pause/Resume using timer controls +- Cancel session using "Cancel Session" button +- Emergency override using "Start Override" button + +### Session Phases +- Sessions automatically cycle between study and break phases +- Timer shows remaining time for current phase +- Notifications alert user when phases switch +- Block groups remain active during both phases +- Interventions may behave differently per phase + +## Future Enhancements + +1. **Session Analytics**: Track session completion rates, focus time, etc. +2. **Smart Scheduling**: Automatic session scheduling based on calendar +3. **Adaptive Timings**: AI-powered session duration optimization +4. **Team Sessions**: Collaborative study sessions with friends +5. **Integration with External Tools**: Calendar apps, productivity tools +6. **Advanced Interventions**: Session-specific intervention configurations +7. **Session Profiles**: Save and share session configurations + +## Technical Notes + +- Components use Web Components (Custom Elements) architecture +- Event-driven communication between components +- Chrome Extension APIs for storage and background processing +- CSS Custom Properties for theming +- Modular import system for clean dependency management + +## Testing + +To test the session system: +1. Load the extension in Chrome +2. Navigate to Dashboard +3. Try starting different session types +4. Verify block groups and interventions activate +5. Test session cancellation and state restoration diff --git a/STORAGE_KEYS.md b/STORAGE_KEYS.md new file mode 100644 index 0000000..ab02972 --- /dev/null +++ b/STORAGE_KEYS.md @@ -0,0 +1,26 @@ +# Storage Keys + +| Storage Key | Description | Area | +| --- | --- | --- | +| `nirva_block_tabs` | Block group configurations | `sync` | +| `nirva_block_tabs_meta` | Block group metadata | `sync` | +| `nirva_intervention_additional_settings` | Global additional intervention settings | `sync` | +| `nirva_intervention_names` | Available intervention names | `sync` | +| `nirva_intervention_type_selected` | Currently selected intervention | `sync` | +| `nirva_block_time_selector_{index}` | Time selector state per block tab | `sync` | +| `nirva_interventions` | Intervention definitions | `sync` | +| `studySessions` | User-defined study session templates | `sync` | +| `nirva_timer_settings` | Timer display settings | `sync` | +| `nirva_display_prefs` | UI theme preferences | `sync` | +| `nirva_overrides` | Override flags for blocklist and strict mode | `sync` | +| `nirva_music_settings` | Background music settings | `sync` | +| `nirva_notification_settings` | Notification preferences | `sync` | +| `nirva_account_links` | Linked account identifiers | `sync` | +| `nirva_analytics` | Usage analytics (streaks, minutes, etc.) | `local` | +| `nirva_active_session` | Current active study session | `local` | +| `nirva_session_history` | Past session history records | `local` | +| `nirva_app_state` | Miscellaneous ephemeral app state | `local` | + +## Maintenance Benefits + +Centralizing these keys with their designated storage areas clarifies where data lives and prevents accidental mixing of persistent and ephemeral information. A shared reference reduces duplication, simplifies migrations, and makes future storage updates safer and easier to maintain. diff --git a/automator.py b/automator.py new file mode 100644 index 0000000..c66610a --- /dev/null +++ b/automator.py @@ -0,0 +1,293 @@ +""" +codex_batch_runner.py + +Batch runner for OpenAI Codex CLI that: + - Reads prompts from prompts.txt (split by '||') + - Runs each with `codex exec` in maximum-autonomy mode + - Logs results to JSONL, checkpoints progress, resumes safely + +Requirements: + - Codex CLI installed: npm i -g @openai/codex or brew install codex + - You are logged in: codex login + - Python 3.9+ + +Usage: + python codex_batch_runner.py [--prompts prompts.txt] [--out runs] [--timeout 600] [--sleep 1.0] [--yes] +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import time +from pathlib import Path +from typing import List, Tuple, Optional + +DEFAULT_PROMPTS = Path("prompts.txt") +DEFAULT_OUTDIR = Path("runs") +CHECKPOINT_FILE = "codex_checkpoint.json" +JSONL_FILE = "responses.jsonl" +LOG_FILE = "run.log" + +def which(cmd: str) -> Optional[str]: + from shutil import which as _which + return _which(cmd) + +import subprocess, sys, time, threading + +def run(cmd, cwd=None, env=None, timeout=None): + """ + Spawn a child process, stream stdout/stderr LIVE to this terminal, + still capture them for JSONL, and enforce an optional timeout. + Return (rc, out_str, err_str). On timeout, rc=124. + """ + # Inherit stdin so you can answer Codex prompts (y/n, etc.) + proc = subprocess.Popen( + cmd, + cwd=str(cwd) if cwd else None, + env=env, + stdin=None, # inherit console stdin + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + shell=True, + bufsize=1 # line-buffered + ) + + out_chunks, err_chunks = [], [] + timed_out = False + + def pump(stream, sink, is_err=False): + try: + for line in iter(stream.readline, ''): + sink.append(line) + # live print without double newlines + try: + if is_err: + # keep stderr formatting + sys.stderr.write(line) + sys.stderr.flush() + else: + sys.stdout.write(line) + sys.stdout.flush() + except Exception: + pass + finally: + try: + stream.close() + except Exception: + pass + + t_out = threading.Thread(target=pump, args=(proc.stdout, out_chunks, False), daemon=True) + t_err = threading.Thread(target=pump, args=(proc.stderr, err_chunks, True), daemon=True) + t_out.start(); t_err.start() + + start = time.time() + while True: + rc = proc.poll() + if rc is not None: + break + if timeout and (time.time() - start) > timeout: + timed_out = True + try: + proc.kill() + except Exception: + pass + break + time.sleep(0.05) + + # Drain remaining output + t_out.join(timeout=1.0) + t_err.join(timeout=1.0) + + out = ''.join(out_chunks) + err = ''.join(err_chunks) + if timed_out: + return 124, out, err + "\n[TIMEOUT]" + return proc.returncode, out, err + +def codex_supports(flag: str) -> bool: + # Grep codex --help once; cache per run + try: + rc, out, _ = run(["codex", "--help"]) + if rc != 0: + return False + return flag in out + except Exception: + return False + +def get_codex_version() -> str: + rc, out, _ = run(["codex", "--version"]) + return out.strip() if rc == 0 else "unknown" + +def detect_repo_root(start: Path) -> Path: + # Prefer git root; otherwise use current directory + try: + rc, out, _ = run(["git", "rev-parse", "--show-toplevel"], cwd=start) + if rc == 0 and out.strip(): + return Path(out.strip()) + except Exception: + pass + return start + +def read_prompts(path: Path) -> List[str]: + text = path.read_text(encoding="utf-8") + # Split by '||', trim surrounding whitespace, keep multi-line prompts intact + chunks = [c.strip() for c in re.split(r"\|\|", text) if c.strip()] + return chunks + +def load_checkpoint(path: Path) -> int: + if not path.exists(): + return 0 + try: + data = json.loads(path.read_text(encoding="utf-8")) + return int(data.get("index", 0)) + except Exception: + return 0 + +def save_checkpoint(path: Path, index: int) -> None: + path.write_text(json.dumps({"index": index}, ensure_ascii=False), encoding="utf-8") + +def stamp(message: str, log_path: Path) -> None: + line = f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {message}" + print(line) + with log_path.open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + +def build_codex_command(prompt: str, prefer_danger_flag: bool, extra: List[str], full_auto: bool) -> List[str]: + # Non-interactive mode + cmd = ["codex", "exec"] + # Max autonomy: either the single shortcut flag, or explicit combo + if prefer_danger_flag: + cmd.append("--dangerously-bypass-approvals-and-sandbox") + else: + cmd += ["--ask-for-approval", "never", "--sandbox", "danger-full-access"] + if full_auto: + cmd.append("--full-auto") + # Extra flags passed by user + cmd += extra + # Prompt as final arg (not via stdin, so we can JSONL the exact string) + cmd.append(prompt) + return cmd + +def main(): + ap = argparse.ArgumentParser(description="Batch-run Codex CLI prompts with maximum autonomy.") + ap.add_argument("--prompts", type=Path, default=DEFAULT_PROMPTS, help="Path to prompts.txt (split on '||').") + ap.add_argument("--out", type=Path, default=DEFAULT_OUTDIR, help="Output directory for logs and jsonl.") + ap.add_argument("--timeout", type=int, default=900, help="Per-prompt timeout in seconds. 0 disables.") + ap.add_argument("--sleep", type=float, default=1.2, help="Seconds to sleep between prompts.") + ap.add_argument("--yes", action="store_true", help="Do not prompt for confirmation of danger-full-access.") + ap.add_argument("--extra", type=str, default="", help="Extra flags for codex (quoted string, e.g. \"--model gpt-4.1\").") + ap.add_argument("--no-full-auto", action="store_true", help="Do NOT pass --full-auto to codex.") + args = ap.parse_args() + + # Preflight checks + if not which("codex"): + print("ERROR: 'codex' not found in PATH. Install with `npm i -g @openai/codex` or `brew install codex`.", file=sys.stderr) + sys.exit(127) + codex_ver = get_codex_version() + + # Working directory = repo root if available + start_dir = Path.cwd() + repo_root = detect_repo_root(start_dir) + + # Prepare output dirs/files + args.out.mkdir(parents=True, exist_ok=True) + log_path = args.out / LOG_FILE + jsonl_path = args.out / JSONL_FILE + ckpt_path = args.out / CHECKPOINT_FILE + if not jsonl_path.exists(): + jsonl_path.write_text("", encoding="utf-8") + + # Danger mode confirmation (once) + prefer_danger_flag = codex_supports("--dangerously-bypass-approvals-and-sandbox") + danger_blurb = "--dangerously-bypass-approvals-and-sandbox" if prefer_danger_flag else "--ask-for-approval never --sandbox danger-full-access" + if not args.yes: + print(f""" +You are about to run Codex with FULL AUTONOMY in this workspace. + + Codex version: {codex_ver} + Working dir : {repo_root} + Flags : {danger_blurb} {'--full-auto' if not args.no_full_auto else ''} + +This allows Codex to execute commands and modify files across your project without prompts. +Highly recommended to have a clean Git state or run in a disposable branch. + +Continue? [y/N] """, end="", flush=True) + resp = sys.stdin.readline().strip().lower() + if resp not in ("y", "yes"): + print("Aborted.") + sys.exit(1) + + # Load and maybe resume + if not args.prompts.exists(): + print(f"ERROR: prompts file not found: {args.prompts}", file=sys.stderr) + sys.exit(2) + prompts = read_prompts(args.prompts) + if not prompts: + print("ERROR: no prompts found in prompts file.", file=sys.stderr) + sys.exit(2) + + start_index = load_checkpoint(ckpt_path) + stamp(f"[INFO] Codex batch starting (version={codex_ver}) in {repo_root}", log_path) + stamp(f"[INFO] Using prompts file {args.prompts}, total {len(prompts)}, resuming at index {start_index}", log_path) + + # Additional flags split + extra_flags = shlex.split(args.extra) if args.extra else [] + full_auto = not args.no_full_auto + per_timeout = None if args.timeout <= 0 else args.timeout + + # Environment hygiene + base_env = os.environ.copy() + base_env.setdefault("RUST_LOG", "error") # Codex is Rust-based; keep logs quiet in exec mode + + # Process prompts + for idx in range(start_index, len(prompts)): + prompt = prompts[idx] + stamp(f"[{idx+1}/{len(prompts)}] sending len={len(prompt)}", log_path) + + cmd = build_codex_command(prompt, prefer_danger_flag, extra_flags, full_auto) + t0 = time.time() + rc, out, err = run(cmd, cwd=repo_root, env=base_env, timeout=per_timeout) + t1 = time.time() + + # Log JSONL line + entry = { + "index": idx, + "ts": time.strftime('%Y-%m-%dT%H:%M:%S'), + "duration_ms": int((t1 - t0) * 1000), + "cwd": str(repo_root), + "codex_version": codex_ver, + "cmd": cmd, + "prompt": prompt, + "returncode": rc, + "stdout": out, + "stderr": err, + } + with jsonl_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + + # Human-readable stamp + if rc == 0: + stamp(f"[OK] completed in {entry['duration_ms']} ms", log_path) + elif rc == 124: + stamp("[TIMEOUT] command exceeded per-prompt timeout", log_path) + else: + stamp(f"[WARN] codex returned rc={rc} (continuing)", log_path) + + # Save checkpoint after each prompt + save_checkpoint(ckpt_path, idx + 1) + + # Rate limit a bit between prompts + time.sleep(max(0.0, args.sleep)) + + stamp(f"[DONE] all prompts processed. Output: {jsonl_path}", log_path) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nInterrupted. Bye.") diff --git a/background/analytics.ts b/background/analytics.ts new file mode 100644 index 0000000..2c094ef --- /dev/null +++ b/background/analytics.ts @@ -0,0 +1,47 @@ +const STORAGE_KEY = 'nirva_events_buffer'; +const MAX_BUFFER = 1000; +const FLUSH_THRESHOLD = 200; +const TRIM_SIZE = 500; +const IDLE_MS = 60000; + +let buffer = []; +let flushTimer = null; + +async function loadBuffer() { + const stored = await chrome.storage.local.get(STORAGE_KEY); + buffer = Array.isArray(stored[STORAGE_KEY]) ? stored[STORAGE_KEY] : []; +} + +loadBuffer(); + +async function flush() { + if (buffer.length > TRIM_SIZE) { + buffer = buffer.slice(-TRIM_SIZE); + await chrome.storage.local.set({ [STORAGE_KEY]: buffer }); + } + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } +} + +export async function track(event, payload = {}) { + buffer.push({ event, payload, ts: Date.now() }); + if (buffer.length > MAX_BUFFER) { + buffer = buffer.slice(-MAX_BUFFER); + } + await chrome.storage.local.set({ [STORAGE_KEY]: buffer }); + if (flushTimer) { + clearTimeout(flushTimer); + } + if (buffer.length >= FLUSH_THRESHOLD) { + await flush(); + } else { + flushTimer = setTimeout(flush, IDLE_MS); + } +} + +export async function read(limit = 100, since = 0) { + const events = buffer.filter(e => !since || e.ts >= since); + return limit ? events.slice(-limit) : events; +} diff --git a/background/decider.ts b/background/decider.ts new file mode 100644 index 0000000..bd36ee0 --- /dev/null +++ b/background/decider.ts @@ -0,0 +1,118 @@ +import * as analytics from './analytics.ts'; +import * as escalation from './interventions/escalation.ts'; +import { log } from './logger.ts'; +import { SHOW_INTERVENTION } from '../shared/messaging/constants.js'; + +interface CompiledRule { + id: string; + blockPatterns: string[]; + allowPatterns: string[]; + interventionType?: string; + interventionId?: string; +} + +interface CompiledRules { + rules: CompiledRule[]; + hash?: string; + selectedId?: string; +} + +interface DecisionOutcome { + decision: 'allow' | 'block' | 'intervene'; + action?: string; + groupId?: string; + interventionId?: string; + rule?: CompiledRule; +} + +function urlMatchesPattern(url: string, pattern: string): boolean { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return regex.test(url); +} + +export function decideForUrl(url: string, compiled: CompiledRules): DecisionOutcome { + const rules = compiled.rules || []; + if (!url) { + return { decision: 'allow' }; + } + const urlWithoutProtocol = url.replace(/^(https?:\/\/)/, ''); + for (const rule of rules) { + const shouldBlock = (rule.blockPatterns || []).some((p) => urlMatchesPattern(urlWithoutProtocol, p)); + if (!shouldBlock) continue; + const isExcepted = (rule.allowPatterns || []).some((p) => urlMatchesPattern(urlWithoutProtocol, p)); + if (isExcepted) continue; + let action = rule.interventionType || 'hard-block'; + let interventionId = rule.interventionId; + if (action !== 'hard-block' && !interventionId && compiled.selectedId) { + action = 'intervention'; + interventionId = compiled.selectedId; + } + const host = new URL(url).hostname; + const escalated = escalation.shouldEscalate(rule, host); + if (escalated) { + action = escalated; + } + const decision: 'block' | 'intervene' = action === 'hard-block' ? 'block' : 'intervene'; + analytics.track('block_decision', { + url, + blocked: true, + type: action, + ruleId: rule.id + }); + return { decision, action, groupId: rule.id, interventionId, rule }; + } + analytics.track('block_decision', { url, blocked: false }); + return { decision: 'allow' }; +} + +export async function handleNavigationDecision(tabId: number, url: string, compiled: CompiledRules): Promise { + const outcome = decideForUrl(url, compiled); + log('debug', 'DECISION_OUTCOME', { + ts: Date.now(), + hash: compiled.hash, + tabId, + url, + outcome: outcome.decision + }); + if (outcome.decision === 'allow') { + return; + } + if (outcome.decision === 'block') { + try { + await chrome.tabs.update(tabId, { url: 'about:blank' }); + } catch (err) { + console.error('BLOCK_FAILED', { tabId, url, error: err?.message }); + } + return; + } + const payload = { url, action: outcome.action, groupId: outcome.groupId }; + const send = () => + new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, { action: SHOW_INTERVENTION, payload }, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + try { + await send(); + return; + } catch (err) { + log('warn', 'INJECTION_SEND_FAIL', { tabId, url, error: (err as Error)?.message }); + try { + log('info', 'INJECTION_EXECUTE_SCRIPT', { tabId, url }); + await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }); + } catch (_) { + log('error', 'INJECTION_FAILED', { tabId, url }); + return; + } + try { + await send(); + log('debug', 'INJECTION_RETRY_OK', { tabId, url }); + } catch (_) { + log('error', 'INJECTION_FAILED', { tabId, url }); + } + } +} diff --git a/background/intervention-recorder.js b/background/intervention-recorder.js new file mode 100644 index 0000000..24ad5ce --- /dev/null +++ b/background/intervention-recorder.js @@ -0,0 +1,49 @@ +import { loadInterventions, saveIntervention } from '../components/storage/intervention-storage.js'; +import { SESSION_HISTORY_KEY } from '../components/storage/keys.js'; + +/** + * Record the completion of an intervention, updating stats and session history. + * @param {string|null} interventionId - ID of the intervention completed + * @param {number} duration - Duration in seconds + * @param {string} url - URL where intervention occurred + * @returns {Promise>} Updated interventions list + */ +export async function recordInterventionCompletion(interventionId, duration, url) { + try { + const interventions = await loadInterventions(); + const idx = interventions.findIndex(i => i.id === interventionId); + if (idx >= 0) { + const intervention = interventions[idx]; + const stats = intervention.stats || { + runs: 0, + last_run: null, + total_delay_seconds: 0 + }; + stats.runs += 1; + stats.last_run = new Date().toISOString(); + if (duration) { + stats.total_delay_seconds += duration; + } + intervention.stats = stats; + await saveIntervention(intervention); + } + const history = await new Promise((resolve) => { + chrome.storage.local.get([SESSION_HISTORY_KEY], (res) => { + resolve(res[SESSION_HISTORY_KEY] || []); + }); + }); + history.push({ + url, + interventionId, + duration, + completedAt: new Date().toISOString() + }); + await new Promise((resolve) => { + chrome.storage.local.set({ [SESSION_HISTORY_KEY]: history }, resolve); + }); + return interventions; + } catch (error) { + console.error('Error recording intervention completion:', error); + return []; + } +} diff --git a/background/interventions/escalation.ts b/background/interventions/escalation.ts new file mode 100644 index 0000000..ddcc92c --- /dev/null +++ b/background/interventions/escalation.ts @@ -0,0 +1,49 @@ +const STORAGE_KEY = 'nirva_escalation'; + +interface EscalationEntry { + windowMin: number; + failThreshold: number; + nextAction: string; +} + +interface EscalationState { + [groupId: string]: { + [host: string]: number[]; + }; +} + +let state: EscalationState = {}; + +export async function init() { + const stored = await chrome.storage.local.get(STORAGE_KEY); + state = stored[STORAGE_KEY] || {}; +} + +export function shouldEscalate(rule: { id: string; escalation?: EscalationEntry | null }, host: string): string | null { + const policy = rule.escalation; + if (!policy) return null; + const now = Date.now(); + const cutoff = now - policy.windowMin * 60000; + const arr = state[rule.id]?.[host] || []; + const recent = arr.filter(ts => ts >= cutoff); + state[rule.id] = state[rule.id] || {}; + state[rule.id][host] = recent; + return recent.length >= policy.failThreshold ? policy.nextAction : null; +} + +export async function recordResult(groupId: string, host: string, passed: boolean, windowMin: number) { + if (passed) return; + const now = Date.now(); + const cutoff = now - windowMin * 60000; + const group = state[groupId] || {}; + const arr = (group[host] || []).filter(ts => ts >= cutoff); + arr.push(now); + group[host] = arr; + state[groupId] = group; + await chrome.storage.local.set({ [STORAGE_KEY]: state }); +} + +export async function clear() { + state = {}; + await chrome.storage.local.remove(STORAGE_KEY); +} diff --git a/background/logger.ts b/background/logger.ts new file mode 100644 index 0000000..aab272f --- /dev/null +++ b/background/logger.ts @@ -0,0 +1,34 @@ +const DEBUG_KEY = 'nirva_debug'; + +let debugEnabled = false; + +chrome.storage.local.get(DEBUG_KEY).then(res => { + debugEnabled = res[DEBUG_KEY] === true; +}); + +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && DEBUG_KEY in changes) { + debugEnabled = changes[DEBUG_KEY].newValue === true; + } +}); + +export function log(level: 'debug' | 'info' | 'warn' | 'error', code: string, data: Record = {}): void { + if (level === 'debug' && !debugEnabled) { + return; + } + const payload = Object.keys(data).length ? data : undefined; + switch (level) { + case 'info': + console.info(code, payload); + break; + case 'warn': + console.warn(code, payload); + break; + case 'error': + console.error(code, payload); + break; + default: + console.debug(code, payload); + break; + } +} diff --git a/background/messaging.js b/background/messaging.js new file mode 100644 index 0000000..8552dfe --- /dev/null +++ b/background/messaging.js @@ -0,0 +1,119 @@ +let ctx = {}; + +export function registerHandlers(context) { + ctx = context; +} + +const schemas = { + 'get-display-prefs': {}, + 'check-block-status': { + url: { type: 'string', required: true } + }, + 'show-intervention': { + tabId: { type: 'number', required: true }, + action: { type: 'string', required: true }, + url: { type: 'string', required: true }, + groupId: { type: 'string', required: false } + }, + 'track-time-allowance': { + tabId: { type: 'number', required: true }, + action: { type: 'string', required: true }, + url: { type: 'string', required: true }, + groupId: { type: 'string', required: false } + }, + 'intervention-complete': { + interventionId: { type: 'string', required: false }, + duration: { type: 'number', required: true }, + url: { type: 'string', required: true }, + passed: { type: 'boolean', required: false } + }, + 'set-blocking-enabled': { + enabled: { type: 'boolean', required: true } + }, + 'get-blocking-status': {}, + 'session.start': { + id: { type: 'string', required: true }, + durationMs: { type: 'number', required: true }, + blockGroups: { type: 'object', required: false } + }, + 'session.pause': {}, + 'session.resume': {}, + 'session.end': {}, + 'sync-caches': {}, + 'get-intervention-details': { + interventionId: { type: 'string', required: true } + }, + 'analytics.read': { + limit: { type: 'number', required: false }, + since: { type: 'number', required: false } + } +}; + +const handlers = { + 'get-display-prefs': async (_payload, ctx) => ctx.getDisplayPrefs(), + 'check-block-status': async ({ url }, ctx) => ctx.checkBlockStatus(url), + 'show-intervention': async ({ tabId, action, url, groupId }, ctx) => ctx.showIntervention(tabId, action, url, groupId), + 'track-time-allowance': async ({ tabId, action, url, groupId }, ctx) => ctx.trackTimeAllowance(tabId, action, url, groupId), + 'intervention-complete': async ({ interventionId, duration, url, passed }, ctx) => ctx.interventionComplete(interventionId, duration, url, passed), + 'set-blocking-enabled': async ({ enabled }, ctx) => ctx.setBlockingEnabled(enabled), + 'get-blocking-status': async (_payload, ctx) => ctx.getBlockingStatus(), + 'session.start': async ({ id, durationMs, blockGroups }, ctx) => ctx.sessionStart(id, durationMs, blockGroups), + 'session.pause': async (_payload, ctx) => ctx.sessionPause(), + 'session.resume': async (_payload, ctx) => ctx.sessionResume(), + 'session.end': async (_payload, ctx) => ctx.sessionEnd(), + 'sync-caches': async (_payload, ctx) => ctx.syncCaches(), + 'get-intervention-details': async ({ interventionId }, ctx) => ctx.getInterventionDetails(interventionId), + 'analytics.read': async ({ limit, since }, ctx) => ctx.analyticsRead(limit, since) +}; + +function validate(schema, payload = {}) { + const errors = []; + const data = {}; + if (!schema) return { valid: true, data: payload }; + for (const key of Object.keys(schema)) { + const rule = schema[key]; + const value = payload[key]; + if (value === undefined || value === null) { + if (rule.required) errors.push(`${key} is required`); + continue; + } + if (rule.type && typeof value !== rule.type) { + errors.push(`${key} must be ${rule.type}`); + continue; + } + data[key] = value; + } + return { valid: errors.length === 0, errors, data }; +} + +export function handleMessage(event, sender, sendResponse) { + if (!event || !event.action) { + sendResponse({ ok: false, code: 'MISSING_ACTION', error: 'Missing action' }); + return false; + } + const handler = handlers[event.action]; + if (!handler) { + sendResponse({ ok: false, code: 'UNKNOWN_ACTION', error: 'Unknown action' }); + return false; + } + const schema = schemas[event.action]; + const { valid, errors, data } = validate(schema, event.payload); + if (!valid) { + sendResponse({ ok: false, code: 'INVALID_PAYLOAD', error: errors.join(', ') }); + return false; + } + try { + const result = handler(data, ctx); + if (result && typeof result.then === 'function') { + result + .then((data) => sendResponse({ ok: true, data })) + .catch((err) => sendResponse({ ok: false, code: err.code || 'INTERNAL_ERROR', error: err.message })); + return true; + } + sendResponse({ ok: true, data: result }); + } catch (err) { + sendResponse({ ok: false, code: err.code || 'INTERNAL_ERROR', error: err.message }); + } + return false; +} + diff --git a/background/service_worker.js b/background/service_worker.js new file mode 100644 index 0000000..252a24c --- /dev/null +++ b/background/service_worker.js @@ -0,0 +1,469 @@ +/** + * Nirvanify Background Script + * Handles site blocking, interventions, and communication with content scripts. + */ + +// Import key storage paths as constants for consistency +import { + BLOCK_TABS_KEY, + SELECTED_INTERVENTION_KEY, + INTERVENTIONS_KEY, + TIMER_SETTINGS_KEY, + MUSIC_SETTINGS_KEY, + NOTIFICATION_SETTINGS_KEY, + DISPLAY_PREFS_KEY, + SESSION_HISTORY_KEY, + SITE_ALLOWANCES_KEY, + OVERRIDES_KEY +} from '../components/storage/keys.js'; +import { ensureDefaults, load, update, onChange } from '../components/storage/storage-manager.js'; +import { loadInterventions } from '../components/storage/intervention-storage.js'; +import { loadActiveBlockSets } from '../storage/blocksets-adapter.ts'; +import { handleMessage, registerHandlers } from './messaging.js'; +import * as sessions from './sessions.ts'; +import * as escalation from './interventions/escalation.ts'; +import * as analytics from './analytics.ts'; +import { initTabPipeline, scheduleDecision, clearTab } from './tabPipeline.ts'; +import { handleNavigationDecision, decideForUrl } from './decider.ts'; +import { log } from './logger.ts'; +import { recordInterventionCompletion } from './intervention-recorder.js'; +import { SETTINGS_UPDATED, SHOW_INTERVENTION, TRACK_TIME_ALLOWANCE } from '../shared/messaging/constants.js'; +import { mergeDisplayPrefs } from '../shared/display-prefs.js'; + +// Track active blocking state and interventions +let activeBlockRules = []; +let activeSiteBlockPatterns = []; +let activeSiteAllowPatterns = []; +let blockingEnabled = true; +let cachedOverrides = {}; +let blockRulesLastUpdated = 0; +let rulesHash = ''; + +const debounceTimers = new Map(); + +// Track tabs with active interventions +const tabInterventions = new Map(); + +chrome.runtime.onInstalled.addListener(() => { + ensureDefaults(); +}); +chrome.runtime.onStartup.addListener(() => { + ensureDefaults(); +}); + +// Cached data for performance +let cachedBlockTabs = []; +let cachedInterventions = []; +let cachedSelectedInterventionId = null; +let siteAllowances = {}; + +initTabPipeline((tabId, url) => handleNavigationDecision(tabId, url, getCompiledRules())); + +/** + * Initialize the background script + */ +async function initialize() { + console.log('Initializing Nirvanify background script...'); + + await ensureDefaults(); + await sessions.bootstrapSessions(); + await escalation.init(); + + // Load initial state + await loadBlockingRules(); + try { + cachedOverrides = (await load(OVERRIDES_KEY, 'overrides')) || {}; + } catch (_) { + cachedOverrides = {}; + } + siteAllowances = await load(SITE_ALLOWANCES_KEY) || {}; + + // Check if we need to inject content scripts into existing tabs + injectContentScriptsIntoExistingTabs(); + + // Set up periodic rule refresh + setInterval(refreshBlockingRules, 60000); // Refresh rules every minute +} + +/** + * Load blocking rules from storage + */ +async function loadBlockingRules() { + try { + const sets = await loadActiveBlockSets(); + cachedBlockTabs = sets.map((s) => ({ + id: s.id, + active: true, + sites: s.patterns, + interventionType: s.action + })); + cachedInterventions = await loadInterventions(); + cachedSelectedInterventionId = await load(SELECTED_INTERVENTION_KEY); + console.log('Active block sets:', cachedBlockTabs.length); + processBlockingRules(); + console.log('Blocking rules loaded successfully'); + } catch (error) { + console.error('Error loading blocking rules:', error); + } +} + +/** + * Process loaded data into active blocking rules + */ +function processBlockingRules() { + const newHash = computeRulesHash(); + if (newHash === rulesHash) { + log('debug', 'RULES_SKIPPED_SAME_HASH'); + return; + } + rulesHash = newHash; + + activeBlockRules = []; + activeSiteBlockPatterns = []; + activeSiteAllowPatterns = []; + + cachedBlockTabs.forEach((blockTab) => { + if (!blockTab.active) return; + + if (blockTab.sites && blockTab.sites.length > 0) { + const siteEntries = parseBlocklistEntries(blockTab.sites); + activeSiteBlockPatterns = [...activeSiteBlockPatterns, ...siteEntries.blockPatterns]; + activeSiteAllowPatterns = [...activeSiteAllowPatterns, ...siteEntries.allowPatterns]; + + activeBlockRules.push({ + id: blockTab.id || `block-${Date.now()}`, + name: blockTab.name || 'Unnamed Block Group', + blockPatterns: siteEntries.blockPatterns, + allowPatterns: siteEntries.allowPatterns, + interventionType: blockTab.interventionType || 'hard-block', + interventionId: blockTab.interventionId, + schedule: blockTab.schedule || null, + }); + } + }); + + blockRulesLastUpdated = Date.now(); + log('info', 'RULES_COMPILED', { count: activeBlockRules.length }); +} + +function computeRulesHash() { + const data = JSON.stringify({ + tabs: cachedBlockTabs, + interventions: cachedInterventions, + selected: cachedSelectedInterventionId + }); + return simpleHash(data); +} + +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) >>> 0; + } + return hash.toString(16); +} + +function getCompiledRules() { + let rules = blockingEnabled ? activeBlockRules : []; + // Apply strict mode: force hard-block and remove allowances/exceptions + if (rules.length && cachedOverrides?.strict_mode) { + rules = rules.map((r) => ({ + ...r, + interventionType: 'hard-block', + allowPatterns: [] + })); + } + return { rules, hash: rulesHash, selectedId: cachedSelectedInterventionId }; +} + +function scheduleRulesRefresh() { + let timer = debounceTimers.get('rules'); + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + debounceTimers.delete('rules'); + loadBlockingRules(); + }, 75); + debounceTimers.set('rules', timer); +} + + +/** + * Parse blocklist entries into block and allow patterns + * @param {string[]} entries - Array of site entries + * @returns {Object} Object with blockPatterns and allowPatterns + */ +function parseBlocklistEntries(entries) { + const blockPatterns = []; + const allowPatterns = []; + + entries.forEach(entry => { + const trimmed = entry.trim(); + + if (!trimmed) return; + + if (trimmed.startsWith('+')) { + // Exception pattern (allowed site) + const pattern = trimmed.substring(1).trim(); + if (pattern) { + allowPatterns.push(normalizeUrl(pattern)); + } + } else { + // Block pattern + blockPatterns.push(normalizeUrl(trimmed)); + } + }); + + return { + blockPatterns, + allowPatterns + }; +} + +/** + * Normalize URL pattern for consistent matching + * @param {string} url - URL or pattern to normalize + * @returns {string} - Normalized URL pattern + */ +function normalizeUrl(url) { + // Remove protocol if present + let normalized = url.replace(/^(https?:\/\/)/, ''); + + // Add wildcard for partial matching if no wildcard present + if (!normalized.includes('*')) { + // If it's a domain without path, match all paths + if (!normalized.includes('/')) { + normalized = `${normalized}/*`; + } + } + + return normalized; +} + +function getAllowanceState(domain, limitMs, periodMs) { + const now = Date.now(); + let state = siteAllowances[domain]; + if (!state || now - state.periodStart >= state.periodMs || state.limitMs !== limitMs || state.periodMs !== periodMs) { + state = { + usedMs: 0, + periodStart: now, + limitMs, + periodMs + }; + siteAllowances[domain] = state; + update(SITE_ALLOWANCES_KEY, siteAllowances); + } + return state; +} + +/** + * Setup browser event listeners + */ +function setupEventListeners() { + if (setupEventListeners.registered) { + return; + } + setupEventListeners.registered = true; + + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status !== 'loading' && !changeInfo.url) { + return; + } + if (tab.incognito) { + return; + } + const url = changeInfo.url || tab.url; + if (url) { + scheduleDecision(tabId, url); + } + }); + + chrome.tabs.onRemoved.addListener((tabId) => { + clearTab(tabId); + }); + + // Listen for messages from content scripts + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.action) { + sendResponse({ ok: false, code: 'MISSING_ACTION', error: 'Missing action' }); + return false; + } + return handleMessage(message, sender, sendResponse); + }); + + // Listen for storage changes to update rules and notify content scripts + onChange((changedKey, newValue) => { + if ([BLOCK_TABS_KEY, INTERVENTIONS_KEY, SELECTED_INTERVENTION_KEY].includes(changedKey)) { + scheduleRulesRefresh(); + } + if (changedKey === OVERRIDES_KEY) { + cachedOverrides = newValue || {}; + scheduleRulesRefresh(); + } + if ([TIMER_SETTINGS_KEY, MUSIC_SETTINGS_KEY, NOTIFICATION_SETTINGS_KEY, DISPLAY_PREFS_KEY].includes(changedKey)) { + broadcast({ + action: SETTINGS_UPDATED, + payload: { + key: changedKey, + value: newValue + } + }); + } + }); + } + +/** + * Evaluate a URL change and apply blocking logic + * @param {number} tabId - Tab ID + * @param {string} url - URL to evaluate + */ +/** + * Record completion of an intervention and update session history. + * Example flow: + * User visits blocked site → background determines block group → + * content script runs intervention → on completion this persists + * a record to SESSION_HISTORY_KEY. + * @param {string|null} interventionId - Intervention ID or null if no intervention was triggered + * @param {number} duration - Duration in seconds + * @param {string} url - URL where the intervention occurred + */ + +/** + * Broadcast a message to all tabs + * @param {Object} message - Message object + */ +function broadcast(message) { + chrome.tabs.query({}, (tabs) => { + tabs.forEach((tab) => { + chrome.tabs.sendMessage(tab.id, message, () => { + if (chrome.runtime.lastError) { + console.warn(`Broadcast to tab ${tab.id} failed:`, chrome.runtime.lastError); + } + }); + }); + }); +} + +/** + * Refresh blocking rules + */ +function refreshBlockingRules() { + loadBlockingRules(); +} + +/** + * Inject content scripts into existing tabs + */ +async function injectContentScriptsIntoExistingTabs() { + try { + const tabs = await chrome.tabs.query({ url: ['http://*/*', 'https://*/*'] }); + + for (const tab of tabs) { + await injectContentScript(tab.id); + } + + console.log(`Injected content scripts into ${tabs.length} existing tabs`); + } catch (error) { + console.error('Error injecting content scripts:', error); + } +} + +/** + * Inject content script into a specific tab + * @param {number} tabId - Tab ID + */ +async function injectContentScript(tabId) { + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['content.js'] + }); + return true; + } catch (error) { + console.error(`Error injecting content script into tab ${tabId}:`, error); + return false; + } +} + +registerHandlers({ + getDisplayPrefs: async () => { + const prefs = await load(DISPLAY_PREFS_KEY, 'display_preferences'); + return { prefs: mergeDisplayPrefs(prefs) }; + }, + checkBlockStatus: (url) => { + const decision = decideForUrl(url, getCompiledRules()); + const blockAction = decision.decision === 'allow' ? null : { action: decision.action, groupId: decision.groupId }; + return { blocked: decision.decision !== 'allow', blockAction }; + }, + showIntervention: async (tabId, action, url, groupId) => { + analytics.track('intervention_shown', { + tabId, + url, + type: action, + ruleId: groupId + }); + await chrome.tabs.sendMessage(tabId, { + action: SHOW_INTERVENTION, + payload: { url, action, groupId } + }); + return { success: true }; + }, + trackTimeAllowance: async (tabId, action, url, groupId) => { + await chrome.tabs.sendMessage(tabId, { + action: TRACK_TIME_ALLOWANCE, + payload: { url, action, groupId } + }); + return { success: true }; + }, + interventionComplete: async (interventionId, duration, url, passed = true) => { + analytics.track('intervention_result', { interventionId, duration, url, passed }); + cachedInterventions = await recordInterventionCompletion(interventionId, duration, url); + const match = decideForUrl(url, getCompiledRules()); + if (match.decision !== 'allow' && match.rule && match.rule.escalation) { + const host = new URL(url).hostname; + await escalation.recordResult(match.rule.id, host, passed, match.rule.escalation.windowMin); + } + return { success: true }; + }, + setBlockingEnabled: async (enabled) => { + blockingEnabled = enabled; + return { success: true }; + }, + getBlockingStatus: async () => ({ + enabled: blockingEnabled, + rulesCount: activeBlockRules.length, + lastUpdated: blockRulesLastUpdated + }), + sessionStart: async (id, durationMs, blockGroups) => { + return sessions.start(id, durationMs, blockGroups); + }, + sessionPause: async () => { + return sessions.pause(); + }, + sessionResume: async () => { + return sessions.resume(); + }, + sessionEnd: async () => { + return sessions.end(); + }, + syncCaches: async () => { + refreshBlockingRules(); + return { success: true }; + }, + getInterventionDetails: async (interventionId) => { + const list = await loadInterventions(); + const intervention = Array.isArray(list) ? list.find((i) => i.id === interventionId) : null; + return { intervention }; + }, + analyticsRead: async (limit, since) => { + const events = await analytics.read(limit, since); + return { events }; + } +}); + +// Register listeners before any async initialization +setupEventListeners(); + +// Initialize the background script when loaded +initialize(); diff --git a/background/sessions.ts b/background/sessions.ts new file mode 100644 index 0000000..4b71b49 --- /dev/null +++ b/background/sessions.ts @@ -0,0 +1,123 @@ +/** + * Session Manager v1.5 + * Handles session lifecycle with pause/resume and state persistence. + */ + +import { clear as clearEscalation } from './interventions/escalation.ts'; +import { track } from './analytics.ts'; +const STORAGE_KEY = 'sessionIntegrationState'; + +let snapshot = { + id: null, + state: 'idle', + startedAt: 0, + activeBlockGroups: [], +}; + +let baselineRestored = false; + +async function persist() { + await chrome.storage.local.set({ [STORAGE_KEY]: snapshot }); +} + +export async function bootstrapSessions() { + const stored = await chrome.storage.local.get(STORAGE_KEY); + if (stored && stored[STORAGE_KEY]) { + snapshot = stored[STORAGE_KEY]; + baselineRestored = snapshot.state === 'ended'; + if (snapshot.state === 'active' && snapshot.endsAt && Date.now() > snapshot.endsAt) { + await end(); + } + } +} + +export function getSnapshot() { + return snapshot; +} + +class InvalidTransitionError extends Error { + constructor(message) { + super(message || 'Invalid transition'); + this.code = 'INVALID_TRANSITION'; + } +} + +export async function start(id, durationMs, blockGroups = []) { + if (snapshot.state === 'active' || snapshot.state === 'paused') { + throw new InvalidTransitionError(); + } + const now = Date.now(); + snapshot = { + id, + state: 'active', + startedAt: now, + endsAt: now + durationMs, + activeBlockGroups: blockGroups, + lastTick: now, + }; + baselineRestored = false; + await persist(); + await track('session_state_changed', { id, state: snapshot.state }); + return snapshot; +} + +export async function pause() { + if (snapshot.state !== 'active') { + throw new InvalidTransitionError(); + } + const now = Date.now(); + snapshot.remainingMs = snapshot.endsAt - now; + snapshot.state = 'paused'; + snapshot.lastTick = now; + delete snapshot.endsAt; + await persist(); + await track('session_state_changed', { id: snapshot.id, state: snapshot.state }); + return snapshot; +} + +export async function resume() { + if (snapshot.state !== 'paused') { + throw new InvalidTransitionError(); + } + const now = Date.now(); + snapshot.endsAt = now + (snapshot.remainingMs || 0); + snapshot.state = 'active'; + snapshot.lastTick = now; + delete snapshot.remainingMs; + await persist(); + await track('session_state_changed', { id: snapshot.id, state: snapshot.state }); + return snapshot; +} + +async function restoreBaseline() { + if (baselineRestored) { + return; + } + baselineRestored = true; + // TODO: Restore original block/intervention states + console.log('Restoring original states'); +} + +export async function end() { + if (snapshot.state !== 'active' && snapshot.state !== 'paused') { + throw new InvalidTransitionError(); + } + snapshot.state = 'ended'; + snapshot.lastTick = Date.now(); + delete snapshot.endsAt; + delete snapshot.remainingMs; + await persist(); + await track('session_state_changed', { id: snapshot.id, state: snapshot.state }); + await restoreBaseline(); + await clearEscalation(); + return snapshot; +} + +export default { + bootstrapSessions, + start, + pause, + resume, + end, + getSnapshot, +}; diff --git a/background/tabPipeline.ts b/background/tabPipeline.ts new file mode 100644 index 0000000..c8956ee --- /dev/null +++ b/background/tabPipeline.ts @@ -0,0 +1,75 @@ +/** + * Tab decision pipeline with per-tab debouncing and single in-flight guard. + */ + +import { log } from './logger.ts'; + +const TAB_DEBOUNCE_MS = 80; + +const tabState = new Map(); +let decideFn = null; + +export function initTabPipeline(decider) { + decideFn = decider; +} + +export function disposeTabPipeline() { + for (const state of tabState.values()) { + if (state.timer) { + clearTimeout(state.timer); + } + } + tabState.clear(); + decideFn = null; +} + +export function clearTab(tabId) { + const state = tabState.get(tabId); + if (state && state.timer) { + clearTimeout(state.timer); + } + tabState.delete(tabId); +} + +export function scheduleDecision(tabId, url) { + if (!decideFn) return; + const normalized = normalize(url); + let state = tabState.get(tabId); + if (!state) { + state = {}; + tabState.set(tabId, state); + } + if (state.lastUrl === normalized || state.inflightUrl === normalized) { + log('debug', 'DECISION_SKIPPED_SAME_URL', { tabId, url: normalized }); + return; + } + if (state.timer) { + clearTimeout(state.timer); + } + state.timer = setTimeout(async () => { + state.timer = undefined; + if (state.inflightUrl) { + return; + } + state.inflightUrl = normalized; + try { + await decideFn(tabId, normalized); + state.lastUrl = normalized; + } finally { + state.inflightUrl = undefined; + } + }, TAB_DEBOUNCE_MS); + log('debug', 'DECISION_SCHEDULED', { tabId, url: normalized }); +} + +function normalize(input) { + try { + const u = new URL(input); + u.hash = ''; + u.host = u.host.toLowerCase(); + return u.toString(); + } catch { + return input; + } +} + diff --git a/components/app.js b/components/app.js index 45feac3..2670243 100644 --- a/components/app.js +++ b/components/app.js @@ -14,14 +14,20 @@ import "./pages/challenges-page.js"; import "./pages/stats-page.js"; import "./pages/timer-page.js"; import "./pages/analytics-page.js"; +import "./pages/focus-redirect-page.js"; // 2. Register widgets import "./dashboard/analytics.js"; import "./dashboard/streak.js"; import "./dashboard/study-session.js"; import "./dashboard/sessions.js"; +import "./dashboard/session-config-modal.js"; +import "./dashboard/session-selector-modal.js"; import "./dashboard/past-sessions.js"; import "./dashboard/full-stats.js"; +import "./notifications/notifications.js"; + +console.log("Components registered: session-selector-modal should be available now"); import "./blocklist/block-group-tabs.js"; import "./blocklist/block-set-name-input.js"; @@ -33,6 +39,7 @@ import "./blocklist/additional-settings.js"; import "./sessions/sessions-list.js"; import "./sessions/edit-session.js"; +import "./stats/stats-overview.js"; import "./interventions/intervention-list.js"; import "./interventions/intervention-editor.js"; import "./interventions/intervention-analytics.js"; @@ -56,8 +63,47 @@ import "./settings/display-preferences.js"; import "./settings/music-settings.js"; import "./settings/overrides-settings.js"; +// Fallback/utility components +import "./not-found.js"; + +// 3. Utilities +// Load session integration only in extension context to avoid errors in plain-browser tests +// It relies on chrome.runtime/storage which are unavailable outside MV3. +// We dynamically import it inside the chrome-guarded block below. +import { load } from "./storage/storage-manager.js"; +import { DISPLAY_PREFS_KEY } from "./storage/keys.js"; +import { createDisplayPrefsApplier, mergeDisplayPrefs } from "../shared/display-prefs.js"; +import { SETTINGS_UPDATED } from "../shared/messaging/constants.js"; + +const displayPrefsApplier = createDisplayPrefsApplier(); +if (typeof chrome !== "undefined" && chrome.storage) { + (async () => { + try { + // Initialize session integration only in extension context + try { + await import("./utils/session-integration.js"); + } catch (e) { + console.warn('[app] Failed to init session integration', e); + } + const prefs = await load(DISPLAY_PREFS_KEY, "display_preferences"); + displayPrefsApplier.apply(mergeDisplayPrefs(prefs)); + } catch (err) { + console.error("[app] Failed to load display preferences", err); + } + })(); + chrome.runtime.onMessage.addListener((message) => { + if ( + message.action === SETTINGS_UPDATED && + message.payload?.key === DISPLAY_PREFS_KEY + ) { + displayPrefsApplier.apply(mergeDisplayPrefs(message.payload.value)); + } + }); + + window.addEventListener("unload", displayPrefsApplier.cleanup); +} // 4. Routing import { initRouter } from "./router.js"; @@ -83,18 +129,34 @@ const routes = { "#/404": "nirva-not-found", }; +const titles = { + "#/dashboard": "Dashboard", + "#/study": "Study Session", + "#/blocklist": "Blocklist", + "#/interventions": "Interventions", + "#/sessions": "Sessions", + "#/challenge": "Challenge", + "#/challenges": "Challenges", + "#/timer": "Timer", + "#/analytics": "Analytics", + "#/stats": "Stats", + "#/settings": "Settings", + "#/challenge-math": "Math Challenge", + "#/challenge-password": "Password Challenge", + "#/challenge-flashcards": "Flashcards Challenge", + "#/challenge-delay": "Delay Challenge", + "#/focus-redirect": "Focus Redirect", + "#/404": "Not Found", +}; + window.addEventListener("DOMContentLoaded", async () => { console.log("[app] DOM loaded, setting up router…"); - const render = initRouter("#app-view", routes); - - // Wait for definitions of all known tags - await Promise.all( - Object.values(routes) - .filter((tag) => customElements.get(tag)) - .map((tag) => customElements.whenDefined(tag)) - ); - + const render = initRouter("#app-view", routes, { titles }); + // Render immediately. Do NOT await definitions for every possible route tag here. + // Previously we used Promise.all(Object.values(routes).map(customElements.whenDefined)), + // which deadlocked if any mapped tag wasn't registered (e.g., future/placeholder routes), + // causing a blank screen on deep-link reload. The router itself awaits only the active tag. render(); console.log( "%c[app] Nirvanify initialized 🚀", @@ -102,3 +164,37 @@ window.addEventListener("DOMContentLoaded", async () => { ); console.timeEnd("[app] Initialization"); }); + +// Lightweight keyboard navigation: press 'g' then a key to jump. +// g d (dashboard), g b (blocklist), g s (sessions), g t (timer), g a (analytics), g c (settings) +(() => { + const combos = new Map([ + ["d", "#/dashboard"], + ["b", "#/blocklist"], + ["s", "#/sessions"], + ["t", "#/timer"], + ["a", "#/analytics"], + ["c", "#/settings"], + ]); + let awaiting = false; + let timer = null; + const reset = () => { awaiting = false; if (timer) { clearTimeout(timer); timer = null; } }; + window.addEventListener("keydown", (e) => { + // ignore when user is typing in inputs/textareas/contenteditable + const el = e.target; + if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return; + + if (!awaiting && (e.key === 'g' || e.key === 'G')) { + awaiting = true; + timer = setTimeout(reset, 1200); + return; + } + if (awaiting) { + const hash = combos.get(e.key.toLowerCase()); + reset(); + if (hash) { + window.location.hash = hash; + } + } + }); +})(); diff --git a/components/blocklist/additional-settings.js b/components/blocklist/additional-settings.js index a0a5102..87166e3 100644 --- a/components/blocklist/additional-settings.js +++ b/components/blocklist/additional-settings.js @@ -1,17 +1,17 @@ import { loadAdditionalSettings, saveAdditionalSettings -} from "../storage/blocklist-storage.js"; +} from '../storage/blocklist-storage.js' const DEFAULT_SETTINGS = { - logic: "and", // "and" or "or" - overridable: false, // true/false - activation: "always", // "always" or "study" + logic: 'and', + overridable: false, + activation: 'always', logging: true, guiltTripping: false -}; +} -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -50,82 +50,82 @@ template.innerHTML = ` -`; +` customElements.define( - "nirva-additional-settings", + 'nirva-additional-settings', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); - - this.logicBtns = shadow.querySelectorAll(".logic-buttons button"); - this.overrideBtns = shadow.querySelectorAll(".override-buttons button"); - this.activationBtns = shadow.querySelectorAll(".activation-buttons button"); - this.loggingCheckbox = shadow.querySelector(".logging-checkbox"); - this.guiltCheckbox = shadow.querySelector(".guilt-checkbox"); - - this.logic = DEFAULT_SETTINGS.logic; - this.overridable = DEFAULT_SETTINGS.overridable; - this.activation = DEFAULT_SETTINGS.activation; - - this.attachEvents(); - this.loadSettings(); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) + + this.logic_btns = shadow.querySelectorAll('.logic-buttons button') + this.override_btns = shadow.querySelectorAll('.override-buttons button') + this.activation_btns = shadow.querySelectorAll('.activation-buttons button') + this.logging_checkbox = shadow.querySelector('.logging-checkbox') + this.guilt_checkbox = shadow.querySelector('.guilt-checkbox') + + this.logic = DEFAULT_SETTINGS.logic + this.overridable = DEFAULT_SETTINGS.overridable + this.activation = DEFAULT_SETTINGS.activation + + this.attachEvents() + this.loadSettings() } attachEvents() { - this.logicBtns.forEach(btn => - btn.addEventListener("click", () => { - this.updateSegment(this.logicBtns, btn); - this.logic = btn.dataset.value; - this.saveSettings(); + this.logic_btns.forEach(btn => + btn.addEventListener('click', () => { + this.updateSegment(this.logic_btns, btn) + this.logic = btn.dataset.value + this.saveSettings() }) - ); + ) - this.overrideBtns.forEach(btn => - btn.addEventListener("click", () => { - this.updateSegment(this.overrideBtns, btn); - this.overridable = btn.dataset.value === "yes"; - this.saveSettings(); + this.override_btns.forEach(btn => + btn.addEventListener('click', () => { + this.updateSegment(this.override_btns, btn) + this.overridable = btn.dataset.value === 'yes' + this.saveSettings() }) - ); + ) - this.activationBtns.forEach(btn => - btn.addEventListener("click", () => { - this.updateSegment(this.activationBtns, btn); - this.activation = btn.dataset.value; - this.saveSettings(); + this.activation_btns.forEach(btn => + btn.addEventListener('click', () => { + this.updateSegment(this.activation_btns, btn) + this.activation = btn.dataset.value + this.saveSettings() }) - ); + ) - this.loggingCheckbox.addEventListener("change", () => this.saveSettings()); - this.guiltCheckbox.addEventListener("change", () => this.saveSettings()); + this.logging_checkbox.addEventListener('change', () => this.saveSettings()) + this.guilt_checkbox.addEventListener('change', () => this.saveSettings()) } - updateSegment(group, activeBtn) { - group.forEach(btn => btn.classList.remove("active")); - activeBtn.classList.add("active"); + updateSegment(group, active_btn) { + group.forEach(btn => btn.classList.remove('active')) + active_btn.classList.add('active') } async loadSettings() { - const stored = await loadAdditionalSettings(); - const settings = Object.assign({}, DEFAULT_SETTINGS, stored); - this.logic = settings.logic; - this.overridable = settings.overridable; - this.activation = settings.activation; - this.loggingCheckbox.checked = settings.logging; - this.guiltCheckbox.checked = settings.guiltTripping; - - this.logicBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === settings.logic) - ); - this.overrideBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === (settings.overridable ? "yes" : "no")) - ); - this.activationBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === settings.activation) - ); + const stored = await loadAdditionalSettings() + const settings = Object.assign({}, DEFAULT_SETTINGS, stored) + this.logic = settings.logic + this.overridable = settings.overridable + this.activation = settings.activation + this.logging_checkbox.checked = settings.logging + this.guilt_checkbox.checked = settings.guiltTripping + + this.logic_btns.forEach(btn => + btn.classList.toggle('active', btn.dataset.value === settings.logic) + ) + this.override_btns.forEach(btn => + btn.classList.toggle('active', btn.dataset.value === (settings.overridable ? 'yes' : 'no')) + ) + this.activation_btns.forEach(btn => + btn.classList.toggle('active', btn.dataset.value === settings.activation) + ) } saveSettings() { @@ -133,11 +133,11 @@ customElements.define( logic: this.logic, overridable: this.overridable, activation: this.activation, - logging: this.loggingCheckbox.checked, - guiltTripping: this.guiltCheckbox.checked - }; - saveAdditionalSettings(settings); - this.dispatchEvent(new CustomEvent('change', { detail: { settings } })); + logging: this.logging_checkbox.checked, + guiltTripping: this.guilt_checkbox.checked + } + saveAdditionalSettings(settings) + this.dispatchEvent(new CustomEvent('change', { detail: { settings } })) } get value() { @@ -145,30 +145,18 @@ customElements.define( logic: this.logic, overridable: this.overridable, activation: this.activation, - logging: this.loggingCheckbox.checked, - guiltTripping: this.guiltCheckbox.checked - }; + logging: this.logging_checkbox.checked, + guiltTripping: this.guilt_checkbox.checked + } } set value(val) { - if (!val) return; - this.logic = val.logic || DEFAULT_SETTINGS.logic; - this.overridable = val.overridable ?? DEFAULT_SETTINGS.overridable; - this.activation = val.activation || DEFAULT_SETTINGS.activation; - this.loggingCheckbox.checked = val.logging ?? DEFAULT_SETTINGS.logging; - this.guiltCheckbox.checked = val.guiltTripping ?? DEFAULT_SETTINGS.guiltTripping; - - this.logicBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === this.logic) - ); - this.overrideBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === (this.overridable ? "yes" : "no")) - ); - this.activationBtns.forEach(btn => - btn.classList.toggle("active", btn.dataset.value === this.activation) - ); - - this.saveSettings(); + if (!val) return + this.logic = val.logic || DEFAULT_SETTINGS.logic + this.overridable = val.overridable ?? DEFAULT_SETTINGS.overridable + this.activation = val.activation || DEFAULT_SETTINGS.activation + this.logging_checkbox.checked = val.logging ?? DEFAULT_SETTINGS.logging + this.guilt_checkbox.checked = val.guiltTripping ?? DEFAULT_SETTINGS.guiltTripping } } -); +) diff --git a/components/blocklist/block-group-tabs.js b/components/blocklist/block-group-tabs.js index 5a11bc5..6bb6736 100644 --- a/components/blocklist/block-group-tabs.js +++ b/components/blocklist/block-group-tabs.js @@ -1,109 +1,105 @@ import { loadBlockGroupMeta, saveBlockGroupMeta -} from "../storage/blocklist-storage.js"; +} from '../storage/blocklist-storage.js' const TABS = [ { count: 0 }, { count: 0 }, { count: 0 }, { count: 0 }, - { count: 0 }, -]; + { count: 0 } +] -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = `
-`; +` customElements.define( - "nirva-block-group-tabs", + 'nirva-block-group-tabs', class extends HTMLElement { constructor() { - super(); - this.attachShadow({ mode: "open" }); - this.shadowRoot.appendChild(template.content.cloneNode(true)); - this.tabs = TABS; - this.activeIndex = 0; + super() + this.attachShadow({ mode: 'open' }) + this.shadowRoot.appendChild(template.content.cloneNode(true)) + this.tabs = TABS + this.active_index = 0 } async connectedCallback() { - this.loadTabs().then((tabs) => { - this.tabs = tabs; + this.loadTabs().then(tabs => { + this.tabs = tabs if (!tabs || tabs.length === 0) { - this.tabs = TABS; - this.saveTabs(); + this.tabs = TABS + this.saveTabs() } - this.renderTabs(); - }); + this.renderTabs() + }) } async loadTabs() { - const tabs = await loadBlockGroupMeta(); - return tabs && tabs.length ? tabs : TABS; + const tabs = await loadBlockGroupMeta() + return tabs && tabs.length ? tabs : TABS } saveTabs() { - saveBlockGroupMeta(this.tabs).catch((err) => - console.error("[storage] save error:", err) - ); + saveBlockGroupMeta(this.tabs).catch(err => + console.error('[storage] save error:', err) + ) } renderTabs() { - const container = this.shadowRoot.querySelector(".tabs-container"); - container.innerHTML = ""; + const container = this.shadowRoot.querySelector('.tabs-container') + container.innerHTML = '' this.tabs.forEach((tab, i) => { - const button = document.createElement("button"); - button.className = `card tab${ - i === this.activeIndex ? " active" : "" - }`; - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", i === this.activeIndex); - button.setAttribute("aria-controls", `panel-${i}`); - button.setAttribute("id", `tab-${i}`); - button.setAttribute("data-index", i); + const button = document.createElement('button') + button.className = `card tab${i === this.active_index ? ' active' : ''}` + button.setAttribute('role', 'tab') + button.setAttribute('aria-selected', i === this.active_index) + button.setAttribute('aria-controls', `panel-${i}`) + button.setAttribute('id', `tab-${i}`) + button.setAttribute('data-index', i) button.innerHTML = ` Set ${i + 1} ${tab.count} sites - ${ - tab.schedule ? "Schedule Set" : "No Schedule" - } - `; - button.addEventListener("click", (e) => this.setActiveTab(i)); - container.appendChild(button); - }); + ${tab.schedule ? 'Schedule Set' : 'No Schedule'} + ` + button.addEventListener('click', () => this.setActiveTab(i)) + container.appendChild(button) + }) } async setActiveTab(index) { - this.activeIndex = index; - this.renderTabs(); - this.saveTabs(); + this.active_index = index + this.renderTabs() + this.saveTabs() this.dispatchEvent( - new CustomEvent("tab-selected", { + new CustomEvent('tab-selected', { detail: { index }, bubbles: true, - composed: true, + composed: true }) - ); + ) } clearLocalStorage() { - localStorage.clear(); - console.log("LocalStorage cleared."); + localStorage.clear() + console.log('LocalStorage cleared.') } resetTabsToDefault() { - this.tabs = [...TABS]; + this.tabs = [...TABS] saveBlockGroupMeta(this.tabs) - .then(() => console.log("Tabs reset to default.")) - .catch((err) => - console.error("[storage] reset error:", err) - ); - this.renderTabs(); + .then(() => console.log('Tabs reset to default.')) + .catch(err => + console.error('[storage] reset error:', err) + ) + this.renderTabs() } } -); +) diff --git a/components/blocklist/block-set-name-input.js b/components/blocklist/block-set-name-input.js index be2362f..1702c54 100644 --- a/components/blocklist/block-set-name-input.js +++ b/components/blocklist/block-set-name-input.js @@ -1,4 +1,4 @@ -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -6,23 +6,23 @@ template.innerHTML = ` -`; +` customElements.define( - "nirva-block-set-name-input", + 'nirva-block-set-name-input', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) } get value() { - return this.shadowRoot.querySelector("#block-set-name").value; + return this.shadowRoot.querySelector('#block-set-name').value } set value(val) { - this.shadowRoot.querySelector("#block-set-name").value = val; + this.shadowRoot.querySelector('#block-set-name').value = val } } -); \ No newline at end of file +) \ No newline at end of file diff --git a/components/blocklist/block-site-list.js b/components/blocklist/block-site-list.js index 5632084..9b6a618 100644 --- a/components/blocklist/block-site-list.js +++ b/components/blocklist/block-site-list.js @@ -1,4 +1,4 @@ -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -25,38 +25,38 @@ template.innerHTML = ` -`; +` customElements.define( - "nirva-block-site-list", + 'nirva-block-site-list', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) - this.dialog = shadow.querySelector(".info-dialog"); - this.shadowRoot.querySelector(".info-icon").addEventListener("click", () => { - this.dialog.showModal(); - }); - this.shadowRoot.querySelector(".close-dialog").addEventListener("click", () => { - this.dialog.close(); - }); + this.dialog = shadow.querySelector('.info-dialog') + this.shadowRoot.querySelector('.info-icon').addEventListener('click', () => { + this.dialog.showModal() + }) + this.shadowRoot.querySelector('.close-dialog').addEventListener('click', () => { + this.dialog.close() + }) } get value() { - return this.shadowRoot.querySelector("#block-urls").value; + return this.shadowRoot.querySelector('#block-urls').value } set value(val) { - this.shadowRoot.querySelector("#block-urls").value = val; + this.shadowRoot.querySelector('#block-urls').value = val } get urls() { return this.value - .split("\n") + .split('\n') .map(line => line.trim()) - .filter(line => line.length > 0); + .filter(line => line.length > 0) } } -); \ No newline at end of file +) diff --git a/components/blocklist/block-time-selector.js b/components/blocklist/block-time-selector.js index c08bf20..72d5758 100644 --- a/components/blocklist/block-time-selector.js +++ b/components/blocklist/block-time-selector.js @@ -1,9 +1,9 @@ import { saveBlockTimeState, loadBlockTimeState -} from "../storage/blocklist-storage.js"; +} from '../storage/blocklist-storage.js' -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -26,189 +26,184 @@ template.innerHTML = ` -`; - - +` + +/* +Some Explanations: +1. this.schedule stores the time constraints for each day, in the format of: + { + "mon": [], + "tue": [ + { + "start": "9:00", + "end": "17:30" + } + ], + "wed": [], + ... + } + This is the data that is actually saved. +2. this.selected_days is a private set and is generated only in this class to help with the calculations + In theory, it should contain all the days that has already had a time constraint. +*/ customElements.define( - "nirva-block-time-selector", + 'nirva-block-time-selector', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); - - this.dayButtons = shadow.querySelectorAll(".day-btn"); - this.input = shadow.querySelector("#block-times"); - this.applyAllButton = shadow.querySelector(".apply-all"); - this.schedule = { - mon: "", - tue: "", - wed: "", - thu: "", - fri: "", - sat: "", - sun: "", - }; - this.selectedDays = new Set(["mon"]); - - // Track tab index for per-tab storage - this.tabIndex = 0; - if (this.hasAttribute('tab-index')) { - this.tabIndex = parseInt(this.getAttribute('tab-index'), 10) || 0; - } - this.restoreState(); - - this.dayButtons.forEach((btn) => { - btn.addEventListener("click", () => { - const day = btn.dataset.day; - if (this.selectedDays.has(day)) { - this.selectedDays.delete(day); - btn.classList.remove("active"); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) + + this.day_buttons = shadow.querySelectorAll('.day-btn') + this.input_el = shadow.querySelector('#block-times') + this.apply_all_button = shadow.querySelector('.apply-all') + this.schedule = { mon: '', tue: '', wed: '', thu: '', fri: '', sat: '', sun: '' } + this.selected_days = new Set(['mon']) + this.tab_index = this.hasAttribute('tab-index') ? parseInt(this.getAttribute('tab-index'), 10) || 0 : 0 + this.restoreState() + + this.day_buttons.forEach(btn => { + btn.addEventListener('click', () => { + const day = btn.dataset.day + if (this.selected_days.has(day)) { + this.selected_days.delete(day) + btn.classList.remove('active') + this.schedule[day]=[]; } else { - this.selectedDays.add(day); - btn.classList.add("active"); + this.selected_days.add(day) + btn.classList.add('active') + this.schedule[day]=this.parseTimeRange(this.input_el.value.replace(/\s+/g, '')) } - this.loadCurrentTimes(); - this.saveState(); - }); - }); - - this.input.addEventListener("input", () => { - const val = this.input.value.replace(/\s+/g, ""); - this.selectedDays.forEach((day) => { - this.schedule[day] = this.parseTimeRange(val); - }); - this.saveState(); - }); - - this.applyAllButton.addEventListener("click", () => { - const val = this.input.value.replace(/\s+/g, ""); - // Select all days - this.selectedDays = new Set(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]); - this.dayButtons.forEach((btn) => btn.classList.add("active")); + this.loadCurrentTimes() + this.saveState() + }) + }) + + this.input_el.addEventListener('input', () => { + const val = this.input_el.value.replace(/\s+/g, '') + this.selected_days.forEach(day => { + this.schedule[day] = this.parseTimeRange(val) + }) + this.saveState() + }) + + this.apply_all_button.addEventListener('click', () => { + const val = this.input_el.value.replace(/\s+/g, '') + this.selected_days = new Set(['mon','tue','wed','thu','fri','sat','sun']) + this.day_buttons.forEach(btn => btn.classList.add('active')) for (const day in this.schedule) { - this.schedule[day] = this.parseTimeRange(val); + this.schedule[day] = this.parseTimeRange(val) } - this.saveState(); - }); + this.saveState() + }) } - // Parse a time range string like "09:00-12:00" into {start: "09:00", end: "12:00"} parseTimeRange(str) { - if (!str) return []; - return str.split(",").map(range => { - const [start, end] = range.split("-"); - return { start, end }; - }); + if (!str) return [] + return str.split(',').map(range => { + const [start, end] = range.split('-') + return { start, end } + }) } - saveState() { - saveBlockTimeState(this.tabIndex, { + saveBlockTimeState(this.tab_index, { schedule: this.schedule, - selectedDays: Array.from(this.selectedDays) - }); + //selectedDays: Array.from(this.selected_days) + }) } async restoreState() { - const res = await loadBlockTimeState(this.tabIndex); - // reset state - this.dayButtons.forEach((btn) => btn.classList.remove("active")); - this.selectedDays = new Set(); - this.schedule = { - mon: [], - tue: [], - wed: [], - thu: [], - fri: [], - sat: [], - sun: [], - }; + const res = await loadBlockTimeState(this.tab_index) + this.day_buttons.forEach(btn => btn.classList.remove('active')) + this.selected_days = new Set() + this.schedule = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] } if (res) { - const saved = res; - if (saved.schedule) { + if (res.schedule) { for (const day in this.schedule) { - if (Array.isArray(saved.schedule[day])) { - this.schedule[day] = saved.schedule[day]; - } else if (typeof saved.schedule[day] === 'string') { - this.schedule[day] = this.parseTimeRange(saved.schedule[day]); + if (Array.isArray(res.schedule[day])) { + this.schedule[day] = res.schedule[day] + } else if (typeof res.schedule[day] === 'string') { + this.schedule[day] = this.parseTimeRange(res.schedule[day]) } else { - this.schedule[day] = []; + this.schedule[day] = [] + } + if(this.schedule[day].length!=0){ + this.selected_days.add(day) } } } - if (saved.selectedDays) { - this.selectedDays = new Set(saved.selectedDays); - } + /* + if (res.selectedDays) { + this.selected_days = new Set(res.selectedDays) + }*/ } - this.dayButtons.forEach((btn) => { - if (this.selectedDays.has(btn.dataset.day)) { - btn.classList.add("active"); + this.day_buttons.forEach(btn => { + if (this.selected_days.has(btn.dataset.day)) { + btn.classList.add('active') } - }); - this.loadCurrentTimes(); + }) + this.loadCurrentTimes() } + static get observedAttributes() { - return ['tab-index']; + return ['tab-index'] } - attributeChangedCallback(name, oldValue, newValue) { + attributeChangedCallback(name, old_value, new_value) { if (name === 'tab-index') { - const idx = parseInt(newValue, 10) || 0; - if (idx !== this.tabIndex) { - this.tabIndex = idx; - this.restoreState(); - // After restoring, update the input box to show the correct value - this.loadCurrentTimes(); + const idx = parseInt(new_value, 10) || 0 + if (idx !== this.tab_index) { + this.tab_index = idx + this.restoreState() + this.loadCurrentTimes() } } } loadCurrentTimes() { - // Show only the singular (all days) time range in the input box - // Find the first non-empty day's schedule and use it as the canonical value - let canonical = null; + let canonical = null for (const day of Object.keys(this.schedule)) { - const ranges = this.schedule[day] || []; + const ranges = this.schedule[day] || [] if (Array.isArray(ranges) && ranges.length > 0 && ranges[0].start) { - canonical = ranges; - break; + canonical = ranges + break } } if (canonical && canonical.length > 0) { - this.input.value = canonical.map(r => `${r.start}-${r.end}`).join(", "); + this.input_el.value = canonical.map(r => `${r.start}-${r.end}`).join(', ') } else { - this.input.value = ""; + this.input_el.value = '' } } get value() { - return this.schedule; + return this.schedule } set value(val) { - // Accepts either the old string format or the new parsed format if (val && typeof val === 'object') { - this.schedule = {}; + this.schedule = {} + this.selected_dats = new Set() for (const day in val) { if (Array.isArray(val[day])) { - this.schedule[day] = val[day]; + this.schedule[day] = val[day] } else if (typeof val[day] === 'string') { - this.schedule[day] = this.parseTimeRange(val[day]); + this.schedule[day] = this.parseTimeRange(val[day]) } else { - this.schedule[day] = []; + this.schedule[day] = [] + } + if(this.schedule[day].length!=0){ + this.selected_days.add(day) } } } else { - // fallback - this.schedule = { - mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] - }; + this.schedule = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] } } - this.saveState(); - this.loadCurrentTimes(); - // Always update the input box to show the value after setting + console.log(this.selected_days) + this.saveState() + this.loadCurrentTimes() } } -); +) diff --git a/components/blocklist/hourly-allowance.js b/components/blocklist/hourly-allowance.js index db364b7..d64e26e 100644 --- a/components/blocklist/hourly-allowance.js +++ b/components/blocklist/hourly-allowance.js @@ -1,4 +1,4 @@ -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -9,27 +9,27 @@ template.innerHTML = ` hours -`; +` customElements.define( - "nirva-hourly-allowance", + 'nirva-hourly-allowance', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) } get value() { return { - minutes: Number(this.shadowRoot.querySelector(".allowance-minutes").value) || 0, - hours: Number(this.shadowRoot.querySelector(".allowance-hours").value) || 0 - }; + minutes: Number(this.shadowRoot.querySelector('.allowance-minutes').value) || 0, + hours: Number(this.shadowRoot.querySelector('.allowance-hours').value) || 0 + } } set value(val) { - this.shadowRoot.querySelector(".allowance-minutes").value = val.minutes || 0; - this.shadowRoot.querySelector(".allowance-hours").value = val.hours || 0; + this.shadowRoot.querySelector('.allowance-minutes').value = val.minutes || 0 + this.shadowRoot.querySelector('.allowance-hours').value = val.hours || 0 } } -); +) diff --git a/components/blocklist/intervention-type.js b/components/blocklist/intervention-type.js index ffc00e1..5abfec2 100644 --- a/components/blocklist/intervention-type.js +++ b/components/blocklist/intervention-type.js @@ -1,6 +1,6 @@ -import { loadInterventionData, saveSelectedIntervention } from "../storage/blocklist-storage.js"; +import { loadInterventionData, saveSelectedIntervention } from '../storage/blocklist-storage.js' -const template = document.createElement("template"); +const template = document.createElement('template') template.innerHTML = ` @@ -14,226 +14,186 @@ template.innerHTML = ` - - `; + ` const DEFAULT_OPTIONS = [ - "Hard Block", - "60 Second Soft Block", - "Bio Exam Review Flashcards", - "Focus Timer (10 min)", - "Breathing Exercise", - "Mindfulness Session", -]; + 'Hard Block', + '60 Second Soft Block', + 'Bio Exam Review Flashcards', + 'Focus Timer (10 min)', + 'Breathing Exercise', + 'Mindfulness Session' +] customElements.define( - "nirva-intervention-type", + 'nirva-intervention-type', class extends HTMLElement { constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - shadow.appendChild(template.content.cloneNode(true)); + super() + const shadow = this.attachShadow({ mode: 'open' }) + shadow.appendChild(template.content.cloneNode(true)) } upgradeProperty(prop) { if (this.hasOwnProperty(prop)) { - const value = this[prop]; - delete this[prop]; - this[prop] = value; + const value = this[prop] + delete this[prop] + this[prop] = value } } connectedCallback() { - // --- Element References --- - this.dropdown = this.shadowRoot.querySelector(".custom-dropdown"); - this.card = this.shadowRoot.querySelector(".dropdown-card"); - this.selectedText = this.shadowRoot.querySelector(".selected-text"); - this.carat = this.shadowRoot.querySelector(".dropdown-carat"); - this.menu = this.shadowRoot.querySelector(".dropdown-menu"); // New menu container - this.search = this.shadowRoot.querySelector(".dropdown-search"); - this.list = this.shadowRoot.querySelector(".dropdown-list"); - - this.options = []; + this.dropdown = this.shadowRoot.querySelector('.custom-dropdown') + this.card = this.shadowRoot.querySelector('.dropdown-card') + this.selected_text = this.shadowRoot.querySelector('.selected-text') + this.carat = this.shadowRoot.querySelector('.dropdown-carat') + this.menu = this.shadowRoot.querySelector('.dropdown-menu') + this.search = this.shadowRoot.querySelector('.dropdown-search') + this.list = this.shadowRoot.querySelector('.dropdown-list') + + this.options = [] if (this.value_ === undefined) { - this.value_ = ""; + this.value_ = '' } - // --- Event Handlers --- - this.handleOutsideClick = (e) => { + this.handle_outside_click = e => { if (!e.composedPath().includes(this.dropdown)) { - this.closeDropdown(); + this.closeDropdown() } - }; + } - this.loadOptions(); + this.loadOptions() - this.dropdown.addEventListener("click", (e) => { - // Toggle if the click is not on a list item - if (!e.target.closest(".dropdown-list li")) { - this.toggleDropdown(); + this.dropdown.addEventListener('click', e => { + if (!e.target.closest('.dropdown-list li')) { + this.toggleDropdown() } - }); + }) - this.search.addEventListener("input", () => this.filterOptions()); + this.search.addEventListener('input', () => this.filterOptions()) - // Prevent dropdown from closing when clicking the search bar - this.search.addEventListener("click", (e) => { - e.stopPropagation(); - }); + this.search.addEventListener('click', e => { + e.stopPropagation() + }) } async loadOptions() { - const { names, selected } = await loadInterventionData(DEFAULT_OPTIONS); - this.populateList(names, selected); + const { names, selected } = await loadInterventionData(DEFAULT_OPTIONS) + this.populateList(names, selected) } - populateList(names, selectedValue) { - this.list.innerHTML = ""; - names.forEach((name) => { - const li = document.createElement("li"); - li.dataset.value = name.toLowerCase().replace(/ /g, "-"); - li.textContent = name; - li.tabIndex = 0; // for accessibility - - li.addEventListener("click", (e) => { - e.stopPropagation(); // prevent the main dropdown click from firing again - this.value = li.dataset.value; - this.closeDropdown(); - }); - - // Also allow selection with Enter key - li.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.stopPropagation(); - this.value = li.dataset.value; - this.closeDropdown(); + populateList(names, selected_value) { + this.list.innerHTML = '' + names.forEach(name => { + const li = document.createElement('li') + li.dataset.value = name.toLowerCase().replace(/ /g, '-') + li.textContent = name + li.tabIndex = 0 + li.addEventListener('click', e => { + e.stopPropagation() + this.value = li.dataset.value + this.closeDropdown() + }) + li.addEventListener('keydown', e => { + if (e.key === 'Enter') { + e.stopPropagation() + this.value = li.dataset.value + this.closeDropdown() } - }); - - this.list.appendChild(li); - }); - this.options = Array.from(this.list.querySelectorAll("li")); - - // Set initial value from chrome storage or fallback + }) + this.list.appendChild(li) + }) + this.options = Array.from(this.list.querySelectorAll('li')) if (this.options.length) { - const initialValue = - selectedValue || this.value_ || this.options[0].dataset.value; - this.value = initialValue; + const initial_value = selected_value || this.value_ || this.options[0].dataset.value + this.value = initial_value } } toggleDropdown() { - const isOpen = this.menu.classList.contains("show"); - if (isOpen) { - this.closeDropdown(); + const is_open = this.menu.classList.contains('show') + if (is_open) { + this.closeDropdown() } else { - this.openDropdown(); + this.openDropdown() } } openDropdown() { - this.dropdown.classList.add("open"); - this.carat.style.transform = "rotate(180deg)"; - - // Reset search and filter - this.search.value = ""; - this.filterOptions(); - - setTimeout(() => this.search.focus(), 50); - - window.addEventListener("click", this.handleOutsideClick, true); - - // ======== PORTAL MENU TO SCROLL CONTAINER ======== - const rect = this.dropdown.getBoundingClientRect(); - this.originalMenuParent = this.menu.parentElement; - - // Find nearest scroll container (main-content-wrapper) within the page's shadow root - const root = this.getRootNode(); - const container = - root.querySelector(".main-content-wrapper") || document.body; - - // Ensure container can anchor absolutely positioned children - const computed = window.getComputedStyle(container); - this.overlayContainer = container; - this.previousContainerPosition = computed.position; - if (computed.position === "static") { - container.style.position = "relative"; + this.dropdown.classList.add('open') + this.carat.style.transform = 'rotate(180deg)' + this.search.value = '' + this.filterOptions() + setTimeout(() => this.search.focus(), 50) + window.addEventListener('click', this.handle_outside_click, true) + const rect = this.dropdown.getBoundingClientRect() + this.original_menu_parent = this.menu.parentElement + const root = this.getRootNode() + const container = root.querySelector('.main-content-wrapper') || document.body + const computed = window.getComputedStyle(container) + this.overlay_container = container + this.previous_container_position = computed.position + if (computed.position === 'static') { + container.style.position = 'relative' } - - container.appendChild(this.menu); - this.menu.classList.add("show"); - - const containerRect = container.getBoundingClientRect(); - - // explicitly size the menu - this.menu.style.position = "absolute"; - this.menu.style.left = `${rect.left - containerRect.left}px`; - this.menu.style.top = `${rect.bottom - containerRect.top}px`; - this.menu.style.minWidth = `${rect.width}px`; - this.menu.style.width = `${rect.width}px`; - this.menu.style.zIndex = "9999"; + container.appendChild(this.menu) + this.menu.classList.add('show') + const container_rect = container.getBoundingClientRect() + this.menu.style.position = 'absolute' + this.menu.style.left = `${rect.left - container_rect.left}px` + this.menu.style.top = `${rect.bottom - container_rect.top}px` + this.menu.style.minWidth = `${rect.width}px` + this.menu.style.width = `${rect.width}px` + this.menu.style.zIndex = '9999' } closeDropdown() { - this.dropdown.classList.remove("open"); - this.carat.style.transform = "rotate(0deg)"; - window.removeEventListener("click", this.handleOutsideClick, true); - - // ======== RESTORE MENU BACK INTO SHADOW DOM ======== - this.menu.classList.remove("show"); - this.menu.style = ""; // reset inline styles - if (this.originalMenuParent) { - this.originalMenuParent.appendChild(this.menu); + this.dropdown.classList.remove('open') + this.carat.style.transform = 'rotate(0deg)' + window.removeEventListener('click', this.handle_outside_click, true) + this.menu.classList.remove('show') + this.menu.style = '' + if (this.original_menu_parent) { + this.original_menu_parent.appendChild(this.menu) } - - if (this.overlayContainer && this.previousContainerPosition === "static") { - this.overlayContainer.style.position = ""; + if (this.overlay_container && this.previous_container_position === 'static') { + this.overlay_container.style.position = '' } } filterOptions() { - const searchTerm = this.search.value.toLowerCase(); - this.options.forEach((opt) => { - const isMatch = opt.textContent - .toLowerCase() - .includes(searchTerm); - opt.style.display = isMatch ? "block" : "none"; - }); + const search_term = this.search.value.toLowerCase() + this.options.forEach(opt => { + const is_match = opt.textContent.toLowerCase().includes(search_term) + opt.style.display = is_match ? 'block' : 'none' + }) } get value() { - return this.value_; + return this.value_ } set value(val) { - const foundOption = this.options.find( - (o) => o.dataset.value === val - ); - if (foundOption) { - this.value_ = val; - this.selectedText.textContent = foundOption.textContent; - - // Update active class for styling - this.options.forEach((opt) => { - opt.classList.toggle("active", opt.dataset.value === val); - }); - - // Persist selection for other components - saveSelectedIntervention(this.value_); - - // Dispatch a change event so outside listeners can react + const found_option = this.options.find(o => o.dataset.value === val) + if (found_option) { + this.value_ = val + this.selected_text.textContent = found_option.textContent + this.options.forEach(opt => { + opt.classList.toggle('active', opt.dataset.value === val) + }) + saveSelectedIntervention(this.value_) this.dispatchEvent( - new CustomEvent("change", { - detail: { value: this.value_ }, + new CustomEvent('change', { + detail: { value: this.value_ } }) - ); + ) } } } -); +) diff --git a/components/challenge/challenge-page.js b/components/challenge/challenge-page.js deleted file mode 120000 index 83bb0fd..0000000 --- a/components/challenge/challenge-page.js +++ /dev/null @@ -1 +0,0 @@ -../pages/challenge-page.js \ No newline at end of file diff --git a/components/challenge/challenge-page.js b/components/challenge/challenge-page.js new file mode 100644 index 0000000..83bb0fd --- /dev/null +++ b/components/challenge/challenge-page.js @@ -0,0 +1 @@ +../pages/challenge-page.js \ No newline at end of file diff --git a/components/challenges/challenges-page.js b/components/challenges/challenges-page.js deleted file mode 120000 index 54eeb7a..0000000 --- a/components/challenges/challenges-page.js +++ /dev/null @@ -1 +0,0 @@ -../pages/challenges-page.js \ No newline at end of file diff --git a/components/challenges/challenges-page.js b/components/challenges/challenges-page.js new file mode 100644 index 0000000..54eeb7a --- /dev/null +++ b/components/challenges/challenges-page.js @@ -0,0 +1 @@ +../pages/challenges-page.js \ No newline at end of file diff --git a/components/dashboard/analytics.js b/components/dashboard/analytics.js index 1d44f27..32cb6cc 100644 --- a/components/dashboard/analytics.js +++ b/components/dashboard/analytics.js @@ -40,7 +40,7 @@ customElements.define( class extends HTMLElement { constructor() { super(); - const shadow = this.attachShadow({ mode: "closed" }); + const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild(template.content.cloneNode(true)); } @@ -52,12 +52,18 @@ customElements.define( loadAnalyticsData() { const data = getDashboardMetrics(); const values = this.shadowRoot.querySelectorAll('.stat-value'); - values[0].textContent = data.focusMinutes; - values[1].textContent = `${data.screenTimeHours}h`; - values[2].textContent = `${data.focusIncreasePercent}%`; - values[3].textContent = data.overrideMinutes; - values[4].textContent = data.overrideCount; - values[5].textContent = data.sitesBlocked; + + // Check if elements exist and have expected length + if (values && values.length >= 6) { + values[0].textContent = data.focusMinutes; + values[1].textContent = `${data.screenTimeHours}h`; + values[2].textContent = `${data.focusIncreasePercent}%`; + values[3].textContent = data.overrideMinutes; + values[4].textContent = data.overrideCount; + values[5].textContent = data.sitesBlocked; + } else { + console.warn('[nirva-analytics] Stat elements not found'); + } } } ); diff --git a/components/dashboard/full-stats.js b/components/dashboard/full-stats.js index ab0964c..d0580b5 100644 --- a/components/dashboard/full-stats.js +++ b/components/dashboard/full-stats.js @@ -36,7 +36,7 @@ customElements.define( class extends HTMLElement { constructor() { super(); - const shadow = this.attachShadow({ mode: "closed" }); + const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild(template.content.cloneNode(true)); } @@ -47,12 +47,18 @@ customElements.define( setupResetLinks() { const resetLinks = this.shadowRoot.querySelectorAll('.reset-link'); - resetLinks.forEach(link => { - link.addEventListener('click', (e) => { - e.preventDefault(); - this.resetStats(e.target); + + // Check if elements exist before iterating + if (resetLinks && resetLinks.length > 0) { + resetLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + this.resetStats(e.target); + }); }); - }); + } else { + console.warn('[nirva-full-stats] Reset link elements not found'); + } } resetStats(target) { diff --git a/components/dashboard/past-sessions.js b/components/dashboard/past-sessions.js index f116d5d..7784ee1 100644 --- a/components/dashboard/past-sessions.js +++ b/components/dashboard/past-sessions.js @@ -23,7 +23,7 @@ customElements.define( class extends HTMLElement { constructor() { super(); - const shadow = this.attachShadow({ mode: "closed" }); + const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild(template.content.cloneNode(true)); } diff --git a/components/dashboard/session-config-modal.js b/components/dashboard/session-config-modal.js new file mode 100644 index 0000000..c6f3e0a --- /dev/null +++ b/components/dashboard/session-config-modal.js @@ -0,0 +1,463 @@ +import { loadSessions } from '../storage/session-storage.js'; +import { loadBlockGroupMeta } from '../storage/blocklist-storage.js'; +import { loadInterventions } from '../interventions/intervention-storage.js'; + +const template = document.createElement("template"); +template.innerHTML = ` + + + + + +`; + +customElements.define( + "nirva-session-config-modal", + class extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + shadow.appendChild(template.content.cloneNode(true)); + + this.selectedTemplate = null; + this.selectedBlockGroups = new Set(); + this.selectedInterventions = new Set(); + } + + connectedCallback() { + this.setupEventListeners(); + this.loadData(); + } + + setupEventListeners() { + const closeBtn = this.shadowRoot.querySelector('#close-modal'); + const cancelBtn = this.shadowRoot.querySelector('#cancel-button'); + const startBtn = this.shadowRoot.querySelector('#start-session-button'); + const overlay = this.shadowRoot.querySelector('.modal-overlay'); + + closeBtn.addEventListener('click', () => this.close()); + cancelBtn.addEventListener('click', () => this.close()); + startBtn.addEventListener('click', () => this.startSession()); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this.close(); + } + }); + + // Template duration inputs + const studyInput = this.shadowRoot.querySelector('#study-duration'); + const breakInput = this.shadowRoot.querySelector('#break-duration'); + + studyInput.addEventListener('input', () => this.updateCustomConfig()); + breakInput.addEventListener('input', () => this.updateCustomConfig()); + } + + async loadData() { + await Promise.all([ + this.loadSessionTemplates(), + this.loadBlockGroups(), + this.loadInterventions() + ]); + } + + async loadSessionTemplates() { + const sessions = await loadSessions(); + const templatesContainer = this.shadowRoot.querySelector('#session-templates'); + + sessions.forEach(session => { + const templateCard = document.createElement('div'); + templateCard.className = 'template-card'; + templateCard.innerHTML = ` +
${session.name}
+
${session.studyMinutes}min study / ${session.breakMinutes}min break
+
${session.description}
+ `; + + templateCard.addEventListener('click', () => { + this.selectTemplate(session, templateCard); + }); + + templatesContainer.appendChild(templateCard); + }); + } + + async loadBlockGroups() { + const blockGroups = await loadBlockGroupMeta(); + const container = this.shadowRoot.querySelector('#block-groups'); + + blockGroups.forEach((group, index) => { + const item = document.createElement('div'); + item.className = 'selection-item'; + item.textContent = group.name || `Block Group ${index + 1}`; + item.dataset.groupId = index; + + item.addEventListener('click', () => { + this.toggleBlockGroup(index, item); + }); + + container.appendChild(item); + }); + } + + async loadInterventions() { + const interventions = await loadInterventions(); + const container = this.shadowRoot.querySelector('#interventions'); + + if (interventions && interventions.items && interventions.items.length > 0) { + interventions.items.forEach(intervention => { + const item = document.createElement('div'); + item.className = 'selection-item'; + item.textContent = intervention.name; + item.dataset.interventionId = intervention.id; + + item.addEventListener('click', () => { + this.toggleIntervention(intervention.id, item); + }); + + container.appendChild(item); + }); + } else { + container.innerHTML = '
No interventions available
'; + } + } + + selectTemplate(session, templateCard) { + // Remove previous selection + this.shadowRoot.querySelectorAll('.template-card.selected').forEach(card => { + card.classList.remove('selected'); + }); + + // Select new template + templateCard.classList.add('selected'); + this.selectedTemplate = session; + + // Update custom inputs + this.shadowRoot.querySelector('#study-duration').value = session.studyMinutes; + this.shadowRoot.querySelector('#break-duration').value = session.breakMinutes; + } + + updateCustomConfig() { + // Clear template selection when custom values are changed + this.shadowRoot.querySelectorAll('.template-card.selected').forEach(card => { + card.classList.remove('selected'); + }); + this.selectedTemplate = null; + } + + toggleBlockGroup(groupId, element) { + if (this.selectedBlockGroups.has(groupId)) { + this.selectedBlockGroups.delete(groupId); + element.classList.remove('selected'); + } else { + this.selectedBlockGroups.add(groupId); + element.classList.add('selected'); + } + } + + toggleIntervention(interventionId, element) { + if (this.selectedInterventions.has(interventionId)) { + this.selectedInterventions.delete(interventionId); + element.classList.remove('selected'); + } else { + this.selectedInterventions.add(interventionId); + element.classList.add('selected'); + } + } + + startSession() { + const studyDuration = parseInt(this.shadowRoot.querySelector('#study-duration').value); + const breakDuration = parseInt(this.shadowRoot.querySelector('#break-duration').value); + + if (!studyDuration || studyDuration < 1) { + alert('Please enter a valid study duration'); + return; + } + + if (!breakDuration || breakDuration < 1) { + alert('Please enter a valid break duration'); + return; + } + + const sessionConfig = { + studyMinutes: studyDuration, + breakMinutes: breakDuration, + blockGroups: Array.from(this.selectedBlockGroups), + interventions: Array.from(this.selectedInterventions), + template: this.selectedTemplate + }; + + console.log('Starting session with config:', sessionConfig); + + // Dispatch custom event with session configuration + this.dispatchEvent(new CustomEvent('session-start', { + detail: sessionConfig, + bubbles: true, + composed: true // Important for crossing shadow DOM boundaries + })); + + this.close(); + } + + close() { + this.remove(); + } + } +); diff --git a/components/dashboard/session-selector-modal.js b/components/dashboard/session-selector-modal.js new file mode 100644 index 0000000..effd929 --- /dev/null +++ b/components/dashboard/session-selector-modal.js @@ -0,0 +1,499 @@ +import { loadSessions } from '../storage/session-storage.js'; +import { loadBlockGroupMeta } from '../storage/blocklist-storage.js'; +import { loadInterventions } from '../interventions/intervention-storage.js'; + +const template = document.createElement("template"); +template.innerHTML = ` + + + + + +`; + +customElements.define( + "nirva-session-selector-modal", + class extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + shadow.appendChild(template.content.cloneNode(true)); + + this.sessions = []; + this.selectedSessionId = null; + this.selectedSession = null; + } + + connectedCallback() { + this.setupEventListeners(); + this.loadSessions(); + } + + setupEventListeners() { + const closeBtn = this.shadowRoot.querySelector('#close-modal'); + const cancelBtn = this.shadowRoot.querySelector('#cancel-button'); + const createNewBtn = this.shadowRoot.querySelector('#create-new-button'); + const startSelectedBtn = this.shadowRoot.querySelector('#start-selected-button'); + const overlay = this.shadowRoot.querySelector('.modal-overlay'); + + closeBtn.addEventListener('click', () => this.close()); + cancelBtn.addEventListener('click', () => this.close()); + createNewBtn.addEventListener('click', () => this.createNewSession()); + startSelectedBtn.addEventListener('click', () => { + if (this.selectedSession) { + this.startSession(this.selectedSession); + } + }); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + this.close(); + } + }); + } + + async loadSessions() { + try { + this.sessions = await loadSessions(); + this.renderSessionList(); + } catch (error) { + console.error('Error loading sessions:', error); + this.renderError(); + } + } + + renderSessionList() { + const container = this.shadowRoot.querySelector('#session-list'); + container.innerHTML = ''; + + if (!this.sessions || this.sessions.length === 0) { + this.renderEmptyState(container); + return; + } + + this.sessions.forEach(session => { + const sessionCard = document.createElement('div'); + sessionCard.className = 'session-card'; + sessionCard.dataset.id = session.id; + + sessionCard.innerHTML = ` +
+
${session.name}
+
${session.studyMinutes}min study / ${session.breakMinutes}min break
+
${session.description || 'No description available'}
+
+
+ +
+ `; + + container.appendChild(sessionCard); + + // Add event listener to the card to select it + sessionCard.addEventListener('click', (e) => { + // Don't select if clicking on the start button + if (!e.target.closest('.start-button')) { + this.selectSession(session.id); + } + }); + + // Add event listener to start button + const startBtn = sessionCard.querySelector('.start-button'); + startBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent event bubbling + this.startSession(session); + }); + }); + } + + selectSession(sessionId) { + // Find the session object + const session = this.sessions.find(s => s.id === sessionId); + if (!session) return; + + // Store the selected session + this.selectedSessionId = sessionId; + this.selectedSession = session; + + // Update UI - clear previously selected + const allCards = this.shadowRoot.querySelectorAll('.session-card'); + allCards.forEach(card => card.classList.remove('selected')); + + // Mark the selected card + const selectedCard = this.shadowRoot.querySelector(`.session-card[data-id="${sessionId}"]`); + if (selectedCard) { + selectedCard.classList.add('selected'); + } + + // Enable the start selected button + const startSelectedBtn = this.shadowRoot.querySelector('#start-selected-button'); + startSelectedBtn.disabled = false; + + console.log(`Session selected: ${session.name} (${sessionId})`); + } + + renderEmptyState(container) { + container.innerHTML = ` +
+
No saved sessions found
+ +
+ `; + + const createBtn = container.querySelector('#create-first-session'); + createBtn.addEventListener('click', () => this.createNewSession()); + } + + renderError() { + const container = this.shadowRoot.querySelector('#session-list'); + container.innerHTML = ` +
+
Error loading sessions
+ +
+ `; + + const retryBtn = container.querySelector('#retry-load'); + retryBtn.addEventListener('click', () => this.loadSessions()); + } + + async startSession(session) { + try { + console.log('Starting session with raw data:', JSON.stringify(session)); + + // Validate session data + if (!session.studyMinutes || isNaN(session.studyMinutes)) { + console.error('Invalid study minutes:', session.studyMinutes); + session.studyMinutes = 25; // Default to 25 minutes + } + + if (!session.breakMinutes || isNaN(session.breakMinutes)) { + console.error('Invalid break minutes:', session.breakMinutes); + session.breakMinutes = 5; // Default to 5 minutes + } + + // Load required data for session configuration + const blockGroups = await this.getBlockGroupsForSession(session); + const interventions = await this.getInterventionsForSession(session); + + // Create session configuration + const sessionConfig = { + studyMinutes: session.studyMinutes, + breakMinutes: session.breakMinutes, + blockGroups: blockGroups, + interventions: interventions, + template: session + }; + + console.log('Starting session with config:', sessionConfig); + + // Add visual feedback + const debugInfo = document.createElement('div'); + debugInfo.style.position = 'fixed'; + debugInfo.style.bottom = '10px'; + debugInfo.style.right = '10px'; + debugInfo.style.background = 'green'; + debugInfo.style.color = 'white'; + debugInfo.style.padding = '10px'; + debugInfo.style.borderRadius = '5px'; + debugInfo.style.zIndex = '9999'; + debugInfo.textContent = 'Starting session...'; + document.body.appendChild(debugInfo); + + // Dispatch session-start event to be caught by the nirva-sessions component + const event = new CustomEvent('session-start', { + detail: sessionConfig, + bubbles: true, + composed: true // This is important for events to cross shadow DOM boundaries + }); + + // Try dispatching directly on document as well + this.dispatchEvent(event); + document.dispatchEvent(new CustomEvent('session-start', { + detail: sessionConfig, + bubbles: true, + composed: true + })); + + console.log('Session start event dispatched'); + + // Remove debug info after a delay + setTimeout(() => { + if (debugInfo.parentNode) { + document.body.removeChild(debugInfo); + } + }, 3000); + + // Close this modal after dispatching the event + this.close(); + + } catch (error) { + console.error('Error starting session:', error); + alert('Failed to start session. Please try again.'); + } + } + + async getBlockGroupsForSession(session) { + // If the session has specific block groups defined, use those + if (session.blockGroups && session.blockGroups.length > 0) { + return session.blockGroups; + } + + // Otherwise, if the session has blockSets defined, map them to block group indices + if (session.blockSets && session.blockSets.length > 0) { + try { + const blockGroupsMeta = await loadBlockGroupMeta(); + // Find block groups with matching names + return blockGroupsMeta + .map((group, index) => ({index, name: group.name})) + .filter(item => session.blockSets.includes(item.name)) + .map(item => item.index); + } catch (error) { + console.error('Error mapping block sets to groups:', error); + return []; + } + } + + // Default to no block groups + return []; + } + + async getInterventionsForSession(session) { + // If the session has specific interventions defined, use those + if (session.interventions && session.interventions.length > 0) { + return session.interventions; + } + + // Default to no interventions + return []; + } + + createNewSession() { + console.log('Creating new session...'); + // Close this modal + this.close(); + + // Open the session configuration modal + const configModal = document.createElement('nirva-session-config-modal'); + document.body.appendChild(configModal); + + console.log('Config modal created and added to DOM'); + } + + close() { + this.remove(); + } + } +); diff --git a/components/dashboard/sessions.js b/components/dashboard/sessions.js index eb24ba8..22b69c3 100644 --- a/components/dashboard/sessions.js +++ b/components/dashboard/sessions.js @@ -1,13 +1,113 @@ +import './session-config-modal.js'; +import './session-selector-modal.js'; +import { sessionIntegration } from '../utils/session-integration.js'; + const template = document.createElement("template"); template.innerHTML = ` + +

Sessions

+ +
+
No active session
+
Configure and start a study session to begin focus mode
+
+
- - - + + +
`; @@ -17,37 +117,1007 @@ customElements.define( class extends HTMLElement { constructor() { super(); - const shadow = this.attachShadow({ mode: "closed" }); + const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild(template.content.cloneNode(true)); + + this.currentSession = null; + this.sessionTimer = null; } connectedCallback() { this.setupSessionControls(); + this.loadSessionState(); + + // Listen for session start events from the modal + const sessionStartHandler = (e) => { + console.log('Received session-start event:', e.detail); + this.handleSessionStart(e.detail); + }; + + // Remove existing event listener if any + document.removeEventListener('session-start', sessionStartHandler); + + // Add fresh event listener + document.addEventListener('session-start', sessionStartHandler); + + // Also listen directly on this element + this.addEventListener('session-start', (e) => { + console.log('Received session-start event directly on nirva-sessions:', e.detail); + this.handleSessionStart(e.detail); + }); + } + + disconnectedCallback() { + if (this.sessionTimer) { + clearInterval(this.sessionTimer); + } } setupSessionControls() { const cancelBtn = this.shadowRoot.querySelector('#cancel-session'); const startBtn = this.shadowRoot.querySelector('#start-session'); const overrideBtn = this.shadowRoot.querySelector('#start-override'); - - cancelBtn.addEventListener('click', () => this.cancelSession()); - startBtn.addEventListener('click', () => this.startNewSession()); - overrideBtn.addEventListener('click', () => this.startOverride()); + + // Check if elements exist before adding event listeners + if (cancelBtn) { + cancelBtn.addEventListener('click', () => this.cancelSession()); + } + + if (startBtn) { + console.log('Adding click event listener to start session button'); + startBtn.addEventListener('click', () => { + console.log('Start session button clicked'); + this.startNewSession(); + }); + } else { + console.error('Start session button not found'); + } + + if (overrideBtn) { + overrideBtn.addEventListener('click', () => this.startOverride()); + } } - cancelSession() { - // Logic to cancel current session - console.log('Cancelling current session'); + async loadSessionState() { + // Check if there's an active session stored + try { + const result = await chrome.storage.local.get(['activeSession']); + if (result.activeSession) { + this.currentSession = result.activeSession; + this.updateSessionDisplay(); + this.startSessionTimer(); + } + } catch (error) { + console.error('Error loading session state:', error); + } } startNewSession() { - // Logic to start new session - console.log('Starting new session'); + console.log('Creating session selector modal'); + try { + // Create and show the session selector modal + const modal = document.createElement('nirva-session-selector-modal'); + document.body.appendChild(modal); + console.log('Session selector modal added to DOM'); + } catch (error) { + console.error('Error creating session selector modal:', error); + } + } + + async handleSessionStart(sessionConfig) { + console.log('Starting session with config:', JSON.stringify(sessionConfig)); + + try { + if (!sessionConfig) { + console.error('Session config is undefined or null'); + return; + } + + // Validate important fields + const studyMinutes = Number(sessionConfig.studyMinutes) || 25; + const breakMinutes = Number(sessionConfig.breakMinutes) || 5; + + if (isNaN(studyMinutes) || studyMinutes <= 0) { + console.error('Invalid study minutes:', sessionConfig.studyMinutes); + } + + if (isNaN(breakMinutes) || breakMinutes <= 0) { + console.error('Invalid break minutes:', sessionConfig.breakMinutes); + } + + // Create session object + this.currentSession = { + id: Date.now().toString(), + startTime: Date.now(), + studyMinutes: studyMinutes, + breakMinutes: breakMinutes, + blockGroups: sessionConfig.blockGroups || [], + interventions: sessionConfig.interventions || [], + template: sessionConfig.template, + phase: 'study', // 'study' or 'break' + phaseStartTime: Date.now(), + cycleCount: 1 + }; + + console.log('Session object created:', this.currentSession); + + // Save session state + await this.saveSessionState(); + } catch (error) { + console.error('Error in handleSessionStart:', error); + } + + // Update UI + this.updateSessionDisplay(); + this.startSessionTimer(); + + // Activate block groups and interventions + await this.activateSessionComponents(); + + try { + // Notify other components + console.log('Dispatching session-activated event with session:', this.currentSession); + + // Dispatch the event on multiple channels to ensure it's received + + // 1. Dispatch on this element with bubbling and composition + const event = new CustomEvent('session-activated', { + detail: this.currentSession, + bubbles: true, + composed: true // Important for crossing shadow DOM boundaries + }); + this.dispatchEvent(event); + + // 2. Dispatch directly on document + document.dispatchEvent(new CustomEvent('session-activated', { + detail: this.currentSession, + bubbles: true, + composed: true + })); + + // 3. Dispatch on window for good measure + window.dispatchEvent(new CustomEvent('session-activated', { + detail: this.currentSession, + bubbles: true, + composed: true + })); + + // 4. Add a visual indicator for user feedback + const feedbackEl = document.createElement('div'); + feedbackEl.style.position = 'fixed'; + feedbackEl.style.top = '20px'; + feedbackEl.style.right = '20px'; + feedbackEl.style.backgroundColor = 'rgba(0, 128, 0, 0.8)'; + feedbackEl.style.color = 'white'; + feedbackEl.style.padding = '15px'; + feedbackEl.style.borderRadius = '5px'; + feedbackEl.style.zIndex = '9999'; + feedbackEl.textContent = 'Session started successfully!'; + document.body.appendChild(feedbackEl); + + setTimeout(() => { + if (feedbackEl.parentNode) { + document.body.removeChild(feedbackEl); + } + }, 3000); + + } catch (error) { + console.error('Error dispatching session-activated event:', error); + } + } + + async saveSessionState() { + try { + await chrome.storage.local.set({ activeSession: this.currentSession }); + } catch (error) { + console.error('Error saving session state:', error); + } + } + + async clearSessionState() { + try { + await chrome.storage.local.remove(['activeSession']); + } catch (error) { + console.error('Error clearing session state:', error); + } + } + + updateSessionDisplay() { + const statusElement = this.shadowRoot.querySelector('#session-status'); + const cancelBtn = this.shadowRoot.querySelector('#cancel-session'); + const startBtn = this.shadowRoot.querySelector('#start-session'); + const overrideBtn = this.shadowRoot.querySelector('#start-override'); + + if (this.currentSession) { + statusElement.className = 'session-status active'; + + const phaseName = this.currentSession.phase === 'study' ? 'Focus' : 'Break'; + const templateName = this.currentSession.template ? this.currentSession.template.name : 'Custom Session'; + + statusElement.innerHTML = ` +
${templateName} - ${phaseName} Phase (Cycle ${this.currentSession.cycleCount})
+
+ ${this.currentSession.studyMinutes}min study / ${this.currentSession.breakMinutes}min break | + ${this.currentSession.blockGroups.length} block groups | + ${this.currentSession.interventions.length} interventions +
+ `; + + cancelBtn.disabled = false; + startBtn.textContent = 'New Session'; + overrideBtn.disabled = false; + } else { + statusElement.className = 'session-status inactive'; + statusElement.innerHTML = ` +
No active session
+
Configure and start a study session to begin focus mode
+ `; + + cancelBtn.disabled = true; + startBtn.textContent = 'Start New Session'; + overrideBtn.disabled = true; + } + } + + startSessionTimer() { + if (this.sessionTimer) { + clearInterval(this.sessionTimer); + } + + this.sessionTimer = setInterval(() => { + this.updateSessionProgress(); + }, 1000); + } + + updateSessionProgress() { + if (!this.currentSession) return; + + const now = Date.now(); + const phaseElapsed = Math.floor((now - this.currentSession.phaseStartTime) / 1000 / 60); // minutes + const phaseDuration = this.currentSession.phase === 'study' + ? this.currentSession.studyMinutes + : this.currentSession.breakMinutes; + + if (phaseElapsed >= phaseDuration) { + this.switchPhase(); + } + } + + async switchPhase() { + if (!this.currentSession) return; + + if (this.currentSession.phase === 'study') { + // Switch to break + this.currentSession.phase = 'break'; + this.currentSession.phaseStartTime = Date.now(); + + // Show break notification + this.showNotification('Break Time!', `Take a ${this.currentSession.breakMinutes}-minute break.`); + } else { + // Switch to study (new cycle) + this.currentSession.phase = 'study'; + this.currentSession.phaseStartTime = Date.now(); + this.currentSession.cycleCount++; + + // Show study notification + this.showNotification('Back to Focus!', `Starting study cycle ${this.currentSession.cycleCount}.`); + } + + await this.saveSessionState(); + this.updateSessionDisplay(); + + // Dispatch phase change event + this.dispatchEvent(new CustomEvent('session-phase-changed', { + detail: { + phase: this.currentSession.phase, + cycleCount: this.currentSession.cycleCount, + studyMinutes: this.currentSession.studyMinutes, + breakMinutes: this.currentSession.breakMinutes + }, + bubbles: true + })); + } + + showNotification(title, message) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(title, { + body: message, + icon: '/assets/images/logo.png' + }); + } + } + + async activateSessionComponents() { + // Use the session integration service to activate components + console.log('Activating session components via integration service'); + + try { + await sessionIntegration.activateSession({ + id: this.currentSession.id, + startTime: this.currentSession.startTime, + studyMinutes: this.currentSession.studyMinutes, + breakMinutes: this.currentSession.breakMinutes, + blockGroups: this.currentSession.blockGroups, + interventions: this.currentSession.interventions + }); + console.log('Session components activated successfully'); + } catch (error) { + console.error('Error activating session components:', error); + } + } + + async cancelSession() { + if (!this.currentSession) return; + + const confirmed = confirm('Are you sure you want to cancel the current session?'); + if (!confirmed) return; + + // Clear session + this.currentSession = null; + await this.clearSessionState(); + + // Clear timer + if (this.sessionTimer) { + clearInterval(this.sessionTimer); + this.sessionTimer = null; + } + + // Update UI + this.updateSessionDisplay(); + + // Deactivate session components + await this.deactivateSessionComponents(); + + // Notify other components + this.dispatchEvent(new CustomEvent('session-cancelled', { + bubbles: true + })); + + console.log('Session cancelled'); + } + + async deactivateSessionComponents() { + // Use the session integration service to deactivate components + console.log('Deactivating session components via integration service'); + + try { + await sessionIntegration.deactivateSession(); + console.log('Session components deactivated successfully'); + } catch (error) { + console.error('Error deactivating session components:', error); + } } - startOverride() { - // Logic to start override - console.log('Starting override'); + async startOverride() { + // Temporary override for emergencies + const reason = prompt('Enter reason for override (required):'); + if (!reason || reason.trim() === '') return; + + console.log('Evaluating override reason:', reason); + + // Show loading indicator + const loadingEl = document.createElement('div'); + loadingEl.style.position = 'fixed'; + loadingEl.style.top = '50%'; + loadingEl.style.left = '50%'; + loadingEl.style.transform = 'translate(-50%, -50%)'; + loadingEl.style.background = 'rgba(0, 0, 0, 0.8)'; + loadingEl.style.color = 'white'; + loadingEl.style.padding = '20px'; + loadingEl.style.borderRadius = '10px'; + loadingEl.style.zIndex = '10000'; + loadingEl.style.textAlign = 'center'; + loadingEl.innerHTML = ` +
Analyzing reason...
+
Our AI is determining if this is a valid emergency
+ `; + document.body.appendChild(loadingEl); + + try { + // Use the AI model to evaluate the reason + const isValidReason = await this.evaluateReasonWithAI(reason); + + // Remove loading indicator + document.body.removeChild(loadingEl); + + if (!isValidReason) { + // Show rejection message with the actual reason quoted + alert('😅 "' + reason + '" is not a valid emergency reason. Nice try though!'); + console.log('Override rejected: AI determined reason was not important'); + return; + } + + console.log('Starting override with valid reason:', reason); + + // Implement override logic + // - Temporarily disable blocking (15 minutes) + // - Persist override for UI and potential re-application + const DURATION_MIN = 15; + const expiresAt = Date.now() + DURATION_MIN * 60 * 1000; + + try { + // Disable blocklists in background + await this.send('set-blocking-enabled', { enabled: false }); + + // Persist override state + const mod = await import('../storage/overrides-storage.js'); + const current = (await mod.loadOverrides()) || {}; + await mod.saveOverrides({ + ...current, + disable_blocklists: true, + expiresAt + }); + + // Auto re-enable after duration + setTimeout(async () => { + try { + await this.send('set-blocking-enabled', { enabled: true }); + const latest = (await mod.loadOverrides()) || {}; + await mod.saveOverrides({ + ...latest, + disable_blocklists: false, + expiresAt: null + }); + } catch (e) { + console.warn('Failed to auto-disable override:', e); + } + }, DURATION_MIN * 60 * 1000); + + // Show confirmation + alert('Emergency override activated for 15 minutes. Use it wisely!'); + } catch (e) { + console.error('Failed to activate override', e); + alert('Could not activate override. Please try again.'); + } + + } catch (error) { + // Remove loading indicator in case of error + if (loadingEl.parentNode) { + document.body.removeChild(loadingEl); + } + + console.error('Error evaluating reason:', error); + + // Fallback to simple check for common invalid reasons + const commonInvalidTerms = ['bored', 'youtube', 'social', 'fun', 'waste', 'just because']; + const isObviouslyInvalid = commonInvalidTerms.some(term => reason.toLowerCase().includes(term)); + + if (isObviouslyInvalid) { + alert('😅 "' + reason + '" is not a valid emergency reason.'); + return; + } + + // Allow it if AI check failed but it's not obviously invalid + alert('Override activated for a limited time. Use it wisely!'); + } + } + + // Nirvanify — MILSPEC Override Evaluator (no LLM, pure JS) + // Purpose: approve only tightly structured, time-boxed, outcome-oriented reasons, + // and annihilate spam/gibberish with layered detectors. + // Notes: strict >200 chars and >50 words. Platform penalties for social sites. + // No calendar boosts. Deterministic. Tweak config to taste. + + async evaluateReasonWithAI(reason, opts = {}) { + const config = { + minChars: 201, + minWords: 51, + approveThreshold: 4.25, + softApproveThreshold: 3.25, + maxPenaltyClamp: 12, + + distractionSites: [ + 'youtube', 'tiktok', 'instagram', 'reddit', 'twitter', 'x.com', 'facebook', 'snapchat', + 'twitch', 'netflix', 'primevideo', 'hulu', 'roblox', 'steam', 'epicgames', 'pinterest', '9gag', 'discord' + ], + + allowedExceptions: [ + { phrase: /\b(2fa|two[-\s]?factor|authenticator|verify|verification code)\b/i, weight: 1.3 }, + { phrase: /\b(support|helpdesk|customer)\b.*\b(ticket|case|id|ref)\b/i, weight: 1.1 }, + { phrase: /\b(upload|submit|post)\b.*\b(form|portal|submission)\b/i, weight: 0.9 }, + { phrase: /\b(contact|message)\b.*\b(teacher|instructor|coach|mentor)\b/i, weight: 0.9 } + ], + + leisureWords: { + bored: 1.0, chill: 1.0, relax: 0.9, fun: 0.8, scroll: 1.4, binge: 1.4, meme: 1.0, + highlights: 0.8, stream: 0.9, watch: 1.1, clips: 1.0, game: 1.3, play: 0.9 + }, + vagueFillers: { + just: 1.0, kinda: 0.7, maybe: 0.6, because: 0.6, whatever: 1.0, idk: 1.1, random: 0.7, + break: 0.8, tired: 0.6, later: 0.6, sometime: 0.7, asap: 0.8, vibe: 1.0 + }, + productivityWords: { + research: 0.7, reference: 0.7, tutorial: 0.6, outline: 0.7, summarize: 0.8, + notes: 0.6, citation: 0.8, export: 0.6, transcript: 0.8, resource: 0.6, timestamp: 0.7 + }, + + structure: { + purposeRe: /\b(write|outline|summariz|compile|extract|collect|compare|document|draft|prepare|record|transcrib|cite|reference|annotat|export|catalog|organize)\w*\b/i, + outcomeRe: /\b(draft|outline|summary|notes|bullets?|todo list|citations?|snippets?|timestamps?|highlights?|checklist|action items?)\b/i, + timeboxRe: /\b(for|within|in)\s+(\d{1,2})\s*(min|mins|minute|minutes)\b/i, + maxMinutes: 10 + }, + + // MILSPEC anti-gibberish/anti-spam thresholds + gibberish: { + minCharDiversity: 0.30, // unique chars / length + minEntropyPerChar: 2.35, // Shannon-ish per-char entropy + minBigramEntropy: 2.8, // entropy on bigrams + maxTopTokenShare: 0.20, // most frequent token share + maxRepeatedCharRun: 6, // any char 7+ in a row + maxRepeatedWordRun: 5, // same word repeated 6x in a row + minStopwordRatio: 0.18, // natural language has some stopwords + maxNonAlphaRatio: 0.40, // symbols/digits overload + maxSentenceDuplication: 0.35, // fraction of sentences that are near-duplicates + maxPeriodicPatternScore: 0.12, // cyclic pattern score for periods up to 8 + maxAlternationScore: 0.10, // ABAB/ABCABC alternation score + maxKeyboardWalks: 2, // number of keyboard walks allowed + maxNgramLoopScore: 0.12, // repetitive n-gram loopiness + minVowelRatio: 0.28, // vowels/(letters) + maxConsecutiveConsonants: 6, // like "grstplk" + maxCamelOrAllcapsRatio: 0.45, // too many allcaps/CamelTokens + maxSuspiciousWordRatio: 0.35, // tokens with no vowels or shape too weird + invisibleCharRE: /[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/, // zero-width & bidi + keyboardRunsRE: /(qwerty|asdfg|zxcvb|12345|09876|poiuy|lkjhg|mnbvc)/i + }, + + // Tiny common-word list to approximate language-likeness without an external dict + commonWords: new Set([ + 'the', 'be', 'and', 'of', 'a', 'in', 'to', 'it', 'is', 'i', 'that', 'for', 'you', 'with', 'on', 'this', 'was', 'are', 'as', 'have', + 'we', 'not', 'or', 'but', 'if', 'then', 'so', 'from', 'by', 'an', 'at', 'my', 'your', 'our', 'their', 'they', 'he', 'she', 'his', 'her', + 'do', 'does', 'did', 'will', 'can', 'just', 'about', 'more', 'like', 'one', 'time', 'use', 'work', 'need', 'want', 'make', 'get', + 'into', 'out', 'up', 'down', 'over', 'under', 'before', 'after', 'today', 'now' + ]) + }; + + // Normalize and gate + const reasonStr = String(reason || '').replace(/\s+/g, ' ').trim(); + const words = reasonStr.split(/\s+/).filter(Boolean); + + // Hard gates + const hardViolations = []; + if (reasonStr.length < config.minChars) hardViolations.push(`too_short_chars_${reasonStr.length}/${config.minChars}`); + if (words.length < config.minWords) hardViolations.push(`too_few_words_${words.length}/${config.minWords}`); + if (config.gibberish.invisibleCharRE.test(reasonStr)) hardViolations.push('invisible_chars_detected'); + + if (hardViolations.length) { + return { + approved: false, + reason: 'Failed minimum requirements', + violations: hardViolations, + scores: { net: -7, pro: 0, anti: 7, structure: 0, spamPenalty: 0 }, + breakdown: [], + normalizedReason: reasonStr + }; + } + + // Tokenize + function tokenize(s) { + return s + .toLowerCase() + .replace(/[^\p{L}\p{N}\s']/gu, ' ') + .split(/\s+/) + .filter(Boolean); + } + const tokens = tokenize(reasonStr); + const lower = reasonStr.toLowerCase(); + + // Helpers + function charEntropy(s) { + if (!s) return 0; + const f = {}; + for (const ch of s) f[ch] = (f[ch] || 0) + 1; + const n = s.length; let H = 0; + for (const k in f) { const p = f[k] / n; H -= p * Math.log2(p); } + return H / n; + } + function bigramEntropy(s) { + const grams = {}; + for (let i = 0; i < s.length - 1; i++) { + const g = s[i] + s[i + 1]; + grams[g] = (grams[g] || 0) + 1; + } + const total = Math.max(1, s.length - 1); + let H = 0; + for (const k in grams) { const p = grams[k] / total; H -= p * Math.log2(p); } + return H; + } + function mostFrequentTokenShare(toks) { + const map = {}; + for (const t of toks) map[t] = (map[t] || 0) + 1; + const total = toks.length || 1; + let maxf = 0; + for (const k in map) maxf = Math.max(maxf, map[k]); + return maxf / total; + } + function repeatedWordRun(toks) { + let best = 1, cur = 1; + for (let i = 1; i < toks.length; i++) { + if (toks[i] === toks[i - 1]) cur++; + else { if (cur > best) best = cur; cur = 1; } + } + return Math.max(best, cur); + } + function charDiversity(s) { return new Set([...s]).size / Math.max(1, s.length); } + + // Alternating/cyclic pattern detectors + function periodicPatternScore(s, maxPeriod = 8) { + // Measures how much the string is explainable by repeating a short period. + let best = 0; + for (let p = 1; p <= Math.min(maxPeriod, Math.floor(s.length / 2)); p++) { + let matches = 0; + for (let i = p; i < s.length; i++) { + if (s[i] === s[i - p]) matches++; + } + const score = matches / Math.max(1, s.length - p); + if (score > best) best = score; + } + return best; // 0..1 + } + function alternationScore(toks, maxPeriod = 6) { + // Token-level ABAB/ABC alternation + if (toks.length < 6) return 0; + let best = 0; + for (let p = 1; p <= Math.min(maxPeriod, Math.floor(toks.length / 2)); p++) { + let matches = 0, comps = 0; + for (let i = p; i < toks.length; i++) { + comps++; + if (toks[i] === toks[i - p]) matches++; + } + best = Math.max(best, matches / Math.max(1, comps)); + } + return best; + } + function ngramLoopScore(toks, n = 3) { + // Measures loopiness from repetitive n-grams + if (toks.length < n * 2) return 0; + const grams = {}; + for (let i = 0; i <= toks.length - n; i++) { + const key = toks.slice(i, i + n).join('\u0001'); + grams[key] = (grams[key] || 0) + 1; + } + let repeats = 0, total = 0; + for (const k in grams) { total += grams[k]; if (grams[k] > 1) repeats += grams[k] - 1; } + return repeats / Math.max(1, total); + } + + // Language-likeness + function vowelStats(s) { + const letters = (s.match(/[a-z]/gi) || []).length; + const vowels = (s.match(/[aeiou]/gi) || []).length; + const consonantRuns = (s.match(/[^aeiou\W]{2,}/gi) || []).map(x => x.length); + const maxRun = consonantRuns.length ? Math.max(...consonantRuns) : 0; + return { + vowelRatio: vowels / Math.max(1, letters), + maxConsonantRun: maxRun + }; + } + function suspiciousWordRatio(toks) { + // Words with no vowels or weird shape (e.g., "qwrty", "aaaaa", "zzzz", "x1x1x1") + let bad = 0; + for (const t of toks) { + const letters = t.replace(/[^a-z]/g, ''); + const vowels = letters.replace(/[^aeiou]/g, ''); + const consonants = letters.length - vowels.length; + const hasVowel = vowels.length > 0; + const longNoVowel = !hasVowel && letters.length >= 4; + const manyDigits = /[0-9].*[0-9].*[0-9]/.test(t); + const tripleRepeat = /(.)\1\1/.test(t); + if (longNoVowel || manyDigits || tripleRepeat) bad++; + } + return bad / Math.max(1, toks.length); + } + function camelCapsRatio(s) { + const tokens = s.split(/\s+/); + let capsOrCamel = 0; + for (const w of tokens) { + if (/^[A-Z]{3,}$/.test(w) || /^[A-Z][a-z]+[A-Z][a-z]+/.test(w)) capsOrCamel++; + } + return capsOrCamel / Math.max(1, tokens.length); + } + + // Sentence duplication + function sentenceNearDuplicationRatio(text) { + const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0); + if (sentences.length < 3) return 0; + function sig(s) { + return s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean).slice(0, 8).join(' '); + } + const map = {}; + for (const s of sentences) { + const k = sig(s); + map[k] = (map[k] || 0) + 1; + } + let dup = 0, total = sentences.length; + for (const k in map) { if (map[k] > 1) dup += map[k] - 1; } + return dup / total; + } + + // Keyboard walks + function countKeyboardWalks(text) { + const kb = [ + 'qwertyuiop', 'asdfghjkl', 'zxcvbnm', + '1234567890', '0987654321', + 'poiuytrewq', 'lkjhgfdsa', 'mnbvcxz' + ]; + let walks = 0; + const lower = text.toLowerCase(); + for (const row of kb) { + const re = new RegExp(row, 'g'); + const matches = lower.match(re); + walks += matches ? matches.length : 0; + } + return walks; + } + + // Dictionary-ish support + function stopwordStats(toks, common, stopwords) { + let sw = 0, cw = 0; + for (const t of toks) { + if (stopwords.has(t)) sw++; + if (common.has(t)) cw++; + } + return { + stopRatio: sw / Math.max(1, toks.length), + commonRatio: cw / Math.max(1, toks.length) + }; + } + + // Core dictionary scoring for intent + function scoreDictionaries(tokens) { + let pro = 0, anti = 0; + const findings = []; + + for (const [w, wt] of Object.entries(config.leisureWords)) { + if (tokens.includes(w)) { anti += wt; findings.push({ type: 'leisure', term: w, delta: -wt }); } + } + for (const [w, wt] of Object.entries(config.vagueFillers)) { + if (tokens.includes(w)) { anti += wt; findings.push({ type: 'vague', term: w, delta: -wt }); } + } + for (const [w, wt] of Object.entries(config.productivityWords)) { + if (tokens.includes(w)) { pro += wt; findings.push({ type: 'prod', term: w, delta: wt }); } + } + + if (/\b(not|no)\s+(urgent|important|critical)\b/i.test(tokens.join(' '))) { + anti += 1.3; findings.push({ type: 'negated_urgency', delta: -1.3 }); + } + return { pro, anti, findings }; + } + + // Structure expectations + function evaluateStructure(text) { + const f = []; + let s = 0; + const { purposeRe, outcomeRe, timeboxRe, maxMinutes } = config.structure; + + const hasPurpose = purposeRe.test(text); + const hasOutcome = outcomeRe.test(text); + const timeMatch = text.match(timeboxRe); + let minutes = null; + + if (hasPurpose) { s += 1.1; f.push({ type: 'purpose', delta: +1.1 }); } + if (hasOutcome) { s += 1.1; f.push({ type: 'outcome', delta: +1.1 }); } + + if (timeMatch) { + minutes = parseInt(timeMatch[2], 10); + if (!Number.isNaN(minutes)) { + if (minutes > 0 && minutes <= maxMinutes) { + s += 1.6; f.push({ type: 'timebox', detail: `${minutes} min`, delta: +1.6 }); + } else if (minutes <= 20) { + s += 0.6; f.push({ type: 'soft_timebox', detail: `${minutes} min`, delta: +0.6 }); + } else { + f.push({ type: 'over_timebox', detail: `${minutes} min`, delta: 0 }); + } + } + } + + if (/\b(might|maybe|if i feel|if i can|when i feel)\b/i.test(text)) { + s -= 0.9; f.push({ type: 'hedge', delta: -0.9 }); + } + + return { score: s, minutes, findings: f, hasPurpose, hasOutcome, hasTightTimebox: minutes !== null && minutes <= maxMinutes }; + } + + // Platform penalties and exceptions + function platformSignals(textLower) { + const platforms = []; + for (const d of config.distractionSites) if (textLower.includes(d)) platforms.push(d); + let penalty = 0; + const pfFindings = []; + if (platforms.length) { + const base = 1.4 + 0.35 * Math.min(4, platforms.length - 1); + penalty += base; + pfFindings.push({ type: 'platform', detail: platforms.join(', '), delta: -base }); + } + let boost = 0; + const exFindings = []; + for (const ex of config.allowedExceptions) { + const m = textLower.match(ex.phrase); + if (m) { boost += ex.weight; exFindings.push({ type: 'exception', detail: m[0], delta: +ex.weight }); } + } + return { platforms, penalty, boost, findings: [...pfFindings, ...exFindings] }; + } + + // MILSPEC anti-gibberish ensemble + function evaluateGibberish(text, toks) { + const g = config.gibberish; + const findings = []; + let pen = 0; + + // 1) Character-level stats + const Hc = charEntropy(text); + if (Hc < g.minEntropyPerChar) { pen += 1.2; findings.push({ type: 'low_char_entropy', value: Hc.toFixed(2), delta: -1.2 }); } + + const Hd = charDiversity(text); + if (Hd < g.minCharDiversity) { pen += 1.1; findings.push({ type: 'low_char_diversity', value: Hd.toFixed(2), delta: -1.1 }); } + + const Hb = bigramEntropy(text); + if (Hb < g.minBigramEntropy) { pen += 1.0; findings.push({ type: 'low_bigram_entropy', value: Hb.toFixed(2), delta: -1.0 }); } + + // 2) Token repetition + const topShare = mostFrequentTokenShare(toks); + if (topShare > g.maxTopTokenShare) { pen += 0.9; findings.push({ type: 'dominant_token', value: topShare.toFixed(2), delta: -0.9 }); } + + const repWordRun = repeatedWordRun(toks); + if (repWordRun > g.maxRepeatedWordRun) { pen += 0.9; findings.push({ type: 'word_run', value: repWordRun, delta: -0.9 }); } + + const repCharRun = /(.)\1{6,}/.test(text); + if (repCharRun) { pen += 0.9; findings.push({ type: 'char_run', delta: -0.9 }); } + + // 3) Periodic/alternation patterns + const cycScore = periodicPatternScore(text, 8); + if (cycScore > g.maxPeriodicPatternScore) { pen += 1.2; findings.push({ type: 'periodic_pattern', value: cycScore.toFixed(2), delta: -1.2 }); } + + const altScore = alternationScore(toks, 6); + if (altScore > g.maxAlternationScore) { pen += 1.2; findings.push({ type: 'alternation_pattern', value: altScore.toFixed(2), delta: -1.2 }); } + + const loop3 = ngramLoopScore(toks, 3); + if (loop3 > g.maxNgramLoopScore) { pen += 0.9; findings.push({ type: 'ngram_loopiness', value: loop3.toFixed(2), delta: -0.9 }); } + + // 4) Language-likeness + const vstats = vowelStats(text); + if (vstats.vowelRatio < g.minVowelRatio) { pen += 0.9; findings.push({ type: 'low_vowel_ratio', value: vstats.vowelRatio.toFixed(2), delta: -0.9 }); } + if (vstats.maxConsonantRun > g.maxConsecutiveConsonants) { pen += 0.8; findings.push({ type: 'consonant_run', value: vstats.maxConsonantRun, delta: -0.8 }); } + + // Suspicious word shapes + const suspRatio = suspiciousWordRatio(toks); + if (suspRatio > g.maxSuspiciousWordRatio) { pen += 0.9; findings.push({ type: 'suspicious_word_ratio', value: suspRatio.toFixed(2), delta: -0.9 }); } + + // Caps/Camel weirdness + const caml = camelCapsRatio(text); + if (caml > g.maxCamelOrAllcapsRatio) { pen += 0.6; findings.push({ type: 'camel_allcaps_ratio', value: caml.toFixed(2), delta: -0.6 }); } + + // Natural language distribution + const stopwords = new Set([ + 'the', 'is', 'in', 'at', 'of', 'on', 'and', 'a', 'to', 'for', 'that', 'it', 'as', 'with', 'this', 'by', 'from', 'or', 'an', 'be', 'are', 'was', 'were', 'but', 'if', 'then', 'so', 'than', 'not', 'no', 'do', 'does', 'did', 'have', 'has', 'had', 'my', 'your', 'our', 'their', 'we', 'you', 'i' + ]); + const sw = stopwordStats(toks, config.commonWords, stopwords); + if (sw.stopRatio < g.minStopwordRatio) { pen += 0.7; findings.push({ type: 'low_stopword_ratio', value: sw.stopRatio.toFixed(2), delta: -0.7 }); } + + // Symbols/digits overload + const nonAlphaRatio = (text.replace(/[A-Za-z]/g, '').length) / Math.max(1, text.length); + if (nonAlphaRatio > g.maxNonAlphaRatio) { pen += 0.7; findings.push({ type: 'nonalpha_heavy', value: nonAlphaRatio.toFixed(2), delta: -0.7 }); } + + // Sentence padding/duplication + const dupRatio = sentenceNearDuplicationRatio(text); + if (dupRatio > g.maxSentenceDuplication) { pen += 0.8; findings.push({ type: 'sentence_padding', value: dupRatio.toFixed(2), delta: -0.8 }); } + + // Keyboard walks + const kbWalks = countKeyboardWalks(text); + if (kbWalks > g.maxKeyboardWalks) { pen += 0.8; findings.push({ type: 'keyboard_walks', value: kbWalks, delta: -0.8 }); } + + // Classic keyboard mashes + if (g.keyboardRunsRE.test(text)) { pen += 0.9; findings.push({ type: 'keyboard_mash', delta: -0.9 }); } + + // ABAB/AaAa-style alternating letters explicitly + if (/^([A-Za-z]{1,3})(\1){6,}$/.test(text.replace(/\s+/g, ''))) { + pen += 1.5; findings.push({ type: 'explicit_alternation_block', delta: -1.5 }); + } + + return { penalty: pen, findings }; + } + + // Scoring phases + const dict = scoreDictionaries(tokens); + const structure = evaluateStructure(reasonStr); + const { platforms, penalty: platformPenalty, boost: exceptionBoost, findings: platformFindings } = platformSignals(lower); + const gib = evaluateGibberish(reasonStr, tokens); + + // Handwave penalty if they sprinkle “research/tutorial/notes” with no structure + let handwavePenalty = 0; + if ((dict.pro > 1.0) && (!structure.hasPurpose || !structure.hasOutcome || !structure.hasTightTimebox)) { + handwavePenalty += 1.2; + } + + // “Break” beyond 5 minutes is rejected harder + if (/\b(break|take a break|mental break)\b/i.test(reasonStr)) { + if (!structure.minutes || structure.minutes > 5) handwavePenalty += 1.4; + } + + // Combine + let pro = 0, anti = 0; + const findings = []; + + pro += structure.score; findings.push(...structure.findings); + pro += exceptionBoost; if (exceptionBoost) findings.push({ type: 'exception_boost_total', delta: +exceptionBoost }); + pro += dict.pro; findings.push(...dict.findings.filter(x => x.type === 'prod')); + + anti += dict.anti; findings.push(...dict.findings.filter(x => x.type !== 'prod')); + anti += platformPenalty; findings.push(...platformFindings); + anti += gib.penalty; findings.push(...gib.findings); + anti += handwavePenalty; if (handwavePenalty) findings.push({ type: 'handwave_penalty', delta: -handwavePenalty }); + + let net = pro - anti; + net = Math.max(-config.maxPenaltyClamp, Math.min(config.maxPenaltyClamp, net)); + + const meetsStructure = structure.hasPurpose && structure.hasOutcome && structure.hasTightTimebox; + + let approved = (net >= config.approveThreshold && meetsStructure) || + (net >= config.softApproveThreshold && meetsStructure && exceptionBoost > 0); + + // Hard kill if text is spammy despite passing other checks + const hardSpam = + /(.)\1{20,}/.test(reasonStr) || + /^[A-Za-z\s]+$/.test(reasonStr) && charEntropy(reasonStr) < 1.9 || + periodicPatternScore(reasonStr, 8) > 0.6 || + alternationScore(tokens, 6) > 0.5; + + if (hardSpam) { + approved = false; + findings.push({ type: 'hard_spam_reject', delta: -3 }); + net = Math.min(net, -4); + } + + return { + approved, + reason: approved ? 'Structured, time-boxed, acceptable override' : 'Insufficient or non-serious justification for distracting sites', + violations: [], + scores: { + pro: Number(pro.toFixed(3)), + anti: Number(anti.toFixed(3)), + net: Number(net.toFixed(3)), + structure: Number(structure.score.toFixed(3)), + spamPenalty: Number(gib.penalty.toFixed(3)), + platformPenalty: Number(platformPenalty.toFixed(3)), + exceptionBoost: Number(exceptionBoost.toFixed(3)) + }, + breakdown: findings, + normalizedReason: reasonStr, + meta: { + platforms, + minutesRequested: structure.minutes ?? null, + requiresPurposeOutcomeTimebox: true, + minChars: config.minChars, + minWords: config.minWords + } + }; } } ); + +// Example quick test (should fail): +// const r = await evaluateReasonWithAI('ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ' + +// 'ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ' + +// 'ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab'); +// console.log(r); + +// Messaging helper for this module +// Keep it outside of class to avoid rebinding in shadow DOM contexts +// but available via this.send using Function.prototype.bind +Object.assign(customElements.get('nirva-sessions').prototype, { + send(action, payload = {}) { + return new Promise((resolve, reject) => { + try { + chrome.runtime.sendMessage({ action, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(response?.data || response); + }); + } catch (err) { + reject(err); + } + }); + } +}); diff --git a/components/dashboard/streak.js b/components/dashboard/streak.js index 2cc284b..c63821e 100644 --- a/components/dashboard/streak.js +++ b/components/dashboard/streak.js @@ -21,7 +21,7 @@ customElements.define( class extends HTMLElement { constructor() { super(); - const shadow = this.attachShadow({ mode: "closed" }); + const shadow = this.attachShadow({ mode: "open" }); shadow.appendChild(template.content.cloneNode(true)); } @@ -32,7 +32,14 @@ customElements.define( updateStreakData() { // Method to update streak data dynamically const streakValue = this.shadowRoot.querySelector('.streak-value'); - // You can fetch and update the streak value here + + // Check if element exists before updating + if (streakValue) { + // You can fetch and update the streak value here + // For now, leaving as is + } else { + console.warn('[nirva-streak] Streak value element not found'); + } } } ); diff --git a/components/dashboard/study-session.js b/components/dashboard/study-session.js index 92c1f7a..181aecf 100644 --- a/components/dashboard/study-session.js +++ b/components/dashboard/study-session.js @@ -2,25 +2,125 @@ import { loadTimerSettings } from "../storage/settings-storage.js"; const template = document.createElement("template"); template.innerHTML = ` - - -
-

Study Session

-

FOCUS MODE ∙ POMODORO 1 OF 4

+ + + + +
+

+ Study Session + +

+

No active session

+
-

25:00

+ + + + + +

--:--

+
- - + +
+
+` + +class GatePreviewModal extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelector('[data-action="pass"]').addEventListener('click', () => this.pass()) + this.shadowRoot.querySelector('[data-action="fail"]').addEventListener('click', () => this.fail()) + this.renderBody() + } + renderBody() { + const body = this.shadowRoot.querySelector('[data-body]') + if (this.getAttribute('type') === 'mental_math') { + const problems = ChallengeBank.mental_math.generateProblems(this.config) + body.textContent = problems.map(p => `${p.a}${p.op}${p.b}=`).join(' ') + } else if (this.getAttribute('type') === 'typing_test') { + body.textContent = ChallengeBank.typing_test.getSample() + } else if (this.getAttribute('type') === 'intent_prompt') { + const inp = document.createElement('textarea') + body.appendChild(inp) + } else { + body.textContent = this.getAttribute('type') + } + } + pass() { + this.dispatchEvent(new CustomEvent('pass', { detail: {} })) + } + fail() { + this.dispatchEvent(new CustomEvent('fail', { detail: {} })) + } +} + +customElements.define('gate-preview-modal', GatePreviewModal) diff --git a/components/interventions/gate-renderer.js b/components/interventions/gate-renderer.js new file mode 100644 index 0000000..1b08ef7 --- /dev/null +++ b/components/interventions/gate-renderer.js @@ -0,0 +1,20 @@ +export function renderGate(type, config, item) { + if (typeof document === 'undefined') { + return Promise.resolve({ passed: true, meta: {} }) + } + return new Promise((resolve) => { + const modal = document.createElement('gate-preview-modal') + modal.setAttribute('type', type) + modal.config = config + modal.item = item + modal.addEventListener('pass', (e) => { + modal.remove() + resolve({ passed: true, meta: e.detail }) + }) + modal.addEventListener('fail', (e) => { + modal.remove() + resolve({ passed: false, meta: e.detail }) + }) + document.body.appendChild(modal) + }) +} diff --git a/components/interventions/intervention-analytics.js b/components/interventions/intervention-analytics.js index 20b5ea4..923fe3b 100644 --- a/components/interventions/intervention-analytics.js +++ b/components/interventions/intervention-analytics.js @@ -2,7 +2,7 @@ * Displays basic analytics for a selected intervention. * Stats are read from chrome sync storage and shown in a simple card. */ -import { loadInterventions } from '../storage/intervention-storage.js'; +import { loadInterventions } from './intervention-storage.js'; const template = document.createElement('template'); template.innerHTML = ` @@ -37,7 +37,8 @@ customElements.define('nirva-intervention-analytics', class extends HTMLElement this.update(); return; } - const list = await loadInterventions(); + const state = await loadInterventions(); + const list = state.items || []; const intr = list.find((i) => i.id === id); this.update(intr ? intr.stats : undefined); } diff --git a/components/interventions/intervention-editor.js b/components/interventions/intervention-editor.js index 8ff8fdf..0cffd7a 100644 --- a/components/interventions/intervention-editor.js +++ b/components/interventions/intervention-editor.js @@ -3,7 +3,7 @@ * Dispatches `intervention-saved` and `intervention-deleted` events * when actions complete. */ -import { loadInterventions, saveIntervention, deleteIntervention, recordInterventionRun } from '../storage/intervention-storage.js'; +import { loadInterventions, saveIntervention, deleteIntervention, recordInterventionRun } from './intervention-storage.js'; import { showConfirm } from '../confirm-dialog.js'; const template = document.createElement('template'); @@ -102,7 +102,8 @@ customElements.define('nirva-intervention-editor', class extends HTMLElement { } async load(id) { - const list = await loadInterventions(); + const state = await loadInterventions(); + const list = state.items || []; const intr = list.find(i => i.id === id); if (!intr) return; this.currentId = id; diff --git a/components/interventions/intervention-engine.js b/components/interventions/intervention-engine.js new file mode 100644 index 0000000..428a5ea --- /dev/null +++ b/components/interventions/intervention-engine.js @@ -0,0 +1,40 @@ +import { loadInterventions, updateTelemetry, emitEvent } from './intervention-storage.js' +import { INTERVENTION_REGISTRY } from './intervention-registry.js' + +function matchSchedule(item, now) { + if (!item.schedule || !item.schedule.length) return true + const date = new Date(now) + const day = date.getDay() + const time = date.toTimeString().slice(0,5) + return item.schedule.some(s => s.days.includes(day) && s.start <= time && time <= s.end) +} + +function pickByContext(ctx, items) { + const url_host = new URL(ctx.url).hostname + let match = items.find(i => i.scopes.sites && i.scopes.sites.includes(url_host)) + if (!match && ctx.block_set) match = items.find(i => i.scopes.block_sets && i.scopes.block_sets.includes(ctx.block_set)) + if (!match) match = items.find(i => i.scopes.global) + return match +} + +export async function selectInterventionByContext(ctx) { + const state = await loadInterventions() + const item = pickByContext(ctx, state.items) + if (!item) return { decision: 'allow' } + if (!matchSchedule(item, ctx.now)) return { decision: 'allow' } + const type = INTERVENTION_REGISTRY[item.type] + if (!type || !type.canApply(ctx, item)) return { decision: 'allow' } + emitEvent('intervention_shown', { id: item.id, type: item.type }) + return type.resolve(ctx, item) +} + +export async function renderGateForDecision(decision) { + if (decision.decision !== 'gate') return { passed: true, meta: {} } + const start = Date.now() + const type = INTERVENTION_REGISTRY[decision.item.type] + const res = await type.renderGate(decision.item.config, decision.item) + const ms = Date.now() - start + await updateTelemetry(decision.item.id, res.passed, ms) + emitEvent(res.passed ? 'intervention_pass' : 'intervention_fail', { id: decision.item.id, type: decision.item.type }) + return res +} diff --git a/components/interventions/intervention-list.js b/components/interventions/intervention-list.js index 6b96f4b..efffca2 100644 --- a/components/interventions/intervention-list.js +++ b/components/interventions/intervention-list.js @@ -2,7 +2,7 @@ * List component displaying all available interventions. * Emits `intervention-selected` when a user chooses an item. */ -import { loadInterventions, saveIntervention, deleteIntervention, saveInterventions } from '../storage/intervention-storage.js'; +import { loadInterventions, saveIntervention, deleteIntervention, saveInterventions } from './intervention-storage.js'; import { showConfirm } from '../confirm-dialog.js'; const template = document.createElement('template'); @@ -64,7 +64,8 @@ customElements.define('nirva-intervention-list', class extends HTMLElement { } async load() { - this.interventions = await loadInterventions(); + const state = await loadInterventions(); + this.interventions = state.items || []; if (!this.currentId && this.interventions.length) { this.currentId = this.interventions[0].id; } @@ -76,6 +77,10 @@ customElements.define('nirva-intervention-list', class extends HTMLElement { render() { this.listEl.innerHTML = ''; + if (!Array.isArray(this.interventions)) { + console.warn('[intervention-list] interventions is not an array:', this.interventions); + this.interventions = []; + } this.interventions.forEach((intr) => { const li = document.createElement('li'); li.className = 'intervention-item'; @@ -165,7 +170,14 @@ customElements.define('nirva-intervention-list', class extends HTMLElement { if (draggedIdx < 0 || targetIdx < 0) return; const [moved] = this.interventions.splice(draggedIdx, 1); this.interventions.splice(targetIdx, 0, moved); - await saveInterventions(this.interventions); + + // Create state object for saveInterventions + const state = { + registry_version: 1, + items: this.interventions, + active_id: this.currentId + }; + await saveInterventions(state); this.render(); } }); diff --git a/components/interventions/intervention-registry.js b/components/interventions/intervention-registry.js new file mode 100644 index 0000000..0189e65 --- /dev/null +++ b/components/interventions/intervention-registry.js @@ -0,0 +1,125 @@ +import { renderGate } from './gate-renderer.js' + +export const INTERVENTION_REGISTRY = { + mental_math: { + defaults: { + operations: { add: true, sub: false, mul: false, div: false }, + digits: 2, + count: 3, + limit_ms: 0, + pass_threshold: 1, + tolerance: 0, + show_steps: false, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('mental_math', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + delay_gate: { + defaults: { + base_ms: 1000, + mode: 'none', + max_ms: 10000, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('delay_gate', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + typing_test: { + defaults: { + wpm: 40, + errors: 5, + source: 'random', + length: 20, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('typing_test', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + intent_prompt: { + defaults: { + prompt: '', + min_chars: 20, + cooldown_ms: 0, + require_reason: false, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('intent_prompt', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + quota: { + defaults: { + minutes: 60, + visits: 5, + hard: false, + reset: '00:00', + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('quota', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + rate_limit: { + defaults: { + minutes: 60, + visits: 5, + hard: false, + reset: '00:00', + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('rate_limit', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + redirect: { + defaults: { + url: '', + same_tab: false, + reading_mode: false, + escalation: null + }, + canApply: () => true, + renderGate: null, + resolve: (ctx, item) => ({ decision: 'redirect', url: item.config.url, item }) + }, + timebox: { + defaults: { + minutes: 15, + penalty: 'none', + daily: 60, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('timebox', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + whitelist: { + defaults: { + paths: '', + block_others: false, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('whitelist', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + }, + zen: { + defaults: { + duration: 60, + animation: 'none', + breath_hold: false, + escalation: null + }, + canApply: () => true, + renderGate: (config, item) => renderGate('zen', config, item), + resolve: (ctx, item) => ({ decision: 'gate', item }) + } +} + +export function getDefaults(type) { + return JSON.parse(JSON.stringify(INTERVENTION_REGISTRY[type].defaults)) +} diff --git a/components/interventions/intervention-storage.js b/components/interventions/intervention-storage.js new file mode 100644 index 0000000..2bb295a --- /dev/null +++ b/components/interventions/intervention-storage.js @@ -0,0 +1,418 @@ +import { settingsManager } from '../storage/settings-manager.js' +import { INTERVENTION_REGISTRY, getDefaults } from './intervention-registry.js' +import { INTERVENTIONS_KEY } from '../storage/keys.js' +import { load, update } from '../storage/storage-manager.js' + +const STORAGE_KEY = INTERVENTIONS_KEY; + +const DEFAULT_INTERVENTION_DEFS = [ + { + id: 'delay-quick', + name: 'Quick Pause', + type: 'delay', + message: 'Take a deep breath and refocus.', + config: { + duration: 10, + resetOnTabSwitch: true, + showCountdown: true, + allowSkip: false, + prompt: 'Breathe in and out...' + } + }, + { + id: 'delay-deep', + name: 'Deep Reflection', + type: 'delay', + message: 'Why are you visiting this site?', + config: { + duration: 60, + resetOnTabSwitch: true, + showCountdown: true, + allowSkip: false, + prompt: 'Think about your goals.' + } + }, + { + id: 'password-simple', + name: 'Focus Password', + type: 'password', + message: 'Type the secret word to proceed.', + config: { + password: 'focus', + hint: 'It starts with f', + attempts: 3, + caseSensitive: false, + lockout: 0 + } + }, + { + id: 'password-strict', + name: 'Strict Gate', + type: 'password', + message: 'Only disciplined users pass.', + config: { + password: 'StudyHard123', + hint: '', + attempts: 2, + caseSensitive: true, + lockout: 5 + } + }, + { + id: 'math-basic', + name: 'Basic Math Drill', + type: 'math', + message: 'Solve a few problems.', + config: { + digits: 2, + operators: ['+', '-', '*'], + timeLimit: 30, + problemCount: 3 + } + }, + { + id: 'math-advanced', + name: 'Advanced Math Drill', + type: 'math', + message: 'Challenge your brain before proceeding.', + config: { + digits: 3, + operators: ['+', '-', '*', '/'], + timeLimit: 45, + problemCount: 5 + } + }, + { + id: 'flashcards-vocab', + name: 'Vocabulary Review', + type: 'flashcards', + message: 'Review some words.', + config: { + deck: 'Vocabulary', + count: 10, + timeLimit: 60, + shuffle: true + } + }, + { + id: 'flashcards-history', + name: 'History Facts', + type: 'flashcards', + message: 'Recall history facts.', + config: { + deck: 'History', + count: 5, + timeLimit: 90, + shuffle: false + } + }, + { + id: 'topsoj-easy', + name: 'TopsOJ Warmup', + type: 'topsoj', + message: 'Solve an easy problem.', + config: { + difficulty: 'easy', + tags: '', + timeLimit: 30 + } + }, + { + id: 'topsoj-grind', + name: 'Algorithm Grind', + type: 'topsoj', + message: 'Face a challenging problem!', + config: { + difficulty: 'hard', + tags: 'dp', + timeLimit: 60 + } + }, + { + id: 'coding-js', + name: 'JS Kata', + type: 'coding', + message: 'Complete the snippet.', + config: { + language: 'javascript', + snippet: '// finish the function\nfunction add(a, b) {\n \n}', + tests: 1 + } + }, + { + id: 'coding-py', + name: 'Python Practice', + type: 'coding', + message: 'Fill in the code.', + config: { + language: 'python', + snippet: '# write a function\ndef greet(name):\n pass', + tests: 2 + } + } +] + +const DEFAULT_STATE = { registry_version: 1, items: [], active_id: null } + +export const INTERVENTION_SCHEMA = { + id: 'string', + name: 'string', + type: 'string', + message: 'string', + config: 'object', + common: 'object', + scopes: 'object', + schedule: 'object', + telemetry: 'object', + stats: 'object', + created_at: 'number', + updated_at: 'number' +} + +export function validateItem(item) { + const errors = [] + for (const [key, type] of Object.entries(INTERVENTION_SCHEMA)) { + if (typeof item[key] !== type) errors.push(key) + } + if (!INTERVENTION_REGISTRY[item.type]) errors.push('type') + return errors +} + +function newId() { + return Math.random().toString(36).slice(2) +} + +export async function initInterventions() { + const state = await load(STORAGE_KEY) + if (!state) { + await update(STORAGE_KEY, { + ...DEFAULT_STATE, + items: buildDefaultItems() + }) + } +} + +export async function loadInterventions() { + try { + const state = await load(STORAGE_KEY) + console.debug('[intervention-storage] Raw loaded state:', state); + + // Handle different possible data formats + if (state === null || state === undefined) { + console.debug('[intervention-storage] No existing data, initializing with defaults'); + const defaultState = { ...DEFAULT_STATE }; + await saveInterventions(defaultState); + return defaultState; + } + + // If we get an array (legacy format), convert it + if (Array.isArray(state)) { + console.debug('[intervention-storage] Converting legacy array format to new state format'); + const newState = { + registry_version: 1, + items: state, + active_id: state.length > 0 ? state[0].id : null + }; + await saveInterventions(newState); + return newState; + } + + // Validate that the loaded state has the correct structure + if (state && typeof state === 'object' && Array.isArray(state.items)) { + console.debug('[intervention-storage] Valid state loaded with', state.items.length, 'interventions'); + return state; + } + + // If state is invalid, return default state + console.warn('[intervention-storage] Loaded invalid interventions state, using defaults. State was:', state); + const defaultState = { ...DEFAULT_STATE }; + await saveInterventions(defaultState); + return defaultState; + } catch (error) { + console.error('[intervention-storage] Error loading interventions:', error); + const defaultState = { ...DEFAULT_STATE }; + await saveInterventions(defaultState); + return defaultState; + } +} + +export async function saveInterventions(state) { + await update(STORAGE_KEY, state) +} + +export function createIntervention(type, name) { + const now = Date.now() + return { + id: newId(), + name: name || 'New Intervention', + type, + message: '', + config: getDefaults(type), + common: { strictness: 'standard', cooldown_ms: 0, retry_limit: 0, session_sensitive: false, a11y_mode: false }, + scopes: { global: true, block_sets: [], sites: [] }, + schedule: [], + telemetry: { attempts: 0, passes: 0, avg_ms_to_pass: 0 }, + stats: { runs: 0, last_run: null, total_delay_seconds: 0 }, + created_at: now, + updated_at: now + } +} + +function buildDefaultItems() { + return DEFAULT_INTERVENTION_DEFS.map(def => { + const item = createIntervention(def.type, def.name) + item.id = def.id + item.message = def.message + item.config = def.config + return item + }) +} + +export async function addIntervention(type, name) { + const state = await loadInterventions() + if (!state || !Array.isArray(state.items)) { + console.warn('[intervention-storage] Invalid state for addIntervention'); + const newState = { ...DEFAULT_STATE }; + const item = createIntervention(type, name); + newState.items.push(item); + newState.active_id = item.id; + await saveInterventions(newState); + return item; + } + const item = createIntervention(type, name) + state.items.push(item) + state.active_id = item.id + await saveInterventions(state) + return item +} + +export async function saveIntervention(intervention) { + const state = await loadInterventions() + + // Ensure state has the correct structure + if (!state || !Array.isArray(state.items)) { + console.warn('[intervention-storage] Invalid state loaded, using default structure'); + const newState = { ...DEFAULT_STATE }; + newState.items.push(intervention); + newState.active_id = intervention.id; + await saveInterventions(newState); + return; + } + + const idx = state.items.findIndex(i => i.id === intervention.id) + if (idx >= 0) state.items[idx] = intervention + else state.items.push(intervention) + state.active_id = intervention.id + await saveInterventions(state) +} + +export async function updateIntervention(id, patch) { + const state = await loadInterventions() + if (!state || !Array.isArray(state.items)) { + console.warn('[intervention-storage] Invalid state for updateIntervention'); + return null; + } + const idx = state.items.findIndex(i => i.id === id) + if (idx < 0) return null + Object.assign(state.items[idx], patch) + state.items[idx].updated_at = Date.now() + await saveInterventions(state) + return state.items[idx] +} + +export async function deleteIntervention(id) { + const state = await loadInterventions() + if (!state || !Array.isArray(state.items)) { + console.warn('[intervention-storage] Invalid state for deleteIntervention'); + return; + } + const idx = state.items.findIndex(i => i.id === id) + if (idx < 0) return + state.items.splice(idx, 1) + if (state.active_id === id) state.active_id = state.items[0] ? state.items[0].id : null + await saveInterventions(state) +} + +export async function duplicateIntervention(id) { + const state = await loadInterventions() + const orig = state.items.find(i => i.id === id) + if (!orig) return null + const copy = JSON.parse(JSON.stringify(orig)) + copy.id = newId() + copy.name = orig.name + ' Copy' + const now = Date.now() + copy.created_at = now + copy.updated_at = now + state.items.push(copy) + state.active_id = copy.id + await saveInterventions(state) + return copy +} + +export async function recordInterventionRun(id, durationSeconds = 0) { + const state = await loadInterventions() + const idx = state.items.findIndex(i => i.id === id) + if (idx < 0) return null + const stats = state.items[idx].stats || { runs: 0, last_run: null, total_delay_seconds: 0 } + stats.runs += 1 + stats.last_run = new Date().toISOString() + stats.total_delay_seconds += durationSeconds + state.items[idx].stats = stats + state.items[idx].updated_at = Date.now() + await saveInterventions(state) + return stats +} + +export async function exportInterventionById(id) { + const state = await loadInterventions() + const item = state.items.find(i => i.id === id) + return item ? JSON.stringify(item) : '' +} + +export async function exportAllInterventions() { + const state = await loadInterventions() + return JSON.stringify(state.items) +} + +export async function importIntervention(json, opts = {}) { + const item = JSON.parse(json) + const errs = validateItem(item) + if (errs.length) throw new Error('invalid') + const state = await loadInterventions() + if (opts.merge) { + const idx = state.items.findIndex(i => i.id === item.id) + if (idx >= 0) state.items[idx] = item + else state.items.push(item) + } else { + state.items.push(item) + } + await saveInterventions(state) + return item +} + +export async function importAllInterventions(json, opts = {}) { + const arr = JSON.parse(json) + const state = opts.merge ? await loadInterventions() : { ...DEFAULT_STATE } + for (const item of arr) { + if (!validateItem(item).length) state.items.push(item) + } + await saveInterventions(state) +} + +export function emitEvent(name, payload) { + if (typeof document !== 'undefined') document.dispatchEvent(new CustomEvent(name, { detail: payload })) +} + +export async function updateTelemetry(id, passed, ms) { + const state = await loadInterventions() + const item = state.items.find(i => i.id === id) + if (!item) return + item.telemetry.attempts++ + if (passed) { + item.telemetry.passes++ + const total = item.telemetry.avg_ms_to_pass * (item.telemetry.passes - 1) + ms + item.telemetry.avg_ms_to_pass = Math.round(total / item.telemetry.passes) + } + item.updated_at = Date.now() + await saveInterventions(state) +} diff --git a/components/interventions/interventions-editor.js b/components/interventions/interventions-editor.js new file mode 100644 index 0000000..811d058 --- /dev/null +++ b/components/interventions/interventions-editor.js @@ -0,0 +1,126 @@ +import { INTERVENTION_REGISTRY, getDefaults } from './intervention-registry.js' +import { validateItem } from './intervention-storage.js' + +const template = document.createElement('template') +template.innerHTML = ` +
+ + + +
+
+ + + + + +
+ + + +
+
+ + + +
+
+` + +class InterventionsEditor extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + this.dirty = false + } + connectedCallback() { + this.type_select = this.shadowRoot.querySelector('[data-field="type"]') + Object.keys(INTERVENTION_REGISTRY).forEach(k => { + const opt = document.createElement('option') + opt.value = k + opt.textContent = k + this.type_select.appendChild(opt) + }) + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => { + el.addEventListener('input', () => this.onField(el)) + }) + this.shadowRoot.querySelector('[data-action="save"]').addEventListener('click', () => this.emit('save')) + this.shadowRoot.querySelector('[data-action="cancel"]').addEventListener('click', () => this.emit('cancel')) + this.shadowRoot.querySelector('[data-action="test"]').addEventListener('click', () => this.emit('test')) + } + set model(item) { + this._model = JSON.parse(JSON.stringify(item)) + this.populate() + } + get model() { + return this._model + } + populate() { + if (!this._model) return + const root = this.shadowRoot + root.querySelector('[data-field="name"]').value = this._model.name + root.querySelector('[data-field="type"]').value = this._model.type + root.querySelector('[data-field="message"]').value = this._model.message + root.querySelector('[data-field="strictness"]').value = this._model.common.strictness + root.querySelector('[data-field="cooldown_ms"]').value = this._model.common.cooldown_ms + root.querySelector('[data-field="retry_limit"]').value = this._model.common.retry_limit + root.querySelector('[data-field="session_sensitive"]').checked = this._model.common.session_sensitive + root.querySelector('[data-field="a11y_mode"]').checked = this._model.common.a11y_mode + const panel_wrap = root.querySelector('[data-panel]') + panel_wrap.innerHTML = '' + const panel = document.createElement(`panel-${this._model.type.replace('_','-')}`) + panel.value = this._model.config + panel.addEventListener('change', e => { + this._model.config = e.detail + this.markDirty() + }) + panel_wrap.appendChild(panel) + const scopes = root.querySelector('scope-picker') + scopes.value = this._model.scopes + scopes.addEventListener('change', e => { this._model.scopes = e.detail; this.markDirty() }) + const sched = root.querySelector('schedule-picker') + sched.value = this._model.schedule + sched.addEventListener('change', e => { this._model.schedule = e.detail; this.markDirty() }) + root.querySelector('mini-analytics').value = this._model.telemetry + this.dirty = false + this.validate() + } + onField(el) { + if (!this._model) return + const field = el.getAttribute('data-field') + if (['name','type','message'].includes(field)) this._model[field] = el.type === 'checkbox' ? el.checked : el.value + if (field === 'type') { + this._model.config = getDefaults(this._model.type) + this.populate() + } + if (['strictness','cooldown_ms','retry_limit','session_sensitive','a11y_mode'].includes(field)) { + const c = this._model.common + if (field === 'session_sensitive' || field === 'a11y_mode') c[field] = el.checked + else if (field === 'cooldown_ms' || field === 'retry_limit') c[field] = Number(el.value) + else c[field] = el.value + } + this.markDirty() + } + markDirty() { + this.dirty = true + this.validate() + } + emit(name) { + this.dispatchEvent(new CustomEvent(name, { detail: this._model })) + } + validate() { + if (!this._model) return + const errs = validateItem(this._model) + const box = this.shadowRoot.querySelector('[data-errors]') + box.textContent = errs.length ? errs.join(',') : '' + const save_btn = this.shadowRoot.querySelector('[data-action="save"]') + save_btn.disabled = errs.length > 0 || !this.dirty + } +} + +customElements.define('interventions-editor', InterventionsEditor) diff --git a/components/interventions/interventions-list.js b/components/interventions/interventions-list.js new file mode 100644 index 0000000..8497c51 --- /dev/null +++ b/components/interventions/interventions-list.js @@ -0,0 +1,54 @@ +import { exportInterventionById } from './intervention-storage.js' + +const template = document.createElement('template') +template.innerHTML = ` +
+
+

Interventions

+ +
+ +
    +
    +` + +class InterventionsList extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelector('[data-action="create"]').addEventListener('click', () => this.emit('create')) + this.shadowRoot.querySelector('[data-action="search"]').addEventListener('input', e => this.emit('search', e.target.value)) + } + set items(v) { + this._items = v || [] + const list = this.shadowRoot.querySelector('[data-list]') + list.innerHTML = '' + this._items.forEach(item => { + const li = document.createElement('li') + li.textContent = item.name + li.dataset.id = item.id + if (item.id === this.active_id) li.setAttribute('data-active', '1') + li.addEventListener('click', () => this.emit('select', item.id)) + li.addEventListener('contextmenu', e => this.openMenu(e, item)) + list.appendChild(li) + }) + } + set activeId(id) { + this.active_id = id + this.items = this._items + } + openMenu(e, item) { + e.preventDefault() + const action = prompt('d=delete,x=export,c=duplicate') + if (action === 'd') this.emit('delete', item.id) + if (action === 'c') this.emit('duplicate', item.id) + if (action === 'x') exportInterventionById(item.id).then(str => this.emit('export', str)) + } + emit(name, detail) { + this.dispatchEvent(new CustomEvent(name, { detail })) + } +} + +customElements.define('interventions-list', InterventionsList) diff --git a/components/interventions/interventions-page.js b/components/interventions/interventions-page.js index e199c57..442d80c 100644 --- a/components/interventions/interventions-page.js +++ b/components/interventions/interventions-page.js @@ -1 +1,71 @@ -import '../pages/interventions-page.js'; +import { loadInterventions, addIntervention, updateIntervention, deleteIntervention, duplicateIntervention } from './intervention-storage.js' +import { renderGateForDecision } from './intervention-engine.js' + +export const INTERVENTIONS_README = `Route '#/interventions' -> 'interventions-page'. Call initInterventions() on startup.` + +const template = document.createElement('template') +template.innerHTML = ` +
    + + +
    +` + +class InterventionsPage extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + async connectedCallback() { + this.list = this.shadowRoot.querySelector('interventions-list') + this.editor = this.shadowRoot.querySelector('interventions-editor') + this.list.addEventListener('select', e => this.select(e.detail)) + this.list.addEventListener('create', () => this.create()) + this.list.addEventListener('delete', e => this.remove(e.detail)) + this.list.addEventListener('duplicate', e => this.duplicate(e.detail)) + this.editor.addEventListener('save', e => this.save(e.detail)) + this.editor.addEventListener('test', e => this.test(e.detail)) + await this.load() + window.addEventListener('keydown', e => this.keys(e)) + } + async load() { + this.state = await loadInterventions() + this.list.items = this.state.items + this.list.activeId = this.state.active_id + const active = this.state.items.find(i => i.id === this.state.active_id) + if (active) this.editor.model = active + } + async select(id) { + this.state.active_id = id + const item = this.state.items.find(i => i.id === id) + this.editor.model = item + } + async create() { + const item = await addIntervention('mental_math') + await this.load() + this.editor.model = item + } + async save(model) { + await updateIntervention(model.id, model) + await this.load() + } + async remove(id) { + await deleteIntervention(id) + await this.load() + } + async duplicate(id) { + await duplicateIntervention(id) + await this.load() + } + async test(model) { + await renderGateForDecision({ decision: 'gate', item: model }) + await this.load() + } + keys(e) { + if (e.key === 'n' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); this.create() } + if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.save(this.editor.model) } + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.shadowRoot.querySelector('interventions-list').shadowRoot.querySelector('[data-action="search"]').focus() } + } +} + +customElements.define('interventions-page', InterventionsPage) diff --git a/components/interventions/mini-analytics.js b/components/interventions/mini-analytics.js new file mode 100644 index 0000000..7ff9e17 --- /dev/null +++ b/components/interventions/mini-analytics.js @@ -0,0 +1,23 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class MiniAnalytics extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + set value(v) { + this._value = v || { attempts: 0, passes: 0, avg_ms_to_pass: 0 } + this.shadowRoot.querySelector('[data-field="attempts"]').textContent = `Attempts: ${this._value.attempts}` + this.shadowRoot.querySelector('[data-field="passes"]').textContent = `Passes: ${this._value.passes}` + this.shadowRoot.querySelector('[data-field="avg"]').textContent = `Avg: ${this._value.avg_ms_to_pass}ms` + } +} + +customElements.define('mini-analytics', MiniAnalytics) diff --git a/components/interventions/panel-delay-gate.js b/components/interventions/panel-delay-gate.js new file mode 100644 index 0000000..53dd443 --- /dev/null +++ b/components/interventions/panel-delay-gate.js @@ -0,0 +1,36 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class PanelDelayGate extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="base_ms"]').value = v.base_ms + r.querySelector('[data-field="mode"]').value = v.mode + r.querySelector('[data-field="max_ms"]').value = v.max_ms + } + onChange() { + const r = this.shadowRoot + this._value = { + base_ms: Number(r.querySelector('[data-field="base_ms"]').value), + mode: r.querySelector('[data-field="mode"]').value, + max_ms: Number(r.querySelector('[data-field="max_ms"]').value) + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-delay-gate', PanelDelayGate) diff --git a/components/interventions/panel-intent-prompt.js b/components/interventions/panel-intent-prompt.js new file mode 100644 index 0000000..3769205 --- /dev/null +++ b/components/interventions/panel-intent-prompt.js @@ -0,0 +1,39 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + + +
    +` + +class PanelIntentPrompt extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="prompt"]').value = v.prompt + r.querySelector('[data-field="min_chars"]').value = v.min_chars + r.querySelector('[data-field="cooldown_ms"]').value = v.cooldown_ms + r.querySelector('[data-field="require_reason"]').checked = v.require_reason + } + onChange() { + const r = this.shadowRoot + this._value = { + prompt: r.querySelector('[data-field="prompt"]').value, + min_chars: Number(r.querySelector('[data-field="min_chars"]').value), + cooldown_ms: Number(r.querySelector('[data-field="cooldown_ms"]').value), + require_reason: r.querySelector('[data-field="require_reason"]').checked + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-intent-prompt', PanelIntentPrompt) diff --git a/components/interventions/panel-mental-math.js b/components/interventions/panel-mental-math.js new file mode 100644 index 0000000..b6d4d46 --- /dev/null +++ b/components/interventions/panel-mental-math.js @@ -0,0 +1,61 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + + + + + + + + +
    +` + +class PanelMentalMath extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => { + el.addEventListener('input', () => this.onChange()) + }) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="add"]').checked = v.operations.add + r.querySelector('[data-field="sub"]').checked = v.operations.sub + r.querySelector('[data-field="mul"]').checked = v.operations.mul + r.querySelector('[data-field="div"]').checked = v.operations.div + r.querySelector('[data-field="digits"]').value = v.digits + r.querySelector('[data-field="count"]').value = v.count + r.querySelector('[data-field="limit_ms"]').value = v.limit_ms + r.querySelector('[data-field="pass_threshold"]').value = v.pass_threshold + r.querySelector('[data-field="tolerance"]').value = v.tolerance + r.querySelector('[data-field="show_steps"]').checked = v.show_steps + } + onChange() { + const r = this.shadowRoot + this._value = { + operations: { + add: r.querySelector('[data-field="add"]').checked, + sub: r.querySelector('[data-field="sub"]').checked, + mul: r.querySelector('[data-field="mul"]').checked, + div: r.querySelector('[data-field="div"]').checked + }, + digits: Number(r.querySelector('[data-field="digits"]').value), + count: Number(r.querySelector('[data-field="count"]').value), + limit_ms: Number(r.querySelector('[data-field="limit_ms"]').value), + pass_threshold: Number(r.querySelector('[data-field="pass_threshold"]').value), + tolerance: Number(r.querySelector('[data-field="tolerance"]').value), + show_steps: r.querySelector('[data-field="show_steps"]').checked + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-mental-math', PanelMentalMath) diff --git a/components/interventions/panel-quota.js b/components/interventions/panel-quota.js new file mode 100644 index 0000000..c431f7f --- /dev/null +++ b/components/interventions/panel-quota.js @@ -0,0 +1,39 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + + +
    +` + +class PanelQuota extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="minutes"]').value = v.minutes + r.querySelector('[data-field="visits"]').value = v.visits + r.querySelector('[data-field="hard"]').checked = v.hard + r.querySelector('[data-field="reset"]').value = v.reset + } + onChange() { + const r = this.shadowRoot + this._value = { + minutes: Number(r.querySelector('[data-field="minutes"]').value), + visits: Number(r.querySelector('[data-field="visits"]').value), + hard: r.querySelector('[data-field="hard"]').checked, + reset: r.querySelector('[data-field="reset"]').value + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-quota', PanelQuota) diff --git a/components/interventions/panel-rate-limit.js b/components/interventions/panel-rate-limit.js new file mode 100644 index 0000000..b205db0 --- /dev/null +++ b/components/interventions/panel-rate-limit.js @@ -0,0 +1,39 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + + +
    +` + +class PanelRateLimit extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="minutes"]').value = v.minutes + r.querySelector('[data-field="visits"]').value = v.visits + r.querySelector('[data-field="hard"]').checked = v.hard + r.querySelector('[data-field="reset"]').value = v.reset + } + onChange() { + const r = this.shadowRoot + this._value = { + minutes: Number(r.querySelector('[data-field="minutes"]').value), + visits: Number(r.querySelector('[data-field="visits"]').value), + hard: r.querySelector('[data-field="hard"]').checked, + reset: r.querySelector('[data-field="reset"]').value + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-rate-limit', PanelRateLimit) diff --git a/components/interventions/panel-redirect.js b/components/interventions/panel-redirect.js new file mode 100644 index 0000000..ef0fd65 --- /dev/null +++ b/components/interventions/panel-redirect.js @@ -0,0 +1,36 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class PanelRedirect extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="url"]').value = v.url + r.querySelector('[data-field="same_tab"]').checked = v.same_tab + r.querySelector('[data-field="reading_mode"]').checked = v.reading_mode + } + onChange() { + const r = this.shadowRoot + this._value = { + url: r.querySelector('[data-field="url"]').value, + same_tab: r.querySelector('[data-field="same_tab"]').checked, + reading_mode: r.querySelector('[data-field="reading_mode"]').checked + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-redirect', PanelRedirect) diff --git a/components/interventions/panel-timebox.js b/components/interventions/panel-timebox.js new file mode 100644 index 0000000..d619453 --- /dev/null +++ b/components/interventions/panel-timebox.js @@ -0,0 +1,36 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class PanelTimebox extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="minutes"]').value = v.minutes + r.querySelector('[data-field="penalty"]').value = v.penalty + r.querySelector('[data-field="daily"]').value = v.daily + } + onChange() { + const r = this.shadowRoot + this._value = { + minutes: Number(r.querySelector('[data-field="minutes"]').value), + penalty: r.querySelector('[data-field="penalty"]').value, + daily: Number(r.querySelector('[data-field="daily"]').value) + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-timebox', PanelTimebox) diff --git a/components/interventions/panel-typing-test.js b/components/interventions/panel-typing-test.js new file mode 100644 index 0000000..2d79fe0 --- /dev/null +++ b/components/interventions/panel-typing-test.js @@ -0,0 +1,39 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + + +
    +` + +class PanelTypingTest extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="wpm"]').value = v.wpm + r.querySelector('[data-field="errors"]').value = v.errors + r.querySelector('[data-field="source"]').value = v.source + r.querySelector('[data-field="length"]').value = v.length + } + onChange() { + const r = this.shadowRoot + this._value = { + wpm: Number(r.querySelector('[data-field="wpm"]').value), + errors: Number(r.querySelector('[data-field="errors"]').value), + source: r.querySelector('[data-field="source"]').value, + length: Number(r.querySelector('[data-field="length"]').value) + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-typing-test', PanelTypingTest) diff --git a/components/interventions/panel-whitelist.js b/components/interventions/panel-whitelist.js new file mode 100644 index 0000000..cb4a9fe --- /dev/null +++ b/components/interventions/panel-whitelist.js @@ -0,0 +1,33 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + +
    +` + +class PanelWhitelist extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="paths"]').value = v.paths + r.querySelector('[data-field="block_others"]').checked = v.block_others + } + onChange() { + const r = this.shadowRoot + this._value = { + paths: r.querySelector('[data-field="paths"]').value, + block_others: r.querySelector('[data-field="block_others"]').checked + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-whitelist', PanelWhitelist) diff --git a/components/interventions/panel-zen.js b/components/interventions/panel-zen.js new file mode 100644 index 0000000..a757c8b --- /dev/null +++ b/components/interventions/panel-zen.js @@ -0,0 +1,36 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class PanelZen extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => el.addEventListener('input', () => this.onChange())) + } + set value(v) { + this._value = v + const r = this.shadowRoot + r.querySelector('[data-field="duration"]').value = v.duration + r.querySelector('[data-field="animation"]').value = v.animation + r.querySelector('[data-field="breath_hold"]').checked = v.breath_hold + } + onChange() { + const r = this.shadowRoot + this._value = { + duration: Number(r.querySelector('[data-field="duration"]').value), + animation: r.querySelector('[data-field="animation"]').value, + breath_hold: r.querySelector('[data-field="breath_hold"]').checked + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('panel-zen', PanelZen) diff --git a/components/interventions/schedule-picker.js b/components/interventions/schedule-picker.js new file mode 100644 index 0000000..fc30d44 --- /dev/null +++ b/components/interventions/schedule-picker.js @@ -0,0 +1,43 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class SchedulePicker extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => { + el.addEventListener('input', () => this.onChange()) + }) + } + set value(v) { + this._value = v || [] + if (this._value[0]) { + const s = this._value[0] + this.shadowRoot.querySelector('[data-field="days"]').value = s.days.join(',') + this.shadowRoot.querySelector('[data-field="start"]').value = s.start + this.shadowRoot.querySelector('[data-field="end"]').value = s.end + } + } + get value() { + return this._value + } + onChange() { + this._value = [{ + days: this.shadowRoot.querySelector('[data-field="days"]').value.split(',').map(n => Number(n.trim())).filter(n => !isNaN(n)), + start: this.shadowRoot.querySelector('[data-field="start"]').value, + end: this.shadowRoot.querySelector('[data-field="end"]').value, + tz: 'local' + }] + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('schedule-picker', SchedulePicker) diff --git a/components/interventions/scope-picker.js b/components/interventions/scope-picker.js new file mode 100644 index 0000000..4696dc3 --- /dev/null +++ b/components/interventions/scope-picker.js @@ -0,0 +1,40 @@ +const template = document.createElement('template') +template.innerHTML = ` +
    + + + +
    +` + +class ScopePicker extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true)) + } + connectedCallback() { + this.shadowRoot.querySelectorAll('[data-field]').forEach(el => { + el.addEventListener('input', () => this.onChange()) + }) + } + set value(v) { + this._value = v || { global: true, block_sets: [], sites: [] } + const root = this.shadowRoot + root.querySelector('[data-field="global"]').checked = this._value.global + root.querySelector('[data-field="block_sets"]').value = this._value.block_sets.join(',') + root.querySelector('[data-field="sites"]').value = this._value.sites.join('\n') + } + get value() { + return this._value + } + onChange() { + this._value = { + global: this.shadowRoot.querySelector('[data-field="global"]').checked, + block_sets: this.shadowRoot.querySelector('[data-field="block_sets"]').value.split(',').filter(Boolean), + sites: this.shadowRoot.querySelector('[data-field="sites"]').value.split('\n').filter(Boolean) + } + this.dispatchEvent(new CustomEvent('change', { detail: this._value })) + } +} + +customElements.define('scope-picker', ScopePicker) diff --git a/components/not-found.js b/components/not-found.js index 85848b0..2d2f106 100644 --- a/components/not-found.js +++ b/components/not-found.js @@ -1,6 +1,17 @@ customElements.define("nirva-not-found", class extends HTMLElement { connectedCallback() { this.attachShadow({ mode: "open" }) - .innerHTML = `

    404 — Page not found

    `; + .innerHTML = ` + +
    +

    404 — Page not found

    +

    The page you’re looking for doesn’t exist.

    + Go to Dashboard +
    `; } }); diff --git a/components/notifications/notifications.js b/components/notifications/notifications.js new file mode 100644 index 0000000..e79ac65 --- /dev/null +++ b/components/notifications/notifications.js @@ -0,0 +1,311 @@ +const template = document.createElement("template"); +template.innerHTML = ` + + + + +
    + +
    +`; + +// Create a singleton notification system +let notificationInstance = null; + +class NirvaNotifications extends HTMLElement { + constructor() { + super(); + + // Only create one instance + if (notificationInstance) { + return notificationInstance; + } + + const shadow = this.attachShadow({ mode: "open" }); + shadow.appendChild(template.content.cloneNode(true)); + + this.container = shadow.querySelector(".notification-container"); + this.notifications = new Map(); // Track active notifications by ID + this.counter = 0; // For generating unique IDs + + notificationInstance = this; + + // Set up event listeners for session events + document.addEventListener('session-activated', (e) => { + const template = e.detail.template; + const name = template ? template.name : 'Custom Session'; + this.show({ + title: 'Session Started', + message: `${name} - ${e.detail.studyMinutes}min focus / ${e.detail.breakMinutes}min break`, + type: 'success', + duration: 5000 + }); + }); + + document.addEventListener('session-cancelled', () => { + this.show({ + title: 'Session Cancelled', + message: 'Your study session has been cancelled', + type: 'info', + duration: 4000 + }); + }); + + // Listen for phase changes + document.addEventListener('session-phase-changed', (e) => { + const phase = e.detail.phase; + if (phase === 'study') { + this.show({ + title: 'Focus Phase Started', + message: `Cycle ${e.detail.cycleCount} - Time to concentrate!`, + type: 'info', + duration: 4000 + }); + } else if (phase === 'break') { + this.show({ + title: 'Break Time!', + message: `Take a ${e.detail.breakMinutes}-minute break`, + type: 'success', + duration: 4000 + }); + } + }); + } + + connectedCallback() { + console.log('Notification system initialized'); + } + + /** + * Show a notification + * @param {Object} options - Notification options + * @param {string} options.title - Notification title + * @param {string} options.message - Notification message + * @param {string} options.type - Notification type (info, success, warning, error) + * @param {number} options.duration - Duration in ms before auto-close (0 for no auto-close) + * @returns {string} Notification ID + */ + show({ title, message, type = 'info', duration = 4000 }) { + const id = `notification-${++this.counter}`; + + // Create notification element + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.setAttribute('role', 'alert'); + notification.innerHTML = ` +
    + ${this.getIconForType(type)} +
    +
    +
    ${title}
    +
    ${message}
    +
    + + `; + + // Add notification to container + this.container.appendChild(notification); + + // Add close button event + const closeBtn = notification.querySelector('.close-button'); + closeBtn.addEventListener('click', () => this.close(id)); + + // Set auto-close timer if duration > 0 + let timer = null; + if (duration > 0) { + timer = setTimeout(() => this.close(id), duration); + } + + // Store notification data + this.notifications.set(id, { element: notification, timer }); + + // Trigger animation + setTimeout(() => notification.classList.add('show'), 10); + + return id; + } + + /** + * Close a notification by ID + * @param {string} id - Notification ID + */ + close(id) { + const notificationData = this.notifications.get(id); + if (!notificationData) return; + + const { element, timer } = notificationData; + + // Clear auto-close timer if exists + if (timer) clearTimeout(timer); + + // Remove show class to trigger exit animation + element.classList.remove('show'); + + // Remove element after animation + setTimeout(() => { + if (element.parentNode) { + element.parentNode.removeChild(element); + } + this.notifications.delete(id); + }, 300); + } + + /** + * Close all notifications + */ + closeAll() { + this.notifications.forEach((_, id) => this.close(id)); + } + + /** + * Get icon HTML for notification type + * @param {string} type - Notification type + * @returns {string} Icon HTML + */ + getIconForType(type) { + switch (type) { + case 'success': + return '✓'; + case 'warning': + return '⚠'; + case 'error': + return '✗'; + case 'info': + default: + return 'i'; + } + } +} + +customElements.define('nirva-notifications', NirvaNotifications); + +// Create and add notification element to body when importing this module +if (!document.querySelector('nirva-notifications')) { + const notificationsElement = document.createElement('nirva-notifications'); + document.body.appendChild(notificationsElement); +} + +// Export a simple API for showing notifications +export default { + show: (options) => { + const notificationsElement = document.querySelector('nirva-notifications'); + if (notificationsElement) { + return notificationsElement.show(options); + } + return null; + }, + closeAll: () => { + const notificationsElement = document.querySelector('nirva-notifications'); + if (notificationsElement) { + notificationsElement.closeAll(); + } + } +}; diff --git a/components/pages/analytics-page.js b/components/pages/analytics-page.js index 0e911ec..04dbe71 100644 --- a/components/pages/analytics-page.js +++ b/components/pages/analytics-page.js @@ -14,10 +14,30 @@ template.innerHTML = ` +
    +

    Recent Events

    +
      +
      `; +function send(action, payload) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action, payload }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (response && response.ok) { + resolve(response.data); + } else { + reject(new Error(response?.error || response?.code || 'Unknown error')); + } + }); + }); +} + customElements.define('nirva-analytics-page', class extends HTMLElement { constructor() { super(); @@ -27,5 +47,22 @@ customElements.define('nirva-analytics-page', class extends HTMLElement { connectedCallback() { console.log('[nirva-analytics-page] loaded'); + this.loadEvents(); + } + + async loadEvents() { + try { + const { events } = await send('analytics.read', { limit: 50 }); + const list = this.shadowRoot.getElementById('event-list'); + list.innerHTML = ''; + events.forEach(ev => { + const item = document.createElement('li'); + const when = new Date(ev.ts).toLocaleString(); + item.textContent = `${when} - ${ev.event}`; + list.appendChild(item); + }); + } catch (err) { + console.error('[nirva-analytics-page] failed to load events', err); + } } }); diff --git a/components/pages/blocklist-page.js b/components/pages/blocklist-page.js index 19f2ff1..e6e85e1 100644 --- a/components/pages/blocklist-page.js +++ b/components/pages/blocklist-page.js @@ -2,8 +2,9 @@ import { loadBlockTabs, saveBlockTabs, saveSelectedIntervention -} from "../storage/blocklist-storage.js"; -const template = document.createElement('template'); +} from '../storage/blocklist-storage.js' + +const template = document.createElement('template') template.innerHTML = ` @@ -29,114 +30,106 @@ template.innerHTML = ` -`; +` customElements.define( - "nirva-blocklist", + 'nirva-blocklist', class extends HTMLElement { constructor() { - super(); - this.attachShadow({ mode: "open" }); - this.shadowRoot.appendChild(template.content.cloneNode(true)); - this.state = []; - this.currentTabIndex = 0; + super() + this.attachShadow({ mode: 'open' }) + this.shadowRoot.appendChild(template.content.cloneNode(true)) + this.state = [] + this.current_tab_index = 0 } connectedCallback() { - this.nameInput = this.shadowRoot.querySelector("nirva-block-set-name-input"); - this.siteList = this.shadowRoot.querySelector("nirva-block-site-list"); - this.timeSelector = this.shadowRoot.querySelector("nirva-block-time-selector"); - this.hourlyAllowance = this.shadowRoot.querySelector("nirva-hourly-allowance"); - this.interventionType = this.shadowRoot.querySelector("nirva-intervention-type"); - this.additionalSettings = this.shadowRoot.querySelector("nirva-additional-settings"); + this.name_input = this.shadowRoot.querySelector('nirva-block-set-name-input') + this.site_list = this.shadowRoot.querySelector('nirva-block-site-list') + this.time_selector = this.shadowRoot.querySelector('nirva-block-time-selector') + this.hourly_allowance = this.shadowRoot.querySelector('nirva-hourly-allowance') + this.intervention_type = this.shadowRoot.querySelector('nirva-intervention-type') + this.additional_settings = this.shadowRoot.querySelector('nirva-additional-settings') - // Set the initial tab-index attribute for the block time selector - if (this.timeSelector) { - this.timeSelector.setAttribute('tab-index', this.currentTabIndex); + if (this.time_selector) { + this.time_selector.setAttribute('tab-index', this.current_tab_index) } - const tabs = this.shadowRoot.querySelector("nirva-block-group-tabs"); - const saveButton = this.shadowRoot.querySelector(".save-button"); + const tabs = this.shadowRoot.querySelector('nirva-block-group-tabs') + const save_button = this.shadowRoot.querySelector('.save-button') - tabs.addEventListener("tab-selected", (e) => { - this.currentTabIndex = e.detail.index; - this.loadDataForTab(this.currentTabIndex); - }); + tabs.addEventListener('tab-selected', e => { + this.current_tab_index = e.detail.index + console.log(`selected: ${this.current_tab_index}`) + this.loadDataForTab(this.current_tab_index) + }) - saveButton.addEventListener("click", () => this.saveCurrentTabData()); + save_button.addEventListener('click', () => this.saveCurrentTabData()) - // Load state using storage module loadBlockTabs() - .then((tabs) => { - this.state = tabs; - this.loadDataForTab(this.currentTabIndex); + .then(tabs => { + this.state = tabs + this.loadDataForTab(this.current_tab_index) }) - .catch((err) => console.error("[storage] load error:", err)); + .catch(err => console.error('[storage] load error:', err)) } loadDataForTab(index) { - const blockSet = this.state[index] || { name: "", sites: "", schedule: {}, allowance: { minutes: 0, hours: 0 }, intervention: "hard block", additionalSettings: undefined }; - this.nameInput.value = blockSet.name || ""; - this.siteList.value = blockSet.sites || ""; - // Update the tab-index attribute so the block time selector loads the correct state - if (this.timeSelector) { - this.timeSelector.setAttribute('tab-index', index); + const block_set = this.state[index] || { name: '', sites: '', schedule: {}, allowance: { minutes: 0, hours: 0 }, intervention: 'hard block', additionalSettings: undefined } + console.log(block_set.schedule) + this.name_input.value = block_set.name || '' + this.site_list.value = block_set.sites || '' + if (this.time_selector) { + this.time_selector.setAttribute('tab-index', index) } - this.timeSelector.value = blockSet.schedule || {}; - this.hourlyAllowance.value = blockSet.allowance || { minutes: 0, hours: 0 }; - // Restore previously selected intervention if available - if (this.interventionType) { - const desired = blockSet.intervention || "hard block"; - if (this.interventionType.options && this.interventionType.options.length > 0) { - this.interventionType.value = desired; + this.time_selector.value = block_set.schedule || {} + this.hourly_allowance.value = block_set.allowance || { minutes: 0, hours: 0 } + if (this.intervention_type) { + const desired = block_set.intervention || 'hard block' + if (this.intervention_type.options && this.intervention_type.options.length > 0) { + this.intervention_type.value = desired } else { - // Options not yet loaded; store value for later - this.interventionType.value_ = desired; + this.intervention_type.value_ = desired } } - // Load additional settings if present - if (this.additionalSettings) { - this.additionalSettings.value = blockSet.additionalSettings || undefined; + if (this.additional_settings) { + this.additional_settings.value = block_set.additionalSettings || undefined } } saveCurrentTabData() { - const name = this.nameInput.value; - const sites = this.siteList.value; - const schedule = this.timeSelector.value; - const allowance = this.hourlyAllowance.value; - const intervention = this.interventionType.value; - const additionalSettings = this.additionalSettings ? this.additionalSettings.value : undefined; + const name = this.name_input.value + const sites = this.site_list.value + const schedule = this.time_selector.value + const allowance = this.hourly_allowance.value + const intervention = this.intervention_type.value + const additional_settings = this.additional_settings ? this.additional_settings.value : undefined - // Ensure state is updated correctly - if (!this.state[this.currentTabIndex]) { - this.state[this.currentTabIndex] = { name: "", sites: "", schedule: {}, allowance: { minutes: 0, hours: 0 }, intervention: "hard block", additionalSettings: undefined }; + if (!this.state[this.current_tab_index]) { + this.state[this.current_tab_index] = { name: '', sites: '', schedule: {}, allowance: { minutes: 0, hours: 0 }, intervention: 'hard block', additionalSettings: undefined } } - this.state[this.currentTabIndex].name = name; - this.state[this.currentTabIndex].sites = sites; - this.state[this.currentTabIndex].schedule = schedule; - this.state[this.currentTabIndex].allowance = allowance; - this.state[this.currentTabIndex].intervention = intervention; - this.state[this.currentTabIndex].additionalSettings = additionalSettings; + this.state[this.current_tab_index].name = name + this.state[this.current_tab_index].sites = sites + this.state[this.current_tab_index].schedule = schedule + this.state[this.current_tab_index].allowance = allowance + this.state[this.current_tab_index].intervention = intervention + this.state[this.current_tab_index].additionalSettings = additional_settings - // Persist using storage utilities Promise.all([ saveBlockTabs(this.state), saveSelectedIntervention(intervention) ]) .then(() => { - console.log(`[nirva-blocklist] Saved block set ${this.currentTabIndex + 1}`); - - const saveButton = this.shadowRoot.querySelector(".save-button"); - saveButton.textContent = "Saved!"; - saveButton.disabled = true; - + console.log(`[nirva-blocklist] Saved block set ${this.current_tab_index + 1}`) + const save_button = this.shadowRoot.querySelector('.save-button') + save_button.textContent = 'Saved!' + save_button.disabled = true setTimeout(() => { - saveButton.textContent = "Save Changes"; - saveButton.disabled = false; - }, 2000); + save_button.textContent = 'Save Changes' + save_button.disabled = false + }, 2000) }) - .catch((err) => console.error("[storage] save error:", err)); + .catch(err => console.error('[storage] save error:', err)) } } -); +) diff --git a/components/pages/dashboard-page.js b/components/pages/dashboard-page.js index ebe1c26..bbbc9d7 100644 --- a/components/pages/dashboard-page.js +++ b/components/pages/dashboard-page.js @@ -1,7 +1,18 @@ +// Import the notifications system and session components +import '../notifications/notifications.js'; +import '../dashboard/session-selector-modal.js'; +import '../dashboard/session-config-modal.js'; +import '../dashboard/study-session.js'; +import '../dashboard/sessions.js'; +import '../dashboard/past-sessions.js'; +import '../dashboard/analytics.js'; +import '../dashboard/streak.js'; +import '../dashboard/full-stats.js'; + const template = document.createElement("template"); template.innerHTML = ` - - + +